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.
This commit is contained in:
113
README.md
113
README.md
@@ -1,13 +1,15 @@
|
|||||||
# 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.
|
||||||
|
- CSR/JSLink-Darstellung und Ribbon-Befehl für klassische SharePoint-Ansichten.
|
||||||
- Pro Liste/Bibliothek gespeicherte Konfiguration.
|
- Pro Liste/Bibliothek gespeicherte Konfiguration.
|
||||||
|
|
||||||
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?
|
||||||
@@ -258,6 +331,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.0 legt bei der App-Installation `ExpiryIndicatorClassic.js` unter
|
||||||
|
`SiteAssets/ExpiryIndicator` ab. Das Aktivierungsskript registriert standardmäßig für die angegebene Liste:
|
||||||
|
|
||||||
|
- einen listenspezifischen `ScriptLink` für CSR/JSLink,
|
||||||
|
- die Berechnung, Beschriftung und den Zeilenverlauf in der klassischen Ansicht,
|
||||||
|
- den Ribbon-Befehl **Ablaufdatum +1 Jahr**,
|
||||||
|
- 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 JSON-Konfiguration aus den
|
||||||
|
`ClientSideComponentProperties` des gebundenen Ablaufdatumsfeldes. 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 +378,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 +391,15 @@ 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.
|
||||||
|
|
||||||
|
### Upgrade von Version 1.x auf 2.0
|
||||||
|
|
||||||
|
1. Das vorhandene Paket im App Catalog durch Version `2.0.0.0` ersetzen.
|
||||||
|
2. Die App in der Site aktualisieren. Die enthaltene Feature-UpgradeAction provisioniert ausschließlich das
|
||||||
|
neue Classic-Asset 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 die Classic-ScriptLink- und Ribbon-Registrierungen.
|
||||||
|
4. Modern- und Classic-Ansicht mit geleertem Browsercache neu laden und prüfen.
|
||||||
|
|
||||||
## Deaktivierung
|
## Deaktivierung
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -304,4 +409,6 @@ 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 listenspezifischen Classic-ScriptLink- und
|
||||||
|
Ribbon-Registrierungen. Die Spalte und alle fachlichen Daten bleiben erhalten. Die modernen Command-Set- und
|
||||||
|
Application-Customizer-Registrierungen werden durch das Entfernen der App aus der Site beseitigt.
|
||||||
|
|||||||
37
ToDo.md
37
ToDo.md
@@ -122,25 +122,34 @@ Beispiel einer Laufzeitkonfiguration:
|
|||||||
|
|
||||||
### Logische Verkettung von Regeln
|
### Logische Verkettung von Regeln
|
||||||
|
|
||||||
- [ ] Konfigurationsschema für mehrere Bedingungen innerhalb einer Regel definieren.
|
- [x] Konfigurationsschema für mehrere Bedingungen innerhalb einer Regel definieren.
|
||||||
- [ ] Bedingungen mit den logischen Operatoren `AND` und `OR` verknüpfen können.
|
- [x] Bedingungen mit den logischen Operatoren `AND` und `OR` verknüpfen können.
|
||||||
- [ ] Verschachtelte Bedingungsgruppen und eine eindeutige Auswertungsreihenfolge festlegen.
|
- [x] Verschachtelte Bedingungsgruppen und eine eindeutige Auswertungsreihenfolge festlegen.
|
||||||
- [ ] Regel-Engine um die Auswertung verketteter Bedingungen erweitern.
|
- [x] Regel-Engine um die Auswertung verketteter Bedingungen erweitern.
|
||||||
- [ ] Bestehende Version-1-Regeln mit `columnName` und `columnValue` abwärtskompatibel weiter unterstützen.
|
- [x] Bestehende Version-1-Regeln mit `columnName` und `columnValue` abwärtskompatibel weiter unterstützen.
|
||||||
- [ ] Einstellungsdialog und die optionale PortalSettings-Integration um verkettete Regeln erweitern.
|
- [x] Einstellungsdialog um verkettete Regeln erweitern.
|
||||||
- [ ] Konfiguration validieren und verständliche Fehlermeldungen für ungültige Regelgruppen ausgeben.
|
- [x] Optionale PortalSettings-Integration um einen zentralen Editor für verkettete Regeln erweitern.
|
||||||
- [ ] Tests für `AND`, `OR`, gemischte Gruppen, fehlende Felder und Fallback auf die Standardregel ergänzen.
|
- [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
|
### Unterstützung klassischer SharePoint-Ansichten
|
||||||
|
|
||||||
- [ ] Technisches Konzept für klassische Listen und Bibliotheken erstellen, da SPFx Field Customizer und ListView Command Sets dort nicht ausgeführt werden.
|
- [x] Technisches Konzept für klassische Listen und Bibliotheken erstellen, da SPFx Field Customizer und ListView Command Sets dort nicht ausgeführt werden.
|
||||||
- [ ] Darstellung und Zeilenverlauf für die klassische Ansicht über eine geeignete Classic-Integration, beispielsweise CSR/JSLink, realisieren.
|
- [x] Darstellung und Zeilenverlauf für die klassische Ansicht über CSR/JSLink realisieren.
|
||||||
- [ ] Die Aktion **„Ablaufdatum +1 Jahr“** in der klassischen Ribbon-/Listenoberfläche bereitstellen.
|
- [x] Die Aktion **„Ablaufdatum +1 Jahr“** in der klassischen Ribbon-/Listenoberfläche bereitstellen.
|
||||||
- [ ] Zugriff auf die ExpiryIndicator-Einstellungen auch aus klassischen Ansichten ermöglichen.
|
- [x] Zugriff auf die ExpiryIndicator-Einstellungen auch aus klassischen Ansichten ermöglichen.
|
||||||
- [ ] Aktivierungs-, Deaktivierungs- und Upgrade-Skripte um die Classic-Registrierungen erweitern.
|
- [x] Aktivierungs-, Deaktivierungs- und Upgrade-Skripte um die Classic-Registrierungen erweitern.
|
||||||
- [ ] Dieselbe Konfiguration und Regel-Engine in moderner und klassischer Ansicht verwenden.
|
- [x] Dieselbe Konfiguration und dasselbe Regelverhalten in moderner und klassischer Ansicht verwenden.
|
||||||
- [ ] Funktions-, Berechtigungs- und Darstellungstests für klassische Listen und Dokumentbibliotheken ergänzen.
|
- [ ] 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.0` 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.
|
||||||
|
|
||||||
## Abnahmekriterien
|
## Abnahmekriterien
|
||||||
|
|
||||||
- Die Solution enthält und provisioniert keine Definition für eine fachliche Ablaufdatumsspalte.
|
- Die Solution enthält und provisioniert keine Definition für eine fachliche Ablaufdatumsspalte.
|
||||||
|
|||||||
@@ -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.0.0.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.0.0.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
17631
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "expiry-indicator",
|
"name": "expiry-indicator",
|
||||||
"version": "1.0.11",
|
"version": "2.0.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ $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)
|
||||||
|
$customActions = $list.UserCustomActions
|
||||||
$context.Load($list)
|
$context.Load($list)
|
||||||
$context.Load($field)
|
$context.Load($field)
|
||||||
|
$context.Load($customActions)
|
||||||
$context.ExecuteQuery()
|
$context.ExecuteQuery()
|
||||||
|
|
||||||
if ($field.ClientSideComponentId -ne $FieldCustomizerComponentId) {
|
if ($field.ClientSideComponentId -ne $FieldCustomizerComponentId) {
|
||||||
@@ -34,8 +36,16 @@ try {
|
|||||||
$field.ClientSideComponentId = [Guid]::Empty
|
$field.ClientSideComponentId = [Guid]::Empty
|
||||||
$field.ClientSideComponentProperties = ''
|
$field.ClientSideComponentProperties = ''
|
||||||
$field.Update()
|
$field.Update()
|
||||||
|
|
||||||
|
$customActions | Where-Object {
|
||||||
|
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or
|
||||||
|
$_.Name -eq 'ExpiryIndicator.Classic.Ribbon'
|
||||||
|
} | 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 Classic-Registrierungen wurden entfernt. Feld und Daten bleiben unverändert.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ 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 = '~site/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'
|
||||||
@@ -41,16 +45,67 @@ try {
|
|||||||
}
|
}
|
||||||
$field.Update()
|
$field.Update()
|
||||||
|
|
||||||
# Remove the temporary list-scoped command registration used by older
|
# Remove old diagnostic registrations and make the V2 Classic
|
||||||
# diagnostic scripts. The app feature owns the production registration.
|
# 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
$context.ExecuteQuery()
|
$context.ExecuteQuery()
|
||||||
|
|
||||||
|
if (-not $SkipClassic) {
|
||||||
|
$scriptAction = $list.UserCustomActions.Add()
|
||||||
|
$scriptAction.Name = 'ExpiryIndicator.Classic.ScriptLink'
|
||||||
|
$scriptAction.Title = 'ExpiryIndicator Classic runtime'
|
||||||
|
$scriptAction.Location = 'ScriptLink'
|
||||||
|
$separator = if ($ClassicScriptUrl.Contains('?')) { '&' } else { '?' }
|
||||||
|
$scriptAction.ScriptSrc = $ClassicScriptUrl + $separator + 'expiryField=' + [Uri]::EscapeDataString($field.InternalName)
|
||||||
|
$scriptAction.Sequence = 650
|
||||||
|
$scriptAction.Update()
|
||||||
|
|
||||||
|
$ribbonLocation = if ($list.BaseType -eq [Microsoft.SharePoint.Client.BaseType]::DocumentLibrary) {
|
||||||
|
'Ribbon.Library.Actions.Controls._children'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
'Ribbon.List.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: CSR/JSLink und 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 {
|
||||||
|
|||||||
501
sharepoint/assets/ExpiryIndicatorClassic.js
Normal file
501
sharepoint/assets/ExpiryIndicatorClassic.js
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
(function (window, document) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var FIELD_CUSTOMIZER_ID = 'a555f4fc-d6a6-4421-8189-457449d9bbde';
|
||||||
|
var state = { context: null, field: null, config: null, loading: 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 pageContext() {
|
||||||
|
return window._spPageContextInfo || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function webUrl() {
|
||||||
|
return String(pageContext().webAbsoluteUrl || '').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 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;
|
||||||
|
state.loading = request('GET', listUrl(id) +
|
||||||
|
'/fields?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties')
|
||||||
|
.then(function (data) {
|
||||||
|
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 config = defaults(field.InternalName);
|
||||||
|
try {
|
||||||
|
if (field.ClientSideComponentProperties) {
|
||||||
|
var parsed = JSON.parse(field.ClientSideComponentProperties);
|
||||||
|
for (var name in parsed) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(parsed, name)) { config[name] = parsed[name]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 extendOne(id, config) {
|
||||||
|
var fields = [config.baseField, config.expiryField].concat(ruleFields(config));
|
||||||
|
return request('GET', listUrl(state.context) + '/items(' + id + ')?$select=Id,' + fields.join(','))
|
||||||
|
.then(function (data) {
|
||||||
|
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.type } };
|
||||||
|
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();
|
||||||
|
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);
|
||||||
10
sharepoint/assets/classic-elements.xml
Normal file
10
sharepoint/assets/classic-elements.xml
Normal 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>
|
||||||
3
sharepoint/assets/upgrade-actions-v2.xml
Normal file
3
sharepoint/assets/upgrade-actions-v2.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<ApplyElementManifests>
|
||||||
|
<ElementManifest Location="913402af-ab9a-4974-9f86-5c2159ae41db\classic-elements.xml" />
|
||||||
|
</ApplyElementManifests>
|
||||||
87
src/common/ExpiryModels.test.ts
Normal file
87
src/common/ExpiryModels.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/// <reference types="mocha" />
|
||||||
|
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import {
|
||||||
|
createDefaultConfig,
|
||||||
|
IExpiryConfig,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -89,7 +104,9 @@ export function normalizeConfig(value: any): IExpiryConfig {
|
|||||||
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 +119,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 +166,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;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"alias": "ExpiryIndicatorFieldCustomizer",
|
"alias": "ExpiryIndicatorFieldCustomizer",
|
||||||
"componentType": "Extension",
|
"componentType": "Extension",
|
||||||
"extensionType": "FieldCustomizer",
|
"extensionType": "FieldCustomizer",
|
||||||
"version": "1.0.11",
|
"version": "2.0.0",
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
"requiresCustomScript": false
|
"requiresCustomScript": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"alias": "ExpiryIndicatorApplicationCustomizer",
|
"alias": "ExpiryIndicatorApplicationCustomizer",
|
||||||
"componentType": "Extension",
|
"componentType": "Extension",
|
||||||
"extensionType": "ApplicationCustomizer",
|
"extensionType": "ApplicationCustomizer",
|
||||||
"version": "1.0.11",
|
"version": "2.0.0",
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
"requiresCustomScript": false
|
"requiresCustomScript": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"alias": "ExpiryIndicatorCommandSet",
|
"alias": "ExpiryIndicatorCommandSet",
|
||||||
"componentType": "Extension",
|
"componentType": "Extension",
|
||||||
"extensionType": "ListViewCommandSet",
|
"extensionType": "ListViewCommandSet",
|
||||||
"version": "1.0.11",
|
"version": "2.0.0",
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
"requiresCustomScript": false,
|
"requiresCustomScript": false,
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user