diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afe0c82b..b5a2b714d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Releases +## 1.3.0 + +**New Controls** + +- `TaxonomyPicker` control got added [#22](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/22) [#63](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/63) [#64](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/64) +- `ListPicker` control got added [#34](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/34) + +**Fixes** + +- Issue fixed when the optional `selection` property was not provided to the `ListView` [#65](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/65) + ## 1.2.5 **Fixes** diff --git a/config/tslint.json b/config/tslint.json index 0bb934c20..c22006217 100644 --- a/config/tslint.json +++ b/config/tslint.json @@ -39,7 +39,8 @@ "use-named-parameter": true, "valid-typeof": true, "variable-name": false, - "whitespace": false + "whitespace": false, + "no-debugger": true } } -} \ No newline at end of file +} diff --git a/docs/documentation/docs/about/release-notes.md b/docs/documentation/docs/about/release-notes.md index 2787eff18..b5a2b714d 100644 --- a/docs/documentation/docs/about/release-notes.md +++ b/docs/documentation/docs/about/release-notes.md @@ -1,5 +1,16 @@ # Releases +## 1.3.0 + +**New Controls** + +- `TaxonomyPicker` control got added [#22](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/22) [#63](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/63) [#64](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/64) +- `ListPicker` control got added [#34](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/34) + +**Fixes** + +- Issue fixed when the optional `selection` property was not provided to the `ListView` [#65](https://github.com/SharePoint/sp-dev-fx-controls-react/issues/65) + ## 1.2.5 **Fixes** @@ -52,7 +63,7 @@ ## 1.1.2 -- Fix for WebPartTitle control to inherit color +- Fix for `WebPartTitle` control to inherit color - Improved telemetry with some object checks ## 1.1.1 diff --git a/docs/documentation/docs/assets/ListView.ContextualMenu.png b/docs/documentation/docs/assets/ListView.ContextualMenu.png new file mode 100644 index 000000000..618d1ab5c Binary files /dev/null and b/docs/documentation/docs/assets/ListView.ContextualMenu.png differ diff --git a/docs/documentation/docs/assets/ListView.ContextualMenu_clicked.png b/docs/documentation/docs/assets/ListView.ContextualMenu_clicked.png new file mode 100644 index 000000000..8979db3f2 Binary files /dev/null and b/docs/documentation/docs/assets/ListView.ContextualMenu_clicked.png differ diff --git a/docs/documentation/docs/assets/TermPicker-autocomplete.png b/docs/documentation/docs/assets/TermPicker-autocomplete.png new file mode 100644 index 000000000..18b317203 Binary files /dev/null and b/docs/documentation/docs/assets/TermPicker-autocomplete.png differ diff --git a/docs/documentation/docs/assets/TermPicker-selection.png b/docs/documentation/docs/assets/TermPicker-selection.png new file mode 100644 index 000000000..fa45a5634 Binary files /dev/null and b/docs/documentation/docs/assets/TermPicker-selection.png differ diff --git a/docs/documentation/docs/assets/termpicker-empty.png b/docs/documentation/docs/assets/termpicker-empty.png new file mode 100644 index 000000000..c8587b23b Binary files /dev/null and b/docs/documentation/docs/assets/termpicker-empty.png differ diff --git a/docs/documentation/docs/assets/termpicker-limit-to-group.png b/docs/documentation/docs/assets/termpicker-limit-to-group.png new file mode 100644 index 000000000..42fb1363b Binary files /dev/null and b/docs/documentation/docs/assets/termpicker-limit-to-group.png differ diff --git a/docs/documentation/docs/assets/termpicker-selected-terms.png b/docs/documentation/docs/assets/termpicker-selected-terms.png new file mode 100644 index 000000000..05119c8e2 Binary files /dev/null and b/docs/documentation/docs/assets/termpicker-selected-terms.png differ diff --git a/docs/documentation/docs/beta.md b/docs/documentation/docs/beta.md new file mode 100644 index 000000000..c493fdb70 --- /dev/null +++ b/docs/documentation/docs/beta.md @@ -0,0 +1,17 @@ +# Testing out a beta release ![](https://img.shields.io/npm/v/@pnp/spfx-controls-react/next.svg) + +All you need to do for testing out a beta release of `@pnp/spfx-controls-react` is to install the dependency as follows: + +``` +npm install @pnp/spfx-controls-react@next --save +``` + +## Beta control documentation + +The control documentation is only live for public releases, not for beta versions. If you want to checkout the markdown files of all controls in the `dev` branch: [beta documentation](https://github.com/SharePoint/sp-dev-fx-controls-react/tree/dev/docs/documentation/docs/controls). + +## Next Steps + +Once you installed the beta version, you can start using the controls in your solution. Go to the homepage to get an overview of all the available controls and the steps to get started: [home](./). + +![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/beta) diff --git a/docs/documentation/docs/controls/ListView.ContextualMenu.md b/docs/documentation/docs/controls/ListView.ContextualMenu.md new file mode 100644 index 000000000..6d1e68568 --- /dev/null +++ b/docs/documentation/docs/controls/ListView.ContextualMenu.md @@ -0,0 +1,108 @@ +# ListView: Add a contextual menu + +## The ContextualMenu component + +In order to create a contextual menu for your list view, you first need to create a new component which will use a combination of an [IconButton](https://developer.microsoft.com/en-us/fabric#/components/button#Variants) and [ContextualMenu](https://developer.microsoft.com/en-us/fabric#/components/contextualmenu) controls from the Office UI Fabric React. + +Here is some sample code: + +```TypeScript +import * as React from 'react'; +import { Layer, IconButton, IButtonProps } from 'office-ui-fabric-react'; +import { ContextualMenuItemType } from 'office-ui-fabric-react/lib/ContextualMenu'; +// The following are project specific components +import { IECBProps } from './IECBProps'; +import styles from './ECB.module.scss'; +import { IListitem } from '../../model/IListitem'; + +export class ECB extends React.Component { + + public constructor(props: IECBProps) { + super(props); + + this.state = { + panelOpen: false + }; + } + + public render() { + return ( +
+ console.error('Disabled action should not be clickable.') + } + ] + }} /> +
+ ); + } + + private handleClick(source:string, event) { + alert(`${source} clicked`); + } +} +``` + +## The ListView column + +Once the ECB component is created, you can add the contextual menu to the `ListView` control. In order to do this, you have to insert another `Viewfield` in code at the position of our choice. For instance after the `Lastname`: + +```TypeScript +{ + name: "", + sorting: false, + maxWidth: 40, + render: (rowitem: IListitem) => { + const element:React.ReactElement = React.createElement( + ECB, + { + item: rowitem + } + ); + return element; + } +} +``` + +Inside the render method of the `IViewField`, the ECB component gets created and the current item will be used as a reference for the clicked row. + +## The result +The result will look like the following: + +![ContextualMenu_shown](../assets/ListView.ContextualMenu.png) + +Once you click on an action, you will see the alert: + +![ContextualMenu_clicked](../assets/ListView.ContextualMenu_clicked.png) diff --git a/docs/documentation/docs/controls/ListView.md b/docs/documentation/docs/controls/ListView.md index 02c73e406..e1a47b614 100644 --- a/docs/documentation/docs/controls/ListView.md +++ b/docs/documentation/docs/controls/ListView.md @@ -54,6 +54,9 @@ const groupByFields: IGrouping[] = [ ]; ``` +!!! note "Extend ListView with a ContextualMenu" + To extend the `ListView` control with a [ContextualMenu](https://developer.microsoft.com/en-us/fabric#/components/contextualmenu) refer to [ListView.ContextualMenu](./ListView.ContextualMenu). + ## Implementation The ListView control can be configured with the following properties: diff --git a/docs/documentation/docs/controls/TaxonomyPicker.md b/docs/documentation/docs/controls/TaxonomyPicker.md new file mode 100644 index 000000000..8244b5204 --- /dev/null +++ b/docs/documentation/docs/controls/TaxonomyPicker.md @@ -0,0 +1,87 @@ +# Taxonomy Picker + +This control Allows you to select one or more Terms from a TermSet via its name or TermSet ID. You can also configure the control to select the child terms from a specific term in the TermSet by setting the AnchorId. + +!!! note "Disclaimer" + This control makes use of the `ProcessQuery` API end-points to retrieve the managed metadata information. This will get changed once the APIs for managing managed metadata will become available. + +**Empty term picker** + +![Empty term picker](../assets/termpicker-empty.png) + +**Selecting terms** + +![Selecting terms](../assets/termpicker-selection.png) + +**Selected terms in picker** + +![Selected terms in the input](../assets/termpicker-selected-terms.png) + +**Term picker: Auto Complete** + +![Selected terms in the input](../assets/termpicker-autocomplete.png) + + +## How to use this control in your solutions + +- Check that you installed the `@pnp/spfx-controls-react` dependency. Check out the [getting started](../#getting-started) page for more information about installing the dependency. +- Import the following modules to your component: + +```TypeScript +import { TaxonomyPicker, IPickerTerms } from "@pnp/spfx-controls-react/lib/TaxonomyPicker"; +``` + +- Use the `TaxonomyPicker` control in your code as follows: + +```TypeScript + +``` + +- With the `onChange` property you can capture the event of when the terms in the picker has changed: + +```typescript +private onTaxPickerChange(terms : IPickerTerms) { + console.log("Terms", terms); +} +``` + +## Implementation + +The TaxonomyPicker control can be configured with the following properties: + +| Property | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| panelTitle | string | yes | TermSet Picker Panel title. | +| label | string | yes | Text displayed above the Taxonomy Picker. | +| disabled | string | no | Specify if the control needs to be disabled. | +| context | WebPartContext | yes | Context of the current web part. | +| initialValues | IPickerTerms | no | Defines the selected by default term sets. | +| allowMultipleSelections | boolean | no | Defines if the user can select only one or many term sets. Default value is false. | +| TermSetNameOrID | string | yes | The name or Id of your TermSet that you would like the Taxonomy Picker to chose terms from. | +| onChange | function | no | captures the event of when the terms in the picker has changed. | +| isTermSetSelectable | boolean | no | Specify if the TermSet itself is selectable in the tree view. | +| anchorId | string | no | Set the anchorid to a child term in the TermSet to be able to select terms from that level and below. | + +Interface `IPickerTerm` + +| Property | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| key | string | yes | The ID of the term | +| name | string | yes | The name of the term | +| path | string | yes | The path of the term | +| termSet | string | yes | The Id of the parent TermSet of the term | +| termSetName | string | no | The Name of the parent TermSet of the term | + +Interface `IPickerTerms` + +An Array of IPickerTerm + +![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/Placeholder) diff --git a/docs/documentation/docs/controls/fields/FieldTitleRenderer.md b/docs/documentation/docs/controls/fields/FieldTitleRenderer.md index e14095bcd..1da473cef 100644 --- a/docs/documentation/docs/controls/fields/FieldTitleRenderer.md +++ b/docs/documentation/docs/controls/fields/FieldTitleRenderer.md @@ -1,6 +1,6 @@ # FieldTitleRenderer control -This control renders title either as a simple text or as a link to the Dislpay Form. Additional actions like Share and Context Menu are not implemented. +This control renders title either as a simple text or as a link to the Display Form. Additional actions like Share and Context Menu are not implemented. ![FieldTitleRenderer control output](../../assets/FieldTitleRenderer.png) diff --git a/docs/documentation/docs/controls/fields/main.md b/docs/documentation/docs/controls/fields/main.md index d24cebf7e..9c4644e04 100644 --- a/docs/documentation/docs/controls/fields/main.md +++ b/docs/documentation/docs/controls/fields/main.md @@ -52,7 +52,7 @@ The following Field Controls are currently available: - [FieldNameRenderer](./FieldNameRenderer) (renders document's name as a link) - [FieldTaxonomyRenderer](./FieldTaxonomyRenderer) (renders terms from Managed Metadata field) - [FieldTextRenderer](./FieldTextRenderer) (renders simple text) -- [FieldTitleRenderer](./FieldTitleRenderer) (renders title either as a simple text or as a link to the Dislpay Form) +- [FieldTitleRenderer](./FieldTitleRenderer) (renders title either as a simple text or as a link to the Display Form) - [FieldUrlRenderer](./FieldUrlRenderer) (renders Hyperlink or Picture field value as a link or image) - [FieldUserRenderer](./FieldUserRenderer) (renders each referenced user/group as a link on a separate line) diff --git a/docs/documentation/docs/index.md b/docs/documentation/docs/index.md index 92720b5d4..bc1dc0932 100644 --- a/docs/documentation/docs/index.md +++ b/docs/documentation/docs/index.md @@ -2,7 +2,8 @@ This repository provides developers with a set of reusable React controls that can be used in SharePoint Framework (SPFx) solutions. The project provides controls for building web parts and extensions. -!!! attention The controls project has a minimal dependency on SharePoint Framework version `1.3.0`. Be aware that the controls might not work in solutions your building for on-premises. As for on-premises solutions version `1.1.0` is currently used. +!!! attention + The controls project has a minimal dependency on SharePoint Framework version `1.3.0`. Be aware that the controls might not work in solutions your building for on-premises. As for on-premises solutions version `1.1.0` is currently used. ## Getting started @@ -32,12 +33,14 @@ The following controls are currently available: - [ListView](./controls/ListView) (List view control) - [Placeholder](./controls/Placeholder) (Control that can be used to show an initial placeholder if the web part has to be configured) - [SiteBreadcrumb](./controls/SiteBreadcrumb) (Breadcrumb control) +- [SiteBreadcrumb](./controls/TaxonomyPicker) (Taxonomy Picker) - [WebPartTitle](./controls/WebPartTitle) (Customizable web part title control) - [IFrameDialog](./controls/IFrameDialog) (renders a Dialog with an iframe as a content) Field customizer controls: -> **Note**: If you want to use these controls in your solution, first check out the start guide for these controls: [using the field controls](./controls/fields/main). +!!! note + If you want to use these controls in your solution, first check out the start guide for these controls: [using the field controls](./controls/fields/main). - [FieldAttachmentsRenderer](./controls/fields/FieldAttachmentsRenderer) (renders Clip icon based on the provided `count` property is defined and greater than 0) - [FieldDateRenderer](./controls/fields/FieldDateRenderer) (renders date string as a simple text) @@ -46,7 +49,7 @@ Field customizer controls: - [FieldNameRenderer](./controls/fields/FieldNameRenderer) (renders document's name as a link) - [FieldTaxonomyRenderer](./controls/fields/FieldTaxonomyRenderer) (renders terms from Managed Metadata field) - [FieldTextRenderer](./controls/fields/FieldTextRenderer) (renders simple text) -- [FieldTitleRenderer](./controls/fields/FieldTitleRenderer) (renders title either as a simple text or as a link to the Dislpay Form) +- [FieldTitleRenderer](./controls/fields/FieldTitleRenderer) (renders title either as a simple text or as a link to the Display Form) - [FieldUrlRenderer](./controls/fields/FieldUrlRenderer) (renders Hyperlink or Picture field value as a link or image) - [FieldUserRenderer](./controls/fields/FieldUserRenderer) (renders each referenced user/group as a link on a separate line) diff --git a/docs/documentation/mkdocs.yml b/docs/documentation/mkdocs.yml index 1c7fe23b0..eef83bac7 100644 --- a/docs/documentation/mkdocs.yml +++ b/docs/documentation/mkdocs.yml @@ -4,9 +4,11 @@ pages: - Controls: - FileTypeIcon: 'controls/FileTypeIcon.md' - ListView: 'controls/ListView.md' + - "ListView: add a contextual menu": 'controls/ListView.ContextualMenu.md' - Placeholder: 'controls/Placeholder.md' - SiteBreadcrumb: 'controls/SiteBreadcrumb.md' - WebPartTitle: 'controls/WebPartTitle.md' + - TaxonomyPicker: 'controls/TaxonomyPicker.md' - IFrameDialog: 'controls/IFrameDialog.md' - 'Field Controls': - 'Getting started': 'controls/fields/main.md' @@ -21,6 +23,7 @@ pages: - FieldTitleRenderer: 'controls/fields/FieldTitleRenderer.md' - FieldUrlRenderer: 'controls/fields/FieldUrlRenderer.md' - FieldUserRenderer: 'controls/fields/FieldUserRenderer.md' + - 'Beta testing': 'beta.md' - About: - 'Release notes': 'about/release-notes.md' - License: 'about/license.md' diff --git a/package.json b/package.json index 4a30c51ed..6bdc5f6af 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@pnp/spfx-controls-react", "description": "Reusable React controls for SharePoint Framework solutions", - "version": "1.2.5", + "version": "1.3.0", "engines": { "node": ">=0.10.0" }, @@ -11,7 +11,8 @@ "test": "gulp test", "prepublishOnly": "gulp", "versionUpdater": "gulp versionUpdater", - "karma": "karma start --circle true" + "karma": "karma start --circle true", + "changelog": "node scripts/sync-changelogs.js" }, "dependencies": { "@pnp/common": "^1.0.1", diff --git a/scripts/sync-changelogs.js b/scripts/sync-changelogs.js new file mode 100644 index 000000000..93d823e8e --- /dev/null +++ b/scripts/sync-changelogs.js @@ -0,0 +1,4 @@ +const fs = require('fs'); +const path = require('path'); +const changelog = fs.readFileSync(path.join(__dirname, '../CHANGELOG.md'), 'utf8'); +fs.writeFileSync(path.join(__dirname, '../docs/documentation/docs/about/release-notes.md'), changelog, 'utf-8'); diff --git a/src/ListPicker.ts b/src/ListPicker.ts new file mode 100644 index 000000000..e19ebaafd --- /dev/null +++ b/src/ListPicker.ts @@ -0,0 +1 @@ +export * from './controls/listPicker/index'; \ No newline at end of file diff --git a/src/TaxonomyPicker.ts b/src/TaxonomyPicker.ts new file mode 100644 index 000000000..1015b7705 --- /dev/null +++ b/src/TaxonomyPicker.ts @@ -0,0 +1 @@ +export * from './controls/taxonomyPicker/index'; diff --git a/src/common/SPEntities.ts b/src/common/SPEntities.ts index bbe110c39..003bde1b6 100644 --- a/src/common/SPEntities.ts +++ b/src/common/SPEntities.ts @@ -1,3 +1,19 @@ +/** + * Represents SP List + */ +export interface ISPList { + Id: string; + Title: string; + BaseTemplate: string; +} + +/** + * Replica of the returned value from the REST api + */ +export interface ISPLists { + value: ISPList[]; +} + /** * Represents SP Field */ diff --git a/src/controls/listPicker/IListPicker.ts b/src/controls/listPicker/IListPicker.ts new file mode 100644 index 000000000..f4e4d9b9c --- /dev/null +++ b/src/controls/listPicker/IListPicker.ts @@ -0,0 +1,68 @@ +import { ApplicationCustomizerContext } from '@microsoft/sp-application-base'; +import IWebPartContext from "@microsoft/sp-webpart-base/lib/core/IWebPartContext"; +import { IDropdownOption } from "office-ui-fabric-react/lib/Dropdown"; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { LibsOrderBy } from "../../services/ISPService"; + +export interface IListPickerProps { + /** + * The web part context + */ + context: WebPartContext | ApplicationCustomizerContext; + /** + * If provided, additional class name to provide on the dropdown element. + */ + className?: string; + /** + * Whether or not the control is disabled + */ + disabled?: boolean; + /** + * The SharePoint BaseTemplate to filter the list options by + */ + baseTemplate?: number; + /** + * Whether or not to include hidden lists. Default is true + */ + includeHidden?: boolean; + /** + * How to order the lists retrieved from SharePoint + */ + orderBy?: LibsOrderBy; + /** + * Keys of the selected item(s). If you provide this, you must maintain selection + * state by observing onSelectionChanged events and passing a new value in when changed. + */ + selectedList?: string | string[]; + /** + * Optional mode indicates if multi-choice selections is allowed. Default to false + */ + multiSelect?: boolean; + /** + * The label to use + */ + label?: string; + /** + * Input placeholder text. Displayed until option is selected. + */ + placeHolder?: string; + /** + * Callback issues when the selected option changes + */ + onSelectionChanged?: (newValue: string | string[]) => void; +} + +export interface IListPickerState { + /** + * The options available to the listPicker + */ + options: IDropdownOption[]; + /** + * Whether or not the listPicker options are loading + */ + loading: boolean; + /** + * Keys of the currently selected item(s). + */ + selectedList?: string | string[]; +} diff --git a/src/controls/listPicker/ListPicker.module.scss b/src/controls/listPicker/ListPicker.module.scss new file mode 100644 index 000000000..e9b94f41e --- /dev/null +++ b/src/controls/listPicker/ListPicker.module.scss @@ -0,0 +1,7 @@ +.listPicker { + .spinner { + float: right; + margin-top: 10px; + margin-right: -20px; + } +} \ No newline at end of file diff --git a/src/controls/listPicker/ListPicker.tsx b/src/controls/listPicker/ListPicker.tsx new file mode 100644 index 000000000..053a00300 --- /dev/null +++ b/src/controls/listPicker/ListPicker.tsx @@ -0,0 +1,164 @@ +import * as React from 'react'; +import { IDropdownOption, IDropdownProps, Dropdown } from 'office-ui-fabric-react/lib/components/Dropdown'; +import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/components/Spinner'; +import { IListPickerProps, IListPickerState } from './IListPicker'; +import { ISPService } from '../../services/ISPService'; +import { SPServiceFactory } from '../../services/SPServiceFactory'; +import * as appInsights from '../../common/appInsights'; + +import styles from './ListPicker.module.scss'; + +/** +* Empty list value, to be checked for single list selection +*/ +const EMPTY_LIST_KEY = 'NO_LIST_SELECTED'; + +/** +* Renders the controls for the ListPicker component +*/ +export class ListPicker extends React.Component { + private _options: IDropdownOption[] = []; + private _selectedList: string | string[]; + + /** + * Constructor method + */ + constructor(props: IListPickerProps) { + super(props); + + appInsights.track('ReactListPicker'); + + this.state = { + options: this._options, + loading: false + }; + + this.onChanged = this.onChanged.bind(this); + } + + /** + * Lifecycle hook when component is mounted + */ + public componentDidMount() { + this.loadLists(); + } + + /** + * componentDidUpdate lifecycle hook + * @param prevProps + * @param prevState + */ + public componentDidUpdate(prevProps: IListPickerProps, prevState: IListPickerState): void { + if ( + prevProps.baseTemplate !== this.props.baseTemplate || + prevProps.includeHidden !== this.props.includeHidden || + prevProps.orderBy !== this.props.orderBy || + prevProps.selectedList !== this.props.selectedList + ) { + this.loadLists(); + } + } + + /** + * Loads the list from SharePoint current web site + */ + private loadLists() { + const { context, baseTemplate, includeHidden, orderBy, multiSelect, selectedList } = this.props; + + // Show the loading indicator and disable the dropdown + this.setState({ loading: true }); + + const service: ISPService = SPServiceFactory.createService(context, true, 5000); + service.getLibs({ + baseTemplate: baseTemplate, + includeHidden: includeHidden, + orderBy: orderBy + }).then((results) => { + // Start mapping the lists to the dropdown option + results.value.map(list => { + this._options.push({ + key: list.Id, + text: list.Title + }); + }); + + if (multiSelect !== true) { + // Add option to unselct list + this._options.unshift({ + key: EMPTY_LIST_KEY, + text: '' + }); + } + + this._selectedList = this.props.selectedList; + + // Hide the loading indicator and set the dropdown options and enable the dropdown + this.setState({ + loading: false, + options: this._options, + selectedList: this._selectedList + }); + }); + } + + /** + * Raises when a list has been selected + * @param option the new selection + * @param index the index of the selection + */ + private onChanged(option: IDropdownOption, index?: number): void { + const { multiSelect, onSelectionChanged } = this.props; + + if (multiSelect === true) { + if (!this._selectedList) { + this._selectedList = [] as string[]; + } + + const selectedLists: string[] = this._selectedList as string[]; + // Check if option was selected + if (option.selected) { + selectedLists.push(option.key as string); + } else { + // Filter out the unselected list + this._selectedList = selectedLists.filter(list => list !== option.key); + } + } else { + this._selectedList = option.key as string; + } + + if (onSelectionChanged) { + onSelectionChanged(this._selectedList); + } + } + + /** + * Renders the ListPicker controls with Office UI Fabric + */ + public render(): JSX.Element { + const { loading, options, selectedList } = this.state; + const { className, disabled, multiSelect, label, placeHolder } = this.props; + + const dropdownOptions: IDropdownProps = { + className: className, + options: options, + disabled: ( loading || disabled ), + label: label, + placeHolder: placeHolder, + onChanged: this.onChanged + }; + + if (multiSelect === true) { + dropdownOptions.multiSelect = true; + dropdownOptions.selectedKeys = selectedList as string[]; + } else { + dropdownOptions.selectedKey = selectedList as string; + } + + return ( +
+ { loading && } + +
+ ); + } +} diff --git a/src/controls/listPicker/index.ts b/src/controls/listPicker/index.ts new file mode 100644 index 000000000..49c69677d --- /dev/null +++ b/src/controls/listPicker/index.ts @@ -0,0 +1,2 @@ +export * from './IListPicker'; +export * from './ListPicker'; \ No newline at end of file diff --git a/src/controls/listView/ListView.tsx b/src/controls/listView/ListView.tsx index 2804bafde..00df6b637 100644 --- a/src/controls/listView/ListView.tsx +++ b/src/controls/listView/ListView.tsx @@ -60,7 +60,9 @@ export class ListView extends React.Component { if (!isEqual(prevProps, this.props)) { // Reset the selected items - this._selection.setItems(this.props.items, true); + if (this._selection) { + this._selection.setItems(this.props.items, true); + } // Process list view properties this._processProperties(); } @@ -353,15 +355,17 @@ export class ListView extends React.Component { const sortedItems = descending ? ascItems.reverse() : ascItems; // Check if selection needs to be updated - const selection = this._selection.getSelection(); - if (selection && selection.length > 0) { - // Clear selection - this._selection.setItems([], true); - setTimeout(() => { - // Find new index - let idxs: number[] = selection.map(item => findIndex(sortedItems, item)); - idxs.forEach(idx => this._selection.setIndexSelected(idx, true, false)); - }, 0); + if (this._selection) { + const selection = this._selection.getSelection(); + if (selection && selection.length > 0) { + // Clear selection + this._selection.setItems([], true); + setTimeout(() => { + // Find new index + let idxs: number[] = selection.map(item => findIndex(sortedItems, item)); + idxs.forEach(idx => this._selection.setIndexSelected(idx, true, false)); + }, 0); + } } // Return the sorted items list diff --git a/src/controls/taxonomyPicker/ErrorMessage.tsx b/src/controls/taxonomyPicker/ErrorMessage.tsx new file mode 100644 index 000000000..a8acea485 --- /dev/null +++ b/src/controls/taxonomyPicker/ErrorMessage.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import styles from './TaxonomyPicker.module.scss'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; + +export interface IFieldErrorMessageProps { + errorMessage: string; +} + +/** + * Component that shows an error message when something went wront with the property control + */ +export default class FieldErrorMessage extends React.Component { + public render(): JSX.Element { + if (this.props.errorMessage !== 'undefined' && this.props.errorMessage !== null && this.props.errorMessage !== '') { + return ( +
+

+ + {this.props.errorMessage} +

+
+ ); + } else { + return null; + } + } +} diff --git a/src/controls/taxonomyPicker/ITaxonomyPicker.ts b/src/controls/taxonomyPicker/ITaxonomyPicker.ts new file mode 100644 index 000000000..c114ea581 --- /dev/null +++ b/src/controls/taxonomyPicker/ITaxonomyPicker.ts @@ -0,0 +1,109 @@ +import { IPickerTerms } from './ITermPicker'; +import { ITermStore, IGroup, ITermSet, ITerm } from '../../services/ISPTermStorePickerService'; +import SPTermStorePickerService from '../../services/SPTermStorePickerService'; +import { IWebPartContext } from '@microsoft/sp-webpart-base'; + +/** + * PropertyFieldTermPickerHost properties interface +// */ +export interface ITaxonomyPickerProps { + /** + * Property field label displayed on top + */ + label: string; + /** + * TermSet Picker Panel title + */ + panelTitle: string; + /** + * Defines if the user can select only one or many term sets. Default value is false. + */ + allowMultipleSelections?: boolean; + /** + * Defines the selected by default term sets. + */ + initialValues?: IPickerTerms; + /** + * WebPart's context + */ + context: IWebPartContext; + /** + * Limit the terms that can be picked by the Term Set name or ID + */ + termsetNameOrID: string; + /** + * Id of a child term in the termset where to be able to selected and search the terms from + */ + anchorId?: string; + /** + * Specify if the term set itself is selectable in the tree view + */ + isTermSetSelectable?: boolean; + /** + * Whether the property pane field is enabled or not. + */ + disabled?: boolean; + /** + * The method is used to get the validation error message and determine whether the input value is valid or not. + * + * When it returns string: + * - If valid, it returns empty string. + * - If invalid, it returns the error message string and the text field will + * show a red border and show an error message below the text field. + * + * When it returns Promise: + * - The resolved value is display as error message. + * - The rejected, the value is thrown away. + * + */ + onGetErrorMessage?: (value: IPickerTerms) => string | Promise; + + /** + * onChange Event + */ + onChange?: (newValue?: IPickerTerms) => void; +} + +/** + * PropertyFieldTermPickerHost state interface + */ +export interface ITaxonomyPickerState { + + termSetAndTerms? : ITermSet; + errorMessage?: string; + openPanel?: boolean; + loaded?: boolean; + activeNodes?: IPickerTerms; +} + +export interface ITermChanges { + changedCallback: (term: ITerm, checked: boolean) => void; + activeNodes?: IPickerTerms; +} + + +export interface ITermParentProps extends ITermChanges { + termset: ITermSet; + multiSelection: boolean; + anchorId? : string; + isTermSetSelectable?: boolean; + + autoExpand: () => void; + termSetSelectedChange?: (termSet: ITermSet, isChecked: boolean) => void; +} + +export interface ITermParentState { + + loaded?: boolean; + expanded?: boolean; +} + +export interface ITermProps extends ITermChanges { + termset: string; + term: ITerm; + multiSelection: boolean; +} + +export interface ITermState { + selected?: boolean; +} diff --git a/src/controls/taxonomyPicker/ITermPicker.ts b/src/controls/taxonomyPicker/ITermPicker.ts new file mode 100644 index 000000000..ee2d6c055 --- /dev/null +++ b/src/controls/taxonomyPicker/ITermPicker.ts @@ -0,0 +1,93 @@ +import { IWebPartContext } from '@microsoft/sp-webpart-base'; + + + +/** + * Selected terms + */ +export interface IPickerTerm { + name: string; + key: string; + path: string; + termSet: string; + termSetName? : string; +} + +export interface IPickerTerms extends Array { } + +/** + * Public properties of the PropertyFieldTermPicker custom field + */ +export interface IPropertyFieldTermPickerProps { + /** + * Property field label displayed on top + */ + label: string; + /** + * TermSet Picker Panel title + */ + panelTitle: string; + /** + * Defines if the user can select only one or many term sets. Default value is false. + */ + allowMultipleSelections?: boolean; + /** + * Defines the selected by default term sets. + */ + initialValues?: IPickerTerms; + /** + * Indicator to define if the system Groups are exclude. Default is false. + */ + excludeSystemGroup?: boolean; + /** + * WebPart's context + */ + context: IWebPartContext; + /** + * Limit the term sets that can be used by the group name or ID + */ + limitByGroupNameOrID?: string; + /** + * Limit the terms that can be picked by the Term Set name or ID + */ + limitByTermsetNameOrID?: string; + /** + * Defines a onPropertyChange function to raise when the selected value changed. + * Normally this function must be always defined with the 'this.onPropertyChange' + * method of the web part object. + */ + onPropertyChange(propertyPath: string, oldValue: any, newValue: any): void; + /** + * Parent Web Part properties + */ + properties: any; + /** + * An UNIQUE key indicates the identity of this control + */ + key: string; + /** + * Whether the property pane field is enabled or not. + */ + disabled?: boolean; + /** + * The method is used to get the validation error message and determine whether the input value is valid or not. + * + * When it returns string: + * - If valid, it returns empty string. + * - If invalid, it returns the error message string and the text field will + * show a red border and show an error message below the text field. + * + * When it returns Promise: + * - The resolved value is display as error message. + * - The rejected, the value is thrown away. + * + */ + onGetErrorMessage?: (value: IPickerTerms) => string | Promise; + /** + * Custom Field will start to validate after users stop typing for `deferredValidationTime` milliseconds. + * Default value is 200. + */ + deferredValidationTime?: number; + +} + diff --git a/src/controls/taxonomyPicker/TaxonomyPicker.module.scss b/src/controls/taxonomyPicker/TaxonomyPicker.module.scss new file mode 100644 index 000000000..5956cc045 --- /dev/null +++ b/src/controls/taxonomyPicker/TaxonomyPicker.module.scss @@ -0,0 +1,151 @@ +.listItem { + height: 36px; + line-height: 36px; + cursor: pointer; + + >div { + display: inline-block; + margin-right: 10px; + } + + img { + margin-right: 5px; + vertical-align: middle; + } +} + +.termFieldTable { + border-spacing: 0; + width: 100%; + + .termFieldRow { + vertical-align: initial; + } + + input[type="text"] { + cursor: pointer; + opacity: 0.8; + width: 100%; + } +} + +.termset { + cursor: pointer; + margin-left: 15px; +} + +.termSetSelectable { + height: 50px; + line-height: 50px; +} + +.termSetSelector { + display: inline-block; + margin: 0 8px 0 4px; + vertical-align: middle; +} + +.term { + padding-left: 20px; + + .termEnabled, + .termDisabled { + background-repeat: no-repeat; + background-position: 30px 3px; + } + + .termEnabled { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACzSURBVDhPY2AYNKCoqIgTiOcD8X8S8F6wB4Aa1IH4akNDw+mPHz++/E8EuHTp0jmQRSDNCcXFxa/XrVt3gAh9KEpgBvx/9OjRLVI1g9TDDYBp3rlz5//Kysr/IJoYgGEASPPatWsbQDQxAMOAbdu2gZ0FookBcAOePHlyhxgN6GqQY+Hdhg0bDpJqCNgAaDrQAnJuNDY2nvr06dMbYgw6e/bsabgBUEN4yEiJ2wdNViLfIQC3sTh2vtJcswAAAABJRU5ErkJggg=='); // /_layouts/15/Images/EMMTerm.png + } + + .termDisabled { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAFMSURBVDhPzZPNSsNAFIULQqEQEPoSQqAg7gqC0HdwXxAElyYgBBGSZ+gDdOUyIauULATdKrgIKCKuQjbiDySCkNV4TrkzpJW2cefAxwwzc86de2em0/k3zXGcHpgC9Qeu5glAsAMefN+/K8vyVbVoWZbdMxDFY9d136Ioum6hW9iiDVSe588rxDXmJ+AAdAWOOVcbAy1O01R5nqfYoxVglyk+Hu7Z4FiwOcc1GBRMwQSnOAxDHz0jDyCwwCVQS3DO0gU0BkmSzG8A/UQiz7DxC5yLGQ1PwDeYGYOiKF6WarCPDUOJeor+A4z0m8P4SNaG+hY+4zi+aZh0scEBNeB41DTBuCcGjj6FjaM/BUFwW1XVO6vdMNiSdIzJLwN5TJZ+iSLQKYwbR9cmZyaFdX+JhZIiMue+cLFQxA0G22uusd/6I8OEb4LXRwZN4Q+3Ys8Mb9+nRgAAAABJRU5ErkJggg=='); // /_layouts/15/Images/EMMTermDeprecated.png + } + + label>span { + padding-left: 25px; + } +} + +.actions { + button:first-child { + margin-right: 15px; + } +} + +.termBasePicker +{ + background-color: #fff; +} + .termSuggestion + { + min-height: 40px; + width: 100%; + text-align: left; + cursor: pointer; + + + .termSuggestionSubTitle + { + font-size: 12px; + color: #666666; + } + + } + + .pickedTermRoot + { + position: relative; + outline: transparent; + box-sizing: content-box; + flex-shrink: 1; + background: #f4f4f4; + margin: 2px; + height: 26px; + line-height: 26px; + cursor: default; + display: flex; + flex-wrap: nowrap; + max-width: 300px; + + .pickedTermText + { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 30px; + margin: 0 8px; + } + .pickedTermCloseIcon + { + cursor: pointer; + color: #666666; + font-size: 12px; + display: inline-block; + text-align: center; + vertical-align: top; + width: 30px; + height: 100%; + -ms-flex-negative: 0; + flex-shrink: 0; + } + } + + .errorMessage { + font-size: 12px; + font-weight: 400; + color: #a80000; + margin: 0; + padding-top: 5px; + display: flex; + align-items: center; + } + + .errorIcon { + font-size: 14px; + margin-right: 5px; + } + + + + diff --git a/src/controls/taxonomyPicker/TaxonomyPicker.tsx b/src/controls/taxonomyPicker/TaxonomyPicker.tsx new file mode 100644 index 000000000..1dce34d85 --- /dev/null +++ b/src/controls/taxonomyPicker/TaxonomyPicker.tsx @@ -0,0 +1,298 @@ +import * as React from 'react'; +import { IWebPartContext } from '@microsoft/sp-webpart-base'; +import { PrimaryButton, DefaultButton, IconButton, IButtonProps } from 'office-ui-fabric-react/lib/Button'; +import { Panel, PanelType } from 'office-ui-fabric-react/lib/Panel'; +import { Spinner, SpinnerType } from 'office-ui-fabric-react/lib/Spinner'; +import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions } from '@microsoft/sp-http'; +import { Label } from 'office-ui-fabric-react/lib/Label'; +import TermPicker from './TermPicker'; +import { BasePicker, IBasePickerProps, IPickerItemProps } from 'office-ui-fabric-react/lib/Pickers'; +import { IPickerTerms, IPickerTerm } from './ITermPicker'; +import { ITaxonomyPickerProps, ITaxonomyPickerState, ITermParentProps, ITermParentState, ITermProps, ITermState } from './ITaxonomyPicker'; +import SPTermStorePickerService from './../../services/SPTermStorePickerService'; +import { ITermSet, IGroup, ITerm } from './../../services/ISPTermStorePickerService'; +import styles from './TaxonomyPicker.module.scss'; +import { sortBy, uniqBy, cloneDeep } from '@microsoft/sp-lodash-subset'; +import TermParent from './TermParent'; +import FieldErrorMessage from './ErrorMessage'; +import * as appInsights from '../../common/appInsights'; + + +/** + * Image URLs / Base64 + */ +export const COLLAPSED_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAUCAYAAABSx2cSAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAABh0RVh0U29mdHdhcmUAUGFpbnQuTkVUIHYzLjEwcrIlkgAAAIJJREFUOE/NkjEKwCAMRdu7ewZXJ/EqHkJwE9TBCwR+a6FLUQsRwYBTeD8/35wADnZVmPvY4OOYO3UNbK1FKeUWH+fRtK21hjEG3vuhQBdOKUEpBedcV6ALExFijJBSIufcFBjCVSCEACEEqpNvBmsmT+3MTnvqn/+O4+1vdtv7274APmNjtuXVz6sAAAAASUVORK5CYII='; // /_layouts/15/images/MDNCollapsed.png +export const EXPANDED_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAUCAYAAABSx2cSAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAABh0RVh0U29mdHdhcmUAUGFpbnQuTkVUIHYzLjEwcrIlkgAAAFtJREFUOE9j/P//PwPZAKSZXEy2RrCLybV1CGjetWvX/46ODqBLUQOXoJ9BGtXU1MCYJM0wjZGRkaRpRtZIkmZ0jSRpBgUOzJ8wmqwAw5eICIb2qGYSkyfNAgwAasU+UQcFvD8AAAAASUVORK5CYII='; // /_layouts/15/images/MDNExpanded.png +export const GROUP_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAC9SURBVDhPY2CgNXh1qEkdiJ8D8X90TNBuJM0V6IpBhoHFgIxebKYTIwYzAMNpxGhGdsFwNoBgNEFjAWsYgOSKiorMgPgbEP/Hgj8AxXpB0Yg1gQAldYuLix8/efLkzn8s4O7du9eAan7iM+DV/v37z546der/jx8/sJkBdhVOA5qbm08ePnwYrOjQoUOkGwDU+AFowLmjR4/idwGukAYaYAkMgxfPnj27h816kDg4DPABoAI/IP6DIxZA4l0AOd9H3QXl5+cAAAAASUVORK5CYII='; // /_layouts/15/Images/EMMGroup.png +export const TERMSET_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACaSURBVDhPrZLRCcAgDERdpZMIjuQA7uWH4CqdxMY0EQtNjKWB0A/77sxF55SKMTalk8a61lqCFqsLiwKac84ZRUUBi7MoYHVmAfjfjzE6vJqZQfie0AcwBQVW8ATi7AR7zGGGNSE6Q2cyLSPIjRswjO7qKhcPDN2hK46w05wZMcEUIG+HrzzcrRsQBIJ5hS8C9fGAPmRwu/9RFxW6L8CM4Ry8AAAAAElFTkSuQmCC'; // /_layouts/15/Images/EMMTermSet.png +export const TERM_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACzSURBVDhPY2AYNKCoqIgTiOcD8X8S8F6wB4Aa1IH4akNDw+mPHz++/E8EuHTp0jmQRSDNCcXFxa/XrVt3gAh9KEpgBvx/9OjRLVI1g9TDDYBp3rlz5//Kysr/IJoYgGEASPPatWsbQDQxAMOAbdu2gZ0FookBcAOePHlyhxgN6GqQY+Hdhg0bDpJqCNgAaDrQAnJuNDY2nvr06dMbYgw6e/bsabgBUEN4yEiJ2wdNViLfIQC3sTh2vtJcswAAAABJRU5ErkJggg=='; + +/** + * Renders the controls for PropertyFieldTermPicker component + */ +export class TaxonomyPicker extends React.Component { + private delayedValidate: (value: IPickerTerms) => void; + private termsService: SPTermStorePickerService; + private previousValues: IPickerTerms = []; + private cancel: boolean = true; + + /** + * Constructor method + */ + constructor(props: ITaxonomyPickerProps) { + super(props); + + appInsights.track('ReactTaxonomyPicker'); + + this.state = { + activeNodes: typeof this.props.initialValues !== 'undefined' ? this.props.initialValues : [], + termSetAndTerms: null, + loaded: false, + openPanel: false, + errorMessage: '' + }; + + this.onOpenPanel = this.onOpenPanel.bind(this); + this.onClosePanel = this.onClosePanel.bind(this); + this.onSave = this.onSave.bind(this); + this.termsChanged = this.termsChanged.bind(this); + this.termsFromPickerChanged = this.termsFromPickerChanged.bind(this); + } + + /** + * Loads the list from SharePoint current web site + */ + private loadTermStores(): void { + this.termsService = new SPTermStorePickerService(this.props, this.props.context); + this.termsService.getAllTerms(this.props.termsetNameOrID).then((response: ITermSet) => { + // Check if a response was retrieved + if (response !== null) { + this.setState({ + termSetAndTerms: response, + loaded: true + }); + } else { + this.setState({ + termSetAndTerms: null, + loaded: true + }); + } + }); + } + + /** + * Open the right Panel + */ + private onOpenPanel(): void { + if (this.props.disabled === true) { + return; + } + + // Store the current code value + this.previousValues = cloneDeep(this.state.activeNodes); + this.cancel = true; + + this.loadTermStores(); + + this.setState({ + openPanel: true, + loaded: false + }); + } + + /** + * Close the panel + */ + private onClosePanel(): void { + + this.setState(() => { + const newState: ITaxonomyPickerState = { + openPanel: false, + loaded: false + }; + + // Check if the property has to be reset + if (this.cancel) { + newState.activeNodes = this.previousValues; + } + + return newState; + }); + } + + /** + * On save click action + */ + private onSave(): void { + this.cancel = false; + this.onClosePanel(); + // Trigger the onChange event + this.props.onChange(this.state.activeNodes); + } + + /** + * Clicks on a node + * @param node + */ + private termsChanged(term: ITerm, checked: boolean): void { + + let activeNodes = this.state.activeNodes; + if (typeof term === 'undefined' || term === null) { + return; + } + + // Term item to add to the active nodes array + const termItem = { + name: term.Name, + key: term.Id, + path: term.PathOfTerm, + termSet: term.TermSet.Id + }; + + // Check if the term is checked or unchecked + if (checked) { + // Check if it is allowed to select multiple terms + if (this.props.allowMultipleSelections) { + // Add the checked term + activeNodes.push(termItem); + // Filter out the duplicate terms + activeNodes = uniqBy(activeNodes, 'key'); + } else { + // Only store the current selected item + activeNodes = [termItem]; + } + } else { + // Remove the term from the list of active nodes + activeNodes = activeNodes.filter(item => item.key !== term.Id); + } + // Sort all active nodes + activeNodes = sortBy(activeNodes, 'path'); + // Update the current state + this.setState({ + activeNodes: activeNodes + }); + } + + /** + * Fires When Items Changed in TermPicker + * @param node + */ + private termsFromPickerChanged(terms: IPickerTerms) { + this.props.onChange(terms); + this.setState({ + activeNodes: terms + }); + } + + + /** + * Gets the given node position in the active nodes collection + * @param node + */ + private getSelectedNodePosition(node: IPickerTerm): number { + for (let i = 0; i < this.state.activeNodes.length; i++) { + if (node.key === this.state.activeNodes[i].key) { + return i; + } + } + return -1; + } + + /** + * TermSet selection handler + * @param termSet + * @param isChecked + */ + private termSetSelectedChange = (termSet: ITermSet, isChecked: boolean) => { + const ts: ITermSet = {...termSet}; + // Clean /Guid.../ from the ID + ts.Id = this.termsService.cleanGuid(ts.Id); + // Create a term for the termset + const term: ITerm = { + Name: ts.Name, + Id: ts.Id, + TermSet: ts, + PathOfTerm: "", + _ObjectType_: ts._ObjectType_, + _ObjectIdentity_: ts._ObjectIdentity_, + Description: ts.Description, + IsDeprecated: null, + IsRoot: null + }; + + // Trigger the normal change event + this.termsChanged(term, isChecked); + } + + /** + * Renders the SPListpicker controls with Office UI Fabric + */ + public render(): JSX.Element { + + return ( +
+ {this.props.label && } + + + + + + + +
+ + + +
+ + + + { + return ( +
+ + + +
+ ); + }}> + + { + /* Show spinner in the panel while retrieving terms */ + this.state.loaded === false ? : '' + } + { + this.state.loaded === true && this.state.termSetAndTerms && ( +
+

{this.state.termSetAndTerms.Name}

+ +
+ ) + } +
+
+ ); + } +} diff --git a/src/controls/taxonomyPicker/Term.tsx b/src/controls/taxonomyPicker/Term.tsx new file mode 100644 index 000000000..66d8a396e --- /dev/null +++ b/src/controls/taxonomyPicker/Term.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; +import { ITermProps, ITermState } from './ITaxonomyPicker'; + +import styles from './TaxonomyPicker.module.scss'; + + +/** + * Term component + * Renders a selectable term + */ +export default class Term extends React.Component { + constructor(props: ITermProps) { + super(props); + + // Check if current term is selected + let active = this.props.activeNodes.filter(item => item.key === this.props.term.Id); + + this.state = { + selected: active.length > 0 + }; + + this._handleChange = this._handleChange.bind(this); + } + + /** + * Handle the checkbox change trigger + */ + private _handleChange(ev: React.FormEvent, isChecked: boolean) { + this.setState({ + selected: isChecked + }); + this.props.changedCallback(this.props.term, isChecked); + } + + /** + * Lifecycle event hook when component retrieves new properties + * @param nextProps + * @param nextContext + */ + public componentWillReceiveProps?(nextProps: ITermProps, nextContext: any): void { + // If multi-selection is turned off, only a single term can be selected + if (!this.props.multiSelection) { + let active = nextProps.activeNodes.filter(item => item.key === this.props.term.Id); + this.state = { + selected: active.length > 0 + }; + } + } + + + public render(): JSX.Element { + const styleProps: React.CSSProperties = { + marginLeft: `${(this.props.term.PathDepth * 30)}px` + }; + + return ( +
+ +
+ ); + } +} diff --git a/src/controls/taxonomyPicker/TermParent.tsx b/src/controls/taxonomyPicker/TermParent.tsx new file mode 100644 index 000000000..1cd044ae8 --- /dev/null +++ b/src/controls/taxonomyPicker/TermParent.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import { Spinner, SpinnerType } from 'office-ui-fabric-react/lib/Spinner'; +import { ITermParentProps, ITermParentState } from './ITaxonomyPicker'; +import { ITerm, ITermSet } from '../../services/ISPTermStorePickerService'; +import { EXPANDED_IMG, COLLAPSED_IMG, TERMSET_IMG, TERM_IMG } from './TaxonomyPicker'; +import Term from './Term'; + +import styles from './TaxonomyPicker.module.scss'; +import { Checkbox } from 'office-ui-fabric-react'; +import * as strings from 'ControlStrings'; + +/** + * Term Parent component, represents termset or term if anchorId + */ +export default class TermParent extends React.Component { + + private _terms : ITerm[]; + private _anchorName : string; + + constructor(props: ITermParentProps) { + super(props); + + this._terms = this.props.termset.Terms; + this.state = { + loaded: true, + expanded: true + }; + this._handleClick = this._handleClick.bind(this); + } + + /** + * componentWillMount + */ + public componentWillMount() + { + // fix term depth if anchroid for rendering + if (this.props.anchorId) + { + const anchorTerm = this._terms.filter(t => t.Id.toLowerCase() === this.props.anchorId.toLowerCase()).shift(); + if (anchorTerm) + { + const anchorDepth = anchorTerm.PathDepth; + this._anchorName = anchorTerm.Name; + var anchorTerms : ITerm[] = this._terms.filter(t => t.PathOfTerm.substring(0, anchorTerm.PathOfTerm.length) === anchorTerm.PathOfTerm && t.Id !== anchorTerm.Id); + + anchorTerms = anchorTerms.map(term => { + term.PathDepth = term.PathDepth - anchorTerm.PathDepth; + + return term; + }); + + this._terms = anchorTerms; + } + } + } + + + /** + * Handle the click event: collapse or expand + */ + private _handleClick() { + this.setState({ + expanded: !this.state.expanded + }); + } + + + /** + * The term set selection changed + */ + private termSetSelectionChange = (ev: React.FormEvent, isChecked: boolean): void => { + this.props.termSetSelectedChange(this.props.termset, isChecked); + } + + + public render(): JSX.Element { + // Specify the inline styling to show or hide the termsets + const styleProps: React.CSSProperties = { + display: this.state.expanded ? 'block' : 'none' + }; + + let termElm: JSX.Element =
; + // Check if the terms have been loaded + + if (this.state.loaded) { + if (this._terms.length > 0) { + termElm = ( +
+ { + this._terms.map(term => { + return ; + }) + } +
+ ); + } else { + termElm =
{strings.TaxonomyPickerNoTerms}
; + } + } else { + termElm = ; + } + + + return ( +
+
+ {strings.TaxonomyPickerExpandTitle} + { + // Show the termset selection box + (!this.props.anchorId && this.props.isTermSetSelectable) && + a.path === "" && a.key === a.termSet).length >= 1} + onChange={this.termSetSelectionChange} /> + } + {strings.TaxonomyPickerMenuTermSet} + { + this.props.anchorId ? + this._anchorName : + this.props.termset.Name + } +
+
+ {termElm} +
+
+ ); + } +} diff --git a/src/controls/taxonomyPicker/TermPicker.tsx b/src/controls/taxonomyPicker/TermPicker.tsx new file mode 100644 index 000000000..06914f9af --- /dev/null +++ b/src/controls/taxonomyPicker/TermPicker.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { BasePicker, IBasePickerProps, IPickerItemProps } from 'office-ui-fabric-react/lib/Pickers'; +import { IPickerTerm, IPickerTerms } from './ITermPicker'; +import SPTermStorePickerService from './../../services/SPTermStorePickerService'; +import styles from './TaxonomyPicker.module.scss'; +import { ITaxonomyPickerProps } from './ITaxonomyPicker'; +import { IWebPartContext } from '@microsoft/sp-webpart-base'; +import * as strings from 'ControlStrings'; + +export class TermBasePicker extends BasePicker> +{ + +} + +export interface ITermPickerState { + terms: IPickerTerms; +} + +export interface ITermPickerProps { + termPickerHostProps: ITaxonomyPickerProps; + context: IWebPartContext; + disabled: boolean; + value: IPickerTerms; + allowMultipleSelections : boolean; + isTermSetSelectable?: boolean; + onChanged: (items: IPickerTerm[]) => void; +} + +export default class TermPicker extends React.Component { + + /** + * Constructor method + */ + constructor(props: any) { + super(props); + this.onRenderItem = this.onRenderItem.bind(this); + this.onRenderSuggestionsItem = this.onRenderSuggestionsItem.bind(this); + this.onFilterChanged = this.onFilterChanged.bind(this); + this.onGetTextFromItem = this.onGetTextFromItem.bind(this); + + this.state = { + terms: this.props.value + }; + + } + + /** + * componentWillReceiveProps method + */ + public componentWillReceiveProps(nextProps: ITermPickerProps) { + // check to see if props is different to avoid re-rendering + let newKeys = nextProps.value.map(a => a.key); + let currentKeys = this.state.terms.map(a => a.key); + if (newKeys.sort().join(',') !== currentKeys.sort().join(',')) { + this.setState({ terms: nextProps.value }); + } + } + + /** + * Renders the item in the picker + */ + protected onRenderItem(term: IPickerItemProps) { + return ( +
+ {term.item.name} + { + !term.disabled && ( + + + + ) + } +
+ ); + } + + /** + * Renders the suggestions in the picker + */ + protected onRenderSuggestionsItem(term: IPickerTerm, props) { + let termParent = term.termSetName; + let termTitle = `${term.name} [${term.termSetName}]`; + if (term.path.indexOf(";") !== -1) { + let splitPath = term.path.split(";"); + termParent = splitPath[splitPath.length - 2]; + splitPath.pop(); + termTitle = `${term.name} [${term.termSetName}:${splitPath.join(':')}]`; + } + return ( +
+
{term.name}
+
{strings.TaxonomyPickerInLabel} {termParent ? termParent : strings.TaxonomyPickerTermSetLabel}
+
+ ); + } + + /** + * When Filter Changes a new search for suggestions + */ + private async onFilterChanged(filterText: string, tagList: IPickerTerm[]): Promise { + if (filterText !== "") { + let termsService = new SPTermStorePickerService(this.props.termPickerHostProps, this.props.context); + let terms: IPickerTerm[] = await termsService.searchTermsByName(filterText); + // Check if the termset can be selected + if (this.props.isTermSetSelectable) { + // Retrieve the current termset + const termSet = await termsService.getTermSet(); + // Check if termset was retrieved and if it contains the filter value + if (termSet && termSet.Name.toLowerCase().indexOf(filterText.toLowerCase()) === 0) { + // Add the termset to the suggestion list + terms.push({ + key: termsService.cleanGuid(termSet.Id), + name: termSet.Name, + path: "", + termSet: termsService.cleanGuid(termSet.Id) + }); + } + } + // Filter out the terms which are already set + const filteredTerms = []; + for (const term of terms) { + if (tagList.filter(tag => tag.key === term.key).length === 0) { + filteredTerms.push(term); + } + } + return filteredTerms; + } else { + return Promise.resolve([]); + } + } + + + /** + * gets the text from an item + */ + private onGetTextFromItem(item: any): any { + return item.name; + } + + /** + * Render method + */ + public render(): JSX.Element { + return ( +
+ +
+ ); + + } +} diff --git a/src/controls/taxonomyPicker/index.ts b/src/controls/taxonomyPicker/index.ts new file mode 100644 index 000000000..d011a5cd1 --- /dev/null +++ b/src/controls/taxonomyPicker/index.ts @@ -0,0 +1,3 @@ +export * from './ITaxonomyPicker'; +export * from './TaxonomyPicker'; +export * from './ITermPicker'; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index e3e96a1bd..abfcf3225 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,11 @@ export * from './FileTypeIcon'; export * from './ListView'; export * from './Placeholder'; export * from './SiteBreadcrumb'; +export * from './TaxonomyPicker'; export * from './WebPartTitle'; -export * from './IFrameDialog'; +export * from './ListPicker'; +export * from './IFrameDialog'; export * from './Common'; export * from './Utilities'; export * from './IFrameDialog'; diff --git a/src/loc/en-us.ts b/src/loc/en-us.ts index c4cdff11c..f443af6df 100644 --- a/src/loc/en-us.ts +++ b/src/loc/en-us.ts @@ -38,6 +38,12 @@ define([], () => { "SendEmailTo": "Send an email to {0}", "StartChatWith": "Start a chat with {0}", "Contact": "Contact", - "UpdateProfile": "Update your profile" + "UpdateProfile": "Update your profile", + + "TaxonomyPickerNoTerms": "Term set does not contain any terms", + "TaxonomyPickerExpandTitle": "Expand this Term Set", + "TaxonomyPickerMenuTermSet": "Menu for Term Set", + "TaxonomyPickerInLabel": "in", + "TaxonomyPickerTermSetLabel": "Term Set" }; }); diff --git a/src/loc/mystrings.d.ts b/src/loc/mystrings.d.ts index cec683743..74482a912 100644 --- a/src/loc/mystrings.d.ts +++ b/src/loc/mystrings.d.ts @@ -8,6 +8,13 @@ declare interface IControlStrings { StartChatWith: string; Contact: string; UpdateProfile: string; + + // Taxonomy picker + TaxonomyPickerNoTerms: string; + TaxonomyPickerExpandTitle: string; + TaxonomyPickerMenuTermSet: string; + TaxonomyPickerInLabel: string; + TaxonomyPickerTermSetLabel: string; } declare module 'ControlStrings' { diff --git a/src/services/ISPService.ts b/src/services/ISPService.ts new file mode 100644 index 000000000..c62fa8a8e --- /dev/null +++ b/src/services/ISPService.ts @@ -0,0 +1,21 @@ +import { ISPLists } from "../common/SPEntities"; + +export enum LibsOrderBy { + Id = 1, + Title +} +/** + * Options used to sort and filter + */ +export interface ILibsOptions { + orderBy?: LibsOrderBy; + baseTemplate?: number; + includeHidden?: boolean; +} +export interface ISPService { + /** + * Get the lists from SharePoint + * @param options Options used to order and filter during the API query + */ + getLibs(options?: ILibsOptions): Promise; +} \ No newline at end of file diff --git a/src/services/ISPTermStorePickerService.ts b/src/services/ISPTermStorePickerService.ts new file mode 100644 index 000000000..39e1d43dd --- /dev/null +++ b/src/services/ISPTermStorePickerService.ts @@ -0,0 +1,75 @@ +/** + * Interfaces for Term store, groups and term sets + */ +export interface ITermStore { + _ObjectType_: string; // SP.Taxonomy.TermStore + _ObjectIdentity_: string; + Id: string; + Name: string; + Groups: IGroups; +} + +export interface IGroups { + _ObjectType_: string; // SP.Taxonomy.TermGroupCollection + _Child_Items_: IGroup[]; +} + +export interface IGroup { + _ObjectType_: string; // SP.Taxonomy.TermGroup + _ObjectIdentity_: string; + TermSets: ITermSets; + Id: string; + Name: string; + IsSystemGroup: boolean; +} + +export interface ITermSets { + _ObjectType_: string; // SP.Taxonomy.TermSetCollection + _Child_Items_: ITermSet[]; +} + +export interface ITermSet { + _ObjectType_: string; // SP.Taxonomy.TermSet + _ObjectIdentity_: string; + Id: string; + Name: string; + Description: string; + Names: ITermSetNames; + Terms?: ITerm[]; +} + +export interface ITermSetMinimal { + _ObjectType_?: string; // SP.Taxonomy.TermSet + _ObjectIdentity_?: string; + Id: string; + Name: string; +} + +export interface ITermSetNames { + [locale: string]: string; +} + +/** + * Interfaces for the terms + */ +export interface ITerms { + _ObjectType_: string; // SP.Taxonomy.TermCollection + _Child_Items_: ITerm[]; +} + +/** + * Term + */ +export interface ITerm { + _ObjectType_: string; // SP.Taxonomy.Term + _ObjectIdentity_: string; + Id: string; + Name: string; + Description: string; + IsDeprecated: boolean; + IsRoot: boolean; + PathOfTerm: string; + TermSet: ITermSetMinimal; + PathDepth?: number; + ParentId?: string; +} diff --git a/src/services/SPService.ts b/src/services/SPService.ts new file mode 100644 index 000000000..a68bbd60a --- /dev/null +++ b/src/services/SPService.ts @@ -0,0 +1,36 @@ +import { ISPService, ILibsOptions, LibsOrderBy } from "./ISPService"; +import { ISPLists } from "../common/SPEntities"; +import { WebPartContext } from "@microsoft/sp-webpart-base"; +import { ApplicationCustomizerContext } from '@microsoft/sp-application-base'; +import { SPHttpClient, SPHttpClientResponse } from "@microsoft/sp-http"; + +export default class SPService implements ISPService { + + constructor(private _context: WebPartContext | ApplicationCustomizerContext) {} + + /** + * Get lists or libraries + * @param options + */ + public getLibs(options?: ILibsOptions): Promise { + let filtered: boolean; + let queryUrl: string = `${this._context.pageContext.web.absoluteUrl}/_api/web/lists?$select=Title,id,BaseTemplate`; + + if (options.orderBy) { + queryUrl += `&$orderby=${options.orderBy === LibsOrderBy.Id ? 'Id': 'Title'}`; + } + + if (options.baseTemplate) { + queryUrl += `&$filter=BaseTemplate eq ${options.baseTemplate}`; + filtered = true; + } + + if (options.includeHidden === false) { + queryUrl += filtered ? ' and Hidden eq false' : '&$filter=Hidden eq false'; + filtered = true; + } + + return this._context.spHttpClient.get(queryUrl, SPHttpClient.configurations.v1) + .then(response => response.json()) as Promise; + } +} diff --git a/src/services/SPServiceFactory.ts b/src/services/SPServiceFactory.ts new file mode 100644 index 000000000..9870bec76 --- /dev/null +++ b/src/services/SPServiceFactory.ts @@ -0,0 +1,15 @@ +import { ApplicationCustomizerContext } from '@microsoft/sp-application-base'; +import { IWebPartContext, WebPartContext } from "@microsoft/sp-webpart-base"; +import { ISPService } from "./ISPService"; +import { Environment, EnvironmentType } from "@microsoft/sp-core-library"; +import SPServiceMock from "./SPServiceMock"; +import SPService from "./SPService"; + +export class SPServiceFactory { + public static createService(context: WebPartContext | ApplicationCustomizerContext, includeDelay?: boolean, delayTimeout?: number): ISPService { + if (Environment.type === EnvironmentType.Local) { + return new SPServiceMock(includeDelay, delayTimeout); + } + return new SPService(context); + } +} diff --git a/src/services/SPServiceMock.ts b/src/services/SPServiceMock.ts new file mode 100644 index 000000000..9880a7183 --- /dev/null +++ b/src/services/SPServiceMock.ts @@ -0,0 +1,38 @@ +import { ISPService, ILibsOptions } from "./ISPService"; +import { ISPLists } from "../common/SPEntities"; + +export default class SPServiceMock implements ISPService { + private _includeDelay?: boolean; + private _delayTimeout?: number; + + constructor(includeDelay?: boolean, delayTimeout?: number) { + this._includeDelay = includeDelay; + this._delayTimeout = delayTimeout || 500; + } + + /** + * The mock lists to present to the local workbench + */ + private static _lists: ISPLists = { + value: [ + { Id: '8dc80f2e-0e01-43ee-b59e-fbbca2d1f35e', Title: 'Mock List One', BaseTemplate: '109' }, + { Id: '772a30d4-2d62-42da-aa48-c2a37971d693', Title: 'Mock List Two', BaseTemplate: '109' }, + { Id: '16c0d1c6-b467-4823-a37b-c308cf730366', Title: 'Mock List Three', BaseTemplate: '109' } + ] + }; + public getLibs(options?: ILibsOptions): Promise { + return new Promise(async resolve => { + if (this._includeDelay === true) { + await this.sleep(this._delayTimeout); // Simulate network load + } + resolve(SPServiceMock._lists); + }); + } + /** + * Locks the thread for the specified amount of time + * @param ms Milliseconds to wait + */ + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/src/services/SPTermStorePickerMockService.ts b/src/services/SPTermStorePickerMockService.ts new file mode 100644 index 000000000..08149c754 --- /dev/null +++ b/src/services/SPTermStorePickerMockService.ts @@ -0,0 +1,164 @@ +import { ITermStore, ITerm, ITermSet } from './ISPTermStorePickerService'; +import {IPickerTerms, IPickerTerm } from '../controls/taxonomyPicker/ITermPicker'; +/** + * Defines a http client to request mock data to use the web part with the local workbench + */ +export default class SPTermStoreMockHttpClient { + + /** + * Mock SharePoint result sample + */ + private static _mockTermStores: ITermStore[] = [{ + "_ObjectType_": "SP.Taxonomy.TermStore", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:st:generated-idwdg==", + "Id": "\/Guid(fd32e8c4-99f8-402a-8444-4efed3df3076)\/", + "Name": "Mock TermStore", + "Groups": { + "_ObjectType_": "SP.Taxonomy.TermGroupCollection", + "_Child_Items_": [{ + "_ObjectType_": "SP.Taxonomy.TermGroup", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:gr:generated-id-wdsWeHAWewaRChzC1Im8LcS8=", + "Name": "Mock TermGroup 1", + "Id": "\/Guid(051c9ec5-c19e-42a4-8730-b5226f0b712f)\/", + "IsSystemGroup": false, + "TermSets": { + "_ObjectType_": "SP.Taxonomy.TermSetCollection", + "_Child_Items_": [{ + "_ObjectType_": "SP.Taxonomy.TermSet", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:se:generated-id-wdsWeHAWewaRChzC1Im8LcS\u002fwbRtbognrQqP2AGVWYhkx", + "Name": "Mock TermSet 1", + "Id": "\/Guid(5b1b6df0-09a2-42eb-a3f6-006556621931)\/", + "Description": "", + "Names": { + "1033": "Mock TermSet 1" + } + }] + } + }] + } + }]; + + private static _mockTerms: ITermSet = + {"_ObjectType_":"SP.Taxonomy.TermSet", + "_ObjectIdentity_":"a4f45d9e-7003-5000-7d35-b4064108885e|fec14c62-7c3b-481b-851b-c80d7802b224:se:15WaN9o+nUi6qkivmCMKhxA4k0b3ed9BqbnSqve6DjrKW1tjX4wxSIv4oNnV63Xg", + "Id":"/Guid(635b5bca-8c5f-4831-8bf8-a0d9d5eb75e0)/", + "Name":"Countries", + "Description":"", +"Names":{"1033":"Countries"}, +"Terms":[ + { + "_ObjectType_": "SP.Taxonomy.Term", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:te:generated-id-SPnCDng5nkmdP+UcRJTUTA==", + "Name": "Belgium", + "Id": "0ec2f948-3978-499e-9d3f-e51c4494d44c", + "Description": "", + "IsDeprecated": false, + "IsRoot": true, + "PathOfTerm": "Belgium", + "PathDepth": 1, + "TermSet": { + "_ObjectType_": "SP.Taxonomy.TermSet", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:se:generated-id-", + "Id": "\/Guid(5b1b6df0-09a2-42eb-a3f6-006556621931)\/", + "Name" : "Country" + } + }, { + "_ObjectType_": "SP.Taxonomy.Term", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:te:generated-id-1a3nKkDuZUOvMhLp9PvKFw==", + "Id": "2ae7add5-ee40-4365-af32-12e9f4fbca17", + "Name": "Antwerp", + "Description": "", + "IsDeprecated": false, + "IsRoot": false, + "PathOfTerm": "Belgium;Antwerp", + "PathDepth": 2, + "TermSet": { + "_ObjectType_": "SP.Taxonomy.TermSet", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:se:generated-id-", + "Id": "\/Guid(5b1b6df0-09a2-42eb-a3f6-006556621931)\/", + "Name" : "Country" + } + }, { + "_ObjectType_": "SP.Taxonomy.Term", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:te:generated-id-WCbUI7Ims0ysT\u002fBkk4NUhQ==", + "Name": "Brussels", + "Id": "23d42658-26b2-4cb3-ac4f-f06493835485", + "Description": "", + "IsDeprecated": false, + "IsRoot": false, + "PathOfTerm": "Belgium;Brussels", + "PathDepth": 2, + "TermSet": { + "_ObjectType_": "SP.Taxonomy.TermSet", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:se:generated-id-", + "Id": "\/Guid(5b1b6df0-09a2-42eb-a3f6-006556621931)\/", + "Name" : "Country" + } + }, { + "_ObjectType_": "SP.Taxonomy.Term", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:te:generated-id-WCbUI7Ims0ysT\u002fBkk4NUhQ==", + "Name": "Deprecated", + "Id": "23d42658-26b2-4cb3-ac4f-f06493835486", + "Description": "", + "IsDeprecated": true, + "IsRoot": true, + "PathOfTerm": "Deprecated", + "PathDepth": 1, + "TermSet": { + "_ObjectType_": "SP.Taxonomy.TermSet", + "_ObjectIdentity_": "5e06ddd0-d2dd-4fff-bcc0-42b40f4aa59e|4dbeb936-1813-4630-a4bd-9811df3fe7f1:se:generated-id-", + "Id": "\/Guid(5b1b6df0-09a2-42eb-a3f6-006556621931)\/", + "Name" : "Country" + } + }]}; + + /** + * Mock method which returns mock terms stores + */ + public static getTermStores(restUrl: string, options?: any): Promise { + return new Promise((resolve) => { + resolve(SPTermStoreMockHttpClient._mockTermStores); + }); + } + + /** + * Mock method wich returns mock terms + */ + public static getAllTerms(): Promise { + return new Promise((resolve) => { + resolve(SPTermStoreMockHttpClient._mockTerms); + }); + } + + public static searchTermsByName(searchText: string): Promise { + return new Promise((resolve) => { + resolve([ + { + key : "23d42658-26b2-4cb3-ac4f-f06493835485", + name : 'Brussels', + path : "Belgium;Brussels", + termSet :"635b5bca-8c5f-4831-8bf8-a0d9d5eb75e0", + termSetName : "Countries" + }, + { + key : "2ae7add5-ee40-4365-af32-12e9f4fbca17", + name : 'Antwerp', + path : "Belgium;Antwerp", + termSet :"635b5bca-8c5f-4831-8bf8-a0d9d5eb75e0", + termSetName : "Countries" + }, + { + key : "0ec2f948-3978-499e-9d3f-e51c4494d44c", + name : 'Belgium', + path : "Belgium", + termSet :"635b5bca-8c5f-4831-8bf8-a0d9d5eb75e0", + termSetName : "Countries" + } + ]); + }); + } + + + } + + diff --git a/src/services/SPTermStorePickerService.ts b/src/services/SPTermStorePickerService.ts new file mode 100644 index 000000000..b2592c25d --- /dev/null +++ b/src/services/SPTermStorePickerService.ts @@ -0,0 +1,342 @@ +/** + * DISCLAIMER + * + * As there is not yet an OData end-point for managed metadata, this service makes use of the ProcessQuery end-points. + * The service will get updated once the APIs are in place for managing managed metadata. + */ + +import { SPHttpClient, SPHttpClientResponse, ISPHttpClientOptions } from '@microsoft/sp-http'; +import { Environment, EnvironmentType } from '@microsoft/sp-core-library'; +import { IWebPartContext } from '@microsoft/sp-webpart-base'; +import { ITaxonomyPickerProps } from '../controls/taxonomyPicker/ITaxonomyPicker'; +import { IPickerTerms, IPickerTerm } from '../controls/taxonomyPicker/ITermPicker'; +import { ITermStore, ITerms, ITerm, IGroup, ITermSet, ITermSets } from './ISPTermStorePickerService'; +import SPTermStoreMockHttpClient from './SPTermStorePickerMockService'; + + +/** + * Service implementation to manage term stores in SharePoint + */ +export default class SPTermStorePickerService { + private taxonomySession: string; + private formDigest: string; + private clientServiceUrl: string; + + /** + * Service constructor + */ + constructor(private props: ITaxonomyPickerProps, private context: IWebPartContext) { + if (Environment.type !== EnvironmentType.Local) { + { + this.clientServiceUrl = this.context.pageContext.web.absoluteUrl + '/_vti_bin/client.svc/ProcessQuery'; + } + } + } + + /** + * Gets the collection of term stores in the current SharePoint env + */ + public getTermStores(): Promise { + if (Environment.type === EnvironmentType.Local) { + // If the running environment is local, load the data from the mock + return this.getTermStoresFromMock(); + } else { + // Retrieve the term store name, groups, and term sets + const data = ''; + + const reqHeaders = new Headers(); + reqHeaders.append("accept", "application/json"); + reqHeaders.append("content-type", "application/xml"); + + const httpPostOptions: ISPHttpClientOptions = { + headers: reqHeaders, + body: data + }; + + return this.context.spHttpClient.post(this.clientServiceUrl, SPHttpClient.configurations.v1, httpPostOptions).then((serviceResponse: SPHttpClientResponse) => { + return serviceResponse.json().then((serviceJSONResponse: any) => { + // Construct results + let termStoreResult: ITermStore[] = serviceJSONResponse.filter(r => r['_ObjectType_'] === 'SP.Taxonomy.TermStore'); + // Check if term store was retrieved + if (termStoreResult.length > 0) { + // Check if the termstore needs to be filtered or limited + if (this.props.termsetNameOrID) { + return termStoreResult.map(termstore => { + let termGroups = termstore.Groups._Child_Items_; + + // Check if the groups have to be limited to a specific term set + if (this.props.termsetNameOrID) { + const termsetNameOrId = this.props.termsetNameOrID; + termGroups = termGroups.map((group: IGroup) => { + group.TermSets._Child_Items_ = group.TermSets._Child_Items_.filter((termSet: ITermSet) => termSet.Name === termsetNameOrId || this.cleanGuid(termSet.Id).toLowerCase() === this.cleanGuid(termsetNameOrId).toLowerCase()); + return group; + }); + } + + // Filter out all systen groups + termGroups = termGroups.filter(group => !group.IsSystemGroup); + + // Filter out empty groups + termGroups = termGroups.filter((group: IGroup) => group.TermSets._Child_Items_.length > 0); + + // Map the new groups + termstore.Groups._Child_Items_ = termGroups; + return termstore; + }); + } + + // Return the term store results + return termStoreResult; + } + return []; + }); + }); + } + } + + /** + * Gets the current term set + */ + public async getTermSet(): Promise { + if (Environment.type === EnvironmentType.Local) { + const termSetInfo = await SPTermStoreMockHttpClient.getAllTerms(); + return termSetInfo; + } else { + const termStore = await this.getTermStores(); + return this.getTermSetId(termStore, this.props.termsetNameOrID); + } + } + + /** + * Retrieve all terms for the given term set + * @param termset + */ + public async getAllTerms(termset: string): Promise { + if (Environment.type === EnvironmentType.Local) { + // If the running environment is local, load the data from the mock + return this.getAllMockTerms(); + } else { + let termsetId: string = termset; + // Check if the provided term set property is a GUID or string + if (!this.isGuid(termset)) { + // Fetch the term store information + const termStore = await this.getTermStores(); + // Get the ID of the provided term set name + const crntTermSet = this.getTermSetId(termStore, termset); + if (crntTermSet) { + termsetId = this.cleanGuid(crntTermSet.Id); + } else { + return null; + } + } + + // Request body to retrieve all terms for the given term set + const data = `${termsetId}`; + + + const reqHeaders = new Headers(); + reqHeaders.append("accept", "application/json"); + reqHeaders.append("content-type", "application/xml"); + + const httpPostOptions: ISPHttpClientOptions = { + headers: reqHeaders, + body: data + }; + + return this.context.spHttpClient.post(this.clientServiceUrl, SPHttpClient.configurations.v1, httpPostOptions).then((serviceResponse: SPHttpClientResponse) => { + return serviceResponse.json().then((serviceJSONResponse: any) => { + const termStoreResultTermSets: ITermSet[] = serviceJSONResponse.filter(r => r['_ObjectType_'] === 'SP.Taxonomy.TermSet'); + + if (termStoreResultTermSets.length > 0) { + var termStoreResultTermSet = termStoreResultTermSets[0]; + termStoreResultTermSet.Terms = []; + // Retrieve the term collection results + const termStoreResultTerms: ITerms[] = serviceJSONResponse.filter(r => r['_ObjectType_'] === 'SP.Taxonomy.TermCollection'); + if (termStoreResultTerms.length > 0) { + // Retrieve all terms + let terms = termStoreResultTerms[0]._Child_Items_; + // Clean the term ID and specify the path depth + terms = terms.map(term => { + term.Id = this.cleanGuid(term.Id); + term['PathDepth'] = term.PathOfTerm.split(';').length; + term.TermSet = { Id : this.cleanGuid(termStoreResultTermSet.Id), Name : termStoreResultTermSet.Name}; + if (term["Parent"]) + { + term.ParentId = this.cleanGuid(term["Parent"].Id); + } + return term; + }); + // Check if the term set was not empty + if (terms.length > 0) { + // Sort the terms by PathOfTerm + terms = terms.sort(this._sortTerms); + termStoreResultTermSet.Terms = terms; + } + } + return termStoreResultTermSet; + } + return null; + }); + }); + } + } + + + /** + * Get the term set ID by its name + * @param termstore + * @param termset + */ + private getTermSetId(termstore: ITermStore[], termsetName: string): ITermSet { + if (termstore && termstore.length > 0 && termsetName) { + // Get the first term store + const ts = termstore[0]; + // Check if the term store contains groups + if (ts.Groups && ts.Groups._Child_Items_) { + for (const group of ts.Groups._Child_Items_) { + // Check if the group contains term sets + if (group.TermSets && group.TermSets._Child_Items_) { + for (const termSet of group.TermSets._Child_Items_) { + // Check if the term set is found + if (termSet.Name === termsetName) { + return termSet; + } + } + } + } + } + } + + return null; + } + + + /** + * Retrieve all terms that starts with the searchText + * @param searchText + */ + public searchTermsByName(searchText: string): Promise { + if (Environment.type === EnvironmentType.Local) { + // If the running environment is local, load the data from the mock + return SPTermStoreMockHttpClient.searchTermsByName(searchText); + } else { + return this.searchTermsByTermSet(searchText, this.props.termsetNameOrID); + } + } + + /** + * Searches terms for the given term set + * @param searchText + * @param termsetId + */ + private searchTermsByTermSet(searchText: string, termSet: string): Promise { + if (Environment.type === EnvironmentType.Local) { + // If the running environment is local, load the data from the mock + return SPTermStoreMockHttpClient.searchTermsByName(searchText); + } else { + return new Promise(resolve => { + this.getTermStores().then(termStore => { + let TermSetId = termSet; + if (!this.isGuid(termSet)) { + // Get the ID of the provided term set name + const crntTermSet = this.getTermSetId(termStore, termSet); + if (crntTermSet) { + TermSetId = this.cleanGuid(crntTermSet.Id); + } else { + resolve(null); + return; + } + } + + let data = `${searchText}true010true${TermSetId}`; + + const reqHeaders = new Headers(); + reqHeaders.append("accept", "application/json"); + reqHeaders.append("content-type", "application/xml"); + + const httpPostOptions: ISPHttpClientOptions = { + headers: reqHeaders, + body: data + }; + + + return this.context.spHttpClient.post(this.clientServiceUrl, SPHttpClient.configurations.v1, httpPostOptions).then((serviceResponse: SPHttpClientResponse) => { + return serviceResponse.json().then((serviceJSONResponse: any) => { + // Retrieve the term collection results + const termStoreResult: ITerms[] = serviceJSONResponse.filter(r => r['_ObjectType_'] === 'SP.Taxonomy.TermCollection'); + if (termStoreResult.length > 0) { + // Retrieve all terms + + let terms = termStoreResult[0]._Child_Items_; + + let returnTerms: IPickerTerm[] = []; + terms.forEach(term => { + if (term.Name.toLowerCase().indexOf(searchText.toLowerCase()) !== -1) { + returnTerms.push({ + key:this.cleanGuid(term.Id), + name: term.Name, + path: term.PathOfTerm, + termSet: this.cleanGuid(term.TermSet.Id), + termSetName: term.TermSet.Name + }); + } + }); + resolve(returnTerms); + } + return null; + }); + }); + }); + }); + } + } + + private isGuid(strGuid: string): boolean { + return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(strGuid); + } + + /** + * Sort the terms by their path + * @param a term 2 + * @param b term 2 + */ + private _sortTerms(a: ITerm, b: ITerm) { + if (a.PathOfTerm < b.PathOfTerm) { + return -1; + } + if (a.PathOfTerm > b.PathOfTerm) { + return 1; + } + return 0; + } + + /** + * Clean the Guid from the Web Service response + * @param guid + */ + public cleanGuid(guid: string): string { + if (guid !== undefined) { + return guid.replace('/Guid(', '').replace('/', '').replace(')', ''); + } else { + return ''; + } + } + + /** + * Returns 3 fake SharePoint lists for the Mock mode + */ + private getTermStoresFromMock(): Promise { + return SPTermStoreMockHttpClient.getTermStores(this.context.pageContext.web.absoluteUrl).then((data) => { + return data; + }) as Promise; + } + + /** + * Returns 3 fake SharePoint lists for the Mock mode + */ + private getAllMockTerms(): Promise { + return SPTermStoreMockHttpClient.getAllTerms().then((data) => { + return data; + }) as Promise; + } +} diff --git a/src/webparts/controlsTest/components/ControlsTest.tsx b/src/webparts/controlsTest/components/ControlsTest.tsx index 914926066..a916c98a6 100644 --- a/src/webparts/controlsTest/components/ControlsTest.tsx +++ b/src/webparts/controlsTest/components/ControlsTest.tsx @@ -11,6 +11,8 @@ import { ListView, IViewField, SelectionMode, GroupOrder, IGrouping } from '../. import { SPHttpClient } from '@microsoft/sp-http'; import { SiteBreadcrumb } from '../../../SiteBreadcrumb'; import { WebPartTitle } from '../../../WebPartTitle'; +import { TaxonomyPicker, IPickerTerms } from '../../../TaxonomyPicker'; +import { ListPicker } from '../../../ListPicker'; import { IFrameDialog } from '../../../IFrameDialog'; import { Environment, EnvironmentType } from '@microsoft/sp-core-library'; @@ -70,6 +72,22 @@ export default class ControlsTest extends React.Component
+ +
List picker tester: + +
+ +
TaxonomyPicker tester: + +
iframe dialog tester: