Added the 'My recent documents' sample (#21)

This commit is contained in:
Waldek Mastykarz 2016-09-20 13:36:36 +02:00 committed by GitHub
parent a3f68c4490
commit ee4e6f4e8f
17 changed files with 436 additions and 21 deletions

View File

@ -16,6 +16,12 @@ Sample SharePoint Framework Client-Side Web Part built using React showing peopl
![Working with Web Part in the SharePoint Workbench](./assets/working-with-preview.png) ![Working with Web Part in the SharePoint Workbench](./assets/working-with-preview.png)
### My recent documents
Sample SharePoint Framework Client-Side Web Part built using React showing documents recently viewed or modified by the current user.
![Working with Web Part in the SharePoint Workbench](./assets/my-recent-documents-preview.png)
## Applies to ## Applies to
* [SharePoint Framework Developer Preview](http://dev.office.com/sharepoint/docs/spfx/sharepoint-framework-overview) * [SharePoint Framework Developer Preview](http://dev.office.com/sharepoint/docs/spfx/sharepoint-framework-overview)
@ -31,6 +37,7 @@ react-officegraph|Waldek Mastykarz (MVP, Rencore, @waldekm)
Version|Date|Comments Version|Date|Comments
-------|----|-------- -------|----|--------
1.2.0|September 20, 2016|Added the My recent documents sample
1.1.0|September 19, 2016|Added the Working with sample 1.1.0|September 19, 2016|Added the Working with sample
1.0.0|September 9, 2016|Initial release 1.0.0|September 9, 2016|Initial release
@ -56,24 +63,7 @@ Version|Date|Comments
## Features ## Features
### Trending in this site Web Part Sample Web Parts in this solution illustrate the following concepts on top of the SharePoint Framework:
The _Trending in this site_ Client-Side Web Part is built on the SharePoint Framework using React and uses the [Office UI Fabric React](https://github.com/OfficeDev/office-ui-fabric-react) for showing document cards.
This Web Part illustrates the following concepts on top of the SharePoint Framework:
- using React for building SharePoint Framework Client-Side Web Parts
- using Office UI Fabric React components for building user experience consistent with SharePoint and Office
- communicating with SharePoint using its REST API
- communicating with the Office Graph via the SharePoint Search REST API
- passing Web Part properties to React components
- using ES6 Promises with vanilla-JavaScript web requests
### Working with Web Part
The _Working with_ Client-Side Web Part is built on the SharePoint Framework using React and uses the [Office UI Fabric React](https://github.com/OfficeDev/office-ui-fabric-react) to show persona cards.
This Web Part illustrates the following concepts on top of the SharePoint Framework:
- using React for building SharePoint Framework Client-Side Web Parts - using React for building SharePoint Framework Client-Side Web Parts
- using Office UI Fabric React components for building user experience consistent with SharePoint and Office - using Office UI Fabric React components for building user experience consistent with SharePoint and Office

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

View File

@ -9,6 +9,11 @@
"entry": "./lib/webparts/workingWith/WorkingWithWebPart.js", "entry": "./lib/webparts/workingWith/WorkingWithWebPart.js",
"manifest": "./src/webparts/workingWith/WorkingWithWebPart.manifest.json", "manifest": "./src/webparts/workingWith/WorkingWithWebPart.manifest.json",
"outputPath": "./dist/working-with.bundle.js" "outputPath": "./dist/working-with.bundle.js"
},
{
"entry": "./lib/webparts/myRecentDocuments/MyRecentDocumentsWebPart.js",
"manifest": "./src/webparts/myRecentDocuments/MyRecentDocumentsWebPart.manifest.json",
"outputPath": "./dist/my-recent-documents.bundle.js"
} }
], ],
"externals": { "externals": {
@ -22,6 +27,7 @@
}, },
"localizedResources": { "localizedResources": {
"trendingInThisSiteStrings": "webparts/trendingInThisSite/loc/{locale}.js", "trendingInThisSiteStrings": "webparts/trendingInThisSite/loc/{locale}.js",
"workingWithStrings": "webparts/workingWith/loc/{locale}.js" "workingWithStrings": "webparts/workingWith/loc/{locale}.js",
"myRecentDocumentsStrings": "webparts/myRecentDocuments/loc/{locale}.js"
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "react-officegraph", "name": "react-officegraph",
"version": "1.1.0", "version": "1.2.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"

View File

@ -0,0 +1,7 @@
export interface IActivity {
name: string;
date: string;
actorId: number;
actorName?: string;
actorPhotoUrl?: string;
}

View File

@ -0,0 +1,6 @@
export interface IActorInformation {
id: number;
name: string;
email: string;
photoUrl: string;
}

View File

@ -0,0 +1,10 @@
import { IActivity } from './IActivity';
export interface ITrendingDocument {
id: string;
title: string;
url: string;
previewImageUrl: string;
extension: string;
activity: IActivity;
}

View File

@ -25,9 +25,24 @@ export interface ICell {
ValueType: string; ValueType: string;
} }
export interface IEdge {
ActorId: number;
ObjectId: number;
Properties: IEdgeProperties;
}
export interface IEdgeProperties {
Action: number;
Blob: any[];
BlobContent: string;
ObjectSource: number;
Time: string;
Weight: number;
}
export class SearchUtils { export class SearchUtils {
public static getValueFromResults(key: string, results: ICell[]): string { public static getValueFromResults(key: string, results: ICell[]): string {
let value: string = ''; let value: string = undefined;
if (results && results.length > 0 && key) { if (results && results.length > 0 && key) {
for (let i: number = 0; i < results.length; i++) { for (let i: number = 0; i < results.length; i++) {
@ -41,4 +56,29 @@ export class SearchUtils {
return value; return value;
} }
public static getPreviewImageUrl(result: ICell[], siteUrl: string): string {
const uniqueID: string = SearchUtils.getValueFromResults('uniqueID', result);
const siteId: string = SearchUtils.getValueFromResults('siteID', result);
const webId: string = SearchUtils.getValueFromResults('webID', result);
const docId: string = SearchUtils.getValueFromResults('DocId', result);
if (uniqueID && siteId && webId && docId) {
return `${siteUrl}/_layouts/15/getpreview.ashx?guidFile=${uniqueID}&guidSite=${siteId}&guidWeb=${webId}&docid=${docId}
&metadatatoken=300x424x2&ClientType=CodenameOsloWeb&size=small`;
}
else {
return '';
}
}
public static getActionName(actionId: number): string {
switch (actionId) {
case 1001:
return 'Viewed';
case 1003:
return 'Modified';
default:
return '';
}
}
} }

View File

@ -2,4 +2,13 @@ export class Utils {
public static getUserPhotoUrl(userEmail: string, siteUrl: string, size: string = 'S'): string { public static getUserPhotoUrl(userEmail: string, siteUrl: string, size: string = 'S'): string {
return `${siteUrl}/_layouts/15/userphoto.aspx?size=${size}&accountname=${userEmail}`; return `${siteUrl}/_layouts/15/userphoto.aspx?size=${size}&accountname=${userEmail}`;
} }
public static trim(s: string): string {
if (s && s.length > 0) {
return s.replace(/^\s+|\s+$/gm, '');
}
else {
return s;
}
}
} }

View File

@ -0,0 +1,4 @@
export interface IMyRecentDocumentsWebPartProps {
numberOfDocuments: number;
title: string;
}

View File

@ -0,0 +1,16 @@
.myRecentDocuments {
.webPartTitle {
margin-bottom: 0.7em;
margin-left: 0.38em;
}
:global .ms-DocumentCard {
float: left;
margin: 0.5em;
}
:global .ms-Spinner {
width: 7em;
margin: 0 auto;
}
}

View File

@ -0,0 +1,20 @@
{
"$schema": "../../../node_modules/@microsoft/sp-module-interfaces/lib/manifestSchemas/jsonSchemas/clientSideComponentManifestSchema.json",
"id": "e978c000-4905-4f25-8f5d-db250dbd30a6",
"componentType": "WebPart",
"version": "0.0.1",
"manifestVersion": 2,
"preconfiguredEntries": [{
"groupId": "e978c000-4905-4f25-8f5d-db250dbd30a6",
"group": { "default": "Content rollup" },
"title": { "default": "My recent documents" },
"description": { "default": "Shows documents recently viewed or modified by the current user" },
"officeFabricIconFontName": "Recent",
"properties": {
"title": "My recent documents",
"numberOfDocuments": 5
}
}]
}

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import {
BaseClientSideWebPart,
IPropertyPaneSettings,
IWebPartContext,
PropertyPaneSlider,
PropertyPaneTextField
} from '@microsoft/sp-client-preview';
import * as strings from 'myRecentDocumentsStrings';
import MyRecentDocuments, { IMyRecentDocumentsProps } from './components/MyRecentDocuments';
import { IMyRecentDocumentsWebPartProps } from './IMyRecentDocumentsWebPartProps';
export default class MyRecentDocumentsWebPart extends BaseClientSideWebPart<IMyRecentDocumentsWebPartProps> {
public constructor(context: IWebPartContext) {
super(context);
}
public render(): void {
const element: React.ReactElement<IMyRecentDocumentsProps> = React.createElement(MyRecentDocuments, {
numberOfDocuments: this.properties.numberOfDocuments,
title: this.properties.title,
httpClient: this.context.httpClient,
siteUrl: this.context.pageContext.web.absoluteUrl
});
ReactDom.render(element, this.domElement);
}
protected get propertyPaneSettings(): IPropertyPaneSettings {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.ViewGroupName,
groupFields: [
PropertyPaneTextField('title', {
label: strings.TitleFieldLabel
}),
PropertyPaneSlider('numberOfDocuments', {
label: strings.NumberOfDocumentsFieldLabel,
min: 1,
max: 10,
step: 1
})
]
}
]
}
]
};
}
}

View File

@ -0,0 +1,222 @@
import * as React from 'react';
import {
css,
DocumentCard,
DocumentCardPreview,
DocumentCardTitle,
DocumentCardActivity,
Spinner
} from 'office-ui-fabric-react';
import styles from '../MyRecentDocuments.module.scss';
import { IMyRecentDocumentsWebPartProps } from '../IMyRecentDocumentsWebPartProps';
import { HttpClient } from '@microsoft/sp-client-base';
import { ITrendingDocument } from '../../ITrendingDocument';
import { IActorInformation } from '../../IActorInformation';
import { SearchUtils, ISearchQueryResponse, IRow, ICell, IEdge } from '../../SearchUtils';
import { Utils } from '../../Utils';
export interface IMyRecentDocumentsProps extends IMyRecentDocumentsWebPartProps {
httpClient: HttpClient;
siteUrl: string;
}
export interface IMyRecentDocumentsState {
myDocuments: ITrendingDocument[];
loading: boolean;
error: string;
}
export default class MyRecentDocuments extends React.Component<IMyRecentDocumentsProps, IMyRecentDocumentsState> {
constructor(props: IMyRecentDocumentsProps, state: IMyRecentDocumentsState) {
super(props);
this.state = {
myDocuments: [] as ITrendingDocument[],
loading: true,
error: null
};
}
public componentDidMount(): void {
this.loadMyDocuments(this.props.siteUrl, this.props.numberOfDocuments);
}
public componentDidUpdate(prevProps: IMyRecentDocumentsProps, prevState: IMyRecentDocumentsState, prevContext: any): void {
if (this.props.numberOfDocuments !== prevProps.numberOfDocuments ||
this.props.siteUrl !== prevProps.siteUrl && (
this.props.numberOfDocuments && this.props.siteUrl
)) {
this.loadMyDocuments(this.props.siteUrl, this.props.numberOfDocuments);
}
}
public render(): JSX.Element {
const loading: JSX.Element = this.state.loading ? <div style={{ margin: '0 auto' }}><Spinner label={'Loading...'} /></div> : <div/>;
const error: JSX.Element = this.state.error ? <div><strong>Error: </strong> {this.state.error}</div> : <div/>;
const documents: JSX.Element[] = this.state.myDocuments.map((doc: ITrendingDocument, i: number) => {
const iconUrl: string = `https://spoprod-a.akamaihd.net/files/odsp-next-prod_ship-2016-08-15_20160815.002/odsp-media/images/filetypes/32/${doc.extension}.png`;
return (
<DocumentCard onClickHref={doc.url} key={doc.id}>
<DocumentCardPreview
previewImages={[
{
previewImageSrc: doc.previewImageUrl,
iconSrc: iconUrl,
width: 318,
height: 196,
accentColor: '#ce4b1f'
}
]}
/>
<DocumentCardTitle title={doc.title}/>
<DocumentCardActivity
activity={`${doc.activity.name} ${doc.activity.date}`}
people={
[
{ name: doc.activity.actorName, profileImageSrc: doc.activity.actorPhotoUrl }
]
}
/>
</DocumentCard>
);
});
return (
<div className={styles.myRecentDocuments}>
<div className={css('ms-font-xl', styles.webPartTitle)}>{this.props.title}</div>
{loading}
{error}
{documents}
<div style={{ clear: 'both' }}/>
</div>
);
}
private loadMyDocuments(siteUrl: string, numberOfDocuments: number): void {
const myDocuments: ITrendingDocument[] = [];
this.props.httpClient.get(`${siteUrl}/_api/search/query?querytext='*'&properties='GraphQuery:actor(me\\,or(action\\:1001\\,action\\:1003)),GraphRankingModel:{"features"\\:[{"function"\\:"EdgeTime"}]}'&selectproperties='Author,AuthorOwsUser,DocId,DocumentPreviewMetadata,Edges,EditorOwsUser,FileExtension,FileType,HitHighlightedProperties,HitHighlightedSummary,LastModifiedTime,LikeCountLifetime,ListID,ListItemID,OriginalPath,Path,Rank,SPWebUrl,SecondaryFileExtension,ServerRedirectedURL,SiteTitle,Title,ViewCountLifetime,siteID,uniqueID,webID'&rowlimit=${numberOfDocuments}&ClientType='MyRecentDocuments'&RankingModelId='0c77ded8-c3ef-466d-929d-905670ea1d72'`, {
headers: {
'Accept': 'application/json;odata=nometadata',
'odata-version': ''
}
})
.then((response: Response): Promise<ISearchQueryResponse> => {
return response.json();
})
.then((response: ISearchQueryResponse): Promise<IActorInformation> => {
if (!response ||
!response.PrimaryQueryResult ||
!response.PrimaryQueryResult.RelevantResults ||
response.PrimaryQueryResult.RelevantResults.RowCount === 0) {
return Promise.resolve();
}
let actorId: number = undefined;
for (let i: number = 0; i < response.PrimaryQueryResult.RelevantResults.Table.Rows.length; i++) {
const row: IRow = response.PrimaryQueryResult.RelevantResults.Table.Rows[i];
const edges: IEdge[] = JSON.parse(SearchUtils.getValueFromResults('Edges', row.Cells));
if (edges.length < 1) {
continue;
}
// we can get multiple edges back so let's show the information from the latest one
let latestEdge: IEdge = edges[0];
if (edges.length > 1) {
let latestEdgeDate: Date = new Date(latestEdge.Properties.Time);
for (let i: number = 1; i < edges.length; i++) {
const edgeDate: Date = new Date(edges[i].Properties.Time);
if (edgeDate > latestEdgeDate) {
latestEdge = edges[i];
latestEdgeDate = edgeDate;
}
}
}
if (!actorId) {
// since all edges that we're retrieving are personal (I viewed, I modified)
// we only need to get the actor ID once because it's the same on all edges (me)
actorId = latestEdge.ActorId;
}
const cells: ICell[] = row.Cells;
const date: Date = new Date(latestEdge.Properties.Time);
const dateString: string = (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear();
myDocuments.push({
id: SearchUtils.getValueFromResults('DocId', cells),
url: SearchUtils.getValueFromResults('ServerRedirectedURL', cells),
title: SearchUtils.getValueFromResults('Title', cells),
previewImageUrl: SearchUtils.getPreviewImageUrl(cells, siteUrl),
extension: SearchUtils.getValueFromResults('FileType', cells),
activity: {
actorId: latestEdge.ActorId,
date: dateString,
name: SearchUtils.getActionName(latestEdge.Properties.Action)
}
});
}
return this.getActorsInfo(actorId, siteUrl);
}).
then((actorInformation: IActorInformation): void => {
if (actorInformation) {
for (let i: number = 0; i < myDocuments.length; i++) {
if (myDocuments[i].activity.actorId !== actorInformation.id) {
continue;
}
myDocuments[i].activity.actorName = actorInformation.name;
myDocuments[i].activity.actorPhotoUrl = actorInformation.photoUrl;
}
}
this.setState({
loading: false,
error: null,
myDocuments: myDocuments
});
}, (error: any): void => {
this.setState({
loading: false,
error: error,
myDocuments: []
});
});
}
private getActorsInfo(actorId: number, siteUrl: string): Promise<IActorInformation> {
if (!actorId) {
return Promise.resolve();
}
return new Promise((resolve: (actorInformation: IActorInformation) => void, reject: (error: any) => void): void => {
this.props.httpClient.get(`${siteUrl}/_api/search/query?querytext='WorkId:${actorId}'&selectproperties='DocId,Title,WorkEmail'&ClientType='MyRecentDocuments'&SourceId='b09a7990-05ea-4af9-81ef-edfab16c4e31'`, {
headers: {
'Accept': 'application/json;odata=nometadata',
'odata-version': ''
}
})
.then((response: Response): Promise<ISearchQueryResponse> => {
return response.json();
})
.then((response: ISearchQueryResponse): Promise<IActorInformation> => {
if (!response ||
!response.PrimaryQueryResult ||
!response.PrimaryQueryResult.RelevantResults ||
response.PrimaryQueryResult.RelevantResults.RowCount === 0) {
return Promise.resolve();
}
const cells: ICell[] = response.PrimaryQueryResult.RelevantResults.Table.Rows[0].Cells;
resolve({
email: SearchUtils.getValueFromResults('WorkEmail', cells),
id: parseInt(SearchUtils.getValueFromResults('DocId', cells)),
name: SearchUtils.getValueFromResults('Title', cells),
photoUrl: Utils.getUserPhotoUrl(SearchUtils.getValueFromResults('WorkEmail', cells), siteUrl)
});
}, (error: any): void => {
reject(error);
});
});
}
}

View File

@ -0,0 +1,8 @@
define([], function() {
return {
"PropertyPaneDescription": "Manage the settings of this Web Part",
"ViewGroupName": "View",
"NumberOfDocumentsFieldLabel": "Number of documents to show",
"TitleFieldLabel": "Web Part Title (displayed in the body)"
}
});

View File

@ -0,0 +1,11 @@
declare interface IMyRecentDocumentsStrings {
PropertyPaneDescription: string;
ViewGroupName: string;
NumberOfDocumentsFieldLabel: string;
TitleFieldLabel: string;
}
declare module 'myRecentDocumentsStrings' {
const strings: IMyRecentDocumentsStrings;
export = strings;
}

View File

@ -0,0 +1,7 @@
import * as assert from 'assert';
describe('MyRecentDocumentsWebPart', () => {
it('should do something', () => {
assert.ok(true);
});
});