Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d97dbf45aa | ||
|
|
69a96b90c5 | ||
|
|
2a642766d1 | ||
|
|
0b8b752837 | ||
|
|
0d6da3451d | ||
|
|
0d6768e24c | ||
|
|
f6e3af23c2 |
139
README.md
139
README.md
@@ -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
72
ToDo.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
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.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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
594
sharepoint/assets/ExpiryIndicatorClassic.js
Normal file
594
sharepoint/assets/ExpiryIndicatorClassic.js
Normal 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);
|
||||||
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>
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
105
src/common/ExpiryModels.test.ts
Normal file
105
src/common/ExpiryModels.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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