React Comparer Web Part (#768)

* Initial version

* Added more property pane fields

* Fixed recent tab style

* Update RecentFilesTab.module.scss

* Update RecentFilesTab.tsx

* Fixed an issue with unitialized PnP  causing wrong API url

* Update package-solution.json

* Cleaned up code and comments.

* Update FileBrowser.tsx

* Update FileBrowser.types.ts

* Update DocumentLibraryBrowser.tsx

* Update RecentFilesTab.tsx

* Added OneDrive support

* Fixed a little speelliing mistake
This commit is contained in:
Hugo Bernier 2019-02-09 05:19:31 -05:00 committed by Vesa Juvonen
parent f6f14d0903
commit f583732935
93 changed files with 25559 additions and 1 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

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

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

View File

@ -0,0 +1,11 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.7.1",
"libraryName": "react-comparer",
"libraryId": "8bc49dd2-60c6-46dc-989b-43a53f055bd0",
"packageManager": "npm",
"componentType": "webpart"
}
}

View File

@ -0,0 +1,179 @@
# react-comparer
![The web part in action](./assets/ComparerWebPart.gif)
## Summary
Allows users to compare Before and After pictures, with a draggable slider. Implements a custom file picker.
## Background
The Microsoft UI Fabric team (formerly Office UI Fabric) recently released a [web site](https://fluentfabric.azurewebsites.net/#/components) that allows you to compare the current version of the Office UI Fabric components side by side with their upcoming Fluent versions, using a cool image slider.
![Microsoft Ui Fabric slider in action](./assets/FluentSlider.gif)
I wanted to re-create a version of the slider that would allow a user to compare two images, side by side.
Re-creating the slider was pretty easy (they use [react-draggable](https://github.com/mzabriskie/react-draggable) to handle dragging); I wanted to make it possible for users to select the
Before and After pictures to use the SharePoint File Picker dialog.
After a lot of digging around, I decided to re-create my own.
After all, one of the premises of SPFx is that SPFx gives us -- the SharePoint developer community -- a framework to build the same web parts that Microsoft's own developers do. Right?
This sample re-creates the SharePoint file picker (or, more accurately, the SharePoint image picker) that is used by out-of-the-box web parts such as the Image and Hero web parts. My goal was to create something that would be re-usable,
and would be as close as possible from the out-of-the-box picker. In fact, as I was testing the app, I found myself testing the out-of-the-box picker when I thought I was testing my own file picker.
After building this sample, I can confirm that it _is_ possible to build web parts that look and feel like the out-of-the-box web parts using SPFx.
The file picker includes the following tabs:
### Recent
![Recent tab](./assets/RecentTab.gif)
### Web search
![Web search tab](./assets/WebSearchTab.gif)
> NOTE: Requires a Bing API key, see below
### OneDrive
![OneDrive tab](./assets/OneDriveTab.gif)
### Site
![Site tab](./assets/SitesTab.gif)
### Upload
![Upload tab](./assets/UploadTab.gif)
### From a link
![From a link tab](./assets/FromLinkTab.gif)
## Used SharePoint Framework Version
![SPFx v1.7.1](https://img.shields.io/badge/SPFx-1.7.1-green.svg)
## Applies to
* [SharePoint Framework](https:/dev.office.com/sharepoint)
* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
## Solution
Solution|Author(s)
--------|---------
react-comparer | Hugo Bernier ([Tahoe Ninjas](http://tahoeninjas.blog), @bernierh)
## Version history
Version|Date|Comments
-------|----|--------
1.0|January 27, 2019|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 i`
* `gulp serve --nobrowser`
* In the web browser navigate to the hosted version of the SharePoint workbench located in the same site as where the Tasks list is, eg. *https://contoso.sharepoint.com/sites/team/_layouts/15/workbench.aspx*.
## Optional Configuration - Bing Search
The sample `PropertyPanelFilePicker` is designed to mimic the standard SharePoint file picker dialog. As such, it includes a **Web search** which uses Bing to return search results.
![Web search with a Bing API key](./assets/WebSearch.gif)
However, the feature requires a Bing API key in order to work. If you do not have a Bing API key, the `PropertyPanelFilePicker` will display a message indicating that the Bing API key is missing.
![Web search without a Bing API key](./assets/WebSearchNoAPI.png)
### To Configure a Bing API key (Optional)
This sample uses **SharePoint Online Tenant Properties** to store the API key. The idea is that if you get an
API key for your organization to use, you want to store it in one place -- the **Tenant Properties** -- instead of having to re-enter the API key
in every web part that needs it.
To configure your API key, use the following steps:
1. If you don't already have a Bing Web Search API key, go to the [Bing Web Search](https://azure.microsoft.com/en-us/services/cognitive-services/bing-web-search-api/) API page and request a key.
2. To set the API key, you'll need to use the **Office 365 CLI**. If you haven't installed it yet, you can get it [from here](https://pnp.github.io/office365-cli/?utm_campaign=Use+SharePoint+Online+tenant+properties&utm_medium=page&utm_source=msft_docs).
3. Using **Office 365 CLI**, enter the following command, making sure to change **value** with your actual Bing API key, and **appCatalogUrl** with your tenant's app catalog (e.g.: https://contoso.sharepoint.com/sites/apps).
```PowerShell
spo storageentity set --appCatalogUrl <appCatalogUrl> --key BingApi --value <value>
```
4. If you want to verify that your API key is stored, use the following command to list all your tenant properties:
```PowerShell
spo storageentity list --appCatalogUrl <appCatalogUrl>
```
If all goes well, you should see an entry called **BingApi** containing your Bing API key.
### To hide the Web search tab
If you want to use this web part without the **Web search** tab, you can simply disable it from the code. To do so, use the following steps:
1. In the solution, open the **ComparerWebPart.ts** file.
2. Look for a line at the top of the file that looks like this:
```TypeScript
const DISABLE_WEB_SEARCH_TAB: boolean = false;
```
3. Change the `false` to `true`
4. Save and rebuild/redeploy the web part.
This constant will be applied to both `PropertyPaneFilePicker` controls used in the web part.
You can also simply set each `PropertyPaneFilePicker`'s `disableWebSearchTab` property to `true`.
## Features
* Using Tenant property
* Using of Office UI Fabric React Selection control
* Using of Office UI Fabric React Selection control
* Making CORS calls within an SPFx component
### Resources
* [Fluent for Fabric](https://fluentfabric.azurewebsites.net/#/components)
* [Build custom controls for the property pane](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/guidance/build-custom-property-pane-controls)
* [Office UI Fabric](https://dev.office.com/fabric)
* [Button](https://developer.microsoft.com/en-us/fabric#/components/button)
* [CommandBar](https://developer.microsoft.com/en-us/fabric#/components/commandbar)
* [DetailsList](https://developer.microsoft.com/en-us/fabric#/components/detailslist)
* [FocusZone](https://developer.microsoft.com/en-us/fabric#/components/focuszone)
* [Label](https://developer.microsoft.com/en-us/fabric#/components/label)
* [Link](https://developer.microsoft.com/en-us/fabric#/components/link)
* [List](https://developer.microsoft.com/en-us/fabric#/components/list)
* [MessageBar](https://developer.microsoft.com/en-us/fabric#/components/messagebar)
* [Nav](https://developer.microsoft.com/en-us/fabric#/components/nav)
* [Panel](https://developer.microsoft.com/en-us/fabric#/components/panel)
* [SearchBox](https://developer.microsoft.com/en-us/fabric#/components/searchbox)
* [Selection](https://developer.microsoft.com/en-us/fabric#/components/selection)
* [Spinner](https://developer.microsoft.com/en-us/fabric#/components/spinner)
* [TextField](https://developer.microsoft.com/en-us/fabric#/components/textfield)
* [SharePoint Online tenant properties](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/tenant-properties)
* [@pnp/spfx-controls-react](https://sharepoint.github.io/sp-dev-fx-controls-react/)
* [Placeholder control](https://sharepoint.github.io/sp-dev-fx-controls-react/controls/Placeholder/)
* [WebPartTitle](https://sharepoint.github.io/sp-dev-fx-controls-react/controls/WebPartTitle/)
* [@pnp/sp](https://pnp.github.io/pnpjs/sp/docs/)
* [React-draggable](https://github.com/mzabriskie/react-draggable)
* [React-block-image](https://github.com/transitive-bullshit/react-block-image)
* [StackExchange - SPFx calls blocked by CORS policy](https://sharepoint.stackexchange.com/questions/254050/spfx-calls-blocked-by-cors-policy)
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-comparer" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

View File

@ -0,0 +1,21 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"comparer-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/comparer/ComparerWebPart.js",
"manifest": "./src/webparts/comparer/ComparerWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"ComparerWebPartStrings": "lib/webparts/comparer/loc/{locale}.js",
"PropertyPaneFilePickerStrings": "lib/controls/propertypanefilepicker/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/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": "react-comparer",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,12 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "Comparer",
"id": "8bc49dd2-60c6-46dc-989b-43a53f055bd0",
"version": "1.0.0.0",
"includeClientSideAssets": true
},
"paths": {
"zippedPackage": "solution/comparer.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 -->"
}

7
samples/react-comparer/gulpfile.js vendored Normal file
View File

@ -0,0 +1,7 @@
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
build.initialize(gulp);

18054
samples/react-comparer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
{
"name": "react-comparer",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.7.1",
"@microsoft/sp-lodash-subset": "1.7.1",
"@microsoft/sp-office-ui-fabric-core": "1.7.1",
"@microsoft/sp-webpart-base": "1.7.1",
"@pnp/common": "^1.2.8",
"@pnp/logging": "^1.2.8",
"@pnp/odata": "^1.2.8",
"@pnp/sp": "^1.2.8",
"@pnp/spfx-controls-react": "^1.11.0",
"@pnp/spfx-property-controls": "^1.13.1",
"@types/es6-promise": "0.0.33",
"@types/react": "16.4.2",
"@types/react-dom": "16.0.5",
"@types/webpack-env": "1.13.1",
"react": "16.3.2",
"react-block-image": "^1.0.0",
"react-dom": "16.3.2",
"react-draggable": "^3.1.1"
},
"resolutions": {
"@types/react": "16.4.2"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.7.1",
"@microsoft/sp-tslint-rules": "1.7.1",
"@microsoft/sp-module-interfaces": "1.7.1",
"@microsoft/sp-webpart-workbench": "1.7.1",
"gulp": "~3.9.1",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2"
}
}

View File

@ -0,0 +1,28 @@
import * as strings from 'PropertyPaneFilePickerStrings';
/**
* Formats a file size in the right unit
* TODO: Move to a common library
*/
export function FormatBytes(bytes, decimals) {
if (bytes == 0) {
return strings.EmptyFileSize;
}
const k: number = 1024;
const dm = decimals <= 0 ? 0 : decimals || 2;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + strings.SizeUnit[i];
}
/**
* Gets the current domain url
*/
export function GetAbsoluteDomainUrl(url: string): string {
if (url !== undefined) {
const myURL = new URL(url.toLowerCase());
return myURL.protocol + "//" + myURL.host;
} else {
return undefined;
}
}

View File

@ -0,0 +1,55 @@
# PropertyPaneFilePicker
Mimics the out-the-box SharePoint file picker, but provides customizable features.
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased/TODO]
- Improve support for files (vs. images)
- OneDrive lists should have a chevron on headers
- Site file picker: list browsing should support tile view.
- Little sev 3 bug when selecting folders in OneDrive where it quickly marks the file in the same
ordinal position as the folder as selected before folder is loaded.
- Test for mobile devices (and fix issues)
- Test for accessibility
- Fix other inconsistencies
## [1.0.0] - 2019-01-27
### Added
- PropertyPaneFilePicker: control to allow browsing from a property pane
- LinkFilePickerTab: allows users to enter a file by URL. Option to allow only files within same domain.
- RecentFilesTab: allows users to select a file from their recent files in SharePoint.
- SiteFilePickerTab: allows users to find files from within the current site.
- UploadFilePickerTab: allows users to upload a file.
- WebSearch: allows users to search Bing for files.
- OneDriveTab: allows users to select files from their OneDrive.
- Tile doesn't show hover border in recent files
- Add ability to enter bing API and store in tenant store
- Add ratio selector
### Changed
- No changes
### Deprecated
- Nothing deprecated
### Removed
- Nothing removed
### Fixed
- Nothing fixed
### Security
- No vulnerabilities fixed

View File

@ -0,0 +1,10 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { ItemType } from "./IPropertyPaneFilePicker";
export interface IFilePickerTab {
itemType: ItemType;
context: WebPartContext;
accepts: string;
onSave: (value: string) => void;
onClose: () => void;
}

View File

@ -0,0 +1,25 @@
import { IPropertyPaneCustomFieldProps, WebPartContext } from "@microsoft/sp-webpart-base";
export interface IPropertyPaneFilePickerProps {
key: string;
label?: string;
buttonLabel: string;
value: string;
disabled?: boolean;
webPartContext: WebPartContext;
disableLocalUpload?: boolean;
disableWebSearchTab?: boolean;
disableCentralAssetRepo?: boolean; // not supported yet
hasMySiteTab?: boolean;
accepts?: string;
itemType: ItemType;
required?: boolean;
onSave:(value:string)=>void;
}
export interface IPropertyPaneFilePickerPropsInternal extends IPropertyPaneCustomFieldProps, IPropertyPaneFilePickerProps {}
export enum ItemType {
Documents,
Images
}

View File

@ -0,0 +1,11 @@
import { IPropertyPaneFilePickerProps } from '.';
export interface IPropertyPaneFilePickerHostProps extends IPropertyPaneFilePickerProps {
onChanged: (value: string) => void;
}
export interface IPropertyPaneFilePickerHostState {
showFullNav: boolean; // reserved for future use
panelOpen: boolean;
selectedTab: string;
}

View File

@ -0,0 +1,5 @@
@import "../PropertyPaneFilePicker.module.scss";
.linkTextField {
max-width: 640px;
}

View File

@ -0,0 +1,204 @@
import * as React from 'react';
// Custom styles
import styles from './LinkFilePickerTab.module.scss';
// Custom props and state
import { ILinkFilePickerTabProps, ILinkFilePickerTabState } from '.';
import { ItemType } from '../IPropertyPaneFilePicker';
// Office Fabric
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/components/Button';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
// PnP
import { FetchClient } from "@pnp/common";
// Localized strings
import * as strings from 'PropertyPaneFilePickerStrings';
import { GetAbsoluteDomainUrl } from '../../../CommonUtils';
export default class LinkFilePickerTab extends React.Component<ILinkFilePickerTabProps, ILinkFilePickerTabState> {
constructor(props: ILinkFilePickerTabProps) {
super(props);
this.state = { isValid: false };
}
public render(): React.ReactElement<ILinkFilePickerTabProps> {
const imageType: boolean = this.props.itemType === ItemType.Images;
return (
<div className={styles.tabContainer}>
<div className={styles.tabHeaderContainer}>
<h2 className={styles.tabHeader}>{strings.LinkHeader}</h2>
</div>
<div className={styles.tab}>
<TextField
multiline={true}
required={true}
resizable={false}
deferredValidationTime={300}
className={styles.linkTextField}
label={imageType ? strings.LinkImageInstructions : strings.LinkFileInstructions}
ariaLabel={imageType ? strings.LinkImageInstructions : strings.LinkFileInstructions}
defaultValue={"https://"}
onGetErrorMessage={(value: string) => this._getErrorMessagePromise(value)}
autoAdjustHeight={false}
underlined={false}
borderless={false}
validateOnFocusIn={false}
validateOnFocusOut={false}
validateOnLoad={true}
value={this.state.fileUrl}
onChanged={(_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => this._handleChange(newValue)}
/>
</div>
<div className={styles.actionButtonsContainer}>
<div className={styles.actionButtons}>
<PrimaryButton
disabled={!this.state.isValid}
onClick={() => this._handleSave()} className={styles.actionButton}>{strings.OpenButtonLabel}</PrimaryButton>
<DefaultButton onClick={() => this._handleClose()} className={styles.actionButton}>{strings.CancelButtonLabel}</DefaultButton>
</div>
</div>
</div>
);
}
/**
* Called as user types in a new value
*/
private _handleChange = (newValue?: string) => {
this.setState({
fileUrl: newValue
});
}
/**
* Verifies the url that was typed in
* @param value
*/
private _getErrorMessagePromise(value: string): Promise<string> {
return new Promise(resolve => {
// DOn't give an error for blank or placeholder value, but don't make it a valid entry either
if (value === undefined || value === 'https://') {
this.setState({ isValid: false });
resolve('');
return;
}
// Make sure that user is typing a valid URL format
if (!this._isUrl(value)) {
this.setState({ isValid: false });
resolve('');
return;
}
// If we don't allow external links, verify that we're in the same domain
if (!this.props.allowExternalTenantLinks && !this._isSameDomain(value)) {
this.setState({ isValid: false });
resolve(strings.NoExternalLinksValidationMessage);
return;
}
// Make sure that item is an image
if (this.props.itemType === ItemType.Images) {
if (!this._isImage(value)) {
this.setState({ isValid: false });
resolve(strings.NoImageValidationMessage);
return;
}
}
// Verify the file exists by actually getting the item
try {
const client = new FetchClient();
client.fetch(value, { method: "HEAD" }).then((response) => {
if (!response.ok) {
this.setState({ isValid: false });
resolve(strings.CantValidateValidationMessage);
return;
}
// the file exists
this.setState({ isValid: true });
resolve('');
}, () => {
this.setState({ isValid: false });
resolve(strings.CantValidateValidationMessage);
}).catch(() => {
this.setState({ isValid: false });
resolve(strings.CantValidateValidationMessage);
});
} catch (error) {
console.log("Error verifying file", error);
this.setState({ isValid: false });
resolve(strings.CantValidateValidationMessage);
}
});
}
/**
* Handles when user saves
*/
private _handleSave = () => {
this.props.onSave(encodeURI(this.state.fileUrl));
}
/**
* HAndles when user closes without saving
*/
private _handleClose = () => {
this.props.onClose();
}
/**
* Is this a URL ?
* (insert guy holding a butterfly meme)
*/
private _isUrl = (fileUrl: string): boolean => {
try {
const myURL = new URL(fileUrl.toLowerCase());
return myURL.host !== undefined;
} catch (error) {
return false;
}
}
/**
* Verifies that file ends with an image extension.
* Should really check the content type instead.
*/
private _isImage = (fileName: string): boolean => {
const acceptableExtensions: string[] = this.props.accepts.toLowerCase().split(",");
// ".gif,.jpg,.jpeg,.bmp,.dib,.tif,.tiff,.ico,.png,.jxr,.svg"
const thisExtension: string = this._getFileExtension(fileName);
return acceptableExtensions.indexOf(thisExtension) > -1;
}
/**
* Inspired from the code in PnP controls
*/
private _getFileExtension = (fileName): string => {
// Split the URL on the dots
const splitFileName = fileName.toLowerCase().split('.');
// Take the last value
let extensionValue = splitFileName.pop();
// Check if there are query string params in place
if (extensionValue.indexOf('?') !== -1) {
// Split the string on the question mark and return the first part
const querySplit = extensionValue.split('?');
extensionValue = querySplit[0];
}
return `.${extensionValue}`;
}
private _isSameDomain = (fileUrl: string): boolean => {
const siteUrl: string = this.props.context.pageContext.web.absoluteUrl;
return GetAbsoluteDomainUrl(siteUrl) === GetAbsoluteDomainUrl(fileUrl);
}
}

View File

@ -0,0 +1,10 @@
import { IFilePickerTab } from "../IFilePickerTab.types";
export interface ILinkFilePickerTabProps extends IFilePickerTab {
allowExternalTenantLinks: boolean;
}
export interface ILinkFilePickerTabState {
fileUrl?: string;
isValid: boolean;
}

View File

@ -0,0 +1,2 @@
export * from './LinkFilePickerTab';
export * from './LinkFilePickerTab.types';

View File

@ -0,0 +1,116 @@
@import "../OneDriveTab.module.scss";
.odItemTile2Image {
display: inline-block;
}
.odImageFrame2 {
display: block;
position: relative;
overflow: hidden;
}
.odImageFrame2Image {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.odImageFrame {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.odImageStack {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow: hidden;
}
.odImageStackTile {
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
//opacity: 0;
}
.odImageTile {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.odImageTile {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.odImageTileBackground {
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
margin: auto;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.odImageTileImage {
display: block;
opacity: 1;
position: absolute;
box-sizing: border-box;
}
.odImageTileImage, a .odImageTileImage {
border: none;
}
.odImageTile img {
display: block;
}
.odItemTile2SmallIcon {
position: absolute;
bottom: 0;
left: 0;
margin: 6px;
}
.fileTypeIcon {
position: relative;
display: inline-block;
}
.fileTypeIconIcon {
display: inline-block;
vertical-align: middle;
position: relative;
overflow: hidden;
}

View File

@ -0,0 +1,140 @@
import * as React from 'react';
import styles from './DocumentTile.module.scss';
import { IOneDriveFile } from '../OneDriveTab.types';
import { css, IRenderFunction, IRectangle } from 'office-ui-fabric-react/lib/Utilities';
import { Image, IImageProps, ImageFit } from 'office-ui-fabric-react/lib/Image';
import * as strings from 'PropertyPaneFilePickerStrings';
import { Check } from 'office-ui-fabric-react/lib/Check';
import { ScreenWidthMinLarge } from 'office-ui-fabric-react/lib/Styling';
import { IDimensions } from '../../../../services/OneDriveServices';
const MAX_ASPECT_RATIO = 3;
/**
*
*/
export interface IDocumentTileProps {
item: IOneDriveFile;
index: number;
isSelected: boolean;
pageWidth: number;
tileDimensions: IDimensions;
onItemInvoked: (item: IOneDriveFile) => void;
}
export interface IDocumentTileState { }
export class DocumentTile extends React.Component<IDocumentTileProps, IDocumentTileState> {
public render(): React.ReactElement<IDocumentTileProps> {
const { isSelected, index, item, pageWidth, tileDimensions } = this.props;
const isLarge: boolean = pageWidth >= ScreenWidthMinLarge;
// Find the dimensions that are biggest
let thumbnailWidth: number = tileDimensions.width;
let thumbnailHeight: number = tileDimensions.height;
if (item.dimensions) {
const contentAspectRatio = item.dimensions.width / item.dimensions.height;
const boundsAspectRatio = tileDimensions.width / tileDimensions.height;
let scale: number;
if (contentAspectRatio > boundsAspectRatio) {
scale = tileDimensions.width / item.dimensions.width;
} else {
scale = tileDimensions.height / item.dimensions.height;
}
const finalScale = Math.min(MAX_ASPECT_RATIO, scale);
thumbnailWidth = item.dimensions.width * finalScale;
thumbnailHeight = item.dimensions.height * finalScale;
}
const thumbnail: string = item.thumbnail + `&width=${thumbnailWidth}&height=${thumbnailHeight}`;
const ariaLabel: string = strings.ImageAriaLabelTemplate.replace('{0}', item.fileIcon);
const itemLabel: string = strings.DocumentLabelTemplate
.replace('{0}', item.name)
.replace('{1}', item.modified)
.replace('{2}', item.modifiedBy);
return (
<div
aria-selected={isSelected}
data-is-draggable={false}
role="listitem"
aria-labelledby={`Tile-label${index}`}
aria-describedby={`Tile-activity${index}`}
className={css(styles.tile, isLarge ? styles.isLarge : styles.isSmall, styles.invokable, styles.selectable, isSelected ? styles.selected : undefined)}
data-is-focusable={true}
data-is-sub-focuszone={true}
data-disable-click-on-enter={true}
data-selection-index={index}
//data-selection-invoke={true}
onClick={(_event)=>this.props.onItemInvoked(item)}
>
<div
className={styles.link}
role="link"
//onClick={(_event) => this.props.onItemInvoked(item)}
//data-selection-invoke={true}
>
<span
id={`Tile-label${index}`}
className={styles.label}>{itemLabel}</span>
<span role="presentation" className={styles.aboveNameplate}>
<span role="presentation" className={styles.content}>
<span role="presentation" className={styles.foreground}>
<span className={styles.odItemTile2Image}>
<span className={styles.odImageFrame2} style={{ width: thumbnailWidth, height: thumbnailHeight }}>
<span className={styles.odImageFrame2Image}>
<span className={styles.odImageFrame}>
<span className={styles.odImageStack}>
<span className={styles.odImageStackTile}>
<span className={styles.odImageTile}>
<span className={styles.odImageTileBackground}>
<Image
src={thumbnail}
width={thumbnailWidth}
height={thumbnailHeight}
imageFit={ImageFit.contain}
/>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
<span className={styles.odItemTile2SmallIcon} >
<div className={styles.fileTypeIcon}
aria-label={ariaLabel}
title={ariaLabel}>
<img className={styles.fileTypeIconIcon} src={strings.ODPhotoIconUrl} style={{ width: 16, height: 16 }} />
</div>
</span>
</span>
</span>
<span className={styles.namePlate}>
<span className={styles.name}>
<span className={css(styles.signalField, styles.compact)}>
<span className={styles.signalFieldValue}>{item.name}</span>
</span>
</span>
<span className={styles.activity} id={`Tile-activity${index}`}>
<span className={css(styles.signalField, styles.compact)}>
<span className={styles.signalFieldValue}>{item.modified}</span>
</span>
</span>
</span>
</div>
<span role="checkbox" className={styles.check} data-selection-toggle={true} aria-checked={isSelected}>
<Check checked={isSelected} />
</span>
</div>
);
}
}

View File

@ -0,0 +1 @@
export * from './DocumentTile';

View File

@ -0,0 +1,106 @@
@import "../OneDriveTab.module.scss";
.folderCover {
display: inline-block;
margin: 3px;
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
position: relative;
vertical-align: bottom;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: end;
-ms-flex-align: end;
align-items: flex-end;
}
.folderCover.isSmall_88d29bd4 {
width: 72px;
height: 52px;
}
.folderCover.isLarge {
width: 112px;
height: 80px;
}
.folderCoverBack {
display: block;
position: absolute;
left: -3px;
right: -3px;
bottom: -4px;
img {
border: none;
padding: 0;
margin: 0;
}
}
.folderCoverFront {
display: block;
position: absolute;
left: -3px;
right: -3px;
bottom: -4px;
img {
border: none;
padding: 0;
margin: 0;
}
}
.folderCoverFrame {
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
position: relative;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
overflow: hidden;
max-width: 100%;
max-height: 100%;
-webkit-box-shadow: 0 1px 3px 2px rgba(1, 1, 0, 0.2);
box-shadow: 0 1px 3px 2px rgba(1, 1, 0, 0.2);
background-color: $ms-color-white;
min-width: 32px;
min-height: 32px;
}
.folderCoverContent {
position: absolute;
left: 4px;
right: 4px;
bottom: 4px;
top: 4px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
opacity: 1;
-webkit-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}

View File

@ -0,0 +1,105 @@
import * as React from 'react';
import styles from './FolderTile.module.scss';
import { IOneDriveFile } from '../OneDriveTab.types';
import { css, IRenderFunction, IRectangle } from 'office-ui-fabric-react/lib/Utilities';
import { Icon, IconType } from 'office-ui-fabric-react/lib/Icon';
import * as strings from 'PropertyPaneFilePickerStrings';
import { ScreenWidthMinLarge } from 'office-ui-fabric-react/lib/Styling';
import { IDimensions } from '../../../../services/OneDriveServices';
export interface IFolderTileProps {
item: IOneDriveFile;
index: number;
isSelected: boolean;
pageWidth: number;
onItemInvoked: (item: IOneDriveFile) => void;
tileDimensions: IDimensions;
}
export interface IFolderTileState { }
/**
*
*/
export class FolderTile extends React.Component<IFolderTileProps, IFolderTileState> {
public render(): React.ReactElement<IFolderTileProps> {
const { isSelected, index, item, pageWidth } = this.props;
const isLarge: boolean = pageWidth >= ScreenWidthMinLarge;
//{item.name}, Folder, Modified {item.modified}, edited by {item.modifiedBy}, {item.totalFileCount} items, Private
const itemLabel: string = strings.FolderLabelTemplate
.replace('{0}', item.name)
.replace('{1}', item.modified)
.replace('{2}', item.modifiedBy)
.replace('{3}', `${item.totalFileCount}`);
return (
<div
aria-selected={isSelected}
data-is-draggable={false}
role="listitem"
aria-labelledby={`Tile-label${index}`}
aria-describedby={`Tile-activity${index}`}
className={css(styles.tile, isLarge ? styles.isLarge : styles.isSmall, styles.invokable, isSelected ? styles.selected : undefined)}
data-is-focusable={true}
data-is-sub-focuszone={true}
data-disable-click-on-enter={true}
data-selection-index={index}
onClick={(_event)=>this.props.onItemInvoked(item)}
>
<div
className={styles.link}
role="link"
>
<span
id={`Tile-label${index}`}
className={styles.label}>{itemLabel}</span>
<span role="presentation" className={styles.aboveNameplate}>
<span role="presentation" className={styles.content}>
<span role="presentation" className={styles.foreground}>
<span className={styles.odItemTile2FolderCover}>
<div
className={css(styles.folderCover, styles.isLarge)}>
<Icon
className={styles.folderCoverBack}
iconType={IconType.image}
imageProps={{
src: strings.FolderBackPlate
}} />
{item.totalFileCount > 0 &&
<span className={styles.folderCoverContent}>
<span className={styles.folderCoverFrame}>
<span className={styles.itemTileBlankCover} style={{ width: 104, height: 72 }}>
</span>
</span>
</span>
}
<Icon
className={styles.folderCoverFront}
iconType={IconType.image}
imageProps={{
src: strings.FolderFrontPlate
}} />
{item.totalFileCount > 0 &&
<span className={styles.metadata}>{item.totalFileCount}</span>
}
</div>
</span>
</span>
</span>
</span>
<span className={styles.namePlate}>
<span className={styles.name}>
<span className={css(styles.signalField, styles.compact)}>
<span className={styles.signalFieldValue}>{item.name}</span>
</span>
</span>
<span className={styles.activity} id={`Tile-activity${index}`}>
<span className={css(styles.signalField, styles.compact)}>
<span className={styles.signalFieldValue}>{item.modified}</span>
</span>
</span>
</span>
</div>
</div>
);
}
}

View File

@ -0,0 +1 @@
export * from './FolderTile';

View File

@ -0,0 +1,562 @@
@import "../PropertyPaneFilePicker.module.scss";
.aboveNameplate {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
position: relative;
margin: 16px 16px 0 16px;
}
.tileContent {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.foreground {
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
position: relative;
overflow: hidden;
max-width: 100%;
max-height: 100%;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
opacity: 1;
-webkit-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
&:after {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
pointer-events: none;
border: 2px solid #ffffff;
}
}
.grid {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-ms-flex-flow: row wrap;
flex-flow: row wrap;
-webkit-box-align: start;
-ms-flex-align: start;
align-items: flex-start;
-webkit-box-pack: start;
-ms-flex-pack: start;
justify-content: flex-start;
-ms-flex-line-pack: start;
align-content: flex-start;
}
.cellContent {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.content {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.tile {
outline: transparent;
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 0;
background-color: $ms-color-white;
-webkit-transition: -webkit-transform 0.1s linear;
transition: -webkit-transform 0.1s linear;
transition: transform 0.1s linear;
transition: transform 0.1s linear, -webkit-transform 0.1s linear;
color: $ms-color-neutralPrimary;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&.invokable {
.foreground {
pointer-events: auto;
cursor: pointer;
}
&:active {
-webkit-transform: scale(0.95);
transform: scale(0.95);
}
}
}
.namePlate {
position: absolute;
bottom: 0;
left: 0;
right: 0;
min-height: 20px;
padding: 4px 8px 8px;
background-color: $ms-color-white;
color: $ms-color-neutralPrimary;
white-space: nowrap;
text-overflow: ellipsis;
opacity: .95;
//border-top: 1px solid #eaeaea;
min-height: 35px;
}
.nameplate .name {
pointer-events: auto;
cursor: pointer;
}
html[dir="ltr"] .name {
padding-left: 8px;
}
html[dir="ltr"] .name {
margin-left: -8px;
}
.nameplate .name {
-webkit-transition: color 0.2s linear;
transition: color 0.2s linear;
}
.name {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
font-weight: 400;
color: $ms-color-neutralPrimary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.name,
.isLarge .name {
font-size: 14px;
height: 30px;
margin-top: -7px;
margin-bottom: -8px;
}
.tile:hover .name {
text-decoration: underline;
}
html[dir="ltr"] .activity {
padding-left: 8px;
}
html[dir="ltr"] .activity {
margin-left: -8px;
}
.signalField,
.signalField.compact {
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
}
.signalField {
max-width: 100%;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row nowrap;
flex-direction: row nowrap;
}
.signalField > span[class*="signalFieldValue"] {
-ms-flex: 1 1 auto !important;
flex: 1 1 auto !important;
}
.signalFieldValue {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-align: left;
-webkit-box-flex: 1;
-ms-flex: 1 1 auto;
flex: 1 1 auto;
}
.activity {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
font-weight: 400;
font-size: 12px;
color: #767676;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
height: 24px;
margin-bottom: -4px;
}
.folderList :global(.ms-List-cell) {
display: table-cell;
}
// .folderList :global(.ms-List-page) {
// display: -webkit-box;
// display: -ms-flexbox;
// display: flex;
// -webkit-box-orient: horizontal;
// -webkit-box-direction: normal;
// -ms-flex-direction: row;
// flex-direction: row;
// flex-wrap: wrap;
// align-content: flex-start;
// align-items: flex-start;
// justify-content: flex-start;
// margin-bottom: 36px;
// margin-left: -4px;
// margin-right: -4px;
// margin-top: -4px;
// }
.folderList :global(.ms-List-surface) {
position: relative;
}
.link {
outline: transparent;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
text-decoration: none;
color: inherit;
overflow: hidden;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: stretch;
-ms-flex-align: stretch;
align-items: stretch;
-ms-flex-line-pack: stretch;
align-content: stretch;
text-decoration: none;
pointer-events: none;
}
.cell {
padding-top: 140px;
position: relative;
}
.listCell {
// text-align: center;
// outline: none;
// position: relative;
// max-width: 176px;
// max-height: 171px;
// margin: 4px;
// border-style: none;
// border-width: 0px;
// width: 176px;
display: block;
margin: 0;
border-color: #ffffff;
border-top-color: rgb(255, 255, 255);
border-right-color: rgb(255, 255, 255);
border-bottom-color: rgb(255, 255, 255);
border-left-color: rgb(255, 255, 255);
}
.label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.foreground {
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
position: relative;
overflow: hidden;
max-width: 100%;
max-height: 100%;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
opacity: 1;
-webkit-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
// .hasForegroundFrame .foreground {
// background-color: #ffffff;
// -webkit-box-shadow: 0 1px 3px 1px rgba(1, 1, 0, 0.05);
// box-shadow: 0 1px 3px 1px rgba(1, 1, 0, 0.05);
// min-width: 32px;
// min-height: 32px;
// }
// .hasForegroundFrame .foreground:after {
// content: '';
// display: block;
// position: absolute;
// top: 0;
// left: 0;
// bottom: 0;
// right: 0;
// pointer-events: none;
// border: 2px solid #ffffff;
// }
.check {
//display: none;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-align: end;
-ms-flex-align: end;
align-items: flex-end;
-webkit-box-pack: start;
-ms-flex-pack: start;
justify-content: flex-start;
top: 0;
border: none;
background: none;
background-color: transparent;
opacity: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
padding: 6px;
outline: transparent;
position: absolute;
:global {
.ms-Check .ms-Check-check,
.ms-Check .ms-Check-circle {
color: #797775;
}
}
&:hover {
color: #797775;
}
}
html[dir="ltr"] .check {
right: 0;
}
.tile.selectable .check {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
.tile.selectable:hover {
background-color: $ms-color-neutralLighter;
}
.tile:hover .check,
.tile.showCheck .check {
opacity: 1;
:global {
.ms-Check .ms-Check-check,
.ms-Check .ms-Check-circle {
color: $ms-color-neutralPrimary;
}
}
}
.tile.selected .check {
opacity: 1;
:global {
.ms-Check .ms-Check-check,
.ms-Check .ms-Check-circle {
color: $ms-color-white;
}
}
}
.dialogMainOverride {
:global {
.ms-Dialog-title {
color: $ms-color-themePrimary;
}
.ms-Dialog-subText {
font-size: 14px;
color: $ms-color-neutralPrimary;
font-weight: 400;
}
}
}
.itemTileBlankCover {
background: $ms-color-white;
display: inline-block;
}
html[dir="ltr"] .metadata {
text-align: right;
}
html[dir="ltr"] .metadata {
margin-right: 8px;
}
.isLarge .metadata {
font-size: 14px;
margin-bottom: 3px;
}
.metadata,
.metadata.isSmall {
font-size: 12px;
margin-bottom: 3px;
}
.metadata {
display: block;
-webkit-box-flex: 1;
-ms-flex: 1 1;
flex: 1 1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-weight: 400;
color: #ffffff;
position: relative;
}
.listPage {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.odItemTile2FolderCover {
display: inline-block;
margin: 3px;
}
.standaloneListBreadcrumb {
// font-size: 17px;
// font-weight: 300;
// }
// .xlg .standaloneListBreadcrumb, .xxlg .standaloneListBreadcrumb, .xxxlg .standaloneListBreadcrumb, .xxxxlg .standaloneListBreadcrumb {
font-size: 21px;
font-weight: 100;
margin-bottom: 1px;
padding-top:0;
padding-bottom:0;
padding-left: 32px;
padding-right: 32px;
}

View File

@ -0,0 +1,963 @@
import * as React from 'react';
// Custom styles
import styles from './OneDriveTab.module.scss';
// Office Fabric
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/components/Button';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import { FocusZone } from 'office-ui-fabric-react/lib/FocusZone';
import { List, IPageProps } from 'office-ui-fabric-react/lib/List';
import { css, IRenderFunction, IRectangle } from 'office-ui-fabric-react/lib/Utilities';
import { SelectionZone } from 'office-ui-fabric-react/lib/Selection';
import { Breadcrumb, IBreadcrumbItem } from 'office-ui-fabric-react/lib/Breadcrumb';
import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar';
import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu';
import {
DetailsList,
DetailsListLayoutMode,
Selection,
SelectionMode,
IColumn,
IDetailsRowProps,
DetailsRow
} from 'office-ui-fabric-react/lib/DetailsList';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
// Used to read JSON string as JSON
import { unescape } from '@microsoft/sp-lodash-subset';
// Custom props and states
import { IOneDriveTabProps, IOneDriveTabState, IOneDriveFile, ViewType } from './OneDriveTab.types';
// Localized resources
import * as strings from 'PropertyPaneFilePickerStrings';
// Used to get OneDrive data
import { IGetListDataAsStreamResult, IRow, IParentFolderInfo } from '../../../services/OneDriveServices/IGetListDataAsStreamResult';
import { OneDriveServices, IDimensions } from '../../../services/OneDriveServices';
// Used to render custom tiles
import { FolderTile } from './FolderTile/FolderTile';
import { DocumentTile } from './DocumentTile/DocumentTile';
import { FormatBytes, GetAbsoluteDomainUrl } from '../../../CommonUtils';
/**
* Rows per page
*/
const ROWS_PER_PAGE: number = 3;
/**
* Maximum row height
*/
const MAX_ROW_HEIGHT: number = 250;
/**
* Maximum number of cells per page
*/
const CELLS_PER_PAGE: number = 100;
/**
* Standard tile margin
*/
const STANDARD_TILE_MARGIN: number = 4;
/**
* Standard left and right padding
*/
const TILE_HORZ_PADDING: number = 32;
/**
* Standard bottom margin
*/
const BOTTOM_MARGIN: number = 36;
const LAYOUT_STORAGE_KEY: string = 'comparerOneDriveLayout';
/**
* This tab uses a different approach than the SiteFilePickerTab because,
* unlike it, all requests are made to a separate site collection and separate domain.
* Unfortuntely, we couldn't use the PnP libraries to make the API calls
* and had to use RemoteWeb to allow retrieving content across domains.
*
*/
export default class OneDriveTab extends React.Component<IOneDriveTabProps, IOneDriveTabState> {
private _columnCount: number;
private _columnWidth: number;
private _rowHeight: number;
private _selection: Selection;
private _listElem: List = undefined;
private _pageWidth: number;
private _oneDriveService: OneDriveServices = undefined;
constructor(props: IOneDriveTabProps) {
super(props);
// If possible, load the user's favourite layout
const lastLayout: ViewType = localStorage ?
localStorage.getItem(LAYOUT_STORAGE_KEY) as ViewType
: 'tiles' as ViewType;
// Set the columns we'll use for the list views
const columns: IColumn[] = [
{
key: 'colIcon',
name: 'Type',
ariaLabel: strings.TypeAriaLabel,
iconName: 'Page',
isIconOnly: true,
fieldName: 'docIcon',
headerClassName: styles.iconColumnHeader,
minWidth: 16,
maxWidth: 16,
onColumnClick: this._onColumnClick,
onRender: (item: IOneDriveFile) => {
// Get the icon
const folderIcon: string = strings.FolderIconUrl;
const iconUrl: string = strings.PhotoIconUrl;
// Insert file type into localized string
const altText: string = item.isFolder ? strings.FolderAltText : strings.ImageAltText.replace('{0}', item.fileType);
//TODO: This will have to change if we add support for non-image fiels
return <div className={styles.fileTypeIcon}>
<img src={item.isFolder ? folderIcon : iconUrl} className={styles.fileTypeIconIcon} alt={altText} title={altText} />
</div>;
}
},
{
key: 'colName',
name: strings.NameField,
fieldName: 'fileLeafRef',
minWidth: 200,
isRowHeader: true,
isResizable: true,
isSorted: true,
isSortedDescending: false,
sortAscendingAriaLabel: strings.SortedAscending,
sortDescendingAriaLabel: strings.SortedDescending,
onColumnClick: this._onColumnClick,
data: 'string',
isPadded: true,
onRender: (item: IOneDriveFile) => {
// If this is a folder, browse to that folder
if (item.isFolder) {
return <span className={styles.folderItem} onClick={(_event) => this._handleOpenLibrary(item)}>{item.name}</span>;
} else {
// Just show the file name
return <span className={styles.fileItem}>{item.name}</span>;
}
},
},
{
key: 'colModified',
name: strings.ODModifiedField,
fieldName: 'modified',
minWidth: 120,
isResizable: true,
onColumnClick: this._onColumnClick,
data: 'number',
onRender: (item: IOneDriveFile) => {
//aria-label="Modified column, March 9, 2017"
const ariaLabel: string = strings.AriaCellValue.replace('{0}', strings.ODModifiedField).replace('{1}', item.modified);
// Modified date already returns localized and formatted
return <span aria-label={ariaLabel} >{item.modified}</span>;
},
isPadded: true
},
{
key: 'colEditor',
name: strings.ModifiedByField,
fieldName: 'modifiedBy',
minWidth: 100,
isResizable: true,
data: 'string',
onColumnClick: this._onColumnClick,
onRender: (item: IOneDriveFile) => {
return <span>{item.modifiedBy}</span>;
},
isPadded: true
},
{
key: 'colSize',
name: strings.FileSizeField,
fieldName: 'fileSizeDisplay',
minWidth: 100,
isResizable: true,
data: 'number',
onColumnClick: this._onColumnClick,
onRender: (item: IOneDriveFile) => {
// Format the size into a nice human-friendly format.
return <span>{item.fileSizeDisplay ? FormatBytes(item.fileSizeDisplay, 1) : undefined}</span>;
}
},
{
key: 'colShared',
name: strings.SharingField,
fieldName: 'principalCount',
minWidth: 100,
isResizable: true,
data: 'string',
onColumnClick: this._onColumnClick,
onRender: (item: IOneDriveFile) => {
// Can't find any references here, but I'm pretty sure that
// if there are no other principals, nobody else is allowed to see it
// but anything above zero means we shared with at least one person/group.
const cellValue: string = item.isShared ? strings.SharingShared : strings.SharingPrivate;
const ariaLabel: string = strings.AriaCellValue.replace('{0}', strings.SharingField).replace('{1}', cellValue);
return <span aria-label={ariaLabel}>
{item.isShared && <span>
<Icon iconName="People" />
<span>&nbsp;</span>
</span>}
{cellValue}
</span>;
}
}
];
this._selection = new Selection(
{
selectionMode: SelectionMode.single,
onSelectionChanged: () => {
// Get the selected item
const selectedItems = this._selection.getSelection();
if (selectedItems && selectedItems.length > 0) {
const selectedKey: IOneDriveFile = selectedItems[0] as IOneDriveFile;
if (!selectedKey.isFolder) {
// Save the selected file
this.setState({
fileUrl: selectedKey.absoluteUrl
});
}
} else {
// Remove any selected file
this.setState({
fileUrl: undefined
});
}
if (this._listElem) {
// Force the list to update to show the selection check
this._listElem.forceUpdate();
}
}
});
this.state = {
isLoading: true,
files: [],
hideDialog: true,
parentFolderInfo: [],
selectedView: lastLayout,
columns: columns
};
}
/**
* Gets the OneDrive files
*/
public componentDidMount(): void {
// Initialize the OneDrive services
this._oneDriveService = new OneDriveServices(this.props.context, this.props.accepts);
// Get the items at the root of the OneDrive folder
this._getListItems();
}
/**
* Handle if we change the relative folder we're browsing
*/
public componentDidUpdate(_prevProps: IOneDriveTabProps, prevState: IOneDriveTabState): void {
// Update the list of items if the folder changes
if (prevState.serverRelativeFolderUrl !== this.state.serverRelativeFolderUrl) {
this._getListItems();
}
}
/**
* Gets the list of items to display
*/
private _getListItems = (): Promise<void> => {
// We're loading!
this.setState({
isLoading: true
});
return this._oneDriveService.GetListDataAsStream(this.state.serverRelativeFolderUrl).then((listDataStream: IGetListDataAsStreamResult) => {
// Get the thumbnail URL template -- stored in the list schema
const thumbnailUrlTemplate: string = listDataStream.ListSchema[".thumbnailUrl"]
.replace("{.mediaBaseUrl}", listDataStream.ListSchema[".mediaBaseUrl"])
.replace("{.callerStack}", listDataStream.ListSchema[".callerStack"])
.replace("{.driveAccessToken}", "encodeFailures=1&ctag={.ctag}");
// Map every item to a OneDrive file
const files: IOneDriveFile[] = listDataStream.ListData.Row.map((item: IRow) => {
// Build the thumbnail URL from the template
// The template is stored in the schema (see above) and contains list-specific
// replacement tokens (which we already replaced above) and item-specific
// tokens, which we're replacing right. now.
const thumbnail: string = thumbnailUrlTemplate
.replace('{.spItemUrl}', item[".spItemUrl"])
.replace('{.ctag}', encodeURIComponent(item[".ctag"]))
.replace('{.fileType}', item[".fileType"]);
// Get the modified date
const modifiedParts: string[] = item["Modified.FriendlyDisplay"]!.split('|');
let modified: string = item.Modified;
// If there is a friendly modified date, use that
// The friendly dates seem to be a lot smarter than what I have here.
// For example, it seems to use a different structure for dates that are on the same
// day, within a few days, etc.
// For this example, we just handle the regular friendly-dates, but if we
// turn this into a PnP control, we'll want to handle all sorts of friendly dates
if (modifiedParts.length === 2) {
modified = modifiedParts[1];
}
// Parse media metadata to see if we can get known dimensions
// Dimensions are stored as HTML-encoded JSON from media services.
// If it is available, get the JSON structure and parse it.
const media: any = item.MediaServiceFastMetadata && JSON.parse(unescape(item.MediaServiceFastMetadata));
const dimensions: IDimensions = media && media.photo && {
width: media.photo.width,
height: media.photo.height
};
// Create a nice OneDriveFile interface so we're not saving all that extra metadata that
// gets returned from SharePoint in our state.
const file: IOneDriveFile = {
key: item.UniqueId,
name: item.FileLeafRef,
absoluteUrl: this._buildOneDriveAbsoluteUrl(listDataStream.HttpRoot, item.FileRef),
serverRelativeUrl: item.FileRef,
isFolder: item.FSObjType === "1",
modified: modified,
modifiedBy: item.Editor[0].title,
fileType: item.File_x0020_Type,
fileIcon: item["HTML_x0020_File_x0020_Type.File_x0020_Type.mapico"],
fileSizeDisplay: item.FileSizeDisplay,
totalFileCount: +item.SMTotalFileCount, // quickly converts string to number
thumbnail: thumbnail,
dimensions: dimensions,
isShared: parseInt(item.PrincipalCount) > 0
};
return file;
});
// Set the selection items so that we know what item we're selecting
this._selection.setItems(files);
// Store the files and stop the loading icon
this.setState({
files: files,
isLoading: false, // we're done loading
parentFolderInfo: listDataStream.ParentInfo.ParentFolderInfo // remember where we are
});
});
}
/**
* Renders the tab
*/
public render(): React.ReactElement<IOneDriveTabProps> {
const {
isLoading,
files,
selectedView,
fileUrl } = this.state;
return (
<div className={css(styles.tabContainer)}>
<div className={styles.tabHeaderContainer}>
{this._onRenderHeader()}
{this._onRenderCommandBar()}
</div>
<div className={styles.tab}>
{isLoading && <Spinner label={strings.Loading} />}
{!isLoading && files!.length > 0 &&
selectedView !== 'tiles' && this._renderListLayout()}
{!isLoading && files!.length > 0 &&
selectedView === 'tiles' && this._renderTileLayout()}
{!isLoading && files!.length < 1 &&
this._renderEmptyFolder()
}
</div>
<div className={styles.actionButtonsContainer}>
<div className={styles.actionButtons}>
<PrimaryButton
disabled={!fileUrl}
onClick={() => this._handleSaveConfirm()} className={styles.actionButton}>{strings.OpenButtonLabel}</PrimaryButton>
<DefaultButton onClick={() => this._handleClose()} className={styles.actionButton}>{strings.CancelButtonLabel}</DefaultButton>
</div>
</div>
{this._onRenderConfirmDialog()}
</div>
);
}
/**
* Gratuitous sorting
*/
private _onColumnClick = (event: React.MouseEvent<HTMLElement>, column: IColumn): void => {
const { columns } = this.state;
let { files } = this.state;
let isSortedDescending = column.isSortedDescending;
// If we've sorted this column, flip it.
if (column.isSorted) {
isSortedDescending = !isSortedDescending;
}
// Sort the items.
files = files!.concat([]).sort((a, b) => {
const firstValue = a[column.fieldName || ''];
const secondValue = b[column.fieldName || ''];
if (isSortedDescending) {
return firstValue > secondValue ? -1 : 1;
} else {
return firstValue > secondValue ? 1 : -1;
}
});
// Reset the items and columns to match the state.
this._selection.setItems(files);
this.setState({
files: files,
columns: columns!.map(col => {
col.isSorted = col.key === column.key;
if (col.isSorted) {
col.isSortedDescending = isSortedDescending;
}
return col;
})
});
}
/**
* Renders a tile
*/
private _renderTileLayout = (): JSX.Element => {
const { files } = this.state;
return (<SelectionZone selection={this._selection}
onItemInvoked={(item: IOneDriveFile) => this._handleItemInvoked(item)}
>
<FocusZone>
<List
ref={this._linkList}
className={styles.folderList}
items={files}
getItemCountForPage={this._getItemCountForPage}
getPageHeight={this._getPageHeight}
renderedWindowsAhead={4}
onRenderPage={(pageProps: IPageProps, defaultRender?: IRenderFunction<IPageProps>) => this._onRenderPage(pageProps, defaultRender)}
/>
</FocusZone>
</SelectionZone>);
}
/**
* Renders a list or a compact list
*/
private _renderListLayout = (): JSX.Element => {
const { files, selectedView, columns } = this.state;
return (
<DetailsList
items={files}
compact={selectedView === 'compact'}
columns={columns}
selectionMode={SelectionMode.single}
setKey="set"
layoutMode={DetailsListLayoutMode.justified}
isHeaderVisible={true}
selection={this._selection}
selectionPreservedOnEmptyClick={true}
onActiveItemChanged={(item: IOneDriveFile, index: number, ev: React.FormEvent<Element>) => this._itemChangedHandler(item, index, ev)}
enterModalSelectionOnTouch={true}
onRenderRow={this._onRenderRow}
/>
);
}
/**
* Renders a row in a detailed list
*/
private _onRenderRow = (props: IDetailsRowProps): JSX.Element => {
const fileItem: IOneDriveFile = props.item;
return <DetailsRow
getRowAriaLabel={(item: IOneDriveFile) => this._getRowAriaLabel(item)}
{...props}
className={fileItem.isFolder ?
styles.folderRow
: styles.fileRow} />;
}
/**
* Gets called on every row to provide an ARIA label describing the data in the row.
*/
private _getRowAriaLabel = (item: IOneDriveFile): string => {
//Attachments, Folder, Modified March 9, 2017, edited by Hugo Bernier, 0 items, Private
//me.png, .png Image, Modified December 12, 2017, edited by Hugo Bernier, 86.5 KB, Shared
//"{0}, {1}, Modified {2}, edited by {3}, {4}, {5}"
const fileType: string = item.isFolder ? strings.FolderAltText : strings.ImageAltText.replace('{0}', item.fileType);
const sharingValue: string = item.isShared ? strings.SharingShared : strings.SharingPrivate;
const ariaLabel: string = strings.ODRowArialLabelTemplate.replace('{0}', item.name)
.replace('{1}', fileType)
.replace('{2}', item.modified)
.replace('{3}', item.modifiedBy)
.replace('{4}', item.fileSizeDisplay)
.replace('{5}', sharingValue);
return ariaLabel;
}
/**
* Renders the command bar above the list/grid
*/
private _onRenderCommandBar = (): JSX.Element => {
return (<div className={styles.itemPickerTopBar}>
<CommandBar
items={this._getToolbarItems()}
farItems={this.getFarItems()}
/>
</div>);
}
/**
* Display an annoying pop-up saying you should make sure
* that you shared this file otherwise people won't see it
*/
private _onRenderConfirmDialog = (): JSX.Element => {
return (<Dialog
hidden={this.state.hideDialog}
dialogContentProps={{
type: DialogType.normal,
title: strings.OneDriveConfirmDialogTitle,
subText: strings.OneDriveConfirmDialogBody
}}
modalProps={{
isBlocking: true,
containerClassName: styles.dialogMainOverride
}}
>
<DialogFooter>
<PrimaryButton onClick={(_ev) => this._handleSave()} text={strings.Yes} />
<DefaultButton onClick={(_ev) => this._handleClose()} text={strings.No} />
</DialogFooter>
</Dialog>);
}
/**
* Render a heading or a breadcrumb (depending on how many levels deep we're in)
*/
private _onRenderHeader = (): JSX.Element => {
const { parentFolderInfo } = this.state;
// If we don't have parent info, or we're only 2 levels deep, don't render breadcrumbs.
// just render a heading.
// also, render a header until we're fully loaded, otherwise the breadcrumb starts
// flickering.
if (parentFolderInfo === undefined || parentFolderInfo.length < 2 || this.state.isLoading) {
return (<h2 className={styles.tabHeader}>{this.state.folderName ? this.state.folderName : strings.OneDriveRootFolderName}</h2>);
}
// Get the breadcrumb path
const breadCrumbItems: IBreadcrumbItem[] = [];
// Parent info comes in reversed, so reverse it to render the breadcrumbs in the right order
parentFolderInfo.reverse().forEach((parentFolder: IParentFolderInfo, index: number) => {
// Anything after the first level is a link
if (index > 1) {
const folderName: string = this._getFolderName(parentFolder.ServerRelativeUrl);
breadCrumbItems.push({
text: folderName,
key: folderName,
onClick: () => this._handleOpenLibraryByPath(parentFolder.ServerRelativeUrl, folderName)
});
} else if (index === 1) {
// First level is always a link to the OneDrive root
breadCrumbItems.push({
text: strings.OneDriveRootFolderName,
key: strings.OneDriveRootFolderName,
onClick: () => this._handleOpenLibraryByPath(parentFolder.ServerRelativeUrl, strings.OneDriveRootFolderName)
});
}
});
// List breadcrumb node is the current folder
breadCrumbItems.push({ text: this.state.folderName, key: this.state.folderName, isCurrentItem: true });
return <Breadcrumb
className={styles.standaloneListBreadcrumb}
items={breadCrumbItems}
/>;
}
/**
* Renders a custom list page
*/
private _onRenderPage = (pageProps: IPageProps, _defaultRender?: IRenderFunction<IPageProps>): JSX.Element => {
const {
page,
className: pageClassName,
...divProps
} = pageProps;
const { items } = page;
return <div {...divProps} className={css(pageClassName, styles.listPage)}>
<div className={styles.grid}
style={{
width: this._pageWidth,
marginTop: -STANDARD_TILE_MARGIN,
marginBottom: BOTTOM_MARGIN,
marginLeft: -STANDARD_TILE_MARGIN,
marginRight: -STANDARD_TILE_MARGIN
}}
>
{items.map((item: IOneDriveFile, index: number) => {
return this._onRenderCell(item, index);
})}
</div>
</div>;
}
/**
* Renders a placeholder to indicate that the folder is empty
*/
private _renderEmptyFolder = (): JSX.Element => {
return (<div className={styles.emptyFolder}>
<div className={styles.emptyFolderImage}>
<img
className={styles.emptyFolderImageTag}
src={strings.OneDriveEmptyFolderIconUrl}
alt={strings.OneDriveEmptyFolderAlt} />
</div>
<div role="alert">
<div className={styles.emptyFolderTitle}>
{strings.OneDriveEmptyFolderTitle}
</div>
<div className={styles.emptyFolderSubText}>
<span className={styles.emptyFolderPc}>
{strings.OneDriveEmptyFolderDescription}
</span>
{/* Removed until we add support to upload */}
{/* <span className={styles.emptyFolderMobile}>
Tap <Icon iconName="Add" className={styles.emptyFolderIcon} /> to add files here.
</span> */}
</div>
</div>
</div>);
}
/**
* Get the list of toolbar items on the left side of the toolbar.
* We leave it empty for now, but we may add the ability to upload later.
*/
private _getToolbarItems = (): ICommandBarItemProps[] => {
return [
// This space for rent
];
}
private getFarItems = (): ICommandBarItemProps[] => {
const { selectedView } = this.state;
let viewIconName: string = undefined;
let viewName: string = undefined;
switch (this.state.selectedView) {
case 'list':
viewIconName = 'List';
viewName = strings.ListLayoutList;
break;
case 'compact':
viewIconName = 'AlignLeft';
viewName = strings.ListLayoutCompact;
break;
default:
viewIconName = 'GridViewMedium';
viewName = strings.ListLayoutTile;
}
const farItems: ICommandBarItemProps[] = [
{
key: 'listOptions',
className: styles.commandBarNoChevron,
title: strings.ListOptionsTitle,
ariaLabel: strings.ListOptionsAlt.replace('{0}', viewName),
iconProps: {
iconName: viewIconName
},
iconOnly: true,
subMenuProps: {
items: [
{
key: 'list',
name: strings.ListLayoutList,
iconProps: {
iconName: 'List'
},
canCheck: true,
checked: this.state.selectedView === 'list',
ariaLabel: strings.ListLayoutAriaLabel.replace('{0}', strings.ListLayoutList).replace('{1}', selectedView === 'list' ? strings.Selected : undefined),
title: strings.ListLayoutListDescrition,
onClick: (_ev?: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, item?: IContextualMenuItem) => this._handleSwitchLayout(item)
},
{
key: 'compact',
name: strings.ListLayoutCompact,
iconProps: {
iconName: 'AlignLeft'
},
canCheck: true,
checked: this.state.selectedView === 'compact',
ariaLabel: strings.ListLayoutAriaLabel.replace('{0}', strings.ListLayoutCompact).replace('{1}', selectedView === 'compact' ? strings.Selected : undefined),
title: strings.ListLayoutCompactDescription,
onClick: (_ev?: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, item?: IContextualMenuItem) => this._handleSwitchLayout(item)
},
{
key: 'tiles',
name: 'Tiles',
iconProps: {
iconName: 'GridViewMedium'
},
canCheck: true,
checked: this.state.selectedView === 'tiles',
ariaLabel: strings.ListLayoutAriaLabel.replace('{0}', strings.ListLayoutTile).replace('{1}', selectedView === 'tiles' ? strings.Selected : undefined),
title: strings.ListLayoutTileDescription,
onClick: (_ev?: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, item?: IContextualMenuItem) => this._handleSwitchLayout(item)
}
]
}
}
];
return farItems;
}
/**
* Called when users switch the view
*/
private _handleSwitchLayout = (item?: IContextualMenuItem) => {
if (item) {
// Store the user's favourite layout
if (localStorage) {
localStorage.setItem(LAYOUT_STORAGE_KEY, item.key);
}
this.setState({
selectedView: item.key as ViewType
});
}
}
/**
* Gets called what a file is selected.
*/
private _handleItemInvoked = (item: IOneDriveFile) => {
// If a file is selected, open the library
if (item.isFolder) {
this._handleOpenLibrary(item);
} else {
// Otherwise, remember it was selected
this._selection.setKeySelected(item.key, true, true);
}
}
/**
* When user selects an item, save selection
*/
private _itemChangedHandler = (item: IOneDriveFile, _index: number, _ev): void => {
// When we highlight a folder, do nothing (except set the selection to blank)
if (item.isFolder) {
this.setState({
fileUrl: undefined
});
return;
}
// set the item selected
this._selection.setKeySelected(item.key, true, true);
}
/**
* Calculates how many items there should be in the page
*/
private _getItemCountForPage = (itemIndex: number, surfaceRect: IRectangle): number => {
if (itemIndex === 0) {
this._columnCount = Math.ceil(surfaceRect.width / MAX_ROW_HEIGHT);
this._columnWidth = Math.floor(surfaceRect.width / this._columnCount);
this._rowHeight = this._columnWidth;
this._pageWidth = surfaceRect.width;
}
// Get the list of items
const { files } = this.state;
const isFolder: boolean = files[itemIndex].isFolder;
// Group items by folders and files
let pageLength: number = 0;
for (let index = itemIndex; index < files.length; index++) {
const element = files[index];
if (element.isFolder === isFolder) {
pageLength++;
} else {
break;
}
}
// Return the page lenght, up to the maximum number of cells per page
return Math.min(pageLength, CELLS_PER_PAGE);
}
/** Calculates the list "page" height (a.k.a. row) */
private _getPageHeight = (): number => {
return this._rowHeight * ROWS_PER_PAGE;
}
/**
* Renders a file folder cover
*/
private _onRenderCell = (item: IOneDriveFile, index: number | undefined): JSX.Element => {
let isSelected: boolean = false;
if (this._selection && index !== undefined) {
isSelected = this._selection.isIndexSelected(index);
}
// I know this is a lot of divs and spans inside of each other, but my
// goal was to mimic the HTML and style of the out-of-the-box file picker
// to the best of my ability.
return (
<div
className={styles.listCell}
data-item-index={index}
style={{
flexBasis: this._columnWidth,
maxWidth: this._columnWidth,
margin: STANDARD_TILE_MARGIN,
borderStyle: "none",
borderWidth: 0
}}
>
<div
role="presentation"
className={styles.cell}
// I don't agree with this magic number. Where does this come from?
style={{ paddingTop: "97.16%" }}
>
<div role="presentation" className={styles.cellContent}>
{item.isFolder ? <FolderTile
item={item}
index={index}
isSelected={isSelected}
pageWidth={this._pageWidth}
tileDimensions={{
width: this._columnWidth - TILE_HORZ_PADDING,
height: this._rowHeight - TILE_HORZ_PADDING
}}
onItemInvoked={(itemInvoked: IOneDriveFile) => this._handleItemInvoked(itemInvoked)}
/>
: <DocumentTile
item={item}
index={index}
isSelected={isSelected}
pageWidth={this._pageWidth}
tileDimensions={{
width: this._columnWidth - TILE_HORZ_PADDING,
height: this._rowHeight - TILE_HORZ_PADDING
}}
onItemInvoked={(itemInvoked: IOneDriveFile) => this._handleItemInvoked(itemInvoked)}
/>}
</div>
</div>
</div>
);
}
/**
* Creates an absolute URL
*/
private _buildOneDriveAbsoluteUrl = (root: string, relativeUrl: string) => {
const siteUrl: string = GetAbsoluteDomainUrl(root);
return siteUrl + relativeUrl;
}
/**
* Calls parent when library is opened
*/
private _handleOpenLibrary = (library: IOneDriveFile) => {
this.setState({
serverRelativeFolderUrl: library.serverRelativeUrl,
folderName: library.name
});
}
/**
* Gets called when someone clicks on a breadcrumb
* In this case, we only have the path to work with, not the full
* reference to the OneDrive file
*/
private _handleOpenLibraryByPath = (serverRelativeUrl: string, libraryName: string) => {
this.setState({
serverRelativeFolderUrl: serverRelativeUrl,
folderName: libraryName
});
}
/**
* Prompt user with dialog before they can save
*/
private _handleSaveConfirm = () => {
this.setState({
hideDialog: false
});
}
/**
* Called when user saves
*/
private _handleSave = () => {
this.props.onSave(encodeURI(this.state.fileUrl));
}
/**
* Called when user closes tab
*/
private _handleClose = () => {
this.props.onClose();
}
/**
* Creates a ref to the list
*/
private _linkList = (e: any) => {
this._listElem = e;
}
/**
* Extracts the last part of the path to get the folder name
*/
private _getFolderName = (folderPath: string): string => {
// break the folder path into its sub-folders
const pathSegments: string[] = folderPath.split('/');
return pathSegments.pop();
}
}

View File

@ -0,0 +1,38 @@
import { IFilePickerTab } from "../IFilePickerTab.types";
import { IParentFolderInfo, IDimensions } from '../../../services/OneDriveServices';
import { IColumn } from 'office-ui-fabric-react/lib/DetailsList';
export interface IOneDriveTabProps extends IFilePickerTab {
//inherited
}
export interface IOneDriveTabState {
isLoading: boolean;
files: IOneDriveFile[];
fileUrl?: string;
serverRelativeFolderUrl?: string;
folderName?: string;
hideDialog: boolean;
parentFolderInfo: IParentFolderInfo[];
selectedView: ViewType;
columns: IColumn[];
}
export interface IOneDriveFile {
name: string;
absoluteUrl: string;
serverRelativeUrl: string;
isFolder: boolean;
modified: string;
modifiedBy: string;
fileType: string;
fileIcon: string;
fileSizeDisplay: string;
totalFileCount: number;
key: string;
thumbnail: string;
dimensions?: IDimensions;
isShared: boolean;
}
export type ViewType = 'list' | 'compact' | 'tiles';

View File

@ -0,0 +1,2 @@
export * from './OneDriveTab';
export * from './OneDriveTab.types';

View File

@ -0,0 +1,376 @@
@import "~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss";
.filePicker {
color: inherit;
}
.filePicker :global(.ms-Panel-commands) {
display: none;
}
.filePicker :global(.ms-Panel-main) {
top: 50px;
border: none;
overflow-y: hidden;
}
[dir="ltr"] .filePicker :global(.ms-Panel-content) {
padding-left: 0;
}
.filePicker :global(.ms-Panel-contentInner) {
padding: 0;
top: 0;
overflow: hidden;
}
.filePicker :global(.ms-Panel-header) {
margin: 0;
}
.filePicker
:global(.ms-Panel-main)
:global(.ms-Panel-contentInner)
:global(.ms-Panel-content) {
height: 100%;
}
.filePicker :global(.ms-Nav-link) {
position: relative;
-webkit-font-smoothing: antialiased;
font-size: 14px;
font-weight: 400;
box-sizing: border-box;
display: block;
text-align: center;
cursor: pointer;
padding-top: 0px;
padding-right: 20px;
padding-bottom: 0px;
padding-left: 15px;
height: 36px;
color: $ms-color-neutralPrimary;
background-color: transparent;
width: 100%;
line-height: 36px;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
outline: transparent;
border-width: initial;
border-style: none;
border-color: initial;
border-image: initial;
text-decoration: none;
border-radius: 0px;
overflow: hidden;
}
.filePicker :global(.is-selected) :global(.ms-Nav-link) {
position: relative;
-webkit-font-smoothing: antialiased;
font-size: 14px;
font-weight: 400;
box-sizing: border-box;
display: block;
text-align: center;
cursor: pointer;
padding-top: 0px;
padding-right: 20px;
padding-bottom: 0px;
padding-left: 15px;
height: 36px;
color: $ms-color-themePrimary;
background-color: $ms-color-neutralLighter;
width: 100%;
line-height: 36px;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
outline: transparent;
border-width: initial;
border-style: none;
border-color: initial;
border-image: initial;
text-decoration: none;
border-radius: 0px;
overflow: hidden;
}
.tabsContainer {
position: absolute;
top: 0;
bottom: 0;
height: auto;
}
.tabContainer {
height: 100%;
-webkit-box-sizing: border-box;
box-sizing: border-box;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
[dir="ltr"] .tabsContainer {
left: 212px;
}
[dir="ltr"] .tabsContainer {
right: 0;
}
.nav {
height: 100%;
color: $ms-color-neutralLighter;
}
[dir="ltr"] .nav {
border-right: 2px solid;
width: 210px;
}
.nav :global(.ms-Nav) {
height: 100%;
width: 210px;
padding-top: 60px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
border-top-width: 40px;
border-top-style: solid;
color: $ms-color-neutralLighter;
}
.tabHeaderContainer {
display: inline-block;
width: 100%;
color: $ms-color-neutralLighter;
border-top: 40px solid;
}
.tab {
padding: 0 32px;
overflow-y: auto;
-webkit-box-flex: 2;
-ms-flex-positive: 2;
flex-grow: 2;
}
.actionButtonsContainer {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
width: 100%;
background-color: $ms-color-white;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.actionButtons {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: end;
-ms-flex-pack: end;
justify-content: flex-end;
padding-top: 10px;
padding-bottom: 10px;
}
.actionButton {
height: 30px;
padding-bottom: 3px;
}
[dir="ltr"] .actionButton {
padding-left: 16px;
}
[dir="ltr"] .actionButton {
margin-left: 0;
}
[dir="ltr"] .actionButton {
margin-right: 10px;
}
[dir="ltr"] .actionButtons {
padding-left: 20px;
}
[dir="ltr"] .actionButtons {
padding-right: 11px;
}
.tabHeader {
color: $ms-color-neutralPrimary;
margin: 0;
font-size: 28px;
font-weight: 100;
padding: 22px 0 20px 32px;
}
.tabContainer {
height: 100%;
-webkit-box-sizing: border-box;
box-sizing: border-box;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.filePicker :global(.ms-Panel-main) {
top: 50px;
border: none;
overflow-y: hidden;
}
.tabsContainer .selectedTab {
display: block;
height: 100%;
width: 100%;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.header {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.xlg, .xxlg, .xxxlg, .xxxxlg {
color: inherit;
}
.itemPickerTopBar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40px;
background-color: red;
}
.commandBarNoChevron :global(.ms-CommandBarItem-chevronDown) {
display: none;
}
.fileItem {
cursor: default;
}
.folderItem {
cursor: pointer;
}
.folderItem:hover {
text-decoration: underline;
}
.fileTypeIcon {
margin: 0 -2px;
display: inline-block;
}
.fileTypeIconIcon {
display: inline-block;
vertical-align: middle;
position: relative;
overflow: hidden;
width: 20px;
height: 20px;
}
.detailsListHeaderFileTypeIcon {
font-size: 16px;
padding: 0;
}
.iconColumnHeader {
width: 40px!important;
margin-right: -8px;
}
.iconColumnHeader i {
font-size:16px;
}
.fileRow {
color: inherit;
}
.folderRow {
color: inherit;
}
.folderRow :global(.ms-DetailsRow-check) {
visibility: hidden;
}
.emptyFolder {
text-align: center;
.emptyFolderImage {
padding-top: 52px;
height: 208px;
width: auto;
margin: 0 auto;
.emptyFolderImageTag {
height: 208px;
width: auto;
}
}
.emptyFolderTitle {
padding: 16px 16px 0 16px;
font-size: 21px;
font-weight: 100;
max-width: 400px;
color: #858585;
margin: 0 auto;
}
.emptyFolderSubText {
padding: 6px 16px 0 16px;
font-size: 14px;
font-weight: 400;
max-width: 400px;
color: #858585;
margin: 0 auto;
}
.emptyFolderMobile {
display: none;
}
.emptyFolderPc {
color: inherit;
}
.emptyFolderIcon {
display: inline;
font-size: 13px;
padding-left: 2px;
padding-right: 1px;
}
}

View File

@ -0,0 +1,69 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {
IPropertyPaneField,
PropertyPaneFieldType
} from '@microsoft/sp-webpart-base';
import { PropertyPaneFilePickerHost, IPropertyPaneFilePickerPropsInternal, IPropertyPaneFilePickerProps } from '.';
class PropertyPaneFilePickerBuilder implements IPropertyPaneField<IPropertyPaneFilePickerPropsInternal> {
public targetProperty: string;
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom;
public properties: IPropertyPaneFilePickerPropsInternal;
private _onChangeCallback: (targetProperty?: string, newValue?: any) => void;
public constructor(_targetProperty: string, _properties: IPropertyPaneFilePickerPropsInternal) {
this.targetProperty = _targetProperty;
this.properties = _properties;
this.properties.onRender = this.render.bind(this);
this.properties.onDispose = this.dispose.bind(this);
}
private render(elem: HTMLElement, context?: any, changeCallback?: (targetProperty?: string, newValue?: any[]) => void): void {
const props: IPropertyPaneFilePickerProps = <IPropertyPaneFilePickerProps>this.properties;
const element = React.createElement(PropertyPaneFilePickerHost, {
...props,
onChanged: this.onChanged.bind(this)
});
ReactDOM.render(element, elem);
if (changeCallback) {
this._onChangeCallback = changeCallback;
}
}
/**
* Dispose the property field
*/
private dispose(elem: HTMLElement) {
ReactDOM.unmountComponentAtNode(elem);
}
/**
* On field change event handler
* @param value
*/
private onChanged(value: any[]): void {
if (this._onChangeCallback) {
this._onChangeCallback(this.targetProperty, value);
}
}
}
/**
* Property field
* @param targetProperty
* @param properties
*/
export function PropertyPaneFilePicker(targetProperty: string, properties: IPropertyPaneFilePickerProps): IPropertyPaneField<IPropertyPaneFilePickerPropsInternal> {
return new PropertyPaneFilePickerBuilder(targetProperty, {
...properties,
onRender: null,
onDispose: null
});
}

View File

@ -0,0 +1,234 @@
import * as React from 'react';
// Our custom styles
import styles from './PropertyPaneFilePicker.module.scss';
// Our props
import { IPropertyPaneFilePickerHostProps, IPropertyPaneFilePickerHostState } from '.';
// Office Fabric controls
import { PrimaryButton } from 'office-ui-fabric-react/lib/components/Button';
import { Panel, PanelType } from 'office-ui-fabric-react/lib/components/Panel';
import { Label } from 'office-ui-fabric-react/lib/components/Label';
import { Nav, INavLink, INavLinkGroup } from 'office-ui-fabric-react/lib/Nav';
import { css } from "@uifabric/utilities/lib/css";
// Localization
import * as strings from 'PropertyPaneFilePickerStrings';
// Custom property pane file picker tabs
import LinkFilePickerTab from './LinkFilePickerTab/LinkFilePickerTab';
import UploadFilePickerTab from './UploadFilePickerTab/UploadFilePickerTab';
import SiteFilePickerTab from './SiteFilePickerTab/SiteFilePickerTab';
import WebSearchTab from './WebSearchTab/WebSearchTab';
import RecentFilesTab from './RecentFilesTab/RecentFilesTab';
import { ItemType } from './IPropertyPaneFilePicker';
import OneDriveTab from './OneDriveTab/OneDriveTab';
// The list of acceptable image file types
const ACCEPTABLE_IMAGEFILE_EXTENSIONS: string = ".gif,.jpg,.jpeg,.bmp,.dib,.tif,.tiff,.ico,.png,.jxr,.svg";
export class PropertyPaneFilePickerHost extends React.Component<IPropertyPaneFilePickerHostProps, IPropertyPaneFilePickerHostState> {
constructor(props: IPropertyPaneFilePickerHostProps) {
super(props);
this.state = {
panelOpen: false,
selectedTab: 'keyRecent',
showFullNav: true
};
}
public render(): JSX.Element {
// If no acceptable file type was passed, and we're expecting images, set the default image filter
const accepts: string = this.props.accepts !== undefined ? this.props.accepts
: this.props.itemType === ItemType.Images && this.props.accepts === undefined ? ACCEPTABLE_IMAGEFILE_EXTENSIONS
: undefined;
// Get a list of groups to display
let groups: INavLinkGroup[] = [
{
links: [
{
name: strings.RecentLinkLabel,
url: '#recent',
icon: 'Recent',
key: 'keyRecent',
},
{
name: strings.WebSearchLinkLabel,
url: '#search',
key: 'keyWeb',
icon: 'Search',
},
{
name: "OneDrive",
url: '#onedrive',
key: 'keyOneDrive',
icon: 'OneDrive',
},
{
name: strings.SiteLinkLabel,
url: '#globe',
key: 'keySite',
icon: 'Globe',
},
{
name: strings.UploadLinkLabel,
url: '#upload',
key: 'keyUpload',
icon: 'System'
},
{
name: strings.FromLinkLinkLabel,
url: '#link',
key: 'keyLink',
icon: 'Link'
}
]
}
];
// Hide tabs we don't want. Start from bottom of the list
// so we're not changing the relative position of items
// as we remove them.
// I'm sure there is a better way to do this...
// If we don't want local uploads, remove it from the list
if (this.props.disableLocalUpload) {
groups[0].links.splice(4, 1);
}
// If we don't want OneDrive, remove it from the list
if (this.props.hasMySiteTab === false) {
groups[0].links.splice(2, 1);
}
// If we don't want web search, remove it from the list
if (this.props.disableWebSearchTab) {
groups[0].links.splice(1, 1);
}
return (
<div >
<Label required={this.props.required}>{this.props.label}</Label>
<PrimaryButton text={this.props.buttonLabel}
onClick={this._handleOpenPanel}
disabled={this.props.disabled} />
<Panel isOpen={this.state.panelOpen}
isBlocking={true}
hasCloseButton={true}
className={styles.filePicker}
onDismiss={this._handleClosePanel}
type={PanelType.extraLarge}
isFooterAtBottom={true}
onRenderNavigation={() => { return undefined; }}
headerText={strings.FilePickerHeader}
isLightDismiss={true}
onRenderHeader={() => this._renderHeader()}
>
<div className={styles.nav}>
<Nav
groups={groups}
selectedKey={this.state.selectedTab}
onLinkClick={(ev?: React.MouseEvent<HTMLElement>, item?: INavLink) => this._handleLinkClick(ev, item)}
/>
</div>
<div className={styles.tabsContainer}>
{this.state.selectedTab === "keyLink" && <LinkFilePickerTab
itemType={this.props.itemType}
allowExternalTenantLinks={true}
accepts={accepts}
context={this.props.webPartContext}
onClose={() => this._handleClosePanel()}
onSave={(value: string) => this._handleSave(value)}
/>}
{this.state.selectedTab === "keyUpload" && <UploadFilePickerTab
itemType={this.props.itemType}
context={this.props.webPartContext}
accepts={accepts}
onClose={() => this._handleClosePanel()}
onSave={(value: string) => this._handleSave(value)}
/>}
{this.state.selectedTab === "keySite" && <SiteFilePickerTab
itemType={this.props.itemType}
context={this.props.webPartContext}
accepts={accepts}
onClose={() => this._handleClosePanel()}
onSave={(value: string) => this._handleSave(value)}
/>}
{this.state.selectedTab === "keyWeb" && <WebSearchTab
itemType={this.props.itemType}
context={this.props.webPartContext}
accepts={accepts}
onClose={() => this._handleClosePanel()}
onSave={(value: string) => this._handleSave(value)}
/>}
{this.state.selectedTab === "keyOneDrive" && <OneDriveTab
itemType={this.props.itemType}
context={this.props.webPartContext}
accepts={accepts}
onClose={() => this._handleClosePanel()}
onSave={(value: string) => this._handleSave(value)}
/>}
{this.state.selectedTab === "keyRecent" && <RecentFilesTab
itemType={this.props.itemType}
context={this.props.webPartContext}
accepts={accepts}
onClose={() => this._handleClosePanel()}
onSave={(value: string) => this._handleSave(value)}
/>}
</div>
</Panel>
</div >
);
}
/**
* Renders the panel header
*/
private _renderHeader = (): JSX.Element => {
return <div className={"ms-Panel-header"}><p className={css("ms-Panel-headerText", styles.header)} role="heading">{strings.FilePickerHeader}</p></div>;
}
/**
* Open the panel
*/
private _handleOpenPanel = () => {
this.setState({
panelOpen: true,
selectedTab: 'keyRecent'
});
}
/**
* Closes the panel
*/
private _handleClosePanel = () => {
this.setState({
panelOpen: false
});
}
/**
* On save action
*/
private _handleSave = (image: string) => {
this.props.onChanged(image);
this.setState({
panelOpen: false
});
}
/**
* Changes the selected tab when a link is selected
*/
private _handleLinkClick = (ev?: React.MouseEvent<HTMLElement>, item?: INavLink) => {
this.setState({ selectedTab: item.key });
}
}

View File

@ -0,0 +1,194 @@
@import "../PropertyPaneFilePicker.module.scss";
.gridListCell {
margin-bottom: 8px;
}
[dir="ltr"] .gridListCell {
margin-right: 8px;
}
[dir="ltr"] .gridListCell.cellRight {
margin-right: 0;
}
.gridListCell.cellLastRow {
margin-bottom: 0;
}
.itemTile.isFile,
.itemTile.isPhoto,
.itemTile.isVideo {
background-color: $ms-color-neutralLighter;
}
.itemTile {
-webkit-transition: -webkit-transform .1s linear;
transition: -webkit-transform .1s linear;
transition: transform .1s linear;
transition: transform .1s linear,-webkit-transform .1s linear;
position: relative;
display: inline-block;
left: 0;
right: 0;
top: 0;
bottom: 0;
cursor: pointer;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
overflow: hidden;
-webkit-box-sizing: border-box;
box-sizing: border-box;
&:focus {
outline: 0;
}
&:active {
-webkit-transform: scale(0.96);
transform: scale(0.96);
}
&:focus:after {
content: "";
position: absolute;
right: 2px;
left: 2px;
top: 2px;
bottom: 2px;
border: 1px solid;
border-color: $ms-color-white;
-webkit-box-shadow: 0 0 0 2px $ms-color-neutralSecondaryAlt;
box-shadow: 0 0 0 2px $ms-color-neutralSecondaryAlt;
}
}
.hasThumbnail {
color: inherit;
}
.itemTileContent {
width: 100%;
height: 100%;
}
.itemTile.isFile .itemTileDiscoverFileContainer,
.itemTile.isFile .itemTileFileContainer {
border-width: 1px;
border-style: solid;
border-color: $ms-color-neutralLight;
}
.itemTileFile {
.itemTileFileContainer {
position: absolute;
background-color: $ms-color-neutralLighter;
left: 0;
right: 0;
top: 0;
bottom: 0;
.itemTileThumbnail {
text-align: center;
overflow: hidden;
width: 100%;
height: 100%;
.image {
img {
display: block;
opacity: 1;
width: 100%;
height: auto;
animation-duration: 0.367s;
animation-timing-function: cubic-bezier(0.1, 0.25, 0.75, 0.9);
animation-fill-mode: both;
}
}
}
}
}
[dir="ltr"] .itemTileCheckCircle {
right: 8px;
}
.itemTileCheckCircle {
display: block;
position: absolute;
top: 6px;
opacity: 0;
background: 0 0;
border: none;
outline: 0;
top: 8px;
:global(.ms-Check-check) {
opacity: 1;
}
}
.gridListCell.isSelected .itemTileCheckCircle,
.gridListCell:hover,
.gridListCell:hover .itemTileCheckCircle {
opacity: 1;
}
.itemTileNamePlate {
position: absolute;
bottom:0;
left:0;
right:0;
min-height:20px;
padding:4px 8px 8px;
background-color:$ms-color-white;
color:$ms-color-neutralPrimary;
white-space:nowrap;
text-overflow:ellipsis
}
.itemTile.isFile .itemTileNamePlate {
opacity: .95;
border-top-width: 1px;
border-top-style: solid;
border-top-color: $ms-color-neutralLight;
min-height: 35px;
}
.itemTileName {
font-size: 15px;
line-height: 20px;
}
.itemTileChildCount, .itemTileName, .itemTileSubText {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.itemTileSubText {
font-size: 11px;
opacity: .7;
}
.recentGridList {
font-size: 0;
}
.recentGridList :global(.ms-List-page) {
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
flex-wrap: wrap;
}

View File

@ -0,0 +1,354 @@
import * as React from 'react';
// Custom styles
import styles from './RecentFilesTab.module.scss';
// Office Fabric
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/components/Button';
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import { FocusZone } from 'office-ui-fabric-react/lib/FocusZone';
import { List } from 'office-ui-fabric-react/lib/List';
import { IRectangle } from 'office-ui-fabric-react/lib/Utilities';
import { css } from "@uifabric/utilities/lib/css";
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
import { Selection, SelectionMode, SelectionZone } from 'office-ui-fabric-react/lib/Selection';
import { Image, ImageFit } from 'office-ui-fabric-react/lib/Image';
import { Check } from 'office-ui-fabric-react/lib/Check';
// Custom props and states
import { IRecentFilesTabProps, IRecentFilesTabState, IRecentFile } from './RecentFilesTab.types';
import { ItemType } from '../IPropertyPaneFilePicker';
// Localized resources
import * as strings from 'PropertyPaneFilePickerStrings';
// PnP
import { sp, SearchResults, SearchResult } from "@pnp/sp";
/**
* Rows per page
*/
const ROWS_PER_PAGE = 3;
/**
* Maximum row height
*/
const MAX_ROW_HEIGHT = 250;
export default class RecentFilesTab extends React.Component<IRecentFilesTabProps, IRecentFilesTabState> {
private _columnCount: number;
private _columnWidth: number;
private _rowHeight: number;
private _selection: Selection;
private _listElem: List = undefined;
constructor(props: IRecentFilesTabProps) {
super(props);
this._selection = new Selection(
{
selectionMode: SelectionMode.single,
onSelectionChanged: () => {
// Get the selected item
const selectedItems = this._selection.getSelection();
if (selectedItems && selectedItems.length > 0) {
//Get the selected key
const selectedKey: IRecentFile = selectedItems[0] as IRecentFile;
// Save the selected file
this.setState({
fileUrl: selectedKey.fileUrl
});
} else {
// Remove any selected file
this.setState({
fileUrl: undefined
});
}
if (this._listElem) {
// Force the list to update to show the selection check
this._listElem.forceUpdate();
}
}
});
this.state = {
isLoading: true,
results: []
};
}
/**
* Gets the most recently used files
*/
public componentDidMount(): void {
const { absoluteUrl } = this.props.context.pageContext.web;
// Build a filter criteria for each accepted file type, if applicable
const fileFilter: string = this._getFileFilter();
// // This is how you make two promises at once and wait for both results to return
// // TODO: research to see if there is a way to get this info in one call. Perhaps as context info?
sp.setup({
sp: { baseUrl: absoluteUrl }
});
const getContext: [Promise<any[]>, Promise<any[]>] = [sp.web.select("Id").get(), sp.site.select("Id").get()];
Promise.all(getContext).then((results: any[]) => {
// retrieve site id and web id
const webId: string = results[0].Id;
const siteId: string = results[1].Id;
// build a query template
const queryTemplate: string = `((SiteID:${siteId} OR SiteID: {${siteId}}) AND (WebId: ${webId} OR WebId: {${webId}})) AND LastModifiedTime < {Today} AND -Title:OneNote_DeletedPages AND -Title:OneNote_RecycleBin${fileFilter}`;
// search for recent changes with accepted file types
sp.search({
QueryTemplate: queryTemplate,
RowLimit: 20,
SelectProperties: [
"Title",
"Path",
"Filename",
"FileExtension",
"FileType",
"Created",
"Author",
"LastModifiedTime",
"EditorOwsUser",
"ModifiedBy",
"LinkingUrl",
"SiteTitle",
"ParentLink",
"DocumentPreviewMetadata",
"ListID",
"ListItemID",
"SPSiteURL",
"SiteID",
"WebId",
"UniqueID",
"SPWebUrl",
"DefaultEncodingURL",
"PictureThumbnailURL",
],
SortList: [
{
"Property": "LastModifiedTime",
"Direction": 1
}
]
}).then((r: SearchResults) => {
const recentFilesResult: IRecentFile[] = r.PrimarySearchResults.map((result: SearchResult) => {
const recentFile: IRecentFile = {
key: result["UniqueID"],
name: result.Title,
fileUrl: result["DefaultEncodingURL"],
editedBy: result["ModifiedBy"]
};
return recentFile;
});
this._selection.setItems(recentFilesResult, true);
this.setState({
isLoading: false,
results: recentFilesResult
});
});
});
}
/**
* Render the tab
*/
public render(): React.ReactElement<IRecentFilesTabProps> {
const imageType: boolean = this.props.itemType === ItemType.Images;
const { results,
isLoading } = this.state;
return (
<span className={styles.tabContainer}>
<span className={styles.tabHeaderContainer}>
<h2 className={styles.tabHeader}>{imageType ? strings.RecentImagesHeader : strings.RecentDocumentsHeader}</h2>
</span>
<span className={styles.tab}>
{isLoading ?
this._renderSpinner() :
results === undefined || results.length < 1 ? this._renderPlaceholder() : this._renderGridList()
}
</span>
<span className={styles.actionButtonsContainer}>
<span className={styles.actionButtons}>
<PrimaryButton
disabled={!this.state.fileUrl}
onClick={() => this._handleSave()}
className={styles.actionButton}
>{strings.OpenButtonLabel}</PrimaryButton>
<DefaultButton onClick={() => this._handleClose()} className={styles.actionButton}>{strings.CancelButtonLabel}</DefaultButton>
</span>
</span>
</span>
);
}
/**
* Calculates how many items there should be in the page
*/
private _getItemCountForPage = (itemIndex: number, surfaceRect: IRectangle): number => {
if (itemIndex === 0) {
this._columnCount = Math.ceil(surfaceRect.width / MAX_ROW_HEIGHT);
this._columnWidth = Math.floor(surfaceRect.width / this._columnCount);
this._rowHeight = this._columnWidth;
}
return this._columnCount * ROWS_PER_PAGE;
}
/** Calculates the list "page" height (a.k.a. row) */
private _getPageHeight = (): number => {
return this._rowHeight * ROWS_PER_PAGE;
}
/**
* Renders a "please wait" spinner while we're loading
*/
private _renderSpinner = (): JSX.Element => {
return <Spinner label={strings.Loading} />;
}
/**
* Renders a message saying that there are no recent files
*/
private _renderPlaceholder = (): JSX.Element => {
return <Placeholder iconName='OpenFolderHorizontal'
iconText={strings.NoRecentFiles}
description={strings.NoRecentFilesDescription}
/>;
}
/**
* Renders a grid list containing results
*/
private _renderGridList = (): JSX.Element => {
return <span className={styles.recentGridList} role="grid">
<FocusZone>
<SelectionZone selection={this._selection}
onItemInvoked={(item: IRecentFile) => this._handleItemInvoked(item)}>
<List
ref={this._linkElement}
items={this.state.results}
onRenderCell={this._onRenderCell}
getItemCountForPage={this._getItemCountForPage}
getPageHeight={this._getPageHeight}
renderedWindowsAhead={4}
/>
</SelectionZone>
</FocusZone>
</span>;
}
/**
* Renders each result in its own cell
*/
private _onRenderCell = (item: IRecentFile, index: number | undefined): JSX.Element => {
let isSelected: boolean = false;
if (this._selection && index !== undefined) {
isSelected = this._selection.isIndexSelected(index);
}
return (
<div
className={styles.gridListCell} role={"gridCell"}>
<div
className={css(styles.itemTile, styles.isFile, styles.hasThumbnail, isSelected ? styles.isSelected : undefined)}
role="link"
aria-selected={isSelected}
data-is-draggable="false"
data-is-focusable="true"
data-selection-index={index}
data-selection-invoke="true"
data-item-index={index}
data-automationid="ItemTile"
style={{
width: this._columnWidth,
height: this._rowHeight
}}
>
<div className={styles.itemTileContent}>
<div className={styles.itemTileFile}>
<div className={styles.itemTileFileContainer}>
<div className={styles.itemTileThumbnail}>
{/* <div className={styles.image}> */}
<Image src={item.fileUrl} width={this._columnWidth} height={this._rowHeight} imageFit={ImageFit.cover} />
{/* </div> */}
</div>
<div className={styles.itemTileCheckCircle}
role='checkbox'
aria-checked={isSelected}
data-item-index={index} data-selection-toggle={true} data-automationid='CheckCircle'>
<Check checked={isSelected} />
</div>
<div className={styles.itemTileNamePlate}>
<div className={styles.itemTileName}>{item.name}</div>
<div className={styles.itemTileSubText}>
<span>{strings.EditedByNamePlate}{item.editedBy}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
/**
* Gets called what a file is selected.
*/
private _handleItemInvoked = (item: IRecentFile) => {
this._selection.setKeySelected(item.key, true, true);
}
/**
* Gets called when it is time to save the currently selected item
*/
private _handleSave = () => {
this.props.onSave(encodeURI(this.state.fileUrl));
}
/**
* Gets called when it is time to close (without saving)
*/
private _handleClose = () => {
this.props.onClose();
}
/**
* Builds a file filter using the accepted file extensions
*/
private _getFileFilter() {
let fileFilter: string = undefined;
if (this.props.itemType === ItemType.Images && this.props.accepts) {
fileFilter = " AND (";
this.props.accepts.split(",").forEach((fileType: string, index: number) => {
fileType = fileType.replace(".", "");
if (index > 0) {
fileFilter = fileFilter + " OR ";
}
fileFilter = fileFilter + `FileExtension:${fileType} OR SecondaryFileExtension:${fileType}`;
});
fileFilter = fileFilter + ")";
}
return fileFilter;
}
/**
* Creates a ref to the list
*/
private _linkElement = (e: any) => {
this._listElem = e;
}
}

View File

@ -0,0 +1,17 @@
import { IFilePickerTab } from "../IFilePickerTab.types";
export interface IRecentFilesTabProps extends IFilePickerTab {
//inherited
}
export interface IRecentFilesTabState {
results: IRecentFile[];
isLoading: boolean;
fileUrl?: string;
}
export interface IRecentFile {
fileUrl: string;
key: string;
name: string;
editedBy: string;
}

View File

@ -0,0 +1,2 @@
export * from './RecentFilesTab';
export * from './RecentFilesTab.types';

View File

@ -0,0 +1,267 @@
@import "~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss";
//@import "../PropertyPaneFilePicker.module.scss";
.aboveNameplate {
position: relative;
margin: 16px 16px 0 16px;
}
.tileContent {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
// -webkit-box-orient: vertical;
// -webkit-box-direction: normal;
// -ms-flex-direction: column;
// flex-direction: column;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.foreground {
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
position: relative;
overflow: hidden;
max-width: 100%;
max-height: 100%;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
opacity: 1;
-webkit-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.folderCover {
display: inline-block;
margin: 3px;
}
.folderCover.isLarge {
width: 112px;
height: 80px;
}
.folderCoverBack {
display: block;
position: absolute;
left: -3px;
right: -3px;
bottom: -4px;
}
.folderCoverFront {
display: block;
position: absolute;
left: -3px;
right: -3px;
bottom: -4px;
}
.listPage {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
// -webkit-box-orient: vertical;
// -webkit-box-direction: normal;
// -ms-flex-direction: column;
// flex-direction: column;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
}
.grid {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-ms-flex-flow: row wrap;
flex-flow: row wrap;
-webkit-box-align: start;
-ms-flex-align: start;
align-items: flex-start;
-webkit-box-pack: start;
-ms-flex-pack: start;
justify-content: flex-start;
-ms-flex-line-pack: start;
align-content: flex-start;
}
.cellContent {
//position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.content {
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.tile {
outline: transparent;
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 0;
background-color: $ms-color-white;
-webkit-transition: -webkit-transform 0.1s linear;
transition: -webkit-transform 0.1s linear;
transition: transform 0.1s linear;
transition: transform 0.1s linear, -webkit-transform 0.1s linear;
color: $ms-color-neutralPrimary;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.namePlate {
position: absolute;
bottom: 0;
font-size: 12px;
left: 0;
width: 100%;
font-size: $ms-font-size-s;
box-sizing: border-box;
margin: auto 0 0 0;
padding: 12px 8px;
text-align: center;
z-index: 0;
}
.nameplate .name {
pointer-events: auto;
cursor: pointer;
}
html[dir="ltr"] .name {
padding-left: 8px;
}
html[dir="ltr"] .name {
margin-left: -8px;
}
.name,
.isLarge .name {
font-size: 14px;
height: 30px;
margin-top: -7px;
margin-bottom: -8px;
}
.nameplate .name {
-webkit-transition: color 0.2s linear;
transition: color 0.2s linear;
}
.name {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
font-weight: 400;
color: $ms-color-neutralPrimary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.link:hover .name {
text-decoration: underline;
}
.folderList {
padding-top: 20px;
}
.folderList :global(.ms-List-cell) {
display: block;
}
.folderList :global(.ms-List-page) {
overflow: hidden;
font-size: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
-webkit-box-direction: normal;
-webkit-box-orient: horizontal;
}
.folderList :global(.ms-List-surface) {
position: relative;
}
.link {
cursor: pointer;
position: absolute;
left: 2px;
top: 2px;
right: 2px;
bottom: 2px;
background-color: $ms-color-white;
outline: transparent;
}
.cell {
//padding-top: 97.16%;
padding-top: 140px;
}
.listCell {
text-align: center;
outline: none;
position: relative;
max-width: 176px;
max-height: 171px;
margin: 4px;
border-style: none;
border-width: 0px;
width: 176px;
}

View File

@ -0,0 +1,99 @@
import * as React from 'react';
import styles from './DocumentLibraryBrowser.module.scss';
import { FocusZone } from 'office-ui-fabric-react/lib/FocusZone';
import { List } from 'office-ui-fabric-react/lib/List';
import { css } from "@uifabric/utilities/lib/css";
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import { IDocumentLibraryBrowserProps, IDocumentLibraryBrowserState, ILibrary } from './DocumentLibraryBrowser.types';
import * as strings from 'PropertyPaneFilePickerStrings';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
/**
* This would have been better done as an Office Fabric TileList, but it isn't available yet for production use
*/
export default class DocumentLibraryBrowser extends React.Component<IDocumentLibraryBrowserProps, IDocumentLibraryBrowserState> {
constructor(props: IDocumentLibraryBrowserProps) {
super(props);
this.state = {
isLoading: true,
lists: []
};
}
public componentDidMount(): void {
const { absoluteUrl } = this.props.context.pageContext.web;
const apiUrl: string = `${absoluteUrl}/_api/SP.Web.GetDocumentAndMediaLibraries?webFullUrl='${encodeURIComponent(absoluteUrl)}'&includePageLibraries='false'`;
this.props.context.spHttpClient.get(apiUrl,
SPHttpClient.configurations.v1)
.then((response: SPHttpClientResponse) => {
response.json().then((responseJSON: any) => {
const lists: ILibrary[] = responseJSON.value.map((item) => {
const list: ILibrary = {
title: item.Title,
absoluteUrl: item.AbsoluteUrl,
serverRelativeUrl: item.ServerRelativeUrl
};
return list;
});
this.setState({
lists: lists,
isLoading: false
});
});
});
}
public render(): React.ReactElement<IDocumentLibraryBrowserProps> {
if (this.state.isLoading) {
return (<Spinner label={strings.Loading} />);
}
return (
<FocusZone>
<List
className={styles.folderList}
items={this.state.lists}
onRenderCell={this._onRenderCell}
/>
</FocusZone>
);
}
/**
* Renders a file folder cover
*/
private _onRenderCell = (item: ILibrary, index: number | undefined): JSX.Element => {
return (
<div
className={styles.listCell}
data-is-focusable={true}
>
<div className={styles.cell}>
<div className={styles.cellContent}>
<a className={styles.link} onClick={(_event) => this._handleOpenLibrary(item)} >
<span className={styles.aboveNameplate}>
<span className={styles.content}>
<div className={css(styles.folderCover, styles.isLarge)}>
<img src={strings.FolderBackPlate} className={styles.folderCoverBack}></img>
<img src={strings.FolderFrontPlate} className={styles.folderCoverFront} />
</div>
</span>
</span>
<span className={styles.namePlate}><span className={styles.name}>{item.title}</span></span>
</a>
</div>
</div>
</div>
);
}
/**
* Calls parent when library is opened
*/
private _handleOpenLibrary = (library: ILibrary) => {
this.props.onOpenLibrary(library);
}
}

View File

@ -0,0 +1,17 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
export interface IDocumentLibraryBrowserProps {
context: WebPartContext;
onOpenLibrary: (selectedLibrary: ILibrary) => void;
}
export interface IDocumentLibraryBrowserState {
isLoading: boolean;
lists: ILibrary[];
}
export interface ILibrary {
title: string;
absoluteUrl: string;
serverRelativeUrl: string;
}

View File

@ -0,0 +1,2 @@
export * from './DocumentLibraryBrowser';
export * from './DocumentLibraryBrowser.types';

View File

@ -0,0 +1,3 @@
//@import "~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss";
@import "../SiteFilePickerTab.module.scss";

View File

@ -0,0 +1,510 @@
import * as React from 'react';
// Custom styles
import styles from './FileBrowser.module.scss';
// Custom properties and state
import { IFileBrowserProps, IFileBrowserState, IFile, ViewType } from './FileBrowser.types';
// PnP library for navigating through libraries
import { sp, RenderListDataParameters, RenderListDataOptions } from "@pnp/sp";
// Office Fabric
import { Spinner } from 'office-ui-fabric-react/lib/Spinner';
import {
DetailsList,
DetailsListLayoutMode,
Selection,
SelectionMode,
IColumn,
IDetailsRowProps,
DetailsRow
} from 'office-ui-fabric-react/lib/DetailsList';
import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar';
import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu';
const LAYOUT_STORAGE_KEY: string = 'comparerSiteFilesLayout';
// Localized strings
import * as strings from 'PropertyPaneFilePickerStrings';
// OneDrive services
import { OneDriveServices } from '../../../../services/OneDriveServices';
import { FormatBytes, GetAbsoluteDomainUrl } from '../../../../CommonUtils';
/**
* Renders list of file in a list.
* I should have used the PnP ListView control, but I wanted specific behaviour that I didn't
* get with the PnP control.
*/
export default class FileBrowser extends React.Component<IFileBrowserProps, IFileBrowserState> {
private _selection: Selection;
constructor(props: IFileBrowserProps) {
super(props);
// If possible, load the user's favourite layout
const lastLayout: ViewType = localStorage ?
localStorage.getItem(LAYOUT_STORAGE_KEY) as ViewType
: 'list' as ViewType;
const columns: IColumn[] = [
{
key: 'column1',
name: 'Type',
ariaLabel: strings.TypeAriaLabel,
iconName: 'Page',
isIconOnly: true,
fieldName: 'docIcon',
headerClassName: styles.iconColumnHeader,
minWidth: 16,
maxWidth: 16,
onColumnClick: this._onColumnClick,
onRender: (item: IFile) => {
const folderIcon: string = strings.FolderIconUrl;
const iconUrl: string = strings.PhotoIconUrl;
const altText: string = item.isFolder ? strings.FolderAltText : strings.ImageAltText.replace('{0}', item.fileType);
return <div className={styles.fileTypeIcon}>
<img src={item.isFolder ? folderIcon : iconUrl} className={styles.fileTypeIconIcon} alt={altText} title={altText} />
</div>;
}
},
{
key: 'column2',
name: strings.NameField,
fieldName: 'fileLeafRef',
minWidth: 210,
isRowHeader: true,
isResizable: true,
isSorted: true,
isSortedDescending: false,
sortAscendingAriaLabel: strings.SortedAscending,
sortDescendingAriaLabel: strings.SortedDescending,
onColumnClick: this._onColumnClick,
data: 'string',
isPadded: true,
onRender: (item: IFile) => {
if (item.isFolder) {
return <span className={styles.folderItem} onClick={(_event) => this._handleOpenFolder(item)}>{item.fileLeafRef}</span>;
} else {
return <span className={styles.fileItem}>{item.fileLeafRef}</span>;
}
},
},
{
key: 'column3',
name: strings.ModifiedField,
fieldName: 'dateModifiedValue',
minWidth: 120,
isResizable: true,
onColumnClick: this._onColumnClick,
data: 'number',
onRender: (item: IFile) => {
//const dateModified = moment(item.modified).format(strings.DateFormat);
return <span>{item.modified}</span>;
},
isPadded: true
},
{
key: 'column4',
name: strings.ModifiedByField,
fieldName: 'modifiedBy',
minWidth: 120,
isResizable: true,
data: 'string',
onColumnClick: this._onColumnClick,
onRender: (item: IFile) => {
return <span>{item.modifiedBy}</span>;
},
isPadded: true
},
{
key: 'column5',
name: strings.FileSizeField,
fieldName: 'fileSizeRaw',
minWidth: 70,
maxWidth: 90,
isResizable: true,
data: 'number',
onColumnClick: this._onColumnClick,
onRender: (item: IFile) => {
return <span>{item.fileSize ? FormatBytes(item.fileSize, 1) : undefined}</span>;
}
}
];
this._selection = new Selection({
onSelectionChanged: () => {
}
});
this.state = {
columns: columns,
items: [],
isLoading: true,
currentPath: this.props.rootPath,
selectedView: lastLayout
};
}
/**
* Gets the list of files when settings change
* @param prevProps
* @param prevState
*/
public componentDidUpdate(prevProps: IFileBrowserProps, prevState: IFileBrowserState): void {
if (prevState.currentPath !== prevState.currentPath) {
this._getListItems();
}
}
/**
* Gets the list of files when tab first loads
*/
public componentDidMount(): void {
this._getListItems();
}
public render(): React.ReactElement<IFileBrowserProps> {
if (this.state.isLoading) {
return (<Spinner label={strings.Loading} />);
}
return (
<div>
<div className={styles.itemPickerTopBar}>
<CommandBar
items={this._getToolbarItems()}
farItems={this.getFarItems()}
/>
</div>
<DetailsList
items={this.state.items}
compact={this.state.selectedView === 'compact'}
columns={this.state.columns}
selectionMode={SelectionMode.single}
setKey="set"
layoutMode={DetailsListLayoutMode.justified}
isHeaderVisible={true}
selection={this._selection}
selectionPreservedOnEmptyClick={true}
onActiveItemChanged={(item: IFile, index: number, ev: React.FormEvent<Element>) => this._itemChangedHandler(item, index, ev)}
enterModalSelectionOnTouch={true}
onRenderRow={this._onRenderRow}
/>
{this.state.items === undefined || this.state.items.length < 1 &&
this._renderEmptyFolder()
}
</div>
);
}
/**
* Renders a placeholder to indicate that the folder is empty
*/
private _renderEmptyFolder = (): JSX.Element => {
return (<div className={styles.emptyFolder}>
<div className={styles.emptyFolderImage}>
<img
className={styles.emptyFolderImageTag}
src={strings.OneDriveEmptyFolderIconUrl}
alt={strings.OneDriveEmptyFolderAlt} />
</div>
<div role="alert">
<div className={styles.emptyFolderTitle}>
{strings.OneDriveEmptyFolderTitle}
</div>
<div className={styles.emptyFolderSubText}>
<span className={styles.emptyFolderPc}>
{strings.OneDriveEmptyFolderDescription}
</span>
{/* Removed until we add support to upload */}
{/* <span className={styles.emptyFolderMobile}>
Tap <Icon iconName="Add" className={styles.emptyFolderIcon} /> to add files here.
</span> */}
</div>
</div>
</div>);
}
private _onRenderRow = (props: IDetailsRowProps): JSX.Element => {
const fileItem: IFile = props.item;
return <DetailsRow {...props} className={fileItem.isFolder ? styles.folderRow : styles.fileRow} />;
}
/**
* Get the list of toolbar items on the left side of the toolbar.
* We leave it empty for now, but we may add the ability to upload later.
*/
private _getToolbarItems = (): ICommandBarItemProps[] => {
return [
];
}
private getFarItems = (): ICommandBarItemProps[] => {
const { selectedView } = this.state;
let viewIconName: string = undefined;
let viewName: string = undefined;
switch (this.state.selectedView) {
case 'list':
viewIconName = 'List';
viewName = strings.ListLayoutList;
break;
case 'compact':
viewIconName = 'AlignLeft';
viewName = strings.ListLayoutCompact;
break;
default:
viewIconName = 'GridViewMedium';
viewName = strings.ListLayoutTile;
}
const farItems: ICommandBarItemProps[] = [
{
key: 'listOptions',
className: styles.commandBarNoChevron,
title: strings.ListOptionsTitle,
ariaLabel: strings.ListOptionsAlt.replace('{0}', viewName),
iconProps: {
iconName: viewIconName
},
iconOnly: true,
subMenuProps: {
items: [
{
key: 'list',
name: strings.ListLayoutList,
iconProps: {
iconName: 'List'
},
canCheck: true,
checked: this.state.selectedView === 'list',
ariaLabel: strings.ListLayoutAriaLabel.replace('{0}', strings.ListLayoutList).replace('{1}', selectedView === 'list' ? strings.Selected : undefined),
title: strings.ListLayoutListDescrition,
onClick: (_ev?: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, item?: IContextualMenuItem) => this._handleSwitchLayout(item)
},
{
key: 'compact',
name: strings.ListLayoutCompact,
iconProps: {
iconName: 'AlignLeft'
},
canCheck: true,
checked: this.state.selectedView === 'compact',
ariaLabel: strings.ListLayoutAriaLabel.replace('{0}', strings.ListLayoutCompact).replace('{1}', selectedView === 'compact' ? strings.Selected : undefined),
title: strings.ListLayoutCompactDescription,
onClick: (_ev?: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, item?: IContextualMenuItem) => this._handleSwitchLayout(item)
},
{
key: 'tiles',
name: 'Tiles',
iconProps: {
iconName: 'GridViewMedium'
},
canCheck: true,
checked: this.state.selectedView === 'tiles',
ariaLabel: strings.ListLayoutAriaLabel.replace('{0}', strings.ListLayoutTile).replace('{1}', selectedView === 'tiles' ? strings.Selected : undefined),
title: strings.ListLayoutTileDescription,
onClick: (_ev?: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>, item?: IContextualMenuItem) => this._handleSwitchLayout(item)
}
]
}
}
];
return farItems;
}
/**
* Called when users switch the view
*/
private _handleSwitchLayout = (item?: IContextualMenuItem) => {
if (item) {
// Store the user's favourite layout
if (localStorage) {
localStorage.setItem(LAYOUT_STORAGE_KEY, item.key);
}
this.setState({
selectedView: item.key as ViewType
});
}
}
/**
* Gratuitous sorting
*/
private _onColumnClick = (event: React.MouseEvent<HTMLElement>, column: IColumn): void => {
const { columns } = this.state;
let { items } = this.state;
let isSortedDescending = column.isSortedDescending;
// If we've sorted this column, flip it.
if (column.isSorted) {
isSortedDescending = !isSortedDescending;
}
// Sort the items.
items = items!.concat([]).sort((a, b) => {
const firstValue = a[column.fieldName || ''];
const secondValue = b[column.fieldName || ''];
if (isSortedDescending) {
return firstValue > secondValue ? -1 : 1;
} else {
return firstValue > secondValue ? 1 : -1;
}
});
// Reset the items and columns to match the state.
this.setState({
items: items,
columns: columns!.map(col => {
col.isSorted = col.key === column.key;
if (col.isSorted) {
col.isSortedDescending = isSortedDescending;
}
return col;
})
});
}
/**
* When a folder is opened, calls parent tab to navigate down
*/
private _handleOpenFolder = (item: IFile) => {
// De-select the list item that was clicked, the item in the same position
// item in the folder will appear selected
this.setState({
fileUrl: undefined,
currentPath: item.fileRef
}, () => this._getListItems());
this.props.onOpenFolder(item);
}
/**
* When user selects an item, save selection
*/
private _itemChangedHandler = (item: IFile, _index: number, _ev): void => {
if (item.isFolder) {
this.setState({
fileUrl: undefined
});
return;
}
// Notify parent tab
const absoluteFileUrl: string = item.absoluteRef;
this.props.onChange(absoluteFileUrl);
this.setState({
fileUrl: absoluteFileUrl
});
}
/**
* Gets all files in a library with a matchihg path
*/
private _getListItems() {
this.setState({
isLoading: true
});
const fileFilter: string = OneDriveServices.GetFileTypeFilter(this.props.accepts);
const parms: RenderListDataParameters = {
RenderOptions: RenderListDataOptions.ContextInfo | RenderListDataOptions.ListData | RenderListDataOptions.ListSchema | RenderListDataOptions.ViewMetadata | RenderListDataOptions.EnableMediaTAUrls | RenderListDataOptions.ParentInfo,//4231, //4103, //4231, //192, //64
AllowMultipleValueFilterForTaxonomyFields: true,
FolderServerRelativeUrl: this.state.currentPath,
ViewXml:
`<View>
<Query>
<Where>
<Or>
<And>
<Eq>
<FieldRef Name="FSObjType" />
<Value Type="Text">1</Value>
</Eq>
<Eq>
<FieldRef Name="SortBehavior" />
<Value Type="Text">1</Value>
</Eq>
</And>
<In>
<FieldRef Name="File_x0020_Type" />
${fileFilter}
</In>
</Or>
</Where>
</Query>
<ViewFields>
<FieldRef Name="DocIcon"/>
<FieldRef Name="LinkFilename"/>
<FieldRef Name="Modified"/>
<FieldRef Name="Editor"/>
<FieldRef Name="FileSizeDisplay"/>
<FieldRef Name="SharedWith"/>
<FieldRef Name="MediaServiceFastMetadata"/>
<FieldRef Name="MediaServiceOCR"/>
<FieldRef Name="_ip_UnifiedCompliancePolicyUIAction"/>
<FieldRef Name="ItemChildCount"/>
<FieldRef Name="FolderChildCount"/>
<FieldRef Name="SMTotalFileCount"/>
<FieldRef Name="SMTotalSize"/>
</ViewFields>
<RowLimit Paged="TRUE">100</RowLimit>
</View>`
};
sp.web.lists.getByTitle(this.props.libraryName).renderListDataAsStream(parms).then((value: any) => {
const fileItems: IFile[] = value.ListData.Row.map(fileItem => {
const modifiedFriendly: string = fileItem["Modified.FriendlyDisplay"];
// Get the modified date
const modifiedParts: string[] = modifiedFriendly!.split('|');
let modified: string = fileItem.Modified;
// If there is a friendly modified date, use that
if (modifiedParts.length === 2) {
modified = modifiedParts[1];
}
const file: IFile = {
fileLeafRef: fileItem.FileLeafRef,
docIcon: fileItem.DocIcon,
fileRef: fileItem.FileRef,
modified: modified,
fileSize: fileItem.File_x0020_Size,
fileType: fileItem.File_x0020_Type,
modifiedBy: fileItem.Editor![0]!.title,
isFolder: fileItem.FSObjType === "1",
absoluteRef: this._buildAbsoluteUrl(fileItem.FileRef)
};
return file;
});
// de-select anything that was previously selected
this._selection.setAllSelected(false);
this.setState({
items: fileItems,
isLoading: false
});
});
}
/**
* Creates an absolute URL
*/
private _buildAbsoluteUrl = (relativeUrl: string) => {
const siteUrl: string = GetAbsoluteDomainUrl(this.props.context.pageContext.web.absoluteUrl);
return siteUrl + relativeUrl;
}
}

View File

@ -0,0 +1,34 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { IColumn } from 'office-ui-fabric-react/lib/DetailsList';
export interface IFileBrowserProps {
libraryName: string;
rootPath: string;
accepts: string;
context: WebPartContext;
onChange: (imageUrl: string) => void;
onOpenFolder: (folder: IFile) => void;
}
export interface IFileBrowserState {
isLoading: boolean;
items: IFile[];
currentPath: string;
fileUrl?: string;
columns: IColumn[];
selectedView: ViewType;
}
export interface IFile {
docIcon: string;
fileRef: string;
fileLeafRef: string;
modifiedBy?: string;
modified: string;
fileType?: string;
fileSize?: number;
isFolder: boolean;
absoluteRef: string;
}
export type ViewType = 'list' | 'compact' | 'tiles';

View File

@ -0,0 +1,2 @@
export * from './FileBrowser';
export * from './FileBrowser.types';

View File

@ -0,0 +1,2 @@
@import "../PropertyPaneFilePicker.module.scss";

View File

@ -0,0 +1,107 @@
import * as React from 'react';
// Custom styles
import styles from './SiteFilePickerTab.module.scss';
// Custom picker interface
import { ISiteFilePickerTabProps, ISiteFilePickerTabState } from '.';
import { ILibrary } from './DocumentLibraryBrowser/DocumentLibraryBrowser.types';
import FileBrowser from './FileBrowser/FileBrowser';
import { IFile } from './FileBrowser/FileBrowser.types';
import DocumentLibraryBrowser from './DocumentLibraryBrowser/DocumentLibraryBrowser';
// Office Fabric
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/components/Button';
// Localized strings
import * as strings from 'PropertyPaneFilePickerStrings';
export default class SiteFilePickerTab extends React.Component<ISiteFilePickerTabProps, ISiteFilePickerTabState> {
constructor(props: ISiteFilePickerTabProps) {
super(props);
this.state = {
libraryAbsolutePath: undefined,
libraryTitle: strings.DocumentLibraries,
libraryPath: undefined,
folderName: strings.DocumentLibraries
};
}
public render(): React.ReactElement<ISiteFilePickerTabProps> {
return (
<div className={styles.tabContainer}>
<div className={styles.tabHeaderContainer}>
<h2 className={styles.tabHeader}>{this.state.folderName}</h2>
</div>
<div className={styles.tab}>
{this.state.libraryAbsolutePath === undefined &&
<DocumentLibraryBrowser
context={this.props.context}
onOpenLibrary={(selectedLibrary: ILibrary) => this._handleOpenLibrary(selectedLibrary)} />}
{this.state.libraryAbsolutePath !== undefined &&
<FileBrowser
onChange={(fileUrl: string) => this._handleSelectionChange(fileUrl)}
onOpenFolder={(folder: IFile) => this._handleOpenFolder(folder)}
context={this.props.context}
libraryName={this.state.libraryTitle}
rootPath={this.state.libraryPath}
accepts={this.props.accepts} />}
</div>
<div className={styles.actionButtonsContainer}>
<div className={styles.actionButtons}>
<PrimaryButton
disabled={!this.state.fileUrl}
onClick={() => this._handleSave()} className={styles.actionButton}>{strings.OpenButtonLabel}</PrimaryButton>
<DefaultButton onClick={() => this._handleClose()} className={styles.actionButton}>{strings.CancelButtonLabel}</DefaultButton>
</div>
</div>
</div>
);
}
/**
* Is called when user selects a different file
*/
private _handleSelectionChange = (imageUrl: string) => {
this.setState({
fileUrl: imageUrl
});
}
/**
* Called when user saves
*/
private _handleSave = () => {
this.props.onSave(encodeURI(this.state.fileUrl));
}
/**
* Called when user closes tab
*/
private _handleClose = () => {
this.props.onClose();
}
/**
* Triggered when user opens a file folder
*/
private _handleOpenFolder = (folder: IFile) => {
this.setState({
libraryPath: folder.fileRef,
folderName: folder.fileLeafRef,
libraryAbsolutePath: folder.absoluteRef
});
}
/**
* Triggered when user opens a top-level document library
*/
private _handleOpenLibrary = (library: ILibrary) => {
this.setState({
libraryAbsolutePath: library.absoluteUrl,
libraryTitle: library.title,
libraryPath: library.serverRelativeUrl
});
}
}

View File

@ -0,0 +1,13 @@
import { IFilePickerTab } from "../IFilePickerTab.types";
export interface ISiteFilePickerTabProps extends IFilePickerTab {
//inherited
}
export interface ISiteFilePickerTabState {
fileUrl?: string;
libraryAbsolutePath: string;
libraryTitle: string;
libraryPath: string;
folderName: string;
}

View File

@ -0,0 +1,2 @@
export * from './SiteFilePickerTab';
export * from './SiteFilePickerTab.types';

View File

@ -0,0 +1,26 @@
@import "../PropertyPaneFilePicker.module.scss";
.localTabSinglePreview {
margin-bottom: 20px;
}
.localTabSinglePreviewImage {
display: block;
max-width: 100%;
max-height: 220px;
image-orientation: from-image;
}
.localTabLabel {
color: $ms-color-themePrimary;
cursor: pointer;
}
.localTabInput {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
}

View File

@ -0,0 +1,97 @@
import * as React from 'react';
// Makes thingy pretty
import styles from './UploadFilePickerTab.module.scss';
// Needed for our custom pane tab
import { IUploadFilePickerTabProps, IUploadFilePickerTabState } from '.';
import { ItemType } from '../IPropertyPaneFilePicker';
// Office Fabric to the rescue!
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/components/Button';
// Localization
import * as strings from 'PropertyPaneFilePickerStrings';
export default class UploadFilePickerTab extends React.Component<IUploadFilePickerTabProps, IUploadFilePickerTabState> {
constructor(props: IUploadFilePickerTabProps) {
super(props);
this.state = {
fileUrl: undefined,
fileName: undefined
};
}
public render(): React.ReactElement<IUploadFilePickerTabProps> {
const { fileUrl, fileName } = this.state;
const imageType: boolean = this.props.itemType === ItemType.Images;
return (
<div className={styles.tabContainer}>
<div className={styles.tabHeaderContainer}>
<h2 className={styles.tabHeader}>{imageType ? strings.UploadImageHeader : strings.UploadFileHeader}</h2>
</div>
<div className={styles.tab}>
<input
className={styles.localTabInput}
type="file" id="fileInput"
accept={this.props.accepts} multiple={false} onChange={(event: React.ChangeEvent<HTMLInputElement>) => this._handleFileUpload(event)} />
{fileUrl && <div className={styles.localTabSinglePreview}>
<img className={styles.localTabSinglePreviewImage} src={fileUrl} alt={fileName} />
</div>}
<label className={styles.localTabLabel} htmlFor="fileInput">{
imageType ?
(fileUrl ? strings.ChangeImageLinkLabel : strings.ChooseImageLinkLabel) :
(fileUrl ? strings.ChangeFileLinkLabel : strings.ChooseFileLinkLabel)
}</label>
</div>
<div className={styles.actionButtonsContainer}>
<div className={styles.actionButtons}>
<PrimaryButton
disabled={fileUrl === undefined}
onClick={() => this._handleSave()} className={styles.actionButton}>{imageType ? strings.AddImageButtonLabel : strings.AddFileButtonLabel}</PrimaryButton>
<DefaultButton onClick={() => this._handleClose()} className={styles.actionButton}>{strings.CancelButtonLabel}</DefaultButton>
</div>
</div>
</div>
);
}
/**
* Gets called when a file is uploaded
*/
private _handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files || event.target.files.length < 1) {
return;
}
// Get the files that were uploaded
let files = event.target.files;
// Grab the first file -- there should always only be one
const file = files[0];
// Convert to base64 image
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
this.setState({
fileUrl: reader.result as string,
fileName: file.name
});
};
}
/**
* Saves base64 encoded image back to property pane file picker
*/
private _handleSave = () => {
this.props.onSave(encodeURI(this.state.fileUrl));
}
/**
* Closes tab without saving
*/
private _handleClose = () => {
this.props.onClose();
}
}

View File

@ -0,0 +1,10 @@
import { IFilePickerTab } from "../IFilePickerTab.types";
export interface IUploadFilePickerTabProps extends IFilePickerTab {
// inherited from the base interface
}
export interface IUploadFilePickerTabState {
fileUrl?: string;
fileName?: string;
}

View File

@ -0,0 +1,2 @@
export * from './UploadFilePickerTab';
export * from './UploadFilePickerTab.types';

View File

@ -0,0 +1,42 @@
interface IBingSearchResult {
webSearchUrl: string;
webSearchUrlPingSuffix: string;
name: string;
thumbnailUrl: string;
datePublished: string;
isFamilyFriendly: boolean;
creativeCommons: string;
contentUrl: string;
contentUrlPingSuffix: string;
hostPageUrl: string;
hostPageUrlPingSuffix: string;
contentSize: string;
encodingFormat: string;
hostPageDisplayUrl: string;
width: number;
height: number;
thumbnail: Thumbnail;
imageInsightsToken: string;
insightsMetadata: InsightsMetadata;
imageId: string;
accentColor: string;
}
interface InsightsMetadata {
pagesIncludingCount: number;
availableSizesCount: number;
recipeSourcesCount?: number;
bestRepresentativeQuery?: BestRepresentativeQuery;
}
interface BestRepresentativeQuery {
text: string;
displayText: string;
webSearchUrl: string;
webSearchUrlPingSuffix: string;
}
interface Thumbnail {
width: number;
height: number;
}

View File

@ -0,0 +1,267 @@
@import "../PropertyPaneFilePicker.module.scss";
.searchBoxContainer {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-flow: row wrap;
flex-flow: row wrap;
margin-bottom: 37px;
.searchBoxMedium {
width: 400px;
}
.searchBox {
margin-bottom: 0;
}
.dropdownContainer {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
:global(.ms-Dropdown-title) {
border: 0;
}
.filterDropdown {
width: auto;
}
}
}
[dir="ltr"] .searchBoxContainer {
margin-left: 33px;
.searchBox {
margin-right: 35px;
color: inherit;
}
}
.filePickerFolderCardGrid {
overflow: hidden;
font-size: 0;
:global {
.ms-List-page {
overflow: hidden;
font-size: 0;
}
.ms-List-surface {
position: relative;
}
}
}
.filePickerFolderCardTile {
text-align: center;
outline: none;
position: relative;
@include ms-float(left);
}
.filePickerFolderCardPadder {
position: absolute;
left: 5px;
top: 5px;
right: 5px;
bottom: 5px;
}
:global(.ms-Fabric--isFocusVisible) .filePickerFolderCardTile:focus:after {
content: "";
position: absolute;
left: 2px;
right: 2px;
top: 2px;
bottom: 2px;
box-sizing: border-box;
border: 1px solid $ms-color-white;
}
.filePickerFolderCardSizer {
padding-bottom: 100%;
}
.filePickerFolderCardImage {
width: 100%;
left: 0;
top: 0;
color: $ms-color-white;
position: absolute;
bottom: 0;
font-size: 12px;
width: 100%;
}
.filePickerFolderCardLabel {
background-color: rgba(0, 0, 0, 0.6);
height: 100%;
width: 100%;
top: 0;
color: $ms-color-white;
padding: 10px;
position: absolute;
bottom: 0;
font-size: 12px;
left: 0;
width: 100%;
box-sizing: border-box;
font-size: 21px;
font-weight: 600;
&:hover {
color: $ms-color-neutralLight;
background-color: rgba(0, 0, 0, 0.6);
}
&:active {
background-color: $ms-color-neutralTertiaryAlt;
color: $ms-color-neutralDark;
}
}
[dir="ltr"] .bingGridListCell {
margin-right: 8px;
}
.bingGridListCell {
margin-bottom: 8px;
}
.bingTile {
-webkit-box-sizing: border-box;
box-sizing: border-box;
cursor: pointer;
display: inline-block;
overflow: hidden;
position: relative;
-webkit-transition: -webkit-transform 0.1s linear;
transition: -webkit-transform 0.1s linear;
transition: transform 0.1s linear;
transition: transform 0.1s linear, -webkit-transform 0.1s linear;
left: 0;
right: 0;
top: 0;
bottom: 0;
&:focus {
outline: 0;
}
&:active {
-webkit-transform: scale(0.96);
transform: scale(0.96);
}
&:focus:after {
content: "";
position: absolute;
right: 2px;
left: 2px;
top: 2px;
bottom: 2px;
border: 1px solid;
border-color: $ms-color-white;
-webkit-box-shadow: 0 0 0 2px $ms-color-neutralSecondaryAlt;
box-shadow: 0 0 0 2px $ms-color-neutralSecondaryAlt;
}
}
.bingTileContent {
border: 1px solid;
border-color: $ms-color-white;
width: 100%;
height: 100%;
}
.bingTileFrame {
position: absolute;
display: none;
left: 2px;
top: 2px;
right: 2px;
bottom: 2px;
-webkit-box-shadow: 0 0 0 2px $ms-color-neutralTertiaryAlt;
box-shadow: 0 0 0 2px $ms-color-neutralTertiaryAlt;
outline: 1px solid transparent;
}
[dir="ltr"] .bingTileCheckCircle {
right: 8px;
}
.bingTileCheckCircle {
display: block;
position: absolute;
top: 6px;
opacity: 0;
background: 0 0;
border: none;
outline: 0;
top: 8px;
}
.bingTileNamePlate {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 4px 8px 8px;
background-color: $ms-color-white;
color: $ms-color-neutralPrimary;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0.95;
text-decoration: underline;
font-size: 12px;
}
.bingTile.isSelected .bingTileCheckCircle,
.bingTile:hover,
.bingTile:hover .bingTileCheckCircle {
opacity: 1;
}
[dir="ltr"] .copyrightLabel {
padding-left: 32px;
}
.copyrightLabel {
font-size: 12px;
}
.noResultLabel {
padding: 12px;
}
.bingTileThumbnail {
position: absolute;
height: 100%;
width: 100%;
top: 0;
img {
display: block;
opacity: 1;
width: 100%;
height: auto;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
animation-duration: 0.367s;
animation-timing-function: cubic-bezier(0.1, 0.25, 0.75, 0.9);
animation-fill-mode: both;
}
}
.bingGrildList :global(.ms-List-cell),
.bingGrildList :global(.ms-List-page) {
display: inline-block;
overflow: hidden;
}

View File

@ -0,0 +1,579 @@
import * as React from 'react';
// Good looks
import styles from './WebSearchTab.module.scss';
// Localization
import * as strings from 'PropertyPaneFilePickerStrings';
// Types
import { IWebSearchTabProps, IWebSearchTabState, ISearchSuggestion, ISearchResult, ImageSize, ImageAspect, ImageLicense } from './WebSearchTab.types';
// Offce Fabric stuff
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/components/Button';
import { Label } from 'office-ui-fabric-react/lib/Label';
import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox';
import { Check } from 'office-ui-fabric-react/lib/Check';
import { Dropdown, IDropdownProps, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown';
import { Image, ImageFit } from 'office-ui-fabric-react/lib/Image';
import { Link } from 'office-ui-fabric-react/lib/Link';
import { FocusZone } from 'office-ui-fabric-react/lib/FocusZone';
import { List } from 'office-ui-fabric-react/lib/List';
import { IRectangle } from 'office-ui-fabric-react/lib/Utilities';
import { Selection, SelectionMode, SelectionZone } from 'office-ui-fabric-react/lib/Selection';
import { MessageBar } from 'office-ui-fabric-react/lib/MessageBar';
// Used for HTTP requests
import { SPHttpClient, IHttpClientOptions, SPHttpClientResponse } from '@microsoft/sp-http';
// CSS utility to combine classes dynamically
import { css } from '@uifabric/utilities/lib/css';
/**
* Rows per page
*/
const ROWS_PER_PAGE = 3;
/**
* Maximum row height
*/
const MAX_ROW_HEIGHT = 250;
/**
* Maximum file size when searching
*/
const MAXFILESIZE = 52428800;
/**
* Maximum number of search results
*/
const MAXRESULTS = 100;
/**
* This is the default search suggestions as of Jan 2019.
* I have no idea where Bing gets them.
* But you can provide your own when calling this component
*/
const DEFAULT_SUGGESTIONS: ISearchSuggestion[] = [
{
topic: 'Backgrounds',
backgroundUrl: 'https://spoprod-a.akamaihd.net/files/sp-client-prod_2019-01-11.008/background_b4f5f0fd0af42d6dc969f795cb65a13c.jpg'
},
{
topic: 'Classrooms',
backgroundUrl: 'https://spoprod-a.akamaihd.net/files/sp-client-prod_2019-01-11.008/classroom_a0b3addf2246028cb7486ddfb0783c6c.jpg'
},
{
topic: 'Conferences',
backgroundUrl: 'https://spoprod-a.akamaihd.net/files/sp-client-prod_2019-01-11.008/conference_b450359b0962cf464f691b68d7c6ecd1.jpg'
},
{
topic: 'Meetings',
backgroundUrl: 'https://spoprod-a.akamaihd.net/files/sp-client-prod_2019-01-11.008/meeting_694397debfa52bc06a952310af01d59d.jpg'
},
{
topic: 'Patterns',
backgroundUrl: 'https://spoprod-a.akamaihd.net/files/sp-client-prod_2019-01-11.008/pattern_6e7c8fd91c9b5fa47519aa3fd4a95a82.jpg'
},
{
topic: 'Teamwork',
backgroundUrl: 'https://spoprod-a.akamaihd.net/files/sp-client-prod_2019-01-11.008/teamwork_5841da2ae9b9424173f601d86e3a252c.jpg'
},
{
topic: 'Technology',
backgroundUrl: 'https://spoprod-a.akamaihd.net/files/sp-client-prod_2019-01-11.008/technology_9a8a4e09c090c65f4c0b3ea06bd48b83.jpg'
},
{
topic: 'Scenery',
backgroundUrl: 'https://spoprod-a.akamaihd.net/files/sp-client-prod_2019-01-11.008/scenery_abe5bfb8f3913bd52be279a793472ead.jpg'
}
];
/**
* The tenant storage key to use when storing the Bing API key.
*/
const BINGAPI_TENANT_STORAGEKEY: string = 'BingApi';
/**
* Renders search suggestions and performs seach queries
*/
export default class WebSearchTab extends React.Component<IWebSearchTabProps, IWebSearchTabState> {
private _columnCount: number;
private _columnWidth: number;
private _rowHeight: number;
private _selection: Selection;
private _listElem: List = undefined;
constructor(props: IWebSearchTabProps) {
super(props);
this._selection = new Selection(
{
selectionMode: SelectionMode.single,
onSelectionChanged: () => {
// Get the selected item
const selectedItems = this._selection.getSelection();
if (selectedItems && selectedItems.length > 0) {
//Get the selected key
const selectedKey: ISearchResult = selectedItems[0] as ISearchResult;
//Brute force approach to making sure all URLs are loading over HTTPS
// even if it breaks the page.
const selectedUrl: string = selectedKey.contentUrl.replace('http://', 'https://');
// Save the selected file
this.setState({
fileUrl: selectedUrl
});
} else {
// Remove any selected file
this.setState({
fileUrl: undefined
});
}
if (this._listElem) {
// Force the list to update to show the selection check
this._listElem.forceUpdate();
}
}
});
this.state = {
isLoading: true,
hasKey: undefined,
results: undefined,
};
}
/**
* Get the API key
*/
public componentDidMount(): void {
// Find out if we should show anything
this._getAPIKey();
}
/**
* Render the tab
*/
public render(): React.ReactElement<IWebSearchTabProps> {
const { hasKey, query, results } = this.state;
return (
<div className={styles.tabContainer}>
<div className={styles.tabHeaderContainer}>
<h2 className={styles.tabHeader}>{strings.WebSearchLinkLabel}</h2>
{hasKey && this._renderSearchBox()}
</div>
<div className={styles.tab}>
{hasKey === false && strings.SorryWebSearch} {/* If we verified we don't have a key, give a little Sorry message */}
{hasKey && !query && this._renderSearchSuggestions()} {/* No search yet, show suggestions */}
{query && results && this._renderSearchResults()} {/* Got results, show them */}
</div>
<div className={styles.actionButtonsContainer}>
{this.state.results && this.state.license === 'Any' && <MessageBar>
{strings.CreativeCommonsMessage}
</MessageBar>}
<Label className={styles.copyrightLabel}>
{strings.CopyrightWarning}&nbsp;&nbsp;
<Link target='_blank' href={strings.CopyrightUrl}>{strings.LearnMoreLink}</Link>
</Label>
<div className={styles.actionButtons}>
<PrimaryButton
disabled={this.state.fileUrl === undefined}
className={styles.actionButton}
onClick={() => this._handleSave()}
>{strings.OpenButtonLabel}</PrimaryButton>
<DefaultButton onClick={() => this._handleClose()} className={styles.actionButton}>{strings.CancelButtonLabel}</DefaultButton>
</div>
</div>
</div>
);
}
/**
* Renders the returned search results
*/
private _renderSearchResults = (): JSX.Element => {
const { results } = this.state;
// If there are no results, tell 'em.
if (results === undefined || results.length < 1) {
return <Label className={styles.noResultLabel}>{strings.NoResultsBadEnglish}</Label>;
}
return (
<FocusZone>
<SelectionZone selection={this._selection}
onItemInvoked={(item: ISearchResult) => this._selection.setKeySelected(item.key, true, true)}
>
<List
ref={this._linkElement}
className={styles.bingGrildList}
items={this.state.results}
getItemCountForPage={this._getItemCountForPage}
getPageHeight={this._getPageHeight}
renderedWindowsAhead={4}
onRenderCell={this._onRenderSearchResultsCell}
/>
</SelectionZone>
</FocusZone>
);
}
/**
* Show an individual search result item
*/
private _onRenderSearchResultsCell = (item: ISearchResult, index: number | undefined): JSX.Element => {
const { query } = this.state;
let isSelected: boolean = false;
if (this._selection && index !== undefined) {
isSelected = this._selection.isIndexSelected(index);
}
// The logic for calculating the thumbnail dimensions is not quite the same as the out-of-the-box file picker,
// but it'll have to do.
// Find the aspect ratio of the picture
const ratio: number = item.width / item.height;
// Fit the height to the desired row height
let thumbnailHeight: number = Math.min(this._rowHeight, item.height);
// Resize the picture with the same aspect ratio
let thumbnailWidth: number = thumbnailHeight * ratio;
const searchResultAltText: string = strings.SearchResultAlt.replace('{0}', query);
return (
<div
className={styles.bingGridListCell}
style={{
width: 100 / this._columnCount + '%'
}}
>
<div
aria-label={searchResultAltText}
className={css(styles.bingTile, isSelected ? styles.isSelected : undefined)}
data-is-focusable={true}
data-selection-index={index}
style={{
width: `${thumbnailWidth}px`,
height: `${thumbnailHeight}px`
}}>
<div className={styles.bingTileContent} data-selection-invoke={true}>
<Image src={item.thumbnailUrl} className={styles.bingTileThumbnail} alt={searchResultAltText} width={thumbnailWidth} height={thumbnailHeight} />
<div className={styles.bingTileFrame}></div>
<div className={styles.bingTileCheckCircle}
role='checkbox'
aria-checked={isSelected}
data-item-index={index} data-selection-toggle={true} data-automationid='CheckCircle'>
<Check checked={isSelected} />
</div>
<div className={styles.bingTileNamePlate}>
<Link
href={item.contentUrl}
target='_blank'
aria-label={strings.SearchResultAriaLabel}
>{item.displayUrl}</Link>
</div>
</div>
</div>
</div>);
}
/**
* Renders suggestions when there aren't any queries
*/
private _renderSearchSuggestions = (): JSX.Element => {
const suggestions: ISearchSuggestion[] = this.props.suggestions !== undefined ? this.props.suggestions : DEFAULT_SUGGESTIONS;
return (
<FocusZone>
<List
className={styles.filePickerFolderCardGrid}
items={suggestions}
getItemCountForPage={this._getItemCountForPage}
getPageHeight={this._getPageHeight}
renderedWindowsAhead={4}
onRenderCell={this._onRenderSuggestionCell}
/>
</FocusZone>
);
}
/**
* Gets search results from Bing
*/
private _getSearchResults = () => {
const aspect: string = this.state.aspect ? this.state.aspect : 'All';
const size: string = this.state.size ? this.state.size : 'All';
const license: string = this.state.license ? this.state.license : 'Any';
const { query } = this.state;
if (query === undefined) {
// No query
return;
}
// Show a loading indicator
this.setState({ isLoading: true });
// Use this client option to prevent CORS issues.
const httpClientOptions: IHttpClientOptions = {
headers: new Headers(),
method: 'GET',
mode: 'cors'
};
// Submit the request
const apiUrl: string = `https://www.bingapis.com/api/v7/images/search?appid=${this.state.apiKey}&traffictype=Internal_monitor&q=${encodeURIComponent(query)}&count=${MAXRESULTS}&aspect=${aspect}&maxFileSize=${MAXFILESIZE}&mkt=en-US&size=${size}&license=${license}`;
this.props.context.httpClient.get(apiUrl,
SPHttpClient.configurations.v1, httpClientOptions)
.then((response: SPHttpClientResponse) => {
response.json().then((responseJSON: any) => {
// Cast to Bing search results (for type safety)
const bingResults: IBingSearchResult[] = responseJSON.value;
//Convert results to search results
const searchResults: ISearchResult[] = bingResults.map((bingResult: IBingSearchResult) => {
// Get dimensions
const width: number = bingResult!.thumbnail!.width ? bingResult!.thumbnail!.width : bingResult!.width;
const height: number = bingResult!.thumbnail!.height ? bingResult!.thumbnail!.height : bingResult!.height;
// Create a search result
const searchResult: ISearchResult = {
thumbnailUrl: bingResult.thumbnailUrl,
contentUrl: bingResult.contentUrl,
displayUrl: this.getDisplayUrl(bingResult.hostPageDisplayUrl),
key: bingResult.imageId,
width: width,
height: height,
};
return searchResult;
});
// Set the items so that the selection zone can keep track of them
this._selection.setItems(searchResults, true);
// Save results and stop loading indicator
this.setState({
isLoading: false,
results: searchResults
});
});
});
}
/**
* Calculates how many items there should be in the page
*/
private _getItemCountForPage = (itemIndex: number, surfaceRect: IRectangle): number => {
if (itemIndex === 0) {
this._columnCount = Math.ceil(surfaceRect.width / MAX_ROW_HEIGHT);
this._columnWidth = Math.floor(surfaceRect.width / this._columnCount);
this._rowHeight = this._columnWidth;
}
return this._columnCount * ROWS_PER_PAGE;
}
/**
* Gets the height of a list "page"
*/
private _getPageHeight = (): number => {
return this._rowHeight * ROWS_PER_PAGE;
}
/**
* Renders a cell for search suggestions
*/
private _onRenderSuggestionCell = (item: ISearchSuggestion, index: number | undefined): JSX.Element => {
return (
<div
className={styles.filePickerFolderCardTile}
data-is-focusable={true}
style={{
width: 100 / this._columnCount + '%'
}}
>
<div className={styles.filePickerFolderCardSizer}>
<div className={styles.filePickerFolderCardPadder}>
<Image src={item.backgroundUrl} className={styles.filePickerFolderCardImage} imageFit={ImageFit.cover} />
<DefaultButton className={styles.filePickerFolderCardLabel} onClick={(_event) => this._handleSearch(item.topic)}>{item.topic}</DefaultButton>
</div>
</div>
</div>
);
}
/**
* Renders the search box
*/
private _renderSearchBox = (): JSX.Element => {
const { query } = this.state;
const hasQuery: boolean = query !== undefined;
const license: string = this.state.license ? this.state.license : 'All';
return <div className={styles.searchBoxContainer}>
<div className={styles.searchBoxMedium}>
<div className={styles.searchBox}>
<SearchBox
placeholder={strings.SearchBoxPlaceholder}
value={query}
onSearch={newQuery => this._handleSearch(newQuery)}
/>
</div>
</div>
<Label>{strings.PoweredByBing}</Label>
{hasQuery && <div className={styles.dropdownContainer}>
<Dropdown
className={styles.filterDropdown}
placeholder={strings.ImageSizePlaceholderText}
onRenderPlaceHolder={(props: IDropdownProps) => this._renderFilterPlaceholder(props)}
selectedKey={this.state.size}
options={[
{ key: 'All', text: strings.SizeOptionAll },
{ key: 'Small', text: strings.SizeOptionSmall },
{ key: 'Medium', text: strings.SizeOptionMedium },
{ key: 'Large', text: strings.SizeOptionLarge },
{ key: 'Wallpaper', text: strings.SizeOptionExtraLarge }
]}
onChange={(event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number) => this._handleChangeSize(option)}
/>
<Dropdown
className={styles.filterDropdown}
placeholder={strings.ImageLayoutPlaceholderText}
onRenderPlaceHolder={(props: IDropdownProps) => this._renderFilterPlaceholder(props)}
selectedKey={this.state.aspect}
options={[
{ key: 'All', text: strings.LayoutOptionAll },
{ key: 'Square', text: strings.LayoutOptionSquare },
{ key: 'Wide', text: strings.LayoutOptionWide },
{ key: 'Tall', text: strings.LayoutOptionTall },
]}
onChange={(event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number) => this._handleChangeLayout(option)}
/>
<Dropdown
className={styles.filterDropdown}
placeholder={strings.LicensePlaceholderText}
onRenderPlaceHolder={(props: IDropdownProps) => this._renderFilterPlaceholder(props)}
selectedKey={license}
options={[
{ key: 'All', text: strings.LicenseOptionAll },
{ key: 'Any', text: strings.LicenseOptionAny }
]}
onChange={(event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption, index?: number) => this._handleChangeLicense(option)}
/>
</div>}
</div>;
}
/**
* Handles when a user changes the size drop down.
* Resubmits search query
*/
private _handleChangeSize = (option: IDropdownOption) => {
this.setState({
size: option.key as ImageSize
}, () => this._getSearchResults());
}
/**
* Handles when user selects a new layout from the drop down.
* Resubmits search query.
*/
private _handleChangeLayout = (option: IDropdownOption) => {
this.setState({
aspect: option.key as ImageAspect
}, () => this._getSearchResults());
}
/**
* Handles when a user changes the license from the drop down
* Resubits search query
*/
private _handleChangeLicense = (option: IDropdownOption) => {
this.setState({
license: option.key as ImageLicense
}, () => this._getSearchResults());
}
/**
* Renders the drop down placeholders
*/
private _renderFilterPlaceholder = (props: IDropdownProps): JSX.Element => {
return <span>{props.placeholder}</span>;
}
/**
* Handles when user triggers search query
*/
private _handleSearch = (newQuery?: string) => {
this.setState({
query: newQuery
}, () => this._getSearchResults());
}
/**
* Handles when user closes search pane
*/
private _handleClose = () => {
this.props.onClose();
}
/**
* Handes when user saves selection
* Calls property pane file picker's save function
*/
private _handleSave = () => {
this.props.onSave(encodeURI(this.state.fileUrl));
}
/**
* Removes protocol and retrieves only the domain, just like Bing search results does
* in the SharePoint file picker
* @param url The display url as provided by Bing
*/
private getDisplayUrl(url: string): string {
// remove any protocols
if (url.indexOf('://') > -1) {
const urlParts: string[] = url.split('://');
url = urlParts.pop();
}
// Split the URL on the first slash
const splitUrl = url.split('/');
return splitUrl[0];
}
/**
* Retrieves the API key from tenant storage
*/
private _getAPIKey() {
const { absoluteUrl } = this.props.context.pageContext.web;
const apiUrl: string = `${absoluteUrl}/_api/web/GetStorageEntity('${BINGAPI_TENANT_STORAGEKEY}')`;
this.props.context.spHttpClient.get(apiUrl, SPHttpClient.configurations.v1)
.then((response: SPHttpClientResponse) => {
response.json().then((responseJSON: any) => {
this.setState({
isLoading: false,
apiKey: responseJSON.Value,
hasKey: responseJSON.Value !== undefined
});
});
}, () => {
this.setState({
isLoading: false,
hasKey: false
});
});
}
/**
* Creates a reference to the list
*/
private _linkElement = (e: any) => {
this._listElem = e;
}
}

View File

@ -0,0 +1,37 @@
import { IFilePickerTab } from "../IFilePickerTab.types";
export interface IWebSearchTabProps extends IFilePickerTab {
suggestions?: ISearchSuggestion[];
}
export interface IWebSearchTabState {
isLoading: boolean;
apiKey?: string;
hasKey?: boolean;
query?: string;
size?: ImageSize;
aspect?: ImageAspect;
license?: ImageLicense;
results: ISearchResult[];
fileUrl?: string;
}
export interface ISearchSuggestion {
topic: string;
backgroundUrl: string;
}
export interface ISearchResult {
thumbnailUrl: string;
contentUrl: string;
displayUrl: string;
key: string;
width: number;
height: number;
}
export type ImageSize = 'All' | 'Small' | 'Medium' | 'Large' | 'Wallpaper';
export type ImageAspect = 'All' | 'Square' | 'Wide' | 'Tall';
export type ImageLicense = 'All' | 'Any';

View File

@ -0,0 +1,2 @@
export * from './WebSearchTab';
export * from './WebSearchTab.types';

View File

@ -0,0 +1,4 @@
export * from './IPropertyPaneFilePicker';
export * from './IPropertyPaneFilePickerHost';
export * from './PropertyPaneFilePickerHost';
export * from './PropertyPaneFilePicker';

View File

@ -0,0 +1,115 @@
define([], function () {
return {
"AddFileButtonLabel": "Add file",
"AddImageButtonLabel": "Add image",
"AriaCellValue": "{0} column, {1}",
"CancelButtonLabel": "Cancel",
"CantValidateValidationMessage": "We couldn't verify this link. Please check the link and try again.",
"ChangeFileLinkLabel": "Change file",
"ChangeImageLinkLabel": "Change image",
"ChooseFileLinkLabel": "Choose file",
"ChooseImageLinkLabel": "Choose image",
"CopyrightUrl": "https://www.microsoft.com/en-US/legal/copyright/default.aspx",
"CopyrightWarning": "You are responsible for respecting others' rights, including copyright.",
"CreativeCommonsMessage": "These results are tagged with Creative Commons licenses. Review the licenses to ensure you comply.",
"DateFormat": "MM/DD/YYYY hh:mm A",
"DocumentLabelTemplate": "{0}, Document, Modified {1}, edited by {2}, Private",
"DocumentLibraries": "Document libraries",
"EditedByNamePlate": "edited by ",
"EmptyFileSize": "0 bytes",
"FilePickerHeader": "File Picker",
"FileSizeField": "File Size",
"FolderAltText": "Folder",
"FolderBackPlate": "https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/foldericons/folder-large_backplate.svg",
"FolderFrontPlate": "https://static2.sharepointonline.com/files/fabric/office-ui-fabric-react-assets/foldericons/folder-large_frontplate_nopreview.svg",
"FolderIconUrl": "https://spoprod-a.akamaihd.net/files/odsp-next-prod_2019-01-11_20190116.001/odsp-media/images/itemtypes/20/folder.svg",
"FolderLabelTemplate": "{0}, Folder, Modified {1}, edited by {2}, {3} items, Private",
"FromLinkLinkLabel": "From a link",
"ImageAltText": ".{0} Image",
"ImageAriaLabelTemplate": ".{0} Image",
"ImageLayoutPlaceholderText": "Layout",
"ImageSizePlaceholderText": "Image size",
"ItemChildCountField": "Item Child Count",
"LayoutOptionAll": "All",
"LayoutOptionSquare": "Square",
"LayoutOptionTall": "Tall",
"LayoutOptionWide": "Wide",
"LearnMoreLink": "Learn more.",
"LicenseOptionAll": "All",
"LicenseOptionAny": "Creative Commons only",
"LicensePlaceholderText": "License",
"LinkFileInstructions": "Paste a link to a file in OneDrive for Business or SharePoint Online",
"LinkHeader": "From a link",
"LinkImageInstructions": "Paste a link to an image in OneDrive for Business or SharePoint Online",
"ListLayoutAriaLabel": "View options. {0} {1} .",
"ListLayoutCompact": "Compact view",
"ListLayoutCompactDescription": "View items and details in a compact list",
"ListLayoutList": "List view",
"ListLayoutListDescrition": "View items and details in a list",
"ListLayoutTile": "Tile view",
"ListLayoutTileDescription": "View items with tile previews",
"ListOptionsAlt": "View options. {0} selected .",
"ListOptionsTitle": "Open the view options menu",
"Loading": "Loading...",
"ModifiedByField": "Modified By",
"ModifiedField": "Date Modified",
"NameField": "Name",
"No": "No",
"NoExternalLinksValidationMessage": "We only support linking to files in your own organization.",
"NoImageValidationMessage": "This isn't a link to a file type we support. You can only link to an image.",
"NoRecentFiles": "No recent files",
"NoRecentFilesDescription": "Try selecting a file from your site, or upload one from your device.",
"NoResultsBadEnglish": "***There is no result found. Try to change the filter options",
"ODModifiedField": "Modified",
"ODPhotoIconUrl": "https://spoprod-a.akamaihd.net/files/odsp-next-prod_2019-01-18_20190124.001/odsp-media/images/itemtypes/16_2x/photo.png",
"ODRowArialLabelTemplate": "{0}, {1}, Modified {2}, edited by {3}, {4}, {5}",
"OneDriveConfirmDialogBody": "This item is from your OneDrive site. Files and folders in OneDrive are private unless you share them. Have you shared this file with your site members so they can access it?",
"OneDriveConfirmDialogTitle": "Just checking...",
"OneDriveEmptyFolderAlt": "Empty folder",
"OneDriveEmptyFolderDescription": "To add files, go to your OneDrive. You can also add files to this folder using the OneDrive app for your computer.",
"OneDriveEmptyFolderIconUrl": "https://spoprod-a.akamaihd.net/files/odsp-next-prod_2019-01-18_20190124.001/odsp-media/images/emptyfolder/empty_folder.svg",
"OneDriveEmptyFolderTitle": "This folder is empty",
"OneDriveRootFolderName": "Files",
"OpenButtonLabel": "Open",
"PhotoIconUrl": "https://spoprod-a.akamaihd.net/files/odsp-next-prod_2019-01-11_20190116.001/odsp-media/images/itemtypes/20_2x/photo.png",
"PoweredByBing": "Powered by Bing",
"RecentDocumentsHeader": "Recent documents",
"RecentImagesHeader": "Recent images",
"RecentLinkLabel": "Recent",
"SearchBoxPlaceholder": "Web search",
"SearchResultAlt": "Image result for {0}.",
"SearchResultAriaLabel": "Press enter to open the image source in a new tab.",
"Selected": "selected",
"SharingField": "Sharing",
"SharingPrivate": "Private",
"SharingShared": "Shared",
"SiteLinkLabel": "Site",
"SizeOptionAll": "All",
"SizeOptionExtraLarge": "Extra Large",
"SizeOptionLarge": "Large",
"SizeOptionMedium": "Medium",
"SizeOptionSmall": "Small",
"SizeUnit": [
"bytes",
"KB",
"MB",
"GB",
"TB",
"PB",
"EB",
"ZB",
"YB"
],
"SorryWebSearch": "Sorry, this function isn't implemented in this sample, because it would require a Bing API key.",
"SortedAscending": "Sorted A to Z",
"SortedDescending": "Sorted Z to A",
"TypeAriaLabel": "Column operations for File type, Press to sort on File type",
"UploadFileHeader": "Upload file",
"UploadImageHeader": "Upload image",
"UploadLinkLabel": "Upload",
"WebSearchLinkLabel": "Web search",
"Yes": "Yes"
}
});

View File

@ -0,0 +1,107 @@
declare interface IPropertyPaneFilePickerStrings {
AddFileButtonLabel: string;
AddImageButtonLabel: string;
AriaCellValue: string;
CancelButtonLabel: string;
CantValidateValidationMessage: string;
ChangeFileLinkLabel: string;
ChangeImageLinkLabel: string;
ChooseFileLinkLabel: string;
ChooseImageLinkLabel: string;
CopyrightUrl: string;
CopyrightWarning: string;
CreativeCommonsMessage: string;
DateFormat: string;
DocumentLabelTemplate: string;
DocumentLibraries: string;
EditedByNamePlate: string;
EmptyFileSize: string;
FilePickerHeader: string;
FileSizeField: string;
FolderAltText: string;
FolderBackPlate: string;
FolderFrontPlate: string;
FolderIconUrl: string;
FolderLabelTemplate: string;
FromLinkLinkLabel: string;
ImageAltText: string;
ImageAriaLabelTemplate: string;
ImageLayoutPlaceholderText: string;
ImageSizePlaceholderText: string;
ImageSizePlaceholderText: string;
ItemChildCountField: string;
LayoutOptionAll: string;
LayoutOptionSquare: string;
LayoutOptionTall: string;
LayoutOptionWide: string;
LearnMoreLink: string;
LicenseOptionAll: string;
LicenseOptionAny: string;
LicensePlaceholderText: string;
LinkFileInstructions: string;
LinkHeader: string;
LinkImageInstructions: string;
ListLayoutAriaLabel: string;
ListLayoutCompact: string;
ListLayoutCompactDescription: string;
ListLayoutList: string;
ListLayoutListDescrition: string;
ListLayoutTile: string;
ListLayoutTileDescription: string;
ListOptionsAlt: string;
ListOptionsTitle: string;
Loading: string;
ModifiedByField: string;
ModifiedField: string;
NameField: string;
No: string;
NoExternalLinksValidationMessage: string;
NoImageValidationMessage: string;
NoRecentFiles: string;
NoRecentFilesDescription: string;
NoResultsBadEnglish: string;
ODModifiedField: string;
ODPhotoIconUrl: string;
ODRowArialLabelTemplate: string;
OneDriveConfirmDialogBody: string;
OneDriveConfirmDialogTitle: string;
OneDriveEmptyFolderAlt: string;
OneDriveEmptyFolderDescription: string;
OneDriveEmptyFolderIconUrl: string;
OneDriveEmptyFolderTitle: string;
OneDriveRootFolderName: string;
OpenButtonLabel: string;
PhotoIconUrl: string;
PoweredByBing: string;
RecentDocumentsHeader: string;
RecentImagesHeader: string;
RecentLinkLabel: string;
SearchBoxPlaceholder: string;
SearchResultAlt: string;
SearchResultAriaLabel: string;
Selected: string;
SharingField: string;
SharingPrivate: string;
SharingShared: string;
SiteLinkLabel: string;
SizeOptionAll: string;
SizeOptionExtraLarge: string;
SizeOptionLarge: string;
SizeOptionMedium: string;
SizeOptionSmall: string;
SizeUnit: string[];
SorryWebSearch: string;
SortedAscending: string;
SortedDescending: string;
TypeAriaLabel: string;
UploadFileHeader: string;
UploadImageHeader: string;
UploadLinkLabel: string;
WebSearchLinkLabel: string;
Yes: string;
}
declare module 'PropertyPaneFilePickerStrings' {
const strings: IPropertyPaneFilePickerStrings;
export = strings;
}

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,5 @@
export interface IDimensions {
width: number;
height: number;
}

View File

@ -0,0 +1,7 @@
export interface IGetListDataAsStreamRequest {
parameters:{
RenderOptions :number;
ViewXml:string;
AllowMultipleValueFilterForTaxonomyFields:boolean;
};
}

View File

@ -0,0 +1,228 @@
// Generated by https://quicktype.io
export interface IGetListDataAsStreamResult {
wpq: string;
Templates: ITemplates;
ListData: IListData;
ListSchema: IListSchema;
ViewMetadata: IViewMetadata;
BaseViewID: string;
ListTemplateType: string;
listBaseType: number;
noGroupCollapse: boolean;
InlineEdit: boolean;
NavigateForFormsPages: boolean;
BasePermissions: IBasePermissions;
CurrentUserIsSiteAdmin: boolean;
IsAppWeb: boolean;
AllowGridMode: boolean;
inGridMode: boolean;
listTemplate: string;
listName: string;
rootFolder: string;
view: string;
viewTitle: string;
listUrlDir: string;
HttpPath: string;
HttpRoot: string;
NoScriptEnabled: boolean;
imagesPath: string;
PortalUrl: null;
SendToLocationName: string;
SendToLocationUrl: string;
RecycleBinEnabled: number;
OfficialFileName: string;
OfficialFileNames: string;
WriteSecurity: string;
SiteTitle: string;
ListTitle: string;
displayFormUrl: string;
newFormUrl: string;
editFormUrl: string;
ctxId: number;
isXslView: boolean;
IsClientRendering: boolean;
CurrentUserId: number;
isModerated: boolean;
EnableRequestSignOff: boolean;
isForceCheckout: boolean;
EnableMinorVersions: boolean;
verEnabled: boolean;
recursiveView: boolean;
WorkflowsAssociated: boolean;
ContentTypesEnabled: boolean;
DocumentLibraryCalloutOfficeWebAppPreviewersDisabled: boolean;
RegionalSettingsTimeZoneBias: number;
NewWOPIDocumentEnabled: boolean;
NewWOPIDocumentUrl: string;
NewWOPIDocumentTypes: number;
canUserCreateMicrosoftForm: boolean;
AllowCreateFolder: boolean;
CanShareLinkForNewDocument: boolean;
VisioDrawingCreationEnabled: boolean;
SiteTemplateId: number;
TenantTagPolicyEnabled: boolean;
WebExcludeFromOfflineClient: boolean;
ExcludeFromOfflineClient: boolean;
ParentInfo: IParentInfo;
}
export interface IBasePermissions {
ManageLists: boolean;
ManagePersonalViews: boolean;
OpenItems: boolean;
UseClientIntegration: boolean;
}
export interface IListData {
Row: IRow[];
FirstRow: number;
FolderPermissions: string;
LastRow: number;
RowLimit: number;
FilterLink: string;
ForceNoHierarchy: string;
HierarchyHasIndention: string;
FolderId: string;
CurrentFolderProgId: string;
}
export interface IRow {
ID: string;
PermMask: string;
FSObjType: string;
HTML_x0020_File_x0020_Type: string;
UniqueId: string;
ProgId: string;
NoExecute: string;
File_x0020_Type: string;
"File_x0020_Type.mapapp": string;
"HTML_x0020_File_x0020_Type.File_x0020_Type.mapcon": string;
"HTML_x0020_File_x0020_Type.File_x0020_Type.mapico": string;
"serverurl.progid": string;
ServerRedirectedEmbedUrl: string;
"File_x0020_Type.progid": string;
"File_x0020_Type.url": string;
FileRef: string;
FileLeafRef: string;
CheckoutUser: string;
CheckedOutUserId: string;
IsCheckedoutToLocal: string;
_ComplianceFlags: string;
_ShortcutUrl: string;
"_ShortcutUrl.desc": string;
_ShortcutSiteId: string;
_ShortcutWebId: string;
_ShortcutUniqueId: string;
"Created_x0020_Date.ifnew": string;
ContentTypeId: string;
Modified: string;
"Modified.FriendlyDisplay": string;
Editor: IEditor[];
File_x0020_Size: string;
PrincipalCount: string;
MediaServiceFastMetadata: string;
MediaServiceOCR: string;
ItemChildCount: string;
FolderChildCount: string;
SMTotalFileCount: string;
SMTotalSize: string;
SortBehavior: string;
FileSizeDisplay: string;
_ComplianceTag: string;
ContentVersion: string;
DocConcurrencyNumber: string;
_VirusStatus: string;
".spItemUrl": string;
".fileType": string;
".hasThumbnail": string;
".hasVideoManifest": string;
".hasPdf": string;
".hasOfficePreview": string;
".hasBxf": string;
".hasGlb": string;
".hasHtml": string;
".ctag": string;
".etag": string;
}
export interface IEditor {
id: string;
title: string;
email: string;
sip: string;
picture: string;
}
export interface IListSchema {
Field: { [key: string]: string }[];
RequiredFields: any[];
JSLink: any[];
LCID: string;
Userid: string;
PagePath: string;
ShowWebPart: string;
View: string;
RootFolderParam: string;
FieldSortParam: string;
HttpVDir: string;
IsDocLib: string;
UIVersion: string;
NoListItem: string;
NoListItemHowTo: string;
DefaultItemOpen: string;
ForceCheckout: string;
Direction: string;
TabularView: string;
ItemCount: string;
EffectivePresenceEnabled: string;
PresenceAlt: string;
UserDispUrl: string;
SelectedID: string;
ListRight_AddListItems: string;
FolderRight_AddListItems: string;
InplaceSearchEnabled: string;
RenderViewSelectorPivotMenuAsync: string;
ViewSelector_ViewParameters: string;
RenderSaveAsNewViewButton: string;
Toolbar: string;
".accessToken": string;
".driveAccessToken": string;
".driveUrl": string;
".driveAccessTokenV21": string;
".driveUrlV21": string;
".mediaBaseUrl": string;
".mediaBaseUrlSecondary": string;
".pushChannelBaseUrl": string;
".callerStack": string;
".correlationId": string;
".transformUrl": string;
".thumbnailUrl": string;
".videoManifestUrl": string;
".pdfConversionUrl": string;
".officeBundleGenerate": string;
".officeBundleGetFragment": string;
}
export interface IParentInfo {
ParentFolderInfo: IParentFolderInfo[];
}
export interface IParentFolderInfo {
ServerRelativeUrl: string;
Permissions: string;
}
export interface ITemplates {
}
export interface IViewMetadata {
Id: string;
ListViewXml: string;
Paged: boolean;
RowLimit: number;
ServerRelativeUrl: string;
Title: string;
TabularView: boolean;
ViewType: string;
}

View File

@ -0,0 +1,167 @@
// PnP
import { sp, RenderListDataOptions } from "@pnp/sp";
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions } from '@microsoft/sp-http';
import { IGetListDataAsStreamResult, IRow } from './IGetListDataAsStreamResult';
import { GetAbsoluteDomainUrl } from "../../CommonUtils";
export class OneDriveServices {
private _oneDriveUrl: string = undefined;
private _oneDriveFullUrl: string = undefined;
private _context: WebPartContext = undefined;
private _absoluteUrl: string = undefined;
private _serverRelativeFolderUrl: string = undefined;
private _accepts: string = undefined;
/**
*
*/
constructor(context: WebPartContext, accepts: string) {
this._context = context;
this._accepts = accepts;
this._absoluteUrl = this._context.pageContext.web.absoluteUrl;
sp.setup({
sp: { baseUrl: this._absoluteUrl }
});
}
private _getOneDriveRootFolder = (): Promise<string> => {
return sp.profiles.userProfile.then((currentUser) => {
// Get the current user's personal site URL
this._oneDriveUrl = currentUser.FollowPersonalSiteUrl;
// Get the list of ... uh.. lists on the user's personal site
// BaseTemplate 700 and BaseType 1 means document library
const apiUrl: string = `${this._absoluteUrl}/_api/SP.RemoteWeb(@a1)/Web/Lists?$filter=BaseTemplate eq 700 and BaseType eq 1&@a1='${encodeURIComponent(this._oneDriveUrl)}'`;
return this._context.spHttpClient.get(apiUrl,
SPHttpClient.configurations.v1)
.then((response: SPHttpClientResponse) => {
return response.json().then((responseJSON: any) => {
// Get the first library
const myDocumentsLibrary = responseJSON.value[0];
// Get the parent url
const parentWebUrl: string = myDocumentsLibrary.ParentWebUrl;
// Get the first root folder. Assumed it is the same name as the library. Could be wrong.
const serverRelativeRootFolder: string = `${myDocumentsLibrary.ParentWebUrl}/${myDocumentsLibrary.Title}`;
// Build an absolute URL so that we can refer to it
this._oneDriveFullUrl = this._buildOneDriveAbsoluteUrl(serverRelativeRootFolder);
return serverRelativeRootFolder;
});
});
});
}
public GetListDataAsStream(rootFolder?: string) {
// If we don't know what the root OneDrive folder is
if (this._serverRelativeFolderUrl === undefined) {
// Get the user's OneDrive root folder
return this._getOneDriveRootFolder().then((oneDriveRootFolder: string) => {
// Call the OneDrive root folder or whatever we passed in as root folder
return this._getListDataAsStream(rootFolder ? rootFolder : oneDriveRootFolder);
});
} else {
return this._getListDataAsStream(rootFolder ? rootFolder : this._serverRelativeFolderUrl);
}
}
private _getListDataAsStream = (rootFolder: string): Promise<IGetListDataAsStreamResult> => {
const listFullUrl: string = this._oneDriveFullUrl;
const encodedFullUrl: string = encodeURIComponent(`'${listFullUrl}'`);
const encodedRootFolder: string = encodeURIComponent(rootFolder);
const listItemUrl: string = `${this._absoluteUrl}/_api/SP.List.GetListDataAsStream?listFullUrl=${encodedFullUrl}&View=&RootFolder=${encodedRootFolder}`;
const fileFilter: string = OneDriveServices.GetFileTypeFilter(this._accepts);
const data: string = JSON.stringify({
parameters: {
RenderOptions: RenderListDataOptions.ContextInfo | RenderListDataOptions.ListData | RenderListDataOptions.ListSchema | RenderListDataOptions.ViewMetadata | RenderListDataOptions.EnableMediaTAUrls | RenderListDataOptions.ParentInfo,//4231, //4103, //4231, //192, //64
AllowMultipleValueFilterForTaxonomyFields: true,
ViewXml:
`<View>
<Query>
<Where>
<Or>
<And>
<Eq>
<FieldRef Name="FSObjType" />
<Value Type="Text">1</Value>
</Eq>
<Eq>
<FieldRef Name="SortBehavior" />
<Value Type="Text">1</Value>
</Eq>
</And>
<In>
<FieldRef Name="File_x0020_Type" />
${fileFilter}
</In>
</Or>
</Where>
</Query>
<ViewFields>
<FieldRef Name="DocIcon"/>
<FieldRef Name="LinkFilename"/>
<FieldRef Name="Modified"/>
<FieldRef Name="Editor"/>
<FieldRef Name="FileSizeDisplay"/>
<FieldRef Name="SharedWith"/>
<FieldRef Name="MediaServiceFastMetadata"/>
<FieldRef Name="MediaServiceOCR"/>
<FieldRef Name="_ip_UnifiedCompliancePolicyUIAction"/>
<FieldRef Name="ItemChildCount"/>
<FieldRef Name="FolderChildCount"/>
<FieldRef Name="SMTotalFileCount"/>
<FieldRef Name="SMTotalSize"/>
</ViewFields>
<RowLimit Paged="TRUE">100</RowLimit>
</View>`
}
});
const spOpts: ISPHttpClientOptions = {
method: "POST",
body: data
};
return this._context.spHttpClient.fetch(listItemUrl, SPHttpClient.configurations.v1, spOpts)
.then((listResponse: SPHttpClientResponse) => listResponse.json().then((listResponseJSON: IGetListDataAsStreamResult) => listResponseJSON));
}
/**
* Creates an absolute URL
*/
private _buildOneDriveAbsoluteUrl = (relativeUrl: string) => {
const siteUrl: string = GetAbsoluteDomainUrl(this._oneDriveUrl);
return siteUrl + relativeUrl;
}
/**
* Builds a file filter
*/
public static GetFileTypeFilter(accepts: string) {
let fileFilter: string = undefined;
fileFilter = "<Values>";
accepts.split(",").forEach((fileType: string, index: number) => {
fileType = fileType.replace(".", "");
if (index > 0) {
fileFilter = fileFilter + `<Value Type="Text">${fileType}</Value>`;
}
});
fileFilter = fileFilter + "</Values>";
return fileFilter;
}
}

View File

@ -0,0 +1,4 @@
export * from './OneDriveServices';
export * from './IGetListDataAsStreamRequest';
export * from './IGetListDataAsStreamResult';
export * from './IDimensions';

View File

@ -0,0 +1,30 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "245378e3-d4e2-480b-a32f-5508a8c398e3",
"alias": "ComparerWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"preconfiguredEntries": [
{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": {
"default": "Other"
},
"title": {
"default": "Comparer"
},
"description": {
"default": "Compare two images side-by-side using a slider."
},
"iconImageUrl": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAYCAYAAAAPtVbGAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAeQAAAHkBOLWIEgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAGySURBVEiJrdQ9aFRBFAXg7212NSDa2VpZaeEfRkQsVJBgnaCNP4iFWAh2gkIEbQQbCwUlxD4ERUUCgkLAXkRQFEEknX9goYUhPIuZxGF5LzvvsQcWhnfPuWfnzr23wGvsNBiPcQf70MVUhgZ+Q4mRTMEEbmVyoYey00DQGsMyGRX+dWuTo1jApZr4KfzCdxxpY7IZD2OCxRrOFNZhEy63MdmKDcIt5mo4X2rOq+gOMHkThQu4h08VnNO4IrTq9TYmf3AA5/Fe9eMuxvia6J+TAhexp4I7lDkZwTRu4wXGGiTcURdITbp4gBM4hmd4jr0Dkhe4KaynC3WkUuigJ0K/H0xM5/ADu7DF/3Jti5zRyCnjbxnnkty9+F2JlzFZf3l6eISfmE1Mrgkd9SoxSI3OpiYr3bUdh4WWTbGE47hfEbtRURXCE0xHg5mVksAhvKsR/cWZeJ6o4fSjwF3hVqsP/yFT3AQdYYCHtoXXMtLFR3zLEMwLTdAUS0U02phDxjj246rQvtkmuRjDbmGtf8bTBtpsnEzOk1ifK2zy8OmtO+Ik52DQqk/xVpiXZXwV5icL/wCnaVnpdL2nMAAAAABJRU5ErkJggg==",
"properties": {
"beforeLabel": "",
"afterLabel": "",
"startPosition": 50,
"aspectRatio": "16:9"
}
}
]
}

View File

@ -0,0 +1,239 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
// Needed for base SharePoint stuff
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneLabel,
PropertyPaneSlider,
PropertyPaneChoiceGroup,
IPropertyPaneChoiceGroupOption,
} from '@microsoft/sp-webpart-base';
//
import { PropertyFieldSpinButton } from '@pnp/spfx-property-controls/lib/PropertyFieldSpinButton';
// Localized strings
import * as strings from 'ComparerWebPartStrings';
// Used to load the Comparer component
import Comparer from './components/Comparer';
import { IComparerProps } from './components/Comparer.types';
// Needed to display a custom property pane file picker
import { PropertyPaneFilePicker, ItemType } from '../../controls/PropertyPaneFilePicker';
export interface IComparerWebPartProps {
afterImg: string;
afterLabel: string;
aspectRatio: '1:1' | '3:2' | '4:3' | '16:9';
beforeImg: string;
beforeLabel: string;
startPosition: number;
title: string;
beforeAlternateText: string;
afterAlternateText: string;
}
const aspectRatio1_1: string = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEQAAABECAYAAAA4E5OyAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjEuNWRHWFIAAADeSURBVHhe7dAxCsNAEARBvVP//8fZDgxi6GjBJ4seQSnoS5Y51lp1gdEMoxlGM4xmGM0wmmE0w2iG0QyjGUYzjGYYzTCaYTTDaIbRDKMZxon39/ndJu+ZwjiRB+6W90xhnPgedp7nVo8YJN9+pYOEDhI6SOggoYOEDhI6SOggoYOEDhI6SOggoYOEDhI6SOggoYOEDhI6SOggoYOEDhI6SOggoYOExwyy098Pcpe8ZwrjRB64W94zhdEMoxlGM4xmGM0wmmE0w2iG0QyjGUYzjGYYzTCaYTTDaIbRax0vGYi5boi8iG0AAAAASUVORK5CYII=`;
/**
* Change the line below to true if you want to hide the Web search tab.
*/
const DISABLE_WEB_SEARCH_TAB: boolean = false;
export default class ComparerWebPart extends BaseClientSideWebPart<IComparerWebPartProps> {
// protected onInit(): Promise<void> {
// return new Promise<void>((resolve, _reject) => {
// if (this.properties.aspectRatio === undefined) {
// this.properties.aspectRatio = '1:1';
// }
// resolve(undefined);
// });
// }
public render(): void {
// Get the width of this web part, we'll need it to resize the images
const { clientWidth } = this.domElement;
// Calculate the aspect ratio
let ratio: number = undefined;
switch (this.properties.aspectRatio) {
case "1:1":
ratio = 1;
break;
case "4:3":
ratio = 4 / 3;
break;
case "3:2":
ratio = 3 / 2;
break;
case "16:9":
default:
ratio = 16 / 9;
}
// Calculate the height based on the selected aspect ratio
const calculatedHeight: number = clientWidth / ratio;
const element: React.ReactElement<IComparerProps> = React.createElement(
Comparer,
{
afterImg: this.properties.afterImg,
afterLabel: this.properties.afterLabel,
beforeImg: this.properties.beforeImg,
beforeLabel: this.properties.beforeLabel,
displayMode: this.displayMode,
height: calculatedHeight,
onConfigure: this.context.propertyPane.open,
startPosition: this.properties.startPosition,
title: this.properties.title,
width: clientWidth,
beforeAlternateText: this.properties.beforeAlternateText,
afterAlternateText: this.properties.afterAlternateText,
onUpdateTitle: (value: string) => {
// when title is changed, store the new title
this.properties.title = value;
}
}
);
ReactDom.render(element, this.domElement);
}
/**
* Redraws the web part when resized
* @param _newWidth
*/
protected onAfterResize(_newWidth: number): void {
// redraw the web part
this.render();
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
displayGroupsAsAccordion: true,
groups: [
{
groupName: strings.BeforeImageGroupName,
groupFields: [
PropertyPaneLabel('beforeImg', {
text: strings.BeforeImageGroupDescription
}),
PropertyPaneFilePicker('beforeImg', {
key: 'beforeImgId',
label: strings.BeforeImageFieldLabel,
buttonLabel: this.properties.beforeImg ? strings.BeforeImageChangeButtonLabel : strings.BeforeImageButtonLabel,
value: this.properties.beforeImg,
webPartContext: this.context,
itemType: ItemType.Images,
required: true,
disableWebSearchTab: DISABLE_WEB_SEARCH_TAB,
onSave: (value: string) => { this.properties.beforeImg = value; }
}),
PropertyPaneTextField('beforeLabel', {
label: strings.BeforeImageLabelFieldLabel
}),
PropertyPaneTextField('beforeAlternateText', {
label: strings.BeforeImageAlternateTextFieldLabel,
multiline: true,
rows: 3,
description: strings.AlternateTextFieldDescription
})
]
},
{
groupName: strings.AfterImageGroupName,
groupFields: [
PropertyPaneLabel('afterImg', {
text: strings.AfterImageGroupDescription
}),
PropertyPaneFilePicker('afterImg', {
key: 'afterImgId',
label: strings.AfterImageFieldLabel,
buttonLabel: this.properties.afterImg ? strings.AfterImageChangeButtonLabel : strings.AfterImageButtonLabel,
value: this.properties.afterImg,
webPartContext: this.context,
itemType: ItemType.Images,
required: true,
disableWebSearchTab: DISABLE_WEB_SEARCH_TAB,
onSave: (value: string) => { this.properties.afterImg = value; }
}),
PropertyPaneTextField('afterLabel', {
label: strings.AfterImageLabelFieldLabel,
}),
PropertyPaneTextField('afterAlternateText', {
label: strings.AfterImageAlternateTextFieldLabel,
multiline: true,
rows: 3,
description: strings.AlternateTextFieldDescription
})
]
},
{
groupName: strings.OptionsGroupName,
isCollapsed: true,
groupFields: [
PropertyPaneSlider('startPosition', {
label: strings.StartPositionFieldLabel,
min: 0,
max: 100,
showValue: true,
step: 1
}),
PropertyPaneChoiceGroup('aspectRatio', {
label: strings.AspectRatioFieldLabel,
options: [
{
key: '1:1',
text: '1:1',
selectedImageSrc: require('./assets/AspectRatio1_1.png'),
imageSrc: require('./assets/AspectRatio1_1.png'),
},
{
key: '3:2',
text: '3:2',
selectedImageSrc: require('./assets/AspectRatio3_2.png'),
imageSrc: require('./assets/AspectRatio3_2.png'),
},
{
key: '4:3',
text: '4:3',
selectedImageSrc: require('./assets/AspectRatio4_3.png'),
imageSrc: require('./assets/AspectRatio4_3.png'),
},
{
key: '16:9',
text: '16:9',
selectedImageSrc: require('./assets/AspectRatio16_9.png'),
imageSrc: require('./assets/AspectRatio16_9.png'),
}
]
})
]
}
]
}
]
};
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

View File

@ -0,0 +1,88 @@
@import "~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss";
.comparer {
position: relative;
border-width: 1px;
border-style: solid;
border-color: $ms-color-neutralTertiaryAlt;
margin-bottom: 24px;
}
// Darken the image at the bottom if we have labels, for accessibility reasons.
.withLabel {
&::after {
display: block;
position: absolute;
background: rgba(0, 0, 0, 0) linear-gradient(to bottom, transparent 0px, $ms-color-blackTranslucent40 100%) repeat 0
0;
top: 50%;
height: 50%;
width: 100%;
content: "";
}
}
.comparerSlider {
background-color: $ms-color-neutralSecondary;
cursor: col-resize;
height: 100%;
position: absolute;
top: 0;
width: 2px;
z-index: 10;
}
.comparerSlider:after {
background-color: $ms-color-white;
border-radius: 50%;
border-width: 2px;
border-style: solid;
border-color: $ms-color-neutralSecondary;
bottom: -12px;
content: "";
height: 18px;
left: -9px;
position: absolute;
width: 18px;
}
.comparerBefore,
.comparerAfter {
width: 100%;
height: 100%;
}
.afterWrapper {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 50%;
overflow: hidden;
}
.comparerLabel {
margin-left: 20px;
margin-right: 20px;
color: $ms-color-white;
padding-left: 2px;
font-size: 12px;
font-weight: 600;
margin-top: 16px;
text-transform: uppercase;
letter-spacing: 2px;
position: absolute;
bottom: 0;
padding-bottom: 28px;
z-index: 25;
white-space: nowrap;
overflow: hidden;
}
.comparerLabelBefore {
right: 0;
}
.comparerLabelAfter {
left: 0;
}

View File

@ -0,0 +1,247 @@
import * as React from 'react';
// Styles
import styles from './Comparer.module.scss';
// Our props and state
import { IComparerProps, IComparerState } from './Comparer.types';
// Used to make a draggable slider
import Draggable, { DraggableData } from 'react-draggable';
// Used for dynamic CSS classname
import { css } from "@uifabric/utilities/lib/css";
// Used to create an image that covers the entire area
import BlockImage from 'react-block-image';
// Localization
import * as strings from 'ComparerWebPartStrings';
// Needed to know if the web part is editable and show the title control
import { DisplayMode } from "@microsoft/sp-core-library";
// PnP controls rock!
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
export default class Comparer extends React.Component<IComparerProps, IComparerState> {
/**
* Keep track of the labels so we can retrieve their positions
*/
private _beforeLabelElem: HTMLDivElement = undefined;
private _afterLabelElem: HTMLDivElement = undefined;
/**
* Keep track of the top parent so we can get relative positions
*/
private _topElem: HTMLDivElement = undefined;
constructor(props: IComparerProps) {
super(props);
this.state = {
sliderPositionX: (this.props.startPosition / 100 * this.props.width),
sliderPosition: (this.props.startPosition),
showChooseImagePanel: false,
hasLabels: this._checkHasLabels(this.props)
};
}
public componentDidUpdate(prevProps: IComparerProps, prevState: IComparerState): void {
// If we updated the slider position, forget the current state and recalculate position
if (prevProps.startPosition !== this.props.startPosition) {
this.setState({
sliderPositionX: (this.props.startPosition / 100 * this.props.width),
sliderPosition: (this.props.startPosition),
});
}
// If we added/removed labels, change the state
const hasLabels: boolean = this._checkHasLabels(this.props);
if (this._checkHasLabels(prevProps) !== hasLabels) {
this.setState({
hasLabels: hasLabels
});
}
}
public render(): React.ReactElement<IComparerProps> {
const {
beforeImg,
afterImg,
className,
beforeClassName,
afterClassName,
handleClassName,
width,
height
} = this.props;
const {
sliderPosition,
hasLabels
} = this.state;
if (!this.props.beforeImg || !this.props.afterImg) {
return this._renderPlaceholders();
}
const sliderPositionX: number = (sliderPosition / 100 * this.props.width);
return (
<React.Fragment>
<WebPartTitle displayMode={this.props.displayMode}
title={this.props.title}
updateProperty={this.props.onUpdateTitle} />
<div
className={css(styles.comparer, hasLabels ? styles.withLabel : undefined, className)}
style={{
width,
height
}}
ref={this._linkTop}
>
<Draggable
bounds={"parent"}
disabled={false}
allowAnyClick={false}
enableUserSelectHack={true}
axis={"x"}
defaultClassName={styles.comparerSlider}
defaultPosition={{ x: sliderPositionX, y: 0 }}
position={{ x: sliderPositionX, y: 0 }}
onDrag={(e, data: DraggableData) => this._handleDrag(e, data)}>
<div className={handleClassName}></div>
</Draggable>
<div
className={styles.afterWrapper}
style={{
width: `${sliderPositionX}px`
}}
>
<BlockImage
role={"Image"}
aria-label={this.props.afterAlternateText}
src={afterImg}
className={css(styles.comparerAfter, afterClassName)}
style={{ width }}
/>
<div
className={css(styles.comparerLabel, styles.comparerLabelAfter)}
ref={this._linkAfterLabel}>{this.props.afterLabel}</div>
</div>
<BlockImage
role={"Image"}
aria-label={this.props.beforeAlternateText}
src={beforeImg}
className={css(styles.comparerBefore, beforeClassName)}
/>
<div
className={css(styles.comparerLabel, styles.comparerLabelBefore)}
ref={this._linkBeforeLabel}>{this.props.beforeLabel}</div>
</div>
</React.Fragment>
);
}
/**
* If the web part isn't configured, renders a placeholder
*/
private _renderPlaceholders = (): JSX.Element => {
const {
onConfigure
} = this.props;
if (this.props.displayMode === DisplayMode.Edit) {
return <div
className={styles.comparer}>
<Placeholder iconName='PhotoCollection'
iconText={strings.PlaceholderIconText}
description={strings.PlaceholderDescription}
buttonLabel={strings.PlaceholderButtonLabel}
onConfigure={onConfigure} />
</div>;
} else {
return undefined;
}
}
/**
* Verifies if labels were specified or now
*/
private _checkHasLabels = (props: IComparerProps): boolean => {
return props.beforeLabel!.length > 0 || props.afterLabel!.length > 0;
}
/**
* Called when user drags the slider
*/
private _handleDrag(_e, data: DraggableData) {
const { sliderPositionX } = this.state;
const { width } = this.props;
this.setState({
sliderPositionX: data.x,
sliderPosition: sliderPositionX / width * 100
});
// If we have no labels, don't do anything else
if (!this.state.hasLabels) {
return;
}
// We DO have labels, we need to move some labels, and maybe hide them
// otherwise they get squished when the slider goes too far to the left or right
// Get the position of the right label and see if we need to hide it
const parentPos: ClientRect = this._topElem.getBoundingClientRect();
const labelPos: ClientRect = this._beforeLabelElem.getBoundingClientRect();
const labelX: number = labelPos.left - parentPos.left;
// Is the slider past the label?
if (labelX < data.x) {
// Hide the label
this._beforeLabelElem.style.opacity = "0";
}
else {
// Show the label
this._beforeLabelElem.style.opacity = "1";
}
// Get the right-most position of the left label
const afterLabelPos: ClientRect = this._afterLabelElem.getBoundingClientRect();
const afterLabelX: number = afterLabelPos.left - parentPos.left + afterLabelPos.width;
// If the slider is to the left of the label
if (data.x < afterLabelX) {
// Hide it
this._afterLabelElem.style.opacity = "0";
}
else {
// Show it
this._afterLabelElem.style.opacity = "1";
}
}
/**
* link to the top element
*/
private _linkTop = (e: any) => {
this._topElem = e;
}
/**
* Link to the right label
*/
private _linkBeforeLabel = (e: any) => {
this._beforeLabelElem = e;
}
/**
* Link to the left label
*/
private _linkAfterLabel = (e: any) => {
this._afterLabelElem = e;
}
}

View File

@ -0,0 +1,28 @@
import { DisplayMode } from "@microsoft/sp-core-library";
export interface IComparerProps {
afterClassName?: string;
afterImg: string;
afterLabel: string;
beforeClassName?: string;
beforeImg: string;
beforeLabel: string;
className?: string;
displayMode: DisplayMode;
handleClassName?: string;
height: number;
startPosition: number;
title: string;
width: number;
beforeAlternateText: string;
afterAlternateText: string;
onConfigure: () => void;
onUpdateTitle: (value: string) => void;
}
export interface IComparerState {
sliderPosition: number;
sliderPositionX: number;
showChooseImagePanel: boolean;
hasLabels: boolean;
}

View File

@ -0,0 +1,27 @@
define([], function() {
return {
"AfterImageAlternateTextFieldLabel": "Alternative text",
"AfterImageButtonLabel": "Choose image",
"AfterImageChangeButtonLabel": "Change image",
"AfterImageFieldLabel": "Image",
"AfterImageGroupName": "After image",
"AfterImageGroupDescription": "The After image appears on the left of the comparer. It is revealed as users drag the divider to the right.",
"AfterImageLabelFieldLabel": "Label",
"AlternateTextFieldDescription": "Describe this image for people who can't see it",
"AlternateTextLabel": "Provide alternative text to describe this image. Alternative text helps people with screen readers understand the content of pictures.",
"BeforeImageAlternateTextFieldLabel": "Alternative text",
"BeforeImageButtonLabel": "Choose image",
"BeforeImageChangeButtonLabel": "Change image",
"BeforeImageFieldLabel": "Image",
"BeforeImageGroupDescription": "The Before image appears on the right of the comparer. It covers the After image as users drag the divider to the left.",
"BeforeImageGroupName": "Before image",
"BeforeImageLabelFieldLabel": "Label",
"OptionsGroupName": "Options",
"PlaceholderButtonLabel": "Add images",
"PlaceholderDescription": "In order to compare images, you need to insert a 'Before' and an 'After' image.",
"PlaceholderIconText": "Compare images",
"PropertyPaneDescription": "Select a Before and After image. Add text labels over your images and description. Choose options such as starting position and web part height.",
"StartPositionFieldLabel": "Start position (%)",
"AspectRatioFieldLabel": "Aspect ratio",
}
});

View File

@ -0,0 +1,30 @@
declare interface IComparerWebPartStrings {
AfterImageAlternateTextFieldLabel: string;
AfterImageButtonLabel: string;
AfterImageChangeButtonLabel: string;
AfterImageFieldLabel: string;
AfterImageGroupName: string;
AfterImageGroupDescription: string;
AfterImageLabelFieldLabel: string;
AlternateTextFieldDescription: string;
BeforeImageAlternateTextFieldLabel: string;
BeforeImageButtonLabel: string;
BeforeImageChangeButtonLabel: string;
BeforeImageFieldLabel: string;
BeforeImageGroupDescription: string;
BeforeImageGroupName: string;
BeforeImageLabelFieldLabel: string;
OptionsGroupName: string;
PlaceholderButtonLabel: string;
PlaceholderDescription: string;
PlaceholderIconText: string;
PropertyPaneDescription: string;
StartPositionFieldLabel: string;
AspectRatioFieldLabel: string;
}
declare module 'ComparerWebPartStrings' {
const strings: IComparerWebPartStrings;
export = strings;
}

View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "*": ["types/*"] },
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"outDir": "lib",
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"es6-promise",
"webpack-env"
],
"lib": [
"es5",
"dom",
"es2015.collection"
]
},
"include": [
"src/**/*.ts", "src/controls/PropertyPaneFilePicker/WebSearchTab/SearchResultTile.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
}
}

View File

@ -54,8 +54,10 @@ Property Bag Navigation Webparts<br/>[react-property-bag-editor](https://github.
Provision SharePoint Assets with the SPFx solution package<br/>[react-provision-assets](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-provision-assets)|This sample shows how we can provision Document Library, Custom List, Web and List PropertyBag properties, Site Columns, Content Types, Images, Site Page with the SFPx Client side webpart and even prepopulated list and library items along with the SPFx solution package. All of the components can be deployed at once with the SPFx webpart when the app is added to a SharePoint site. It also contains custom list and document library xml schemas.|![react-provision-assets](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-provision-assets/assets/spfx-provision-assets.gif)|![drop](https://img.shields.io/badge/drop-GA-green.svg)
React &amp; Office Graph Web Part samples<br/>[react-officegraph](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-officegraph)|Sample SharePoint Framework Client-Side Web Parts built using React showing interacting with the Office Graph.|![react-officegraph](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-officegraph/assets/trendinginthissite-preview.png)|![drop](https://img.shields.io/badge/drop-ga-green.svg)
React Aggregated Calendar Webpart<br/>[react-aggregated-calendar](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-aggregated-calendar)|This is a sample webpart developed using React Framework to gather the aggregated events from the multiple calendars from multiple sites using Full Calendar from fullcalendar.io|![react-aggregated-calendar](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-aggregated-calendar/assets/react-aggregated-calendar.gif)|![drop](https://img.shields.io/badge/version-GA-green.svg)
React Birthdays Web Part<br/>[react-birthdays](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-birthdays)|The Web Part Birthdays shows the upcoming birthdays in the company, the web part reads birthdays from a list located on the tenant's home site with title "Birthdays."|![react-birthdays](https://github.com/SharePoint/sp-dev-fx-webparts/raw/master/samples/react-birthdays/assets/birthdays.png)|![drop](https://img.shields.io/badge/version-GA-green.svg)
React Calendar Feed Web Part<br/>[react-calendar-feed](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-calendar-feed)|This web part uses RSS event feeds, iCal feeds, or WordPress calendar feeds and renders events using a look and feel that is consistent with the SharePoint out-of-the-box Group calendar/events web part.|![react-calendar-feed](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-calendar-feed/assets/react-calendar-feed-demo.gif)|![drop](https://img.shields.io/badge/drop-1.7.1-green.svg)
React Chart Control<br/>[react-chartcontrol](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-chartcontrol)|This sample contains several web parts that demonstrate how to use the ChartControl from @pnp/spfx-controls-react.|![react-chartcontrol](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-chartcontrol/assets/react-chartcontrol.gif)|![drop](https://img.shields.io/badge/drop-1.7.1-green.svg)
React Chart Control<br/>[react-chartcontrol](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-chartcontrol)|This sample contains several web parts that demonstrate how to use the ChartControl from @pnp/spfx-controls-react.|![react-chartcontrol](https://github.com/SharePoint/sp-dev-fx-webparts/raw/master/samples/react-chartcontrol/assets/WebPartList.png)|![drop](https://img.shields.io/badge/drop-1.7.1-green.svg)
React Comparer<br/>[react-comparer](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-comparer)|Allows users to compare Before and After pictures, with a drag-and-drop slider. Implements a custom file picker.|![react-comparer](https://github.com/SharePoint/sp-dev-fx-webparts/raw/master/samples/react-comparer/assets/ComparerWebPart.png)|![drop](https://img.shields.io/badge/drop-1.7.1-green.svg)
React Content Query WebPart<br/>[react-content-query-webpart](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-content-query-webpart)|The React Content Query WebPart is a modern version of the good old Content by Query WebPart that was introduced in SharePoint 2007. Built for SharePoint 2016 and Office 365, this modern version is built against the new SharePoint Framework (SPFx) and uses the latest Web Stack practices. While the original WebPart was based on a XSLT templating engine, this React WebPart is based on the well known Handlebars templating engine, which empowers users to create simple, yet powerfull HTML templates for rendering the queried content. This new version also lets the user query any site collections which resides on the same domain url, add unlimited filters, query DateTime fields to the nearest minute rather than being limited to a day, and much more.|![react-content-query-webpart](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-content-query-webpart/Misc/toolpart.gif)|![drop](https://img.shields.io/badge/drop-GA-green.svg)
React File Upload WebPart<br/>[react-file-upload](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-file-upload)|The file upload web part allowing users to upload multiple files to a document library or as item attachments.|![react-file-upload](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-file-upload/assets/SPFileUploadPreview.gif)|![drop](https://img.shields.io/badge/version-GA-green.svg)
React List Form WebPart<br/>[react-list-form](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-list-form)|The React List Form web part is a web part for adding a list form to any page. It provides a working example of implementing generic SharePoint list forms using the SharePoint Framework (SPFx) and the React and Office UI Fabric libraries.|![react-list-form](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-list-form/assets/React-ListForm-Overview.gif)|![drop](https://img.shields.io/badge/version-GA-green.svg)
@ -82,6 +84,7 @@ SharePoint CRUD operations<br/>[sharepoint-crud](https://github.com/SharePoint/s
SharePoint Framework Facebook Page Social Plugin web part sample<br/>[react-facebook-plugin](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-facebook-plugin)|This sample shows how to implement iFrame-based web parts with a dynamic responsive behavior on the example of Facebook Page Social Plugin.|![react-facebook-plugin](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-facebook-plugin/assets/preview.png)|![drop](https://img.shields.io/badge/drop-1.5.1-blue.svg)
SharePoint Framework PnP Controls Sample<br/>[pnp-controls](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/pnp-controls)|This is a sample project that contains a web part which makes use of the PnP SPFx Controls:|![pnp-controls](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/pnp-controls/assets/webpart-outcome.gif)|![drop](https://img.shields.io/badge/drop-1.4.1-green.svg)
SharePoint Framework React Jest Testing sample<br/>[react-jest-testing](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-jest-testing)|This sample uses the popular Jest Testing Framework with a SPFx client side solution. It is a SPFx-Jest-Enzyme-Sinon starter kit so you can start writing and debugging unit tests in typescript for your SPFx solution.The setup includes unit tests examples, code coverage reports in different formats, visual studio code unit test debug configurations for typescript, setting a coverage threshold (gates) for continuous integration and continuous deployment scenarios.|![react-jest-testing](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-jest-testing/assets/Jest-Typescript-VSCode-debugging.png)|![drop](https://img.shields.io/badge/drop-1.6.0-green.svg)
SharePoint Framework RSS Reader<br/>[react-rss-reader](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-rss-reader)|A RSS Reader original based on work completed by Olivier Carpentier's from: https://github.com/OlivierCC/spfx-40-fantastics/tree/master/src/webparts/rssReader Root project: https://github.com/OlivierCC/spfx-40-fantastics| ![react-rss-reader](https://github.com/SharePoint/sp-dev-fx-webparts/raw/cfe16c906678ea36e79065844b61bc2888305abd/samples/react-rss-reader/images/react-rss-reader.gif) |![drop](https://img.shields.io/badge/drop-1.7.0-green.svg)
SharePoint Framework search with search box, refiners and paging sample<br/>[react-search-refiners](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-search-refiners)|This sample shows you how to build user friendly SharePoint search experiences using SPFx in the modern interface. The main features include:|![react-search-refiners](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-search-refiners/images/react-search-refiners.gif)|![drop](https://img.shields.io/badge/drop-1.7.0-green.svg)
SharePoint Framework webpart sample using React, Redux and ImmutableJS<br/>[react-redux-async-immutablejs](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-redux-async-immutablejs)|SharePoint Framework webpart which uses Redux to maintain a single state for the entire application and ImmutableJS to create performant state trees.|![react-redux-async-immutablejs](https://raw.githubusercontent.com/vman/sp-dev-fx-webparts/master/samples/react-redux-async-immutablejs/assets/react-redux-immutable.gif)|![drop](https://img.shields.io/badge/version-GA-green.svg)
SharePoint Themes Client Side Web Part<br/>[react-themes](https://github.com/SharePoint/sp-dev-fx-webparts/tree/master/samples/react-themes)|This web part illustrates how to use SharePoint Theme variables in custom web parts.|![react-themes](https://raw.githubusercontent.com/SharePoint/sp-dev-fx-webparts/master/samples/react-themes/assets/themes.png)|![drop](https://img.shields.io/badge/drop-ga-green.svg)