diff --git a/README.md b/README.md index 34764d4..a05e488 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,29 @@ Using this solution you can: - view retention information from files and folders labeled with a retention label. - toggle record lock status - clear the retention label from a file or folder. +- bulk toggle record status for selected items or the entire library. +- bulk clear labels for selected items or the entire library. This solution comes in handy if you choose to not publish any retention label, for example for purposes of automatic labelling. In such a scenario, the retention label dropdown would not be visible, and thus it would be impossible to clear the label of an item. +Opening the Retention Controls dialog: + ![Opening the retention controls screen](screenshot_1.jpg) +Viewing retention information for a single selected item: + ![The retention controls screen](screenshot_2.jpg) +Viewing retention information for multiple selected items or the entire library: + +![Retention controls for the entire library](screenshot_3.jpg) + +Taking action on a single item, all selected items or the entire library: + +![Executing actions](screenshot_4.jpg) + +> "Take bulk action" encompasses the entire library if no items were selected before opening the retention controls dialog. + ## Used SharePoint Framework Version ![version](https://img.shields.io/badge/version-1.19.0-green.svg) diff --git a/config/config.json b/config/config.json index 240728a..5bd768e 100644 --- a/config/config.json +++ b/config/config.json @@ -15,4 +15,4 @@ "localizedResources": { "RetentionControlsCommandSetStrings": "lib/extensions/retentionControls/loc/{locale}.js" } -} +} \ No newline at end of file diff --git a/config/package-solution.json b/config/package-solution.json index 50bd1cc..9894739 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "Blimped SPFx Retention Controls Client Side Solution", "id": "39608f99-117d-457c-9001-79c8d257aab5", - "version": "1.0.8.1", + "version": "1.1.0.1", "includeClientSideAssets": true, "skipFeatureDeployment": true, "isDomainIsolated": false, diff --git a/package-lock.json b/package-lock.json index fb600be..e666cb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@fluentui/react": "8.118.9", + "@fluentui/react-file-type-icons": "8.11.21", "@microsoft/decorators": "1.19.0", "@microsoft/sp-core-library": "1.19.0", "@microsoft/sp-dialog": "1.19.0", @@ -1142,25 +1143,25 @@ } }, "node_modules/@fluentui/font-icons-mdl2": { - "version": "8.5.45", - "resolved": "https://registry.npmjs.org/@fluentui/font-icons-mdl2/-/font-icons-mdl2-8.5.45.tgz", - "integrity": "sha512-aRMZNrFPldABPeWzqqqWUTbK8vh5mksQiFERnLsbSoDmthXDqvaZJ8qTFS43m80yhMHCvYkaPVikArC87J64pw==", + "version": "8.5.50", + "resolved": "https://registry.npmjs.org/@fluentui/font-icons-mdl2/-/font-icons-mdl2-8.5.50.tgz", + "integrity": "sha512-04pRRmuBf9r/3cnBlIedF+SFk2UW7GdRQvdfKxoMuL4dDMLPqo4ruPkI/dz8Mp3EDERQU01XDWtBx11w9obmFQ==", "dependencies": { "@fluentui/set-version": "^8.2.23", - "@fluentui/style-utilities": "^8.10.16", - "@fluentui/utilities": "^8.15.11", + "@fluentui/style-utilities": "^8.10.21", + "@fluentui/utilities": "^8.15.15", "tslib": "^2.1.0" } }, "node_modules/@fluentui/foundation-legacy": { - "version": "8.4.11", - "resolved": "https://registry.npmjs.org/@fluentui/foundation-legacy/-/foundation-legacy-8.4.11.tgz", - "integrity": "sha512-EccLnbmfHo3gHRKlV+qpYvPzIjMkrVIfLZXdjJ/wF0cx3Rl1jD8B343uYhGs9IH3xIP2OHoe1mGhh/1icsZrpQ==", + "version": "8.4.16", + "resolved": "https://registry.npmjs.org/@fluentui/foundation-legacy/-/foundation-legacy-8.4.16.tgz", + "integrity": "sha512-01/uQPQ2pEkQ6nUUF+tXaYeOG8UssfoEgAVLPolYXr1DC4tT66hPi7Smgsh6tzUkt/Ljy0nw9TIMRoHDHlfRyg==", "dependencies": { - "@fluentui/merge-styles": "^8.6.11", + "@fluentui/merge-styles": "^8.6.13", "@fluentui/set-version": "^8.2.23", - "@fluentui/style-utilities": "^8.10.16", - "@fluentui/utilities": "^8.15.11", + "@fluentui/style-utilities": "^8.10.21", + "@fluentui/utilities": "^8.15.15", "tslib": "^2.1.0" }, "peerDependencies": { @@ -1177,9 +1178,9 @@ } }, "node_modules/@fluentui/merge-styles": { - "version": "8.6.11", - "resolved": "https://registry.npmjs.org/@fluentui/merge-styles/-/merge-styles-8.6.11.tgz", - "integrity": "sha512-tXexIDxsxnKNcr9dk3JtZIS/2NlzioSN188Thgu56W25ZpApw/lkUpMq7fOH2BIexuDdozXOy/QjWSnvyhnIjg==", + "version": "8.6.13", + "resolved": "https://registry.npmjs.org/@fluentui/merge-styles/-/merge-styles-8.6.13.tgz", + "integrity": "sha512-IWgvi2CC+mcQ7/YlCvRjsmHL2+PUz7q+Pa2Rqk3a+QHN0V1uBvgIbKk5y/Y/awwDXy1yJHiqMCcDHjBNmS1d4A==", "dependencies": { "@fluentui/set-version": "^8.2.23", "tslib": "^2.1.0" @@ -1212,16 +1213,30 @@ "react-dom": ">=16.8.0 <19.0.0" } }, + "node_modules/@fluentui/react-file-type-icons": { + "version": "8.11.21", + "resolved": "https://registry.npmjs.org/@fluentui/react-file-type-icons/-/react-file-type-icons-8.11.21.tgz", + "integrity": "sha512-RLVrHOXAsRx/1lMTzw3//74kRMC/gEHIfyTWklozR+AUFGH9MhDUL5POQFpilbgemwA6cq6r/U7pSBEgjtUKcw==", + "dependencies": { + "@fluentui/set-version": "^8.2.23", + "@fluentui/style-utilities": "^8.10.21", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <19.0.0", + "react": ">=16.8.0 <19.0.0" + } + }, "node_modules/@fluentui/react-focus": { - "version": "8.9.8", - "resolved": "https://registry.npmjs.org/@fluentui/react-focus/-/react-focus-8.9.8.tgz", - "integrity": "sha512-EA9li3KREBm+EyXbXksIk9cyI3MZRz4sh7Gf550cLXmAep/KtrR38bWtMjH3zhhrQc95byiMxqDGqYjSsRl8DA==", + "version": "8.9.13", + "resolved": "https://registry.npmjs.org/@fluentui/react-focus/-/react-focus-8.9.13.tgz", + "integrity": "sha512-oUtY4F+tp0RmV0Wr30CoYFdTQEqHWKjU3/dYHPbI0xKH4emLrf8+sc0FAHJdeHH2rx4T1XSA807pm7YB4CQqWw==", "dependencies": { "@fluentui/keyboard-key": "^0.4.23", - "@fluentui/merge-styles": "^8.6.11", + "@fluentui/merge-styles": "^8.6.13", "@fluentui/set-version": "^8.2.23", - "@fluentui/style-utilities": "^8.10.16", - "@fluentui/utilities": "^8.15.11", + "@fluentui/style-utilities": "^8.10.21", + "@fluentui/utilities": "^8.15.15", "tslib": "^2.1.0" }, "peerDependencies": { @@ -1230,13 +1245,13 @@ } }, "node_modules/@fluentui/react-hooks": { - "version": "8.8.8", - "resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.8.8.tgz", - "integrity": "sha512-H6rnPWXWhPBJuToa287Lxmy0mOPlA0vnxPWJyD26mWjkaJ4jN5V4v8qG5jLVINjWUQGJdBK71bia4zWRF421yQ==", + "version": "8.8.12", + "resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.8.12.tgz", + "integrity": "sha512-lplre6x5dONjd12D0BWs4LKq4lX++o0w07pIk2XhxikOW1e4Xfjn6VM52WSdtx+tU4rbLUoCA8drN2y/wDvhGg==", "dependencies": { - "@fluentui/react-window-provider": "^2.2.27", + "@fluentui/react-window-provider": "^2.2.28", "@fluentui/set-version": "^8.2.23", - "@fluentui/utilities": "^8.15.11", + "@fluentui/utilities": "^8.15.15", "tslib": "^2.1.0" }, "peerDependencies": { @@ -1245,9 +1260,9 @@ } }, "node_modules/@fluentui/react-portal-compat-context": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/@fluentui/react-portal-compat-context/-/react-portal-compat-context-9.0.11.tgz", - "integrity": "sha512-ubvW/ej0O+Pago9GH3mPaxzUgsNnBoqvghNamWjyKvZIViyaXUG6+sgcAl721R+qGAFac+A20akI5qDJz/xtdg==", + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/@fluentui/react-portal-compat-context/-/react-portal-compat-context-9.0.12.tgz", + "integrity": "sha512-5AVXWX9GnbvwnJZYUb4LSIF7BsI/N8oTI6+7Yn0w6B3yaWykA8Menlz757X5tgVBjouEj4Eom+AoVvA7u8gPDA==", "dependencies": { "@swc/helpers": "^0.5.1" }, @@ -1257,9 +1272,9 @@ } }, "node_modules/@fluentui/react-window-provider": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.2.27.tgz", - "integrity": "sha512-Dg0G9bizjryV0Q/r0CPtCVTPa2II/EsT9E6JT3jPSALjQADDLlW4/+ZXbcEC7geZ/40+KpZDmhplvk/AJSFBKg==", + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.2.28.tgz", + "integrity": "sha512-YdZ74HTaoDwlvLDzoBST80/17ExIl93tLJpTxnqK5jlJOAUVQ+mxLPF2HQEJq+SZr5IMXHsQ56w/KaZVRn72YA==", "dependencies": { "@fluentui/set-version": "^8.2.23", "tslib": "^2.1.0" @@ -1283,14 +1298,14 @@ } }, "node_modules/@fluentui/style-utilities": { - "version": "8.10.16", - "resolved": "https://registry.npmjs.org/@fluentui/style-utilities/-/style-utilities-8.10.16.tgz", - "integrity": "sha512-sRigtvU1awF6l3GmLOuUzdprI2XWU+Yiru/4PCAMUqD2RA37tlZBLLzWwZemrkPVPInB8b3J0il95FznqYbZLg==", + "version": "8.10.21", + "resolved": "https://registry.npmjs.org/@fluentui/style-utilities/-/style-utilities-8.10.21.tgz", + "integrity": "sha512-tqdSQI1MAnNUPtNKKV9LeNqmEhBZL+lpV+m6Ngl6SDuR0aQkMkuo1jA9rPxNRLUf5+pbI8LrNQ4WiCWqYkV/QQ==", "dependencies": { - "@fluentui/merge-styles": "^8.6.11", + "@fluentui/merge-styles": "^8.6.13", "@fluentui/set-version": "^8.2.23", - "@fluentui/theme": "^2.6.54", - "@fluentui/utilities": "^8.15.11", + "@fluentui/theme": "^2.6.59", + "@fluentui/utilities": "^8.15.15", "@microsoft/load-themed-styles": "^1.10.26", "tslib": "^2.1.0" } @@ -1301,13 +1316,13 @@ "integrity": "sha512-W+IzEBw8a6LOOfRJM02dTT7BDZijxm+Z7lhtOAz1+y9vQm1Kdz9jlAO+qCEKsfxtUOmKilW8DIRqFw2aUgKeGg==" }, "node_modules/@fluentui/theme": { - "version": "2.6.54", - "resolved": "https://registry.npmjs.org/@fluentui/theme/-/theme-2.6.54.tgz", - "integrity": "sha512-t+IlWkVCTi7WPJ8DI9vijCSpVojzhBRNfpLkZN8AoMysglhvqGuQUUnhP3zcQfEYDBJlEUdSbwJusJSv+5yBqQ==", + "version": "2.6.59", + "resolved": "https://registry.npmjs.org/@fluentui/theme/-/theme-2.6.59.tgz", + "integrity": "sha512-o/6UgKgPW6QI/+2OfCXeJfcOCbtzLIwM/3W/DzI2Pjt56ubT98IEcb32NCHoIKB2xkEnJoTjGgN1m+vHAvcQxA==", "dependencies": { - "@fluentui/merge-styles": "^8.6.11", + "@fluentui/merge-styles": "^8.6.13", "@fluentui/set-version": "^8.2.23", - "@fluentui/utilities": "^8.15.11", + "@fluentui/utilities": "^8.15.15", "tslib": "^2.1.0" }, "peerDependencies": { @@ -1316,13 +1331,13 @@ } }, "node_modules/@fluentui/utilities": { - "version": "8.15.11", - "resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.15.11.tgz", - "integrity": "sha512-0+SmseiBY+L+biZrKeJ8M5nVXtLdCc3KTAbbkGJ5TOdTCqaVTZq0ut9IfrJAmP+nYWKChWdn+hE3dpY8koPdsQ==", + "version": "8.15.15", + "resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.15.15.tgz", + "integrity": "sha512-7GpET/AuWR8aBEQSQj9iO2j+9riAaoK1qBduCB4Ht6353d25vwwsKXreHZGqS8efv+NNIxQTlLWz0Rq73iQFWw==", "dependencies": { "@fluentui/dom-utilities": "^2.3.7", - "@fluentui/merge-styles": "^8.6.11", - "@fluentui/react-window-provider": "^2.2.27", + "@fluentui/merge-styles": "^8.6.13", + "@fluentui/react-window-provider": "^2.2.28", "@fluentui/set-version": "^8.2.23", "tslib": "^2.1.0" }, diff --git a/package.json b/package.json index 982df74..2110ab0 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@fluentui/react": "8.118.9", + "@fluentui/react-file-type-icons": "8.11.21", "@microsoft/decorators": "1.19.0", "@microsoft/sp-core-library": "1.19.0", "@microsoft/sp-dialog": "1.19.0", diff --git a/screenshot_3.jpg b/screenshot_3.jpg new file mode 100644 index 0000000..7a730a6 Binary files /dev/null and b/screenshot_3.jpg differ diff --git a/screenshot_4.jpg b/screenshot_4.jpg new file mode 100644 index 0000000..1fe4ca4 Binary files /dev/null and b/screenshot_4.jpg differ diff --git a/src/extensions/retentionControls/AlertDialogManager.tsx b/src/extensions/retentionControls/AlertDialogManager.tsx new file mode 100644 index 0000000..bdb4572 --- /dev/null +++ b/src/extensions/retentionControls/AlertDialogManager.tsx @@ -0,0 +1,55 @@ +import * as ReactDOM from "react-dom"; +import * as React from "react"; +import Dialog, { DialogFooter, DialogType } from "@fluentui/react/lib/Dialog"; +import { DefaultButton } from "@fluentui/react/lib/Button"; +import * as strings from "RetentionControlsCommandSetStrings"; + +export default class AlertDialogManager { + private domElement: HTMLDivElement | null = null; + private onClosedCallback: (confirmed?: boolean) => void; + + constructor(private title: string, private subText: string, private buttonText?: string) { + } + + public async close(confirmed?: boolean): Promise { + if (this.domElement) { + ReactDOM.unmountComponentAtNode(this.domElement); + this.domElement.remove(); + this.domElement = null; + } + + if (this.onClosedCallback !== undefined) { + this.onClosedCallback(confirmed); + } + } + + public onClosed(callback: (confirmed?:boolean)=>void): void { + this.onClosedCallback = callback; + } + + public async show(): Promise { + this.domElement = document.createElement('div'); + document.body.appendChild(this.domElement); + + const close = async (confirmed?: boolean): Promise => { + await this.close(confirmed); + }; + + const reactElement = + + ReactDOM.render(reactElement, + this.domElement); + } +} diff --git a/src/extensions/retentionControls/ConfirmationDialogManager.tsx b/src/extensions/retentionControls/ConfirmationDialogManager.tsx new file mode 100644 index 0000000..55d7b62 --- /dev/null +++ b/src/extensions/retentionControls/ConfirmationDialogManager.tsx @@ -0,0 +1,57 @@ +import * as ReactDOM from "react-dom"; +import * as React from "react"; +import Dialog, { DialogFooter, DialogType } from "@fluentui/react/lib/Dialog"; +import { DefaultButton, PrimaryButton } from "@fluentui/react/lib/Button"; +import * as strings from "RetentionControlsCommandSetStrings"; + +export default class ConfirmationDialogManager { + private domElement: HTMLDivElement | null = null; + private onClosedCallback: (confirmed?: boolean) => void; + + constructor(private title: string, private subText: string, private primaryButtonText?: string, private secondaryButtonText?: string, private showPrimaryButton: boolean = true, private showSecondaryButton: boolean = true) { + } + + public async close(confirmed?: boolean): Promise { + if (this.domElement) { + ReactDOM.unmountComponentAtNode(this.domElement); + this.domElement.remove(); + this.domElement = null; + } + + if (this.onClosedCallback !== undefined) { + this.onClosedCallback(confirmed); + } + } + + public onClosed(callback: (confirmed?:boolean)=>void): void { + this.onClosedCallback = callback; + } + + public async show(): Promise { + this.domElement = document.createElement('div'); + document.body.appendChild(this.domElement); + + const close = async (confirmed?: boolean): Promise => { + await this.close(confirmed); + }; + + const reactElement = + + ReactDOM.render(reactElement, + this.domElement); + } +} diff --git a/src/extensions/retentionControls/RetentionControlsCommandSet.ts b/src/extensions/retentionControls/RetentionControlsCommandSet.ts index ff4460e..9040271 100644 --- a/src/extensions/retentionControls/RetentionControlsCommandSet.ts +++ b/src/extensions/retentionControls/RetentionControlsCommandSet.ts @@ -1,10 +1,10 @@ import { Log } from "@microsoft/sp-core-library"; import { BaseListViewCommandSet, RowAccessor, type Command, type IListViewCommandSetExecuteEventParameters, type ListViewStateChangedEventArgs } from "@microsoft/sp-listview-extensibility"; -import RetentionControlsDialog from "./components/RetentionControlsDialog"; +import RetentionControlsDialogManager from "./RetentionControlsDialogManager"; export interface IRetentionControlsCommandSetProperties {} -const LOG_SOURCE: string = "RetentionControlsCommandSet"; +export const LOG_SOURCE: string = "RetentionControlsCommandSet"; export default class RetentionControlsCommandSet extends BaseListViewCommandSet { public onInit(): Promise { @@ -14,7 +14,7 @@ export default class RetentionControlsCommandSet extends BaseListViewCommandSet< const color = encodeURIComponent(this.context.isServedFromLocalhost ? "#ff0000" : themePrimary); //"#ff0000" const command: Command = this.tryGetCommand("RETENTION_CONTROLS_COMMAND"); - command.visible = false; + command.visible = true; command.iconImageUrl = `data:image/svg+xml,`; this.context.listView.listViewStateChangedEvent.add(this, this._onListViewStateChanged); @@ -27,7 +27,12 @@ export default class RetentionControlsCommandSet extends BaseListViewCommandSet< return; } - const dialog = new RetentionControlsDialog(this.context, this.context.pageContext.list.id.toString(), listItems); + const itemsWithLabel = listItems.filter(item => { + const _complianceTag = item.getValueByName("_ComplianceTag"); + return _complianceTag !== undefined && _complianceTag !== "" + }); + + const dialog = new RetentionControlsDialogManager(this.context, this.context.pageContext.list.id.toString(), itemsWithLabel, listItems.length); await dialog.show(); }; @@ -35,18 +40,18 @@ export default class RetentionControlsCommandSet extends BaseListViewCommandSet< if (event.itemId !== "RETENTION_CONTROLS_COMMAND") { throw new Error("Unknown command"); } - + this.openRetentionControls(event.selectedRows).catch(console.error); } private _onListViewStateChanged = (args: ListViewStateChangedEventArgs): void => { Log.info(LOG_SOURCE, "List view state changed"); - const command: Command = this.tryGetCommand("RETENTION_CONTROLS_COMMAND"); - if (command) { - const hasSelectedItemsWithRetentionLabel = (this.context.listView.selectedRows && this.context.listView.selectedRows.length > 0 && this.context.listView.selectedRows?.some((row) => row.getValueByName("_ComplianceTag") !== undefined && row.getValueByName("_ComplianceTag") !== "")) || false; - command.visible = hasSelectedItemsWithRetentionLabel; - } + // const command: Command = this.tryGetCommand("RETENTION_CONTROLS_COMMAND"); + // // if (command) { + // // const hasSelectedItemsWithRetentionLabel = (this.context.listView.selectedRows && this.context.listView.selectedRows.length > 0 && this.context.listView.selectedRows?.some((row) => row.getValueByName("_ComplianceTag") !== undefined && row.getValueByName("_ComplianceTag") !== "")) || false; + // // command.visible = hasSelectedItemsWithRetentionLabel; + // // } this.raiseOnChange(); }; diff --git a/src/extensions/retentionControls/RetentionControlsDialogManager.tsx b/src/extensions/retentionControls/RetentionControlsDialogManager.tsx new file mode 100644 index 0000000..684d5d9 --- /dev/null +++ b/src/extensions/retentionControls/RetentionControlsDialogManager.tsx @@ -0,0 +1,32 @@ +import { BaseComponentContext } from "@microsoft/sp-component-base"; +import * as ReactDOM from "react-dom"; +import RetentionControlsDialog from "./components/RetentionControlsDialog"; +import * as React from "react"; +import { RowAccessor } from "@microsoft/sp-listview-extensibility"; + +export default class RetentionControlsDialogManager { + private domElement: HTMLDivElement | null = null; + + constructor(private context: BaseComponentContext, private listId: string, private listItems: readonly RowAccessor[], private selectedItems: number) { + } + + public async close(): Promise { + if (this.domElement) { + ReactDOM.unmountComponentAtNode(this.domElement); + this.domElement.remove(); + this.domElement = null; + } + } + + public async show(): Promise { + this.domElement = document.createElement('div'); + document.body.appendChild(this.domElement); + + const close = async (): Promise => { + await this.close(); + }; + + ReactDOM.render(, + this.domElement); + } +} diff --git a/src/extensions/retentionControls/components/ItemColumn.tsx b/src/extensions/retentionControls/components/ItemColumn.tsx new file mode 100644 index 0000000..ff857c4 --- /dev/null +++ b/src/extensions/retentionControls/components/ItemColumn.tsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import { ICustomColumn } from "../../../shared/interfaces/ICustomColumn"; +import { IItemMetadata } from "../../../shared/interfaces/IItemMetadata"; +import { FontIcon } from "@fluentui/react/lib/Icon"; +import { getBehaviorLabel } from "../../../shared/utils"; +import { getFileTypeIconProps, FileIconType } from "@fluentui/react-file-type-icons"; +import { Spinner, SpinnerSize } from "@fluentui/react/lib/Spinner"; +import * as strings from "RetentionControlsCommandSetStrings"; +import { classNames } from "../../../shared/styles"; +import { format } from "@fluentui/react/lib/Utilities"; +import { IItemState } from "../../../shared/interfaces/IItemState"; +import ConfirmationDialogManager from "../ConfirmationDialogManager"; +import AlertDialogManager from "../AlertDialogManager"; + +export interface IItemColumn { + item: IItemMetadata; + itemState?: IItemState; + column: ICustomColumn; + onClearing: (item: IItemMetadata) => void; + onToggling: (item: IItemMetadata) => void; +} + +export const ItemColumn: React.FC = (props) => { + const { item, itemState, column, onToggling, onClearing } = props; + + const onToggleClick = async (item: IItemMetadata): Promise => { + if (!item.isRecordTypeLabel) { + const dialog = new AlertDialogManager(strings.ToggleRecordForNonRecordLabelAlertTitle, strings.ToggleRecordForNonRecordLabelAlertMessage); + await dialog.show(); + } + else if (item.isFolder) { + const dialog = new AlertDialogManager(strings.ToggleRecordForFolderAlertTitle, strings.ToggleRecordForFolderAlertMessage); + await dialog.show(); + } + else { + onToggling(item) + } + } + + const onClearClick = async (item: IItemMetadata): Promise => { + if (item.isFolder) { + const dialog = new ConfirmationDialogManager(strings.ClearLabelConfirmationTitle, strings.ClearLabelConfirmationMessage); + dialog.onClosed((confirmed?: boolean) => { + if (confirmed === true) { + onClearing(item); + } + }); + await dialog.show(); + } + else if (item.isRecordTypeLabel && !item.isRecordLocked) { + const dialog = new AlertDialogManager(strings.CannotClearWhileUnlockedTitle, strings.CannotClearWhileUnlockedMessage); + await dialog.show(); + } + else { + onClearing(item); + } + } + + if (column.key === "icon") { + if (item.contentTypeId.indexOf("0x0120D520") > -1) { + return ; + } else if (item.contentTypeId.indexOf("0x0120") > -1) { + return ; + } + + const extension = item.name.substring(item.name.lastIndexOf(".")); + return ; + } else if (column.key === "name") { + return {item.name}; + } else if (column.key === "behaviorDuringRetentionPeriod") { + const fieldValue = getBehaviorLabel(item.behaviorDuringRetentionPeriod); + return {fieldValue}; + } else if (column.key === "isDeleteAllowed" || column.key === "isMetadataUpdateAllowed" || column.key === "isContentUpdateAllowed" || column.key === "isLabelUpdateAllowed") { + const boolValue = item[column.key as keyof IItemMetadata]; + if (boolValue === true) { + return ; + } else if (boolValue === false) { + return ; + } + + return <>; + } else if (column.key === "isRecordLocked") { + if (itemState?.toggling) { + return ; + } + if (itemState?.errorToggling !== undefined) { + return onToggleClick(item)} />; + } + if (item.isRecordLocked === true) { + return onToggleClick(item)} />; + } else if (item.isRecordLocked === false) { + return onToggleClick(item)} />; + } + + return <>; + } + else if (column.key === "clearLabel") { + if (itemState?.clearing) { + return ; + } + if (itemState?.errorClearing === true) { + return onClearClick(item)} />; + } + + return onClearClick(item)} />; + } + + const fieldValue = item[column.key as keyof IItemMetadata] as string; + return {fieldValue}; +}; diff --git a/src/extensions/retentionControls/components/LibraryView.tsx b/src/extensions/retentionControls/components/LibraryView.tsx new file mode 100644 index 0000000..d5fbd10 --- /dev/null +++ b/src/extensions/retentionControls/components/LibraryView.tsx @@ -0,0 +1,419 @@ +import * as React from "react"; +import { useEffect, useState } from "react"; +import * as strings from "RetentionControlsCommandSetStrings"; +import { format, SelectionMode } from "@fluentui/react/lib/Utilities"; +import { ShimmeredDetailsList } from "@fluentui/react/lib/ShimmeredDetailsList"; +import { IItemMetadata } from "../../../shared/interfaces/IItemMetadata"; +import { ICustomColumn } from "../../../shared/interfaces/ICustomColumn"; +import { ItemColumn } from "./ItemColumn"; +import Dialog, { DialogFooter, DialogType } from "@fluentui/react/lib/Dialog"; +import { DefaultButton, IButtonProps, PrimaryButton } from "@fluentui/react/lib/Button"; +import { dialogFooterStyles, messageBarStyles } from "../../../shared/styles"; +import { Stack } from "@fluentui/react/lib/Stack"; +import { Spinner, SpinnerSize } from "@fluentui/react/lib/Spinner"; +import { ContextualMenu, IContextualMenuProps } from "@fluentui/react/lib/ContextualMenu"; +import { Log } from "@microsoft/sp-core-library"; +import { LOG_SOURCE } from "../RetentionControlsCommandSet"; +import { MessageBar, MessageBarType } from "@fluentui/react/lib/MessageBar"; +import { IItemState } from "../../../shared/interfaces/IItemState"; +import { IBatchItemResponse } from "../../../shared/interfaces/IBatchErrorResponse"; +import { flattenItemMetadata, flattenItemMetadataList, updateObjectProperties } from "../../../shared/utils"; +import { INotification } from "../../../shared/interfaces/INotification"; +import { ResponsiveMode } from "@fluentui/react/lib/ResponsiveMode"; +import { IPagedDriveItems } from "../../../shared/interfaces/IPagedDriveItems"; +import { itemMetadataColumns } from "../../../shared/constants"; +import { IDriveItem } from "../../../shared/interfaces/IDriveItem"; + +export interface ILibraryView { + onClose: () => void; + onFetching: (listItemIds: number[]) => Promise; + onFetchingPaged: (pageSize: number, nextLink?: string) => Promise; + onClearing: (listItemIds: number[]) => Promise; + onToggling: (listItemIds: number[], newLockstate: boolean) => Promise; +} + +export const LibraryView: React.FC = (props) => { + const fetchPageSize = 100; + const pageSize = 10; + const shimmerLines = 10; + const [nextLink, setNextLink] = useState(); + const [loading, setLoading] = useState(true); + const [notification, setNotification] = useState(); + const [totalPages, setTotalPages] = useState(1); + const [pageNumber, setPageNumber] = useState(1); + const [executingAction, setExecutingAction] = useState(false); + const [actionStatus, setActionStatus] = useState(""); + const [fetchedItems, setFetchedItems] = useState([]); + const [itemsWithMetadata, setItemsWithMetadata] = useState(); + const [itemsState, setItemsState] = useState([]); + + const refreshItemMetadata = async (listItemId: number): Promise => { + if (itemsWithMetadata === undefined) { + return; + } + + try { + const response = await props.onFetching([listItemId]); + + if (!response || response.length === 0) { + throw new Error(strings.ErrorFetchingData); + } + + const updatedItem = flattenItemMetadata(response[0]) as IItemMetadata; + const fetchedItem = fetchedItems.filter(i => i.id === updatedItem.id)[0]; + const item = itemsWithMetadata.filter(i => i.id === updatedItem.id)[0]; + + updateObjectProperties(fetchedItem, updatedItem); + updateObjectProperties(item, updatedItem); + + setFetchedItems([...fetchedItems]); + setItemsWithMetadata([...itemsWithMetadata]); + } catch (error) { + const message = strings.ErrorFetchingData + ": " + error.message; + Log.error(LOG_SOURCE, new Error(message)); + setNotification({ message: message, notificationType: MessageBarType.error }); + } + }; + + const fetchData = async (page: number, showLoader: boolean = true, appendToFetchedList: boolean = true): Promise => { + try { + if (showLoader) + setLoading(true); + + let fetchedItemsList = fetchedItems; + + if (!appendToFetchedList) + fetchedItemsList = []; + + setPageNumber(page); + const retrieveNewItems = (page === 1 && fetchedItemsList.length === 0) || (page * pageSize > (fetchedItemsList?.length ?? 0) && nextLink !== undefined); + + if (retrieveNewItems) { + const response = await props.onFetchingPaged(fetchPageSize, nextLink); + const newItems = flattenItemMetadataList(response.items); + + for (const item of newItems) { + if (!fetchedItemsList.some(i => i.driveItemId === item.driveItemId)) { + fetchedItemsList.push(item); + } + } + + setNextLink(response.nextLink); + setFetchedItems([...fetchedItemsList]); + } + + const slice = fetchedItemsList.slice((page - 1) * pageSize, page * pageSize); + setItemsWithMetadata(slice); + setTotalPages(Math.ceil(fetchedItemsList.length / pageSize)); + + if (showLoader) + setLoading(false); + } catch (error) { + const message = strings.ErrorFetchingData + ": " + error.message; + Log.error(LOG_SOURCE, new Error(message)); + setNotification({ message: message, notificationType: MessageBarType.error }); + + if (showLoader) + setLoading(false); + } + }; + + const onTogglingRecord = async (item: IItemMetadata): Promise => { + if (itemsState === undefined || itemsWithMetadata === undefined) { + return; + } + + Log.info(LOG_SOURCE, `Toggling record status for '${item.name}'`); + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: true, errorToggling: undefined, clearing: false, errorClearing: false }]); + setNotification(undefined); + setExecutingAction(true); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + + try { + const newLockState = !item.isRecordLocked; + const response = await props.onToggling([item.id], newLockState); + const success = response[0].success; + + if (!success) { + setNotification({ message: format(strings.ToggleErrorForSingleItem, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase()) + " " + response[0].errorMessage, notificationType: MessageBarType.error }); + } + else { + setNotification({ message: format(strings.RecordStatusToggled, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase()), notificationType: MessageBarType.success }); + } + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: response[0].success === false ? response[0].errorMessage : undefined, clearing: false, errorClearing: false }]); + await refreshItemMetadata(item.id); + } + catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: true, errorToggling: undefined, clearing: false, errorClearing: false }]); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + } + finally { + setExecutingAction(false); + } + } + + const onTogglingAllRecords = (newLockState: boolean): void => { + Promise.resolve().then(async () => { + if (itemsWithMetadata === undefined) { + return; + } + + Log.info(LOG_SOURCE, `Toggling record status for all items`); + + setActionStatus(`0 ${strings.ItemsDone}`); + setItemsState([]); + setNotification(undefined); + setExecutingAction(true); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + + try { + let newItemsState: IItemState[] = []; + let errorCount = 0; + let more = true; + let nextLink = undefined; + + while (more) { + const itemsPage: IPagedDriveItems = await props.onFetchingPaged(100, nextLink); + const itemsToToggle = flattenItemMetadataList(itemsPage.items).filter(i => !i.isFolder && i.isRecordTypeLabel && i.isRecordLocked !== newLockState).map(i => i.id); + const responses = await props.onToggling(itemsToToggle, newLockState); + more = itemsPage.nextLink !== undefined && itemsPage.items.length > 0; + nextLink = itemsPage.nextLink; + errorCount += responses.filter(r => !r.success).length; + + for(const itemResponse of responses) { + newItemsState = [...newItemsState.filter(i => i.listItemId !== itemResponse.listItemId), { listItemId: itemResponse.listItemId, toggling: false, errorToggling: itemResponse.errorMessage, clearing: false, errorClearing: false }]; + } + + setActionStatus(`${newItemsState.length} ${strings.ItemsDone}`); + } + + if (errorCount > 0) { + setNotification({ message: format(strings.ToggleErrorForMultipleItems, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase(), errorCount, newItemsState.length), notificationType: MessageBarType.warning }); + } + else if (errorCount === 0) { + setNotification({ message: format(strings.RecordStatusToggledEntireLibrary, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase()), notificationType: MessageBarType.success }); + } + + setItemsState(newItemsState); + await fetchData(1, true, false); + } catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + + setItemsState([]); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + } + finally { + setExecutingAction(false); + setActionStatus(""); + } + }).catch(error => console.log(error)); + } + + const onClearingLabel = async (item: IItemMetadata): Promise => { + if (itemsState === undefined || itemsWithMetadata === undefined) { + return; + } + + Log.info(LOG_SOURCE, `Clearing label for '${item.name}'`); + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: undefined, clearing: true, errorClearing: false }]); + setNotification(undefined); + setExecutingAction(true); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + + try { + const responses = await props.onClearing([item.id]); + const success = responses[0].success; + + if (!success) { + setNotification({ message: strings.ClearErrorForSingleItem, notificationType: MessageBarType.error }); + } + else { + setNotification({ message: strings.LabelCleared, notificationType: MessageBarType.success }); + } + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: undefined, clearing: false, errorClearing: responses[0].success === false }]); + setItemsWithMetadata([...itemsWithMetadata.filter(i => i.id !== item.id)]); + setFetchedItems([...fetchedItems.filter(i => i.id !== item.id)]); + } + catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: undefined, clearing: false, errorClearing: false }]); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + } + finally { + setExecutingAction(false); + } + } + + const onClearingAllLabels = (): void => { + Promise.resolve().then(async () => { + if (itemsWithMetadata === undefined) { + return; + } + + Log.info(LOG_SOURCE, `Clearing all labels`); + + setActionStatus(`0 ${strings.ItemsDone}`); + setItemsState([]); + setNotification(undefined); + setExecutingAction(true); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + + try { + let newItemsState: IItemState[] = []; + let errorCount = 0; + let more = true; + let nextLink = undefined; + + while (more) { + const itemsPage: IPagedDriveItems = await props.onFetchingPaged(100, nextLink); + const responses = await props.onClearing(itemsPage.items.map(i => parseFloat(i.listItem.id))); + more = itemsPage.nextLink !== undefined && itemsPage.items.length > 0; + nextLink = itemsPage.nextLink; + errorCount += responses.filter(r => !r.success).length; + + for(const itemResponse of responses) { + newItemsState = [...newItemsState.filter(i => i.listItemId !== itemResponse.listItemId), { listItemId: itemResponse.listItemId, toggling: false, errorToggling: undefined, clearing: false, errorClearing: itemResponse.success === false }]; + } + + setActionStatus(`${newItemsState.length} ${strings.ItemsDone}`); + } + + if (errorCount > 0) { + setNotification({ message: format(strings.ClearErrorForMultipleItems, errorCount, newItemsState.length), notificationType: MessageBarType.warning }); + } + else { + setNotification({ message: strings.LabelClearedForLibrary, notificationType: MessageBarType.success }); + } + + setItemsState(newItemsState); + await fetchData(1, true, false); + } catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + + setItemsState([]); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + } + finally { + setExecutingAction(false); + setActionStatus(""); + } + }).catch(error => console.log(error)); + } + + const onRenderItemColumn = (item: IItemMetadata, index: number, column: ICustomColumn): JSX.Element => { + return i.listItemId === item.id)[0]} column={column} onToggling={onTogglingRecord} onClearing={onClearingLabel} />; + } + + const menuProps: IContextualMenuProps = { + items: [ + { + key: 'lockRecords', + text: strings.LockRecords, + title: strings.LockRecordsTooltip, + onClick: () => onTogglingAllRecords(true), + iconProps: { iconName: 'Lock' }, + }, + { + key: 'unlockRecords', + text: strings.UnlockRecords, + title: strings.UnlockRecordsTooltip, + onClick: () => onTogglingAllRecords(false), + iconProps: { iconName: 'Unlock' }, + }, + { + key: 'clearAllLabels', + text: strings.ClearLabels, + title: strings.ClearLabelsTooltip, + onClick: () => onClearingAllLabels(), + iconProps: { iconName: 'Untag' }, + }, + ], + directionalHintFixed: true, + }; + + const getMenu = (props: IContextualMenuProps): JSX.Element => { + return ; + } + + const getPage = (page: number): void => { + Log.info(LOG_SOURCE, `Fetching page ${page}`); + fetchData(page).catch((error) => { console.log(error); }); + } + + useEffect(() => { + fetchData(pageNumber).catch((error) => { console.log(error); }); + }, []); + + let paginationButtons: IButtonProps[] = []; + + if (totalPages > 1) { + paginationButtons = [ + { iconProps: { iconName: "ChevronLeft"}, onClick: () => getPage(pageNumber-1), disabled: pageNumber === 1, title: pageNumber === 1 ? strings.IsFirstPage : format(strings.ToPage, pageNumber-1) }, + { iconProps: { iconName: "ChevronRight"}, onClick: () => getPage(pageNumber+1), disabled: pageNumber === totalPages, title: pageNumber === totalPages ? strings.IsLastPage : format(strings.ToPage, pageNumber+1) } + ]; + } + + return <> + + ; +}; \ No newline at end of file diff --git a/src/extensions/retentionControls/components/MultiItemView.tsx b/src/extensions/retentionControls/components/MultiItemView.tsx new file mode 100644 index 0000000..29ac0b4 --- /dev/null +++ b/src/extensions/retentionControls/components/MultiItemView.tsx @@ -0,0 +1,430 @@ +import * as React from "react"; +import { useEffect, useState } from "react"; +import { IDriveItem } from "../../../shared/interfaces/IDriveItem"; +import * as strings from "RetentionControlsCommandSetStrings"; +import { RowAccessor } from "@microsoft/sp-listview-extensibility"; +import { format, SelectionMode } from "@fluentui/react/lib/Utilities"; +import { ShimmeredDetailsList } from "@fluentui/react/lib/ShimmeredDetailsList"; +import { IItemMetadata } from "../../../shared/interfaces/IItemMetadata"; +import { ICustomColumn } from "../../../shared/interfaces/ICustomColumn"; +import { ItemColumn } from "./ItemColumn"; +import Dialog, { DialogFooter, DialogType } from "@fluentui/react/lib/Dialog"; +import { DefaultButton, IButtonProps, PrimaryButton } from "@fluentui/react/lib/Button"; +import { dialogFooterStyles, messageBarStyles } from "../../../shared/styles"; +import { Stack } from "@fluentui/react/lib/Stack"; +import { Spinner, SpinnerSize } from "@fluentui/react/lib/Spinner"; +import { ContextualMenu, IContextualMenuProps } from "@fluentui/react/lib/ContextualMenu"; +import { Log } from "@microsoft/sp-core-library"; +import { LOG_SOURCE } from "../RetentionControlsCommandSet"; +import { MessageBar, MessageBarType } from "@fluentui/react/lib/MessageBar"; +import { IItemState } from "../../../shared/interfaces/IItemState"; +import { IBatchItemResponse } from "../../../shared/interfaces/IBatchErrorResponse"; +import { flattenItemMetadata, flattenItemMetadataList, updateObjectProperties } from "../../../shared/utils"; +import { INotification } from "../../../shared/interfaces/INotification"; +import { ResponsiveMode } from "@fluentui/react/lib/ResponsiveMode"; +import { itemMetadataColumns } from "../../../shared/constants"; + +export interface IMultiItemView { + listItems: readonly RowAccessor[]; + onClose: () => void; + onFetching: (listItemIds: number[]) => Promise; + onClearing: (listItemIds: number[]) => Promise; + onToggling: (listItemIds: number[], newLockstate: boolean) => Promise; +} + +export const MultiItemView: React.FC = (props) => { + const pageSize = 10; + const shimmerLines = props.listItems.length > pageSize ? pageSize : props.listItems.length; + const [listItemIds, setListItemIds] = useState(props.listItems.map(i => parseFloat(i.getValueByName("ID")))); + const [loading, setLoading] = useState(true); + const [notification, setNotification] = useState(); + const [pageNumber, setPageNumber] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [executingAction, setExecutingAction] = useState(false); + const [actionStatus, setActionStatus] = useState(""); + const [fetchedItems, setFetchedItems] = useState([]); + const [itemsState, setItemsState] = useState([]); + const [itemsWithMetadata, setItemsWithMetadata] = useState(); + + const refreshItemMetadata = async (listItemId: number): Promise => { + if (itemsWithMetadata === undefined) { + return; + } + + try { + const response = await props.onFetching([listItemId]); + + if (!response || response.length === 0) { + throw new Error(strings.ErrorFetchingData); + } + + const updatedItem = flattenItemMetadata(response[0]) as IItemMetadata; + const item = itemsWithMetadata.filter(i => i.id === updatedItem.id)[0]; + + updateObjectProperties(item, updatedItem); + + setItemsWithMetadata([...itemsWithMetadata]); + } catch (error) { + const message = strings.ErrorFetchingData + ": " + error.message; + Log.error(LOG_SOURCE, new Error(message)); + setNotification({ message: message, notificationType: MessageBarType.error }); + } + }; + + const fetchAllItems = async (): Promise => { + const allItemsWithMetadata: IItemMetadata[] = []; + const pages = Math.ceil(listItemIds.length / pageSize); + + for (let page = 1; page <= pages; page++) { + const itemIds = listItemIds.slice((page - 1) * pageSize, page * pageSize); + const response = await props.onFetching(itemIds); + + for (const item of flattenItemMetadataList(response)) { + if (item.retentionLabel !== "") { + allItemsWithMetadata.push(item); + } + } + } + + return allItemsWithMetadata; + } + + const fetchData = async (data: number[], page: number, showLoader: boolean = true, appendToFetchedList: boolean = true): Promise => { + try { + if (showLoader) + setLoading(true); + + let fetchedItemsList = fetchedItems; + + if (!appendToFetchedList) + fetchedItemsList = []; + + setPageNumber(page); + + if (data.length > 0) { + const retrieveNewItems = (page === 1 && fetchedItemsList.length === 0) || (page * pageSize > (fetchedItemsList?.length ?? 0) && fetchedItemsList?.length > ((page-1) * pageSize)); + + if (retrieveNewItems) { + const itemIds = data.slice((page - 1) * pageSize, (page * pageSize) + 1); // Retrieve one item extra to check if more pages are available. + const response = await props.onFetching(itemIds); + const newItems = flattenItemMetadataList(response); + + for (const item of newItems) { + if (!fetchedItemsList.some(i => i.driveItemId === item.driveItemId)) { + fetchedItemsList.push(item); + } + } + + setFetchedItems([...fetchedItemsList]); + } + + + const items = fetchedItemsList.slice((page - 1) * pageSize, page * pageSize); + setItemsWithMetadata(items); + setTotalPages(Math.ceil(fetchedItemsList.length / pageSize)); + } + else { + setItemsWithMetadata([]); + setTotalPages(1); + } + + if (showLoader) + setLoading(false); + } catch (error) { + const message = strings.ErrorFetchingData + ": " + error.message; + Log.error(LOG_SOURCE, new Error(message)); + setNotification({ message: message, notificationType: MessageBarType.error }); + + if (showLoader) + setLoading(false); + } + }; + + const onTogglingRecord = async (item: IItemMetadata): Promise => { + if (itemsState === undefined || itemsWithMetadata === undefined) { + return; + } + + Log.info(LOG_SOURCE, `Toggling record status for '${item.name}'`); + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: true, errorToggling: undefined, clearing: false, errorClearing: false }]); + setNotification(undefined); + setExecutingAction(true); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + + try { + const newLockState = !item.isRecordLocked; + const response = await props.onToggling([item.id], newLockState); + const success = response[0].success; + + if (!success) { + setNotification({ message: format(strings.ToggleErrorForSingleItem, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase()) + " " + response[0].errorMessage, notificationType: MessageBarType.error }); + } + else { + setNotification({ message: format(strings.RecordStatusToggled, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase()), notificationType: MessageBarType.success }); + } + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: response[0].errorMessage, clearing: false, errorClearing: false }]); + await refreshItemMetadata(item.id); + } + catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: undefined, clearing: false, errorClearing: false }]); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + } + finally { + setExecutingAction(false); + } + } + + const onTogglingAllRecords = (newLockState: boolean): void => { + Promise.resolve().then(async () => { + if (itemsWithMetadata === undefined) { + return; + } + + Log.info(LOG_SOURCE, `Toggling record status for all items`); + + setActionStatus(strings.CheckingItems); + setItemsState([]); + setNotification(undefined); + setExecutingAction(true); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + + try { + const allItemsWithMetadata: IItemMetadata[] = await fetchAllItems(); + + const itemsToToggle = allItemsWithMetadata.filter(i => !i.isFolder && i.isRecordTypeLabel && i.isRecordLocked !== newLockState).map(i => i.id); + setActionStatus(format(strings.TogglingItems, itemsToToggle.length)); + const responses = await props.onToggling(itemsToToggle, newLockState); + const errorCount = responses.filter(r => !r.success).length; + + if (errorCount > 0) { + setNotification({ message: format(strings.ToggleErrorForMultipleItems, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase(), errorCount, listItemIds.length), notificationType: MessageBarType.warning }); + } + else { + setNotification({ message: format(strings.RecordStatusToggled, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase()), notificationType: MessageBarType.success }); + } + + let newItemsState: IItemState[] = []; + for(const itemResponse of responses) { + newItemsState = [...newItemsState.filter(i => i.listItemId !== itemResponse.listItemId), { listItemId: itemResponse.listItemId, toggling: false, errorToggling: itemResponse.errorMessage, clearing: false, errorClearing: false }]; + } + + setItemsState(newItemsState); + await fetchData(listItemIds, 1, true, false); + } catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + + setItemsState([]); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + } + finally { + setExecutingAction(false); + setActionStatus(""); + } + }).catch(error => console.log(error)); + } + + const onClearingLabel = async (item: IItemMetadata): Promise => { + if (itemsState === undefined || itemsWithMetadata === undefined) { + return; + } + + Log.info(LOG_SOURCE, `Clearing label for '${item.name}'`); + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: undefined, clearing: true, errorClearing: false }]); + setNotification(undefined); + setExecutingAction(true); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + + try { + const responses = await props.onClearing([item.id]); + const success = responses[0].success; + + if (!success) { + setNotification({ message: strings.ClearErrorForSingleItem, notificationType: MessageBarType.error }); + } + else { + setNotification({ message: strings.LabelCleared, notificationType: MessageBarType.success }); + } + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: undefined, clearing: false, errorClearing: responses[0].success === false }]); + setListItemIds(listItemIds.filter(i => i !== item.id)); + setItemsWithMetadata([...itemsWithMetadata.filter(i => i.id !== item.id)]); + setFetchedItems([...fetchedItems.filter(i => i.id !== item.id)]); + } + catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + + setItemsState([...itemsState.filter(i => i.listItemId !== item.id), { listItemId: item.id, toggling: false, errorToggling: undefined, clearing: false, errorClearing: false }]); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + } + finally { + setExecutingAction(false); + } + } + + const onClearingAllLabels = (): void => { + Promise.resolve().then(async () => { + if (itemsWithMetadata === undefined) { + return; + } + + Log.info(LOG_SOURCE, `Clearing all labels`); + + setActionStatus(strings.CheckingItems); + setItemsState([]); + setNotification(undefined); + setExecutingAction(true); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + + try { + const allItemsWithMetadata: IItemMetadata[] = await fetchAllItems(); + + const itemsToClear = allItemsWithMetadata.filter(i => i.retentionLabel !== "").map(i => i.id); + setActionStatus(format(strings.ClearingItems, itemsToClear.length)); + const responses = await props.onClearing(itemsToClear); + const errorCount = responses.filter(r => !r.success).length; + + if (errorCount > 0) { + setNotification({ message: format(strings.ClearErrorForMultipleItems, errorCount, listItemIds.length), notificationType: MessageBarType.warning }); + } + else { + setNotification({ message: strings.LabelCleared, notificationType: MessageBarType.success }); + } + + let newItemsState: IItemState[] = []; + let newListItemIds: number[] = listItemIds; + for(const itemResponse of responses) { + newItemsState = [...newItemsState.filter(i => i.listItemId !== itemResponse.listItemId), { listItemId: itemResponse.listItemId, toggling: false, errorToggling: undefined, clearing: false, errorClearing: !itemResponse.success }]; + if (itemResponse.success) { + newListItemIds = newListItemIds.filter(i => i !== itemResponse.listItemId); + } + } + + setItemsState(newItemsState); + setListItemIds([...newListItemIds]); + await fetchData(newListItemIds, 1, true, false); + } catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + + // Trigger re-render table + setItemsWithMetadata([...itemsWithMetadata]); + } + finally { + setExecutingAction(false); + setActionStatus(""); + } + }).catch(error => console.log(error)); + } + + const onRenderItemColumn = (item: IItemMetadata, index: number, column: ICustomColumn): JSX.Element => { + return i.listItemId === item.id)[0]} column={column} onToggling={onTogglingRecord} onClearing={onClearingLabel} />; + } + + const menuProps: IContextualMenuProps = { + items: [ + { + key: 'lockRecords', + text: strings.LockRecords, + title: strings.LockRecordsTooltip, + onClick: () => onTogglingAllRecords(true), + iconProps: { iconName: 'Lock' }, + }, + { + key: 'unlockRecords', + text: strings.UnlockRecords, + title: strings.UnlockRecordsTooltip, + onClick: () => onTogglingAllRecords(false), + iconProps: { iconName: 'Unlock' }, + }, + { + key: 'clearAllLabels', + text: strings.ClearLabels, + title: strings.ClearLabelsTooltip, + onClick: () => onClearingAllLabels(), + iconProps: { iconName: 'Untag' }, + }, + ], + directionalHintFixed: true, + }; + + const getMenu = (props: IContextualMenuProps): JSX.Element => { + return ; + } + + const getPage = (page: number): void => { + Log.info(LOG_SOURCE, `Fetching page ${page}`); + fetchData(listItemIds, page).catch((error) => { console.log(error); }); + } + + useEffect(() => { + fetchData(listItemIds, pageNumber).catch((error) => { console.log(error); }); + }, []); + + let paginationButtons: IButtonProps[] = []; + + if (totalPages > 1) { + paginationButtons = [ + { iconProps: { iconName: "ChevronLeft"}, onClick: () => getPage(pageNumber-1), disabled: pageNumber === 1, title: pageNumber === 1 ? strings.IsFirstPage : format(strings.ToPage, pageNumber-1) }, + { iconProps: { iconName: "ChevronRight"}, onClick: () => getPage(pageNumber+1), disabled: pageNumber === totalPages, title: pageNumber === totalPages ? strings.IsLastPage : format(strings.ToPage, pageNumber+1) } + ]; + } + + return <> + + ; +}; \ No newline at end of file diff --git a/src/extensions/retentionControls/components/RetentionControlsDialog.tsx b/src/extensions/retentionControls/components/RetentionControlsDialog.tsx index b2828d3..0d67968 100644 --- a/src/extensions/retentionControls/components/RetentionControlsDialog.tsx +++ b/src/extensions/retentionControls/components/RetentionControlsDialog.tsx @@ -1,28 +1,59 @@ -import { BaseDialog, IDialogConfiguration } from "@microsoft/sp-dialog"; import { BaseComponentContext } from "@microsoft/sp-component-base"; -import * as ReactDOM from "react-dom"; -import RetentionControlsDialogContent from "./RetentionControlsDialogContent"; import * as React from "react"; +import { SharePointService } from "../../../shared/services/SharePointService"; import { RowAccessor } from "@microsoft/sp-listview-extensibility"; +import { IDriveItem } from "../../../shared/interfaces/IDriveItem"; +import { SingleItemView } from "./SingleItemView"; +import { MultiItemView } from "./MultiItemView"; +import { IBatchItemResponse } from "../../../shared/interfaces/IBatchErrorResponse"; +import { LibraryView } from "./LibraryView"; +import { IPagedDriveItems } from "../../../shared/interfaces/IPagedDriveItems"; -export default class RetentionControlsDialog extends BaseDialog { - constructor(private context: BaseComponentContext, private listId: string, private listItems: readonly RowAccessor[]) { - super(); - } +export interface IRetentionControlsDialogProps { + context: BaseComponentContext; + listId: string; + listItems: readonly RowAccessor[]; + selectedItems: number; + onClose: { (): void }; +} - public render(): void { - ReactDOM.render(, this.domElement); - //ReactDOM.render(
Test {this.context.pageContext.list?.id} {this.listId} {this.listItemIds.map(x =>
{x}
)}
, this.domElement); - } +const RetentionControlsDialog: React.FC = (props) => { + const { selectedItems } = props; + const spoService = props.context.serviceScope.consume(SharePointService.serviceKey); - public getConfig(): IDialogConfiguration { - return { isBlocking: true }; - } + const fetchItemMetadata = async (listItemIds: number[]): Promise => { + return await spoService.getDriveItems(props.listId, listItemIds); + }; - protected onAfterClose(): void { - super.onAfterClose(); + const fetchItemsPaged = async (pageSize: number, nextLink?: string): Promise => { + return nextLink !== undefined ? + await spoService.getPagedDriveItemsUsingNextLink(nextLink) : + await spoService.getPagedDriveItems(props.listId, pageSize); + }; - // Clean up the element for the next dialog - ReactDOM.unmountComponentAtNode(this.domElement); - } -} + const onClearingLabels = async (listItemIds: number[]): Promise => { + if (listItemIds.length === 0) { + return []; + } + + return await spoService.clearRetentionLabels(listItemIds); + }; + + const onTogglingRecords = async (listItemIds: number[], newLockState: boolean): Promise => { + if (listItemIds.length === 0) { + return []; + } + + return await spoService.toggleLockStatus(listItemIds, newLockState); + }; + + return selectedItems === 1 ? <> + + : selectedItems > 1 ? <> + + : <> + + ; +}; + +export default RetentionControlsDialog; diff --git a/src/extensions/retentionControls/components/RetentionControlsDialogContent.tsx b/src/extensions/retentionControls/components/RetentionControlsDialogContent.tsx deleted file mode 100644 index 08ee2fc..0000000 --- a/src/extensions/retentionControls/components/RetentionControlsDialogContent.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import { BaseComponentContext } from "@microsoft/sp-component-base"; -import * as React from "react"; -import { SharePointService } from "../../../shared/services/SharePointService"; -import { IRetentionLabel } from "../../../shared/interfaces/IRetentionLabel"; -import { useState } from "react"; -import * as strings from "RetentionControlsCommandSetStrings"; -import { initializeIcons } from "@fluentui/font-icons-mdl2"; -import { FontIcon } from "@fluentui/react/lib/Icon"; -import { Warning } from "../../../shared/Warning"; -import { RowAccessor } from "@microsoft/sp-listview-extensibility"; -import { IListItemFields } from "../../../shared/interfaces/IListItemFields"; -import { format } from "@fluentui/react/lib/Utilities"; -import { IMessageBarStyles, MessageBar, MessageBarType } from "@fluentui/react/lib/MessageBar"; -import { DialogContent, DialogFooter, DialogType, IDialogFooterStyles } from "@fluentui/react/lib/Dialog"; -import { ResponsiveMode } from "@fluentui/react/lib/ResponsiveMode"; -import { IStackItemStyles, IStackTokens, Stack } from "@fluentui/react/lib/Stack"; -import { mergeStyles, mergeStyleSets } from "@fluentui/react/lib/Styling"; -import { Spinner, SpinnerSize } from "@fluentui/react/lib/Spinner"; -import { Link } from "@fluentui/react/lib/Link"; -import { Shimmer } from "@fluentui/react/lib/Shimmer"; -import { DefaultButton } from "@fluentui/react/lib/Button"; -initializeIcons(); - -export interface IRetentionControlsDialogProps { - context: BaseComponentContext; - listId: string; - listItems: readonly RowAccessor[]; - close: { (): void }; -} - -const stackItemStyles: IStackItemStyles = { - root: { - alignItems: "center", - display: "flex", - width: "250px", - }, -}; - -const dialogFooterStyles: IDialogFooterStyles = { - action: { - width: "100%", - }, - actions: {}, - actionsRight: {}, -}; - -const messageBarStyles: IMessageBarStyles = { - root: { - marginBottom: "10px", - }, -}; - -const stackTokens: IStackTokens = { - childrenGap: 5, -}; - -const iconClass = mergeStyles({ - fontSize: 14, - height: 14, - width: 14, - margin: "0 10px 0 0", -}); - -const classNames = mergeStyleSets({ - green: [{ color: "darkgreen" }, iconClass], - red: [{ color: "indianred" }, iconClass], - blue: [{ color: "#28a8ea" }, iconClass], -}); - -const getBehaviorLabel = (behavior: string | undefined): string => { - switch (behavior) { - case "retain": - return "Retain"; - case "doNotRetain": - return "Do not retain"; - case "retainAsRecord": - return "Retain as record"; - case "retainAsRegulatoryRecord": - return "Retain as regulatory record"; - default: - return "N/A"; - } -}; - -const RetentionControlsDialogContent: React.FC = (props) => { - const [error, setError] = useState(); - const [warning, setWarning] = useState(); - const [loading, setLoading] = useState(true); - const [successMessage, setSuccessMessage] = useState(); - const [clearing, setClearing] = useState(false); - const [toggling, setToggling] = useState(false); - const [driveItemLabel, setDriveItemLabel] = useState(); - const [listItemFields, setListItemFields] = useState(); - const spoService = props.context.serviceScope.consume(SharePointService.serviceKey); - - const fetchListItemData = async (): Promise => { - try { - const listItemIds = props.listItems.map((item) => parseFloat(item.getValueByName("ID"))); - const response = await spoService.getListItemFields(props.listId, listItemIds[0]); - setListItemFields(response); - - return response; - } catch (error) { - setError(error.message); - } - }; - - const fetchRetentionLabelSettings = async (): Promise => { - try { - const listItemIds = props.listItems.map((item) => parseFloat(item.getValueByName("ID"))); - const response = await spoService.getRetentionSettings(props.listId, listItemIds[0]); - setDriveItemLabel(response); - - return response; - } catch (error) { - setError(error.message); - } - }; - - const fetchData = async (): Promise => { - await fetchListItemData(); - await fetchRetentionLabelSettings(); - setLoading(false); - }; - - const clearLabel = async (): Promise => { - setSuccessMessage(undefined); - setError(undefined); - setWarning(undefined); - - try { - if (driveItemLabel?.retentionSettings?.isRecordLocked === false) { - throw new Error(strings.CannotClearWhileUnlocked); - } - - setClearing(true); - const listItemIds = props.listItems.map((item) => parseFloat(item.getValueByName("ID"))); - await spoService.clearRetentionLabels(listItemIds); - setDriveItemLabel(undefined); - setSuccessMessage(strings.LabelCleared); - setClearing(false); - } catch (error) { - if ((error as Warning).isWarning) { - setWarning(error.message); - } else { - setError(error.message); - } - setClearing(false); - } - }; - - const toggleLockStatus = async (): Promise => { - setSuccessMessage(undefined); - setError(undefined); - setWarning(undefined); - - try { - setToggling(true); - await spoService.toggleLockStatus(props.listItems[0].getValueByName("ID"), driveItemLabel?.retentionSettings?.isRecordLocked === true ? false : true); - const retentionLabel = await fetchRetentionLabelSettings(); - - setSuccessMessage(format(strings.RecordStatusToggled, retentionLabel?.retentionSettings?.isRecordLocked === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase())); - setToggling(false); - } catch (error) { - setError(error.message); - setToggling(false); - } - }; - - React.useEffect(() => { - if (props.listItems.length === 1) { - fetchData().catch(() => { - setError(error); - }); - } else { - setLoading(false); - } - }, []); - - const labelAppliedDate = driveItemLabel?.labelAppliedDateTime ? new Date(driveItemLabel.labelAppliedDateTime).toLocaleDateString() : "N/A"; - - // Get a unique list of retention labels applied to the selected items - const retentionLabels = props.listItems - .map((item) => item.getValueByName("_ComplianceTag")) - .filter((label) => label !== undefined && label !== null && label !== "") - .filter((label, index, array) => array.indexOf(label) === index); - - const eventDate = props.listItems.length === 1 && listItemFields?.TagEventDate !== undefined && listItemFields?.TagEventDate?.indexOf("9999") === -1 ? new Date(listItemFields.TagEventDate).toLocaleDateString() : undefined; - - return ( - - {successMessage ? ( - - {successMessage} - - ) : ( - <> - )} - {error ? ( - - {error} - - ) : ( - <> - )} - {warning ? ( - - {warning} - - ) : ( - <> - )} - {!loading && props.listItems.length === 1 && driveItemLabel?.name === undefined ? ( - - {strings.NoLabelApplied} - - ) : ( - <> - )} - {!loading && props.listItems.length > 1 ? ( - - - - {retentionLabels.length > 1 ? strings.RetentionLabelsApplied : strings.RetentionLabelApplied} - - - <> - {retentionLabels[0]} {retentionLabels.length > 1 ? <>+{retentionLabels.length - 1} : <>} - - {clearing ? : <>{retentionLabels.length > 1 ? strings.ClearLabels : strings.ClearLabel}} - - - - - {format(strings.MultipleItemsSelected, props.listItems.length)} - - ) : ( - <> - )} - {props.listItems.length === 1 && (loading || (!loading && driveItemLabel?.name !== undefined)) ? ( - - - - {strings.RetentionLabelApplied} - - - <> - {retentionLabels[0]} - - {clearing ? : <>{strings.ClearLabel}} - - - - - - - - {strings.RetentionLabelApplicationDate} - - - - -
{labelAppliedDate}
-
-
-
- - - - {strings.RetentionLabelAppliedBy} - - - - -
{driveItemLabel?.labelAppliedBy?.user?.displayName || (driveItemLabel?.labelAppliedBy as { application?: { displayName: string } })?.application?.displayName}
-
-
-
- - {eventDate ? ( - - - - {strings.RetentionLabelEventDate} - - - - -
{eventDate}
-
-
-
- ) : ( - <> - )} - - - - - {strings.BehaviorDuringRetentionPeriod} - - - - - {getBehaviorLabel(driveItemLabel?.retentionSettings?.behaviorDuringRetentionPeriod)} - - - - - - - - {strings.IsMetadataUpdateAllowed} - - - - - - {driveItemLabel?.retentionSettings?.isMetadataUpdateAllowed === true ? ( - <> - {strings.ToggleOnText} - - ) : ( - <> - {strings.ToggleOffText} - - )} - - - - - - - {strings.IsContentUpdateAllowed} - - - - - - {driveItemLabel?.retentionSettings?.isContentUpdateAllowed === true ? ( - <> - {strings.ToggleOnText} - - ) : ( - <> - {strings.ToggleOffText} - - )} - - - - - - - {strings.IsDeleteAllowed} - - - - - - {driveItemLabel?.retentionSettings?.isDeleteAllowed === true ? ( - <> - {strings.ToggleOnText} - - ) : ( - <> - {strings.ToggleOffText} - - )} - - - - - - - {strings.IsLabelUpdateAllowed} - - - - - - {driveItemLabel?.retentionSettings?.isLabelUpdateAllowed === true ? ( - <> - {strings.ToggleOnText} - - ) : ( - <> - {strings.ToggleOffText} - - )} - - - - - {driveItemLabel?.retentionSettings?.behaviorDuringRetentionPeriod === "retainAsRecord" ? ( - <> - - - - {strings.RecordStatus} - - - - - - <> - {driveItemLabel?.retentionSettings?.isRecordLocked === true ? ( - <> - {strings.Locked} - - ) : ( - <> - {strings.Unlocked} - - )} - - {toggling ? : <>{strings.ToggleLockStatus}} - - - - - - - ) : ( - <> - )} -
- ) : ( - <> - )} - - - - -
- ); -}; -export default RetentionControlsDialogContent; diff --git a/src/extensions/retentionControls/components/SingleItemView.tsx b/src/extensions/retentionControls/components/SingleItemView.tsx new file mode 100644 index 0000000..b6eb46f --- /dev/null +++ b/src/extensions/retentionControls/components/SingleItemView.tsx @@ -0,0 +1,403 @@ +import * as React from "react"; +import { IDriveItem } from "../../../shared/interfaces/IDriveItem"; +import * as strings from "RetentionControlsCommandSetStrings"; +import { classNames, dialogFooterStyles, messageBarStyles, stackItemStyles, stackTokens } from "../../../shared/styles"; +import { Stack } from "@fluentui/react/lib/Stack"; +import { Shimmer } from "@fluentui/react/lib/Shimmer"; +import { Spinner, SpinnerSize } from "@fluentui/react/lib/Spinner"; +import { Link } from "@fluentui/react/lib/Link"; +import { FontIcon } from "@fluentui/react/lib/Icon"; +import { flattenItemMetadata, getBehaviorLabel } from "../../../shared/utils"; +import { initializeIcons } from "@fluentui/react/lib/Icons"; +import Dialog, { DialogFooter, DialogType } from "@fluentui/react/lib/Dialog"; +import { DefaultButton } from "@fluentui/react/lib/Button"; +import { MessageBar, MessageBarType } from "@fluentui/react/lib/MessageBar"; +import { useEffect, useState } from "react"; +import { Log } from "@microsoft/sp-core-library"; +import { LOG_SOURCE } from "../RetentionControlsCommandSet"; +import { IItemState } from "../../../shared/interfaces/IItemState"; +import { RowAccessor } from "@microsoft/sp-listview-extensibility"; +import { IBatchItemResponse } from "../../../shared/interfaces/IBatchErrorResponse"; +import { format } from "@fluentui/react/lib/Utilities"; +import { ResponsiveMode } from "@fluentui/react/lib/ResponsiveMode"; +import { INotification } from "../../../shared/interfaces/INotification"; +import AlertDialogManager from "../AlertDialogManager"; +import ConfirmationDialogManager from "../ConfirmationDialogManager"; +import { IItemMetadata } from "../../../shared/interfaces/IItemMetadata"; +initializeIcons(); + +export interface ISingleItemView { + listItems: readonly RowAccessor[]; + onClose: () => void; + onFetching: (listItemIds: number[]) => Promise; + onClearing: (listItemIds: number[]) => Promise; + onToggling: (listItemIds: number[], newLockState: boolean) => Promise; +} + +export const SingleItemView: React.FC = (props) => { + const { listItems } = props; + const listItem = listItems[0]; + const listItemId = listItem !== undefined ? parseFloat(listItem.getValueByName("ID")) : undefined; + const fileName = listItem?.getValueByName("FileLeafRef"); + const initialRetentionLabel = listItem?.getValueByName("_ComplianceTag"); + + const [loading, setLoading] = useState(true); + const [notification, setNotification] = useState(); + const [itemDetails, setItemDetails] = useState(); + const [itemState, setItemState] = useState>({ clearing: false, toggling: false, errorClearing: false }); + + const labelAppliedDate = itemDetails?.labelAppliedDate ? itemDetails?.labelAppliedDate : "N/A"; + const eventDate = itemDetails?.eventDate !== undefined && itemDetails?.eventDate?.indexOf("9999") === -1 ? new Date(itemDetails?.eventDate).toLocaleDateString() : undefined; + + const fetchData = async (): Promise => { + try { + setLoading(true); + + if (listItemId) { + const response = await props.onFetching([listItemId]); + setItemDetails(flattenItemMetadata(response[0])); + } + else + setNotification({ message: strings.NoLabelApplied, notificationType: MessageBarType.info }); + + setLoading(false); + } catch (error) { + const message = strings.ErrorFetchingData + ": " + error.message; + Log.error(LOG_SOURCE, new Error(message)); + setNotification({ message: message, notificationType: MessageBarType.error }); + setLoading(false); + } + }; + + const onClearingLabel = async (): Promise => { + if (!itemDetails || !listItemId) { + return; + } + + Log.info(LOG_SOURCE, `Clearing label for '${itemDetails.name}'`); + + setNotification(undefined); + setItemState({ ...itemState, clearing: true, errorClearing: false }); + + try { + const responses = await props.onClearing([itemDetails.id]); + const isError = responses.every((r) => r.success === false); + const newItemDetails = await props.onFetching([listItemId]); + setItemDetails(flattenItemMetadata(newItemDetails[0])); + + if (isError) { + setNotification({ message: strings.ClearErrorForSingleItem, notificationType: MessageBarType.error }); + } + else if (!isError && newItemDetails.every(d => d.retentionLabel?.name === undefined)) { + setNotification({ message: strings.LabelCleared, notificationType: MessageBarType.success }); + } + + setItemState({ ...itemState, clearing: false, errorClearing: isError === true }); + } + catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + setItemState({ ...itemState, clearing: false, errorClearing: true }); + } + }; + + const onTogglingLabel = async (): Promise => { + if (!itemDetails || !listItemId) { + return; + } + + Log.info(LOG_SOURCE, `Toggling record state for '${itemDetails.name}'`); + + setNotification(undefined); + setItemState({ ...itemState, toggling: true, errorToggling: undefined }); + + try { + const newLockState = itemDetails.isRecordLocked === true ? false : true; + const responses = await props.onToggling([itemDetails.id], newLockState); + const isError = responses.every((r) => r.success === false); + const newItemDetails = await props.onFetching([listItemId]); + setItemDetails(flattenItemMetadata(newItemDetails[0])); + + if (isError) { + setNotification({ message: strings.ClearErrorForSingleItem, notificationType: MessageBarType.error }); + } + else if (!isError) { + setNotification({ message: format(strings.RecordStatusToggled, newLockState === true ? strings.Locked.toLowerCase() : strings.Unlocked.toLowerCase()), notificationType: MessageBarType.success }); + } + + setItemState({ ...itemState, toggling: false, errorToggling: responses[0].errorMessage }); + } + catch (error) { + setNotification({ message: error.message, notificationType: MessageBarType.error }); + Log.error(LOG_SOURCE, new Error(error.message)); + setItemState({ ...itemState, toggling: false }); + } + }; + + const onToggleClick = async (): Promise => { + if (!itemDetails || !listItemId) { + return; + } + + if (itemDetails.isFolder) { + const dialog = new AlertDialogManager(strings.ToggleRecordForFolderAlertTitle, strings.ToggleRecordForFolderAlertMessage); + await dialog.show(); + } + else if (!itemDetails.isRecordTypeLabel) { + const dialog = new AlertDialogManager(strings.ToggleRecordForNonRecordLabelAlertTitle, strings.ToggleRecordForNonRecordLabelAlertMessage); + await dialog.show(); + } + else { + await onTogglingLabel(); + } + } + + const onClearClick = async (): Promise => { + if (!itemDetails || !listItemId) { + return; + } + + if (itemDetails.isFolder) { + const dialog = new ConfirmationDialogManager(strings.ClearLabelConfirmationTitle, strings.ClearLabelConfirmationMessage); + dialog.onClosed(async (confirmed?: boolean) => { + if (confirmed === true) { + await onClearingLabel(); + } + }); + await dialog.show(); + } + else if (itemDetails.isRecordTypeLabel && !itemDetails.isRecordLocked) { + const dialog = new AlertDialogManager(strings.CannotClearWhileUnlockedTitle, strings.CannotClearWhileUnlockedMessage); + await dialog.show(); + } + else { + await onClearingLabel(); + } + } + + useEffect(() => { + fetchData().catch((error) => { console.log(error); }); + }, []); + + return <> + + ; +}; diff --git a/src/extensions/retentionControls/loc/en-us.js b/src/extensions/retentionControls/loc/en-us.js index e306a50..90219a8 100644 --- a/src/extensions/retentionControls/loc/en-us.js +++ b/src/extensions/retentionControls/loc/en-us.js @@ -1,43 +1,80 @@ define([], function () { return { - RetentionControlsHeader: "Retention controls", - RetentionLabelApplied: "Retention label", - RetentionLabelsApplied: "Retention labels", - RetentionLabelApplicationDate: "Applied", - RetentionLabelAppliedBy: "Applied by", - RetentionLabelEventDate: "Event date", - RecordStatus: "Record status", - IsDeleteAllowed: "Delete allowed", + BehaviorDoNotRetain: "Do not retain", BehaviorDuringRetentionPeriod: "Behavior during retention period", - IsMetadataUpdateAllowed: "Metadata update allowed", - IsContentUpdateAllowed: "Content update allowed", - IsLabelUpdateAllowed: "Label update allowed", - ToggleOnText: "Yes", - ToggleOffText: "No", - Locked: "Locked", - Unlocked: "Unlocked", - Toggling: "Toggling...", - Clearing: "Clearing...", - ToggleLockStatus: "Toggle", BehaviorRetain: "Retain", - BehaviorDoNotRetain: "Do not retain", BehaviorRetainAsRecord: "Retain as record", BehaviorRetainAsRegulatoryRecord: "Retain as regulatory record", - NoLabelApplied: "No retention label has been applied to this file/folder", - MultipleItemsSelected: "You have selected {0} items. Select one item at a time for more information.", - ClearLabel: "Clear label", - ClearLabels: "Clear labels", - LabelCleared: "The retention label has been cleared for the selected item(s).", - RecordStatusToggled: "This record has been {0} for editing.", - CloseModal: "Close", - CannotClearWhileUnlocked: "This record is unlocked for editing. Please lock it first before clearing the label.", + CannotClearWhileUnlockedTitle: "Unlocked Record", + CannotClearWhileUnlockedMessage: "This record is unlocked for editing. Please lock it first before clearing the label.", + CheckingItems: "Checking items", ClearErrorForMultipleItems: "The retention label could not be cleared for {0} out of {1} items. Possible reasons may be that the items are unlocked for editing, or that you don't have permission.", ClearErrorForSingleItem: "The retention label could not be cleared for this item. A possible reasons might be that the items are unlocked for editing, or that you don't have permission.", - UnhandledError: "A generic error occurred while executing an HTTP-request", - IsMetadataUpdateAllowedTooltip: "Specifies whether updates to the item metadata (for example, the Title field) are allowed.", - IsDeleteAllowedTooltip: "Specifies whether item deletion is allowed.", - isLabelUpdateAllowedTooltip: "Specifies whether you're allowed to change the retention label on the document.", + Clearing: "Clearing...", + ClearingItems: "Clearing {0} items", + ClearLabel: "Clear label", + ClearLabelWarningTooltip: "Something went wrong while clearing the label. Click here to try again.", + ClearLabels: "Clear all labels", + ClearLabelsTooltip: "Click to clear all labels", + ClearLabelConfirmationTitle: "About folders", + ClearLabelConfirmationMessage: "The selected item is a folder. This operation does not clear the retention label from any content in the folder. It will only make sure new content created in the folder will not automatically get a retention label. Do you want to continue clearing the label from just the folder?", + CloseModal: "Close", + ErrorFetchingData: "An error occurred while fetching data", + FileName: "File", + IsContentUpdateAllowed: "Content update allowed", IsContentUpdateAllowedTooltip: "Specifies whether updates to document content are allowed.", + IsDeleteAllowed: "Delete allowed", + IsDeleteAllowedTooltip: "Specifies whether item deletion is allowed.", + IsFirstPage: "This is the first page", + IsLabelUpdateAllowed: "Label update allowed", + IsLabelUpdateAllowedTooltip: "Specifies whether you're allowed to change the retention label on the document.", + IsLastPage: "This is the last page", + IsMetadataUpdateAllowed: "Metadata update allowed", + IsMetadataUpdateAllowedTooltip: "Specifies whether updates to the item metadata (for example, the Title field) are allowed.", + IsRecordLocked: "Record locked", + ItemsDone: "items done", + LabelCleared: "The retention label has been cleared for the selected item(s).", + LabelClearedForLibrary: "The retention label has been cleared for all items in this library.", + Locked: "Locked", + LockRecords: "Lock all records", + LockRecordsTooltip: "Click to lock all unlocked records", + UnlockRecords: "Unlock all records", + UnlockRecordsTooltip: "Unlock all locked records for editing", + MultipleItemsSelected: "You have selected {0} items. Select one item at a time for more information.", + NoLabelApplied: "No retention label has been applied to this file/folder", + NoLabelsApplied: "No retention labels have been applied to selected files/folders", + NoLabelsAppliedEntireLibrary: "No items with retention labels found in this library", + No: "No", + None: "None", + RecordStatus: "Record status", + RecordStatusToggled: "Record(s) have been {0} for editing.", + RecordStatusToggledEntireLibrary: "All records in this library have been {0} for editing.", RecordStatusTooltip: "Specifies whether the item is locked for editing.", + RetentionControlsHeader: "Retention controls", + RetentionLabelApplicationDate: "Applied", + RetentionLabelApplied: "Retention label", + RetentionLabelAppliedBy: "Applied by", + RetentionLabelEventDate: "Event date", + RetentionLabelsApplied: "Retention labels", + TakeBulkActionsSelectedItems: "Take bulk action (on selected items)", + TakeBulkActionsSelectedItemsTooltip: "Click to take bulk actions on the selected items", + TakeBulkActionsEntireLibrary: "Take bulk action (on entire library)", + TakeBulkActionsEntireLibraryTooltip: "Click to take bulk actions on the entire library", + ToggleLockStatus: "Toggle", + ToggleOffText: "No", + ToggleOnText: "Yes", + Toggling: "Toggling...", + ToggleErrorForMultipleItems: "The record could not be {0} for {1} out of {2} items.", + ToggleErrorForSingleItem: "The record could not be {0} for this item.", + TogglingItems: "Toggling {0} items", + ToggleWarning: "Record status: {0}. An error occurred: {1} Click to try toggling the lock status again.", + ToggleRecordForFolderAlertTitle: "About folders", + ToggleRecordForFolderAlertMessage: "The selected item is a folder and cannot be unlocked.", + ToggleRecordForNonRecordLabelAlertTitle: "Non-record label applied", + ToggleRecordForNonRecordLabelAlertMessage: "The selected item is labelled with a non-record retention label and therefore cannot be locked.", + ToPage: "Navigate to page {0}", + UnhandledError: "A generic error occurred while executing an HTTP-request", + Unlocked: "Unlocked", + Yes: "Yes", }; }); diff --git a/src/extensions/retentionControls/loc/myStrings.d.ts b/src/extensions/retentionControls/loc/myStrings.d.ts index a375984..205c600 100644 --- a/src/extensions/retentionControls/loc/myStrings.d.ts +++ b/src/extensions/retentionControls/loc/myStrings.d.ts @@ -1,43 +1,80 @@ declare interface IRetentionControlsCommandSetStrings { - RetentionControlsHeader: string; - RetentionLabelApplied: string; - RetentionLabelsApplied: string; - RetentionLabelApplicationDate: string; - RetentionLabelAppliedBy: string; - RetentionLabelEventDate: string; - RecordStatus: string; - IsDeleteAllowed: string; + BehaviorDoNotRetain: string; BehaviorDuringRetentionPeriod: string; - IsMetadataUpdateAllowed: string; - IsContentUpdateAllowed: string; - IsLabelUpdateAllowed: string; - ToggleOnText: string; - ToggleOffText: string; - Locked: string; - Unlocked: string; - Toggling: string; - Clearing: string; - ToggleLockStatus: string; BehaviorRetain: string; - BehaviorDoNotRetain: string; BehaviorRetainAsRecord: string; BehaviorRetainAsRegulatoryRecord: string; - NoLabelApplied: string; - MultipleItemsSelected: string; + CannotClearWhileUnlockedTitle: string; + CannotClearWhileUnlockedMessage: string; + CheckingItems: string; + ClearErrorForMultipleItems: string; + ClearErrorForSingleItem: string; + Clearing: string; + ClearingItems: string; ClearLabel: string; + ClearLabelWarningTooltip: string; ClearLabels: string; - LabelCleared: string; - RecordStatusToggled: string; + ClearLabelsTooltip: string; + ClearLabelConfirmationTitle: string; + ClearLabelConfirmationMessage: string; CloseModal: string; - CannotClearWhileUnlocked: string; - ClearErrorForMultipleItems: string; - ClearErrorForSingleItem: string; - UnhandledError: string; - IsMetadataUpdateAllowedTooltip: string; - IsDeleteAllowedTooltip: string; - isLabelUpdateAllowedTooltip: string; + ErrorFetchingData: string; + FileName: string; + IsContentUpdateAllowed: string; IsContentUpdateAllowedTooltip: string; + IsDeleteAllowed: string; + IsDeleteAllowedTooltip: string; + IsFirstPage: string; + IsLabelUpdateAllowed: string; + IsLabelUpdateAllowedTooltip: string; + IsLastPage: string; + IsMetadataUpdateAllowed: string; + IsMetadataUpdateAllowedTooltip: string; + IsRecordLocked: string; + ItemsDone: string; + LabelCleared: string; + LabelClearedForLibrary: string; + Locked: string; + LockRecords: string; + LockRecordsTooltip: string; + UnlockRecords: string; + UnlockRecordsTooltip: string; + MultipleItemsSelected: string; + NoLabelApplied: string; + NoLabelsApplied: string; + NoLabelsAppliedEntireLibrary: string; + No: string; + None: string; + RecordStatus: string; + RecordStatusToggled: string; + RecordStatusToggledEntireLibrary: string; RecordStatusTooltip: string; + RetentionControlsHeader: string; + RetentionLabelApplicationDate: string; + RetentionLabelApplied: string; + RetentionLabelAppliedBy: string; + RetentionLabelEventDate: string; + RetentionLabelsApplied: string; + TakeBulkActionsSelectedItems: string; + TakeBulkActionsSelectedItemsTooltip: string; + TakeBulkActionsEntireLibrary: string; + TakeBulkActionsEntireLibraryTooltip: string; + ToggleLockStatus: string; + ToggleOffText: string; + ToggleOnText: string; + Toggling: string; + ToggleErrorForMultipleItems: string; + ToggleErrorForSingleItem: string; + TogglingItems: string; + ToggleWarning: string; + ToggleRecordForFolderAlertTitle: string; + ToggleRecordForFolderAlertMessage: string; + ToggleRecordForNonRecordLabelAlertTitle: string; + ToggleRecordForNonRecordLabelAlertMessage: string; + ToPage: string; + UnhandledError: string; + Unlocked: string; + Yes: string; } declare module "RetentionControlsCommandSetStrings" { diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..bc92aab --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,18 @@ +import * as strings from "RetentionControlsCommandSetStrings"; +import { ICustomColumn } from "./interfaces/ICustomColumn"; + +export const itemMetadataColumns: ICustomColumn[] = [ + { key: "icon", name: "Icon", fieldName: "icon", minWidth: 16, maxWidth: 16, isResizable: false, isIconOnly: true, iconName: "Page" }, + { key: "name", name: "Name", fieldName: "name", minWidth: 80, maxWidth: 200, isResizable: true, }, + { key: "retentionLabel", name: "Label", fieldName: "retentionLabel", minWidth: 80, maxWidth: 200, isResizable: true }, + { key: "labelAppliedBy", name: "Applied by", fieldName: "labelAppliedBy", minWidth: 80, maxWidth: 200, isResizable: true }, + { key: "labelAppliedDate", name: "Applied", fieldName: "labelAppliedDate", minWidth: 80, maxWidth: 200, isResizable: true }, + { key: "eventDate", name: "Event date", fieldName: "eventDate", minWidth: 80, maxWidth: 200, isResizable: true }, + { key: "behaviorDuringRetentionPeriod", name: "Behavior", fieldName: "name", minWidth: 80, maxWidth: 200, isResizable: true }, + { key: "isDeleteAllowed", name: "Delete", fieldName: "isDeleteAllowed", minWidth: 16, maxWidth: 16, isResizable: false, isIconOnly: true, iconName: "Delete", title: strings.IsDeleteAllowed }, + { key: "isMetadataUpdateAllowed", name: "Metadata update", fieldName: "isMetadataUpdateAllowed", minWidth: 16, maxWidth: 16, isResizable: false, isIconOnly: true, iconName: "PageHeaderEdit", title: strings.IsMetadataUpdateAllowed }, + { key: "isContentUpdateAllowed", name: "Content update", fieldName: "isContentUpdateAllowed", minWidth: 16, maxWidth: 16, isResizable: false, isIconOnly: true, iconName: "PageEdit", title: strings.IsContentUpdateAllowed }, + { key: "isLabelUpdateAllowed", name: "Label update", fieldName: "isLabelUpdateAllowed", minWidth: 16, maxWidth: 16, isResizable: false, isIconOnly: true, iconName: "Tag", title: strings.IsLabelUpdateAllowed }, + { key: "isRecordLocked", name: "Locked", fieldName: "isRecordLocked", minWidth: 16, maxWidth: 16, isResizable: false, isIconOnly: true, iconName: "Lock", title: strings.RecordStatus }, + { key: "clearLabel", name: "clearLabel", fieldName: "clearLabel", minWidth: 16, maxWidth: 16, isResizable: false, isIconOnly: true, iconName: "Untag", title: strings.ClearLabel }, +]; \ No newline at end of file diff --git a/src/shared/interfaces/IBatchErrorResponse.ts b/src/shared/interfaces/IBatchErrorResponse.ts new file mode 100644 index 0000000..fa63dc7 --- /dev/null +++ b/src/shared/interfaces/IBatchErrorResponse.ts @@ -0,0 +1,5 @@ +export interface IBatchItemResponse { + listItemId: number; + success: boolean; + errorMessage?: string; +} \ No newline at end of file diff --git a/src/shared/interfaces/ICustomColumn.ts b/src/shared/interfaces/ICustomColumn.ts new file mode 100644 index 0000000..2927fa1 --- /dev/null +++ b/src/shared/interfaces/ICustomColumn.ts @@ -0,0 +1,5 @@ +import { IColumn } from "@fluentui/react/lib/components/DetailsList"; + +export interface ICustomColumn extends IColumn { + title?: string; +} \ No newline at end of file diff --git a/src/shared/interfaces/IDriveItem.ts b/src/shared/interfaces/IDriveItem.ts new file mode 100644 index 0000000..d133b10 --- /dev/null +++ b/src/shared/interfaces/IDriveItem.ts @@ -0,0 +1,10 @@ +import { IListItemFields } from "./IListItemFields"; +import { IRetentionLabel } from "./IRetentionLabel"; + +export interface IDriveItem { + name: string; + id: string; + parentReference: { path: string }; + listItem: { fields: IListItemFields, id: string, contentType: { id: string, name: string } }; + retentionLabel?: IRetentionLabel; +} \ No newline at end of file diff --git a/src/shared/interfaces/IItemMetadata.ts b/src/shared/interfaces/IItemMetadata.ts new file mode 100644 index 0000000..1bbafde --- /dev/null +++ b/src/shared/interfaces/IItemMetadata.ts @@ -0,0 +1,19 @@ +export interface IItemMetadata { + id: number; + driveItemId: string; + name: string; + path: string; + contentTypeId: string; + isFolder: boolean; + isRecordTypeLabel: boolean; + retentionLabel?: string; + labelAppliedBy?: string; + labelAppliedDate?: string; + eventDate?: string; + behaviorDuringRetentionPeriod?: string; + isDeleteAllowed?: boolean; + isRecordLocked?: boolean; + isMetadataUpdateAllowed?: boolean; + isContentUpdateAllowed?: boolean; + isLabelUpdateAllowed?: boolean; +} \ No newline at end of file diff --git a/src/shared/interfaces/IItemState.ts b/src/shared/interfaces/IItemState.ts new file mode 100644 index 0000000..9c9ca3a --- /dev/null +++ b/src/shared/interfaces/IItemState.ts @@ -0,0 +1,8 @@ + +export interface IItemState { + listItemId: number; + toggling: boolean; + clearing: boolean; + errorToggling?: string | undefined; + errorClearing: boolean; +} diff --git a/src/shared/interfaces/INotification.ts b/src/shared/interfaces/INotification.ts new file mode 100644 index 0000000..93f3acb --- /dev/null +++ b/src/shared/interfaces/INotification.ts @@ -0,0 +1,7 @@ +import { MessageBarType } from "@fluentui/react/lib/MessageBar"; + +export interface INotification { + message: string; + notificationType: MessageBarType; +} + \ No newline at end of file diff --git a/src/shared/interfaces/IPagedDriveItems.ts b/src/shared/interfaces/IPagedDriveItems.ts new file mode 100644 index 0000000..bfd0239 --- /dev/null +++ b/src/shared/interfaces/IPagedDriveItems.ts @@ -0,0 +1,7 @@ +import { IDriveItem } from "./IDriveItem"; + +export interface IPagedDriveItems { + nextLink?: string; + items: IDriveItem[]; +} + \ No newline at end of file diff --git a/src/shared/services/SharePointService.ts b/src/shared/services/SharePointService.ts index 195febd..72d33ba 100644 --- a/src/shared/services/SharePointService.ts +++ b/src/shared/services/SharePointService.ts @@ -1,17 +1,21 @@ -import { ServiceKey, ServiceScope } from "@microsoft/sp-core-library"; +import { Guid, ServiceKey, ServiceScope } from "@microsoft/sp-core-library"; import { SPHttpClient } from "@microsoft/sp-http"; import { PageContext } from "@microsoft/sp-page-context"; import { IRetentionLabel } from "../interfaces/IRetentionLabel"; -import { Warning } from "../Warning"; import * as strings from "RetentionControlsCommandSetStrings"; -import { format } from "@fluentui/react"; import { IListItemFields } from "../interfaces/IListItemFields"; +import { IDriveItem as IDriveItem } from "../interfaces/IDriveItem"; +import { IBatchItemResponse } from "../interfaces/IBatchErrorResponse"; +import { IPagedDriveItems } from "../interfaces/IPagedDriveItems"; export interface ISharePointService { + getPagedDriveItems(listId: string, pageSize?: number): Promise + getPagedDriveItemsUsingNextLink(nextLink: string): Promise + getDriveItems: (listId: string, listItemId: number[]) => Promise; getListItemFields: (listId: string, listItemId: number) => Promise; getRetentionSettings: (listId: string, listItemId: number) => Promise; - clearRetentionLabels: (listItemId: number[]) => Promise; - toggleLockStatus: (listItemId: string, lockStatus: boolean) => Promise; + clearRetentionLabels: (listItemIds: number[]) => Promise; + toggleLockStatus: (listItemIds: number[], lockStatus: boolean) => Promise; } export class SharePointService implements ISharePointService { @@ -27,6 +31,51 @@ export class SharePointService implements ISharePointService { }); } + /** + * Get a recursively paged list of Drive Items with retention labels + */ + public async getPagedDriveItems(listId: string, pageSize: number = 10): Promise { + const driveId = await this.getDriveId(listId); + const requestUrl = `${this._pageContext.site.absoluteUrl}/_api/v2.0/drives/${driveId}/items`; + + const queryStrings = [ + `$filter=retentionLabel/name ne null`, + `$expand=retentionLabel,listItem($select=id,contentType;$expand=fields($select=FileLeafRef,TagEventDate))`, + `$select=id,name,parentReference,retentionLabel,id,listItem`, + `$top=${pageSize}` + ]; + + return await this.executeGetPagedDriveItems(`${requestUrl}?${queryStrings.join("&")}`); + } + + public async getPagedDriveItemsUsingNextLink(nextLink: string): Promise { + return await this.executeGetPagedDriveItems(nextLink); + } + + /** + * Get Drive Items based on a list of item ID's + */ + public async getDriveItems(listId: string, listItemId: number[]): Promise { + const driveId = await this.getDriveId(listId); + const requestUrl = `${this._pageContext.site.absoluteUrl}/_api/v2.0/drives/${driveId}/items`; + const filterString = listItemId.map((id) => `listItem/id eq '${id}'`).join(" or "); + const queryStrings = [ + `$filter=${filterString}`, + `$expand=retentionLabel,listItem($select=id,contentType;$expand=fields($select=FileLeafRef,TagEventDate))`, + `$select=id,name,parentReference,retentionLabel,id,listItem` + ]; + + const response = await this._spoHttpClient.get(`${requestUrl}?${queryStrings.join("&")}`, SPHttpClient.configurations.v1); + + if (!response.ok) { + const error: { error: { message: string } } = await response.json(); + throw new Error(error?.error?.message ?? strings.UnhandledError); + } + + const responseContent: { value: IDriveItem[] } = await response.json(); + return responseContent.value; + } + public async getListItemFields(listId: string, listItemId: number): Promise { const requestUrl = `${this._pageContext.site.absoluteUrl}/_api/web/lists(guid'${listId}')/items(${listItemId})?$select=TagEventDate`; const response = await this._spoHttpClient.get(requestUrl, SPHttpClient.configurations.v1); @@ -66,60 +115,88 @@ export class SharePointService implements ISharePointService { return responseContent; } - public async clearRetentionLabels(listItemIds: number[]): Promise { + public async clearRetentionLabels(listItemIds: number[]): Promise { if (this._pageContext.list === undefined) { throw new Error("List information not available"); } + const listAbsoluteUrl = new URL(this._pageContext.list.serverRelativeUrl, this._pageContext.site.absoluteUrl); const url = `${this._pageContext.site.absoluteUrl}/_api/SP_CompliancePolicy_SPPolicyStoreProxy_SetComplianceTagOnBulkItems`; - const body = { - listUrl: listAbsoluteUrl.href, - complianceTagValue: "", - itemIds: listItemIds, - }; + const batchArray = [...listItemIds]; + const allResponses: IBatchItemResponse[] = []; + + // Loop through the array in batches of 100 items, which is the max amount to post at this endpoint + while (batchArray.length > 0) { + const listItemIdsBatch = batchArray.splice(0, 100); + + const body = { + listUrl: listAbsoluteUrl.href, + complianceTagValue: "", + itemIds: listItemIdsBatch, + }; + + const response = await this._spoHttpClient.post(url, SPHttpClient.configurations.v1, { + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error: { error: { message: string } } = await response.json(); + throw new Error(error?.error?.message ?? strings.UnhandledError); + } - const response = await this._spoHttpClient.post(url, SPHttpClient.configurations.v1, { - body: JSON.stringify(body), - }); + const content: { value?: number[] } = await response.json(); - if (!response.ok) { - const error: { error: { message: string } } = await response.json(); - throw new Error(error?.error?.message ?? strings.UnhandledError); + if (content.value && content.value.length > 0) { + for (const itemId of listItemIdsBatch) { + allResponses.push({ listItemId: itemId, success: content.value.indexOf(itemId) === -1 }); + } + } + else { + for (const itemId of listItemIdsBatch) { + allResponses.push({ listItemId: itemId, success: true }); + } + } } - const content: { value?: number[] } = await response.json(); + return allResponses; + } - if (content.value && content.value.length > 0) { - if (listItemIds.length !== content.value.length) { - throw new Warning(format(strings.ClearErrorForMultipleItems, content.value.length, listItemIds.length)); + public async toggleLockStatus(listItemIds: number[], lockStatus: boolean): Promise { + const absoluteUrl = new URL(this._pageContext.site.absoluteUrl); + const host = absoluteUrl.host; + const requestUrl = `${this._pageContext.site.absoluteUrl}/_api/$batch`; + + const batchArray = [...listItemIds]; + const allResponses: IBatchItemResponse[] = []; + + // Loop through the array in batches of 100 items + while (batchArray.length > 0) { + const listItemIdsBatch = batchArray.splice(0, 100); + const batchId = Guid.newGuid().toString(); + const body = this.buildBatchBody(listItemIdsBatch, lockStatus, batchId) + + const response = await this._spoHttpClient.post(requestUrl, SPHttpClient.configurations.v1, { + body: body, + headers: { + "Content-Type": `multipart/mixed; boundary="batch_${batchId}"`, + "Content-Transfer-Encoding": "binary", + "Host": host + }, + }); + + if (!response.ok) { + const error: { error: { message: string } } = await response.json(); + throw new Error(error?.error?.message ?? strings.UnhandledError); } - throw new Error(strings.ClearErrorForSingleItem); - } - } - - public async toggleLockStatus(listItemId: string, lockStatus: boolean): Promise { - if (this._pageContext.list === undefined) { - throw new Error("List information not available"); + const responseContent = await response.text(); + const responses = this.parseBatchResponseBody(responseContent, listItemIdsBatch); + allResponses.push(...responses); } - const url = lockStatus ? `${this._pageContext.site.absoluteUrl}/_api/SP.CompliancePolicy.SPPolicyStoreProxy.LockRecordItem()` : `${this._pageContext.site.absoluteUrl}/_api/SP.CompliancePolicy.SPPolicyStoreProxy.UnlockRecordItem()`; - - const body = { - listUrl: this._pageContext.list.serverRelativeUrl, - itemId: listItemId, - }; - - const response = await this._spoHttpClient.post(url, SPHttpClient.configurations.v1, { - body: JSON.stringify(body), - }); - - if (!response.ok) { - const error: { error: { message: string } } = await response.json(); - throw new Error(error?.error?.message ?? strings.UnhandledError); - } + return allResponses; } private async getDriveId(listId: string): Promise { @@ -161,4 +238,69 @@ export class SharePointService implements ISharePointService { return responseContent.value[0].id; } + + private buildBatchBody(listItemIds: number[], lockStatus: boolean, batchId: string): string { + const serverRelativeUrl = this._pageContext.list?.serverRelativeUrl; + if (serverRelativeUrl === undefined) { + throw new Error("List information not available"); + } + + const batchUrl = lockStatus ? `${this._pageContext.site.absoluteUrl}/_api/SP.CompliancePolicy.SPPolicyStoreProxy.LockRecordItem()` : `${this._pageContext.site.absoluteUrl}/_api/SP.CompliancePolicy.SPPolicyStoreProxy.UnlockRecordItem()`; + const changeSetId = Guid.newGuid().toString(); + const batchBody: string[] = []; + + batchBody.push(`--batch_${batchId}\n`); + batchBody.push(`Content-Type: multipart/mixed; boundary="changeset_${changeSetId}"\n\n`); + batchBody.push('Content-Transfer-Encoding: binary\n\n'); + + listItemIds.forEach((listItemId, index) => { + batchBody.push(`--changeset_${changeSetId}\n`); + batchBody.push(`Content-Type: application/http\n`); + batchBody.push(`Content-ID: ${index}\n`); + batchBody.push(`Content-Transfer-Encoding: binary\n\n`); + batchBody.push(`POST ${batchUrl} HTTP/1.1\n`); + batchBody.push(`Accept: application/json\n`); + batchBody.push(`Content-Type: application/json;odata=nometadata\n\n`); + batchBody.push(`{ "listUrl": "${serverRelativeUrl}", "itemId": ${listItemId} }\n\n`); + batchBody.push(``); + }); + + batchBody.push(`--changeset_${changeSetId}--\n\n`); + batchBody.push(`--batch_${batchId}--\n`); + + return batchBody.join(''); + } + + private parseBatchResponseBody(response: string, listItemIds: number[]): IBatchItemResponse[] { + const responses: IBatchItemResponse[] = []; + + response.split('\r\n') + .filter((line: string) => line.indexOf('{') === 0) + .forEach((line: string, index: number) => { + const parsedResponse = JSON.parse(line); + + if (parsedResponse.error) { + // if an error object is returned, the request failed + const error = parsedResponse.error as { message: string }; + responses.push({ errorMessage: error.message, listItemId: listItemIds[index], success: false }); + } + else { + responses.push({ listItemId: listItemIds[index], success: true }); + } + }); + + return responses; + } + + private async executeGetPagedDriveItems(requestUrl: string): Promise { + const response = await this._spoHttpClient.get(requestUrl, SPHttpClient.configurations.v1); + + if (!response.ok) { + const error: { error: { message: string } } = await response.json(); + throw new Error(error?.error?.message ?? strings.UnhandledError); + } + + const responseContent: { value: IDriveItem[] } = await response.json(); + return { nextLink: (responseContent as never)['@odata.nextLink'], items: responseContent.value }; + } } diff --git a/src/shared/styles.ts b/src/shared/styles.ts new file mode 100644 index 0000000..1865c28 --- /dev/null +++ b/src/shared/styles.ts @@ -0,0 +1,46 @@ +import { IDialogFooterStyles } from "@fluentui/react/lib/Dialog"; +import { IMessageBarStyles } from "@fluentui/react/lib/MessageBar"; +import { IStackItemStyles, IStackTokens } from "@fluentui/react/lib/Stack"; +import { mergeStyles, mergeStyleSets } from "@fluentui/react/lib/Styling"; + +export const messageBarStyles: IMessageBarStyles = { + root: { + marginBottom: "10px", + }, +}; + +export const stackItemStyles: IStackItemStyles = { + root: { + alignItems: "center", + display: "flex", + width: "250px", + }, +}; + +export const dialogFooterStyles: IDialogFooterStyles = { + action: { }, + actions: {}, + actionsRight: { + justifyContent: "space-between", + display: "flex" + }, +}; + +export const stackTokens: IStackTokens = { + childrenGap: 5, +}; + +export const iconClass = mergeStyles({ + fontSize: 14, + height: 14, + width: 14, + margin: "0 0 0 0", + cursor: "pointer", +}); + +export const classNames = mergeStyleSets({ + green: [{ color: "darkgreen" }, iconClass], + red: [{ color: "indianred" }, iconClass], + blue: [{ color: "#28a8ea" }, iconClass], + dark: [{ }, iconClass], +}); diff --git a/src/shared/utils.ts b/src/shared/utils.ts new file mode 100644 index 0000000..af9a9b9 --- /dev/null +++ b/src/shared/utils.ts @@ -0,0 +1,62 @@ +import { IDriveItem } from "./interfaces/IDriveItem"; +import { IItemMetadata } from "./interfaces/IItemMetadata"; + +export const getBehaviorLabel = (behavior: string | undefined): string => { + switch (behavior) { + case "retain": + return "Retain"; + case "doNotRetain": + return "Do not retain"; + case "retainAsRecord": + return "Retain as record"; + case "retainAsRegulatoryRecord": + return "Retain as regulatory record"; + default: + return "N/A"; + } +}; + +export const isRecordTypeLabel = (behavior: string | undefined): boolean => { + return behavior === "retainAsRecord" || behavior === "retainAsRegulatoryRecord"; +} + +export const flattenItemMetadata = (item: IDriveItem | undefined): IItemMetadata | undefined => { + if (item === undefined) { + return undefined; + } + + const siteRelativeFolder = item.parentReference.path.split(":").pop()?.replace(/^\//,''); + const libraryRelativePath = siteRelativeFolder ? `${siteRelativeFolder}/${item.name}` : item.name; + + return { + id: parseFloat(item.listItem.id), + driveItemId: item.id, + name: item.name, + path: libraryRelativePath, + contentTypeId: item.listItem.contentType.id, + isFolder: item.listItem.contentType.id.indexOf("0x0120") !== -1, + isRecordTypeLabel: isRecordTypeLabel(item.retentionLabel?.retentionSettings?.behaviorDuringRetentionPeriod), + retentionLabel: item.retentionLabel?.name, + labelAppliedBy: item?.retentionLabel?.labelAppliedBy?.user?.displayName || (item?.retentionLabel?.labelAppliedBy as { application?: { displayName: string } })?.application?.displayName, + labelAppliedDate: item?.retentionLabel?.labelAppliedDateTime ? new Date(item?.retentionLabel?.labelAppliedDateTime).toLocaleDateString() : undefined, + eventDate: item?.listItem.fields.TagEventDate !== undefined && item?.listItem.fields.TagEventDate?.indexOf("9999") === -1 ? new Date(item?.listItem.fields.TagEventDate).toLocaleDateString() : undefined, + behaviorDuringRetentionPeriod: item.retentionLabel?.retentionSettings?.behaviorDuringRetentionPeriod, + isDeleteAllowed: item.retentionLabel?.retentionSettings?.isDeleteAllowed, + isRecordLocked: item.retentionLabel?.retentionSettings?.isRecordLocked, + isMetadataUpdateAllowed: item.retentionLabel?.retentionSettings?.isMetadataUpdateAllowed, + isContentUpdateAllowed: item.retentionLabel?.retentionSettings?.isContentUpdateAllowed, + isLabelUpdateAllowed: item.retentionLabel?.retentionSettings?.isLabelUpdateAllowed + } as IItemMetadata; +}; + +export const flattenItemMetadataList = (items: IDriveItem[] | undefined): IItemMetadata[] => { + return items ? items.filter(i => i.retentionLabel?.name !== undefined).map(item => flattenItemMetadata(item) as IItemMetadata) : []; +}; + +export const updateObjectProperties = (original: T, updated: T): void => { + for (const key in updated) { + if (Object.prototype.hasOwnProperty.call(original, key)) { + (original as never)[key] = (updated as never)[key]; + } + } +}; \ No newline at end of file