Added the 'My recent documents' sample (#21)
This commit is contained in:
parent
a3f68c4490
commit
ee4e6f4e8f
|
@ -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)
|
||||
|
||||
### 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
|
||||
|
||||
* [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
|
||||
-------|----|--------
|
||||
1.2.0|September 20, 2016|Added the My recent documents sample
|
||||
1.1.0|September 19, 2016|Added the Working with sample
|
||||
1.0.0|September 9, 2016|Initial release
|
||||
|
||||
|
@ -56,24 +63,7 @@ Version|Date|Comments
|
|||
|
||||
## Features
|
||||
|
||||
### Trending in this site Web Part
|
||||
|
||||
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:
|
||||
Sample Web Parts in this solution illustrate 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
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 445 KiB |
|
@ -9,6 +9,11 @@
|
|||
"entry": "./lib/webparts/workingWith/WorkingWithWebPart.js",
|
||||
"manifest": "./src/webparts/workingWith/WorkingWithWebPart.manifest.json",
|
||||
"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": {
|
||||
|
@ -22,6 +27,7 @@
|
|||
},
|
||||
"localizedResources": {
|
||||
"trendingInThisSiteStrings": "webparts/trendingInThisSite/loc/{locale}.js",
|
||||
"workingWithStrings": "webparts/workingWith/loc/{locale}.js"
|
||||
"workingWithStrings": "webparts/workingWith/loc/{locale}.js",
|
||||
"myRecentDocumentsStrings": "webparts/myRecentDocuments/loc/{locale}.js"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "react-officegraph",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export interface IActivity {
|
||||
name: string;
|
||||
date: string;
|
||||
actorId: number;
|
||||
actorName?: string;
|
||||
actorPhotoUrl?: string;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export interface IActorInformation {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
photoUrl: string;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { IActivity } from './IActivity';
|
||||
|
||||
export interface ITrendingDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
previewImageUrl: string;
|
||||
extension: string;
|
||||
activity: IActivity;
|
||||
}
|
|
@ -25,9 +25,24 @@ export interface ICell {
|
|||
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 {
|
||||
public static getValueFromResults(key: string, results: ICell[]): string {
|
||||
let value: string = '';
|
||||
let value: string = undefined;
|
||||
|
||||
if (results && results.length > 0 && key) {
|
||||
for (let i: number = 0; i < results.length; i++) {
|
||||
|
@ -41,4 +56,29 @@ export class SearchUtils {
|
|||
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,4 +2,13 @@ export class Utils {
|
|||
public static getUserPhotoUrl(userEmail: string, siteUrl: string, size: string = 'S'): string {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export interface IMyRecentDocumentsWebPartProps {
|
||||
numberOfDocuments: number;
|
||||
title: string;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}]
|
||||
}
|
|
@ -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
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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)"
|
||||
}
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
declare interface IMyRecentDocumentsStrings {
|
||||
PropertyPaneDescription: string;
|
||||
ViewGroupName: string;
|
||||
NumberOfDocumentsFieldLabel: string;
|
||||
TitleFieldLabel: string;
|
||||
}
|
||||
|
||||
declare module 'myRecentDocumentsStrings' {
|
||||
const strings: IMyRecentDocumentsStrings;
|
||||
export = strings;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import * as assert from 'assert';
|
||||
|
||||
describe('MyRecentDocumentsWebPart', () => {
|
||||
it('should do something', () => {
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue