Merge commit '8348f1431362272ea3e8c9898ea468ac2cd69d3d' as 'samples/react-msgraph-peoplesearch'

This commit is contained in:
Hugo Bernier 2020-08-01 01:24:56 -04:00
commit afbcba4fda
49 changed files with 19930 additions and 0 deletions

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

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,6 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
}

View File

@ -0,0 +1,13 @@
{
"@microsoft/generator-sharepoint": {
"version": "1.11.0",
"libraryName": "spfx-msgraph-peoplesearch",
"libraryId": "98a8d9d1-47c4-477c-addd-ecae95b235cc",
"environment": "spo",
"packageManager": "npm",
"framework": "react",
"isCreatingSolution": true,
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,79 @@
# Microsoft Graph People Search Web Part
## Summary
Show and search users from your organisation, through Microsoft Graph. Search results show as a nice People Card, and display the Live Persona Card on hover.
The web part accepts a search query through a Dynamic Data connection, to further filter the displayed results. A source for this search query is not provided, but by default this can come from the Microsoft Search search box or the Page Environment. You could also use the Search Box Web Part provided by the [PnP Modern Search Web Parts](https://microsoft-search.github.io/pnp-modern-search/).
![directory](/assets/MicrosoftGraphPeopleSearch.gif)
![directory](/assets/MicrosoftGraphPeopleSearch-LPC.gif)
## Future improvements
- Support loading Profile Pictures
- Support for multiple pages
- Improve $select field with predefined properties of the User object
- Improve field mapping with the selected properties defined in $select
- Toggle Live Person Card
## Accompanying blog post
I wrote a blog post covering more if the inner workings, you can find it at [SPFx People Search web part based on Microsoft Graph](https://blog.yannickreekmans.be/spfx-people-search-web-part-based-on-microsoft-graph/)
## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/version-1.11-green.svg)
## Applies to
* [SharePoint Online](https://docs.microsoft.com/sharepoint/dev/spfx/sharepoint-framework-overview)
* [Microsoft Teams](https://products.office.com/en-US/microsoft-teams/group-chat-software) - Untested!!
* [Office 365 tenant](https://docs.microsoft.com/sharepoint/dev/spfx/set-up-your-development-environment)
## Version history
Version|Date|Comments
-------|----|--------
2.0.0|July 30, 2020|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
- Clone this repository
- in the command line run:
- `npm install`
- `gulp build`
- `gulp bundle --ship`
- `gulp package-solution --ship`
- Add to AppCatalog and deploy
- Assign 'User.Read.All' delegated permissions to the 'SharePoint Online Client Extensibility Web Application Principal', easiest way is with [Office 365 CLI](https://pnp.github.io/office365-cli/):
```
o365 login
o365 spo serviceprincipal grant add --resource 'Microsoft Graph' --scope 'User.Read.All'
```
---
## Acknowledgements / Inspiration
There are many web parts that aim to do the same thing, but they either use SharePoint Search as data store or they render their results in a completely different way. It's impossible to acknowledge all sources of inspiration to this solution, but I do want to give a shout out to two projects (and their contributors) that were foundational to deliver this solution as quickly as I did:
### React Directory Web Part
The foundation on which I started building my own solution. This web part can be downloaded from the [SharePoint Framework Client-Side Web Part Samples & Tutorial Materials](https://github.com/pnp/sp-dev-fx-webparts/tree/master/samples/react-directory)
#### Thanks to
- João Mendes ([@joaojmendes](https://twitter.com/joaojmendes))
- Peter Paul Kirschner ([@petkir_at](https://twitter.com/petkir_at))
### PnP Modern Search Web Parts
These web parts were an enormous inspiration on code structure and implementation approach. Their codebase is very impressive, and a lot of the code in this web part is a literal copy paste from them. You can find more on the [PnP Modern Search Web Parts](https://microsoft-search.github.io/pnp-modern-search/) page.
#### Thanks to
- Franck Cornu (aequos) - [@FranckCornu](http://www.twitter.com/FranckCornu) - [GitHub Sponsor Page](https://github.com/sponsors/FranckyC)
- Mikael Svenson (Microsoft) - [@mikaelsvenson](http://www.twitter.com/mikaelsvenson)
- Yannick Reekmans - [@yannickreekmans](https://twitter.com/yannickreekmans)
- Albert-Jan Schot - [@appieschot](https://twitter.com/appieschot)
- Tarald Gåsbakk (PuzzlePart) - [@taraldgasbakk](https://twitter.com/Taraldgasbakk)
- Brad Schlintz (Microsoft) - [@bschlintz](https://twitter.com/bschlintz)
- Richard Gigan - [@PooLP](https://twitter.com/PooLP)

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 MiB

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"peoplesearch-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/peoplesearch/PeopleSearchWebPart.js",
"manifest": "./src/webparts/peoplesearch/PeopleSearchWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"PeopleSearchWebPartStrings": "lib/webparts/peoplesearch/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,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "spfx-msgraph-peoplesearch",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1 @@
{"preset":"@voitanos/jest-preset-spfx-react16","rootDir":"../src","coverageReporters":["text","json","lcov","text-summary","cobertura"],"reporters":["default","jest-junit"]}

View File

@ -0,0 +1,21 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"developer": {
"name": "Contoso",
"privacyUrl": "https://contoso.com/privacy",
"termsOfUseUrl": "https://contoso.com/terms-of-use",
"websiteUrl": "https://contoso.com/my-app",
"mpnId": "000000"
},
"name": "Microsoft Graph People Search",
"id": "98a8d9d1-47c4-477c-addd-ecae95b235cc",
"version": "2.0.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"isDomainIsolated": false
},
"paths": {
"zippedPackage": "solution/spfx-msgraph-peoplesearch.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,53 @@
{
"name": "spfx-msgraph-peoplesearch",
"version": "2.0.0",
"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/spfx-controls-react": "1.13.2",
"@pnp/spfx-property-controls": "1.15.0",
"@uifabric/styling": "6.50.7",
"immutability-helper": "^2.4.0",
"office-ui-fabric-react": "6.194.0",
"react": "16.7.0",
"react-ace": "^5.8.0",
"react-dom": "16.7.0"
},
"resolutions": {
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^1.13.0",
"@microsoft/rush-stack-compiler-3.3": "0.3.5",
"@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.8.8",
"@types/react-dom": "16.8.3",
"@types/webpack-env": "1.13.1",
"ajv": "~5.2.2",
"gulp": "~3.9.1",
"gulp-sequence": "1.0.0",
"typescript": "~3.3.x"
}
}

View File

@ -0,0 +1,39 @@
import * as React from 'react';
import { Suspense } from 'react';
import AceEditor from 'react-ace';
import 'brace/mode/json';
import 'brace/theme/textmate';
export interface IDebugViewProps {
/**
* The debug content to display
*/
content?: string;
}
export interface IDebugViewState {
}
export class DebugViewComponent extends React.Component<IDebugViewProps, IDebugViewState> {
public render() {
return <Suspense fallback={""}><AceEditor
width="100%"
mode="json"
theme="textmate"
enableLiveAutocompletion={ true }
showPrintMargin={ false }
showGutter= { true }
value={ this.props.content }
highlightActiveLine={ true }
readOnly={ true }
editorProps={
{
$blockScrolling: Infinity,
}
}
name="CodeView"
/></Suspense> ;
}
}

View File

@ -0,0 +1,130 @@
import * as React from 'react';
import ITemplateContext from '../../models/ITemplateContext';
import { isEmpty } from '@microsoft/sp-lodash-subset';
import styles from './PeopleViewComponent.module.scss';
import * as strings from "PeopleSearchWebPartStrings";
import { Shimmer, ShimmerElementType as ElemType, ShimmerElementsGroup } from 'office-ui-fabric-react';
import { ITheme } from '@uifabric/styling';
export interface IPeopleShimmerViewProps {
templateContext: ITemplateContext;
}
export interface IPeopleShimmerViewState {
}
export class PeopleShimmerViewComponent extends React.Component<IPeopleShimmerViewProps, IPeopleShimmerViewState> {
public render() {
const ctx = this.props.templateContext;
let mainElement: JSX.Element = null;
let resultCountElement: JSX.Element = null;
let paginationElement: JSX.Element = null;
if (isEmpty(ctx.items) || isEmpty(ctx.items.value)) {
if (ctx.showResultsCount) {
const shimmerLineStyle = {
width: '20%'
};
resultCountElement = <div className={styles.resultCount}>
<span className="shimmer line" style={shimmerLineStyle}></span>
</div>;
}
if (ctx.showPagination) {
paginationElement = null;
}
let personaSize: number;
switch (parseInt(ctx.personaSize)) {
case 11:
personaSize = 32;
break;
case 12:
personaSize = 40;
break;
case 13:
personaSize = 48;
break;
case 14:
personaSize = 72;
break;
case 15:
personaSize = 100;
break;
default:
personaSize = 48;
break;
}
const personaCards = [];
for (let i = 0; i < ctx.pageSize; i++) {
personaCards.push(<div className={styles.documentCardItem} key={i}>
<div className={styles.personaCard}>
{this._getPersonaCardShimmers(personaSize)}
</div>
</div>);
}
mainElement = <React.Fragment>
<div className={styles.defaultCard}>
{resultCountElement}
<div className={styles.documentCardContainer}>
{personaCards}
</div>
</div>
{paginationElement}
</React.Fragment>;
}
else if (!ctx.showBlank) {
mainElement = <div className={styles.noResults}>{strings.NoResultMessage}</div>;
}
return <div className={styles.peopleView}>{mainElement}</div>;
}
private _getPersonaCardShimmers(personaSize: number): JSX.Element {
const shimmerContent = <div
style={{
display: 'flex' ,
marginBottom: 15
}}
>
<div style={{
paddingTop: 8,
paddingRight: 16,
paddingBottom: 8,
width: '100%'
}}>
<Shimmer
theme={this.props.templateContext.themeVariant as ITheme}
customElementsGroup={
<div style={{ display: 'flex', marginTop: 10 }}>
<ShimmerElementsGroup
theme={this.props.templateContext.themeVariant as ITheme}
backgroundColor={this.props.templateContext.themeVariant.semanticColors.bodyBackground}
shimmerElements={[{ type: ElemType.circle, height: personaSize}, { type: ElemType.gap, width: 10, height: personaSize }]}
/>
<ShimmerElementsGroup
theme={this.props.templateContext.themeVariant as ITheme}
flexWrap={true}
backgroundColor={this.props.templateContext.themeVariant.semanticColors.bodyBackground}
width="100%"
shimmerElements={[
{ type: ElemType.line, width: '30%', height: 10, verticalAlign: 'center' },
{ type: ElemType.gap, width: '70%', height: personaSize/2 },
{ type: ElemType.line, width: '60%', height: 10, verticalAlign: 'top' },
{ type: ElemType.gap, width: '40%', height: (personaSize/2) }
]}
/>
</div>
}/>
</div>
</div>;
return shimmerContent;
}
}

View File

@ -0,0 +1,76 @@
.peopleView {
.defaultCard {
.documentCardContainer {
display: flex;
flex-flow: row wrap;
justify-content: left;
}
.documentCardItem {
margin-right: 15px;
flex: 0 0 345px;
.personaCard {
margin: 10px;
:global {
.ms-Persona-optionalText, .ms-Persona-tertiaryText {
display: initial;
}
.ms-Persona-primaryText, .ms-Persona-secondaryText, .ms-Persona-optionalText, .ms-Persona-tertiaryText {
width: 213px;
}
}
}
}
}
.resultCount {
padding-left: 10px;
margin-bottom: 10px;
}
.noResults {
padding: 10px;
text-align: center;
}
}
.placeholder_root {
.shimmer {
background: #f6f7f8;
background-image: linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%);
background-repeat: no-repeat;
background-size: 800px 104px;
display: inline-block;
position: relative;
-webkit-animation-duration: 1s;
-webkit-animation-fill-mode: forwards;
-webkit-animation-iteration-count: infinite;
-webkit-animation-name: placeholderShimmer;
-webkit-animation-timing-function: linear;
}
@-webkit-keyframes placeholderShimmer {
0% {
background-position: -468px 0;
}
100% {
background-position: 468px 0;
}
}
.line {
height: 10px;
}
.line.shimmer + .line.shimmer {
margin-top: 10px;
}
}

View File

@ -0,0 +1,61 @@
import * as React from 'react';
import ITemplateContext from '../../models/ITemplateContext';
import { isEmpty } from '@microsoft/sp-lodash-subset';
import { PersonaCard } from '../PersonaCard/PersonaCard';
import styles from './PeopleViewComponent.module.scss';
import { Text } from '@microsoft/sp-core-library';
import * as strings from "PeopleSearchWebPartStrings";
export interface IPeopleViewProps {
templateContext: ITemplateContext;
}
export interface IPeopleViewState {
}
export class PeopleViewComponent extends React.Component<IPeopleViewProps, IPeopleViewState> {
public render() {
const ctx = this.props.templateContext;
let mainElement: JSX.Element = null;
let resultCountElement: JSX.Element = null;
let paginationElement: JSX.Element = null;
if (!isEmpty(ctx.items) && !isEmpty(ctx.items.value)) {
if (ctx.showResultsCount) {
const resultCount = ctx.items["@odata.count"];
resultCountElement = <div className={styles.resultCount}>
<label className="ms-fontWeight-semibold">{Text.format(strings.ResultsCount, resultCount)}</label>
</div>;
}
if (ctx.showPagination) {
paginationElement = null;
}
const personaCards = [];
for (let i = 0; i < ctx.items.value.length; i++) {
personaCards.push(<div className={styles.documentCardItem} key={i}>
<div className={styles.personaCard}>
<PersonaCard serviceScope={ctx.serviceScope} fieldsConfiguration={ctx.peopleFields} item={ctx.items.value[i]} themeVariant={ctx.themeVariant} personaSize={ctx.personaSize} />
</div>
</div>);
}
mainElement = <React.Fragment>
<div className={styles.defaultCard}>
{resultCountElement}
<div className={styles.documentCardContainer}>
{personaCards}
</div>
</div>
{paginationElement}
</React.Fragment>;
}
else if (!ctx.showBlank) {
mainElement = <div className={styles.noResults}>{strings.NoResultMessage}</div>;
}
return <div className={styles.peopleView}>{mainElement}</div>;
}
}

View File

@ -0,0 +1,19 @@
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { IComponentFieldsConfiguration } from "../../services/TemplateService/TemplateService";
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import { ServiceScope } from '@microsoft/sp-core-library';
export interface IPersonaCardProps {
serviceScope: ServiceScope;
item?: MicrosoftGraph.User;
fieldsConfiguration?: IComponentFieldsConfiguration[];
personaSize?: string;
themeVariant?: IReadonlyTheme;
// Individual content properties (i.e web component attributes)
text?: string;
secondaryText?: string;
tertiaryText?: string;
optionalText?: string;
upn?: string;
}

View File

@ -0,0 +1,3 @@
export interface IPersonaCardState {
isComponentLoaded: boolean;
}

View File

@ -0,0 +1,142 @@
import * as React from 'react';
import { IPersonaCardProps } from './IPersonaCardProps';
import { IPersonaCardState } from './IPersonaCardState';
import {
Log, Environment, EnvironmentType,
} from '@microsoft/sp-core-library';
import { SPComponentLoader } from '@microsoft/sp-loader';
import {
Persona,
IPersonaSharedProps,
} from 'office-ui-fabric-react';
import { ITheme } from '@uifabric/styling';
import { TemplateService } from '../../services/TemplateService/TemplateService';
import { isEmpty } from '@microsoft/sp-lodash-subset';
const LIVE_PERSONA_COMPONENT_ID: string = "914330ee-2df2-4f6e-a858-30c23a812408";
export class PersonaCard extends React.Component<IPersonaCardProps,IPersonaCardState> {
private sharedLibrary: any;
constructor(props: IPersonaCardProps) {
super(props);
this.state = {
isComponentLoaded: false,
};
this.sharedLibrary = null;
}
/**
*
*
* @memberof PersonaCard
*/
public async componentDidMount() {
if (Environment.type !== EnvironmentType.Local) {
await this._loadSpfxSharedLibrary();
}
}
private async _loadSpfxSharedLibrary() {
if (!this.state.isComponentLoaded) {
try {
this.sharedLibrary = await SPComponentLoader.loadComponentById(LIVE_PERSONA_COMPONENT_ID);
this.setState({
isComponentLoaded: true
});
} catch (error) {
Log.error(`[LivePersona_Component]`, error, this.props.serviceScope);
}
}
}
private determinePersonaConfig(): IPersonaCardProps {
let processedProps: IPersonaCardProps = this.props;
if (this.props.fieldsConfiguration && this.props.item) {
processedProps = TemplateService.processFieldsConfiguration<IPersonaCardProps>(this.props.fieldsConfiguration, this.props.item);
}
return processedProps;
}
/**
*
*
* @private
* @returns
* @memberof PersonaCard
*/
private _LivePersonaCard() {
let processedProps: IPersonaCardProps = this.determinePersonaConfig();
return React.createElement(
this.sharedLibrary.LivePersonaCard,
{
className: 'livePersonaCard',
clientScenario: "PeopleWebPart",
disableHover: false,
hostAppPersonaInfo: {
PersonaType: "User"
},
serviceScope: this.props.serviceScope,
upn: processedProps.upn,
onCardOpen: () => {
console.log('LivePersonaCard Open');
},
onCardClose: () => {
console.log('LivePersonaCard Close');
},
},
this._PersonaCard(processedProps)
);
}
/**
*
*
* @private
* @returns {JSX.Element}
* @memberof PersonaCard
*/
private _PersonaCard(processedProps?: IPersonaCardProps): JSX.Element {
if (isEmpty(processedProps)) {
processedProps = this.determinePersonaConfig();
}
const persona: IPersonaSharedProps = {
theme:this.props.themeVariant as ITheme,
text: processedProps.text,
secondaryText: processedProps.secondaryText,
tertiaryText: processedProps.tertiaryText,
optionalText: processedProps.optionalText
};
return <Persona {...persona} size={parseInt(this.props.personaSize)} />;
}
/**
*
*
* @returns {React.ReactElement<IPersonaCardProps>}
* @memberof PersonaCard
*/
public render(): React.ReactElement<IPersonaCardProps> {
return (
<React.Fragment>
{this.state.isComponentLoaded
? this._LivePersonaCard()
: this._PersonaCard()}
</React.Fragment>
);
}
}

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,18 @@
import { PageCollection } from './PageCollection';
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import { IComponentFieldsConfiguration } from '../services/TemplateService/TemplateService';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { ServiceScope } from '@microsoft/sp-core-library';
interface ITemplateContext {
items: PageCollection<MicrosoftGraph.User>;
showResultsCount: boolean;
showBlank: boolean;
showPagination: boolean;
peopleFields?: IComponentFieldsConfiguration[];
themeVariant?: IReadonlyTheme;
serviceScope: ServiceScope;
[key:string]: any;
}
export default ITemplateContext;

View File

@ -0,0 +1,7 @@
export interface PageCollection<T> {
value: T[];
"@odata.nextLink"?: string;
"@odata.prevLink"?: string;
"@odata.count"?: number;
[Key: string]: any;
}

View File

@ -0,0 +1,6 @@
enum ResultsLayoutOption {
People = 5,
Debug = 6
}
export default ResultsLayoutOption;

View File

@ -0,0 +1,12 @@
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import { PageCollection } from '../../models/PageCollection';
export interface ISearchService {
selectParameter: string[];
filterParameter: string;
orderByParameter: string;
searchParameter: string;
pageSize: number;
searchUsers(): Promise<PageCollection<MicrosoftGraph.User>>;
fetchPage(pageLink: string): Promise<PageCollection<MicrosoftGraph.User>>;
}

View File

@ -0,0 +1,81 @@
import { ISearchService } from "./ISearchService";
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import * as peopleSearchResults from './MockSearchServiceResults.json';
import { PageCollection } from "../../models/PageCollection";
export class MockSearchService implements ISearchService {
private _selectParameter: string[];
private _filterParameter: string;
private _orderByParameter: string;
private _searchParameter: string;
private _pageSize: number;
public get selectParameter(): string[] { return this._selectParameter; }
public set selectParameter(value: string[]) { this._selectParameter = value; }
public get filterParameter(): string { return this._filterParameter; }
public set filterParameter(value: string) { this._filterParameter = value; }
public get orderByParameter(): string { return this._orderByParameter; }
public set orderByParameter(value: string) { this._orderByParameter = value; }
public get searchParameter(): string { return this._searchParameter; }
public set searchParameter(value: string) { this._searchParameter = value; }
public get pageSize(): number { return this._pageSize; }
public set pageSize(value: number) { this._pageSize = value; }
public async searchUsers(): Promise<PageCollection<MicrosoftGraph.User>> {
const timeout = Math.floor(Math.random() * (1000)) + 1;
let resultData: PageCollection<MicrosoftGraph.User> = this.getResultData("1");
return new Promise((resolve) => {
setTimeout(() => {
resolve(resultData);
}, timeout);
});
}
public async fetchPage(currentPage: string): Promise<PageCollection<MicrosoftGraph.User>> {
const timeout = Math.floor(Math.random() * (1000)) + 1;
let resultData: PageCollection<MicrosoftGraph.User> = this.getResultData(currentPage);
return new Promise((resolve) => {
setTimeout(() => {
resolve(resultData);
}, timeout);
});
}
private getResultData(currentPage: string): PageCollection<MicrosoftGraph.User> {
let resultData: PageCollection<MicrosoftGraph.User> = {
"@odata.count": peopleSearchResults["@odata.count"],
value: peopleSearchResults.value as MicrosoftGraph.User[]
};
let peopleResults = resultData.value;
//TODO: Implement select
//TODO: Implement filter
//TODO: Implement orderBy
//Pagination
let totalPages = Math.ceil(resultData["@odata.count"] / this.pageSize);
let currentPageNumber = parseInt(currentPage);
let currentPageZeroBased = currentPageNumber-1;
peopleResults = peopleResults.slice(currentPageZeroBased * this.pageSize, (currentPageZeroBased * this.pageSize) + this.pageSize);
if (currentPageNumber < totalPages) {
resultData["@odata.nextLink"] = (currentPageNumber + 1).toString();
}
if (currentPageNumber > 0) {
resultData["@odata.prevLink"] = (currentPageNumber - 1).toString();
}
resultData.value = peopleResults;
return resultData;
}
}

View File

@ -0,0 +1,285 @@
{
"@odata.context": "https://graph.microsoft.com/beta/$metadata#users(id,userPrincipalName,displayName,mail,jobTitle,mobilePhone,companyName,extension_7d1bd45a54af4c7c943ed5bff5a2a231_company)",
"@odata.count": 31,
"value": [
{
"id": "87d349ed-44d7-43e1-9a83-5f2406dee5bd",
"userPrincipalName": "AdeleV@M365x214355.onmicrosoft.com",
"displayName": "Adele Vance",
"mail": "AdeleV@M365x214355.onmicrosoft.com",
"jobTitle": "Product Marketing Manager",
"mobilePhone": null,
"companyName": null
},
{
"id": "4782e723-f4f4-4af3-a76e-25e3bab0d896",
"userPrincipalName": "AlexW@M365x214355.onmicrosoft.com",
"displayName": "Alex Wilber",
"mail": "AlexW@M365x214355.onmicrosoft.com",
"jobTitle": "Marketing Assistant",
"mobilePhone": null,
"companyName": null
},
{
"id": "c03e6eaa-b6ab-46d7-905b-73ec7ea1f755",
"userPrincipalName": "AllanD@M365x214355.onmicrosoft.com",
"displayName": "Allan Deyoung",
"mail": "AllanD@M365x214355.onmicrosoft.com",
"jobTitle": "Corporate Security Officer",
"mobilePhone": null,
"companyName": null
},
{
"id": "f5289423-7233-4d60-831a-fe107a8551cc",
"userPrincipalName": "BenW@M365x214355.onmicrosoft.com",
"displayName": "Ben Walters",
"mail": "BenW@M365x214355.onmicrosoft.com",
"jobTitle": "VP Sales",
"mobilePhone": null,
"companyName": null
},
{
"id": "e46ba1a2-59e7-4019-b0fa-b940053e0e30",
"userPrincipalName": "BrianJ@M365x214355.onmicrosoft.com",
"displayName": "Brian Johnson (TAILSPIN)",
"mail": "BrianJ@M365x214355.onmicrosoft.com",
"jobTitle": null,
"mobilePhone": null,
"companyName": null
},
{
"id": "b66ecf79-a093-4d51-86e0-efcc4531f37a",
"userPrincipalName": "ChristieC@M365x214355.onmicrosoft.com",
"displayName": "Christie Cline",
"mail": "ChristieC@M365x214355.onmicrosoft.com",
"jobTitle": "Sr. VP Sales & Marketing",
"mobilePhone": null,
"companyName": null
},
{
"id": "6e7b768e-07e2-4810-8459-485f84f8f204",
"userPrincipalName": "Adams@M365x214355.onmicrosoft.com",
"displayName": "Conf Room Adams",
"mail": "Adams@M365x214355.onmicrosoft.com",
"jobTitle": null,
"mobilePhone": null,
"companyName": null
},
{
"id": "013b7b1b-5411-4e6e-bdc9-c4790dae1051",
"userPrincipalName": "Baker@M365x214355.onmicrosoft.com",
"displayName": "Conf Room Baker",
"mail": "Baker@M365x214355.onmicrosoft.com",
"jobTitle": null,
"mobilePhone": null,
"companyName": null
},
{
"id": "8528d6e9-dce3-45d1-85d4-d2db5f738a9f",
"userPrincipalName": "Crystal@M365x214355.onmicrosoft.com",
"displayName": "Conf Room Crystal",
"mail": "Crystal@M365x214355.onmicrosoft.com",
"jobTitle": null,
"mobilePhone": null,
"companyName": null
},
{
"id": "3fec04fc-e036-42f4-8f6f-b3b02288085c",
"userPrincipalName": "Hood@M365x214355.onmicrosoft.com",
"displayName": "Conf Room Hood",
"mail": "Hood@M365x214355.onmicrosoft.com",
"jobTitle": null,
"mobilePhone": null,
"companyName": null
},
{
"id": "6f1c452b-f9f4-4f43-8c42-17e30ab0077c",
"userPrincipalName": "Rainier@M365x214355.onmicrosoft.com",
"displayName": "Conf Room Rainier",
"mail": "Rainier@M365x214355.onmicrosoft.com",
"jobTitle": null,
"mobilePhone": null,
"companyName": null
},
{
"id": "5c7188eb-da70-4f1a-a8a5-afc26c2fe22c",
"userPrincipalName": "Stevens@M365x214355.onmicrosoft.com",
"displayName": "Conf Room Stevens",
"mail": "Stevens@M365x214355.onmicrosoft.com",
"jobTitle": null,
"mobilePhone": null,
"companyName": null
},
{
"id": "d4957c9d-869e-4364-830c-d0c95be72738",
"userPrincipalName": "DebraB@M365x214355.onmicrosoft.com",
"displayName": "Debra Berger",
"mail": "DebraB@M365x214355.onmicrosoft.com",
"jobTitle": "Administrative Assistant",
"mobilePhone": null,
"companyName": null
},
{
"id": "24fcbca3-c3e2-48bf-9ffc-c7f81b81483d",
"userPrincipalName": "DiegoS@M365x214355.onmicrosoft.com",
"displayName": "Diego Siciliani",
"mail": "DiegoS@M365x214355.onmicrosoft.com",
"jobTitle": "CVP Finance",
"mobilePhone": null,
"companyName": null
},
{
"id": "2804bc07-1e1f-4938-9085-ce6d756a32d2",
"userPrincipalName": "EmilyB@M365x214355.onmicrosoft.com",
"displayName": "Emily Braun",
"mail": "EmilyB@M365x214355.onmicrosoft.com",
"jobTitle": "Budget Analyst",
"mobilePhone": null,
"companyName": null
},
{
"id": "16cfe710-1625-4806-9990-91b8f0afee35",
"userPrincipalName": "EnricoC@M365x214355.onmicrosoft.com",
"displayName": "Enrico Cattaneo",
"mail": "EnricoC@M365x214355.onmicrosoft.com",
"jobTitle": "Attorney",
"mobilePhone": null,
"companyName": null
},
{
"id": "df043ff1-49d5-414e-86a4-0c7f239c36cf",
"userPrincipalName": "GradyA@M365x214355.onmicrosoft.com",
"displayName": "Grady Archie",
"mail": "GradyA@M365x214355.onmicrosoft.com",
"jobTitle": "CVP Legal",
"mobilePhone": null,
"companyName": null
},
{
"id": "c8913c86-ceea-4d39-b1ea-f63a5b675166",
"userPrincipalName": "HenriettaM@M365x214355.onmicrosoft.com",
"displayName": "Henrietta Mueller",
"mail": "HenriettaM@M365x214355.onmicrosoft.com",
"jobTitle": "Marketing Assistant",
"mobilePhone": null,
"companyName": null
},
{
"id": "baafca12-9874-4765-9576-e0e5cafe491b",
"userPrincipalName": "IrvinS@M365x214355.onmicrosoft.com",
"displayName": "Irvin Sayers",
"mail": "IrvinS@M365x214355.onmicrosoft.com",
"jobTitle": "Director",
"mobilePhone": null,
"companyName": null
},
{
"id": "e3d0513b-449e-4198-ba6f-bd97ae7cae85",
"userPrincipalName": "IsaiahL@M365x214355.onmicrosoft.com",
"displayName": "Isaiah Langer",
"mail": "IsaiahL@M365x214355.onmicrosoft.com",
"jobTitle": "Web Marketing Manager",
"mobilePhone": null,
"companyName": null
},
{
"id": "626cbf8c-5dde-46b0-8385-9e40d64736fe",
"userPrincipalName": "JohannaL@M365x214355.onmicrosoft.com",
"displayName": "Johanna Lorenz",
"mail": "JohannaL@M365x214355.onmicrosoft.com",
"jobTitle": "CVP Engineering",
"mobilePhone": null,
"companyName": null
},
{
"id": "8b209ac8-08ff-4ef1-896d-3b9fde0bbf04",
"userPrincipalName": "JoniS@M365x214355.onmicrosoft.com",
"displayName": "Joni Sherman",
"mail": "JoniS@M365x214355.onmicrosoft.com",
"jobTitle": "Paralegal",
"mobilePhone": null,
"companyName": null
},
{
"id": "074e56ea-0b50-4461-89e5-c67ae14a2c0b",
"userPrincipalName": "LeeG@M365x214355.onmicrosoft.com",
"displayName": "Lee Gu",
"mail": "LeeG@M365x214355.onmicrosoft.com",
"jobTitle": "CVP Research & Development",
"mobilePhone": null,
"companyName": null
},
{
"id": "2ed03dfd-01d8-4005-a9ef-fa8ee546dc6c",
"userPrincipalName": "LidiaH@M365x214355.onmicrosoft.com",
"displayName": "Lidia Holloway",
"mail": "LidiaH@M365x214355.onmicrosoft.com",
"jobTitle": "Product Manager",
"mobilePhone": null,
"companyName": null
},
{
"id": "e8a02cc7-df4d-4778-956d-784cc9506e5a",
"userPrincipalName": "LynneR@M365x214355.onmicrosoft.com",
"displayName": "Lynne Robbins",
"mail": "LynneR@M365x214355.onmicrosoft.com",
"jobTitle": "Product Manager",
"mobilePhone": null,
"companyName": null
},
{
"id": "48d31887-5fad-4d73-a9f5-3c356e68a038",
"userPrincipalName": "MeganB@M365x214355.onmicrosoft.com",
"displayName": "Megan Bowen",
"mail": "MeganB@M365x214355.onmicrosoft.com",
"jobTitle": "Auditor",
"mobilePhone": null,
"companyName": null
},
{
"id": "08fa38e4-cbfa-4488-94ed-c834da6539df",
"userPrincipalName": "MiriamG@M365x214355.onmicrosoft.com",
"displayName": "Miriam Graham",
"mail": "MiriamG@M365x214355.onmicrosoft.com",
"jobTitle": "VP Marketing",
"mobilePhone": null,
"companyName": null
},
{
"id": "5bde3e51-d13b-4db1-9948-fe4b109d11a7",
"userPrincipalName": "admin@M365x214355.onmicrosoft.com",
"displayName": "MOD Administrator",
"mail": "admin@M365x214355.onmicrosoft.com",
"jobTitle": null,
"mobilePhone": "5555555555",
"companyName": null
},
{
"id": "089a6bb8-e8cb-492c-aa41-c078aa0b5120",
"userPrincipalName": "NestorW@M365x214355.onmicrosoft.com",
"displayName": "Nestor Wilke",
"mail": "NestorW@M365x214355.onmicrosoft.com",
"jobTitle": "CVP Operations",
"mobilePhone": null,
"companyName": null
},
{
"id": "40079818-3808-4585-903b-02605f061225",
"userPrincipalName": "PattiF@M365x214355.onmicrosoft.com",
"displayName": "Patti Fernandez",
"mail": "PattiF@M365x214355.onmicrosoft.com",
"jobTitle": "President",
"mobilePhone": null,
"companyName": null
},
{
"id": "ec63c778-24e1-4240-bea3-d12a167d5232",
"userPrincipalName": "PradeepG@M365x214355.onmicrosoft.com",
"displayName": "Pradeep Gupta",
"mail": "PradeepG@M365x214355.onmicrosoft.com",
"jobTitle": "Accountant II",
"mobilePhone": null,
"companyName": null
}
]
}

View File

@ -0,0 +1,70 @@
import { ISearchService } from "./ISearchService";
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import { MSGraphClientFactory } from '@microsoft/sp-http';
import { isEmpty } from "@microsoft/sp-lodash-subset";
import { PageCollection } from "../../models/PageCollection";
export class SearchService implements ISearchService {
private _msGraphClientFactory: MSGraphClientFactory;
private _selectParameter: string[];
private _filterParameter: string;
private _orderByParameter: string;
private _searchParameter: string;
private _pageSize: number;
public get selectParameter(): string[] { return this._selectParameter; }
public set selectParameter(value: string[]) { this._selectParameter = value; }
public get filterParameter(): string { return this._filterParameter; }
public set filterParameter(value: string) { this._filterParameter = value; }
public get orderByParameter(): string { return this._orderByParameter; }
public set orderByParameter(value: string) { this._orderByParameter = value; }
public get searchParameter(): string { return this._searchParameter; }
public set searchParameter(value: string) { this._searchParameter = value; }
public get pageSize(): number { return this._pageSize; }
public set pageSize(value: number) { this._pageSize = value; }
constructor(msGraphClientFactory: MSGraphClientFactory) {
this._msGraphClientFactory = msGraphClientFactory;
}
public async searchUsers(): Promise<PageCollection<MicrosoftGraph.User>> {
const graphClient = await this._msGraphClientFactory.getClient();
let resultQuery = graphClient
.api('/users')
.version("beta")
.header("ConsistencyLevel", "eventual")
.count(true)
.top(this.pageSize);
if (!isEmpty(this.selectParameter)) {
resultQuery = resultQuery.select(this.selectParameter);
}
if (!isEmpty(this.filterParameter)) {
resultQuery = resultQuery.filter(this.filterParameter);
}
if (!isEmpty(this.orderByParameter)) {
resultQuery = resultQuery.orderby(this.orderByParameter);
}
if (!isEmpty(this.searchParameter)) {
resultQuery = resultQuery.query({ $search: `"displayName:${this.searchParameter}"` });
}
return await resultQuery.get();
}
public async fetchPage(pageLink: string): Promise<PageCollection<MicrosoftGraph.User>> {
const graphClient = await this._msGraphClientFactory.getClient();
let resultQuery = graphClient.api(pageLink).header("ConsistencyLevel", "eventual");
return await resultQuery.get();
}
}

View File

@ -0,0 +1,3 @@
export { ISearchService } from './ISearchService';
export { MockSearchService } from './MockSearchService';
export { SearchService } from './SearchService';

View File

@ -0,0 +1,181 @@
import * as React from 'react';
import ResultsLayoutOption from '../../models/ResultsLayoutOption';
import { IPropertyPaneField } from '@microsoft/sp-property-pane';
import { PropertyFieldCollectionData, CustomCollectionFieldType } from '@pnp/spfx-property-controls/lib/PropertyFieldCollectionData';
import * as strings from 'PeopleSearchWebPartStrings';
import { PropertyPaneChoiceGroup } from "@microsoft/sp-property-pane";
import { IPeopleSearchWebPartProps } from '../../webparts/peoplesearch/IPeopleSearchWebPartProps';
import { DebugViewComponent, IDebugViewProps } from '../../components/DebugViewComponent';
import ITemplateContext from '../../models/ITemplateContext';
import { PeopleViewComponent, IPeopleViewProps } from '../../components/PeopleViewComponent/PeopleViewComponent';
import { IPeopleShimmerViewProps, PeopleShimmerViewComponent } from '../../components/PeopleViewComponent/PeopleShimmerViewComponent';
const PEOPLE_RESULT_SOURCEID = 'b09a7990-05ea-4af9-81ef-edfab16c4e31';
export interface IComponentFieldsConfiguration {
/**
* The name of the field
*/
name: string;
/**
* The field name for the inner component props
*/
field: string;
/**
* The value of the field
*/
value: string;
}
export class TemplateService {
/**
* Gets template parameters
* @param layout the selected layout
* @param properties the Web Part properties
* @param onUpdateAvailableProperties callback when the list of managed properties is fetched by the control (Optional)
* @param availableProperties the list of available managed properties already fetched once (Optional)
*/
public getTemplateParameters(layout: ResultsLayoutOption, properties: IPeopleSearchWebPartProps): IPropertyPaneField<any>[] {
switch (layout) {
case ResultsLayoutOption.People:
return this._getPeopleLayoutFields(properties);
default:
return [];
}
}
public getTemplateComponent(layout: ResultsLayoutOption, results: ITemplateContext): JSX.Element {
let templateComponent = null;
switch (layout) {
case ResultsLayoutOption.People:
templateComponent = React.createElement(
PeopleViewComponent,
{
templateContext: results
} as IPeopleViewProps
);
break;
case ResultsLayoutOption.Debug:
templateComponent = React.createElement(
DebugViewComponent,
{
content: JSON.stringify(results.items, undefined, 2)
} as IDebugViewProps
);
break;
}
return templateComponent;
}
public getShimmerTemplateComponent(layout: ResultsLayoutOption, results: ITemplateContext): JSX.Element {
let templateComponent = null;
switch (layout) {
case ResultsLayoutOption.People:
templateComponent = React.createElement(
PeopleShimmerViewComponent,
{
templateContext: results
} as IPeopleShimmerViewProps
);
break;
}
return templateComponent;
}
/**
* Replaces item field values with field mapping values configuration
* @param fieldsConfigurationAsString the fields configuration as stringified object
* @param itemAsString the item context as stringified object
* @param themeVariant the current theem variant
*/
public static processFieldsConfiguration<T>(fieldsConfiguration: IComponentFieldsConfiguration[], item: any): T {
let processedProps = {};
// Use configuration
fieldsConfiguration.map(configuration => {
let processedValue = item[configuration.value];
processedProps[configuration.field] = processedValue;
});
return processedProps as T;
}
private _getPeopleLayoutFields(properties: IPeopleSearchWebPartProps): IPropertyPaneField<any>[] {
// Setup default values
if (!properties.templateParameters.peopleFields) {
properties.templateParameters.peopleFields = [
{ name: 'User Principal Name', field: 'upn', value: "userPrincipalName" },
{ name: 'Primary Text', field: 'text', value: "displayName" },
{ name: 'Secondary Text', field: 'secondaryText', value: "jobTitle" },
{ name: 'Tertiary Text', field: 'tertiaryText', value: "mail" },
{ name: 'Optional Text', field: 'optionalText', value: "mobilePhone" }
] as IComponentFieldsConfiguration[];
}
if (!properties.templateParameters.personaSize) {
properties.templateParameters.personaSize = 14;
}
return [
// Careful, the property names should match the React components props. These will be injected in the Handlebars template context and passed as web component attributes
PropertyFieldCollectionData('templateParameters.peopleFields', {
manageBtnLabel: strings.TemplateParameters.ManagePeopleFieldsLabel,
key: 'templateParameters.peopleFields',
panelHeader: strings.TemplateParameters.ManagePeopleFieldsLabel,
panelDescription: strings.TemplateParameters.ManagePeopleFieldsPanelDescriptionLabel,
enableSorting: false,
disableItemCreation: true,
disableItemDeletion: true,
label: strings.TemplateParameters.ManagePeopleFieldsLabel,
value: properties.templateParameters.peopleFields,
fields: [
{
id: 'name',
type: CustomCollectionFieldType.string,
disableEdit: true,
title: strings.TemplateParameters.PlaceholderNameFieldLabel
},
{
id: 'value',
type: CustomCollectionFieldType.string,
title: strings.TemplateParameters.PlaceholderValueFieldLabel,
}
]
}),
PropertyPaneChoiceGroup('templateParameters.personaSize', {
label: strings.TemplateParameters.PersonaSizeOptionsLabel,
options: [
{
key: 11,
text: strings.TemplateParameters.PersonaSizeExtraSmall
},
{
key: 12,
text: strings.TemplateParameters.PersonaSizeSmall
},
{
key: 13,
text: strings.TemplateParameters.PersonaSizeRegular
},
{
key: 14,
text: strings.TemplateParameters.PersonaSizeLarge
},
{
key: 15,
text: strings.TemplateParameters.PersonaSizeExtraLarge
}
]
}),
];
}
}

View File

@ -0,0 +1,16 @@
import ResultsLayoutOption from "../../models/ResultsLayoutOption";
import { DynamicProperty } from '@microsoft/sp-component-base';
export interface IPeopleSearchWebPartProps {
selectParameter: string;
filterParameter: string;
orderByParameter: string;
searchParameter: DynamicProperty<string>;
pageSize: string;
showPagination: boolean;
showResultsCount: boolean;
showBlank: boolean;
selectedLayout: ResultsLayoutOption;
webPartTitle: string;
templateParameters: { [key:string]: any };
}

View File

@ -0,0 +1,28 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "0890f045-1475-4b52-8e1d-28f9ef6943ff",
"alias": "MsGraphPeopleSearchWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart","TeamsTab", "SharePointFullPage"],
"supportsThemeVariants": true,
"preconfiguredEntries": [{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "SPFx WebParts" },
"title": { "default": "People Search" },
"description": { "default": "Search for people within your organization, through Microsoft Graph" },
"officeFabricIconFontName": "ProfileSearch",
"properties": {
"selectParameter": "",
"filterParameter": "",
"orderByParameter": "",
"pageSize": 10,
"webPartTitle": "People Search",
"showPagination": true,
"showBlank": true,
"showResultsCount": true
}
}]
}

View File

@ -0,0 +1,373 @@
import * as React from "react";
import * as ReactDom from "react-dom";
import { Version, Environment, EnvironmentType } from "@microsoft/sp-core-library";
import { ThemeProvider, IReadonlyTheme, ThemeChangedEventArgs } from '@microsoft/sp-component-base';
import { BaseClientSideWebPart, IWebPartPropertiesMetadata } from "@microsoft/sp-webpart-base";
import { DisplayMode } from "@microsoft/sp-core-library";
import { isEqual } from '@microsoft/sp-lodash-subset';
import {
IPropertyPaneConfiguration,
PropertyPaneToggle,
IPropertyPaneField,
IPropertyPaneChoiceGroupOption,
PropertyPaneChoiceGroup,
PropertyPaneTextField,
IPropertyPaneGroup,
IPropertyPaneConditionalGroup,
DynamicDataSharedDepth,
PropertyPaneDynamicField,
PropertyPaneDynamicFieldSet
} from "@microsoft/sp-property-pane";
import * as update from 'immutability-helper';
import * as strings from "PeopleSearchWebPartStrings";
import { IPeopleSearchWebPartProps } from "./IPeopleSearchWebPartProps";
import { ISearchService, MockSearchService, SearchService } from "../../services/SearchService";
import { IPeopleSearchContainerProps, PeopleSearchContainer } from "./components/PeopleSearchContainer";
import ResultsLayoutOption from "../../models/ResultsLayoutOption";
import { TemplateService } from "../../services/TemplateService/TemplateService";
export default class PeopleSearchWebPart extends BaseClientSideWebPart<IPeopleSearchWebPartProps> {
private _searchService: ISearchService;
private _templateService: TemplateService;
private _placeholder = null;
private _themeProvider: ThemeProvider;
private _themeVariant: IReadonlyTheme;
private _initComplete = false;
private _templatePropertyPaneOptions: IPropertyPaneField<any>[] = [];
public async render(): Promise<void> {
if (!this._initComplete) {
return;
}
await this._initTemplate();
if (this.displayMode === DisplayMode.Edit) {
const { Placeholder } = await import(
/* webpackChunkName: 'search-property-pane' */
'@pnp/spfx-controls-react/lib/Placeholder'
);
this._placeholder = Placeholder;
}
this.renderCompleted();
}
protected get isRenderAsync(): boolean {
return true;
}
protected renderCompleted(): void {
super.renderCompleted();
let renderElement = null;
if (this._isWebPartConfigured()) {
const searchParameter: string | undefined = this.properties.searchParameter.tryGetValue();
this._searchService = update(this._searchService, {
selectParameter: { $set: this.properties.selectParameter ? this.properties.selectParameter.split(',') : [] },
filterParameter: { $set: this.properties.filterParameter },
orderByParameter: { $set: this.properties.orderByParameter },
searchParameter: { $set: searchParameter },
pageSize: { $set: parseInt(this.properties.pageSize) }
});
renderElement = React.createElement(
PeopleSearchContainer,
{
webPartTitle: this.properties.webPartTitle,
displayMode: this.displayMode,
showBlank: this.properties.showBlank,
showResultsCount: this.properties.showResultsCount,
showPagination: this.properties.showPagination,
searchService: this._searchService,
templateService: this._templateService,
templateParameters: this.properties.templateParameters,
selectedLayout: this.properties.selectedLayout,
themeVariant: this._themeVariant,
serviceScope: this.context.serviceScope,
updateWebPartTitle: (value: string) => {
this.properties.webPartTitle = value;
}
} as IPeopleSearchContainerProps
);
} else {
if (this.displayMode === DisplayMode.Edit) {
const placeholder: React.ReactElement<any> = React.createElement(
this._placeholder,
{
iconName: strings.PlaceHolderEditLabel,
iconText: strings.PlaceHolderIconText,
description: strings.PlaceHolderDescription,
buttonLabel: strings.PlaceHolderConfigureBtnLabel,
onConfigure: this._setupWebPart.bind(this)
}
);
renderElement = placeholder;
} else {
renderElement = React.createElement('div', null);
}
}
ReactDom.render(renderElement, this.domElement);
}
protected async onInit(): Promise<void> {
this._initializeRequiredProperties();
this._initThemeVariant();
if (Environment.type === EnvironmentType.Local) {
this._searchService = new MockSearchService();
} else {
this._searchService = new SearchService(this.context.msGraphClientFactory);
}
this._templateService = new TemplateService();
this._initComplete = true;
return super.onInit();
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse("1.0");
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const templateParametersGroup = this._getTemplateFieldsGroup();
let propertyPaneGroups: (IPropertyPaneGroup | IPropertyPaneConditionalGroup)[] = [
{
groupName: strings.QuerySettingsGroupName,
groupFields: this._getQueryFields()
},
{
primaryGroup: {
groupName: strings.SearchQuerySettingsGroupName,
groupFields: [
PropertyPaneTextField('searchParameter', {
label: strings.SearchParameter
})
]
},
secondaryGroup: {
groupName: strings.SearchQuerySettingsGroupName,
groupFields: [
PropertyPaneDynamicFieldSet({
label: strings.SearchParameter,
fields: [
PropertyPaneDynamicField('searchParameter', {
label: strings.SearchParameter
})
],
sharedConfiguration: {
depth: DynamicDataSharedDepth.Property
}
})
]
},
// Show the secondary group only if the web part has been
// connected to a dynamic data source
showSecondaryGroup: !!this.properties.searchParameter.tryGetSource()
} as IPropertyPaneConditionalGroup,
{
groupName: strings.StylingSettingsGroupName,
groupFields: this._getStylingFields(),
}
];
if (templateParametersGroup) {
propertyPaneGroups.push(templateParametersGroup);
}
return {
pages: [
{
groups: propertyPaneGroups,
displayGroupsAsAccordion: false
}
]
};
}
protected async onPropertyPaneFieldChanged(propertyPath: string) {
if (propertyPath.localeCompare('selectedLayout') === 0) {
await this._initTemplate();
this.context.propertyPane.refresh();
}
}
protected get propertiesMetadata(): IWebPartPropertiesMetadata {
return {
'searchParameter': {
dynamicPropertyType: 'string'
}
} as any as IWebPartPropertiesMetadata;
}
/**
* Determines the group fields for query options inside the property pane
*/
private _getQueryFields(): IPropertyPaneField<any>[] {
let stylingFields: IPropertyPaneField<any>[] = [
PropertyPaneTextField('selectParameter', {
label: strings.SelectParameter,
multiline: true
}),
PropertyPaneTextField('filterParameter', {
label: strings.FilterParameter,
multiline: true
}),
PropertyPaneTextField('orderByParameter', {
label: strings.OrderByParameter,
multiline: true
}),
PropertyPaneTextField('pageSize', {
label: strings.PageSizeParameter,
value: this.properties.pageSize.toString(),
maxLength: 3,
deferredValidationTime: 300,
onGetErrorMessage: (value: string) => {
return this._validateNumber(value);
}
}),
];
return stylingFields;
}
/**
* Init the template according to the property pane current configuration
* @returns the template content as a string
*/
private async _initTemplate(): Promise<void> {
this._templatePropertyPaneOptions = this._templateService.getTemplateParameters(this.properties.selectedLayout, this.properties);
}
/**
* Determines the group fields for styling options inside the property pane
*/
private _getStylingFields(): IPropertyPaneField<any>[] {
const layoutOptions = [
{
iconProps: {
officeFabricIconFontName: 'People'
},
text: strings.PeopleLayoutOption,
key: ResultsLayoutOption.People
},
{
iconProps: {
officeFabricIconFontName: 'Code'
},
text: strings.DebugLayoutOption,
key: ResultsLayoutOption.Debug
}
] as IPropertyPaneChoiceGroupOption[];
let stylingFields: IPropertyPaneField<any>[] = [
// PropertyPaneToggle('showPagination', {
// label: strings.ShowPaginationControl,
// }),
PropertyPaneToggle('showBlank', {
label: strings.ShowBlankLabel,
checked: this.properties.showBlank,
}),
PropertyPaneToggle('showResultsCount', {
label: strings.ShowResultsCountLabel,
checked: this.properties.showResultsCount,
}),
PropertyPaneChoiceGroup('selectedLayout', {
label: strings.ResultsLayoutLabel,
options: layoutOptions
}),
];
return stylingFields;
}
/**
* Gets template parameters fields
*/
private _getTemplateFieldsGroup(): IPropertyPaneGroup {
let templateFieldsGroup: IPropertyPaneGroup = null;
if (this._templatePropertyPaneOptions.length > 0) {
templateFieldsGroup = {
groupFields: this._templatePropertyPaneOptions,
isCollapsed: false,
groupName: strings.TemplateParameters.TemplateParametersGroupName
};
}
return templateFieldsGroup;
}
/**
* Checks if all webpart properties have been configured
*/
private _isWebPartConfigured(): boolean {
return true;
}
/**
* Initializes the Web Part required properties if there are not present in the manifest (i.e. during an update scenario)
*/
private _initializeRequiredProperties() {
this.properties.selectedLayout = (this.properties.selectedLayout !== undefined && this.properties.selectedLayout !== null) ? this.properties.selectedLayout : ResultsLayoutOption.People;
this.properties.templateParameters = this.properties.templateParameters ? this.properties.templateParameters : {};
}
/**
* Initializes theme variant properties
*/
private _initThemeVariant(): void {
// Consume the new ThemeProvider service
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.bind(this));
}
/**
* Update the current theme variant reference and re-render.
* @param args The new theme
*/
private _handleThemeChangedEvent(args: ThemeChangedEventArgs): void {
if (!isEqual(this._themeVariant, args.theme)) {
this._themeVariant = args.theme;
this.render();
}
}
/**
* Opens the Web Part property pane
*/
private _setupWebPart() {
this.context.propertyPane.open();
}
private _validateNumber(value: string): string {
let number = parseInt(value);
if (isNaN(number)) {
return strings.InvalidNumberIntervalMessage;
}
if (number < 1 || number > 999) {
return strings.InvalidNumberIntervalMessage;
}
return '';
}
}

View File

@ -0,0 +1,58 @@
import { DisplayMode, ServiceScope } from "@microsoft/sp-core-library";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { ISearchService } from "../../../../services/SearchService";
import ResultsLayoutOption from "../../../../models/ResultsLayoutOption";
import { TemplateService } from "../../../../services/TemplateService/TemplateService";
export interface IPeopleSearchContainerProps {
/**
* The web part title
*/
webPartTitle: string;
/**
* The search data provider instance
*/
searchService: ISearchService;
/**
* Show the result count and entered keywords
*/
showResultsCount: boolean;
/**
* Show nothing if no result
*/
showBlank: boolean;
showPagination: boolean;
/**
* The current display mode of Web Part
*/
displayMode: DisplayMode;
/**
* The current selected layout
*/
selectedLayout: ResultsLayoutOption;
/**
* The current theme variant
*/
themeVariant: IReadonlyTheme | undefined;
/**
* The template helper instance
*/
templateService: TemplateService;
/**
* Template parameters from Web Part property pane
*/
templateParameters: { [key:string]: any };
serviceScope: ServiceScope;
updateWebPartTitle: (value: string) => void;
}

View File

@ -0,0 +1,9 @@
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';
import { PageCollection } from '../../../../models/PageCollection';
export interface IPeopleSearchContainerState {
results: PageCollection<MicrosoftGraph.User>;
areResultsLoading: boolean;
errorMessage: string;
hasError: boolean;
}

View File

@ -0,0 +1,181 @@
import * as React from "react";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import * as strings from "PeopleSearchWebPartStrings";
import styles from "../PeopleSearchWebPart.module.scss";
import { IPeopleSearchContainerProps } from "./IPeopleSearchContainerProps";
import { IPeopleSearchContainerState } from "./IPeopleSearchContainerState";
import {
Spinner,
SpinnerSize,
MessageBar,
MessageBarType,
} from "office-ui-fabric-react";
import { Overlay } from 'office-ui-fabric-react/lib/Overlay';
import { ITheme } from '@uifabric/styling';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import { DisplayMode } from "@microsoft/sp-core-library";
import ResultsLayoutOption from "../../../../models/ResultsLayoutOption";
import { isEqual, isEmpty } from "@microsoft/sp-lodash-subset";
import ITemplateContext from "../../../../models/ITemplateContext";
export class PeopleSearchContainer extends React.Component<IPeopleSearchContainerProps,IPeopleSearchContainerState> {
constructor(props: IPeopleSearchContainerProps) {
super(props);
this.state = {
results: {
value: []
},
areResultsLoading: false,
errorMessage: '',
hasError: false
};
}
public async componentDidMount() {
await this._fetchPeopleSearchResults();
}
/**
*
*
* @param {IPeopleSearchContainerProps} prevProps
* @param {IPeopleSearchContainerState} prevState
* @memberof Directory
*/
public async componentDidUpdate(prevProps: IPeopleSearchContainerProps, prevState: IPeopleSearchContainerState) {
if (!isEqual(this.props.searchService, prevProps.searchService)) {
await this._fetchPeopleSearchResults();
}
else if (!isEqual(this.props, prevProps)) {
if (this.state.hasError) {
this.setState({
hasError: false,
});
} else {
this.forceUpdate();
}
}
}
/**
*
*
* @returns {React.ReactElement<IPeopleSearchContainerProps>}
* @memberof Directory
*/
public render(): React.ReactElement<IPeopleSearchContainerProps> {
const areResultsLoading = this.state.areResultsLoading;
const items = this.state.results;
const hasError = this.state.hasError;
const errorMessage = this.state.errorMessage;
const { semanticColors }: IReadonlyTheme = this.props.themeVariant;
let renderWebPartTitle: JSX.Element = null;
let renderWebPartContent: JSX.Element = null;
let renderOverlay: JSX.Element = null;
let renderShimmerElements: JSX.Element = null;
// Loading behavior
if (areResultsLoading) {
if (!isEmpty(items.value)) {
renderOverlay = <div>
<Overlay isDarkThemed={false} theme={this.props.themeVariant as ITheme} className={styles.overlay}>
<Spinner size={SpinnerSize.medium} />
</Overlay>
</div>;
} else {
let templateContext = {
items: this.state.results,
showPagination: this.props.showPagination,
showResultsCount: this.props.showResultsCount,
showBlank: this.props.showBlank,
themeVariant: this.props.themeVariant,
pageSize: this.props.searchService.pageSize,
serviceScope: this.props.serviceScope
} as ITemplateContext;
templateContext = { ...templateContext, ...this.props.templateParameters };
renderShimmerElements = this.props.templateService.getShimmerTemplateComponent(this.props.selectedLayout, templateContext);
}
}
// WebPart title
renderWebPartTitle = <WebPartTitle displayMode={this.props.displayMode} title={this.props.webPartTitle} updateProperty={(value: string) => this.props.updateWebPartTitle(value)} />;
// WebPart content
if (isEmpty(items.value) && this.props.showBlank && this.props.selectedLayout !== ResultsLayoutOption.Debug) {
if (this.props.displayMode === DisplayMode.Edit) {
renderWebPartContent = <MessageBar messageBarType={MessageBarType.info}>{strings.ShowBlankEditInfoMessage}</MessageBar>;
}
else {
renderWebPartTitle = null;
}
} else {
let templateContext = {
items: this.state.results,
showPagination: this.props.showPagination,
showResultsCount: this.props.showResultsCount,
showBlank: this.props.showBlank,
themeVariant: this.props.themeVariant,
pageSize: this.props.searchService.pageSize,
serviceScope: this.props.serviceScope
} as ITemplateContext;
templateContext = { ...templateContext, ...this.props.templateParameters };
let renderSearchResultTemplate = this.props.templateService.getTemplateComponent(this.props.selectedLayout, templateContext);
renderWebPartContent =
<React.Fragment>
{renderOverlay}
{renderSearchResultTemplate}
</React.Fragment>;
}
// Error Message
if (hasError) {
renderWebPartContent = <MessageBar messageBarType={MessageBarType.error}>{errorMessage}</MessageBar>;
}
return (
<div style={{backgroundColor: semanticColors.bodyBackground}}>
<div className={styles.peopleSearchWebPart}>
{renderWebPartTitle}
{renderShimmerElements ? renderShimmerElements : renderWebPartContent}
</div>
</div>
);
}
private async _fetchPeopleSearchResults(): Promise<void> {
try {
this.setState({
areResultsLoading: true,
hasError: false,
errorMessage: ""
});
const searchResults = await this.props.searchService.searchUsers();
this.setState({
results: searchResults,
areResultsLoading: false
});
} catch (error) {
this.setState({
areResultsLoading: false,
results: {
value: []
},
hasError: true,
errorMessage: error.message
});
}
}
}

View File

@ -0,0 +1,3 @@
export { PeopleSearchContainer } from './PeopleSearchContainer';
export { IPeopleSearchContainerProps } from './IPeopleSearchContainerProps';
export { IPeopleSearchContainerState } from './IPeopleSearchContainerState';

View File

@ -0,0 +1,15 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.peopleSearchWebPart {
min-height: 35px;
// Needed to avoid overlay overflow
position: relative;
}
.overlay {
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
}

View File

@ -0,0 +1,39 @@
define([], function() {
return {
"DebugLayoutOption": "Debug",
"FilterParameter": "$filter parameter value",
"InvalidNumberIntervalMessage": "Value needs to be at least 1 and maximum 999",
"NoResultMessage": "No results to display",
"OrderByParameter": "$orderby parameter value",
"PageSizeParameter": "Number of items per page",
"PeopleLayoutOption": "People",
"PlaceHolderEditLabel": "Edit",
"PlaceHolderConfigureBtnLabel": "Configure",
"PlaceHolderIconText": "People Search Web Part",
"PlaceHolderDescription": "This component displays people search results from Microsoft Graph",
"QuerySettingsGroupName": "Query",
"ResultsLayoutLabel": "Results layout",
"ResultsCount": "{0} results",
"SearchParameter": "$search parameter value",
"SearchQuerySettingsGroupName": "Search Parameter",
"SelectParameter": "$select parameter value",
"ShowPaginationControl": "Show pagination",
"ShowResultsCountLabel": "Show results count",
"ShowBlankLabel": "Show blank if no result",
"ShowBlankEditInfoMessage": "No result returned for this query. This Web Part will remain blank in display mode according to parameters.",
"StylingSettingsGroupName": "Styling options",
"TemplateParameters": {
"TemplateParametersGroupName": "Template options",
"ManagePeopleFieldsLabel": "Manage persona fields",
"ManagePeopleFieldsPanelDescriptionLabel": "Here you can map each field values with the corresponding persona placeholders. You can use either the managed property value directly without any transformation or use an Handlebars expression in the value field.",
"PlaceholderNameFieldLabel": "Name",
"PlaceholderValueFieldLabel": "Value",
"PersonaSizeOptionsLabel": "Picture size",
"PersonaSizeExtraSmall": "Extra small",
"PersonaSizeSmall": "Small",
"PersonaSizeRegular": "Regular",
"PersonaSizeLarge": "Large",
"PersonaSizeExtraLarge": "Extra large",
},
}
});

View File

@ -0,0 +1,42 @@
declare interface IPeopleSearchWebPartStrings {
DebugLayoutOption: string;
FilterParameter: string;
InvalidNumberIntervalMessage: string;
NoResultMessage: string;
OrderByParameter: string;
PageSizeParameter: string;
PeopleLayoutOption: string;
PlaceHolderEditLabel: string;
PlaceHolderConfigureBtnLabel: string;
PlaceHolderIconText: string;
PlaceHolderDescription: string;
QuerySettingsGroupName: string;
ResultsCount: string;
ResultsLayoutLabel: string;
SearchParameter: string;
SearchQuerySettingsGroupName: string;
SelectParameter: string;
ShowPaginationControl: string;
ShowResultsCountLabel: string;
ShowBlankLabel: string;
ShowBlankEditInfoMessage: string;
StylingSettingsGroupName: string;
TemplateParameters: {
TemplateParametersGroupName: string;
ManagePeopleFieldsLabel: string;
ManagePeopleFieldsPanelDescriptionLabel: string;
PlaceholderNameFieldLabel: string;
PlaceholderValueFieldLabel: string;
PersonaSizeOptionsLabel: string,
PersonaSizeExtraSmall: string;
PersonaSizeSmall: string;
PersonaSizeRegular: string;
PersonaSizeLarge: string;
PersonaSizeExtraLarge: string;
}
}
declare module 'PeopleSearchWebPartStrings' {
const strings: IPeopleSearchWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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,40 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule" : true,
"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"
]
},
"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": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}