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.
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
lib/
|
||||
dist/
|
||||
temp/
|
||||
release/
|
||||
*.sppkg
|
||||
*.tgz
|
||||
npm-debug.log*
|
||||
|
||||
12
.yo-rc.json
Normal file
12
.yo-rc.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
97
README.md
Normal file
97
README.md
Normal file
@@ -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.
|
||||
128
ToDo.md
Normal file
128
ToDo.md
Normal file
@@ -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.
|
||||
28
config/config.json
Normal file
28
config/config.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
5
config/copy-assets.json
Normal file
5
config/copy-assets.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
|
||||
"deployCdnPath": "temp/deploy"
|
||||
}
|
||||
|
||||
27
config/package-solution.json
Normal file
27
config/package-solution.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
26
config/serve.json
Normal file
26
config/serve.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
config/write-manifests.json
Normal file
5
config/write-manifests.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||
}
|
||||
|
||||
6
gulpfile.js
Normal file
6
gulpfile.js
Normal file
@@ -0,0 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const build = require('@microsoft/sp-build-web');
|
||||
|
||||
build.initialize(require('gulp'));
|
||||
|
||||
33
package.json
Normal file
33
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
44
scripts/Disable-ExpiryIndicator.ps1
Normal file
44
scripts/Disable-ExpiryIndicator.ps1
Normal file
@@ -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()
|
||||
}
|
||||
|
||||
43
scripts/Enable-ExpiryIndicator.ps1
Normal file
43
scripts/Enable-ExpiryIndicator.ps1
Normal file
@@ -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()
|
||||
}
|
||||
|
||||
21
sharepoint/assets/elements.xml
Normal file
21
sharepoint/assets/elements.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
|
||||
<!-- ExpiryDate is intentionally not provisioned. It is supplied by the Content Type Hub. -->
|
||||
<CustomAction
|
||||
Title="ExpiryIndicator commands for lists"
|
||||
Name="ExpiryIndicator.CommandSet.Lists"
|
||||
RegistrationId="100"
|
||||
RegistrationType="List"
|
||||
Location="ClientSideExtension.ListViewCommandSet.CommandBar"
|
||||
ClientSideComponentId="cd58f5d9-ffc8-4df7-a910-3054842d7abf"
|
||||
ClientSideComponentProperties="{}" />
|
||||
<CustomAction
|
||||
Title="ExpiryIndicator commands for document libraries"
|
||||
Name="ExpiryIndicator.CommandSet.Libraries"
|
||||
RegistrationId="101"
|
||||
RegistrationType="List"
|
||||
Location="ClientSideExtension.ListViewCommandSet.CommandBar"
|
||||
ClientSideComponentId="cd58f5d9-ffc8-4df7-a910-3054842d7abf"
|
||||
ClientSideComponentProperties="{}" />
|
||||
</Elements>
|
||||
|
||||
214
src/common/ExpiryConfigService.ts
Normal file
214
src/common/ExpiryConfigService.ts
Normal file
@@ -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<IExpiryConfig> {
|
||||
return this._getConfigurationList(false).then((list: IConfigurationListInfo | undefined): Promise<IExpiryConfig> => {
|
||||
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<void> {
|
||||
const normalized: IExpiryConfig = normalizeConfig(config);
|
||||
return this._getConfigurationList(true).then((list: IConfigurationListInfo): Promise<void> => {
|
||||
return this._getConfigurationItem(listId).then((item: IConfigurationItem | undefined): Promise<void> => {
|
||||
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<void> => {
|
||||
if (!response.ok) {
|
||||
return responseError(response, 'Konfiguration konnte nicht gespeichert werden')
|
||||
.then((error: Error): Promise<void> => Promise.reject(error));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _getConfigurationList(createIfMissing: boolean): Promise<any> {
|
||||
const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName';
|
||||
return this._spHttpClient.get(url, SPHttpClient.configurations.v1)
|
||||
.then((response: SPHttpClientResponse): Promise<any> => {
|
||||
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<any> => Promise.reject(error));
|
||||
}
|
||||
return response.json().then((data: any): IConfigurationListInfo => this._mapListInfo(data));
|
||||
});
|
||||
}
|
||||
|
||||
private _createConfigurationList(): Promise<IConfigurationListInfo> {
|
||||
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<IConfigurationListInfo> => {
|
||||
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<any> => Promise.reject(error));
|
||||
});
|
||||
}
|
||||
|
||||
return this._createConfigurationField()
|
||||
.then((): Promise<void> => this._hideConfigurationList())
|
||||
.then((): Promise<any> => this._getConfigurationList(false));
|
||||
});
|
||||
}
|
||||
|
||||
private _createConfigurationField(): Promise<void> {
|
||||
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<void> => {
|
||||
if (!response.ok) {
|
||||
return responseError(response, 'Konfigurationsfeld konnte nicht erstellt werden')
|
||||
.then((error: Error): Promise<void> => Promise.reject(error));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
private _hideConfigurationList(): Promise<void> {
|
||||
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<IConfigurationItem | undefined> {
|
||||
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<IConfigurationItem | undefined> => {
|
||||
if (!response.ok) {
|
||||
return responseError(response, 'Konfiguration konnte nicht gelesen werden')
|
||||
.then((error: Error): Promise<any> => 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;
|
||||
}
|
||||
}
|
||||
|
||||
107
src/common/ExpiryDateCalculator.ts
Normal file
107
src/common/ExpiryDateCalculator.ts
Normal file
@@ -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);
|
||||
}
|
||||
183
src/common/ExpiryItemService.ts
Normal file
183
src/common/ExpiryItemService.ts
Normal file
@@ -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<string> | undefined;
|
||||
private _availablePolicyFields: { [key: string]: Promise<string[]> } = {};
|
||||
|
||||
public constructor(spHttpClient: SPHttpClient, webUrl: string, listId: string) {
|
||||
this._spHttpClient = spHttpClient;
|
||||
this._webUrl = webUrl.replace(/\/$/, '');
|
||||
this._listId = listId;
|
||||
}
|
||||
|
||||
public fieldExists(internalName: string): Promise<boolean> {
|
||||
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<IUpdateResult[]> {
|
||||
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<void> {
|
||||
if (offset >= ids.length) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const batch: number[] = ids.slice(offset, offset + 10);
|
||||
return Promise.all(batch.map((id: number): Promise<IUpdateResult> => this._extendItem(id, config)))
|
||||
.then((batchResult: IUpdateResult[]): Promise<void> => {
|
||||
batchResult.forEach((item: IUpdateResult): void => { result.push(item); });
|
||||
return this._processBatch(ids, offset + batch.length, config, result);
|
||||
});
|
||||
}
|
||||
|
||||
private _extendItem(id: number, config: IExpiryConfig): Promise<IUpdateResult> {
|
||||
return this.getItemDates(id, config).then((item: IItemDateValues): Promise<IUpdateResult> => {
|
||||
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<IItemDateValues> {
|
||||
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<IItemDateValues> => {
|
||||
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<IItemDateValues> => {
|
||||
if (!response.ok) {
|
||||
return responseError(response, 'Element ' + id + ' konnte nicht gelesen werden')
|
||||
.then((error: Error): Promise<IItemDateValues> => 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<string[]> {
|
||||
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<any> =>
|
||||
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<void> {
|
||||
return this._getEntityType().then((entityType: string): Promise<void> => {
|
||||
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<void> => {
|
||||
if (!response.ok) {
|
||||
return responseError(response, 'Element ' + id + ' konnte nicht aktualisiert werden')
|
||||
.then((error: Error): Promise<void> => Promise.reject(error));
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _getEntityType(): Promise<string> {
|
||||
if (!this._entityTypePromise) {
|
||||
this._entityTypePromise = this._spHttpClient.get(
|
||||
this._listUrl() + '?$select=ListItemEntityTypeFullName',
|
||||
SPHttpClient.configurations.v1
|
||||
).then((response: SPHttpClientResponse): Promise<string> => {
|
||||
if (!response.ok) {
|
||||
return responseError(response, 'Listentyp konnte nicht gelesen werden')
|
||||
.then((error: Error): Promise<string> => 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 + "')";
|
||||
}
|
||||
}
|
||||
240
src/common/ExpiryModels.ts
Normal file
240
src/common/ExpiryModels.ts
Normal file
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
20
src/common/RestError.ts
Normal file
20
src/common/RestError.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SPHttpClientResponse } from '@microsoft/sp-http';
|
||||
|
||||
export function responseError(response: SPHttpClientResponse, operation: string): Promise<Error> {
|
||||
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, "''");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
157
src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts
Normal file
157
src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts
Normal file
@@ -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<IExpiryIndicatorFieldCustomizerProperties> {
|
||||
|
||||
private _config: IExpiryConfig = createDefaultConfig();
|
||||
private _itemService: ExpiryItemService;
|
||||
private _itemCache: { [id: string]: Promise<IItemDateValues> } = {};
|
||||
|
||||
@override
|
||||
public onInit(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/extensions/expiryIndicator/loc/de-de.js
Normal file
7
src/extensions/expiryIndicator/loc/de-de.js
Normal file
@@ -0,0 +1,7 @@
|
||||
define([], function() {
|
||||
return {
|
||||
"CalculatedHint": "aus Created berechnet",
|
||||
"DaysUntilExpiry": "Verbleibende Tage: {0}",
|
||||
"Loading": "Wird berechnet …"
|
||||
};
|
||||
});
|
||||
7
src/extensions/expiryIndicator/loc/en-us.js
Normal file
7
src/extensions/expiryIndicator/loc/en-us.js
Normal file
@@ -0,0 +1,7 @@
|
||||
define([], function() {
|
||||
return {
|
||||
"CalculatedHint": "calculated from Created",
|
||||
"DaysUntilExpiry": "Days remaining: {0}",
|
||||
"Loading": "Calculating…"
|
||||
};
|
||||
});
|
||||
10
src/extensions/expiryIndicator/loc/mystrings.d.ts
vendored
Normal file
10
src/extensions/expiryIndicator/loc/mystrings.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare interface IExpiryIndicatorStrings {
|
||||
CalculatedHint: string;
|
||||
DaysUntilExpiry: string;
|
||||
Loading: string;
|
||||
}
|
||||
|
||||
declare module 'ExpiryIndicatorStrings' {
|
||||
const strings: IExpiryIndicatorStrings;
|
||||
export = strings;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IExpiryIndicatorCommandSetProperties> {
|
||||
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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<void> = (): Promise<void> => {
|
||||
return this._itemService.extendItems(ids, this._config).then((results: IUpdateResult[]): Promise<void> => {
|
||||
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<void> => 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<void> => {
|
||||
return this._configService.saveConfig(listId, config).then((): Promise<void> => {
|
||||
this._config = config;
|
||||
return this._refreshFieldState();
|
||||
});
|
||||
}
|
||||
);
|
||||
dialog.show().then((): void => { this.raiseOnChange(); });
|
||||
}
|
||||
|
||||
private _refreshFieldState(): Promise<void> {
|
||||
return this._itemService.fieldExists(this._config.expiryField).then((exists: boolean): void => {
|
||||
this._expiryFieldExists = exists;
|
||||
this.raiseOnChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
125
src/extensions/expiryIndicatorCommandSet/ExpirySettingsDialog.ts
Normal file
125
src/extensions/expiryIndicatorCommandSet/ExpirySettingsDialog.ts
Normal file
@@ -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<void>;
|
||||
|
||||
public constructor(config: IExpiryConfig, save: (config: IExpiryConfig) => Promise<void>) {
|
||||
super();
|
||||
this._config = config;
|
||||
this._save = save;
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.domElement.innerHTML =
|
||||
'<div style="max-width:760px;padding:20px;font-family:Segoe UI,Arial,sans-serif">' +
|
||||
'<h2 style="margin-top:0">' + strings.SettingsTitle + '</h2>' +
|
||||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">' +
|
||||
this._input('baseField', strings.BaseField) +
|
||||
this._input('expiryField', strings.ExpiryField) +
|
||||
this._input('lifetimeValue', strings.DefaultLifetimeValue, 'number') +
|
||||
'<label>' + strings.DefaultLifetimeUnit +
|
||||
'<select data-field="lifetimeUnit" style="display:block;width:100%;padding:6px;margin-top:4px">' +
|
||||
'<option value="days">days</option><option value="months">months</option><option value="years">years</option>' +
|
||||
'</select>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<label style="display:block;margin-top:12px">' + strings.DefaultColumnRulesJson +
|
||||
'<textarea data-field="defaultColumnRule" rows="10" style="display:block;width:100%;box-sizing:border-box;font-family:Consolas,monospace;margin-top:4px"></textarea>' +
|
||||
'</label>' +
|
||||
'<label style="display:block;margin-top:12px">' + strings.RulesJson +
|
||||
'<textarea data-field="rules" rows="12" style="display:block;width:100%;box-sizing:border-box;font-family:Consolas,monospace;margin-top:4px"></textarea>' +
|
||||
'</label>' +
|
||||
'<label style="display:block;margin-top:12px"><input data-field="confirmExtension" type="checkbox"> ' +
|
||||
strings.ConfirmExtension + '</label>' +
|
||||
'<label style="display:block;margin-top:12px">' + strings.NullText +
|
||||
'<input data-field="nullText" type="text" style="display:block;width:100%;box-sizing:border-box;padding:6px;margin-top:4px">' +
|
||||
'</label>' +
|
||||
'<div data-field="error" style="color:#a4262c;min-height:20px;margin-top:10px"></div>' +
|
||||
'<div style="text-align:right;margin-top:12px">' +
|
||||
'<button data-action="cancel" type="button" style="padding:7px 18px;margin-right:8px">' + strings.Cancel + '</button>' +
|
||||
'<button data-action="save" type="button" style="padding:7px 18px;background:#0078d4;color:#fff;border:0">' + strings.Save + '</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
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 '<label>' + label + '<input data-field="' + field + '" type="' + (type || 'text') +
|
||||
'" style="display:block;width:100%;box-sizing:border-box;padding:6px;margin-top:4px"></label>';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
19
src/extensions/expiryIndicatorCommandSet/loc/de-de.js
Normal file
19
src/extensions/expiryIndicatorCommandSet/loc/de-de.js
Normal file
@@ -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?"
|
||||
};
|
||||
});
|
||||
19
src/extensions/expiryIndicatorCommandSet/loc/en-us.js
Normal file
19
src/extensions/expiryIndicatorCommandSet/loc/en-us.js
Normal file
@@ -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?"
|
||||
};
|
||||
});
|
||||
22
src/extensions/expiryIndicatorCommandSet/loc/mystrings.d.ts
vendored
Normal file
22
src/extensions/expiryIndicatorCommandSet/loc/mystrings.d.ts
vendored
Normal file
@@ -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;
|
||||
}
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
8
tslint.json
Normal file
8
tslint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./node_modules/@microsoft/sp-tslint-rules/base-tslint.json",
|
||||
"rules": {
|
||||
"no-any": false,
|
||||
"no-string-based-set-timeout": false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user