Merge pull request #1716 from joaojmendes/staffdirectory

This commit is contained in:
Hugo Bernier 2021-02-18 10:26:46 -05:00 committed by GitHub
commit ea5cfe7e9d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 10030 additions and 1938 deletions

View File

@ -1,7 +0,0 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"whichFolder": "subdir"
}
}

View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# change these settings to your own preference
indent_style = space
indent_size = 2
# we recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[{package,bower}.json]
indent_style = space
indent_size = 2

32
samples/react-staffdirectory/.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
solution
temp
*.sppkg
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts

View File

@ -0,0 +1,34 @@
{
"@pnp/generator-spfx": {
"framework": "react",
"pnpFramework": "reactjs.plus",
"pnp-libraries": [
"jquery@3",
"msgraph",
"@pnp/pnpjs",
"@pnp/spfx-property-controls",
"spfx-uifabric-themes",
"lodash",
"@pnp/spfx-controls-react",
"ouifr@7",
"rush@3.7"
],
"pnp-ci": "azure-preview",
"pnp-vetting": [],
"spfxenv": "spo",
"pnp-testing": [
"jest"
]
},
"@microsoft/generator-sharepoint": {
"environment": "spo",
"framework": "react",
"isCreatingSolution": true,
"version": "1.11.0",
"libraryName": "staff-directory",
"libraryId": "89d7389c-be48-41e9-9f72-5eb9a1099c1f",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,73 @@
---
page_type: sample
products:
- office-sp
languages:
- javascript
- typescript
extensions:
contentType: samples
technologies:
- SharePoint Framework
- Msgraph
- Fluent UI React Controls
platforms:
- React
createdDate: 4/9/2020 12:00:00 AM
---
# Staff Directory (Search Directory)
## Summary
This web part shows a Colleagues of current user, and allows the user to search AD directory, The user can configure the properties to show when expand the user card.
![staff](./assets/staffDirectory.gif)
## Compatibility
![SPFx 1.11](https://img.shields.io/badge/SPFx-1.11.0-green.svg)
![Node.js LTS 10.x](https://img.shields.io/badge/Node.js-LTS%2010.x-green.svg)
![SharePoint Online](https://img.shields.io/badge/SharePoint-Online-yellow.svg)
![Teams N/A: Untested with Microsoft Teams](https://img.shields.io/badge/Teams-N%2FA-lightgrey.svg "Untested with Microsoft Teams")
![Workbench Hosted: Does not work with local workbench](https://img.shields.io/badge/Workbench-Hosted-yellow.svg "Does not work with local workbench")
## Applies to
* [SharePoint Framework](https://docs.microsoft.com/sharepoint/dev/spfx/sharepoint-framework-overview)
* [Office 365 tenant](https://docs.microsoft.com/sharepoint/dev/spfx/set-up-your-development-environment)
## Solution
Solution|Author(s)
--------|---------
react-staffdirectory|João Mendes ([joaojmendes](https://github.com/joaojmendes))
## Version history
Version|Date|Comments
-------|----|--------
1.0.0|February 16, 2021|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
Please follow all the steps:
- Clone this repository
- in the command line run:
- `npm install`
- `gulp build`
- `gulp bundle --ship`
- `gulp package-solution --ship`
- Add and deploy package to your tenant's App Catalog
- Go to **API Access** - from **SharePoint Admin Center** new experience, and **Approve** the permission to use Microsoft Graph scope **Presence.Read.All**
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/staffdirectory" />

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 MiB

View File

@ -0,0 +1,67 @@
parameters:
name: ''
jobs:
- job: ${{ parameters.name }}
pool:
vmImage: 'ubuntu-latest'
demands:
- npm
- node.js
- java
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm
steps:
- checkout: self
- task: NodeTool@0
displayName: 'Use Node 10.x'
inputs:
versionSpec: 10.x
checkLatest: true
- task: CacheBeta@1
inputs:
key: npm | $(Agent.OS) | package-lock.json
path: $(npm_config_cache)
cacheHitVar: CACHE_RESTORED
- script: npm ci
displayName: 'npm ci'
- task: Gulp@0
displayName: 'Bundle project'
inputs:
targets: bundle
arguments: '--ship'
- script: npm test
displayName: 'npm test'
- task: PublishTestResults@2
displayName: Publish test results
inputs:
testResultsFormat: JUnit
testResultsFiles: '**/junit.xml'
#failTaskOnFailedTests: true #if we want to fail the build on failed unit tests
- task: PublishCodeCoverageResults@1
displayName: 'Publish code coverage results'
inputs:
codeCoverageTool: Cobertura
summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/*coverage.xml'
- task: Gulp@0
displayName: 'Package Solution'
inputs:
targets: 'package-solution'
arguments: '--ship'
- task: CopyFiles@2
displayName: 'Copy Files to: $(Build.ArtifactStagingDirectory)'
inputs:
Contents: |
sharepoint/**/*.sppkg
TargetFolder: '$(Build.ArtifactStagingDirectory)'
- task: PublishBuildArtifacts@1
displayName: 'Publish Artifact: drop'

View File

@ -0,0 +1,39 @@
parameters:
# unique name of the job
job_name: deploy_sppkg
# friendly name of the job
display_name: Upload & deploy *.sppkg to SharePoint app catalog
# name of target enviroment deploying to
target_environment: ''
# app catalog scope (tenant|sitecollection)
o365cli_app_catalog_scope: 'tenant'
variable_group_name: ''
jobs:
- deployment: ${{ parameters.job_name }}
displayName: ${{ parameters.display_name }}
pool:
vmImage: 'ubuntu-latest'
environment: ${{ parameters.target_environment }}
variables:
- group: ${{parameters.variable_group_name}} #o365_user_login, o365_user_password, o365_app_catalog_site_url
strategy:
runOnce:
deploy:
steps:
- checkout: none
- download: current
artifact: drop
patterns: '**/*.sppkg'
- script: sudo npm install --global @pnp/office365-cli
displayName: Install Office365 CLI
- script: o365 login $(o365_app_catalog_site_url) --authType password --userName $(o365_user_login) --password $(o365_user_password)
displayName: Login to Office365
- script: |
CMD_GET_SPPKG_NAME=$(find $(Pipeline.Workspace)/drop -name '*.sppkg' -exec basename {} \;)
echo "##vso[task.setvariable variable=SpPkgFileName;isOutput=true]${CMD_GET_SPPKG_NAME}"
displayName: Get generated *.sppkg filename
name: GetSharePointPackage
- script: o365 spo app add --filePath "$(Pipeline.Workspace)/drop/sharepoint/solution/$(GetSharePointPackage.SpPkgFileName)" --appCatalogUrl $(o365_app_catalog_site_url) --scope ${{ parameters.o365cli_app_catalog_scope }} --overwrite
displayName: Upload SharePoint package to Site Collection App Catalog
- script: o365 spo app deploy --name $(GetSharePointPackage.SpPkgFileName) --appCatalogUrl $(o365_app_catalog_site_url) --scope ${{ parameters.o365cli_app_catalog_scope }}
displayName: Deploy SharePoint package

View File

@ -0,0 +1,26 @@
name: $(TeamProject)_$(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r)
resources:
- repo: self
trigger:
branches:
include:
- master
- develop
stages:
- stage: build
displayName: build
jobs:
- template: ./azure-pipelines-build-template.yml
parameters:
name: 'buildsolution'
- stage: 'deployqa'
# uncomment if you want deployments to occur only for a specific branch
#condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop'))
jobs:
- template: ./azure-pipelines-deploy-template.yml
parameters:
job_name: deploy_solution
target_environment: 'qa'
variable_group_name: qa_configuration

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"staff-directory-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/staffDirectory/StaffDirectoryWebPart.js",
"manifest": "./src/webparts/staffDirectory/StaffDirectoryWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"StaffDirectoryWebPartStrings": "lib/webparts/staffDirectory/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,8 @@
{
"includeExtensions": [
"svg",
"png",
"jpg"
]
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "staff-directory",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1 @@
{"preset":"@voitanos/jest-preset-spfx-react16","rootDir":"../src","collectCoverageFrom":["<rootDir>/**/*.{ts,tsx}","!<rootDir>/**/*.scss.*","!<rootDir>/loc/**/*.*"],"coverageReporters":["text","json","lcov","text-summary","cobertura"],"reporters":["default",["jest-junit",{"suiteName":"jest tests","outputDirectory":"temp/test/junit","outputName":"junit.xml"}]]}

View File

@ -0,0 +1,27 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "staff-directory-client-side-solution",
"id": "89d7389c-be48-41e9-9f72-5eb9a1099c1f",
"version": "1.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"isDomainIsolated": false,
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "Presence.Read.All"
}
],
"developer": {
"name": "",
"websiteUrl": "",
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": ""
}
},
"paths": {
"zippedPackage": "solution/staff-directory.sppkg"
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://localhost:5432/workbench",
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,29 @@
'use strict';
// check if gulp dist was called
if (process.argv.indexOf('dist') !== -1) {
// add ship options to command call
process.argv.push('--ship');
}
const path = require('path');
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
const gulpSequence = require('gulp-sequence');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
// Create clean distrubution package
gulp.task('dist', gulpSequence('clean', 'bundle', 'package-solution'));
// Create clean development package
gulp.task('dev', gulpSequence('clean', 'bundle', 'package-solution'));
/**
* Custom Framework Specific gulp tasks
*/
build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
{
"name": "staff-directory",
"version": "0.0.1",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"preversion": "node ./tools/pre-version.js",
"postversion": "gulp dist",
"test": "./node_modules/.bin/jest --config ./config/jest.config.json",
"test:watch": "./node_modules/.bin/jest --config ./config/jest.config.json --watchAll"
},
"dependencies": {
"@microsoft/sp-core-library": "1.11.0",
"@microsoft/sp-lodash-subset": "1.11.0",
"@microsoft/sp-office-ui-fabric-core": "1.11.0",
"@microsoft/sp-property-pane": "1.11.0",
"@microsoft/sp-webpart-base": "1.11.0",
"@pnp/pnpjs": "^2.0.6",
"@pnp/spfx-controls-react": "1.19.0",
"@pnp/spfx-property-controls": "1.19.0",
"@types/jquery": "^3.5.0",
"jquery": "^3.5.1",
"office-ui-fabric-react": "^7.123.1",
"react": "16.8.5",
"react-dom": "16.8.5"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^1.12.0",
"@microsoft/rush-stack-compiler-3.7": "^0.6.x",
"@microsoft/sp-build-web": "1.11.0",
"@microsoft/sp-module-interfaces": "1.11.0",
"@microsoft/sp-tslint-rules": "1.11.0",
"@microsoft/sp-webpart-workbench": "1.11.0",
"@types/chai": "3.4.34",
"@types/es6-promise": "0.0.33",
"@types/mocha": "2.2.38",
"@types/react": "^16.9.43",
"@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1",
"@voitanos/jest-preset-spfx-react16": "^1.3.2",
"ajv": "~5.2.2",
"gulp": "~3.9.1",
"gulp-sequence": "1.0.0",
"jest": "^23.6.0",
"jest-junit": "^10.0.0",
"lodash": "^4.17.15",
"spfx-uifabric-themes": "^0.8.0",
"typescript": "~3.7.x"
},
"jest-junit": {
"output": "temp/test/junit/junit.xml",
"usePathForSuiteName": "true"
}
}

View File

@ -0,0 +1,3 @@
import React from "react";
import { IAppContext } from '../common/IAppContext';
export const AppContext = React.createContext<IAppContext>(undefined);

View File

@ -0,0 +1,8 @@
import { MSGraphClient } from "@microsoft/sp-http";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
export interface IAppContext {
currentUser: any;
msGraphClient: MSGraphClient;
themeVariant: IReadonlyTheme;
}
//# sourceMappingURL=IAppContext.d.ts.map

View File

@ -0,0 +1,15 @@
import { PersonaPresence } from "office-ui-fabric-react";
export interface IPresenceStatus {
[key:string]: {presenceStatus:number, presenceStatusLabel:string};
}
export const presenceStatus:IPresenceStatus[] = [];
presenceStatus["Available"] = { presenceStatus: PersonaPresence.online, presenceStatusLabel: "Available"};
presenceStatus["AvailableIdle"] = { presenceStatus: PersonaPresence.online, presenceStatusLabel: "Available idle"};
presenceStatus["Away"] = { presenceStatus: PersonaPresence.away, presenceStatusLabel: "Away"};
presenceStatus["BeRightBack"] = { presenceStatus: PersonaPresence.away, presenceStatusLabel: "Be right back"};
presenceStatus["Busy"] = { presenceStatus: PersonaPresence.busy, presenceStatusLabel: "Busy"};
presenceStatus["BusyIdle"] = { presenceStatus: PersonaPresence.busy, presenceStatusLabel: "Busy idle"};
presenceStatus["DoNotDisturb"] = { presenceStatus: PersonaPresence.dnd, presenceStatusLabel: "Do not disturb"};
presenceStatus["Offline"] ={ presenceStatus: PersonaPresence.offline, presenceStatusLabel: "Offline "};
presenceStatus["PresenceUnknown"] ={ presenceStatus: PersonaPresence.none, presenceStatusLabel: "Presence Unknown" };

View File

@ -0,0 +1,16 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { DisplayMode } from '@microsoft/sp-core-library';
export interface IStaffDirectoryProps {
title: string;
context: WebPartContext;
themeVariant: IReadonlyTheme;
displayMode: DisplayMode;
maxHeight:number;
showBox: boolean;
updateProperty: (value: string) => void;
refreshInterval: number;
updatePresenceStatus:boolean;
userAttributes: string[];
pageSize:number;
}

View File

@ -0,0 +1,11 @@
import { IUserExtended } from "../../entites/IUserExtended";
export interface IStaffDirectoryState {
isLoading: boolean;
listUsers: IUserExtended[];
hasError:boolean;
errorMessage:string;
updateUsersPresence:boolean;
nextPageLink:string;
isLoadingNextPage:boolean;
}

View File

@ -0,0 +1,7 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.hideScrollBar::-webkit-scrollbar-thumb { background-color: $ms-color-neutralLight; }
.hideScrollBar::-webkit-scrollbar{
width: 5px;
}

View File

@ -0,0 +1,536 @@
import * as React from "react";
import styles from "./StaffDirectory.module.scss";
import { IStaffDirectoryProps } from "./IStaffDirectoryProps";
import { IStaffDirectoryState } from "./IStaffDirectoryState";
import { escape } from "@microsoft/sp-lodash-subset";
import { WebPartTitle } from "@pnp/spfx-controls-react";
import { MSGraphClient } from "@microsoft/sp-http";
import {
IStackTokens,
mergeStyleSets,
IBasePickerStyles,
IPersonaProps,
Spinner,
SpinnerSize,
MessageBar,
MessageBarType,
IPersonaStyleProps,
ValidationState,
Customizer,
Stack,
FontIcon,
NormalPeoplePicker,
ImageFit,
Image,
Text,
ActionButton,
Link,
ILinkStyles,
warnConditionallyRequiredProps,
LinkBase,
mergeStyles,
} from "office-ui-fabric-react";
import { IAppContext } from "../../common/IAppContext";
import { IUser } from "../../entites/IUser";
import {
useGetUsersByDepartment,
useGetUserId,
useSearchUsers,
useGetUsersPresence,
useGetUsersNextPage,
} from "../../hooks/useSearchUsers";
import { useInterval } from '../../hooks/useInterval';
import { IUserExtended } from "../../entites/IUserExtended";
import { presenceStatus } from "../../common/PresenceStatus";
import { AppContext } from "../../common/AppContext";
import { UserCard } from "../UserCard/UserCard";
import { useRef } from "react";
import strings from "StaffDirectoryWebPartStrings";
const imageNoData: string = require("../../../assets/Nodatarafiki.svg");
const stackTokens: IStackTokens = {
childrenGap: 10,
};
// Component Styles
const stackStyles = {
root: {
width: "100%",
},
};
const suggestionProps = {
suggestionsHeaderText: "Suggested People",
mostRecentlyUsedHeaderText: "Suggested Contacts",
noResultsFoundText: "No results found",
loadingText: "Loading",
showRemoveButtons: false,
suggestionsAvailableAlertText: "People Picker Suggestions available",
suggestionsContainerAriaLabel: "Suggested contacts",
};
export const StaffDirectory: React.FunctionComponent<IStaffDirectoryProps> = (
props: IStaffDirectoryProps
) => {
const { showBox, maxHeight } = props;
const styleClasses = mergeStyleSets({
webPartTitle: {
marginBottom: 20,
},
separator: {
paddingLeft: 30,
paddingRight: 30,
margin: 20,
borderBottomStyle: "solid",
borderWidth: 1,
borderBottomColor: props.themeVariant.palette.themeLighter,
},
styleIcon: {
maxWidth: 44,
minWidth: 44,
minHeight: 30,
height: 30,
borderColor: props.themeVariant.palette.themePrimary,
borderRightWidth: 0,
borderRightStyle: "none",
borderLeftWidth: 1,
borderLeftStyle: "solid",
borderTopWidth: 1,
borderTopStyle: "solid",
borderBottomWidth: 1,
borderBottomStyle: "solid",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
listContainer: {
maxWidth: "100%",
overflowY: "auto",
marginTop: 20,
padding: 10,
boxShadow: showBox
? "0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1)"
: "",
},
});
const pickerStyles: Partial<IBasePickerStyles> = {
root: {
width: "100%",
maxHeight: 32,
minHeight: 32,
borderColor: props.themeVariant.palette.themePrimary,
},
itemsWrapper: {
borderColor: props.themeVariant.palette.themePrimary,
},
text: {
borderLeftWidth: 0,
minHeight: 32,
borderColor: props.themeVariant.palette.themePrimary,
selectors: {
":focus": {
borderColor: props.themeVariant.palette.themePrimary,
},
":hover": {
borderColor: props.themeVariant.palette.themePrimary,
},
"::after": {
borderColor: props.themeVariant.palette.themePrimary,
borderWidth: 1,
borderLeftWidth: 0,
},
},
},
};
const nextPageStyle: ILinkStyles = {
root: {
fontWeight: 600,
fontSize: props.themeVariant.fonts.mediumPlus.fontSize,
selectors: { ":hover": { textDecoration: "underline" } },
},
};
const _appContext = React.useRef<IAppContext>({} as IAppContext);
const _msGraphClient = React.useRef<MSGraphClient>();
const _currentUser= React.useRef<any>('');
const _currentUserDepartment = React.useRef<string>('');
const [state, setState] = React.useState<IStaffDirectoryState>({
listUsers: [],
hasError: false,
errorMessage: "",
isLoading: true,
updateUsersPresence: false,
nextPageLink: undefined,
isLoadingNextPage: false,
});
const picker = React.useRef(null);
_currentUser.current = props.context.pageContext.user;
let _currentuserProperties: IUser = {} as IUser;
let _listUsers: IUserExtended[] = [];
const _interval = props.refreshInterval * 60000;
const _updatePresenceStatus: boolean = props.updatePresenceStatus;
useInterval( async () => {
_listUsers = state.listUsers;
if (_listUsers) {
_listUsers = await useGetUsersPresence(_listUsers, _msGraphClient.current);
setState({
...state,
listUsers: _listUsers,
isLoading: false,
updateUsersPresence: true,
});
}
},(!_updatePresenceStatus || isNaN(_interval) ? null : _interval ));
React.useEffect(() => {
(async () => {
try {
_msGraphClient.current = await props.context.msGraphClientFactory.getClient();
_currentUser.current = props.context.pageContext.user;
_appContext.current.currentUser = _currentUser.current ;
_appContext.current.msGraphClient = _msGraphClient.current;
_appContext.current.themeVariant = props.themeVariant;
_currentuserProperties = await useGetUserId(
_currentUser.current.email,
_msGraphClient.current
);
setState({
...state,
isLoading: true,
});
_currentUserDepartment.current = _currentuserProperties.department;
const _usersResults = await useGetUsersByDepartment(
_currentUserDepartment.current,
_msGraphClient.current,
props.pageSize
);
_listUsers = _usersResults.usersExtended;
setState({
...state,
listUsers: _listUsers,
isLoading: false,
updateUsersPresence: false,
nextPageLink: _usersResults.nextPage,
});
// Pooling status each x min ( value define as property in webpart)
} catch (error) {
setState({
...state,
errorMessage: error.message
? error.message
: " Error searching users, please try later or contact support.",
hasError: true,
isLoading: false,
});
console.log(error);
}
})();
}, [props]);
// on Filter changed
const _onFilterChanged = async (
filterText: string,
currentPersonas: IPersonaProps[],
limitResults?: number
): Promise<IPersonaProps[]> => {
let filteredPersonas: IPersonaProps[] = [];
if (filterText.trim().length > 0) {
try {
const _usersResults = await useSearchUsers(
"displayName:" + filterText,
_msGraphClient.current
);
filteredPersonas = [];
for (const _user of _usersResults.usersExtended) {
filteredPersonas.push({
text: _user.displayName,
presence:presenceStatus[_user.availability].presenceStatus,
presenceTitle:
presenceStatus[_user.availability].presenceStatusLabel,
imageUrl: _user.pictureBase64,
secondaryText: _user.department,
});
}
filteredPersonas = removeDuplicates(filteredPersonas, currentPersonas);
return filteredPersonas;
} catch (error) {
console.log(error);
return [];
}
} else {
return [];
}
};
// On Picker Changed
const _onPickerChange = async (items: IPersonaProps[]) => {
if (!(items.length === 0)) return;
try {
setState({
...state,
isLoading: true,
});
const _usersResults = await useGetUsersByDepartment(
_currentUserDepartment.current,
_msGraphClient.current,
props.pageSize
);
setState({
...state,
nextPageLink: _usersResults.nextPage,
listUsers: _usersResults.usersExtended,
updateUsersPresence: false,
});
} catch (error) {
console.log(error);
setState({
...state,
listUsers: [],
hasError: true,
errorMessage: error.message
? error.message
: " Error searching users, please try later or contact support.",
});
}
};
const _onNextPage = async (
event: React.MouseEvent<
HTMLElement | HTMLAnchorElement | HTMLButtonElement | LinkBase,
MouseEvent
>
) => {
event.preventDefault();
let { listUsers, nextPageLink } = state;
setState({
...state,
isLoadingNextPage: true,
});
try {
const _usersResults = await useGetUsersNextPage(
nextPageLink,
_msGraphClient.current
);
const _newlistUsers = listUsers.concat(_usersResults.usersExtended);
setState({
...state,
listUsers: _newlistUsers,
isLoadingNextPage: false,
nextPageLink: _usersResults.nextPage,
});
} catch (error) {
console.log(error);
setState({
...state,
listUsers: [],
hasError: true,
isLoadingNextPage: false,
errorMessage: error.message
? error.message
: strings.ErrorMessage
});
}
};
// Render compoent
// Has Error
if (state.hasError) {
return (
<MessageBar messageBarType={MessageBarType.error}>{state.errorMessage}</MessageBar>
);
}
return (
<AppContext.Provider value={_appContext.current}>
<Customizer settings={{ theme: props.themeVariant }}>
<WebPartTitle
displayMode={props.displayMode}
title={props.title}
themeVariant={props.themeVariant}
updateProperty={props.updateProperty}
className={styleClasses.webPartTitle}
/>
<Stack tokens={stackTokens} style={{ width: "100%" }}>
<Stack
style={{ width: "100%" }}
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 0 }}
>
<div className={styleClasses.styleIcon}>
<FontIcon
iconName="Search"
style={{
verticalAlign: "center",
fontSize: props.themeVariant.fonts.mediumPlus.fontSize,
color: props.themeVariant.palette.themePrimary,
}}
/>
</div>
<NormalPeoplePicker
itemLimit={1}
onResolveSuggestions={_onFilterChanged}
getTextFromItem={getTextFromItem}
pickerSuggestionsProps={suggestionProps}
className="ms-PeoplePicker"
key="normal"
onValidateInput={validateInput}
onChange={_onPickerChange}
styles={pickerStyles}
componentRef={picker}
onInputChange={onInputChange}
resolveDelay={300}
disabled={state.isLoading}
onItemSelected={async (selectedItem: IPersonaProps) => {
const _useSearchUsers = await useSearchUsers(
`displayName:${selectedItem.text.trim()}`,
_msGraphClient.current,
props.pageSize
);
setState({
...state,
nextPageLink: _useSearchUsers.nextPage,
listUsers: _useSearchUsers.usersExtended,
isLoading: false,
updateUsersPresence: false,
});
return selectedItem;
}}
/>
</Stack>
{state.isLoading ? (
<Spinner size={SpinnerSize.medium}></Spinner>
) : (
<div
className={`${styleClasses.listContainer} ${styles.hideScrollBar}`}
style={{ maxHeight: props.maxHeight }}
>
{state.listUsers.length > 0 ? (
state.listUsers.map((user, i) => {
return (
<>
<UserCard
userData={user}
userAttributes={props.userAttributes}
updateUsersPresence={state.updateUsersPresence}
></UserCard>
<div className={styleClasses.separator}></div>
</>
);
})
) : (
<>
<Stack
horizontalAlign="center"
verticalAlign="center"
style={{ marginBottom: 25 }}
>
<Image
src={imageNoData}
imageFit={ImageFit.cover}
width={250}
height={300}
></Image>
<Text variant="large">No colleagues found </Text>
</Stack>
</>
)}
{state.nextPageLink && (
<Stack
horizontal
horizontalAlign="end"
verticalAlign="center"
style={{
marginTop: 10,
marginRight: 20,
marginBottom: 20,
}}
>
<Link
styles={nextPageStyle}
disabled={state.isLoadingNextPage}
onClick={_onNextPage}
>
Next Page
</Link>
{state.isLoadingNextPage && (
<Spinner
style={{ marginLeft: 5 }}
size={SpinnerSize.small}
></Spinner>
)}
</Stack>
)}
</div>
)}
</Stack>
</Customizer>
</AppContext.Provider>
);
};
// Get text from Persona
const getTextFromItem = (persona) => {
return persona.text;
};
// Remove dumplicate Items
const removeDuplicates = (personas, possibleDupes) => {
return personas.filter((persona) => {
return !listContainsPersona(persona, possibleDupes);
});
};
// Check if selecte list has a persona selected
const listContainsPersona = (persona, personas) => {
if (!personas || !personas.length || personas.length === 0) {
return false;
}
return (
personas.filter((item) => {
return item.text === persona.text;
}).length > 0
);
};
// Validate Input Function
const validateInput = (input) => {
if (input.trim().length > 1) {
return ValidationState.valid;
} else {
return ValidationState.invalid;
}
};
/**
*
*
* @param input The text entered into the picker.
*/
const onInputChange = (input) => {
/* const outlookRegEx = /<.*>/g;
const emailAddress = outlookRegEx.exec(input);
if (emailAddress && emailAddress[0]) {
return emailAddress[0].substring(1, emailAddress[0].length - 1);
} */
return input;
};

View File

@ -0,0 +1,6 @@
import { IUserExtended } from "../../entites/IUserExtended";
export interface IUserCardProps {
userData: IUserExtended;
updateUsersPresence:boolean;
userAttributes: string[];
}

View File

@ -0,0 +1,32 @@
@media (min-width: 480px) {
.media
{
min-width: 100%!important;
}
}
@media (min-width: 768px) {
.media
{
min-width: 47%!important;
}
}
@media (min-width: 1024px) {
.media
{
min-width: 32% !important;
}
}
@media (min-width: 1200px) {
.media
{
min-width: 22% !important;
}
}
.media {
min-width: 290px;
}

View File

@ -0,0 +1,43 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.hideScrollBar::-webkit-scrollbar{
display:none
}
:global {
.media {
width: '100%';
}
}
@media (max-width: 480px) {
.media
{
width: '100%';
-ms-overflow-style: -ms-autohiding-scrollbar;
}
}
@media (max-width: 768px) {
.media
{
width: '47%';
}
}
@media (max-width: 1024px) {
.media
{
width: '32%';
}
}
@media (max-width: 1200px) {
.media
{
width: '22%';
}
}

View File

@ -0,0 +1,710 @@
import * as React from "react";
import {
mergeStyleSets,
Persona,
PersonaSize,
Stack,
Label,
FontIcon,
Text,
Link,
ITextFieldStyles,
IPersonaProps,
} from "office-ui-fabric-react";
import { presenceStatus, IPresenceStatus } from "../../common/PresenceStatus";
import { AppContext } from "../../common/AppContext";
import { ActionButton } from "office-ui-fabric-react";
import { IUserExtended } from "../../entites/IUserExtended";
import { IAppContext } from "../../common/IAppContext";
import { IUserCardProps } from "./IUserCardProps";
export const UserCard = (props: IUserCardProps) => {
const { userData, updateUsersPresence } = props;
const _context: IAppContext = React.useContext(AppContext);
const [expandIcon, setExpandIcon] = React.useState("ChevronDownSmall");
const [isDetailsOpen, setIsDetailsOpen] = React.useState(false);
const styleClasses = mergeStyleSets({
separator: {
marginLeft: 20,
marginRight: 20,
marginTop: 15,
borderBottomStyle: "solid",
borderWidth: 1,
borderBottomColor: _context.themeVariant.palette.neutralLighterAlt,
},
stylContainerDetails: {
marginTop: 25,
display: "grid",
justifyContent: "stretch",
alignItems: "center",
backgroundColor: _context.themeVariant.palette.themeLighter,
gridTemplateColumns: "repeat( auto-fit, minmax(280px, 1fr) )",
gridTemplateRows: "auto",
},
styleIconDetails: {
fontSize: 16,
color: _context.themeVariant.palette.themePrimary,
},
styleFieldLabel: {
fontSize: 12,
fontWeight: 400,
paddingLeft: 3,
},
styleField: {
paddingTop: 15,
},
});
const styleTextField: Partial<ITextFieldStyles> = {
root: {
fontWeight: 500,
width: "calc(100% - 46)",
marginLeft: 45,
paddingRight: 20,
},
};
React.useEffect(() => {
(async () => {
if (!updateUsersPresence) {
setExpandIcon("ChevronDownSmall");
setIsDetailsOpen(false);
}
})();
}, [userData]);
const _onShowDetails = (event) => {
event.preventDefault();
if (!isDetailsOpen) {
setExpandIcon("ChevronUpSmall");
setIsDetailsOpen(true);
} else {
setExpandIcon("ChevronDownSmall");
setIsDetailsOpen(false);
}
};
const _onRenderPrimaryText = (persona: IPersonaProps) => {
return (
<>
<Stack
horizontal={true}
verticalAlign="start"
tokens={{ childrenGap: 5 }}
styles={{
root: { justifyContent: "flex-start", width: "100%" },
}}
>
<Text
variant="medium"
block
nowrap
style={{
width: "100%",
fontWeight: 600,
padding: 0,
marginBottom: 3,
}}
>
{persona.text}
</Text>
<Stack
horizontal
horizontalAlign="end"
verticalAlign="start"
tokens={{ childrenGap: 3 }}
style={{ width: "100%", maxHeight: 21 }}
>
<div style={{ fontSize: 12 }}>
<ActionButton
styles={{
root: {
height: 21,
width: 26,
color: _context.themeVariant.palette.themePrimary,
},
}}
iconProps={{
iconName: "CannedChat",
color: _context.themeVariant.palette.themePrimary,
}}
allowDisabledFocus={true}
disabled={false}
checked={true}
title="Start Teams Chat"
onClick={(event) => {
event.preventDefault();
window.open(
"https://teams.microsoft.com/l/chat/0/0?users=" +
userData.mail +
"&message=Hi " +
userData.displayName,
"_blank"
);
}}
></ActionButton>
</div>
<div>
<ActionButton
styles={{
root: {
height: 21,
width: 26,
color: _context.themeVariant.palette.themePrimary,
},
}}
iconProps={{
iconName: expandIcon,
color: _context.themeVariant.palette.themePrimary,
}}
allowDisabledFocus={true}
disabled={false}
checked={true}
title={isDetailsOpen ? "Hide details" : "Show Details"}
onClick={_onShowDetails}
></ActionButton>
</div>
</Stack>
</Stack>
</>
);
};
const _onRenderSecondaryText = (persona: IPersonaProps) => {
return (
<>
<Stack verticalAlign="start" tokens={{ childrenGap: 0 }}>
<Text title={persona.secondaryText} variant="medium" block nowrap>
{" "}
{persona.secondaryText}
</Text>
<Text
title={presenceStatus[userData.availability].presenceStatusLabel}
variant="smallPlus"
block
nowrap
>
{presenceStatus[userData.availability].presenceStatusLabel}
</Text>
</Stack>
</>
);
};
return (
<>
<Stack
verticalAlign="center"
tokens={{ childrenGap: 20 }}
style={{ padding: 10 }}
>
<Persona
size={PersonaSize.size48}
imageUrl={userData.pictureBase64}
presence={presenceStatus[userData.availability].presenceStatus}
presenceTitle={
presenceStatus[userData.availability].presenceStatusLabel
}
text={userData.displayName}
title={userData.displayName}
tertiaryText={userData.mail}
secondaryText={userData.jobTitle}
onRenderPrimaryText={_onRenderPrimaryText}
onRenderSecondaryText={_onRenderSecondaryText}
></Persona>
</Stack>
{isDetailsOpen && (
<>
<Stack
style={{ width: "100%" }}
className={styleClasses.stylContainerDetails}
>
<div
className={`${styleClasses.styleField}`}
title={
userData.department ? userData.department : "Not available"
}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="Teamwork"
/>
<Label className={styleClasses.styleFieldLabel}>
Department
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.department ? userData.department : "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
<div
className={`${styleClasses.styleField}`}
title={
userData.businessPhones.join(",")
? userData.businessPhones.join(",")
: "Not available"
}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="Phone"
/>
<Label className={styleClasses.styleFieldLabel}>
Business Phones
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.businessPhones.join(",") ? (
<>
<Link href={`CALLTO:${userData.businessPhones[0]}`}>
{userData.businessPhones[0]}
</Link>
</>
) : (
"Not available"
)}
</Text>
<div className={styleClasses.separator}></div>
</div>
<div
className={`${styleClasses.styleField}`}
title={userData.mail ? userData.mail : "Not Available"}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="Mail"
/>
<Label className={styleClasses.styleFieldLabel}>E-mail</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.mail ? (
<>
<Link href={`MAILTO:${userData.mail}`}>
{userData.mail}
</Link>
</>
) : (
"Not available"
)}
</Text>
<div className={styleClasses.separator}></div>
</div>
{props.userAttributes.length > 0 &&
props.userAttributes.map((attribute, i) => {
switch (attribute) {
case "mobilePhone":
return (
<div
className={`${styleClasses.styleField}`}
title={
userData.mobilePhone
? userData.mobilePhone
: "Not available"
}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="CellPhone"
/>
<Label className={styleClasses.styleFieldLabel}>
Mobile Phone
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.mobilePhone ? (
<>
<Link href={`CALLTO:${userData.mobilePhone}`}>
{userData.mobilePhone}
</Link>
</>
) : (
"Not available"
)}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "company":
return (
<div
className={`${styleClasses.styleField}`}
title={userData.companyName ? userData.companyName : "Not available"}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="TextField"
/>
<Label className={styleClasses.styleFieldLabel}>
Company Name
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.companyName ? userData.companyName : "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "officeLocation":
return (
<div
className={`${styleClasses.styleField}`}
title={
userData.officeLocation
? userData.officeLocation
: "Not available"
}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="POISolid"
/>
<Label className={styleClasses.styleFieldLabel}>
Office Location
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.officeLocation
? userData.officeLocation
: "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "city":
return (
<div
className={`${styleClasses.styleField}`}
title={userData.city ? userData.city : "Not available"}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="CityNext2"
/>
<Label className={styleClasses.styleFieldLabel}>
City
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.city ? userData.city : "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "postalCode":
return (
<div
className={`${styleClasses.styleField}`}
title={userData.postalCode ? userData.postalCode : "Not available"}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="NumberField"
/>
<Label className={styleClasses.styleFieldLabel}>
Postal Code
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{ userData.postalCode ? userData.postalCode : "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "country":
return (
<div
className={`${styleClasses.styleField}`}
title={
userData.country ? userData.country : "Not available"
}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="World"
/>
<Label className={styleClasses.styleFieldLabel}>
Country
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.country
? userData.country
: "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "userType":
return (
<div
className={`${styleClasses.styleField}`}
title={
userData.userType
? userData.userType
: "Not available"
}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="UserOptional"
/>
<Label className={styleClasses.styleFieldLabel}>
Office 365 User Type
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.userType
? userData.userType
: "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "employeeId":
return (
<div
className={`${styleClasses.styleField}`}
title={userData.employeeId ? userData.employeeId : "Not available"}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="Contact"
/>
<Label className={styleClasses.styleFieldLabel}>
Employee Id
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.employeeId ? userData.employeeId : "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "imAddresses":
return (
<div
className={`${styleClasses.styleField}`}
title={
userData.imAddresses.join(",")
? userData.imAddresses.join(",")
: "Not available"
}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="CannedChat"
/>
<Label className={styleClasses.styleFieldLabel}>
IMS Address
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.imAddresses.join(",") ? (
<>
<Link
data-interception="off"
target="_blank"
href={`https://teams.microsoft.com/l/chat/0/0?users=${userData.imAddresses[0]}`}
>
{userData.imAddresses[0]}
</Link>
</>
) : (
"Not available"
)}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
case "userType":
return (
<div
className={`${styleClasses.styleField}`}
title={userData.userType ? userData.userType : "Not available"}
>
<Stack
horizontal={true}
verticalAlign="center"
tokens={{ childrenGap: 5 }}
style={{ marginRight: 20, marginLeft: 20 }}
>
<FontIcon
className={styleClasses.styleIconDetails}
iconName="CityNext2"
/>
<Label className={styleClasses.styleFieldLabel}>
User Type
</Label>
</Stack>
<Text
styles={styleTextField}
variant="medium"
block={true}
nowrap={true}
>
{userData.userType ? userData.userType : "Not available"}
</Text>
<div className={styleClasses.separator}></div>
</div>
);
break;
}
})}
</Stack>
</>
)}
</>
);
};

View File

@ -0,0 +1,19 @@
export interface IUser {
id?: string;
displayName: string;
mail: string;
jobTitle: string;
mobilePhone: string;
department: string;
businessPhones: string[];
userPrincipalName: string;
city: string;
companyName: string;
country: string;
employeeId: string;
imAddresses: string[];
officeLocation: string;
postalCode: string;
userType: string;
}

View File

@ -0,0 +1,6 @@
import { IUser } from "./IUser";
import { IUserPresence } from "./IUserPresence";
export interface IUserExtended extends IUser, IUserPresence {
count: number;
pictureBase64: string;
}

View File

@ -0,0 +1,7 @@
export interface IUserPresence {
"@odata.context"?: string;
id?: string;
availability: string;
activity: string;
}
//# sourceMappingURL=IUserPresence.d.ts.map

View File

@ -0,0 +1,22 @@
import { useRef } from "react";
import React from "react";
export const useInterval = (callback:any, delay:number) => {
const savedCallback = useRef(null);
// Remember the latest function.
React.useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
React.useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
};

View File

@ -0,0 +1,281 @@
import "@pnp/sp/profiles";
import { MSGraphClient } from "@microsoft/sp-http";
import "@pnp/graph/users";
import { IUserExtended } from "../entites/IUserExtended";
import { IUser } from "../entites/IUser";
import { IUserPresence } from "../entites/IUserPresence";
import { SPComponentLoader } from "@microsoft/sp-loader";
import { findIndex } from "lodash";
/*************************************************************************************/
// Hook to search users
//*************************************************************************************/
const DEFAULT_PERSONA_IMG_HASH = "7ad602295f8386b7615b582d87bcc294";
const DEFAULT_IMAGE_PLACEHOLDER_HASH = "4a48f26592f4e1498d7a478a4c48609c";
const MD5_MODULE_ID = "8494e7d7-6b99-47b2-a741-59873e42f16f";
const PROFILE_IMAGE_URL = "/_layouts/15/userphoto.aspx?size=M&accountname=";
// Search Users
export const useSearchUsers = async (
searchString: string,
_MSGraphClient: MSGraphClient,
pageSize?: number
): Promise<{ usersExtended: IUserExtended[]; nextPage: string }> => {
pageSize = pageSize ? pageSize : 5;
const _searchResults: any = await _MSGraphClient
.api('/users?$search="' + searchString + '"')
.version("beta")
.header("ConsistencyLevel", "eventual")
.select(
"id,displayName,jobTitle,mail,mobilePhone,department,businessPhones,userPrincipalName,city,companyName,country,employeeId,imAddresses,officeLocation,postalCode,userType"
)
.top(pageSize)
.orderby("displayName")
.count(true)
.get();
const _users: IUser[] = _searchResults.value;
let _usersExtended: IUserExtended[] = [];
for (const _user of _users) {
const _userPresence = await getUserPresence(_user.id, _MSGraphClient);
const _pictureBase64: string = await getUserPhoto(_user.mail);
_usersExtended.push({
..._user,
..._userPresence,
pictureBase64: _pictureBase64,
count: 0,
});
}
let _nextPage: string = undefined;
if (_searchResults["@odata.nextLink"]) {
_nextPage = _searchResults["@odata.nextLink"];
}
return { usersExtended: _usersExtended, nextPage: _nextPage };
};
// Get Users by department
export const useGetUsersByDepartment = async (
filter: string,
_MSGraphClient: MSGraphClient,
pageSize?: number
): Promise<{ usersExtended: IUserExtended[]; nextPage: string }> => {
pageSize = pageSize ? pageSize : 5;
const _filter: string =
filter && filter.trim().length > 0
? `?$filter=department eq '${encodeURIComponent(filter)}'`
: ""; // if department is blanks get first 1000 users
const _searchResults: any = await _MSGraphClient
.api(`/users${_filter}`)
.version("beta")
.header("ConsistencyLevel", "eventual")
.select(
"id,displayName,jobTitle,mail,mobilePhone,department,businessPhones,userPrincipalName,city,companyName,country,employeeId,imAddresses,officeLocation,postalCode,userType"
)
.top(pageSize)
.orderby("displayName")
.count(true)
.get();
const _users: IUser[] = _searchResults.value;
let _usersExtended: IUserExtended[] = [];
for (const _user of _users) {
const _userPresence = await getUserPresence(_user.id, _MSGraphClient);
const _pictureBase64: string = await getUserPhoto(_user.mail);
_usersExtended.push({
..._user,
..._userPresence,
pictureBase64: _pictureBase64,
count: 0,
});
}
let _nextPage: string = undefined;
if (_searchResults["@odata.nextLink"]) {
_nextPage = _searchResults["@odata.nextLink"];
}
return { usersExtended: _usersExtended, nextPage: _nextPage };
};
// Get Users Next Page
export const useGetUsersNextPage = async (
nextPageLink: string,
_MSGraphClient: MSGraphClient
): Promise<{ usersExtended: IUserExtended[]; nextPage: string }> => {
const _searchResults: any = await _MSGraphClient
.api(`${nextPageLink}`)
.version("beta")
.header("ConsistencyLevel", "eventual")
.select(
"id,displayName,jobTitle,mail,mobilePhone,department,businessPhones,userPrincipalName,city,companyName,country,employeeId,imAddresses,officeLocation,postalCode,userType"
)
.orderby("displayName")
.count(true)
.get();
const _users: IUser[] = _searchResults.value;
let _usersExtended: IUserExtended[] = [];
for (const _user of _users) {
const _userPresence = await getUserPresence(_user.id, _MSGraphClient);
const _pictureBase64: string = await getUserPhoto(_user.mail);
_usersExtended.push({
..._user,
..._userPresence,
pictureBase64: _pictureBase64,
count: 0,
});
}
let _nextPage: string = undefined;
if (_searchResults["@odata.nextLink"]) {
_nextPage = _searchResults["@odata.nextLink"];
}
return { usersExtended: _usersExtended, nextPage: _nextPage };
};
//*************************************************************************************//
// function Get Users Presence
//*************************************************************************************//
export const useGetUsersPresence = async (
users: IUserExtended[],
_MSGraphClient: MSGraphClient
): Promise<IUserExtended[]> => {
let userObjsIds: string[] = [];
// debugger;
for (const _user of users) {
userObjsIds.push(_user.id);
}
if (userObjsIds.length > 0) {
const _presences: any = await _MSGraphClient
.api(`/communications/getPresencesByUserId`)
.version("beta")
.post({ ids: userObjsIds });
// update presence
for (const _presence of _presences.value) {
const i = findIndex(users, (v) => {
return v.id == _presence.id;
});
users[i] = {
...users[i],
..._presence,
};
}
}
return users;
};
//*************************************************************************************//
// function Get Users Presence
//*************************************************************************************//
const getUserPresence = async (
userObjId,
_MSGraphClient
): Promise<IUserPresence> => {
let _presence: IUserPresence = await _MSGraphClient
.api("/users/" + userObjId + "/presence")
.version("beta")
.get();
return _presence;
};
/**
* Gets user photo
* @param userId
* @returns user photo
*/
const getUserPhoto = async (userId: string): Promise<string> => {
const personaImgUrl = PROFILE_IMAGE_URL + userId;
const url: string = await getImageBase64(personaImgUrl);
const newHash = await getMd5HashForUrl(url);
if (
newHash !== DEFAULT_PERSONA_IMG_HASH &&
newHash !== DEFAULT_IMAGE_PLACEHOLDER_HASH
) {
return "data:image/png;base64," + url;
} else {
return "undefined";
}
};
/**
* Get MD5Hash for the image url to verify if user has default image or custom image
* @param url
*/
const getMd5HashForUrl = (url: string) => {
return new Promise(async (resolve, reject) => {
const library: any = await loadSPComponentById(MD5_MODULE_ID);
try {
const md5Hash = library.Md5Hash;
if (md5Hash) {
const convertedHash = md5Hash(url);
resolve(convertedHash);
}
} catch (error) {
resolve(url);
}
});
};
/**
* Load SPFx component by id, SPComponentLoader is used to load the SPFx components
* @param componentId - componentId, guid of the component library
*/
const loadSPComponentById = (componentId: string) => {
return new Promise((resolve, reject) => {
SPComponentLoader.loadComponentById(componentId)
.then((component: any) => {
resolve(component);
})
.catch((error) => {});
});
};
/**
* Gets image base64
* @param pictureUrl
* @returns image base64
*/
const getImageBase64 = async (pictureUrl: string): Promise<string> => {
return new Promise((resolve, reject) => {
let image = new Image();
image.addEventListener("load", () => {
let tempCanvas = document.createElement("canvas");
(tempCanvas.width = image.width),
(tempCanvas.height = image.height),
tempCanvas.getContext("2d").drawImage(image, 0, 0);
let base64Str;
try {
base64Str = tempCanvas.toDataURL("image/png");
} catch (e) {
return "";
}
base64Str = base64Str.replace(/^data:image\/png;base64,/, "");
resolve(base64Str);
});
image.src = pictureUrl;
});
};
export const useGetUserId = async (user, _MSGraphClient): Promise<IUser> => {
const _usersResults: IUser = await _MSGraphClient
.api("/users/" + user)
.version("beta")
.header("ConsistencyLevel", "eventual")
.select(
"id,displayName,jobTitle,mail,mobilePhone,department,businessPhones,userPrincipalName,city,companyName,country,employeeId,imAddresses,officeLocation,postalCode,userType"
)
.orderby("displayName")
.count(true)
.get();
// Get UserID
return _usersResults;
};

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,34 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "587a52af-e713-42a1-86b9-04ae167d4954",
"alias": "StaffDirectoryWebPart",
"componentType": "WebPart",
// The "*" signifies that the version should be taken from the package.json
"version": "*",
"manifestVersion": 2,
"supportsThemeVariants": true,
// If true, the component can only be installed on sites where Custom Script is allowed.
// Components that allow authors to embed arbitrary script code should set this to true.
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart"],
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Other
"group": { "default": "SPFx Custom Web Parts" },
"title": { "default": "Search Directory" },
"description": { "default": "Search Directory" },
"officeFabricIconFontName": "ProfileSearch",
"properties": {
"title": "Search Directory",
"maxHeight": 700,
"showBox": true,
"refreshInterval": 3,
"updatePresenceStatus": true,
"userAttributes": [],
"pageSize": 5
}
}]
}

View File

@ -0,0 +1,191 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version, DisplayMode } from '@microsoft/sp-core-library';
import {
IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneSlider,
PropertyPaneToggle,
PropertyPaneHorizontalRule
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart, PropertyPaneLabel } from '@microsoft/sp-webpart-base';
import * as strings from 'StaffDirectoryWebPartStrings';
import {StaffDirectory} from '../../components/StaffDirectory/StaffDirectory';
import { IStaffDirectoryProps } from '../../components/StaffDirectory/IStaffDirectoryProps';
import { PropertyFieldMultiSelect } from '@pnp/spfx-property-controls/lib/PropertyFieldMultiSelect';
import { ThemeProvider, ThemeChangedEventArgs, IReadonlyTheme } from '@microsoft/sp-component-base';
export interface IStaffDirectoryWebPartProps {
title: string;
maxHeight: number;
showBox: boolean;
displayMode: DisplayMode;
refreshInterval: number;
updateProperty: (value: string) => void;
updatePresenceStatus: boolean;
userAttributes: string[];
pageSize:number;
}
export default class StaffDirectoryWebPart extends BaseClientSideWebPart<IStaffDirectoryWebPartProps> {
private _themeProvider: ThemeProvider;
private _themeVariant: IReadonlyTheme | undefined;
protected async onInit(): Promise<void> {
window.sessionStorage.clear();
this._themeProvider = this.context.serviceScope.consume(ThemeProvider.serviceKey);
// If it exists, get the theme variant
this._themeVariant = this._themeProvider.tryGetTheme();
// Register a handler to be notified if the theme variant changes
this._themeProvider.themeChangedEvent.add(this, this._handleThemeChangedEvent);
return Promise.resolve();
}
private _handleThemeChangedEvent(args: ThemeChangedEventArgs): void {
this._themeVariant = args.theme;
this.render();
}
public render(): void {
const element: React.ReactElement<IStaffDirectoryProps> = React.createElement(
StaffDirectory,
{
title: this.properties.title,
context: this.context,
maxHeight: this.properties.maxHeight,
showBox: this.properties.showBox,
themeVariant: this._themeVariant,
displayMode: this.displayMode,
updateProperty: (value:string ) => {
this.properties.title = value;
},
refreshInterval: this.properties.refreshInterval,
updatePresenceStatus: this.properties.updatePresenceStatus,
userAttributes: this.properties.userAttributes,
pageSize: this.properties.pageSize,
}
);
ReactDom.render(element, this.domElement);
}
protected get disableReactivePropertyChanges() {
return true;
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneTextField('title', {
label: strings.TitleFieldLabel,
value: this.properties.title
}),
PropertyPaneHorizontalRule(),
PropertyPaneSlider("maxHeight",{
min: 280,
max: 1200,
value: this.properties.maxHeight,
label: strings.MaxHeightLabel
}),
PropertyPaneHorizontalRule(),
PropertyPaneToggle("showBox",{
label: strings.ShowBoxLabel,
key:"showBox",
checked: this.properties.showBox
}),
PropertyPaneToggle("updatePresenceStatus",{
label: strings.UpdatePresenceStatusLabel,
key:"presenceStatus",
checked: this.properties.updatePresenceStatus
}),
PropertyPaneHorizontalRule(),
this.properties.updatePresenceStatus && (
PropertyPaneSlider("refreshInterval",{
min: 3,
max: 15,
value: this.properties.refreshInterval,
label: strings.RefreshIntervalLabel
})
),
PropertyPaneLabel('',{text:''}),
PropertyPaneHorizontalRule(),
PropertyFieldMultiSelect('userAttributes', {
key: 'userAttributes',
label: strings.UserAttributesLabel,
options: [
{
key: "company",
text: "Company"
},
{
key: "officeLocation",
text: "Office Location"
},
{
key: "mobilePhone",
text: "Mobile Phone",
},
{
key: "postalCode",
text: "Postal Code"
},
{
key: "country",
text: "Country"
},
{
key: "employeeId",
text: "Employee Id"
},
{
key: "imAddresses",
text: "IMS Address"
},
{
key: "userType",
text: "User Type"
},
],
selectedKeys: this.properties.userAttributes
}),
PropertyPaneLabel('',{text:''}),
PropertyPaneHorizontalRule(),
PropertyPaneSlider("pageSize",{
min: 5,
max: 100,
value: this.properties.pageSize,
label: strings.PageSizeLabel
}),
]
}
]
}
]
};
}
}

View File

@ -0,0 +1,14 @@
define([], function() {
return {
ErrorMessage: " Error searching users, please try later or contact support.",
UserAttributesLabel: "Select aditional user properties to show",
"PropertyPaneDescription": "Search for user in Azure AD Active Directory, Initial shows colleagues of same department",
"BasicGroupName": "Properties",
"TitleFieldLabel": "Title",
"MaxHeightLabel": "Web Part max height",
"ShowBoxLabel": "Show Backgroud Box",
"RefreshIntervalLabel": "Refresh Presence Status (min)",
"UpdatePresenceStatusLabel": "Update Presence Status?",
"PageSizeLabel": "Number of users to show per page",
}
});

View File

@ -0,0 +1,17 @@
declare interface IStaffDirectoryWebPartStrings {
PropertyPaneDescription: string;
BasicGroupName: string;
ErrorMessage: string;
TitleFieldLabel: string;
MaxHeightLabel:string;
ShowBoxLabel: string;
RefreshIntervalLabel:string;
UpdatePresenceStatusLabel: string;
PageSizeLabel:string;
UserAttributesLabel: string;
}
declare module 'StaffDirectoryWebPartStrings' {
const strings: IStaffDirectoryWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

View File

@ -0,0 +1,64 @@
/**
* This script updates the package-solution version analogue to the
* the package.json file.
*/
if (process.env.npm_package_version === undefined) {
throw 'Package version cannot be evaluated';
}
// define path to package-solution file
const solution = './config/package-solution.json',
teams = './teams/manifest.json';
// require filesystem instance
const fs = require('fs');
// get next automated package version from process variable
const nextPkgVersion = process.env.npm_package_version;
// make sure next build version match
const nextVersion = nextPkgVersion.indexOf('-') === -1 ?
nextPkgVersion : nextPkgVersion.split('-')[0];
// Update version in SPFx package-solution if exists
if (fs.existsSync(solution)) {
// read package-solution file
const solutionFileContent = fs.readFileSync(solution, 'UTF-8');
// parse file as json
const solutionContents = JSON.parse(solutionFileContent);
// set property of version to next version
solutionContents.solution.version = nextVersion + '.0';
// save file
fs.writeFileSync(
solution,
// convert file back to proper json
JSON.stringify(solutionContents, null, 2),
'UTF-8');
}
// Update version in teams manifest if exists
if (fs.existsSync(teams)) {
// read package-solution file
const teamsManifestContent = fs.readFileSync(teams, 'UTF-8');
// parse file as json
const teamsContent = JSON.parse(teamsManifestContent);
// set property of version to next version
teamsContent.version = nextVersion;
// save file
fs.writeFileSync(
teams,
// convert file back to proper json
JSON.stringify(teamsContent, null, 2),
'UTF-8');
}

View File

@ -0,0 +1,41 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.7/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"outDir": "lib",
"inlineSources": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"es6-promise",
"webpack-env"
],
"lib": [
"es5",
"dom",
"es2015.collection"
],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"lib"
]
}

View File

@ -0,0 +1,30 @@
{
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": false,
"no-construct": false,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": false,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}