7 Commits

Author SHA1 Message Date
Torsten Brendgen
d97dbf45aa feat: update version to 2.1.2 and enhance ExpiryIndicator functionality with new binding handling 2026-07-18 23:48:40 +02:00
Torsten Brendgen
69a96b90c5 feat: update version to 2.1.1 and enhance property normalization in ExpiryIndicator functionality 2026-07-18 21:33:05 +02:00
Torsten Brendgen
2a642766d1 feat: enable registration of new bindings via PortalSettings popup without PowerShell and update ClassicScriptUrl path 2026-07-18 21:02:33 +02:00
Torsten Brendgen
0b8b752837 feat: update to version 2.1.0 with central site collection configuration and inheritance support 2026-07-18 20:42:00 +02:00
Torsten Brendgen
0d6da3451d chore: update version to 2.0.1 and enhance Classic functionality with dynamic field discovery 2026-07-18 20:02:49 +02:00
Torsten Brendgen
0d6768e24c feat: add ExpiryIndicatorClassic functionality with configuration and rendering
- Implemented ExpiryIndicatorClassic.js for managing expiry dates in SharePoint lists.
- Created classic-elements.xml to define the module for ExpiryIndicator assets.
- Added upgrade-actions-v2.xml to apply the new element manifests.
- Introduced ExpiryModels.test.ts for unit testing the expiry configuration and rules validation.
2026-07-18 19:52:50 +02:00
Torsten Brendgen
f6e3af23c2 Aktualisiere ToDo.md mit abgeschlossenen Umsetzungsschritten und füge Roadmap für Version 2.0 hinzu, einschließlich logischer Verkettung von Regeln und Unterstützung klassischer SharePoint-Ansichten. 2026-07-18 00:07:16 +02:00
22 changed files with 19074 additions and 66 deletions

139
README.md
View File

@@ -1,14 +1,16 @@
# ExpiryIndicator # ExpiryIndicator
SPFx-1.4.1-Solution für moderne Listen und Dokumentbibliotheken in SharePoint Server Subscription Edition. SPFx-1.4.1-Solution für moderne und klassische Listen und Dokumentbibliotheken in SharePoint Server Subscription Edition.
## Funktionen ## Funktionen
- Farbliche Anzeige einer vorhandenen, frei wählbaren Ablaufdatumsspalte. - Farbliche Anzeige einer vorhandenen, frei wählbaren Ablaufdatumsspalte.
- Fallback-Berechnung aus einem konfigurierbaren Erstellungsfeld und `defaultLifetime`. - Fallback-Berechnung aus einem konfigurierbaren Erstellungsfeld und `defaultLifetime`.
- Priorisierte Regeln mit eigener Laufzeit und eigenen Farbschwellen anhand vorhandener Feldwerte. - Priorisierte Regeln mit eigener Laufzeit und eigenen Farbschwellen anhand vorhandener Feldwerte.
- Verschachtelte V2-Bedingungsgruppen mit den logischen Verknüpfungen `AND` und `OR`.
- Command-Bar-Befehl zum Verlängern eines oder mehrerer Elemente um ein Kalenderjahr. - Command-Bar-Befehl zum Verlängern eines oder mehrerer Elemente um ein Kalenderjahr.
- Pro Liste/Bibliothek gespeicherte Konfiguration. - CSR/JSLink-Darstellung und Ribbon-Befehl für klassische SharePoint-Ansichten.
- Zentraler Site-Collection-Standard mit optionalen Subweb- und Listen-Ausnahmen.
Die Solution provisioniert ausdrücklich keine fachliche Ablaufdatumsspalte und keinen Content Type. Die Solution provisioniert ausdrücklich keine fachliche Ablaufdatumsspalte und keinen Content Type.
@@ -210,6 +212,77 @@ In **Feldwertregeln** wird nur das JSON-Array für `rules` eingetragen. Folgende
] ]
``` ```
Das bisherige Format mit `columnName` und `columnValue` bleibt in Version 2.0 vollständig kompatibel.
### Verkettete Regeln mit AND und OR (Version 2.0)
Neue Regeln verwenden `condition`. Eine Gruppe enthält `operator` mit `and` oder `or` sowie ein Array
`conditions`. Jeder Eintrag ist entweder eine Feldbedingung oder eine weitere Gruppe. Dieses Beispiel gilt,
wenn `eGovPersDat` dem angegebenen Taxonomy-Term entspricht **und** der Status `Freigegeben` oder `Genehmigt` ist:
```json
[
{
"condition": {
"operator": "and",
"conditions": [
{
"columnName": "eGovPersDat",
"columnValue": "0d19386a-ebe1-4955-ad12-164d7846bca6"
},
{
"operator": "or",
"conditions": [
{
"columnName": "Status",
"columnValue": "Freigegeben"
},
{
"columnName": "Status",
"columnValue": "Genehmigt"
}
]
}
]
},
"lifeTime": {
"value": 1,
"unit": "years"
},
"columnRule": [
{
"daysUntilExpiry": 0,
"operator": "lessOrEqual",
"backgroundColor": "#a4262c",
"textColor": "#ffffff",
"label": "Abgelaufen"
},
{
"daysUntilExpiry": 7,
"operator": "lessOrEqual",
"backgroundColor": "#ffaa44",
"textColor": "#000000",
"label": "Läuft bald ab"
},
{
"daysUntilExpiry": 14,
"operator": "lessOrEqual",
"backgroundColor": "#fff4ce",
"textColor": "#000000",
"label": "Beobachten"
}
]
}
]
```
- `and`: Alle enthaltenen Bedingungen müssen erfüllt sein.
- `or`: Mindestens eine enthaltene Bedingung muss erfüllt sein.
- Ein fehlendes Feld erfüllt eine Feldbedingung nicht.
- Gruppen dürfen bis zu zehn Ebenen tief verschachtelt werden.
- Die erste vollständig passende fachliche Regel gewinnt; andernfalls gilt `default`.
- Der Einstellungsdialog lehnt ungültige oder leere Bedingungsgruppen vor dem Speichern ab.
Die Regeln werden von oben nach unten geprüft. Dabei gilt: Die Regeln werden von oben nach unten geprüft. Dabei gilt:
1. Existiert `columnName` in der Liste oder Bibliothek? 1. Existiert `columnName` in der Liste oder Bibliothek?
@@ -236,6 +309,22 @@ Konfigurationsmechanismus wie andere SPFx-Extensions und benötigt keine zusätz
PortalSettings kann optional dieselben Component Properties verwalten, ist aber keine Voraussetzung für den PortalSettings kann optional dieselben Component Properties verwalten, ist aber keine Voraussetzung für den
Betrieb des ExpiryIndicators. Betrieb des ExpiryIndicators.
### Zentrale Vererbung mit PortalSettings (Version 2.1)
Version 2.1 kann einen Standard aus dem Property Bag des Root Webs auf alle registrierten Bindungen der
Site Collection vererben. Die Aufloesung erfolgt in dieser Reihenfolge:
1. lokale Konfiguration am Listenfeld,
2. optionaler Override im Property Bag des aktuellen Subwebs,
3. Site-Collection-Standard im Property Bag des Root Webs,
4. eingebauter Standard der Extension.
`Enable-ExpiryIndicator.ps1` registriert jede Bindung im Root Web und verwendet fuer neue oder bisher leere
Feld-Properties eine schlanke Vererbungsreferenz. Bestehende vollstaendige Listenkonfigurationen bleiben beim
erneuten Ausfuehren des Skripts erhalten. In PortalSettings kann man den zentralen Standard bearbeiten, einzelne
Bindungen auswaehlen oder alle registrierten Bindungen bewusst auf Vererbung umstellen. Eine versteckte Liste
wird nicht verwendet.
Bei einem Upgrade wird eine vorhandene Konfiguration aus der früheren ausgeblendeten Liste Bei einem Upgrade wird eine vorhandene Konfiguration aus der früheren ausgeblendeten Liste
`_ExpiryIndicatorConfiguration` weiterhin gelesen, solange noch keine Component Properties gespeichert wurden. `_ExpiryIndicatorConfiguration` weiterhin gelesen, solange noch keine Component Properties gespeichert wurden.
Beim nächsten Speichern wird sie in das gebundene Feld übernommen. Die alte Liste wird aus Sicherheitsgründen Beim nächsten Speichern wird sie in das gebundene Feld übernommen. Die alte Liste wird aus Sicherheitsgründen
@@ -258,6 +347,29 @@ Listenfeld registriert. Der interne Name wird anschließend aus dieser Bindung e
nur lesbar angezeigt. Das Command Set wird durch die Installation der App registriert. Danach die Listenansicht nur lesbar angezeigt. Das Command Set wird durch die Installation der App registriert. Danach die Listenansicht
neu laden. neu laden.
### Klassische SharePoint-Ansichten
Version 2.1 legt bei der App-Installation `ExpiryIndicatorClassic.js` unter
`SiteAssets/ExpiryIndicator` ab. Das Aktivierungsskript registriert standardmäßig für die angegebene Liste:
- einen einmaligen Web-`ScriptLink` für CSR/JSLink, der das gebundene Feld der aktuellen Liste selbst erkennt,
- die Berechnung, Beschriftung und den Zeilenverlauf in der klassischen Ansicht,
- den Ribbon-Befehl **Ablaufdatum +1 Jahr** im Tab **Files** beziehungsweise **Items**,
- den Einstellungsdialog über denselben URL-Parameter `expiryIndicatorSettings=1`.
Soll nur die moderne Ansicht aktiviert werden, kann die Classic-Registrierung ausgelassen werden:
```powershell
.\scripts\Enable-ExpiryIndicator.ps1 `
-SiteUrl 'https://sharepoint/sites/fachbereich' `
-ListTitle 'Dokumente' `
-ExpiryFieldInternalName 'CustomerExpiryDate' `
-SkipClassic
```
Die klassische Laufzeit verwendet dieselbe Vererbungskette und dieselben Feld-Properties wie die moderne
Laufzeit. Es gibt keine zweite Konfiguration.
### Ablaufdatum um ein Jahr verlängern ### Ablaufdatum um ein Jahr verlängern
Ein oder mehrere Elemente markieren und **Ablaufdatum +1 Jahr** auswählen. Ist bereits ein Ablaufdatum Ein oder mehrere Elemente markieren und **Ablaufdatum +1 Jahr** auswählen. Ist bereits ein Ablaufdatum
@@ -282,7 +394,7 @@ Das Paket wird als `sharepoint/solution/expiry-indicator.sppkg` erzeugt.
1. `expiry-indicator.sppkg` in den App Catalog der On-Premises-Farm laden. 1. `expiry-indicator.sppkg` in den App Catalog der On-Premises-Farm laden.
2. Die App in der gewünschten Site installieren. 2. Die App in der gewünschten Site installieren.
3. Eine moderne Liste/Bibliothek öffnen und wie im Abschnitt 3. Eine moderne oder klassische Liste/Bibliothek öffnen und wie im Abschnitt
**Konfiguration über den Application Customizer** beschrieben konfigurieren. **Konfiguration über den Application Customizer** beschrieben konfigurieren.
4. Den Field Customizer mit der vorhandenen Ablaufdatumsspalte verbinden: 4. Den Field Customizer mit der vorhandenen Ablaufdatumsspalte verbinden:
@@ -295,6 +407,21 @@ Das Paket wird als `sharepoint/solution/expiry-indicator.sppkg` erzeugt.
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. 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.
Alternativ kann die Bindung ohne PowerShell in PortalSettings über **ExpiryIndicator anbinden** erstellt werden.
Das Popup fragt Web-URL, Listentitel und internen Namen des bereits vorhandenen Datumsfeldes ab. Es erhält eine
vorhandene lokale Konfiguration, registriert das zentrale Bindungsinventar und kann optional die Classic-Aktionen
anlegen. Das Skript bleibt vor allem für automatisierte oder umfangreiche Rollouts sinnvoll.
### Upgrade auf Version 2.1
1. Das vorhandene Paket im App Catalog durch Version `2.1.2.0` ersetzen.
2. Die App in der Site aktualisieren. Die enthaltene Feature-UpgradeAction provisioniert ausschließlich das
aktualisierten Assets und legt keine fachliche Spalte an.
3. `Enable-ExpiryIndicator.ps1` für jede bereits angebundene Liste erneut ausführen. Das Skript erhält die
vorhandene Feldkonfiguration und ergänzt idempotent den gemeinsamen Web-ScriptLink sowie die
listenspezifische Ribbon-Registrierung sowie das zentrale Bindungsinventar.
4. Modern- und Classic-Ansicht mit geleertem Browsercache neu laden und prüfen.
## Deaktivierung ## Deaktivierung
```powershell ```powershell
@@ -304,4 +431,8 @@ Das Skript ist in einer SharePoint Management Shell beziehungsweise auf einem Re
-ExpiryFieldInternalName 'CustomerExpiryDate' -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. Das Skript entfernt die Field-Customizer-Verknüpfung sowie die listenspezifische Classic-Ribbon-Registrierung.
Die Spalte und alle fachlichen Daten bleiben erhalten. Der gemeinsam auf Web-Ebene verwendete ScriptLink bleibt
standardmäßig bestehen, da ihn weitere Listen benötigen können. Wenn keine weitere Liste den ExpiryIndicator
verwendet, kann er mit `-RemoveClassicWebScriptLink` entfernt werden. Die modernen Command-Set- und
Application-Customizer-Registrierungen werden durch das Entfernen der App aus der Site beseitigt.

72
ToDo.md
View File

@@ -104,19 +104,69 @@ Beispiel einer Laufzeitkonfiguration:
## Umsetzungsschritte ## Umsetzungsschritte
- [x] Architektur und Randbedingungen dokumentieren. - [x] Architektur und Randbedingungen dokumentieren.
- [ ] SPFx-1.4.1-Projektstruktur anlegen. - [x] SPFx-1.4.1-Projektstruktur anlegen.
- [ ] gemeinsame Modelle, Datumsberechnung und Regel-Auswertung implementieren. - [x] gemeinsame Modelle, Datumsberechnung und Regel-Auswertung implementieren.
- [ ] Regel-Engine für feldwertabhängige Laufzeiten und Farbschwellen implementieren. - [x] Regel-Engine für feldwertabhängige Laufzeiten und Farbschwellen implementieren.
- [ ] REST-Service für Konfiguration und Elementaktualisierungen implementieren. - [x] REST-Service für Konfiguration und Elementaktualisierungen implementieren.
- [ ] Field Customizer implementieren. - [x] Field Customizer implementieren.
- [x] ListView Command Set und Einstellungsdialog implementieren. - [x] ListView Command Set und Einstellungsdialog implementieren.
- [x] Settings-Application-Customizer mit URL-Aktivierung implementieren. - [x] Settings-Application-Customizer mit URL-Aktivierung implementieren.
- [ ] Feature-XML für Command-Bar-Registrierung erstellen. - [x] Feature-XML für Command-Bar-Registrierung erstellen.
- [ ] CSOM-Aktivierungs- und Deaktivierungsskripte für die vorhandene `ExpiryDate`-Spalte erstellen. - [x] CSOM-Aktivierungs- und Deaktivierungsskripte für die vorhandene `ExpiryDate`-Spalte erstellen.
- [ ] Lokalisierung Deutsch/Englisch ergänzen. - [x] Lokalisierung Deutsch/Englisch ergänzen.
- [ ] Build mit der SPFx-1.4.1-kompatiblen Legacy-Toolchain ausführen. - [x] Build mit der SPFx-1.4.1-kompatiblen Legacy-Toolchain ausführen.
- [ ] `.sppkg` erzeugen und Installationsanleitung ergänzen. - [x] `.sppkg` erzeugen und Installationsanleitung ergänzen.
- [ ] Tests für Kalenderarithmetik, Schwellwerte und leere Ablaufdaten durchführen. - [x] Tests für Kalenderarithmetik, Schwellwerte und leere Ablaufdaten durchführen.
## Roadmap Version 2.0
### Logische Verkettung von Regeln
- [x] Konfigurationsschema für mehrere Bedingungen innerhalb einer Regel definieren.
- [x] Bedingungen mit den logischen Operatoren `AND` und `OR` verknüpfen können.
- [x] Verschachtelte Bedingungsgruppen und eine eindeutige Auswertungsreihenfolge festlegen.
- [x] Regel-Engine um die Auswertung verketteter Bedingungen erweitern.
- [x] Bestehende Version-1-Regeln mit `columnName` und `columnValue` abwärtskompatibel weiter unterstützen.
- [x] Einstellungsdialog um verkettete Regeln erweitern.
- [x] Optionale PortalSettings-Integration um einen zentralen Editor für verkettete Regeln erweitern.
- [x] Konfiguration validieren und verständliche Fehlermeldungen für ungültige Regelgruppen ausgeben.
- [x] Tests für `AND`, `OR`, gemischte Gruppen, fehlende Felder und Fallback auf die Standardregel ergänzen.
### Unterstützung klassischer SharePoint-Ansichten
- [x] Technisches Konzept für klassische Listen und Bibliotheken erstellen, da SPFx Field Customizer und ListView Command Sets dort nicht ausgeführt werden.
- [x] Darstellung und Zeilenverlauf für die klassische Ansicht über CSR/JSLink realisieren.
- [x] Die Aktion **„Ablaufdatum +1 Jahr“** in der klassischen Ribbon-/Listenoberfläche bereitstellen.
- [x] Zugriff auf die ExpiryIndicator-Einstellungen auch aus klassischen Ansichten ermöglichen.
- [x] Aktivierungs-, Deaktivierungs- und Upgrade-Skripte um die Classic-Registrierungen erweitern.
- [x] Dieselbe Konfiguration und dasselbe Regelverhalten in moderner und klassischer Ansicht verwenden.
- [ ] Funktions-, Berechtigungs- und Darstellungstests für klassische Listen und Dokumentbibliotheken ergänzen.
### Release 2.0
- [x] Versionsstände von Solution, Feature und SPFx-Komponenten auf `2.0.1` anheben.
- [x] README um V2-Regeln, Classic-Aktivierung und Deaktivierung ergänzen.
- [x] Pakete mit Node.js 8.17.0 erstellen und lokale Package-Validierung erfolgreich ausführen.
- [ ] Feature-Upgrade in SharePoint Server Subscription Edition testen.
- [ ] Modern-/Classic-Abnahmetest auf einer Testliste und einer Test-Dokumentbibliothek durchführen.
## Release 2.1 zentrale Site-Collection-Konfiguration
- [x] Hierarchie `Liste > Subweb > Site Collection > eingebauter Standard` implementieren.
- [x] Moderne und klassische Laufzeit auf dieselbe Vererbungskette umstellen.
- [x] Solution-Descriptor und Bindungsinventar im Property Bag des Root Webs registrieren.
- [x] Bestehende vollständige Listenkonfigurationen beim erneuten Aktivieren erhalten.
- [x] Neue beziehungsweise leere Bindungen standardmäßig auf zentrale Vererbung stellen.
- [x] PortalSettings um providerbasierte Erkennung installierter Solutions erweitern.
- [x] ExpiryIndicator-Tab nur bei erkanntem Descriptor, Standard oder Bindungsinventar anzeigen.
- [x] Site-Collection-Standard in PortalSettings laden, validieren und speichern.
- [x] Registrierte Bindungen aus Root Web und Subwebs in PortalSettings anzeigen.
- [x] Einzelne oder alle registrierten Bindungen auf Vererbung umstellen können.
- [x] Neue Bindungen per PortalSettings-Popup ohne PowerShell registrieren können.
- [x] Optionale Classic-Aktionen beim Anbinden per PortalSettings idempotent registrieren.
- [x] Versionen anheben und ExpiryIndicator- sowie PortalSettings-Pakete mit Node.js 8.17.0 bauen.
- [ ] Upgrade und zentrale Vererbung in SharePoint Server Subscription Edition testen.
- [ ] Berechtigungen mit Site-Collection-Administrator und normalem Listenverwalter prüfen.
## Abnahmekriterien ## Abnahmekriterien

View File

@@ -3,7 +3,7 @@
"solution": { "solution": {
"name": "expiry-indicator-client-side-solution", "name": "expiry-indicator-client-side-solution",
"id": "68bb6d2d-9895-45b4-88e8-b98835faa981", "id": "68bb6d2d-9895-45b4-88e8-b98835faa981",
"version": "1.0.11.0", "version": "2.1.2.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": false, "skipFeatureDeployment": false,
"features": [ "features": [
@@ -11,10 +11,17 @@
"title": "ExpiryIndicator extension registration", "title": "ExpiryIndicator extension registration",
"description": "Registers ExpiryIndicator commands and its settings Application Customizer. Does not provision ExpiryDate.", "description": "Registers ExpiryIndicator commands and its settings Application Customizer. Does not provision ExpiryDate.",
"id": "913402af-ab9a-4974-9f86-5c2159ae41db", "id": "913402af-ab9a-4974-9f86-5c2159ae41db",
"version": "1.0.11.0", "version": "2.1.2.0",
"assets": { "assets": {
"elementManifests": [ "elementManifests": [
"elements.xml" "elements.xml",
"classic-elements.xml"
],
"elementFiles": [
"ExpiryIndicatorClassic.js"
],
"upgradeActions": [
"upgrade-actions-v2.xml"
] ]
} }
} }

17631
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "expiry-indicator", "name": "expiry-indicator",
"version": "1.0.11", "version": "2.1.2",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=6.9.0 <9.0.0" "node": ">=6.9.0 <9.0.0"
@@ -10,7 +10,8 @@
"clean": "gulp clean", "clean": "gulp clean",
"package-solution": "gulp package-solution", "package-solution": "gulp package-solution",
"package": "gulp clean && gulp bundle --ship && gulp package-solution --ship", "package": "gulp clean && gulp bundle --ship && gulp package-solution --ship",
"test": "gulp test" "test": "gulp bundle && npm run test:rules",
"test:rules": "tsc src/common/ExpiryModels.ts src/common/ExpiryModels.test.ts --target es5 --module commonjs --outDir temp/rule-tests --typeRoots ./node_modules/@types --types mocha,chai && mocha temp/rule-tests/ExpiryModels.test.js"
}, },
"dependencies": { "dependencies": {
"@microsoft/sp-application-base": "1.4.1", "@microsoft/sp-application-base": "1.4.1",

View File

@@ -9,21 +9,48 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ExpiryFieldInternalName, [string]$ExpiryFieldInternalName,
[Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde' [Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde',
[switch]$RemoveClassicWebScriptLink
) )
$isapiPath = Join-Path ([Environment]::GetFolderPath('CommonProgramFiles')) 'microsoft shared\Web Server Extensions\16\ISAPI' $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.Runtime.dll')
Add-Type -Path (Join-Path $isapiPath 'Microsoft.SharePoint.Client.dll') Add-Type -Path (Join-Path $isapiPath 'Microsoft.SharePoint.Client.dll')
function Expand-ExpiryIndicatorBindings {
param([object]$Value)
if ($null -eq $Value) { return }
if ($Value -is [System.Array]) {
foreach ($item in $Value) {
Expand-ExpiryIndicatorBindings -Value $item
}
return
}
if ($Value.PSObject.Properties['webUrl'] -and
$Value.PSObject.Properties['listId'] -and
$Value.PSObject.Properties['fieldInternalName']) {
Write-Output $Value
}
}
$context = New-Object Microsoft.SharePoint.Client.ClientContext($SiteUrl) $context = New-Object Microsoft.SharePoint.Client.ClientContext($SiteUrl)
$context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials $context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
try { try {
$list = $context.Web.Lists.GetByTitle($ListTitle) $list = $context.Web.Lists.GetByTitle($ListTitle)
$field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName)
$rootWeb = $context.Site.RootWeb
$customActions = $list.UserCustomActions
$webCustomActions = $context.Web.UserCustomActions
$context.Load($list) $context.Load($list)
$context.Load($field) $context.Load($field)
$context.Load($context.Web)
$context.Load($rootWeb)
$context.Load($rootWeb.AllProperties)
$context.Load($customActions)
$context.Load($webCustomActions)
$context.ExecuteQuery() $context.ExecuteQuery()
if ($field.ClientSideComponentId -ne $FieldCustomizerComponentId) { if ($field.ClientSideComponentId -ne $FieldCustomizerComponentId) {
@@ -34,8 +61,50 @@ try {
$field.ClientSideComponentId = [Guid]::Empty $field.ClientSideComponentId = [Guid]::Empty
$field.ClientSideComponentProperties = '' $field.ClientSideComponentProperties = ''
$field.Update() $field.Update()
$bindingsKey = 'PortalSettings.ExpiryIndicator.Bindings'
$bindings = @()
$existingBindingsJson = [string]$rootWeb.AllProperties[$bindingsKey]
if (-not [string]::IsNullOrWhiteSpace($existingBindingsJson)) {
try {
$parsedBindings = $existingBindingsJson | ConvertFrom-Json
$bindings = @(Expand-ExpiryIndicatorBindings -Value $parsedBindings)
}
catch {
$bindings = @()
}
}
$webUrl = $context.Web.Url.TrimEnd('/')
$listId = $list.Id.ToString('D')
$bindings = @($bindings | Where-Object {
-not (
([string]$_.webUrl).TrimEnd('/').Equals($webUrl, [StringComparison]::OrdinalIgnoreCase) -and
([string]$_.listId).Equals($listId, [StringComparison]::OrdinalIgnoreCase)
)
})
$rootWeb.AllProperties[$bindingsKey] = (ConvertTo-Json -InputObject @($bindings) -Compress -Depth 5)
$rootWeb.Update()
$customActions | Where-Object {
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or
$_.Name -eq 'ExpiryIndicator.Classic.Ribbon'
} | ForEach-Object {
$_.DeleteObject()
}
if ($RemoveClassicWebScriptLink) {
$webCustomActions | Where-Object {
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink'
} | ForEach-Object {
$_.DeleteObject()
}
}
$context.ExecuteQuery() $context.ExecuteQuery()
Write-Host 'ExpiryIndicator-Verknüpfung wurde entfernt. Feld und Daten bleiben unverändert.' Write-Host 'ExpiryIndicator-Verknüpfung und listenspezifische Classic-Registrierungen wurden entfernt. Feld und Daten bleiben unverändert.'
if (-not $RemoveClassicWebScriptLink) {
Write-Host 'Der gemeinsam verwendete Classic-Web-ScriptLink bleibt bestehen. Verwende -RemoveClassicWebScriptLink, wenn keine weitere Liste ihn benötigt.'
}
} }
} }
finally { finally {

View File

@@ -9,23 +9,50 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ExpiryFieldInternalName, [string]$ExpiryFieldInternalName,
[Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde' [Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde',
[string]$ClassicScriptUrl = '~sitecollection/SiteAssets/ExpiryIndicator/ExpiryIndicatorClassic.js',
[switch]$SkipClassic
) )
$isapiPath = Join-Path ([Environment]::GetFolderPath('CommonProgramFiles')) 'microsoft shared\Web Server Extensions\16\ISAPI' $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.Runtime.dll')
Add-Type -Path (Join-Path $isapiPath 'Microsoft.SharePoint.Client.dll') Add-Type -Path (Join-Path $isapiPath 'Microsoft.SharePoint.Client.dll')
function Expand-ExpiryIndicatorBindings {
param([object]$Value)
if ($null -eq $Value) { return }
if ($Value -is [System.Array]) {
foreach ($item in $Value) {
Expand-ExpiryIndicatorBindings -Value $item
}
return
}
if ($Value.PSObject.Properties['webUrl'] -and
$Value.PSObject.Properties['listId'] -and
$Value.PSObject.Properties['fieldInternalName']) {
Write-Output $Value
}
}
$context = New-Object Microsoft.SharePoint.Client.ClientContext($SiteUrl) $context = New-Object Microsoft.SharePoint.Client.ClientContext($SiteUrl)
$context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials $context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
try { try {
$list = $context.Web.Lists.GetByTitle($ListTitle) $list = $context.Web.Lists.GetByTitle($ListTitle)
$field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName)
$rootWeb = $context.Site.RootWeb
$customActions = $list.UserCustomActions $customActions = $list.UserCustomActions
$webCustomActions = $context.Web.UserCustomActions
$context.Load($list) $context.Load($list)
$context.Load($field) $context.Load($field)
$context.Load($context.Web)
$context.Load($rootWeb)
$context.Load($rootWeb.AllProperties)
$context.Load($customActions) $context.Load($customActions)
$context.Load($webCustomActions)
$context.ExecuteQuery() $context.ExecuteQuery()
if ($field.InternalName -ne $ExpiryFieldInternalName) { if ($field.InternalName -ne $ExpiryFieldInternalName) {
@@ -35,22 +62,130 @@ try {
$alreadyBound = $field.ClientSideComponentId -eq $FieldCustomizerComponentId $alreadyBound = $field.ClientSideComponentId -eq $FieldCustomizerComponentId
$field.ClientSideComponentId = $FieldCustomizerComponentId $field.ClientSideComponentId = $FieldCustomizerComponentId
if (-not $alreadyBound -or [string]::IsNullOrWhiteSpace($field.ClientSideComponentProperties)) { if (-not $alreadyBound -or [string]::IsNullOrWhiteSpace($field.ClientSideComponentProperties)) {
$field.ClientSideComponentProperties = (@{ $field.ClientSideComponentProperties = ([ordered]@{
schemaVersion = 2
expiryField = $field.InternalName expiryField = $field.InternalName
inheritSiteDefaults = $true
} | ConvertTo-Json -Compress) } | ConvertTo-Json -Compress)
} }
$field.Update() $field.Update()
# Remove the temporary list-scoped command registration used by older $descriptorKey = 'PortalSettings.Solutions.ExpiryIndicator'
# diagnostic scripts. The app feature owns the production registration. $bindingsKey = 'PortalSettings.ExpiryIndicator.Bindings'
$rootWeb.AllProperties[$descriptorKey] = ([ordered]@{
key = 'ExpiryIndicator'
displayName = 'Expiry Indicator'
version = '2.1.2'
provider = 'expiryIndicator'
componentIds = @(
'a555f4fc-d6a6-4421-8189-457449d9bbde',
'cd58f5d9-ffc8-4df7-a910-3054842d7abf',
'95691218-bcb1-4fad-adda-02bb12014d15'
)
settingsScope = @('siteCollection', 'web', 'list')
} | ConvertTo-Json -Compress -Depth 5)
$bindings = @()
$existingBindingsJson = [string]$rootWeb.AllProperties[$bindingsKey]
if (-not [string]::IsNullOrWhiteSpace($existingBindingsJson)) {
try {
$parsedBindings = $existingBindingsJson | ConvertFrom-Json
$bindings = @(Expand-ExpiryIndicatorBindings -Value $parsedBindings)
}
catch {
Write-Warning 'Das vorhandene ExpiryIndicator-Bindungsinventar war ungültig und wird neu aufgebaut.'
}
}
$webUrl = $context.Web.Url.TrimEnd('/')
$listId = $list.Id.ToString('D')
$bindings = @($bindings | Where-Object {
-not (
([string]$_.webUrl).TrimEnd('/').Equals($webUrl, [StringComparison]::OrdinalIgnoreCase) -and
([string]$_.listId).Equals($listId, [StringComparison]::OrdinalIgnoreCase)
)
})
$bindings += [ordered]@{
webUrl = $webUrl
listId = $listId
listTitle = $list.Title
fieldInternalName = $field.InternalName
fieldId = $field.Id.ToString('D')
}
$rootWeb.AllProperties[$bindingsKey] = (ConvertTo-Json -InputObject @($bindings) -Compress -Depth 5)
$rootWeb.Update()
# Remove old diagnostic registrations and make the V2 Classic
# registrations idempotent.
$customActions | Where-Object { $customActions | Where-Object {
$_.Name -eq 'ExpiryIndicator.CommandSet' $_.Name -eq 'ExpiryIndicator.CommandSet' -or
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or
$_.Name -eq 'ExpiryIndicator.Classic.Ribbon'
} | ForEach-Object { } | ForEach-Object {
$_.DeleteObject() $_.DeleteObject()
} }
if (-not $SkipClassic) {
$webCustomActions | Where-Object {
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink'
} | ForEach-Object {
$_.DeleteObject()
}
}
$context.ExecuteQuery() $context.ExecuteQuery()
if (-not $SkipClassic) {
# SharePoint permits Location=ScriptLink only on Web/Site custom
# actions, not on List.UserCustomActions. The runtime detects the
# current list and its bound expiry field by itself.
$scriptAction = $context.Web.UserCustomActions.Add()
$scriptAction.Name = 'ExpiryIndicator.Classic.ScriptLink'
$scriptAction.Title = 'ExpiryIndicator Classic runtime'
$scriptAction.Location = 'ScriptLink'
$scriptAction.ScriptSrc = $ClassicScriptUrl
$scriptAction.Sequence = 650
$scriptAction.Update()
$ribbonLocation = if ($list.BaseType -eq [Microsoft.SharePoint.Client.BaseType]::DocumentLibrary) {
'Ribbon.Documents.Manage.Controls._children'
}
else {
'Ribbon.ListItem.Actions.Controls._children'
}
$ribbonXml = @"
<CommandUIExtension xmlns="http://schemas.microsoft.com/sharepoint/">
<CommandUIDefinitions>
<CommandUIDefinition Location="$ribbonLocation">
<Button Id="ExpiryIndicator.Classic.ExtendOneYear.Button"
Command="ExpiryIndicator.Classic.ExtendOneYear"
Sequence="90"
LabelText="Ablaufdatum +1 Jahr"
Description="Verlängert das Ablaufdatum der ausgewählten Elemente um ein Kalenderjahr."
TemplateAlias="o1" />
</CommandUIDefinition>
</CommandUIDefinitions>
<CommandUIHandlers>
<CommandUIHandler Command="ExpiryIndicator.Classic.ExtendOneYear"
CommandAction="javascript:ExpiryIndicatorClassic.extendSelected();"
EnabledScript="javascript:ExpiryIndicatorClassic.canExtend();" />
</CommandUIHandlers>
</CommandUIExtension>
"@
$ribbonAction = $list.UserCustomActions.Add()
$ribbonAction.Name = 'ExpiryIndicator.Classic.Ribbon'
$ribbonAction.Title = 'ExpiryIndicator Classic ribbon command'
$ribbonAction.Location = 'CommandUI.Ribbon'
$ribbonAction.CommandUIExtension = $ribbonXml
$ribbonAction.Sequence = 651
$ribbonAction.Update()
$context.ExecuteQuery()
Write-Host 'Classic-Ansicht: Web-ScriptLink und listenspezifischer Ribbon-Befehl wurden registriert.'
}
Write-Host "ExpiryIndicator wurde für '$($list.Title)' an '$($field.InternalName)' gebunden." Write-Host "ExpiryIndicator wurde für '$($list.Title)' an '$($field.InternalName)' gebunden."
} }
finally { finally {

View File

@@ -0,0 +1,594 @@
(function (window, document) {
'use strict';
var FIELD_CUSTOMIZER_ID = 'a555f4fc-d6a6-4421-8189-457449d9bbde';
var SITE_DEFAULTS_PROPERTY = 'PortalSettings.ExpiryIndicator.Defaults';
var WEB_OVERRIDE_PROPERTY = 'PortalSettings.ExpiryIndicator.Override';
var state = { context: null, field: null, config: null, loading: null, itemEntityType: null };
function configuredExpiryField() {
var scripts = document.getElementsByTagName('script');
for (var index = scripts.length - 1; index >= 0; index--) {
var source = String(scripts[index].src || '');
if (source.indexOf('ExpiryIndicatorClassic.js') < 0) { continue; }
var match = /[?&]expiryField=([^&]+)/i.exec(source);
if (match) { return decodeURIComponent(match[1]); }
}
return '';
}
function discoverExpiryField() {
var id = listId(null);
if (!id) { return ''; }
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', listUrl(id) +
'/fields?$select=InternalName,ClientSideComponentId', false);
xhr.setRequestHeader('Accept', 'application/json;odata=verbose');
xhr.setRequestHeader('OData-Version', '3.0');
xhr.send(null);
if (xhr.status < 200 || xhr.status >= 300) { return ''; }
var data = JSON.parse(xhr.responseText || '{}');
var fields = data.d ? data.d.results : (data.value || []);
for (var index = 0; index < fields.length; index++) {
var componentId = String(fields[index].ClientSideComponentId || '').replace(/[{}]/g, '').toLowerCase();
if (componentId === FIELD_CUSTOMIZER_ID) { return fields[index].InternalName; }
}
} catch (ignore) { }
return '';
}
function pageContext() {
return window._spPageContextInfo || {};
}
function webUrl() {
return String(pageContext().webAbsoluteUrl || '').replace(/\/$/, '');
}
function siteUrl() {
return String(pageContext().siteAbsoluteUrl || webUrl()).replace(/\/$/, '');
}
function listId(context) {
return String((context && context.listName) || pageContext().pageListId || '').replace(/[{}]/g, '');
}
function request(method, url, body) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
xhr.setRequestHeader('Accept', 'application/json;odata=verbose');
xhr.setRequestHeader('Content-Type', 'application/json;odata=verbose');
xhr.setRequestHeader('OData-Version', '3.0');
if (method !== 'GET') {
var digest = document.getElementById('__REQUESTDIGEST');
if (digest) { xhr.setRequestHeader('X-RequestDigest', digest.value); }
}
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) { return; }
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText ? JSON.parse(xhr.responseText) : {});
} else {
var message = xhr.status + ' ' + xhr.statusText;
try {
var error = JSON.parse(xhr.responseText);
message = error.error && error.error.message ? error.error.message.value : message;
} catch (ignore) { }
reject(new Error(message));
}
};
xhr.send(body ? JSON.stringify(body) : null);
});
}
function listUrl(id) {
return webUrl() + "/_api/web/lists(guid'" + id + "')";
}
function defaults(expiryField) {
return {
baseField: 'Created',
expiryField: expiryField || 'ExpiryDate',
default: {
lifeTime: { value: 2, unit: 'years' },
columnRule: []
},
rules: [],
nullText: 'Kein Ablaufdatum',
confirmExtension: true
};
}
function mergeConfig(baseValue, overrideValue) {
var base = baseValue || defaults();
var override = overrideValue || {};
var result = {
baseField: override.baseField || base.baseField || 'Created',
expiryField: override.expiryField || base.expiryField || 'ExpiryDate',
default: {
lifeTime: override.default && override.default.lifeTime ? override.default.lifeTime :
(base.default && base.default.lifeTime ? base.default.lifeTime : { value: 2, unit: 'years' }),
columnRule: override.default && override.default.columnRule instanceof Array ? override.default.columnRule :
(base.default && base.default.columnRule instanceof Array ? base.default.columnRule : [])
},
rules: override.rules instanceof Array ? override.rules : (base.rules instanceof Array ? base.rules : []),
nullText: typeof override.nullText === 'string' ? override.nullText : base.nullText,
confirmExtension: typeof override.confirmExtension === 'boolean' ? override.confirmExtension : base.confirmExtension
};
return result;
}
function propertyValue(payload, key) {
var properties = payload && payload.d ? payload.d : (payload || {});
var expected = String(key || '').replace(/_x002e_/gi, '.').toLowerCase();
for (var name in properties) {
var normalizedName = String(name || '').replace(/_x002e_/gi, '.').toLowerCase();
if (Object.prototype.hasOwnProperty.call(properties, name) && normalizedName === expected) {
try { return typeof properties[name] === 'string' ? JSON.parse(properties[name]) : properties[name]; }
catch (ignore) { return null; }
}
}
return null;
}
function isBindingOnly(value) {
if (!value || typeof value !== 'object') { return false; }
return typeof value.expiryField === 'string' && !value.default && !value.rules &&
!value.baseField && typeof value.nullText === 'undefined' && typeof value.confirmExtension === 'undefined';
}
function loadConfig(context) {
var id = listId(context);
if (!id) { return Promise.reject(new Error('Keine Liste erkannt.')); }
if (state.loading && state.context === id) { return state.loading; }
state.context = id;
var fieldRequest = request('GET', listUrl(id) +
'/fields?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties');
var sitePropertiesRequest = request('GET', siteUrl() + '/_api/web/AllProperties').catch(function () { return {}; });
var webPropertiesRequest = siteUrl().toLowerCase() === webUrl().toLowerCase() ? Promise.resolve({}) :
request('GET', webUrl() + '/_api/web/AllProperties').catch(function () { return {}; });
state.loading = Promise.all([fieldRequest, sitePropertiesRequest, webPropertiesRequest])
.then(function (results) {
var data = results[0];
var fields = data.d ? data.d.results : (data.value || []);
var field;
for (var index = 0; index < fields.length; index++) {
var componentId = String(fields[index].ClientSideComponentId || '').replace(/[{}]/g, '').toLowerCase();
if (componentId === FIELD_CUSTOMIZER_ID) { field = fields[index]; break; }
}
if (!field) { throw new Error('Kein ExpiryIndicator-Feld gebunden.'); }
var inherited = mergeConfig(defaults(field.InternalName), propertyValue(results[1], SITE_DEFAULTS_PROPERTY));
inherited = mergeConfig(inherited, propertyValue(results[2], WEB_OVERRIDE_PROPERTY));
var config = inherited;
try {
if (field.ClientSideComponentProperties) {
var parsed = JSON.parse(field.ClientSideComponentProperties);
if (parsed.inheritSiteDefaults === true || parsed.override || isBindingOnly(parsed)) {
config = mergeConfig(inherited, parsed.override || {});
} else if (parsed.inheritSiteDefaults === false) {
config = mergeConfig(defaults(field.InternalName), parsed.override || {});
} else {
config = mergeConfig(defaults(field.InternalName), parsed);
}
}
} catch (ignore) { }
config.expiryField = field.InternalName;
state.field = field;
state.config = config;
return config;
});
return state.loading;
}
function ruleFields(config) {
var result = [];
function add(name) {
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name || '') && result.indexOf(name) < 0) { result.push(name); }
}
function visit(condition) {
if (!condition) { return; }
if (condition.conditions instanceof Array) {
for (var index = 0; index < condition.conditions.length; index++) { visit(condition.conditions[index]); }
} else { add(condition.columnName); }
}
for (var index = 0; index < (config.rules || []).length; index++) {
var rule = config.rules[index];
if (rule.condition) { visit(rule.condition); } else { add(rule.columnName); }
}
return result;
}
function equalsIgnoreCase(left, right) {
return String(left).toLowerCase() === String(right).toLowerCase();
}
function valueMatches(rawValue, configuredValue) {
if (rawValue === null || typeof rawValue === 'undefined') { return String(configuredValue) === ''; }
var values = rawValue instanceof Array ? rawValue :
(rawValue.results instanceof Array ? rawValue.results : [rawValue]);
for (var index = 0; index < values.length; index++) {
var value = values[index];
if (value && typeof value === 'object') {
if (value.TermGuid && equalsIgnoreCase(value.TermGuid, configuredValue)) { return true; }
value = value.Title || value.LookupValue || value.Label || value.Value || '';
}
var text = String(value);
if (text === String(configuredValue)) { return true; }
var separator = text.lastIndexOf('|');
if (separator >= 0 && equalsIgnoreCase(text.substring(separator + 1), configuredValue)) { return true; }
}
return false;
}
function conditionMatches(condition, values) {
if (condition && condition.conditions instanceof Array) {
if (condition.operator === 'and') {
for (var andIndex = 0; andIndex < condition.conditions.length; andIndex++) {
if (!conditionMatches(condition.conditions[andIndex], values)) { return false; }
}
return condition.conditions.length > 0;
}
if (condition.operator === 'or') {
for (var orIndex = 0; orIndex < condition.conditions.length; orIndex++) {
if (conditionMatches(condition.conditions[orIndex], values)) { return true; }
}
}
return false;
}
return !!condition && Object.prototype.hasOwnProperty.call(values, condition.columnName) &&
valueMatches(values[condition.columnName], condition.columnValue);
}
function validateRules(rules) {
var errors = [];
function safeName(value) { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value || ''); }
function validateGroup(group, path, depth) {
if (depth > 10) { errors.push(path + ': maximale Verschachtelungstiefe 10 überschritten.'); return; }
if (!group || (group.operator !== 'and' && group.operator !== 'or')) {
errors.push(path + '.operator muss "and" oder "or" sein.');
}
if (!group || !(group.conditions instanceof Array) || !group.conditions.length) {
errors.push(path + '.conditions muss mindestens eine Bedingung enthalten.'); return;
}
for (var index = 0; index < group.conditions.length; index++) {
var condition = group.conditions[index];
var childPath = path + '.conditions[' + index + ']';
if (condition && typeof condition.operator !== 'undefined') { validateGroup(condition, childPath, depth + 1); }
else if (!condition || !safeName(condition.columnName) || typeof condition.columnValue === 'undefined') {
errors.push(childPath + ': columnName/columnValue fehlen oder sind ungültig.');
}
}
}
if (!(rules instanceof Array)) { return ['rules muss ein JSON-Array sein.']; }
for (var index = 0; index < rules.length; index++) {
var rule = rules[index];
if (rule && rule.condition) { validateGroup(rule.condition, 'rules[' + index + '].condition', 0); }
else if (!rule || !safeName(rule.columnName) || typeof rule.columnValue === 'undefined') {
errors.push('rules[' + index + '] benötigt condition oder columnName/columnValue.');
}
}
return errors;
}
function behavior(config, values) {
for (var index = 0; index < (config.rules || []).length; index++) {
var rule = config.rules[index];
var matched = rule.condition ? conditionMatches(rule.condition, values) :
Object.prototype.hasOwnProperty.call(values, rule.columnName) &&
valueMatches(values[rule.columnName], rule.columnValue);
if (matched) { return rule; }
}
return config.default;
}
function parseDate(value) {
if (!value) { return null; }
var match = /\/Date\((-?\d+)/.exec(String(value));
var result = match ? new Date(Number(match[1])) : new Date(value);
return isNaN(result.getTime()) ? null : result;
}
function formatDate(value) {
function pad(number) { return number < 10 ? '0' + number : String(number); }
return pad(value.getDate()) + '.' + pad(value.getMonth() + 1) + '.' + value.getFullYear();
}
function addDuration(source, duration) {
var result = new Date(source.getTime());
if (duration.unit === 'days') {
result.setUTCDate(result.getUTCDate() + duration.value);
return result;
}
var months = duration.unit === 'years' ? duration.value * 12 : duration.value;
var day = result.getUTCDate();
result.setUTCDate(1);
result.setUTCMonth(result.getUTCMonth() + months);
var lastDay = new Date(Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0)).getUTCDate();
result.setUTCDate(Math.min(day, lastDay));
return result;
}
function dayDifference(from, to) {
var fromDay = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate());
var toDay = Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate());
return Math.round((toDay - fromDay) / 86400000);
}
function colorRule(days, rules) {
function matches(operator, threshold) {
if (operator === 'lessThan') { return days < threshold; }
if (operator === 'lessOrEqual') { return days <= threshold; }
if (operator === 'equal') { return days === threshold; }
if (operator === 'greaterOrEqual') { return days >= threshold; }
return operator === 'greaterThan' && days > threshold;
}
for (var index = 0; index < (rules || []).length; index++) {
if (matches(rules[index].operator, rules[index].daysUntilExpiry)) { return rules[index]; }
}
return null;
}
function findRow(id) {
var rows = document.querySelectorAll('tr[iid]');
for (var index = 0; index < rows.length; index++) {
var parts = String(rows[index].getAttribute('iid') || '').split(',');
if (String(parts[1]) === String(id)) { return rows[index]; }
}
return null;
}
function applyGradient(row, color, textColor, title) {
var allCells = row.querySelectorAll('td');
var cells = [];
for (var index = 0; index < allCells.length; index++) {
if (!allCells[index].querySelector('input[type="checkbox"]') &&
String(allCells[index].className).indexOf('ms-vb-itmcbx') < 0) {
cells.push(allCells[index]);
}
}
if (!cells.length) { return; }
var first = cells[0].getBoundingClientRect();
var last = cells[cells.length - 1].getBoundingClientRect();
var width = Math.max(1, last.right - first.left);
var gradient = 'linear-gradient(to right, #ffffff, ' + color + ')';
for (var cellIndex = 0; cellIndex < cells.length; cellIndex++) {
var rect = cells[cellIndex].getBoundingClientRect();
cells[cellIndex].style.backgroundColor = '#ffffff';
cells[cellIndex].style.backgroundImage = gradient;
cells[cellIndex].style.backgroundSize = width + 'px 100%';
cells[cellIndex].style.backgroundPosition = (-(rect.left - first.left)) + 'px 0';
cells[cellIndex].style.backgroundRepeat = 'no-repeat';
cells[cellIndex].style.color = textColor;
cells[cellIndex].title = title;
}
}
function render(context) {
loadConfig(context).then(function (config) {
var rows = context && context.ListData && context.ListData.Row ? context.ListData.Row : [];
var ids = [];
for (var index = 0; index < rows.length; index++) {
var id = Number(rows[index].ID || rows[index].Id);
if (id > 0) { ids.push(id); }
}
if (!ids.length) { return; }
var fields = [config.baseField, config.expiryField].concat(ruleFields(config));
var select = ['Id'];
for (var fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) {
if (select.indexOf(fields[fieldIndex]) < 0) { select.push(fields[fieldIndex]); }
}
var filters = [];
for (var idIndex = 0; idIndex < ids.length; idIndex++) { filters.push('Id eq ' + ids[idIndex]); }
return request('GET', listUrl(listId(context)) + '/items?$select=' + select.join(',') +
'&$filter=' + encodeURIComponent(filters.join(' or ')) + '&$top=' + ids.length)
.then(function (data) {
var items = data.d ? data.d.results : (data.value || []);
for (var itemIndex = 0; itemIndex < items.length; itemIndex++) {
var item = items[itemIndex];
var created = parseDate(item[config.baseField]);
var expiry = parseDate(item[config.expiryField]);
if (!created && !expiry) { continue; }
var selected = behavior(config, item);
var effective = expiry || addDuration(created, selected.lifeTime);
var days = dayDifference(new Date(), effective);
var rule = colorRule(days, selected.columnRule);
var row = findRow(item.Id);
var marker = row ? row.querySelector('[data-expiry-classic-item-id="' + item.Id + '"]') : null;
if (marker) {
marker.innerHTML = '';
marker.appendChild(document.createTextNode(formatDate(effective) +
(rule && rule.label ? ' ' + rule.label : '')));
marker.title = days + ' Resttage' + (!expiry ? ' aus ' + config.baseField + ' berechnet' : '');
}
if (row && rule) {
applyGradient(row, rule.backgroundColor, rule.textColor,
days + ' Resttage' + (rule.label ? ' ' + rule.label : ''));
}
}
});
}).catch(function (error) {
if (window.console && console.warn) { console.warn('ExpiryIndicator Classic:', error.message); }
});
}
function selectedIds() {
try {
var selected = SP.ListOperation.Selection.getSelectedItems();
var ids = [];
for (var index = 0; index < selected.length; index++) { ids.push(Number(selected[index].id)); }
return ids;
} catch (ignore) { return []; }
}
function getItemEntityType() {
if (state.itemEntityType) { return Promise.resolve(state.itemEntityType); }
return request('GET', listUrl(state.context) + '?$select=ListItemEntityTypeFullName')
.then(function (data) {
var list = data.d || data;
if (!list.ListItemEntityTypeFullName) {
throw new Error('Der REST-Entitätstyp der Liste konnte nicht ermittelt werden.');
}
state.itemEntityType = list.ListItemEntityTypeFullName;
return state.itemEntityType;
});
}
function extendOne(id, config) {
var fields = [config.baseField, config.expiryField].concat(ruleFields(config));
return Promise.all([
request('GET', listUrl(state.context) + '/items(' + id + ')?$select=Id,' + fields.join(',')),
getItemEntityType()
]).then(function (results) {
var data = results[0];
var entityType = results[1];
var item = data.d || data;
var created = parseDate(item[config.baseField]);
var expiry = parseDate(item[config.expiryField]);
var base = expiry || addDuration(created, behavior(config, item).lifeTime);
var updated = addDuration(base, { value: 1, unit: 'years' });
var body = { '__metadata': { 'type': item.__metadata && item.__metadata.type || entityType } };
body[config.expiryField] = updated.toISOString();
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('POST', listUrl(state.context) + '/items(' + id + ')', true);
xhr.setRequestHeader('Accept', 'application/json;odata=verbose');
xhr.setRequestHeader('Content-Type', 'application/json;odata=verbose');
xhr.setRequestHeader('OData-Version', '3.0');
xhr.setRequestHeader('IF-MATCH', '*');
xhr.setRequestHeader('X-HTTP-Method', 'MERGE');
var digest = document.getElementById('__REQUESTDIGEST');
if (digest) { xhr.setRequestHeader('X-RequestDigest', digest.value); }
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) { return; }
if (xhr.status >= 200 && xhr.status < 300) { resolve(); }
else { reject(new Error('#' + id + ': ' + xhr.status + ' ' + xhr.statusText)); }
};
xhr.send(JSON.stringify(body));
});
});
}
function extendSelected() {
var ids = selectedIds();
if (!ids.length) { alert('Bitte mindestens ein Element auswählen.'); return; }
loadConfig(state.context).then(function (config) {
if (config.confirmExtension && !confirm('Ablaufdatum für ' + ids.length + ' Element(e) um ein Jahr verlängern?')) {
return;
}
return Promise.all(ids.map(function (id) {
return extendOne(id, config).then(function () {
return { id: id, succeeded: true };
}, function (error) {
return { id: id, succeeded: false, error: error.message };
});
})).then(function (results) {
var succeeded = 0;
var errors = [];
for (var index = 0; index < results.length; index++) {
if (results[index].succeeded) { succeeded++; }
else { errors.push('#' + results[index].id + ': ' + results[index].error); }
}
var message = 'Erfolgreich aktualisiert: ' + succeeded + '; Fehler: ' + errors.length;
if (errors.length) { message += '\n\n' + errors.slice(0, 10).join('\n'); }
alert(message);
if (succeeded > 0) { window.location.reload(); }
});
}).catch(function (error) { alert(error.message); });
}
function canExtend() {
return selectedIds().length > 0;
}
function settingsRequested() {
return /(?:\?|&)expiryIndicatorSettings=(?:1|true|yes)(?:&|$)/i.test(window.location.search);
}
function openSettings() {
loadConfig(state.context).then(function (config) {
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;z-index:100000;inset:0;background:rgba(0,0,0,.35);padding:5vh 10vw';
var panel = document.createElement('div');
panel.style.cssText = 'background:#fff;padding:20px;height:80vh;font-family:Segoe UI,Arial;box-sizing:border-box';
panel.innerHTML = '<h2>ExpiryIndicator-Einstellungen</h2>' +
'<p>Vollständige JSON-Konfiguration für diese Liste/Bibliothek:</p>' +
'<textarea style="width:100%;height:60%;box-sizing:border-box;font-family:Consolas"></textarea>' +
'<div data-error style="color:#a4262c;min-height:24px"></div>' +
'<div style="text-align:right"><button data-cancel>Abbrechen</button> ' +
'<button data-save>Speichern</button></div>';
overlay.appendChild(panel);
document.body.appendChild(overlay);
var textarea = panel.querySelector('textarea');
textarea.value = JSON.stringify(config, null, 2);
panel.querySelector('[data-cancel]').onclick = function () { document.body.removeChild(overlay); };
panel.querySelector('[data-save]').onclick = function () {
var parsed;
try { parsed = JSON.parse(textarea.value); }
catch (error) { panel.querySelector('[data-error]').innerHTML = 'Ungültiges JSON: ' + error.message; return; }
var validationErrors = validateRules(parsed.rules);
if (validationErrors.length) {
panel.querySelector('[data-error]').innerHTML = validationErrors.join('<br>');
return;
}
parsed.expiryField = state.field.InternalName;
var body = {
'__metadata': { 'type': state.field.__metadata && state.field.__metadata.type || 'SP.Field' },
'ClientSideComponentProperties': JSON.stringify(parsed)
};
var fieldId = String(state.field.Id).replace(/[{}]/g, '');
var xhr = new XMLHttpRequest();
xhr.open('POST', listUrl(state.context) + "/fields(guid'" + fieldId + "')", true);
xhr.setRequestHeader('Accept', 'application/json;odata=verbose');
xhr.setRequestHeader('Content-Type', 'application/json;odata=verbose');
xhr.setRequestHeader('OData-Version', '3.0');
xhr.setRequestHeader('IF-MATCH', '*');
xhr.setRequestHeader('X-HTTP-Method', 'MERGE');
var digest = document.getElementById('__REQUESTDIGEST');
if (digest) { xhr.setRequestHeader('X-RequestDigest', digest.value); }
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) { return; }
if (xhr.status >= 200 && xhr.status < 300) { window.location.reload(); }
else { panel.querySelector('[data-error]').innerHTML = 'Speichern fehlgeschlagen: ' + xhr.statusText; }
};
xhr.send(JSON.stringify(body));
};
}).catch(function (error) { alert(error.message); });
}
window.ExpiryIndicatorClassic = {
canExtend: canExtend,
extendSelected: extendSelected,
openSettings: openSettings,
render: render
};
function register() {
if (window.SPClientTemplates && SPClientTemplates.TemplateManager) {
var overrides = { OnPostRender: render };
var expiryField = configuredExpiryField() || discoverExpiryField();
if (expiryField) {
overrides.Templates = { Fields: {} };
overrides.Templates.Fields[expiryField] = {
View: function (context) {
var item = context.CurrentItem || {};
var id = Number(item.ID || item.Id);
var date = parseDate(item[expiryField]);
var value = date ? formatDate(date) : '';
var encoded = window.STSHtmlEncode ? STSHtmlEncode(value) : value;
return '<span data-expiry-classic-item-id="' + id + '">' + encoded + '</span>';
}
};
}
SPClientTemplates.TemplateManager.RegisterTemplateOverrides(overrides);
}
if (settingsRequested()) { setTimeout(openSettings, 0); }
}
if (window.SPClientTemplates) { register(); }
else if (window._spBodyOnLoadFunctionNames) { window._spBodyOnLoadFunctionNames.push('ExpiryIndicatorClassicRegister'); }
window.ExpiryIndicatorClassicRegister = register;
if (window.NotifyScriptLoadedAndExecuteWaitingJobs) {
window.NotifyScriptLoadedAndExecuteWaitingJobs('ExpiryIndicatorClassic.js');
}
})(window, document);

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Module Name="ExpiryIndicatorClassicAssets" Url="SiteAssets/ExpiryIndicator">
<File
Path="ExpiryIndicatorClassic.js"
Url="ExpiryIndicatorClassic.js"
Type="GhostableInLibrary"
ReplaceContent="TRUE" />
</Module>
</Elements>

View File

@@ -0,0 +1,3 @@
<ApplyElementManifests>
<ElementManifest Location="913402af-ab9a-4974-9f86-5c2159ae41db\classic-elements.xml" />
</ApplyElementManifests>

View File

@@ -3,7 +3,7 @@ import {
SPHttpClient, SPHttpClient,
SPHttpClientResponse SPHttpClientResponse
} from '@microsoft/sp-http'; } from '@microsoft/sp-http';
import { createDefaultConfig, IExpiryConfig, normalizeConfig } from './ExpiryModels'; import { createDefaultConfig, IExpiryConfig, mergeConfigValues, normalizeConfig } from './ExpiryModels';
import { escapeODataString, responseError } from './RestError'; import { escapeODataString, responseError } from './RestError';
interface IConfigurationListInfo { interface IConfigurationListInfo {
@@ -27,19 +27,29 @@ interface IBoundFieldInfo {
const CONFIGURATION_LIST_TITLE: string = '_ExpiryIndicatorConfiguration'; const CONFIGURATION_LIST_TITLE: string = '_ExpiryIndicatorConfiguration';
const CONFIGURATION_FIELD: string = 'ExpiryIndicatorJson'; const CONFIGURATION_FIELD: string = 'ExpiryIndicatorJson';
const FIELD_CUSTOMIZER_COMPONENT_ID: string = 'a555f4fc-d6a6-4421-8189-457449d9bbde'; const FIELD_CUSTOMIZER_COMPONENT_ID: string = 'a555f4fc-d6a6-4421-8189-457449d9bbde';
const SITE_DEFAULTS_PROPERTY: string = 'PortalSettings.ExpiryIndicator.Defaults';
const WEB_OVERRIDE_PROPERTY: string = 'PortalSettings.ExpiryIndicator.Override';
export class ExpiryConfigService { export class ExpiryConfigService {
private readonly _spHttpClient: SPHttpClient; private readonly _spHttpClient: SPHttpClient;
private readonly _webUrl: string; private readonly _webUrl: string;
private readonly _siteUrl: string;
private _inheritedConfigPromise: Promise<IExpiryConfig> | undefined;
public constructor(spHttpClient: SPHttpClient, webUrl: string) { public constructor(spHttpClient: SPHttpClient, webUrl: string, siteUrl?: string) {
this._spHttpClient = spHttpClient; this._spHttpClient = spHttpClient;
this._webUrl = webUrl.replace(/\/$/, ''); this._webUrl = webUrl.replace(/\/$/, '');
this._siteUrl = (siteUrl || webUrl).replace(/\/$/, '');
} }
public getConfig(listId: string): Promise<IExpiryConfig> { public getConfig(listId: string): Promise<IExpiryConfig> {
return this._getBoundField(listId).then((field: IBoundFieldInfo): Promise<IExpiryConfig> => { return this._getBoundField(listId).then((field: IBoundFieldInfo): Promise<IExpiryConfig> => {
const propertyConfig: IExpiryConfig | undefined = this._parseProperties(field.properties, field.internalName); return this._getInheritedConfig().then((inherited: IExpiryConfig): Promise<IExpiryConfig> => {
const propertyConfig: IExpiryConfig | undefined = this._parseProperties(
field.properties,
field.internalName,
inherited
);
if (propertyConfig) { if (propertyConfig) {
return Promise.resolve(propertyConfig); return Promise.resolve(propertyConfig);
} }
@@ -51,9 +61,9 @@ export class ExpiryConfigService {
legacyConfig.expiryField = field.internalName; legacyConfig.expiryField = field.internalName;
return legacyConfig; return legacyConfig;
} }
const defaults: IExpiryConfig = createDefaultConfig(); inherited.expiryField = field.internalName;
defaults.expiryField = field.internalName; return inherited;
return defaults; });
}); });
}); });
} }
@@ -132,12 +142,29 @@ export class ExpiryConfigService {
}); });
} }
private _parseProperties(value: string, internalName: string): IExpiryConfig | undefined { private _parseProperties(
value: string,
internalName: string,
inherited: IExpiryConfig
): IExpiryConfig | undefined {
if (!value || value.trim() === '' || value.trim() === '{}') { if (!value || value.trim() === '' || value.trim() === '{}') {
return undefined; return undefined;
} }
try { try {
const config: IExpiryConfig = normalizeConfig(JSON.parse(value)); const properties: any = JSON.parse(value);
let config: IExpiryConfig;
if (properties.inheritSiteDefaults === true || properties.override || this._isBindingOnly(properties)) {
config = properties.inheritSiteDefaults === false
? createDefaultConfig()
: mergeConfigValues(createDefaultConfig(), inherited);
if (properties.override) {
config = mergeConfigValues(config, properties.override);
}
} else {
// Existing V1/V2 field configurations remain list-local until an
// administrator explicitly enables inheritance.
config = normalizeConfig(properties);
}
config.expiryField = internalName; config.expiryField = internalName;
return config; return config;
} catch (error) { } catch (error) {
@@ -145,6 +172,64 @@ export class ExpiryConfigService {
} }
} }
private _getInheritedConfig(): Promise<IExpiryConfig> {
if (!this._inheritedConfigPromise) {
this._inheritedConfigPromise = Promise.all([
this._getPropertyBagValue(this._siteUrl, SITE_DEFAULTS_PROPERTY),
this._webUrl.toLowerCase() === this._siteUrl.toLowerCase()
? Promise.resolve(undefined)
: this._getPropertyBagValue(this._webUrl, WEB_OVERRIDE_PROPERTY)
]).then((values: any[]): IExpiryConfig => {
let config: IExpiryConfig = createDefaultConfig();
if (values[0]) {
config = mergeConfigValues(config, values[0]);
}
if (values[1]) {
config = mergeConfigValues(config, values[1]);
}
return config;
});
}
return this._inheritedConfigPromise;
}
private _getPropertyBagValue(webUrl: string, key: string): Promise<any | undefined> {
return this._spHttpClient.get(webUrl + '/_api/web/AllProperties', SPHttpClient.configurations.v1, {
headers: this._headers()
}).then((response: SPHttpClientResponse): Promise<any | undefined> => {
if (!response.ok) {
return Promise.resolve(undefined);
}
return response.json().then((data: any): any | undefined => {
const source: any = data.d || data;
let rawValue: any = source[key];
if (typeof rawValue === 'undefined') {
const expectedKey: string = key.replace(/_x002e_/gi, '.').toLowerCase();
Object.keys(source).some((propertyName: string): boolean => {
const normalizedName: string = propertyName.replace(/_x002e_/gi, '.').toLowerCase();
if (normalizedName === expectedKey) {
rawValue = source[propertyName];
return true;
}
return false;
});
}
if (typeof rawValue !== 'string' || rawValue.trim() === '') {
return undefined;
}
try {
return JSON.parse(rawValue);
} catch (error) {
return undefined;
}
});
});
}
private _isBindingOnly(properties: any): boolean {
return properties && isOnlyKnownBindingProperties(properties);
}
private _getConfigurationList(): Promise<any> { private _getConfigurationList(): Promise<any> {
const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName'; const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName';
return this._spHttpClient.get(url, SPHttpClient.configurations.v1) return this._spHttpClient.get(url, SPHttpClient.configurations.v1)
@@ -211,3 +296,15 @@ export class ExpiryConfigService {
return headers; return headers;
} }
} }
function isOnlyKnownBindingProperties(properties: any): boolean {
if (!properties || typeof properties.expiryField !== 'string') {
return false;
}
const configurationKeys: string[] = [
'baseField', 'default', 'rules', 'nullText', 'confirmExtension'
];
return !configurationKeys.some((key: string): boolean =>
typeof properties[key] !== 'undefined'
);
}

View File

@@ -0,0 +1,105 @@
/// <reference types="mocha" />
import { expect } from 'chai';
import {
createDefaultConfig,
IExpiryConfig,
mergeConfigValues,
normalizeConfig,
resolveBehavior,
validateValueRules
} from './ExpiryModels';
function configWithRules(rules: any[]): IExpiryConfig {
const config: any = createDefaultConfig();
config.rules = rules;
return normalizeConfig(config);
}
describe('ExpiryModels V2 rules', (): void => {
it('keeps matching legacy rules', (): void => {
const config: IExpiryConfig = configWithRules([{
columnName: 'DataPrivacy',
columnValue: 'PersDat1',
lifeTime: { value: 1, unit: 'years' },
columnRule: []
}]);
expect(resolveBehavior(config, { DataPrivacy: 'PersDat1' }).lifeTime.value).to.equal(1);
});
it('matches all conditions in an and group', (): void => {
const config: IExpiryConfig = configWithRules([{
condition: {
operator: 'and',
conditions: [
{ columnName: 'DataPrivacy', columnValue: 'PersDat1' },
{ columnName: 'Status', columnValue: 'Active' }
]
},
lifeTime: { value: 14, unit: 'days' },
columnRule: []
}]);
expect(resolveBehavior(config, { DataPrivacy: 'PersDat1', Status: 'Active' }).lifeTime.value).to.equal(14);
expect(resolveBehavior(config, { DataPrivacy: 'PersDat1', Status: 'Draft' }).lifeTime.value)
.to.equal(config.default.lifeTime.value);
});
it('supports nested or groups and missing fields', (): void => {
const config: IExpiryConfig = configWithRules([{
condition: {
operator: 'and',
conditions: [
{ columnName: 'Status', columnValue: 'Active' },
{
operator: 'or',
conditions: [
{ columnName: 'Country', columnValue: 'DE' },
{ columnName: 'Country', columnValue: 'AT' }
]
}
]
},
lifeTime: { value: 6, unit: 'months' },
columnRule: []
}]);
expect(resolveBehavior(config, { Status: 'Active', Country: 'AT' }).lifeTime.value).to.equal(6);
expect(resolveBehavior(config, { Status: 'Active' }).lifeTime.value).to.equal(config.default.lifeTime.value);
});
it('matches managed metadata by TermGuid', (): void => {
const termGuid: string = '0d19386a-ebe1-4955-ad12-164d7846bca6';
const config: IExpiryConfig = configWithRules([{
columnName: 'eGovPersDat',
columnValue: termGuid.toUpperCase(),
lifeTime: { value: 1, unit: 'years' },
columnRule: []
}]);
expect(resolveBehavior(config, {
eGovPersDat: { Label: '6', TermGuid: termGuid, WssId: 6 }
}).lifeTime.value).to.equal(1);
});
it('reports invalid condition groups', (): void => {
const errors: string[] = validateValueRules([{
condition: { operator: 'xor', conditions: [] }
}]);
expect(errors.length).to.be.greaterThan(0);
});
it('merges partial site and web defaults without losing inherited values', (): void => {
const siteConfig: IExpiryConfig = mergeConfigValues(createDefaultConfig(), {
baseField: 'DocumentCreated',
default: { lifeTime: { value: 3, unit: 'years' } },
nullText: 'Nicht berechenbar'
});
const webConfig: IExpiryConfig = mergeConfigValues(siteConfig, {
default: { columnRule: [] },
confirmExtension: false
});
expect(webConfig.baseField).to.equal('DocumentCreated');
expect(webConfig.default.lifeTime.value).to.equal(3);
expect(webConfig.default.columnRule.length).to.equal(0);
expect(webConfig.nullText).to.equal('Nicht berechenbar');
expect(webConfig.confirmExtension).to.equal(false);
});
});

View File

@@ -1,5 +1,6 @@
export type DurationUnit = 'days' | 'months' | 'years'; export type DurationUnit = 'days' | 'months' | 'years';
export type ColumnRuleOperator = 'lessThan' | 'lessOrEqual' | 'equal' | 'greaterOrEqual' | 'greaterThan'; export type ColumnRuleOperator = 'lessThan' | 'lessOrEqual' | 'equal' | 'greaterOrEqual' | 'greaterThan';
export type ExpiryLogicalOperator = 'and' | 'or';
export interface IExpiryDuration { export interface IExpiryDuration {
value: number; value: number;
@@ -19,11 +20,25 @@ export interface IExpiryBehavior {
columnRule: IExpiryColumnRule[]; columnRule: IExpiryColumnRule[];
} }
export interface IExpiryValueRule extends IExpiryBehavior { export interface IExpiryFieldCondition {
columnName: string; columnName: string;
columnValue: string; columnValue: string;
} }
export interface IExpiryConditionGroup {
operator: ExpiryLogicalOperator;
conditions: IExpiryCondition[];
}
export type IExpiryCondition = IExpiryFieldCondition | IExpiryConditionGroup;
export interface IExpiryValueRule extends IExpiryBehavior {
// Version 1 compatibility. New rules should use condition.
columnName?: string;
columnValue?: string;
condition?: IExpiryConditionGroup;
}
export interface IExpiryConfig { export interface IExpiryConfig {
baseField: string; baseField: string;
expiryField: string; expiryField: string;
@@ -86,10 +101,34 @@ export function normalizeConfig(value: any): IExpiryConfig {
}; };
} }
export function mergeConfigValues(baseValue: any, overrideValue: any): IExpiryConfig {
const base: IExpiryConfig = normalizeConfig(baseValue);
const override: any = overrideValue || {};
const overrideDefault: any = override.default || {};
return normalizeConfig({
baseField: isSafeInternalName(override.baseField) ? override.baseField : base.baseField,
expiryField: isSafeInternalName(override.expiryField) ? override.expiryField : base.expiryField,
default: {
lifeTime: overrideDefault.lifeTime || base.default.lifeTime,
columnRule: Array.isArray(overrideDefault.columnRule)
? overrideDefault.columnRule
: base.default.columnRule
},
rules: Array.isArray(override.rules) ? override.rules : base.rules,
nullText: typeof override.nullText === 'string' ? override.nullText : base.nullText,
confirmExtension: typeof override.confirmExtension === 'boolean'
? override.confirmExtension
: base.confirmExtension
});
}
export function getRuleFieldNames(config: IExpiryConfig): string[] { export function getRuleFieldNames(config: IExpiryConfig): string[] {
const fields: string[] = []; const fields: string[] = [];
config.rules.forEach((rule: IExpiryValueRule): void => { config.rules.forEach((rule: IExpiryValueRule): void => {
if (isSafeInternalName(rule.columnName) && fields.indexOf(rule.columnName) < 0) { if (rule.condition) {
collectConditionFieldNames(rule.condition, fields);
} else if (rule.columnName && isSafeInternalName(rule.columnName) && fields.indexOf(rule.columnName) < 0) {
fields.push(rule.columnName); fields.push(rule.columnName);
} }
}); });
@@ -102,11 +141,7 @@ export function resolveBehavior(
): IExpiryBehavior { ): IExpiryBehavior {
for (let index: number = 0; index < config.rules.length; index++) { for (let index: number = 0; index < config.rules.length; index++) {
const rule: IExpiryValueRule = config.rules[index]; const rule: IExpiryValueRule = config.rules[index];
if (!Object.prototype.hasOwnProperty.call(fieldValues, rule.columnName)) { if (ruleMatches(rule, fieldValues)) {
continue;
}
if (valueMatches(fieldValues[rule.columnName], rule.columnValue)) {
return { return {
lifeTime: rule.lifeTime, lifeTime: rule.lifeTime,
columnRule: rule.columnRule columnRule: rule.columnRule
@@ -153,21 +188,147 @@ function normalizeValueRules(value: any): IExpiryValueRule[] {
const rules: IExpiryValueRule[] = []; const rules: IExpiryValueRule[] = [];
value.forEach((rule: any): void => { value.forEach((rule: any): void => {
if (!rule || !isSafeInternalName(rule.columnName) || typeof rule.columnValue === 'undefined') { if (!rule) {
return;
}
const condition: IExpiryConditionGroup | undefined = normalizeConditionGroup(rule.condition, 0);
const hasLegacyCondition: boolean = isSafeInternalName(rule.columnName) &&
typeof rule.columnValue !== 'undefined';
if (!condition && !hasLegacyCondition) {
return; return;
} }
const columnRules: IExpiryColumnRule[] = normalizeColumnRules(rule.columnRule, []); const columnRules: IExpiryColumnRule[] = normalizeColumnRules(rule.columnRule, []);
rules.push({ const normalizedRule: IExpiryValueRule = {
columnName: rule.columnName,
columnValue: String(rule.columnValue),
lifeTime: normalizeDuration(rule.lifeTime, { value: 1, unit: 'years' }), lifeTime: normalizeDuration(rule.lifeTime, { value: 1, unit: 'years' }),
columnRule: columnRules columnRule: columnRules
}); };
if (condition) {
normalizedRule.condition = condition;
} else {
normalizedRule.columnName = rule.columnName;
normalizedRule.columnValue = String(rule.columnValue);
}
rules.push(normalizedRule);
}); });
return rules; return rules;
} }
export function validateValueRules(value: any): string[] {
if (!Array.isArray(value)) {
return ['rules muss ein JSON-Array sein.'];
}
const errors: string[] = [];
value.forEach((rule: any, index: number): void => {
const path: string = 'rules[' + index + ']';
if (!rule || typeof rule !== 'object') {
errors.push(path + ' muss ein Objekt sein.');
return;
}
const hasLegacyCondition: boolean = isSafeInternalName(rule.columnName) &&
typeof rule.columnValue !== 'undefined';
if (rule.condition) {
validateConditionGroup(rule.condition, path + '.condition', 0, errors);
} else if (!hasLegacyCondition) {
errors.push(path + ' benötigt condition oder columnName/columnValue.');
}
});
return errors;
}
function normalizeConditionGroup(value: any, depth: number): IExpiryConditionGroup | undefined {
if (!value || depth > 10 || !isLogicalOperator(value.operator) || !Array.isArray(value.conditions) ||
value.conditions.length === 0) {
return undefined;
}
const conditions: IExpiryCondition[] = [];
value.conditions.forEach((condition: any): void => {
if (condition && typeof condition.operator !== 'undefined') {
const group: IExpiryConditionGroup | undefined = normalizeConditionGroup(condition, depth + 1);
if (group) {
conditions.push(group);
}
} else if (condition && isSafeInternalName(condition.columnName) &&
typeof condition.columnValue !== 'undefined') {
conditions.push({
columnName: condition.columnName,
columnValue: String(condition.columnValue)
});
}
});
return conditions.length > 0 ? { operator: value.operator, conditions: conditions } : undefined;
}
function validateConditionGroup(value: any, path: string, depth: number, errors: string[]): void {
if (depth > 10) {
errors.push(path + ' überschreitet die maximale Verschachtelungstiefe 10.');
return;
}
if (!value || typeof value !== 'object') {
errors.push(path + ' muss ein Objekt sein.');
return;
}
if (!isLogicalOperator(value.operator)) {
errors.push(path + '.operator muss "and" oder "or" sein.');
}
if (!Array.isArray(value.conditions) || value.conditions.length === 0) {
errors.push(path + '.conditions muss mindestens eine Bedingung enthalten.');
return;
}
value.conditions.forEach((condition: any, index: number): void => {
const conditionPath: string = path + '.conditions[' + index + ']';
if (condition && typeof condition.operator !== 'undefined') {
validateConditionGroup(condition, conditionPath, depth + 1, errors);
} else if (!condition || !isSafeInternalName(condition.columnName) ||
typeof condition.columnValue === 'undefined') {
errors.push(conditionPath + ' benötigt einen gültigen columnName und columnValue.');
}
});
}
function ruleMatches(rule: IExpiryValueRule, fieldValues: { [fieldName: string]: any }): boolean {
if (rule.condition) {
return conditionMatches(rule.condition, fieldValues);
}
return !!rule.columnName && typeof rule.columnValue !== 'undefined' &&
Object.prototype.hasOwnProperty.call(fieldValues, rule.columnName) &&
valueMatches(fieldValues[rule.columnName], rule.columnValue);
}
function conditionMatches(condition: IExpiryCondition, fieldValues: { [fieldName: string]: any }): boolean {
if (isConditionGroup(condition)) {
if (condition.operator === 'and') {
return condition.conditions.every((child: IExpiryCondition): boolean => conditionMatches(child, fieldValues));
}
return condition.conditions.some((child: IExpiryCondition): boolean => conditionMatches(child, fieldValues));
}
return Object.prototype.hasOwnProperty.call(fieldValues, condition.columnName) &&
valueMatches(fieldValues[condition.columnName], condition.columnValue);
}
function collectConditionFieldNames(condition: IExpiryCondition, fields: string[]): void {
if (isConditionGroup(condition)) {
condition.conditions.forEach((child: IExpiryCondition): void => collectConditionFieldNames(child, fields));
} else if (fields.indexOf(condition.columnName) < 0) {
fields.push(condition.columnName);
}
}
function isConditionGroup(condition: IExpiryCondition): condition is IExpiryConditionGroup {
return typeof (condition as IExpiryConditionGroup).operator !== 'undefined';
}
function isLogicalOperator(value: any): value is ExpiryLogicalOperator {
return value === 'and' || value === 'or';
}
function normalizeDuration(value: any, fallback: IExpiryDuration): IExpiryDuration { function normalizeDuration(value: any, fallback: IExpiryDuration): IExpiryDuration {
if (!value || typeof value.value !== 'number' || !isFinite(value.value)) { if (!value || typeof value.value !== 'number' || !isFinite(value.value)) {
return fallback; return fallback;
@@ -182,6 +343,9 @@ function normalizeColumnRules(value: any, fallback: IExpiryColumnRule[]): IExpir
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return fallback; return fallback;
} }
if (value.length === 0) {
return [];
}
const rules: IExpiryColumnRule[] = []; const rules: IExpiryColumnRule[] = [];
value.forEach((rule: any): void => { value.forEach((rule: any): void => {

View File

@@ -4,7 +4,7 @@
"alias": "ExpiryIndicatorFieldCustomizer", "alias": "ExpiryIndicatorFieldCustomizer",
"componentType": "Extension", "componentType": "Extension",
"extensionType": "FieldCustomizer", "extensionType": "FieldCustomizer",
"version": "1.0.11", "version": "2.1.2",
"manifestVersion": 2, "manifestVersion": 2,
"requiresCustomScript": false "requiresCustomScript": false
} }

View File

@@ -33,7 +33,8 @@ export default class ExpiryIndicatorFieldCustomizer
const service: ExpiryConfigService = new ExpiryConfigService( const service: ExpiryConfigService = new ExpiryConfigService(
this.context.spHttpClient, this.context.spHttpClient,
this.context.pageContext.web.absoluteUrl this.context.pageContext.web.absoluteUrl,
this.context.pageContext.site.absoluteUrl
); );
this._itemService = new ExpiryItemService( this._itemService = new ExpiryItemService(

View File

@@ -4,7 +4,7 @@
"alias": "ExpiryIndicatorApplicationCustomizer", "alias": "ExpiryIndicatorApplicationCustomizer",
"componentType": "Extension", "componentType": "Extension",
"extensionType": "ApplicationCustomizer", "extensionType": "ApplicationCustomizer",
"version": "1.0.11", "version": "2.1.2",
"manifestVersion": 2, "manifestVersion": 2,
"requiresCustomScript": false "requiresCustomScript": false
} }

View File

@@ -32,7 +32,8 @@ export default class ExpiryIndicatorApplicationCustomizer
const listId: string = this.context.pageContext.list.id.toString(); const listId: string = this.context.pageContext.list.id.toString();
const service: ExpiryConfigService = new ExpiryConfigService( const service: ExpiryConfigService = new ExpiryConfigService(
this.context.spHttpClient, this.context.spHttpClient,
this.context.pageContext.web.absoluteUrl this.context.pageContext.web.absoluteUrl,
this.context.pageContext.site.absoluteUrl
); );
service.getConfig(listId).then((config: IExpiryConfig): void => { service.getConfig(listId).then((config: IExpiryConfig): void => {

View File

@@ -4,7 +4,7 @@
"alias": "ExpiryIndicatorCommandSet", "alias": "ExpiryIndicatorCommandSet",
"componentType": "Extension", "componentType": "Extension",
"extensionType": "ListViewCommandSet", "extensionType": "ListViewCommandSet",
"version": "1.0.11", "version": "2.1.2",
"manifestVersion": 2, "manifestVersion": 2,
"requiresCustomScript": false, "requiresCustomScript": false,
"items": { "items": {

View File

@@ -32,7 +32,11 @@ export default class ExpiryIndicatorCommandSet
const listId: string = this.context.pageContext.list.id.toString(); const listId: string = this.context.pageContext.list.id.toString();
const webUrl: string = this.context.pageContext.web.absoluteUrl; const webUrl: string = this.context.pageContext.web.absoluteUrl;
this._configService = new ExpiryConfigService(this.context.spHttpClient, webUrl); this._configService = new ExpiryConfigService(
this.context.spHttpClient,
webUrl,
this.context.pageContext.site.absoluteUrl
);
this._itemService = new ExpiryItemService(this.context.spHttpClient, webUrl, listId); this._itemService = new ExpiryItemService(this.context.spHttpClient, webUrl, listId);
this._canEdit = this.context.pageContext.list.permissions.hasPermission(SPPermission.editListItems); this._canEdit = this.context.pageContext.list.permissions.hasPermission(SPPermission.editListItems);

View File

@@ -3,7 +3,8 @@ import {
IExpiryConfig, IExpiryConfig,
IExpiryColumnRule, IExpiryColumnRule,
IExpiryValueRule, IExpiryValueRule,
normalizeConfig normalizeConfig,
validateValueRules
} from '../../common/ExpiryModels'; } from '../../common/ExpiryModels';
import * as strings from 'ExpiryIndicatorCommandSetStrings'; import * as strings from 'ExpiryIndicatorCommandSetStrings';
@@ -84,6 +85,10 @@ export class ExpirySettingsDialog extends BaseDialog {
try { try {
const defaultColumnRule: IExpiryColumnRule[] = JSON.parse(this._value('defaultColumnRule')); const defaultColumnRule: IExpiryColumnRule[] = JSON.parse(this._value('defaultColumnRule'));
const rules: IExpiryValueRule[] = JSON.parse(this._value('rules')); const rules: IExpiryValueRule[] = JSON.parse(this._value('rules'));
const ruleErrors: string[] = validateValueRules(rules);
if (ruleErrors.length > 0) {
throw new Error(ruleErrors.join('\n'));
}
const config: IExpiryConfig = normalizeConfig({ const config: IExpiryConfig = normalizeConfig({
baseField: this._value('baseField').trim(), baseField: this._value('baseField').trim(),
expiryField: this._value('expiryField').trim(), expiryField: this._value('expiryField').trim(),

View File

@@ -6,7 +6,7 @@ define([], function() {
"DefaultLifetimeValue": "Standardlaufzeit", "DefaultLifetimeValue": "Standardlaufzeit",
"DefaultLifetimeUnit": "Einheit", "DefaultLifetimeUnit": "Einheit",
"DefaultColumnRulesJson": "Standard-Farbregeln (default.columnRule als JSON)", "DefaultColumnRulesJson": "Standard-Farbregeln (default.columnRule als JSON)",
"RulesJson": "Feldwertregeln (rules als JSON, erste passende Regel gewinnt)", "RulesJson": "Feldwertregeln (V1 oder V2 mit condition sowie AND/OR; erste passende Regel gewinnt)",
"ConfirmExtension": "Verlängerung vorher bestätigen", "ConfirmExtension": "Verlängerung vorher bestätigen",
"NullText": "Text bei fehlendem Datum", "NullText": "Text bei fehlendem Datum",
"Cancel": "Abbrechen", "Cancel": "Abbrechen",

View File

@@ -6,7 +6,7 @@ define([], function() {
"DefaultLifetimeValue": "Default lifetime", "DefaultLifetimeValue": "Default lifetime",
"DefaultLifetimeUnit": "Unit", "DefaultLifetimeUnit": "Unit",
"DefaultColumnRulesJson": "Default color rules (default.columnRule JSON)", "DefaultColumnRulesJson": "Default color rules (default.columnRule JSON)",
"RulesJson": "Field value rules (rules JSON, first matching rule wins)", "RulesJson": "Field value rules (V1 or V2 condition with AND/OR; first matching rule wins)",
"ConfirmExtension": "Confirm before extending", "ConfirmExtension": "Confirm before extending",
"NullText": "Text when no date is available", "NullText": "Text when no date is available",
"Cancel": "Cancel", "Cancel": "Cancel",