commit c1f1d02d9c76382539c42aae5d521d38e78e751a Author: Torsten Brendgen Date: Thu Jul 16 21:55:30 2026 +0200 Add Expiry Indicator functionality with configuration and command set - Implemented ExpiryItemService to manage expiry date logic for list items. - Created ExpiryModels to define types and structures for expiry configurations and rules. - Added RestError utility for handling API response errors. - Developed ExpiryIndicatorFieldCustomizer to display expiry information in list views. - Introduced ExpiryIndicatorCommandSet for extending expiry dates and managing settings. - Added settings dialog for configuring expiry settings. - Implemented localization support for English and German languages. - Added TypeScript configuration files for project setup. - Included TSLint configuration for code quality enforcement. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15193f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +lib/ +dist/ +temp/ +release/ +*.sppkg +*.tgz +npm-debug.log* + diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..39f8b93 --- /dev/null +++ b/.yo-rc.json @@ -0,0 +1,12 @@ +{ + "@microsoft/generator-sharepoint": { + "version": "1.4.1", + "libraryName": "expiry-indicator", + "libraryId": "68bb6d2d-9895-45b4-88e8-b98835faa981", + "environment": "onprem", + "packageManager": "npm", + "solutionName": "expiry-indicator", + "componentType": "extension" + } +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0da4b87 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# ExpiryIndicator + +SPFx-1.4.1-Solution für moderne Listen und Dokumentbibliotheken in SharePoint Server Subscription Edition. + +## Funktionen + +- Farbliche Anzeige einer vorhandenen, frei wählbaren Ablaufdatumsspalte. +- Fallback-Berechnung aus einem konfigurierbaren Erstellungsfeld und `defaultLifetime`. +- Priorisierte Regeln mit eigener Laufzeit und eigenen Farbschwellen anhand vorhandener Feldwerte. +- Command-Bar-Befehl zum Verlängern eines oder mehrerer Elemente um ein Kalenderjahr. +- Pro Liste/Bibliothek gespeicherte Konfiguration. + +Die Solution provisioniert ausdrücklich keine fachliche Ablaufdatumsspalte und keinen Content Type. + +## Regelauswertung + +`rules` werden von oben nach unten geprüft. Für jede Regel gilt: + +1. Das konfigurierte interne Feld muss in der Liste/Bibliothek existieren. +2. Erst dann wird sein Wert geprüft. +3. Die erste Regel mit passendem `columnValue` bestimmt `lifeTime` und `columnRule`. +4. Wenn keine Regel passt oder keines der Regelfelder vorhanden ist, gilt der gesamte `default`-Block. + +Beispiel: + +```json +{ + "baseField": "Created", + "expiryField": "ExpiryDate", + "default": { + "lifeTime": { "value": 2, "unit": "years" }, + "columnRule": [ + { + "daysUntilExpiry": 0, + "operator": "lessOrEqual", + "backgroundColor": "#a4262c", + "textColor": "#ffffff", + "label": "Abgelaufen" + } + ] + }, + "rules": [ + { + "columnName": "DataPrivacy", + "columnValue": "PersDat1", + "lifeTime": { "value": 1, "unit": "years" }, + "columnRule": [ + { + "daysUntilExpiry": 30, + "operator": "lessOrEqual", + "backgroundColor": "#ffaa44", + "textColor": "#000000", + "label": "Läuft bald ab" + } + ] + } + ] +} +``` + +## Build + +SPFx 1.4.1 verwendet die Legacy-Toolchain. Unterstützt wird Node.js 6 oder 8; empfohlen wird für reproduzierbare Builds Node.js 8.17.0. + +```powershell +npm install +npm run package +``` + +Das Paket wird als `sharepoint/solution/expiry-indicator.sppkg` erzeugt. + +## Installation + +1. `expiry-indicator.sppkg` in den App Catalog der On-Premises-Farm laden. +2. Die App in der gewünschten Site installieren. +3. Eine moderne Liste/Bibliothek öffnen und über **Expiry-Einstellungen** interne Feldnamen, Standardlaufzeit, Policies und Farben konfigurieren. +4. Den Field Customizer mit der vorhandenen Ablaufdatumsspalte verbinden: + +```powershell +.\scripts\Enable-ExpiryIndicator.ps1 ` + -SiteUrl 'https://sharepoint/sites/fachbereich' ` + -ListTitle 'Dokumente' ` + -ExpiryFieldInternalName 'CustomerExpiryDate' +``` + +Das Skript ist in einer SharePoint Management Shell beziehungsweise auf einem Rechner mit den passenden SharePoint-CSOM-Assemblies und Zugriff auf die Site auszuführen. + +## Deaktivierung + +```powershell +.\scripts\Disable-ExpiryIndicator.ps1 ` + -SiteUrl 'https://sharepoint/sites/fachbereich' ` + -ListTitle 'Dokumente' ` + -ExpiryFieldInternalName 'CustomerExpiryDate' +``` + +Das Skript entfernt ausschließlich die Field-Customizer-Verknüpfung. Die Spalte und alle fachlichen Daten bleiben erhalten. Die Command-Set-Registrierung wird durch das Entfernen der App aus der Site beseitigt. diff --git a/ToDo.md b/ToDo.md new file mode 100644 index 0000000..ba750b1 --- /dev/null +++ b/ToDo.md @@ -0,0 +1,128 @@ +# ExpiryIndicator – Vorgehen + +## Ziel + +Eine SharePoint-Framework-Solution für SharePoint Server Subscription Edition auf Basis von SPFx 1.4.1. Die App stellt für moderne Listen und Dokumentbibliotheken Folgendes bereit: + +- farbliche Kennzeichnung einer frei konfigurierbaren vorhandenen Datumsspalte, +- Berechnung eines effektiven Ablaufdatums aus `Created`, wenn `ExpiryDate` leer ist, +- frei konfigurierbare Laufzeit-, Schwellenwert- und Farbregeln pro Liste/Bibliothek, +- einen Befehl **„Ablaufdatum +1 Jahr“** für ein oder mehrere ausgewählte Elemente, +- einen nur für Listenverwalter sichtbaren Befehl **„Expiry-Einstellungen“**. + +## Wichtige Randbedingung + +Die fachliche Ablaufdatumsspalte wird **nicht** durch diese Solution angelegt oder geändert. Sie stammt aus dem Content Type Hub und kann bei jedem Kunden einen anderen internen Namen besitzen. Der interne Name wird pro Liste/Bibliothek konfiguriert und beim Aktivieren an das Skript übergeben. Falls das konfigurierte Feld fehlt, bleibt der Verlängerungsbefehl deaktiviert beziehungsweise verborgen; der Einstellungsbefehl bleibt für Listenverwalter verfügbar. + +## Technischer Aufbau + +### 1. Field Customizer + +`ExpiryIndicatorFieldCustomizer` wird an die vorhandene Spalte `ExpiryDate` gebunden und rendert deren Zelle. + +Berechnung des effektiven Ablaufdatums: + +1. Ist das konfigurierte Ablaufdatumsfeld gesetzt, wird dieser Wert verwendet. +2. Ist es leer, werden die fachlichen `rules` in ihrer konfigurierten Reihenfolge ausgewertet. +3. Vor jeder Wertprüfung wird geprüft, ob `columnName` in der aktuellen Liste/Bibliothek existiert. Ein fehlendes Feld kann die Regel nicht erfüllen. +4. Existiert das Feld und entspricht sein Wert `columnValue`, werden `lifeTime` und `columnRule` dieser Regel verwendet. +5. Passt keine Regel oder existiert keines der Regelfelder, werden `default.lifeTime` und `default.columnRule` verwendet. +6. Die erste passende `columnRule` bestimmt Hintergrundfarbe, Textfarbe und optionale Beschriftung. + +Die Berechnung arbeitet mit Kalenderjahren/-monaten/-tagen, nicht mit pauschalen 365-Tage-Jahren. + +### 2. ListView Command Set + +`ExpiryIndicatorCommandSet` stellt in der modernen Befehlsleiste zwei Aktionen bereit: + +- **Ablaufdatum +1 Jahr** + - sichtbar, wenn mindestens ein Element gewählt wurde und `ExpiryDate` vorhanden ist, + - unterstützt Einzel- und Mehrfachauswahl, + - liest `Created` und `ExpiryDate` serverseitig über REST, + - verwendet bei leerem `ExpiryDate` zunächst das berechnete effektive Ablaufdatum, + - addiert ein Kalenderjahr und schreibt das Ergebnis nach `ExpiryDate`, + - zeigt eine Zusammenfassung über erfolgreiche und fehlgeschlagene Änderungen. +- **Expiry-Einstellungen** + - nur sichtbar für Benutzer mit `ManageLists`-Berechtigung, + - öffnet einen Konfigurationsdialog für die aktuelle Liste/Bibliothek. + +### 3. Konfiguration + +Die Konfiguration wird pro Liste anhand ihrer GUID gespeichert. Dafür verwendet die App eine eigene, versteckte Konfigurationsliste im aktuellen Web. Diese technische Liste darf von der Solution beziehungsweise beim ersten Speichern der Einstellungen erzeugt werden; die fachliche Spalte `ExpiryDate` bleibt davon unberührt. + +Konfigurierbar sind mindestens: + +- interner Name des Erstellungsfeldes, Standard `Created`, +- interner Name des Ablaufdatums, Standard `ExpiryDate`, +- Standardlaufzeit unter `default.lifeTime` als Wert und Einheit (`days`, `months`, `years`), +- Standard-Farbregeln unter `default.columnRule`, +- priorisierte fachliche Regeln mit `columnName`, `columnValue`, eigener `lifeTime` und eigenen `columnRule`-Einträgen, +- beliebig viele Farbregeln mit Operator, Tagesgrenze, Hintergrundfarbe, Textfarbe und Beschriftung, +- Darstellung bei fehlenden oder ungültigen Datumswerten, +- Bestätigungsdialog vor der Verlängerung. + +Regeln werden deklarativ gespeichert. Es wird kein JavaScript aus der Konfiguration mit `eval` oder ähnlichen Mechanismen ausgeführt. + +Beispiel einer Laufzeitkonfiguration: + +```json +{ + "baseField": "Created", + "expiryField": "ExpiryDate", + "default": { + "lifeTime": { "value": 3, "unit": "years" }, + "columnRule": [] + }, + "rules": [ + { + "columnName": "DataPrivacy", + "columnValue": "PersDat1", + "lifeTime": { "value": 1, "unit": "years" }, + "columnRule": [] + }, + { + "columnName": "DataPrivacy", + "columnValue": "PersDat2", + "lifeTime": { "value": 3, "unit": "years" }, + "columnRule": [] + } + ] +} +``` + +### 4. Aktivierung und Deployment + +- Erstellung eines `.sppkg`-Pakets mit eingebetteten Client-Side Assets. +- Registrierung des Command Sets für moderne generische Listen und Dokumentbibliotheken. +- Keine Provisionierung fachlicher Ablaufdatumsspalten, Site Columns oder Content Types. +- Bereitstellung eines PowerShell-/CSOM-Aktivierungsskripts, das den Field Customizer mit einer als Parameter angegebenen vorhandenen Ablaufdatumsspalte verknüpft. +- Bereitstellung eines entsprechenden Deaktivierungsskripts, das nur diese Verknüpfung und App-Custom-Actions entfernt, nicht aber fachliche Daten oder Spalten. + +## Umsetzungsschritte + +- [x] Architektur und Randbedingungen dokumentieren. +- [ ] SPFx-1.4.1-Projektstruktur anlegen. +- [ ] gemeinsame Modelle, Datumsberechnung und Regel-Auswertung implementieren. +- [ ] Regel-Engine für feldwertabhängige Laufzeiten und Farbschwellen implementieren. +- [ ] REST-Service für Konfiguration und Elementaktualisierungen implementieren. +- [ ] Field Customizer implementieren. +- [ ] ListView Command Set und Einstellungsdialog implementieren. +- [ ] Feature-XML für Command-Bar-Registrierung erstellen. +- [ ] CSOM-Aktivierungs- und Deaktivierungsskripte für die vorhandene `ExpiryDate`-Spalte erstellen. +- [ ] Lokalisierung Deutsch/Englisch ergänzen. +- [ ] Build mit der SPFx-1.4.1-kompatiblen Legacy-Toolchain ausführen. +- [ ] `.sppkg` erzeugen und Installationsanleitung ergänzen. +- [ ] Tests für Kalenderarithmetik, Schwellwerte und leere Ablaufdaten durchführen. + +## Abnahmekriterien + +- Die Solution enthält und provisioniert keine Definition für eine fachliche Ablaufdatumsspalte. +- Erstellungsfeld, Ablaufdatumsfeld und Bedingungsfelder sind über interne Feldnamen konfigurierbar. +- Unterschiedliche Laufzeiten und Farbschwellen können anhand priorisierter Feldwertregeln bestimmt werden. +- Die Anzeige funktioniert in modernen Ansichten von Listen und Dokumentbibliotheken. +- Regeln und Farben sind pro Liste/Bibliothek ohne Codeänderung konfigurierbar. +- Leere Ablaufdaten werden aus `Created` und der Grundlaufzeit berechnet. +- Der Verlängerungsbefehl erhöht das effektive Ablaufdatum um genau ein Kalenderjahr. +- Mehrfachauswahl führt nicht zum vollständigen Abbruch, wenn einzelne Elemente nicht aktualisiert werden können. +- Benutzer ohne Bearbeitungsrechte können keine Elemente verlängern. +- Nur Listenverwalter können die Konfiguration ändern. diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..76f3be3 --- /dev/null +++ b/config/config.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json", + "version": "2.0", + "bundles": { + "expiry-indicator-field-customizer": { + "components": [ + { + "entrypoint": "./lib/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.js", + "manifest": "./src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.manifest.json" + } + ] + }, + "expiry-indicator-command-set": { + "components": [ + { + "entrypoint": "./lib/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.js", + "manifest": "./src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json" + } + ] + } + }, + "externals": {}, + "localizedResources": { + "ExpiryIndicatorStrings": "lib/extensions/expiryIndicator/loc/{locale}.js", + "ExpiryIndicatorCommandSetStrings": "lib/extensions/expiryIndicatorCommandSet/loc/{locale}.js" + } +} + diff --git a/config/copy-assets.json b/config/copy-assets.json new file mode 100644 index 0000000..0c899a2 --- /dev/null +++ b/config/copy-assets.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", + "deployCdnPath": "temp/deploy" +} + diff --git a/config/package-solution.json b/config/package-solution.json new file mode 100644 index 0000000..9f4b83f --- /dev/null +++ b/config/package-solution.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json", + "solution": { + "name": "expiry-indicator-client-side-solution", + "id": "68bb6d2d-9895-45b4-88e8-b98835faa981", + "version": "1.0.0.0", + "includeClientSideAssets": true, + "skipFeatureDeployment": false, + "features": [ + { + "title": "ExpiryIndicator command set registration", + "description": "Registers ExpiryIndicator commands for modern lists and document libraries. Does not provision ExpiryDate.", + "id": "913402af-ab9a-4974-9f86-5c2159ae41db", + "version": "1.0.0.0", + "assets": { + "elementManifests": [ + "elements.xml" + ] + } + } + ] + }, + "paths": { + "zippedPackage": "solution/expiry-indicator.sppkg" + } +} + diff --git a/config/serve.json b/config/serve.json new file mode 100644 index 0000000..ee06040 --- /dev/null +++ b/config/serve.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json", + "port": 4321, + "https": true, + "serveConfigurations": { + "fieldCustomizer": { + "pageUrl": "https://sharepoint/sites/demo/Lists/Test/AllItems.aspx", + "fieldCustomizers": { + "ExpiryDate": { + "id": "a555f4fc-d6a6-4421-8189-457449d9bbde", + "properties": {} + } + } + }, + "commandSet": { + "pageUrl": "https://sharepoint/sites/demo/Lists/Test/AllItems.aspx", + "customActions": { + "cd58f5d9-ffc8-4df7-a910-3054842d7abf": { + "location": "ClientSideExtension.ListViewCommandSet.CommandBar", + "properties": {} + } + } + } + } +} + diff --git a/config/write-manifests.json b/config/write-manifests.json new file mode 100644 index 0000000..bc669ad --- /dev/null +++ b/config/write-manifests.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", + "cdnBasePath": "" +} + diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..7e9e979 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,6 @@ +'use strict'; + +const build = require('@microsoft/sp-build-web'); + +build.initialize(require('gulp')); + diff --git a/package.json b/package.json new file mode 100644 index 0000000..96a3542 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "expiry-indicator", + "version": "1.0.0", + "private": true, + "engines": { + "node": ">=6.9.0 <9.0.0" + }, + "scripts": { + "build": "gulp bundle", + "clean": "gulp clean", + "package-solution": "gulp package-solution", + "package": "gulp clean && gulp bundle --ship && gulp package-solution --ship", + "test": "gulp test" + }, + "dependencies": { + "@microsoft/sp-core-library": "1.4.1", + "@microsoft/sp-dialog": "1.4.1", + "@microsoft/sp-http": "1.4.1", + "@microsoft/sp-listview-extensibility": "1.4.1", + "@types/es6-promise": "0.0.33", + "@types/webpack-env": "1.13.1" + }, + "devDependencies": { + "@microsoft/sp-build-web": "1.4.1", + "@microsoft/sp-module-interfaces": "1.4.1", + "@microsoft/sp-tslint-rules": "1.4.1", + "@types/chai": "3.4.34", + "@types/mocha": "2.2.38", + "ajv": "5.2.2", + "gulp": "~3.9.1" + } +} + diff --git a/scripts/Disable-ExpiryIndicator.ps1 b/scripts/Disable-ExpiryIndicator.ps1 new file mode 100644 index 0000000..0b9bdda --- /dev/null +++ b/scripts/Disable-ExpiryIndicator.ps1 @@ -0,0 +1,44 @@ +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)] + [string]$SiteUrl, + + [Parameter(Mandatory = $true)] + [string]$ListTitle, + + [Parameter(Mandatory = $true)] + [string]$ExpiryFieldInternalName, + + [Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde' +) + +$isapiPath = Join-Path ([Environment]::GetFolderPath('CommonProgramFiles')) 'microsoft shared\Web Server Extensions\16\ISAPI' +Add-Type -Path (Join-Path $isapiPath 'Microsoft.SharePoint.Client.Runtime.dll') +Add-Type -Path (Join-Path $isapiPath 'Microsoft.SharePoint.Client.dll') + +$context = New-Object Microsoft.SharePoint.Client.ClientContext($SiteUrl) +$context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials + +try { + $list = $context.Web.Lists.GetByTitle($ListTitle) + $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) + $context.Load($list) + $context.Load($field) + $context.ExecuteQuery() + + if ($field.ClientSideComponentId -ne $FieldCustomizerComponentId) { + throw "Das Feld '$($field.InternalName)' ist nicht mit dem erwarteten ExpiryIndicator-Field-Customizer verbunden." + } + + if ($PSCmdlet.ShouldProcess("$($list.Title)/$($field.InternalName)", 'Field-Customizer-Verknüpfung entfernen')) { + $field.ClientSideComponentId = [Guid]::Empty + $field.ClientSideComponentProperties = '' + $field.Update() + $context.ExecuteQuery() + Write-Host "ExpiryIndicator-Verknüpfung wurde entfernt. Feld und Daten bleiben unverändert." + } +} +finally { + $context.Dispose() +} + diff --git a/scripts/Enable-ExpiryIndicator.ps1 b/scripts/Enable-ExpiryIndicator.ps1 new file mode 100644 index 0000000..87fae5b --- /dev/null +++ b/scripts/Enable-ExpiryIndicator.ps1 @@ -0,0 +1,43 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$SiteUrl, + + [Parameter(Mandatory = $true)] + [string]$ListTitle, + + [Parameter(Mandatory = $true)] + [string]$ExpiryFieldInternalName, + + [Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde' +) + +$isapiPath = Join-Path ([Environment]::GetFolderPath('CommonProgramFiles')) 'microsoft shared\Web Server Extensions\16\ISAPI' +Add-Type -Path (Join-Path $isapiPath 'Microsoft.SharePoint.Client.Runtime.dll') +Add-Type -Path (Join-Path $isapiPath 'Microsoft.SharePoint.Client.dll') + +$context = New-Object Microsoft.SharePoint.Client.ClientContext($SiteUrl) +$context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials + +try { + $list = $context.Web.Lists.GetByTitle($ListTitle) + $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) + $context.Load($list) + $context.Load($field) + $context.ExecuteQuery() + + if ($field.InternalName -ne $ExpiryFieldInternalName) { + Write-Warning "Das Feld wurde über seinen Anzeigenamen gefunden. Für eine stabile Konfiguration sollte der interne Name '$($field.InternalName)' verwendet werden." + } + + $field.ClientSideComponentId = $FieldCustomizerComponentId + $field.ClientSideComponentProperties = '{}' + $field.Update() + $context.ExecuteQuery() + + Write-Host "ExpiryIndicator wurde für '$($list.Title)' an '$($field.InternalName)' gebunden." +} +finally { + $context.Dispose() +} + diff --git a/sharepoint/assets/elements.xml b/sharepoint/assets/elements.xml new file mode 100644 index 0000000..229e14e --- /dev/null +++ b/sharepoint/assets/elements.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/common/ExpiryConfigService.ts b/src/common/ExpiryConfigService.ts new file mode 100644 index 0000000..fbdbe6a --- /dev/null +++ b/src/common/ExpiryConfigService.ts @@ -0,0 +1,214 @@ +import { + ISPHttpClientOptions, + SPHttpClient, + SPHttpClientResponse +} from '@microsoft/sp-http'; +import { createDefaultConfig, IExpiryConfig, normalizeConfig } from './ExpiryModels'; +import { escapeODataString, responseError } from './RestError'; + +interface IConfigurationListInfo { + id: string; + entityType: string; +} + +interface IConfigurationItem { + Id: number; + Title: string; + ExpiryIndicatorJson: string; +} + +const CONFIGURATION_LIST_TITLE: string = '_ExpiryIndicatorConfiguration'; +const CONFIGURATION_FIELD: string = 'ExpiryIndicatorJson'; + +export class ExpiryConfigService { + private readonly _spHttpClient: SPHttpClient; + private readonly _webUrl: string; + + public constructor(spHttpClient: SPHttpClient, webUrl: string) { + this._spHttpClient = spHttpClient; + this._webUrl = webUrl.replace(/\/$/, ''); + } + + public getConfig(listId: string): Promise { + return this._getConfigurationList(false).then((list: IConfigurationListInfo | undefined): Promise => { + if (!list) { + return Promise.resolve(createDefaultConfig()); + } + + return this._getConfigurationItem(listId).then((item: IConfigurationItem | undefined): IExpiryConfig => { + if (!item || !item[CONFIGURATION_FIELD]) { + return createDefaultConfig(); + } + + try { + return normalizeConfig(JSON.parse(item[CONFIGURATION_FIELD])); + } catch (error) { + return createDefaultConfig(); + } + }); + }); + } + + public saveConfig(listId: string, config: IExpiryConfig): Promise { + const normalized: IExpiryConfig = normalizeConfig(config); + return this._getConfigurationList(true).then((list: IConfigurationListInfo): Promise => { + return this._getConfigurationItem(listId).then((item: IConfigurationItem | undefined): Promise => { + const body: any = { + '__metadata': { 'type': list.entityType }, + 'Title': listId, + 'ExpiryIndicatorJson': JSON.stringify(normalized) + }; + + const options: ISPHttpClientOptions = { + headers: this._headers(item ? 'MERGE' : undefined), + body: JSON.stringify(body) + }; + const url: string = this._configurationListUrl() + '/items' + (item ? '(' + item.Id + ')' : ''); + + return this._spHttpClient.post(url, SPHttpClient.configurations.v1, options) + .then((response: SPHttpClientResponse): Promise => { + if (!response.ok) { + return responseError(response, 'Konfiguration konnte nicht gespeichert werden') + .then((error: Error): Promise => Promise.reject(error)); + } + return Promise.resolve(); + }); + }); + }); + } + + private _getConfigurationList(createIfMissing: boolean): Promise { + const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName'; + return this._spHttpClient.get(url, SPHttpClient.configurations.v1) + .then((response: SPHttpClientResponse): Promise => { + if (response.status === 404) { + return createIfMissing ? this._createConfigurationList() : Promise.resolve(undefined); + } + if (!response.ok) { + return responseError(response, 'Konfigurationsliste konnte nicht gelesen werden') + .then((error: Error): Promise => Promise.reject(error)); + } + return response.json().then((data: any): IConfigurationListInfo => this._mapListInfo(data)); + }); + } + + private _createConfigurationList(): Promise { + const body: any = { + '__metadata': { 'type': 'SP.List' }, + 'AllowContentTypes': false, + 'BaseTemplate': 100, + 'ContentTypesEnabled': false, + 'Description': 'Technische Konfiguration der ExpiryIndicator-App.', + 'Title': CONFIGURATION_LIST_TITLE + }; + const options: ISPHttpClientOptions = { + headers: this._headers(), + body: JSON.stringify(body) + }; + + return this._spHttpClient.post(this._webUrl + '/_api/web/lists', SPHttpClient.configurations.v1, options) + .then((response: SPHttpClientResponse): Promise => { + if (!response.ok) { + // Another request may have created the list in parallel. + return this._getConfigurationList(false).then((existing: IConfigurationListInfo | undefined): any => { + if (existing) { + return existing; + } + return responseError(response, 'Konfigurationsliste konnte nicht erstellt werden') + .then((error: Error): Promise => Promise.reject(error)); + }); + } + + return this._createConfigurationField() + .then((): Promise => this._hideConfigurationList()) + .then((): Promise => this._getConfigurationList(false)); + }); + } + + private _createConfigurationField(): Promise { + const body: any = { + '__metadata': { 'type': 'SP.Field' }, + 'FieldTypeKind': 3, + 'Required': false, + 'Title': CONFIGURATION_FIELD + }; + const options: ISPHttpClientOptions = { + headers: this._headers(), + body: JSON.stringify(body) + }; + + return this._spHttpClient.post( + this._configurationListUrl() + '/fields', + SPHttpClient.configurations.v1, + options + ).then((response: SPHttpClientResponse): Promise => { + if (!response.ok) { + return responseError(response, 'Konfigurationsfeld konnte nicht erstellt werden') + .then((error: Error): Promise => Promise.reject(error)); + } + return Promise.resolve(); + }); + } + + private _hideConfigurationList(): Promise { + const options: ISPHttpClientOptions = { + headers: this._headers('MERGE'), + body: JSON.stringify({ + '__metadata': { 'type': 'SP.List' }, + 'Hidden': true, + 'OnQuickLaunch': false + }) + }; + + return this._spHttpClient.post( + this._configurationListUrl(), + SPHttpClient.configurations.v1, + options + ).then((): void => undefined); + } + + private _getConfigurationItem(listId: string): Promise { + const filter: string = escapeODataString(listId.toLowerCase()); + const url: string = this._configurationListUrl() + '/items' + + '?$select=Id,Title,' + CONFIGURATION_FIELD + + "&$filter=Title eq '" + filter + "'&$top=1"; + + return this._spHttpClient.get(url, SPHttpClient.configurations.v1) + .then((response: SPHttpClientResponse): Promise => { + if (!response.ok) { + return responseError(response, 'Konfiguration konnte nicht gelesen werden') + .then((error: Error): Promise => Promise.reject(error)); + } + + return response.json().then((data: any): IConfigurationItem | undefined => { + const values: IConfigurationItem[] = data.value || (data.d && data.d.results) || []; + return values.length > 0 ? values[0] : undefined; + }); + }); + } + + private _configurationListUrl(): string { + return this._webUrl + "/_api/web/lists/getbytitle('" + CONFIGURATION_LIST_TITLE + "')"; + } + + private _mapListInfo(data: any): IConfigurationListInfo { + const source: any = data.d || data; + return { + id: source.Id, + entityType: source.ListItemEntityTypeFullName + }; + } + + private _headers(method?: string): any { + const headers: any = { + 'Accept': 'application/json;odata=verbose', + 'Content-type': 'application/json;odata=verbose' + }; + if (method) { + headers['IF-MATCH'] = '*'; + headers['X-HTTP-Method'] = method; + } + return headers; + } +} + diff --git a/src/common/ExpiryDateCalculator.ts b/src/common/ExpiryDateCalculator.ts new file mode 100644 index 0000000..959734a --- /dev/null +++ b/src/common/ExpiryDateCalculator.ts @@ -0,0 +1,107 @@ +import { + IExpiryConfig, + IExpiryBehavior, + IExpiryDuration, + IExpiryEvaluation, + IExpiryColumnRule, + ColumnRuleOperator +} from './ExpiryModels'; + +const DAY_IN_MILLISECONDS: number = 24 * 60 * 60 * 1000; + +export function parseSharePointDate(value: any): Date | undefined { + if (!value) { + return undefined; + } + + const result: Date = value instanceof Date ? new Date(value.getTime()) : new Date(value); + return isNaN(result.getTime()) ? undefined : result; +} + +export function addDuration(source: Date, duration: IExpiryDuration): Date { + if (duration.unit === 'days') { + const daysResult: Date = new Date(source.getTime()); + daysResult.setUTCDate(daysResult.getUTCDate() + duration.value); + return daysResult; + } + + const months: number = duration.unit === 'years' ? duration.value * 12 : duration.value; + return addCalendarMonths(source, months); +} + +export function addCalendarYear(source: Date): Date { + return addCalendarMonths(source, 12); +} + +export function evaluateExpiry( + created: Date, + expiry: Date | undefined, + config: IExpiryConfig, + now?: Date, + behavior?: IExpiryBehavior +): IExpiryEvaluation { + const selectedBehavior: IExpiryBehavior = behavior || config.default; + const effectiveExpiryDate: Date = expiry ? new Date(expiry.getTime()) : addDuration(created, selectedBehavior.lifeTime); + const daysUntilExpiry: number = calendarDayDifference(now || new Date(), effectiveExpiryDate); + + return { + effectiveExpiryDate: effectiveExpiryDate, + daysUntilExpiry: daysUntilExpiry, + matchedRule: findMatchingRule(daysUntilExpiry, selectedBehavior.columnRule), + wasCalculated: !expiry + }; +} + +export function calendarDayDifference(from: Date, to: Date): number { + const fromDay: number = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()); + const toDay: number = Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate()); + return Math.round((toDay - fromDay) / DAY_IN_MILLISECONDS); +} + +export function formatDate(date: Date): string { + const day: string = pad(date.getDate()); + const month: string = pad(date.getMonth() + 1); + return day + '.' + month + '.' + date.getFullYear(); +} + +export function findMatchingRule(days: number, rules: IExpiryColumnRule[]): IExpiryColumnRule | undefined { + for (let index: number = 0; index < rules.length; index++) { + if (matches(days, rules[index].operator, rules[index].daysUntilExpiry)) { + return rules[index]; + } + } + + return undefined; +} + +function addCalendarMonths(source: Date, months: number): Date { + const result: Date = new Date(source.getTime()); + const originalDay: number = result.getUTCDate(); + + result.setUTCDate(1); + result.setUTCMonth(result.getUTCMonth() + months); + + const lastDayOfTargetMonth: number = new Date(Date.UTC( + result.getUTCFullYear(), + result.getUTCMonth() + 1, + 0 + )).getUTCDate(); + + result.setUTCDate(Math.min(originalDay, lastDayOfTargetMonth)); + return result; +} + +function matches(value: number, operator: ColumnRuleOperator, threshold: number): boolean { + switch (operator) { + case 'lessThan': return value < threshold; + case 'lessOrEqual': return value <= threshold; + case 'equal': return value === threshold; + case 'greaterOrEqual': return value >= threshold; + case 'greaterThan': return value > threshold; + default: return false; + } +} + +function pad(value: number): string { + return value < 10 ? '0' + value : String(value); +} diff --git a/src/common/ExpiryItemService.ts b/src/common/ExpiryItemService.ts new file mode 100644 index 0000000..c8797b0 --- /dev/null +++ b/src/common/ExpiryItemService.ts @@ -0,0 +1,183 @@ +import { + ISPHttpClientOptions, + SPHttpClient, + SPHttpClientResponse +} from '@microsoft/sp-http'; +import { addCalendarYear, addDuration, parseSharePointDate } from './ExpiryDateCalculator'; +import { + IExpiryConfig, + IItemDateValues, + IUpdateResult, + getRuleFieldNames, + resolveBehavior, + isSafeInternalName +} from './ExpiryModels'; +import { responseError } from './RestError'; + +export class ExpiryItemService { + private readonly _spHttpClient: SPHttpClient; + private readonly _webUrl: string; + private readonly _listId: string; + private _entityTypePromise: Promise | undefined; + private _availablePolicyFields: { [key: string]: Promise } = {}; + + public constructor(spHttpClient: SPHttpClient, webUrl: string, listId: string) { + this._spHttpClient = spHttpClient; + this._webUrl = webUrl.replace(/\/$/, ''); + this._listId = listId; + } + + public fieldExists(internalName: string): Promise { + if (!isSafeInternalName(internalName)) { + return Promise.resolve(false); + } + + const url: string = this._listUrl() + "/fields/getbyinternalnameortitle('" + internalName + "')?$select=InternalName"; + return this._spHttpClient.get(url, SPHttpClient.configurations.v1) + .then((response: SPHttpClientResponse): boolean => response.ok); + } + + public extendItems(ids: number[], config: IExpiryConfig): Promise { + const uniqueIds: number[] = ids.filter((id: number, index: number): boolean => + id > 0 && ids.indexOf(id) === index + ); + const result: IUpdateResult[] = []; + return this._processBatch(uniqueIds, 0, config, result).then((): IUpdateResult[] => result); + } + + private _processBatch( + ids: number[], + offset: number, + config: IExpiryConfig, + result: IUpdateResult[] + ): Promise { + if (offset >= ids.length) { + return Promise.resolve(); + } + + const batch: number[] = ids.slice(offset, offset + 10); + return Promise.all(batch.map((id: number): Promise => this._extendItem(id, config))) + .then((batchResult: IUpdateResult[]): Promise => { + batchResult.forEach((item: IUpdateResult): void => { result.push(item); }); + return this._processBatch(ids, offset + batch.length, config, result); + }); + } + + private _extendItem(id: number, config: IExpiryConfig): Promise { + return this.getItemDates(id, config).then((item: IItemDateValues): Promise => { + const baseDate: Date = item.expiry || addDuration(item.created, resolveBehavior(config, item.fieldValues).lifeTime); + const newExpiry: Date = addCalendarYear(baseDate); + return this._updateExpiry(id, config.expiryField, newExpiry).then((): IUpdateResult => ({ + id: id, + succeeded: true, + newExpiryDate: newExpiry + })); + }).catch((error: Error): IUpdateResult => ({ + id: id, + succeeded: false, + error: error && error.message ? error.message : String(error) + })); + } + + public getItemDates(id: number, config: IExpiryConfig): Promise { + if (!isSafeInternalName(config.baseField) || !isSafeInternalName(config.expiryField)) { + return Promise.reject(new Error('Ungültiger interner Feldname in der Konfiguration.')); + } + + return this._getAvailablePolicyFields(config).then((policyFields: string[]): Promise => { + const selectedFields: string[] = [config.baseField, config.expiryField]; + policyFields.forEach((field: string): void => { + if (selectedFields.indexOf(field) < 0) { + selectedFields.push(field); + } + }); + const url: string = this._listUrl() + '/items(' + id + ')?$select=Id,' + selectedFields.join(','); + + return this._spHttpClient.get(url, SPHttpClient.configurations.v1) + .then((response: SPHttpClientResponse): Promise => { + if (!response.ok) { + return responseError(response, 'Element ' + id + ' konnte nicht gelesen werden') + .then((error: Error): Promise => Promise.reject(error)); + } + + return response.json().then((data: any): IItemDateValues => { + const source: any = data.d || data; + const created: Date | undefined = parseSharePointDate(source[config.baseField]); + const expiry: Date | undefined = parseSharePointDate(source[config.expiryField]); + if (!created) { + throw new Error('Element ' + id + ': Created enthält kein gültiges Datum.'); + } + const fieldValues: { [fieldName: string]: any } = {}; + policyFields.forEach((field: string): void => { fieldValues[field] = source[field]; }); + return { id: id, created: created, expiry: expiry, fieldValues: fieldValues }; + }); + }); + }); + } + + private _getAvailablePolicyFields(config: IExpiryConfig): Promise { + const configuredFields: string[] = getRuleFieldNames(config); + const key: string = configuredFields.slice().sort().join('|'); + if (!this._availablePolicyFields[key]) { + this._availablePolicyFields[key] = Promise.all(configuredFields.map((field: string): Promise => + this.fieldExists(field).then((exists: boolean): any => ({ field: field, exists: exists })) + )).then((results: any[]): string[] => results + .filter((result: any): boolean => result.exists) + .map((result: any): string => result.field) + ); + } + return this._availablePolicyFields[key]; + } + + private _updateExpiry(id: number, fieldName: string, expiry: Date): Promise { + return this._getEntityType().then((entityType: string): Promise => { + const body: any = { '__metadata': { 'type': entityType } }; + body[fieldName] = expiry.toISOString(); + + const options: ISPHttpClientOptions = { + headers: { + 'Accept': 'application/json;odata=verbose', + 'Content-type': 'application/json;odata=verbose', + 'IF-MATCH': '*', + 'X-HTTP-Method': 'MERGE' + }, + body: JSON.stringify(body) + }; + + return this._spHttpClient.post( + this._listUrl() + '/items(' + id + ')', + SPHttpClient.configurations.v1, + options + ).then((response: SPHttpClientResponse): Promise => { + if (!response.ok) { + return responseError(response, 'Element ' + id + ' konnte nicht aktualisiert werden') + .then((error: Error): Promise => Promise.reject(error)); + } + return Promise.resolve(); + }); + }); + } + + private _getEntityType(): Promise { + if (!this._entityTypePromise) { + this._entityTypePromise = this._spHttpClient.get( + this._listUrl() + '?$select=ListItemEntityTypeFullName', + SPHttpClient.configurations.v1 + ).then((response: SPHttpClientResponse): Promise => { + if (!response.ok) { + return responseError(response, 'Listentyp konnte nicht gelesen werden') + .then((error: Error): Promise => Promise.reject(error)); + } + return response.json().then((data: any): string => { + const source: any = data.d || data; + return source.ListItemEntityTypeFullName; + }); + }); + } + return this._entityTypePromise; + } + + private _listUrl(): string { + return this._webUrl + "/_api/web/lists(guid'" + this._listId + "')"; + } +} diff --git a/src/common/ExpiryModels.ts b/src/common/ExpiryModels.ts new file mode 100644 index 0000000..0c6b5d1 --- /dev/null +++ b/src/common/ExpiryModels.ts @@ -0,0 +1,240 @@ +export type DurationUnit = 'days' | 'months' | 'years'; +export type ColumnRuleOperator = 'lessThan' | 'lessOrEqual' | 'equal' | 'greaterOrEqual' | 'greaterThan'; + +export interface IExpiryDuration { + value: number; + unit: DurationUnit; +} + +export interface IExpiryColumnRule { + daysUntilExpiry: number; + operator: ColumnRuleOperator; + backgroundColor: string; + textColor: string; + label: string; +} + +export interface IExpiryBehavior { + lifeTime: IExpiryDuration; + columnRule: IExpiryColumnRule[]; +} + +export interface IExpiryValueRule extends IExpiryBehavior { + columnName: string; + columnValue: string; +} + +export interface IExpiryConfig { + baseField: string; + expiryField: string; + default: IExpiryBehavior; + rules: IExpiryValueRule[]; + nullText: string; + confirmExtension: boolean; +} + +export interface IExpiryEvaluation { + effectiveExpiryDate: Date; + daysUntilExpiry: number; + matchedRule?: IExpiryColumnRule; + wasCalculated: boolean; +} + +export interface IItemDateValues { + id: number; + created: Date; + expiry?: Date; + fieldValues: { [fieldName: string]: any }; +} + +export interface IUpdateResult { + id: number; + succeeded: boolean; + newExpiryDate?: Date; + error?: string; +} + +export function createDefaultConfig(): IExpiryConfig { + return { + baseField: 'Created', + expiryField: 'ExpiryDate', + default: { + lifeTime: { value: 2, unit: 'years' }, + columnRule: createDefaultColumnRules() + }, + rules: [], + nullText: 'Kein Ablaufdatum', + confirmExtension: true + }; +} + +export function normalizeConfig(value: any): IExpiryConfig { + const defaults: IExpiryConfig = createDefaultConfig(); + const config: any = value || {}; + const defaultBehavior: any = config.default || {}; + + return { + baseField: isSafeInternalName(config.baseField) ? config.baseField : defaults.baseField, + expiryField: isSafeInternalName(config.expiryField) ? config.expiryField : defaults.expiryField, + default: { + lifeTime: normalizeDuration(defaultBehavior.lifeTime, defaults.default.lifeTime), + columnRule: normalizeColumnRules(defaultBehavior.columnRule, defaults.default.columnRule) + }, + rules: normalizeValueRules(config.rules), + nullText: typeof config.nullText === 'string' ? config.nullText : defaults.nullText, + confirmExtension: typeof config.confirmExtension === 'boolean' ? config.confirmExtension : defaults.confirmExtension + }; +} + +export function getRuleFieldNames(config: IExpiryConfig): string[] { + const fields: string[] = []; + config.rules.forEach((rule: IExpiryValueRule): void => { + if (isSafeInternalName(rule.columnName) && fields.indexOf(rule.columnName) < 0) { + fields.push(rule.columnName); + } + }); + return fields; +} + +export function resolveBehavior( + config: IExpiryConfig, + fieldValues: { [fieldName: string]: any } +): IExpiryBehavior { + for (let index: number = 0; index < config.rules.length; index++) { + const rule: IExpiryValueRule = config.rules[index]; + if (!Object.prototype.hasOwnProperty.call(fieldValues, rule.columnName)) { + continue; + } + + if (valueMatches(fieldValues[rule.columnName], rule.columnValue)) { + return { + lifeTime: rule.lifeTime, + columnRule: rule.columnRule + }; + } + } + return config.default; +} + +export function isSafeInternalName(value: string): boolean { + return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); +} + +function createDefaultColumnRules(): IExpiryColumnRule[] { + return [ + { + daysUntilExpiry: 0, + operator: 'lessOrEqual', + backgroundColor: '#a4262c', + textColor: '#ffffff', + label: 'Abgelaufen' + }, + { + daysUntilExpiry: 30, + operator: 'lessOrEqual', + backgroundColor: '#ffaa44', + textColor: '#000000', + label: 'Läuft bald ab' + }, + { + daysUntilExpiry: 90, + operator: 'lessOrEqual', + backgroundColor: '#fff4ce', + textColor: '#000000', + label: 'Beobachten' + } + ]; +} + +function normalizeValueRules(value: any): IExpiryValueRule[] { + if (!Array.isArray(value)) { + return []; + } + + const rules: IExpiryValueRule[] = []; + value.forEach((rule: any): void => { + if (!rule || !isSafeInternalName(rule.columnName) || typeof rule.columnValue === 'undefined') { + return; + } + + const columnRules: IExpiryColumnRule[] = normalizeColumnRules(rule.columnRule, []); + rules.push({ + columnName: rule.columnName, + columnValue: String(rule.columnValue), + lifeTime: normalizeDuration(rule.lifeTime, { value: 1, unit: 'years' }), + columnRule: columnRules + }); + }); + return rules; +} + +function normalizeDuration(value: any, fallback: IExpiryDuration): IExpiryDuration { + if (!value || typeof value.value !== 'number' || !isFinite(value.value)) { + return fallback; + } + if (value.unit !== 'days' && value.unit !== 'months' && value.unit !== 'years') { + return fallback; + } + return { value: Math.floor(value.value), unit: value.unit }; +} + +function normalizeColumnRules(value: any, fallback: IExpiryColumnRule[]): IExpiryColumnRule[] { + if (!Array.isArray(value)) { + return fallback; + } + + const rules: IExpiryColumnRule[] = []; + value.forEach((rule: any): void => { + if (!rule || typeof rule.daysUntilExpiry !== 'number' || !isFinite(rule.daysUntilExpiry)) { + return; + } + if (!isColumnRuleOperator(rule.operator) || !isColor(rule.backgroundColor) || !isColor(rule.textColor)) { + return; + } + rules.push({ + daysUntilExpiry: Math.floor(rule.daysUntilExpiry), + operator: rule.operator, + backgroundColor: rule.backgroundColor, + textColor: rule.textColor, + label: typeof rule.label === 'string' ? rule.label : '' + }); + }); + return rules.length > 0 || fallback.length === 0 ? rules : fallback; +} + +function valueMatches(rawValue: any, configuredValue: string): boolean { + if (rawValue === null || typeof rawValue === 'undefined') { + return configuredValue === ''; + } + + let values: any[]; + if (Array.isArray(rawValue)) { + values = rawValue; + } else if (rawValue.results && Array.isArray(rawValue.results)) { + values = rawValue.results; + } else { + values = [rawValue]; + } + + return values.some((value: any): boolean => { + if (value && typeof value === 'object') { + return String(value.Title || value.LookupValue || value.Label || value.Value || '') === configuredValue; + } + return String(value) === configuredValue; + }); +} + +function isColumnRuleOperator(value: string): boolean { + return value === 'lessThan' || value === 'lessOrEqual' || value === 'equal' || + value === 'greaterOrEqual' || value === 'greaterThan'; +} + +function isColor(value: string): boolean { + return typeof value === 'string' && ( + /^#[0-9a-fA-F]{3}$/.test(value) || + /^#[0-9a-fA-F]{6}$/.test(value) || + /^rgba?\([0-9.,% ]+\)$/.test(value) || + /^[a-zA-Z]+$/.test(value) + ); +} + diff --git a/src/common/RestError.ts b/src/common/RestError.ts new file mode 100644 index 0000000..58c75ea --- /dev/null +++ b/src/common/RestError.ts @@ -0,0 +1,20 @@ +import { SPHttpClientResponse } from '@microsoft/sp-http'; + +export function responseError(response: SPHttpClientResponse, operation: string): Promise { + return response.text().then((body: string): Error => { + let detail: string = body; + try { + const parsed: any = JSON.parse(body); + detail = parsed.error && parsed.error.message ? parsed.error.message.value : body; + } catch (error) { + // Keep the raw response body. + } + + return new Error(operation + ' (' + response.status + '): ' + detail); + }); +} + +export function escapeODataString(value: string): string { + return value.replace(/'/g, "''"); +} + diff --git a/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.manifest.json b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.manifest.json new file mode 100644 index 0000000..e1f5232 --- /dev/null +++ b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.manifest.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-extension-manifest.schema.json", + "id": "a555f4fc-d6a6-4421-8189-457449d9bbde", + "alias": "ExpiryIndicatorFieldCustomizer", + "componentType": "Extension", + "extensionType": "FieldCustomizer", + "version": "1.0.0", + "manifestVersion": 2 +} + diff --git a/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.module.scss b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.module.scss new file mode 100644 index 0000000..c3d04d0 --- /dev/null +++ b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.module.scss @@ -0,0 +1,27 @@ +.cell { + box-sizing: border-box; + display: block; + margin: -7px -11px; + min-height: 32px; + padding: 7px 11px; +} + +.date { + font-weight: 600; +} + +.label { + display: inline-block; + font-size: 11px; + margin-left: 8px; + opacity: .9; +} + +.calculated { + border-bottom: 1px dotted currentColor; +} + +.neutral { + color: #666666; +} + diff --git a/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts new file mode 100644 index 0000000..0fe40c9 --- /dev/null +++ b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts @@ -0,0 +1,157 @@ +import { override } from '@microsoft/decorators'; +import { + BaseFieldCustomizer, + IFieldCustomizerCellEventParameters +} from '@microsoft/sp-listview-extensibility'; +import { evaluateExpiry, formatDate, parseSharePointDate } from '../../common/ExpiryDateCalculator'; +import { ExpiryConfigService } from '../../common/ExpiryConfigService'; +import { ExpiryItemService } from '../../common/ExpiryItemService'; +import { + createDefaultConfig, + IExpiryConfig, + IExpiryEvaluation, + IItemDateValues, + resolveBehavior +} from '../../common/ExpiryModels'; +import styles from './ExpiryIndicatorFieldCustomizer.module.scss'; +import * as strings from 'ExpiryIndicatorStrings'; + +export interface IExpiryIndicatorFieldCustomizerProperties { +} + +export default class ExpiryIndicatorFieldCustomizer + extends BaseFieldCustomizer { + + private _config: IExpiryConfig = createDefaultConfig(); + private _itemService: ExpiryItemService; + private _itemCache: { [id: string]: Promise } = {}; + + @override + public onInit(): Promise { + if (!this.context.pageContext.list) { + return Promise.resolve(); + } + + const service: ExpiryConfigService = new ExpiryConfigService( + this.context.spHttpClient, + this.context.pageContext.web.absoluteUrl + ); + + this._itemService = new ExpiryItemService( + this.context.spHttpClient, + this.context.pageContext.web.absoluteUrl, + this.context.pageContext.list.id.toString() + ); + + return service.getConfig(this.context.pageContext.list.id.toString()) + .then((config: IExpiryConfig): void => { this._config = config; }) + .catch((): void => { this._config = createDefaultConfig(); }); + } + + @override + public onRenderCell(event: IFieldCustomizerCellEventParameters): void { + this._clear(event.domElement); + + const expiry: Date | undefined = parseSharePointDate(event.fieldValue); + const createdValue: any = event.listItem && event.listItem.getValueByName + ? event.listItem.getValueByName(this._config.baseField) + : undefined; + const created: Date | undefined = parseSharePointDate(createdValue); + + const idValue: any = event.listItem && event.listItem.getValueByName + ? (event.listItem.getValueByName('ID') || event.listItem.getValueByName('Id')) + : undefined; + const itemId: number = Number(idValue); + + if (itemId > 0 && this._itemService) { + const requestKey: string = String(itemId); + event.domElement.setAttribute('data-expiry-item-id', requestKey); + this._renderNeutral(event.domElement, strings.Loading); + if (!this._itemCache[requestKey]) { + this._itemCache[requestKey] = this._itemService.getItemDates(itemId, this._config); + } + + this._itemCache[requestKey].then((item: IItemDateValues): void => { + if (event.domElement.getAttribute('data-expiry-item-id') !== requestKey) { + return; + } + this._clear(event.domElement); + this._renderValues(event.domElement, item.created, item.expiry, item.fieldValues); + }).catch((): void => { + if (event.domElement.getAttribute('data-expiry-item-id') === requestKey) { + this._clear(event.domElement); + this._renderValues(event.domElement, created, expiry, {}); + } + }); + return; + } + + this._renderValues(event.domElement, created, expiry, {}); + } + + @override + public onDisposeCell(event: IFieldCustomizerCellEventParameters): void { + this._clear(event.domElement); + super.onDisposeCell(event); + } + + private _renderEvaluation(container: HTMLElement, evaluation: IExpiryEvaluation): void { + const wrapper: HTMLSpanElement = document.createElement('span'); + wrapper.className = styles.cell + (evaluation.wasCalculated ? ' ' + styles.calculated : ''); + + if (evaluation.matchedRule) { + wrapper.style.backgroundColor = evaluation.matchedRule.backgroundColor; + wrapper.style.color = evaluation.matchedRule.textColor; + } + + const date: HTMLSpanElement = document.createElement('span'); + date.className = styles.date; + date.textContent = formatDate(evaluation.effectiveExpiryDate); + wrapper.appendChild(date); + + if (evaluation.matchedRule && evaluation.matchedRule.label) { + const label: HTMLSpanElement = document.createElement('span'); + label.className = styles.label; + label.textContent = evaluation.matchedRule.label; + wrapper.appendChild(label); + } + + const calculationHint: string = evaluation.wasCalculated ? ' – ' + strings.CalculatedHint : ''; + wrapper.title = strings.DaysUntilExpiry.replace('{0}', String(evaluation.daysUntilExpiry)) + calculationHint; + container.appendChild(wrapper); + } + + private _renderValues( + container: HTMLElement, + created: Date | undefined, + expiry: Date | undefined, + fieldValues: { [fieldName: string]: any } + ): void { + if (!expiry && !created) { + this._renderNeutral(container, this._config.nullText); + return; + } + + const evaluation: IExpiryEvaluation = evaluateExpiry( + created || expiry as Date, + expiry, + this._config, + undefined, + resolveBehavior(this._config, fieldValues) + ); + this._renderEvaluation(container, evaluation); + } + + private _renderNeutral(container: HTMLElement, text: string): void { + const wrapper: HTMLSpanElement = document.createElement('span'); + wrapper.className = styles.cell + ' ' + styles.neutral; + wrapper.textContent = text; + container.appendChild(wrapper); + } + + private _clear(container: HTMLElement): void { + while (container.firstChild) { + container.removeChild(container.firstChild); + } + } +} diff --git a/src/extensions/expiryIndicator/loc/de-de.js b/src/extensions/expiryIndicator/loc/de-de.js new file mode 100644 index 0000000..276dfec --- /dev/null +++ b/src/extensions/expiryIndicator/loc/de-de.js @@ -0,0 +1,7 @@ +define([], function() { + return { + "CalculatedHint": "aus Created berechnet", + "DaysUntilExpiry": "Verbleibende Tage: {0}", + "Loading": "Wird berechnet …" + }; +}); diff --git a/src/extensions/expiryIndicator/loc/en-us.js b/src/extensions/expiryIndicator/loc/en-us.js new file mode 100644 index 0000000..f204b61 --- /dev/null +++ b/src/extensions/expiryIndicator/loc/en-us.js @@ -0,0 +1,7 @@ +define([], function() { + return { + "CalculatedHint": "calculated from Created", + "DaysUntilExpiry": "Days remaining: {0}", + "Loading": "Calculating…" + }; +}); diff --git a/src/extensions/expiryIndicator/loc/mystrings.d.ts b/src/extensions/expiryIndicator/loc/mystrings.d.ts new file mode 100644 index 0000000..f241a55 --- /dev/null +++ b/src/extensions/expiryIndicator/loc/mystrings.d.ts @@ -0,0 +1,10 @@ +declare interface IExpiryIndicatorStrings { + CalculatedHint: string; + DaysUntilExpiry: string; + Loading: string; +} + +declare module 'ExpiryIndicatorStrings' { + const strings: IExpiryIndicatorStrings; + export = strings; +} diff --git a/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json b/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json new file mode 100644 index 0000000..888d3cf --- /dev/null +++ b/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-extension-manifest.schema.json", + "id": "cd58f5d9-ffc8-4df7-a910-3054842d7abf", + "alias": "ExpiryIndicatorCommandSet", + "componentType": "Extension", + "extensionType": "ListViewCommandSet", + "version": "1.0.0", + "manifestVersion": 2, + "items": { + "EXTEND_ONE_YEAR": { + "title": { + "default": "Extend expiry +1 year", + "de-de": "Ablaufdatum +1 Jahr" + }, + "type": "command" + }, + "SETTINGS": { + "title": { + "default": "Expiry settings", + "de-de": "Expiry-Einstellungen" + }, + "type": "command" + } + } +} + diff --git a/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.ts b/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.ts new file mode 100644 index 0000000..273e084 --- /dev/null +++ b/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.ts @@ -0,0 +1,138 @@ +import { override } from '@microsoft/decorators'; +import { SPPermission } from '@microsoft/sp-core-library'; +import { Dialog } from '@microsoft/sp-dialog'; +import { + BaseListViewCommandSet, + Command, + IListViewCommandSetExecuteEventParameters, + IListViewCommandSetListViewUpdatedParameters +} from '@microsoft/sp-listview-extensibility'; +import { ExpiryConfigService } from '../../common/ExpiryConfigService'; +import { ExpiryItemService } from '../../common/ExpiryItemService'; +import { createDefaultConfig, IExpiryConfig, IUpdateResult } from '../../common/ExpiryModels'; +import { ExpirySettingsDialog } from './ExpirySettingsDialog'; +import * as strings from 'ExpiryIndicatorCommandSetStrings'; + +export interface IExpiryIndicatorCommandSetProperties { +} + +export default class ExpiryIndicatorCommandSet + extends BaseListViewCommandSet { + + private _config: IExpiryConfig = createDefaultConfig(); + private _configService: ExpiryConfigService; + private _itemService: ExpiryItemService; + private _expiryFieldExists: boolean = false; + private _canEdit: boolean = false; + private _canManage: boolean = false; + + @override + public onInit(): Promise { + if (!this.context.pageContext.list) { + return Promise.resolve(); + } + + const listId: string = this.context.pageContext.list.id.toString(); + const webUrl: string = this.context.pageContext.web.absoluteUrl; + this._configService = new ExpiryConfigService(this.context.spHttpClient, webUrl); + this._itemService = new ExpiryItemService(this.context.spHttpClient, webUrl, listId); + this._canEdit = this.context.pageContext.list.permissions.hasPermission(SPPermission.editListItems); + this._canManage = this.context.pageContext.list.permissions.hasPermission(SPPermission.manageLists); + + return this._configService.getConfig(listId).then((config: IExpiryConfig): Promise => { + this._config = config; + return this._refreshFieldState(); + }).catch((): void => { + this._config = createDefaultConfig(); + this._expiryFieldExists = false; + }); + } + + @override + public onListViewUpdated(event: IListViewCommandSetListViewUpdatedParameters): void { + const extendCommand: Command = this.tryGetCommand('EXTEND_ONE_YEAR'); + const settingsCommand: Command = this.tryGetCommand('SETTINGS'); + + if (extendCommand) { + extendCommand.visible = this._expiryFieldExists && this._canEdit && event.selectedRows.length > 0; + } + if (settingsCommand) { + settingsCommand.visible = this._canManage; + } + } + + @override + public onExecute(event: IListViewCommandSetExecuteEventParameters): void { + if (event.itemId === 'EXTEND_ONE_YEAR') { + this._executeExtend(event); + return; + } + + if (event.itemId === 'SETTINGS') { + this._openSettings(); + return; + } + + throw new Error('Unknown command: ' + event.itemId); + } + + private _executeExtend(event: IListViewCommandSetExecuteEventParameters): void { + const ids: number[] = event.selectedRows.map((row: any): number => + Number(row.getValueByName('ID') || row.getValueByName('Id')) + ).filter((id: number): boolean => id > 0); + + if (ids.length === 0) { + Dialog.alert(strings.NoItemsSelected); + return; + } + + const execute: () => Promise = (): Promise => { + return this._itemService.extendItems(ids, this._config).then((results: IUpdateResult[]): Promise => { + const succeeded: number = results.filter((result: IUpdateResult): boolean => result.succeeded).length; + const failed: IUpdateResult[] = results.filter((result: IUpdateResult): boolean => !result.succeeded); + let message: string = strings.UpdateSummary + .replace('{0}', String(succeeded)) + .replace('{1}', String(failed.length)); + if (failed.length > 0) { + message += '\n\n' + failed.slice(0, 10).map((result: IUpdateResult): string => + '#' + result.id + ': ' + result.error + ).join('\n'); + } + return Dialog.alert(message).then((): void => { + if (succeeded > 0) { + window.location.reload(); + } + }); + }); + }; + + if (this._config.confirmExtension) { + Dialog.confirm(strings.ConfirmMessage.replace('{0}', String(ids.length))) + .then((confirmed: boolean): Promise => confirmed ? execute() : Promise.resolve()); + } else { + execute(); + } + } + + private _openSettings(): void { + const listId: string = this.context.pageContext.list.id.toString(); + const dialog: ExpirySettingsDialog = new ExpirySettingsDialog( + this._config, + (config: IExpiryConfig): Promise => { + return this._configService.saveConfig(listId, config).then((): Promise => { + this._config = config; + return this._refreshFieldState(); + }); + } + ); + dialog.show().then((): void => { this.raiseOnChange(); }); + } + + private _refreshFieldState(): Promise { + return this._itemService.fieldExists(this._config.expiryField).then((exists: boolean): void => { + this._expiryFieldExists = exists; + this.raiseOnChange(); + }); + } +} + diff --git a/src/extensions/expiryIndicatorCommandSet/ExpirySettingsDialog.ts b/src/extensions/expiryIndicatorCommandSet/ExpirySettingsDialog.ts new file mode 100644 index 0000000..93275a5 --- /dev/null +++ b/src/extensions/expiryIndicatorCommandSet/ExpirySettingsDialog.ts @@ -0,0 +1,125 @@ +import { BaseDialog, IDialogConfiguration } from '@microsoft/sp-dialog'; +import { + IExpiryConfig, + IExpiryColumnRule, + IExpiryValueRule, + normalizeConfig +} from '../../common/ExpiryModels'; +import * as strings from 'ExpiryIndicatorCommandSetStrings'; + +export class ExpirySettingsDialog extends BaseDialog { + private _config: IExpiryConfig; + private _save: (config: IExpiryConfig) => Promise; + + public constructor(config: IExpiryConfig, save: (config: IExpiryConfig) => Promise) { + super(); + this._config = config; + this._save = save; + } + + public render(): void { + this.domElement.innerHTML = + '
' + + '

' + strings.SettingsTitle + '

' + + '
' + + this._input('baseField', strings.BaseField) + + this._input('expiryField', strings.ExpiryField) + + this._input('lifetimeValue', strings.DefaultLifetimeValue, 'number') + + '' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + + this._setValue('baseField', this._config.baseField); + this._setValue('expiryField', this._config.expiryField); + this._setValue('lifetimeValue', String(this._config.default.lifeTime.value)); + this._setValue('lifetimeUnit', this._config.default.lifeTime.unit); + this._setValue('defaultColumnRule', JSON.stringify(this._config.default.columnRule, undefined, 2)); + this._setValue('rules', JSON.stringify(this._config.rules, undefined, 2)); + this._setValue('nullText', this._config.nullText); + (this._field('confirmExtension') as HTMLInputElement).checked = this._config.confirmExtension; + + (this.domElement.querySelector('[data-action="cancel"]') as HTMLButtonElement).onclick = (): void => { + this.close(); + }; + (this.domElement.querySelector('[data-action="save"]') as HTMLButtonElement).onclick = (): void => { + this._saveForm(); + }; + } + + public getConfig(): IDialogConfiguration { + return { isBlocking: true }; + } + + private _saveForm(): void { + const saveButton: HTMLButtonElement = this.domElement.querySelector('[data-action="save"]') as HTMLButtonElement; + const errorElement: HTMLElement = this._field('error') as HTMLElement; + errorElement.textContent = ''; + + try { + const defaultColumnRule: IExpiryColumnRule[] = JSON.parse(this._value('defaultColumnRule')); + const rules: IExpiryValueRule[] = JSON.parse(this._value('rules')); + const config: IExpiryConfig = normalizeConfig({ + baseField: this._value('baseField').trim(), + expiryField: this._value('expiryField').trim(), + default: { + lifeTime: { + value: Number(this._value('lifetimeValue')), + unit: this._value('lifetimeUnit') + }, + columnRule: defaultColumnRule + }, + rules: rules, + nullText: this._value('nullText'), + confirmExtension: (this._field('confirmExtension') as HTMLInputElement).checked + }); + + saveButton.disabled = true; + this._save(config).then((): void => { + this._config = config; + this.close(); + }).catch((error: Error): void => { + saveButton.disabled = false; + errorElement.textContent = error && error.message ? error.message : String(error); + }); + } catch (error) { + errorElement.textContent = strings.InvalidJson + ': ' + (error && error.message ? error.message : String(error)); + } + } + + private _input(field: string, label: string, type?: string): string { + return ''; + } + + private _field(name: string): Element { + return this.domElement.querySelector('[data-field="' + name + '"]') as Element; + } + + private _value(name: string): string { + return (this._field(name) as HTMLInputElement).value; + } + + private _setValue(name: string, value: string): void { + (this._field(name) as HTMLInputElement).value = value; + } +} diff --git a/src/extensions/expiryIndicatorCommandSet/loc/de-de.js b/src/extensions/expiryIndicatorCommandSet/loc/de-de.js new file mode 100644 index 0000000..d19daef --- /dev/null +++ b/src/extensions/expiryIndicatorCommandSet/loc/de-de.js @@ -0,0 +1,19 @@ +define([], function() { + return { + "SettingsTitle": "ExpiryIndicator-Einstellungen", + "BaseField": "Internes Basis-Datumsfeld", + "ExpiryField": "Internes Ablaufdatumsfeld", + "DefaultLifetimeValue": "Standardlaufzeit", + "DefaultLifetimeUnit": "Einheit", + "DefaultColumnRulesJson": "Standard-Farbregeln (default.columnRule als JSON)", + "RulesJson": "Feldwertregeln (rules als JSON, erste passende Regel gewinnt)", + "ConfirmExtension": "Verlängerung vorher bestätigen", + "NullText": "Text bei fehlendem Datum", + "Cancel": "Abbrechen", + "Save": "Speichern", + "InvalidJson": "Die Konfiguration enthält ungültiges JSON", + "NoItemsSelected": "Es wurden keine gültigen Elemente ausgewählt.", + "UpdateSummary": "Erfolgreich aktualisiert: {0}; Fehler: {1}", + "ConfirmMessage": "Ablaufdatum für {0} Element(e) um ein Kalenderjahr verlängern?" + }; +}); diff --git a/src/extensions/expiryIndicatorCommandSet/loc/en-us.js b/src/extensions/expiryIndicatorCommandSet/loc/en-us.js new file mode 100644 index 0000000..174a09c --- /dev/null +++ b/src/extensions/expiryIndicatorCommandSet/loc/en-us.js @@ -0,0 +1,19 @@ +define([], function() { + return { + "SettingsTitle": "ExpiryIndicator settings", + "BaseField": "Base date field internal name", + "ExpiryField": "Expiry field internal name", + "DefaultLifetimeValue": "Default lifetime", + "DefaultLifetimeUnit": "Unit", + "DefaultColumnRulesJson": "Default color rules (default.columnRule JSON)", + "RulesJson": "Field value rules (rules JSON, first matching rule wins)", + "ConfirmExtension": "Confirm before extending", + "NullText": "Text when no date is available", + "Cancel": "Cancel", + "Save": "Save", + "InvalidJson": "The configuration contains invalid JSON", + "NoItemsSelected": "No valid items were selected.", + "UpdateSummary": "Successfully updated: {0}; errors: {1}", + "ConfirmMessage": "Extend the expiry date for {0} item(s) by one calendar year?" + }; +}); diff --git a/src/extensions/expiryIndicatorCommandSet/loc/mystrings.d.ts b/src/extensions/expiryIndicatorCommandSet/loc/mystrings.d.ts new file mode 100644 index 0000000..acd5985 --- /dev/null +++ b/src/extensions/expiryIndicatorCommandSet/loc/mystrings.d.ts @@ -0,0 +1,22 @@ +declare interface IExpiryIndicatorCommandSetStrings { + SettingsTitle: string; + BaseField: string; + ExpiryField: string; + DefaultLifetimeValue: string; + DefaultLifetimeUnit: string; + DefaultColumnRulesJson: string; + RulesJson: string; + ConfirmExtension: string; + NullText: string; + Cancel: string; + Save: string; + InvalidJson: string; + NoItemsSelected: string; + UpdateSummary: string; + ConfirmMessage: string; +} + +declare module 'ExpiryIndicatorCommandSetStrings' { + const strings: IExpiryIndicatorCommandSetStrings; + export = strings; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e168023 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "forceConsistentCasingInFileNames": true, + "module": "commonjs", + "jsx": "react", + "declaration": true, + "sourceMap": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "typeRoots": [ + "./node_modules/@types" + ], + "types": [ + "es6-promise", + "webpack-env" + ], + "lib": [ + "es5", + "dom", + "es2015.collection" + ] + }, + "include": [ + "src/**/*.ts" + ] +} + diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..cad9c50 --- /dev/null +++ b/tslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/@microsoft/sp-tslint-rules/base-tslint.json", + "rules": { + "no-any": false, + "no-string-based-set-timeout": false + } +} +