feat: add ExpiryIndicatorClassic functionality with configuration and rendering
- Implemented ExpiryIndicatorClassic.js for managing expiry dates in SharePoint lists. - Created classic-elements.xml to define the module for ExpiryIndicator assets. - Added upgrade-actions-v2.xml to apply the new element manifests. - Introduced ExpiryModels.test.ts for unit testing the expiry configuration and rules validation.
This commit is contained in:
113
README.md
113
README.md
@@ -1,13 +1,15 @@
|
||||
# ExpiryIndicator
|
||||
|
||||
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
|
||||
|
||||
- Farbliche Anzeige einer vorhandenen, frei wählbaren Ablaufdatumsspalte.
|
||||
- Fallback-Berechnung aus einem konfigurierbaren Erstellungsfeld und `defaultLifetime`.
|
||||
- Priorisierte Regeln mit eigener Laufzeit und eigenen Farbschwellen anhand vorhandener Feldwerte.
|
||||
- Verschachtelte V2-Bedingungsgruppen mit den logischen Verknüpfungen `AND` und `OR`.
|
||||
- Command-Bar-Befehl zum Verlängern eines oder mehrerer Elemente um ein Kalenderjahr.
|
||||
- CSR/JSLink-Darstellung und Ribbon-Befehl für klassische SharePoint-Ansichten.
|
||||
- Pro Liste/Bibliothek gespeicherte Konfiguration.
|
||||
|
||||
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:
|
||||
|
||||
1. Existiert `columnName` in der Liste oder Bibliothek?
|
||||
@@ -258,6 +331,29 @@ Listenfeld registriert. Der interne Name wird anschließend aus dieser Bindung e
|
||||
nur lesbar angezeigt. Das Command Set wird durch die Installation der App registriert. Danach die Listenansicht
|
||||
neu laden.
|
||||
|
||||
### Klassische SharePoint-Ansichten
|
||||
|
||||
Version 2.0 legt bei der App-Installation `ExpiryIndicatorClassic.js` unter
|
||||
`SiteAssets/ExpiryIndicator` ab. Das Aktivierungsskript registriert standardmäßig für die angegebene Liste:
|
||||
|
||||
- einen listenspezifischen `ScriptLink` für CSR/JSLink,
|
||||
- die Berechnung, Beschriftung und den Zeilenverlauf in der klassischen Ansicht,
|
||||
- den Ribbon-Befehl **Ablaufdatum +1 Jahr**,
|
||||
- den Einstellungsdialog über denselben URL-Parameter `expiryIndicatorSettings=1`.
|
||||
|
||||
Soll nur die moderne Ansicht aktiviert werden, kann die Classic-Registrierung ausgelassen werden:
|
||||
|
||||
```powershell
|
||||
.\scripts\Enable-ExpiryIndicator.ps1 `
|
||||
-SiteUrl 'https://sharepoint/sites/fachbereich' `
|
||||
-ListTitle 'Dokumente' `
|
||||
-ExpiryFieldInternalName 'CustomerExpiryDate' `
|
||||
-SkipClassic
|
||||
```
|
||||
|
||||
Die klassische Laufzeit verwendet dieselbe JSON-Konfiguration aus den
|
||||
`ClientSideComponentProperties` des gebundenen Ablaufdatumsfeldes. Es gibt keine zweite Konfiguration.
|
||||
|
||||
### Ablaufdatum um ein Jahr verlängern
|
||||
|
||||
Ein oder mehrere Elemente markieren und **Ablaufdatum +1 Jahr** auswählen. Ist bereits ein Ablaufdatum
|
||||
@@ -282,7 +378,7 @@ Das Paket wird als `sharepoint/solution/expiry-indicator.sppkg` erzeugt.
|
||||
|
||||
1. `expiry-indicator.sppkg` in den App Catalog der On-Premises-Farm laden.
|
||||
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.
|
||||
4. Den Field Customizer mit der vorhandenen Ablaufdatumsspalte verbinden:
|
||||
|
||||
@@ -295,6 +391,15 @@ Das Paket wird als `sharepoint/solution/expiry-indicator.sppkg` erzeugt.
|
||||
|
||||
Das Skript ist in einer SharePoint Management Shell beziehungsweise auf einem Rechner mit den passenden SharePoint-CSOM-Assemblies und Zugriff auf die Site auszuführen.
|
||||
|
||||
### Upgrade von Version 1.x auf 2.0
|
||||
|
||||
1. Das vorhandene Paket im App Catalog durch Version `2.0.0.0` ersetzen.
|
||||
2. Die App in der Site aktualisieren. Die enthaltene Feature-UpgradeAction provisioniert ausschließlich das
|
||||
neue Classic-Asset und legt keine fachliche Spalte an.
|
||||
3. `Enable-ExpiryIndicator.ps1` für jede bereits angebundene Liste erneut ausführen. Das Skript erhält die
|
||||
vorhandene Feldkonfiguration und ergänzt idempotent die Classic-ScriptLink- und Ribbon-Registrierungen.
|
||||
4. Modern- und Classic-Ansicht mit geleertem Browsercache neu laden und prüfen.
|
||||
|
||||
## Deaktivierung
|
||||
|
||||
```powershell
|
||||
@@ -304,4 +409,6 @@ Das Skript ist in einer SharePoint Management Shell beziehungsweise auf einem Re
|
||||
-ExpiryFieldInternalName 'CustomerExpiryDate'
|
||||
```
|
||||
|
||||
Das Skript entfernt ausschließlich die Field-Customizer-Verknüpfung. Die Spalte und alle fachlichen Daten bleiben erhalten. Die Command-Set-Registrierung wird durch das Entfernen der App aus der Site beseitigt.
|
||||
Das Skript entfernt die Field-Customizer-Verknüpfung sowie die listenspezifischen Classic-ScriptLink- und
|
||||
Ribbon-Registrierungen. Die Spalte und alle fachlichen Daten bleiben erhalten. Die modernen Command-Set- und
|
||||
Application-Customizer-Registrierungen werden durch das Entfernen der App aus der Site beseitigt.
|
||||
|
||||
37
ToDo.md
37
ToDo.md
@@ -122,25 +122,34 @@ Beispiel einer Laufzeitkonfiguration:
|
||||
|
||||
### Logische Verkettung von Regeln
|
||||
|
||||
- [ ] Konfigurationsschema für mehrere Bedingungen innerhalb einer Regel definieren.
|
||||
- [ ] Bedingungen mit den logischen Operatoren `AND` und `OR` verknüpfen können.
|
||||
- [ ] Verschachtelte Bedingungsgruppen und eine eindeutige Auswertungsreihenfolge festlegen.
|
||||
- [ ] Regel-Engine um die Auswertung verketteter Bedingungen erweitern.
|
||||
- [ ] Bestehende Version-1-Regeln mit `columnName` und `columnValue` abwärtskompatibel weiter unterstützen.
|
||||
- [ ] Einstellungsdialog und die optionale PortalSettings-Integration um verkettete Regeln erweitern.
|
||||
- [ ] Konfiguration validieren und verständliche Fehlermeldungen für ungültige Regelgruppen ausgeben.
|
||||
- [ ] Tests für `AND`, `OR`, gemischte Gruppen, fehlende Felder und Fallback auf die Standardregel ergänzen.
|
||||
- [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
|
||||
|
||||
- [ ] Technisches Konzept für klassische Listen und Bibliotheken erstellen, da SPFx Field Customizer und ListView Command Sets dort nicht ausgeführt werden.
|
||||
- [ ] Darstellung und Zeilenverlauf für die klassische Ansicht über eine geeignete Classic-Integration, beispielsweise CSR/JSLink, realisieren.
|
||||
- [ ] Die Aktion **„Ablaufdatum +1 Jahr“** in der klassischen Ribbon-/Listenoberfläche bereitstellen.
|
||||
- [ ] Zugriff auf die ExpiryIndicator-Einstellungen auch aus klassischen Ansichten ermöglichen.
|
||||
- [ ] Aktivierungs-, Deaktivierungs- und Upgrade-Skripte um die Classic-Registrierungen erweitern.
|
||||
- [ ] Dieselbe Konfiguration und Regel-Engine in moderner und klassischer Ansicht verwenden.
|
||||
- [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.0` anheben.
|
||||
- [x] README um V2-Regeln, Classic-Aktivierung und Deaktivierung ergänzen.
|
||||
- [x] Pakete mit Node.js 8.17.0 erstellen und lokale Package-Validierung erfolgreich ausführen.
|
||||
- [ ] Feature-Upgrade in SharePoint Server Subscription Edition testen.
|
||||
- [ ] Modern-/Classic-Abnahmetest auf einer Testliste und einer Test-Dokumentbibliothek durchführen.
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
- Die Solution enthält und provisioniert keine Definition für eine fachliche Ablaufdatumsspalte.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"solution": {
|
||||
"name": "expiry-indicator-client-side-solution",
|
||||
"id": "68bb6d2d-9895-45b4-88e8-b98835faa981",
|
||||
"version": "1.0.11.0",
|
||||
"version": "2.0.0.0",
|
||||
"includeClientSideAssets": true,
|
||||
"skipFeatureDeployment": false,
|
||||
"features": [
|
||||
@@ -11,10 +11,17 @@
|
||||
"title": "ExpiryIndicator extension registration",
|
||||
"description": "Registers ExpiryIndicator commands and its settings Application Customizer. Does not provision ExpiryDate.",
|
||||
"id": "913402af-ab9a-4974-9f86-5c2159ae41db",
|
||||
"version": "1.0.11.0",
|
||||
"version": "2.0.0.0",
|
||||
"assets": {
|
||||
"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",
|
||||
"version": "1.0.11",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0 <9.0.0"
|
||||
@@ -10,7 +10,8 @@
|
||||
"clean": "gulp clean",
|
||||
"package-solution": "gulp package-solution",
|
||||
"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": {
|
||||
"@microsoft/sp-application-base": "1.4.1",
|
||||
|
||||
@@ -22,8 +22,10 @@ $context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
|
||||
try {
|
||||
$list = $context.Web.Lists.GetByTitle($ListTitle)
|
||||
$field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName)
|
||||
$customActions = $list.UserCustomActions
|
||||
$context.Load($list)
|
||||
$context.Load($field)
|
||||
$context.Load($customActions)
|
||||
$context.ExecuteQuery()
|
||||
|
||||
if ($field.ClientSideComponentId -ne $FieldCustomizerComponentId) {
|
||||
@@ -34,8 +36,16 @@ try {
|
||||
$field.ClientSideComponentId = [Guid]::Empty
|
||||
$field.ClientSideComponentProperties = ''
|
||||
$field.Update()
|
||||
|
||||
$customActions | Where-Object {
|
||||
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or
|
||||
$_.Name -eq 'ExpiryIndicator.Classic.Ribbon'
|
||||
} | ForEach-Object {
|
||||
$_.DeleteObject()
|
||||
}
|
||||
|
||||
$context.ExecuteQuery()
|
||||
Write-Host 'ExpiryIndicator-Verknüpfung wurde entfernt. Feld und Daten bleiben unverändert.'
|
||||
Write-Host 'ExpiryIndicator-Verknüpfung und Classic-Registrierungen wurden entfernt. Feld und Daten bleiben unverändert.'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -9,7 +9,11 @@ param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpiryFieldInternalName,
|
||||
|
||||
[Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde'
|
||||
[Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde',
|
||||
|
||||
[string]$ClassicScriptUrl = '~site/SiteAssets/ExpiryIndicator/ExpiryIndicatorClassic.js',
|
||||
|
||||
[switch]$SkipClassic
|
||||
)
|
||||
|
||||
$isapiPath = Join-Path ([Environment]::GetFolderPath('CommonProgramFiles')) 'microsoft shared\Web Server Extensions\16\ISAPI'
|
||||
@@ -41,16 +45,67 @@ try {
|
||||
}
|
||||
$field.Update()
|
||||
|
||||
# Remove the temporary list-scoped command registration used by older
|
||||
# diagnostic scripts. The app feature owns the production registration.
|
||||
# Remove old diagnostic registrations and make the V2 Classic
|
||||
# registrations idempotent.
|
||||
$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 {
|
||||
$_.DeleteObject()
|
||||
}
|
||||
|
||||
$context.ExecuteQuery()
|
||||
|
||||
if (-not $SkipClassic) {
|
||||
$scriptAction = $list.UserCustomActions.Add()
|
||||
$scriptAction.Name = 'ExpiryIndicator.Classic.ScriptLink'
|
||||
$scriptAction.Title = 'ExpiryIndicator Classic runtime'
|
||||
$scriptAction.Location = 'ScriptLink'
|
||||
$separator = if ($ClassicScriptUrl.Contains('?')) { '&' } else { '?' }
|
||||
$scriptAction.ScriptSrc = $ClassicScriptUrl + $separator + 'expiryField=' + [Uri]::EscapeDataString($field.InternalName)
|
||||
$scriptAction.Sequence = 650
|
||||
$scriptAction.Update()
|
||||
|
||||
$ribbonLocation = if ($list.BaseType -eq [Microsoft.SharePoint.Client.BaseType]::DocumentLibrary) {
|
||||
'Ribbon.Library.Actions.Controls._children'
|
||||
}
|
||||
else {
|
||||
'Ribbon.List.Actions.Controls._children'
|
||||
}
|
||||
|
||||
$ribbonXml = @"
|
||||
<CommandUIExtension xmlns="http://schemas.microsoft.com/sharepoint/">
|
||||
<CommandUIDefinitions>
|
||||
<CommandUIDefinition Location="$ribbonLocation">
|
||||
<Button Id="ExpiryIndicator.Classic.ExtendOneYear.Button"
|
||||
Command="ExpiryIndicator.Classic.ExtendOneYear"
|
||||
Sequence="90"
|
||||
LabelText="Ablaufdatum +1 Jahr"
|
||||
Description="Verlängert das Ablaufdatum der ausgewählten Elemente um ein Kalenderjahr."
|
||||
TemplateAlias="o1" />
|
||||
</CommandUIDefinition>
|
||||
</CommandUIDefinitions>
|
||||
<CommandUIHandlers>
|
||||
<CommandUIHandler Command="ExpiryIndicator.Classic.ExtendOneYear"
|
||||
CommandAction="javascript:ExpiryIndicatorClassic.extendSelected();"
|
||||
EnabledScript="javascript:ExpiryIndicatorClassic.canExtend();" />
|
||||
</CommandUIHandlers>
|
||||
</CommandUIExtension>
|
||||
"@
|
||||
|
||||
$ribbonAction = $list.UserCustomActions.Add()
|
||||
$ribbonAction.Name = 'ExpiryIndicator.Classic.Ribbon'
|
||||
$ribbonAction.Title = 'ExpiryIndicator Classic ribbon command'
|
||||
$ribbonAction.Location = 'CommandUI.Ribbon'
|
||||
$ribbonAction.CommandUIExtension = $ribbonXml
|
||||
$ribbonAction.Sequence = 651
|
||||
$ribbonAction.Update()
|
||||
|
||||
$context.ExecuteQuery()
|
||||
Write-Host 'Classic-Ansicht: CSR/JSLink und Ribbon-Befehl wurden registriert.'
|
||||
}
|
||||
|
||||
Write-Host "ExpiryIndicator wurde für '$($list.Title)' an '$($field.InternalName)' gebunden."
|
||||
}
|
||||
finally {
|
||||
|
||||
501
sharepoint/assets/ExpiryIndicatorClassic.js
Normal file
501
sharepoint/assets/ExpiryIndicatorClassic.js
Normal file
@@ -0,0 +1,501 @@
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
var FIELD_CUSTOMIZER_ID = 'a555f4fc-d6a6-4421-8189-457449d9bbde';
|
||||
var state = { context: null, field: null, config: null, loading: null };
|
||||
|
||||
function configuredExpiryField() {
|
||||
var scripts = document.getElementsByTagName('script');
|
||||
for (var index = scripts.length - 1; index >= 0; index--) {
|
||||
var source = String(scripts[index].src || '');
|
||||
if (source.indexOf('ExpiryIndicatorClassic.js') < 0) { continue; }
|
||||
var match = /[?&]expiryField=([^&]+)/i.exec(source);
|
||||
if (match) { return decodeURIComponent(match[1]); }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pageContext() {
|
||||
return window._spPageContextInfo || {};
|
||||
}
|
||||
|
||||
function webUrl() {
|
||||
return String(pageContext().webAbsoluteUrl || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function listId(context) {
|
||||
return String((context && context.listName) || pageContext().pageListId || '').replace(/[{}]/g, '');
|
||||
}
|
||||
|
||||
function request(method, url, body) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url, true);
|
||||
xhr.setRequestHeader('Accept', 'application/json;odata=verbose');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json;odata=verbose');
|
||||
xhr.setRequestHeader('OData-Version', '3.0');
|
||||
if (method !== 'GET') {
|
||||
var digest = document.getElementById('__REQUESTDIGEST');
|
||||
if (digest) { xhr.setRequestHeader('X-RequestDigest', digest.value); }
|
||||
}
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) { return; }
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.responseText ? JSON.parse(xhr.responseText) : {});
|
||||
} else {
|
||||
var message = xhr.status + ' ' + xhr.statusText;
|
||||
try {
|
||||
var error = JSON.parse(xhr.responseText);
|
||||
message = error.error && error.error.message ? error.error.message.value : message;
|
||||
} catch (ignore) { }
|
||||
reject(new Error(message));
|
||||
}
|
||||
};
|
||||
xhr.send(body ? JSON.stringify(body) : null);
|
||||
});
|
||||
}
|
||||
|
||||
function listUrl(id) {
|
||||
return webUrl() + "/_api/web/lists(guid'" + id + "')";
|
||||
}
|
||||
|
||||
function defaults(expiryField) {
|
||||
return {
|
||||
baseField: 'Created',
|
||||
expiryField: expiryField || 'ExpiryDate',
|
||||
default: {
|
||||
lifeTime: { value: 2, unit: 'years' },
|
||||
columnRule: []
|
||||
},
|
||||
rules: [],
|
||||
nullText: 'Kein Ablaufdatum',
|
||||
confirmExtension: true
|
||||
};
|
||||
}
|
||||
|
||||
function loadConfig(context) {
|
||||
var id = listId(context);
|
||||
if (!id) { return Promise.reject(new Error('Keine Liste erkannt.')); }
|
||||
if (state.loading && state.context === id) { return state.loading; }
|
||||
state.context = id;
|
||||
state.loading = request('GET', listUrl(id) +
|
||||
'/fields?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties')
|
||||
.then(function (data) {
|
||||
var fields = data.d ? data.d.results : (data.value || []);
|
||||
var field;
|
||||
for (var index = 0; index < fields.length; index++) {
|
||||
var componentId = String(fields[index].ClientSideComponentId || '').replace(/[{}]/g, '').toLowerCase();
|
||||
if (componentId === FIELD_CUSTOMIZER_ID) { field = fields[index]; break; }
|
||||
}
|
||||
if (!field) { throw new Error('Kein ExpiryIndicator-Feld gebunden.'); }
|
||||
var config = defaults(field.InternalName);
|
||||
try {
|
||||
if (field.ClientSideComponentProperties) {
|
||||
var parsed = JSON.parse(field.ClientSideComponentProperties);
|
||||
for (var name in parsed) {
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, name)) { config[name] = parsed[name]; }
|
||||
}
|
||||
}
|
||||
} catch (ignore) { }
|
||||
config.expiryField = field.InternalName;
|
||||
state.field = field;
|
||||
state.config = config;
|
||||
return config;
|
||||
});
|
||||
return state.loading;
|
||||
}
|
||||
|
||||
function ruleFields(config) {
|
||||
var result = [];
|
||||
function add(name) {
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name || '') && result.indexOf(name) < 0) { result.push(name); }
|
||||
}
|
||||
function visit(condition) {
|
||||
if (!condition) { return; }
|
||||
if (condition.conditions instanceof Array) {
|
||||
for (var index = 0; index < condition.conditions.length; index++) { visit(condition.conditions[index]); }
|
||||
} else { add(condition.columnName); }
|
||||
}
|
||||
for (var index = 0; index < (config.rules || []).length; index++) {
|
||||
var rule = config.rules[index];
|
||||
if (rule.condition) { visit(rule.condition); } else { add(rule.columnName); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function equalsIgnoreCase(left, right) {
|
||||
return String(left).toLowerCase() === String(right).toLowerCase();
|
||||
}
|
||||
|
||||
function valueMatches(rawValue, configuredValue) {
|
||||
if (rawValue === null || typeof rawValue === 'undefined') { return String(configuredValue) === ''; }
|
||||
var values = rawValue instanceof Array ? rawValue :
|
||||
(rawValue.results instanceof Array ? rawValue.results : [rawValue]);
|
||||
for (var index = 0; index < values.length; index++) {
|
||||
var value = values[index];
|
||||
if (value && typeof value === 'object') {
|
||||
if (value.TermGuid && equalsIgnoreCase(value.TermGuid, configuredValue)) { return true; }
|
||||
value = value.Title || value.LookupValue || value.Label || value.Value || '';
|
||||
}
|
||||
var text = String(value);
|
||||
if (text === String(configuredValue)) { return true; }
|
||||
var separator = text.lastIndexOf('|');
|
||||
if (separator >= 0 && equalsIgnoreCase(text.substring(separator + 1), configuredValue)) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function conditionMatches(condition, values) {
|
||||
if (condition && condition.conditions instanceof Array) {
|
||||
if (condition.operator === 'and') {
|
||||
for (var andIndex = 0; andIndex < condition.conditions.length; andIndex++) {
|
||||
if (!conditionMatches(condition.conditions[andIndex], values)) { return false; }
|
||||
}
|
||||
return condition.conditions.length > 0;
|
||||
}
|
||||
if (condition.operator === 'or') {
|
||||
for (var orIndex = 0; orIndex < condition.conditions.length; orIndex++) {
|
||||
if (conditionMatches(condition.conditions[orIndex], values)) { return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return !!condition && Object.prototype.hasOwnProperty.call(values, condition.columnName) &&
|
||||
valueMatches(values[condition.columnName], condition.columnValue);
|
||||
}
|
||||
|
||||
function validateRules(rules) {
|
||||
var errors = [];
|
||||
function safeName(value) { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value || ''); }
|
||||
function validateGroup(group, path, depth) {
|
||||
if (depth > 10) { errors.push(path + ': maximale Verschachtelungstiefe 10 überschritten.'); return; }
|
||||
if (!group || (group.operator !== 'and' && group.operator !== 'or')) {
|
||||
errors.push(path + '.operator muss "and" oder "or" sein.');
|
||||
}
|
||||
if (!group || !(group.conditions instanceof Array) || !group.conditions.length) {
|
||||
errors.push(path + '.conditions muss mindestens eine Bedingung enthalten.'); return;
|
||||
}
|
||||
for (var index = 0; index < group.conditions.length; index++) {
|
||||
var condition = group.conditions[index];
|
||||
var childPath = path + '.conditions[' + index + ']';
|
||||
if (condition && typeof condition.operator !== 'undefined') { validateGroup(condition, childPath, depth + 1); }
|
||||
else if (!condition || !safeName(condition.columnName) || typeof condition.columnValue === 'undefined') {
|
||||
errors.push(childPath + ': columnName/columnValue fehlen oder sind ungültig.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!(rules instanceof Array)) { return ['rules muss ein JSON-Array sein.']; }
|
||||
for (var index = 0; index < rules.length; index++) {
|
||||
var rule = rules[index];
|
||||
if (rule && rule.condition) { validateGroup(rule.condition, 'rules[' + index + '].condition', 0); }
|
||||
else if (!rule || !safeName(rule.columnName) || typeof rule.columnValue === 'undefined') {
|
||||
errors.push('rules[' + index + '] benötigt condition oder columnName/columnValue.');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function behavior(config, values) {
|
||||
for (var index = 0; index < (config.rules || []).length; index++) {
|
||||
var rule = config.rules[index];
|
||||
var matched = rule.condition ? conditionMatches(rule.condition, values) :
|
||||
Object.prototype.hasOwnProperty.call(values, rule.columnName) &&
|
||||
valueMatches(values[rule.columnName], rule.columnValue);
|
||||
if (matched) { return rule; }
|
||||
}
|
||||
return config.default;
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) { return null; }
|
||||
var match = /\/Date\((-?\d+)/.exec(String(value));
|
||||
var result = match ? new Date(Number(match[1])) : new Date(value);
|
||||
return isNaN(result.getTime()) ? null : result;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
function pad(number) { return number < 10 ? '0' + number : String(number); }
|
||||
return pad(value.getDate()) + '.' + pad(value.getMonth() + 1) + '.' + value.getFullYear();
|
||||
}
|
||||
|
||||
function addDuration(source, duration) {
|
||||
var result = new Date(source.getTime());
|
||||
if (duration.unit === 'days') {
|
||||
result.setUTCDate(result.getUTCDate() + duration.value);
|
||||
return result;
|
||||
}
|
||||
var months = duration.unit === 'years' ? duration.value * 12 : duration.value;
|
||||
var day = result.getUTCDate();
|
||||
result.setUTCDate(1);
|
||||
result.setUTCMonth(result.getUTCMonth() + months);
|
||||
var lastDay = new Date(Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
result.setUTCDate(Math.min(day, lastDay));
|
||||
return result;
|
||||
}
|
||||
|
||||
function dayDifference(from, to) {
|
||||
var fromDay = Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate());
|
||||
var toDay = Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate());
|
||||
return Math.round((toDay - fromDay) / 86400000);
|
||||
}
|
||||
|
||||
function colorRule(days, rules) {
|
||||
function matches(operator, threshold) {
|
||||
if (operator === 'lessThan') { return days < threshold; }
|
||||
if (operator === 'lessOrEqual') { return days <= threshold; }
|
||||
if (operator === 'equal') { return days === threshold; }
|
||||
if (operator === 'greaterOrEqual') { return days >= threshold; }
|
||||
return operator === 'greaterThan' && days > threshold;
|
||||
}
|
||||
for (var index = 0; index < (rules || []).length; index++) {
|
||||
if (matches(rules[index].operator, rules[index].daysUntilExpiry)) { return rules[index]; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findRow(id) {
|
||||
var rows = document.querySelectorAll('tr[iid]');
|
||||
for (var index = 0; index < rows.length; index++) {
|
||||
var parts = String(rows[index].getAttribute('iid') || '').split(',');
|
||||
if (String(parts[1]) === String(id)) { return rows[index]; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyGradient(row, color, textColor, title) {
|
||||
var allCells = row.querySelectorAll('td');
|
||||
var cells = [];
|
||||
for (var index = 0; index < allCells.length; index++) {
|
||||
if (!allCells[index].querySelector('input[type="checkbox"]') &&
|
||||
String(allCells[index].className).indexOf('ms-vb-itmcbx') < 0) {
|
||||
cells.push(allCells[index]);
|
||||
}
|
||||
}
|
||||
if (!cells.length) { return; }
|
||||
var first = cells[0].getBoundingClientRect();
|
||||
var last = cells[cells.length - 1].getBoundingClientRect();
|
||||
var width = Math.max(1, last.right - first.left);
|
||||
var gradient = 'linear-gradient(to right, #ffffff, ' + color + ')';
|
||||
for (var cellIndex = 0; cellIndex < cells.length; cellIndex++) {
|
||||
var rect = cells[cellIndex].getBoundingClientRect();
|
||||
cells[cellIndex].style.backgroundColor = '#ffffff';
|
||||
cells[cellIndex].style.backgroundImage = gradient;
|
||||
cells[cellIndex].style.backgroundSize = width + 'px 100%';
|
||||
cells[cellIndex].style.backgroundPosition = (-(rect.left - first.left)) + 'px 0';
|
||||
cells[cellIndex].style.backgroundRepeat = 'no-repeat';
|
||||
cells[cellIndex].style.color = textColor;
|
||||
cells[cellIndex].title = title;
|
||||
}
|
||||
}
|
||||
|
||||
function render(context) {
|
||||
loadConfig(context).then(function (config) {
|
||||
var rows = context && context.ListData && context.ListData.Row ? context.ListData.Row : [];
|
||||
var ids = [];
|
||||
for (var index = 0; index < rows.length; index++) {
|
||||
var id = Number(rows[index].ID || rows[index].Id);
|
||||
if (id > 0) { ids.push(id); }
|
||||
}
|
||||
if (!ids.length) { return; }
|
||||
var fields = [config.baseField, config.expiryField].concat(ruleFields(config));
|
||||
var select = ['Id'];
|
||||
for (var fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) {
|
||||
if (select.indexOf(fields[fieldIndex]) < 0) { select.push(fields[fieldIndex]); }
|
||||
}
|
||||
var filters = [];
|
||||
for (var idIndex = 0; idIndex < ids.length; idIndex++) { filters.push('Id eq ' + ids[idIndex]); }
|
||||
return request('GET', listUrl(listId(context)) + '/items?$select=' + select.join(',') +
|
||||
'&$filter=' + encodeURIComponent(filters.join(' or ')) + '&$top=' + ids.length)
|
||||
.then(function (data) {
|
||||
var items = data.d ? data.d.results : (data.value || []);
|
||||
for (var itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||
var item = items[itemIndex];
|
||||
var created = parseDate(item[config.baseField]);
|
||||
var expiry = parseDate(item[config.expiryField]);
|
||||
if (!created && !expiry) { continue; }
|
||||
var selected = behavior(config, item);
|
||||
var effective = expiry || addDuration(created, selected.lifeTime);
|
||||
var days = dayDifference(new Date(), effective);
|
||||
var rule = colorRule(days, selected.columnRule);
|
||||
var row = findRow(item.Id);
|
||||
var marker = row ? row.querySelector('[data-expiry-classic-item-id="' + item.Id + '"]') : null;
|
||||
if (marker) {
|
||||
marker.innerHTML = '';
|
||||
marker.appendChild(document.createTextNode(formatDate(effective) +
|
||||
(rule && rule.label ? ' ' + rule.label : '')));
|
||||
marker.title = days + ' Resttage' + (!expiry ? ' – aus ' + config.baseField + ' berechnet' : '');
|
||||
}
|
||||
if (row && rule) {
|
||||
applyGradient(row, rule.backgroundColor, rule.textColor,
|
||||
days + ' Resttage' + (rule.label ? ' – ' + rule.label : ''));
|
||||
}
|
||||
}
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (window.console && console.warn) { console.warn('ExpiryIndicator Classic:', error.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function selectedIds() {
|
||||
try {
|
||||
var selected = SP.ListOperation.Selection.getSelectedItems();
|
||||
var ids = [];
|
||||
for (var index = 0; index < selected.length; index++) { ids.push(Number(selected[index].id)); }
|
||||
return ids;
|
||||
} catch (ignore) { return []; }
|
||||
}
|
||||
|
||||
function extendOne(id, config) {
|
||||
var fields = [config.baseField, config.expiryField].concat(ruleFields(config));
|
||||
return request('GET', listUrl(state.context) + '/items(' + id + ')?$select=Id,' + fields.join(','))
|
||||
.then(function (data) {
|
||||
var item = data.d || data;
|
||||
var created = parseDate(item[config.baseField]);
|
||||
var expiry = parseDate(item[config.expiryField]);
|
||||
var base = expiry || addDuration(created, behavior(config, item).lifeTime);
|
||||
var updated = addDuration(base, { value: 1, unit: 'years' });
|
||||
var body = { '__metadata': { 'type': item.__metadata.type } };
|
||||
body[config.expiryField] = updated.toISOString();
|
||||
return new Promise(function (resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', listUrl(state.context) + '/items(' + id + ')', true);
|
||||
xhr.setRequestHeader('Accept', 'application/json;odata=verbose');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json;odata=verbose');
|
||||
xhr.setRequestHeader('OData-Version', '3.0');
|
||||
xhr.setRequestHeader('IF-MATCH', '*');
|
||||
xhr.setRequestHeader('X-HTTP-Method', 'MERGE');
|
||||
var digest = document.getElementById('__REQUESTDIGEST');
|
||||
if (digest) { xhr.setRequestHeader('X-RequestDigest', digest.value); }
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) { return; }
|
||||
if (xhr.status >= 200 && xhr.status < 300) { resolve(); }
|
||||
else { reject(new Error('#' + id + ': ' + xhr.status + ' ' + xhr.statusText)); }
|
||||
};
|
||||
xhr.send(JSON.stringify(body));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extendSelected() {
|
||||
var ids = selectedIds();
|
||||
if (!ids.length) { alert('Bitte mindestens ein Element auswählen.'); return; }
|
||||
loadConfig(state.context).then(function (config) {
|
||||
if (config.confirmExtension && !confirm('Ablaufdatum für ' + ids.length + ' Element(e) um ein Jahr verlängern?')) {
|
||||
return;
|
||||
}
|
||||
return Promise.all(ids.map(function (id) {
|
||||
return extendOne(id, config).then(function () {
|
||||
return { id: id, succeeded: true };
|
||||
}, function (error) {
|
||||
return { id: id, succeeded: false, error: error.message };
|
||||
});
|
||||
})).then(function (results) {
|
||||
var succeeded = 0;
|
||||
var errors = [];
|
||||
for (var index = 0; index < results.length; index++) {
|
||||
if (results[index].succeeded) { succeeded++; }
|
||||
else { errors.push('#' + results[index].id + ': ' + results[index].error); }
|
||||
}
|
||||
var message = 'Erfolgreich aktualisiert: ' + succeeded + '; Fehler: ' + errors.length;
|
||||
if (errors.length) { message += '\n\n' + errors.slice(0, 10).join('\n'); }
|
||||
alert(message);
|
||||
if (succeeded > 0) { window.location.reload(); }
|
||||
});
|
||||
}).catch(function (error) { alert(error.message); });
|
||||
}
|
||||
|
||||
function canExtend() {
|
||||
return selectedIds().length > 0;
|
||||
}
|
||||
|
||||
function settingsRequested() {
|
||||
return /(?:\?|&)expiryIndicatorSettings=(?:1|true|yes)(?:&|$)/i.test(window.location.search);
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
loadConfig(state.context).then(function (config) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;z-index:100000;inset:0;background:rgba(0,0,0,.35);padding:5vh 10vw';
|
||||
var panel = document.createElement('div');
|
||||
panel.style.cssText = 'background:#fff;padding:20px;height:80vh;font-family:Segoe UI,Arial;box-sizing:border-box';
|
||||
panel.innerHTML = '<h2>ExpiryIndicator-Einstellungen</h2>' +
|
||||
'<p>Vollständige JSON-Konfiguration für diese Liste/Bibliothek:</p>' +
|
||||
'<textarea style="width:100%;height:60%;box-sizing:border-box;font-family:Consolas"></textarea>' +
|
||||
'<div data-error style="color:#a4262c;min-height:24px"></div>' +
|
||||
'<div style="text-align:right"><button data-cancel>Abbrechen</button> ' +
|
||||
'<button data-save>Speichern</button></div>';
|
||||
overlay.appendChild(panel);
|
||||
document.body.appendChild(overlay);
|
||||
var textarea = panel.querySelector('textarea');
|
||||
textarea.value = JSON.stringify(config, null, 2);
|
||||
panel.querySelector('[data-cancel]').onclick = function () { document.body.removeChild(overlay); };
|
||||
panel.querySelector('[data-save]').onclick = function () {
|
||||
var parsed;
|
||||
try { parsed = JSON.parse(textarea.value); }
|
||||
catch (error) { panel.querySelector('[data-error]').innerHTML = 'Ungültiges JSON: ' + error.message; return; }
|
||||
var validationErrors = validateRules(parsed.rules);
|
||||
if (validationErrors.length) {
|
||||
panel.querySelector('[data-error]').innerHTML = validationErrors.join('<br>');
|
||||
return;
|
||||
}
|
||||
parsed.expiryField = state.field.InternalName;
|
||||
var body = {
|
||||
'__metadata': { 'type': state.field.__metadata && state.field.__metadata.type || 'SP.Field' },
|
||||
'ClientSideComponentProperties': JSON.stringify(parsed)
|
||||
};
|
||||
var fieldId = String(state.field.Id).replace(/[{}]/g, '');
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', listUrl(state.context) + "/fields(guid'" + fieldId + "')", true);
|
||||
xhr.setRequestHeader('Accept', 'application/json;odata=verbose');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json;odata=verbose');
|
||||
xhr.setRequestHeader('OData-Version', '3.0');
|
||||
xhr.setRequestHeader('IF-MATCH', '*');
|
||||
xhr.setRequestHeader('X-HTTP-Method', 'MERGE');
|
||||
var digest = document.getElementById('__REQUESTDIGEST');
|
||||
if (digest) { xhr.setRequestHeader('X-RequestDigest', digest.value); }
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) { return; }
|
||||
if (xhr.status >= 200 && xhr.status < 300) { window.location.reload(); }
|
||||
else { panel.querySelector('[data-error]').innerHTML = 'Speichern fehlgeschlagen: ' + xhr.statusText; }
|
||||
};
|
||||
xhr.send(JSON.stringify(body));
|
||||
};
|
||||
}).catch(function (error) { alert(error.message); });
|
||||
}
|
||||
|
||||
window.ExpiryIndicatorClassic = {
|
||||
canExtend: canExtend,
|
||||
extendSelected: extendSelected,
|
||||
openSettings: openSettings,
|
||||
render: render
|
||||
};
|
||||
|
||||
function register() {
|
||||
if (window.SPClientTemplates && SPClientTemplates.TemplateManager) {
|
||||
var overrides = { OnPostRender: render };
|
||||
var expiryField = configuredExpiryField();
|
||||
if (expiryField) {
|
||||
overrides.Templates = { Fields: {} };
|
||||
overrides.Templates.Fields[expiryField] = {
|
||||
View: function (context) {
|
||||
var item = context.CurrentItem || {};
|
||||
var id = Number(item.ID || item.Id);
|
||||
var date = parseDate(item[expiryField]);
|
||||
var value = date ? formatDate(date) : '';
|
||||
var encoded = window.STSHtmlEncode ? STSHtmlEncode(value) : value;
|
||||
return '<span data-expiry-classic-item-id="' + id + '">' + encoded + '</span>';
|
||||
}
|
||||
};
|
||||
}
|
||||
SPClientTemplates.TemplateManager.RegisterTemplateOverrides(overrides);
|
||||
}
|
||||
if (settingsRequested()) { setTimeout(openSettings, 0); }
|
||||
}
|
||||
|
||||
if (window.SPClientTemplates) { register(); }
|
||||
else if (window._spBodyOnLoadFunctionNames) { window._spBodyOnLoadFunctionNames.push('ExpiryIndicatorClassicRegister'); }
|
||||
window.ExpiryIndicatorClassicRegister = register;
|
||||
if (window.NotifyScriptLoadedAndExecuteWaitingJobs) {
|
||||
window.NotifyScriptLoadedAndExecuteWaitingJobs('ExpiryIndicatorClassic.js');
|
||||
}
|
||||
})(window, document);
|
||||
10
sharepoint/assets/classic-elements.xml
Normal file
10
sharepoint/assets/classic-elements.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
|
||||
<Module Name="ExpiryIndicatorClassicAssets" Url="SiteAssets/ExpiryIndicator">
|
||||
<File
|
||||
Path="ExpiryIndicatorClassic.js"
|
||||
Url="ExpiryIndicatorClassic.js"
|
||||
Type="GhostableInLibrary"
|
||||
ReplaceContent="TRUE" />
|
||||
</Module>
|
||||
</Elements>
|
||||
3
sharepoint/assets/upgrade-actions-v2.xml
Normal file
3
sharepoint/assets/upgrade-actions-v2.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<ApplyElementManifests>
|
||||
<ElementManifest Location="913402af-ab9a-4974-9f86-5c2159ae41db\classic-elements.xml" />
|
||||
</ApplyElementManifests>
|
||||
87
src/common/ExpiryModels.test.ts
Normal file
87
src/common/ExpiryModels.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/// <reference types="mocha" />
|
||||
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
createDefaultConfig,
|
||||
IExpiryConfig,
|
||||
normalizeConfig,
|
||||
resolveBehavior,
|
||||
validateValueRules
|
||||
} from './ExpiryModels';
|
||||
|
||||
function configWithRules(rules: any[]): IExpiryConfig {
|
||||
const config: any = createDefaultConfig();
|
||||
config.rules = rules;
|
||||
return normalizeConfig(config);
|
||||
}
|
||||
|
||||
describe('ExpiryModels V2 rules', (): void => {
|
||||
it('keeps matching legacy rules', (): void => {
|
||||
const config: IExpiryConfig = configWithRules([{
|
||||
columnName: 'DataPrivacy',
|
||||
columnValue: 'PersDat1',
|
||||
lifeTime: { value: 1, unit: 'years' },
|
||||
columnRule: []
|
||||
}]);
|
||||
expect(resolveBehavior(config, { DataPrivacy: 'PersDat1' }).lifeTime.value).to.equal(1);
|
||||
});
|
||||
|
||||
it('matches all conditions in an and group', (): void => {
|
||||
const config: IExpiryConfig = configWithRules([{
|
||||
condition: {
|
||||
operator: 'and',
|
||||
conditions: [
|
||||
{ columnName: 'DataPrivacy', columnValue: 'PersDat1' },
|
||||
{ columnName: 'Status', columnValue: 'Active' }
|
||||
]
|
||||
},
|
||||
lifeTime: { value: 14, unit: 'days' },
|
||||
columnRule: []
|
||||
}]);
|
||||
expect(resolveBehavior(config, { DataPrivacy: 'PersDat1', Status: 'Active' }).lifeTime.value).to.equal(14);
|
||||
expect(resolveBehavior(config, { DataPrivacy: 'PersDat1', Status: 'Draft' }).lifeTime.value)
|
||||
.to.equal(config.default.lifeTime.value);
|
||||
});
|
||||
|
||||
it('supports nested or groups and missing fields', (): void => {
|
||||
const config: IExpiryConfig = configWithRules([{
|
||||
condition: {
|
||||
operator: 'and',
|
||||
conditions: [
|
||||
{ columnName: 'Status', columnValue: 'Active' },
|
||||
{
|
||||
operator: 'or',
|
||||
conditions: [
|
||||
{ columnName: 'Country', columnValue: 'DE' },
|
||||
{ columnName: 'Country', columnValue: 'AT' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
lifeTime: { value: 6, unit: 'months' },
|
||||
columnRule: []
|
||||
}]);
|
||||
expect(resolveBehavior(config, { Status: 'Active', Country: 'AT' }).lifeTime.value).to.equal(6);
|
||||
expect(resolveBehavior(config, { Status: 'Active' }).lifeTime.value).to.equal(config.default.lifeTime.value);
|
||||
});
|
||||
|
||||
it('matches managed metadata by TermGuid', (): void => {
|
||||
const termGuid: string = '0d19386a-ebe1-4955-ad12-164d7846bca6';
|
||||
const config: IExpiryConfig = configWithRules([{
|
||||
columnName: 'eGovPersDat',
|
||||
columnValue: termGuid.toUpperCase(),
|
||||
lifeTime: { value: 1, unit: 'years' },
|
||||
columnRule: []
|
||||
}]);
|
||||
expect(resolveBehavior(config, {
|
||||
eGovPersDat: { Label: '6', TermGuid: termGuid, WssId: 6 }
|
||||
}).lifeTime.value).to.equal(1);
|
||||
});
|
||||
|
||||
it('reports invalid condition groups', (): void => {
|
||||
const errors: string[] = validateValueRules([{
|
||||
condition: { operator: 'xor', conditions: [] }
|
||||
}]);
|
||||
expect(errors.length).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
export type DurationUnit = 'days' | 'months' | 'years';
|
||||
export type ColumnRuleOperator = 'lessThan' | 'lessOrEqual' | 'equal' | 'greaterOrEqual' | 'greaterThan';
|
||||
export type ExpiryLogicalOperator = 'and' | 'or';
|
||||
|
||||
export interface IExpiryDuration {
|
||||
value: number;
|
||||
@@ -19,11 +20,25 @@ export interface IExpiryBehavior {
|
||||
columnRule: IExpiryColumnRule[];
|
||||
}
|
||||
|
||||
export interface IExpiryValueRule extends IExpiryBehavior {
|
||||
export interface IExpiryFieldCondition {
|
||||
columnName: 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 {
|
||||
baseField: string;
|
||||
expiryField: string;
|
||||
@@ -89,7 +104,9 @@ export function normalizeConfig(value: any): IExpiryConfig {
|
||||
export function getRuleFieldNames(config: IExpiryConfig): string[] {
|
||||
const fields: string[] = [];
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -102,11 +119,7 @@ export function resolveBehavior(
|
||||
): IExpiryBehavior {
|
||||
for (let index: number = 0; index < config.rules.length; index++) {
|
||||
const rule: IExpiryValueRule = config.rules[index];
|
||||
if (!Object.prototype.hasOwnProperty.call(fieldValues, rule.columnName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueMatches(fieldValues[rule.columnName], rule.columnValue)) {
|
||||
if (ruleMatches(rule, fieldValues)) {
|
||||
return {
|
||||
lifeTime: rule.lifeTime,
|
||||
columnRule: rule.columnRule
|
||||
@@ -153,21 +166,147 @@ function normalizeValueRules(value: any): IExpiryValueRule[] {
|
||||
|
||||
const rules: IExpiryValueRule[] = [];
|
||||
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;
|
||||
}
|
||||
|
||||
const columnRules: IExpiryColumnRule[] = normalizeColumnRules(rule.columnRule, []);
|
||||
rules.push({
|
||||
columnName: rule.columnName,
|
||||
columnValue: String(rule.columnValue),
|
||||
const normalizedRule: IExpiryValueRule = {
|
||||
lifeTime: normalizeDuration(rule.lifeTime, { value: 1, unit: 'years' }),
|
||||
columnRule: columnRules
|
||||
});
|
||||
};
|
||||
if (condition) {
|
||||
normalizedRule.condition = condition;
|
||||
} else {
|
||||
normalizedRule.columnName = rule.columnName;
|
||||
normalizedRule.columnValue = String(rule.columnValue);
|
||||
}
|
||||
rules.push(normalizedRule);
|
||||
});
|
||||
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 {
|
||||
if (!value || typeof value.value !== 'number' || !isFinite(value.value)) {
|
||||
return fallback;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"alias": "ExpiryIndicatorFieldCustomizer",
|
||||
"componentType": "Extension",
|
||||
"extensionType": "FieldCustomizer",
|
||||
"version": "1.0.11",
|
||||
"version": "2.0.0",
|
||||
"manifestVersion": 2,
|
||||
"requiresCustomScript": false
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"alias": "ExpiryIndicatorApplicationCustomizer",
|
||||
"componentType": "Extension",
|
||||
"extensionType": "ApplicationCustomizer",
|
||||
"version": "1.0.11",
|
||||
"version": "2.0.0",
|
||||
"manifestVersion": 2,
|
||||
"requiresCustomScript": false
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"alias": "ExpiryIndicatorCommandSet",
|
||||
"componentType": "Extension",
|
||||
"extensionType": "ListViewCommandSet",
|
||||
"version": "1.0.11",
|
||||
"version": "2.0.0",
|
||||
"manifestVersion": 2,
|
||||
"requiresCustomScript": false,
|
||||
"items": {
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
IExpiryConfig,
|
||||
IExpiryColumnRule,
|
||||
IExpiryValueRule,
|
||||
normalizeConfig
|
||||
normalizeConfig,
|
||||
validateValueRules
|
||||
} from '../../common/ExpiryModels';
|
||||
import * as strings from 'ExpiryIndicatorCommandSetStrings';
|
||||
|
||||
@@ -84,6 +85,10 @@ export class ExpirySettingsDialog extends BaseDialog {
|
||||
try {
|
||||
const defaultColumnRule: IExpiryColumnRule[] = JSON.parse(this._value('defaultColumnRule'));
|
||||
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({
|
||||
baseField: this._value('baseField').trim(),
|
||||
expiryField: this._value('expiryField').trim(),
|
||||
|
||||
@@ -6,7 +6,7 @@ define([], function() {
|
||||
"DefaultLifetimeValue": "Standardlaufzeit",
|
||||
"DefaultLifetimeUnit": "Einheit",
|
||||
"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",
|
||||
"NullText": "Text bei fehlendem Datum",
|
||||
"Cancel": "Abbrechen",
|
||||
|
||||
@@ -6,7 +6,7 @@ define([], function() {
|
||||
"DefaultLifetimeValue": "Default lifetime",
|
||||
"DefaultLifetimeUnit": "Unit",
|
||||
"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",
|
||||
"NullText": "Text when no date is available",
|
||||
"Cancel": "Cancel",
|
||||
|
||||
Reference in New Issue
Block a user