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:
Torsten Brendgen
2026-07-18 19:52:50 +02:00
parent f6e3af23c2
commit 0d6768e24c
18 changed files with 18610 additions and 45 deletions

113
README.md
View File

@@ -1,13 +1,15 @@
# ExpiryIndicator # ExpiryIndicator
SPFx-1.4.1-Solution für moderne Listen und Dokumentbibliotheken in SharePoint Server Subscription Edition. SPFx-1.4.1-Solution für moderne und klassische Listen und Dokumentbibliotheken in SharePoint Server Subscription Edition.
## Funktionen ## Funktionen
- Farbliche Anzeige einer vorhandenen, frei wählbaren Ablaufdatumsspalte. - Farbliche Anzeige einer vorhandenen, frei wählbaren Ablaufdatumsspalte.
- Fallback-Berechnung aus einem konfigurierbaren Erstellungsfeld und `defaultLifetime`. - Fallback-Berechnung aus einem konfigurierbaren Erstellungsfeld und `defaultLifetime`.
- Priorisierte Regeln mit eigener Laufzeit und eigenen Farbschwellen anhand vorhandener Feldwerte. - Priorisierte Regeln mit eigener Laufzeit und eigenen Farbschwellen anhand vorhandener Feldwerte.
- Verschachtelte V2-Bedingungsgruppen mit den logischen Verknüpfungen `AND` und `OR`.
- Command-Bar-Befehl zum Verlängern eines oder mehrerer Elemente um ein Kalenderjahr. - Command-Bar-Befehl zum Verlängern eines oder mehrerer Elemente um ein Kalenderjahr.
- CSR/JSLink-Darstellung und Ribbon-Befehl für klassische SharePoint-Ansichten.
- Pro Liste/Bibliothek gespeicherte Konfiguration. - Pro Liste/Bibliothek gespeicherte Konfiguration.
Die Solution provisioniert ausdrücklich keine fachliche Ablaufdatumsspalte und keinen Content Type. Die Solution provisioniert ausdrücklich keine fachliche Ablaufdatumsspalte und keinen Content Type.
@@ -210,6 +212,77 @@ In **Feldwertregeln** wird nur das JSON-Array für `rules` eingetragen. Folgende
] ]
``` ```
Das bisherige Format mit `columnName` und `columnValue` bleibt in Version 2.0 vollständig kompatibel.
### Verkettete Regeln mit AND und OR (Version 2.0)
Neue Regeln verwenden `condition`. Eine Gruppe enthält `operator` mit `and` oder `or` sowie ein Array
`conditions`. Jeder Eintrag ist entweder eine Feldbedingung oder eine weitere Gruppe. Dieses Beispiel gilt,
wenn `eGovPersDat` dem angegebenen Taxonomy-Term entspricht **und** der Status `Freigegeben` oder `Genehmigt` ist:
```json
[
{
"condition": {
"operator": "and",
"conditions": [
{
"columnName": "eGovPersDat",
"columnValue": "0d19386a-ebe1-4955-ad12-164d7846bca6"
},
{
"operator": "or",
"conditions": [
{
"columnName": "Status",
"columnValue": "Freigegeben"
},
{
"columnName": "Status",
"columnValue": "Genehmigt"
}
]
}
]
},
"lifeTime": {
"value": 1,
"unit": "years"
},
"columnRule": [
{
"daysUntilExpiry": 0,
"operator": "lessOrEqual",
"backgroundColor": "#a4262c",
"textColor": "#ffffff",
"label": "Abgelaufen"
},
{
"daysUntilExpiry": 7,
"operator": "lessOrEqual",
"backgroundColor": "#ffaa44",
"textColor": "#000000",
"label": "Läuft bald ab"
},
{
"daysUntilExpiry": 14,
"operator": "lessOrEqual",
"backgroundColor": "#fff4ce",
"textColor": "#000000",
"label": "Beobachten"
}
]
}
]
```
- `and`: Alle enthaltenen Bedingungen müssen erfüllt sein.
- `or`: Mindestens eine enthaltene Bedingung muss erfüllt sein.
- Ein fehlendes Feld erfüllt eine Feldbedingung nicht.
- Gruppen dürfen bis zu zehn Ebenen tief verschachtelt werden.
- Die erste vollständig passende fachliche Regel gewinnt; andernfalls gilt `default`.
- Der Einstellungsdialog lehnt ungültige oder leere Bedingungsgruppen vor dem Speichern ab.
Die Regeln werden von oben nach unten geprüft. Dabei gilt: Die Regeln werden von oben nach unten geprüft. Dabei gilt:
1. Existiert `columnName` in der Liste oder Bibliothek? 1. Existiert `columnName` in der Liste oder Bibliothek?
@@ -258,6 +331,29 @@ Listenfeld registriert. Der interne Name wird anschließend aus dieser Bindung e
nur lesbar angezeigt. Das Command Set wird durch die Installation der App registriert. Danach die Listenansicht nur lesbar angezeigt. Das Command Set wird durch die Installation der App registriert. Danach die Listenansicht
neu laden. neu laden.
### Klassische SharePoint-Ansichten
Version 2.0 legt bei der App-Installation `ExpiryIndicatorClassic.js` unter
`SiteAssets/ExpiryIndicator` ab. Das Aktivierungsskript registriert standardmäßig für die angegebene Liste:
- einen listenspezifischen `ScriptLink` für CSR/JSLink,
- die Berechnung, Beschriftung und den Zeilenverlauf in der klassischen Ansicht,
- den Ribbon-Befehl **Ablaufdatum +1 Jahr**,
- den Einstellungsdialog über denselben URL-Parameter `expiryIndicatorSettings=1`.
Soll nur die moderne Ansicht aktiviert werden, kann die Classic-Registrierung ausgelassen werden:
```powershell
.\scripts\Enable-ExpiryIndicator.ps1 `
-SiteUrl 'https://sharepoint/sites/fachbereich' `
-ListTitle 'Dokumente' `
-ExpiryFieldInternalName 'CustomerExpiryDate' `
-SkipClassic
```
Die klassische Laufzeit verwendet dieselbe JSON-Konfiguration aus den
`ClientSideComponentProperties` des gebundenen Ablaufdatumsfeldes. Es gibt keine zweite Konfiguration.
### Ablaufdatum um ein Jahr verlängern ### Ablaufdatum um ein Jahr verlängern
Ein oder mehrere Elemente markieren und **Ablaufdatum +1 Jahr** auswählen. Ist bereits ein Ablaufdatum Ein oder mehrere Elemente markieren und **Ablaufdatum +1 Jahr** auswählen. Ist bereits ein Ablaufdatum
@@ -282,7 +378,7 @@ Das Paket wird als `sharepoint/solution/expiry-indicator.sppkg` erzeugt.
1. `expiry-indicator.sppkg` in den App Catalog der On-Premises-Farm laden. 1. `expiry-indicator.sppkg` in den App Catalog der On-Premises-Farm laden.
2. Die App in der gewünschten Site installieren. 2. Die App in der gewünschten Site installieren.
3. Eine moderne Liste/Bibliothek öffnen und wie im Abschnitt 3. Eine moderne oder klassische Liste/Bibliothek öffnen und wie im Abschnitt
**Konfiguration über den Application Customizer** beschrieben konfigurieren. **Konfiguration über den Application Customizer** beschrieben konfigurieren.
4. Den Field Customizer mit der vorhandenen Ablaufdatumsspalte verbinden: 4. Den Field Customizer mit der vorhandenen Ablaufdatumsspalte verbinden:
@@ -295,6 +391,15 @@ Das Paket wird als `sharepoint/solution/expiry-indicator.sppkg` erzeugt.
Das Skript ist in einer SharePoint Management Shell beziehungsweise auf einem Rechner mit den passenden SharePoint-CSOM-Assemblies und Zugriff auf die Site auszuführen. Das Skript ist in einer SharePoint Management Shell beziehungsweise auf einem Rechner mit den passenden SharePoint-CSOM-Assemblies und Zugriff auf die Site auszuführen.
### Upgrade von Version 1.x auf 2.0
1. Das vorhandene Paket im App Catalog durch Version `2.0.0.0` ersetzen.
2. Die App in der Site aktualisieren. Die enthaltene Feature-UpgradeAction provisioniert ausschließlich das
neue Classic-Asset und legt keine fachliche Spalte an.
3. `Enable-ExpiryIndicator.ps1` für jede bereits angebundene Liste erneut ausführen. Das Skript erhält die
vorhandene Feldkonfiguration und ergänzt idempotent die Classic-ScriptLink- und Ribbon-Registrierungen.
4. Modern- und Classic-Ansicht mit geleertem Browsercache neu laden und prüfen.
## Deaktivierung ## Deaktivierung
```powershell ```powershell
@@ -304,4 +409,6 @@ Das Skript ist in einer SharePoint Management Shell beziehungsweise auf einem Re
-ExpiryFieldInternalName 'CustomerExpiryDate' -ExpiryFieldInternalName 'CustomerExpiryDate'
``` ```
Das Skript entfernt ausschließlich die Field-Customizer-Verknüpfung. Die Spalte und alle fachlichen Daten bleiben erhalten. Die Command-Set-Registrierung wird durch das Entfernen der App aus der Site beseitigt. Das Skript entfernt die Field-Customizer-Verknüpfung sowie die listenspezifischen Classic-ScriptLink- und
Ribbon-Registrierungen. Die Spalte und alle fachlichen Daten bleiben erhalten. Die modernen Command-Set- und
Application-Customizer-Registrierungen werden durch das Entfernen der App aus der Site beseitigt.

37
ToDo.md
View File

@@ -122,25 +122,34 @@ Beispiel einer Laufzeitkonfiguration:
### Logische Verkettung von Regeln ### Logische Verkettung von Regeln
- [ ] Konfigurationsschema für mehrere Bedingungen innerhalb einer Regel definieren. - [x] Konfigurationsschema für mehrere Bedingungen innerhalb einer Regel definieren.
- [ ] Bedingungen mit den logischen Operatoren `AND` und `OR` verknüpfen können. - [x] Bedingungen mit den logischen Operatoren `AND` und `OR` verknüpfen können.
- [ ] Verschachtelte Bedingungsgruppen und eine eindeutige Auswertungsreihenfolge festlegen. - [x] Verschachtelte Bedingungsgruppen und eine eindeutige Auswertungsreihenfolge festlegen.
- [ ] Regel-Engine um die Auswertung verketteter Bedingungen erweitern. - [x] Regel-Engine um die Auswertung verketteter Bedingungen erweitern.
- [ ] Bestehende Version-1-Regeln mit `columnName` und `columnValue` abwärtskompatibel weiter unterstützen. - [x] Bestehende Version-1-Regeln mit `columnName` und `columnValue` abwärtskompatibel weiter unterstützen.
- [ ] Einstellungsdialog und die optionale PortalSettings-Integration um verkettete Regeln erweitern. - [x] Einstellungsdialog um verkettete Regeln erweitern.
- [ ] Konfiguration validieren und verständliche Fehlermeldungen für ungültige Regelgruppen ausgeben. - [x] Optionale PortalSettings-Integration um einen zentralen Editor für verkettete Regeln erweitern.
- [ ] Tests für `AND`, `OR`, gemischte Gruppen, fehlende Felder und Fallback auf die Standardregel ergänzen. - [x] Konfiguration validieren und verständliche Fehlermeldungen für ungültige Regelgruppen ausgeben.
- [x] Tests für `AND`, `OR`, gemischte Gruppen, fehlende Felder und Fallback auf die Standardregel ergänzen.
### Unterstützung klassischer SharePoint-Ansichten ### Unterstützung klassischer SharePoint-Ansichten
- [ ] Technisches Konzept für klassische Listen und Bibliotheken erstellen, da SPFx Field Customizer und ListView Command Sets dort nicht ausgeführt werden. - [x] Technisches Konzept für klassische Listen und Bibliotheken erstellen, da SPFx Field Customizer und ListView Command Sets dort nicht ausgeführt werden.
- [ ] Darstellung und Zeilenverlauf für die klassische Ansicht über eine geeignete Classic-Integration, beispielsweise CSR/JSLink, realisieren. - [x] Darstellung und Zeilenverlauf für die klassische Ansicht über CSR/JSLink realisieren.
- [ ] Die Aktion **„Ablaufdatum +1 Jahr“** in der klassischen Ribbon-/Listenoberfläche bereitstellen. - [x] Die Aktion **„Ablaufdatum +1 Jahr“** in der klassischen Ribbon-/Listenoberfläche bereitstellen.
- [ ] Zugriff auf die ExpiryIndicator-Einstellungen auch aus klassischen Ansichten ermöglichen. - [x] Zugriff auf die ExpiryIndicator-Einstellungen auch aus klassischen Ansichten ermöglichen.
- [ ] Aktivierungs-, Deaktivierungs- und Upgrade-Skripte um die Classic-Registrierungen erweitern. - [x] Aktivierungs-, Deaktivierungs- und Upgrade-Skripte um die Classic-Registrierungen erweitern.
- [ ] Dieselbe Konfiguration und Regel-Engine in moderner und klassischer Ansicht verwenden. - [x] Dieselbe Konfiguration und dasselbe Regelverhalten in moderner und klassischer Ansicht verwenden.
- [ ] Funktions-, Berechtigungs- und Darstellungstests für klassische Listen und Dokumentbibliotheken ergänzen. - [ ] Funktions-, Berechtigungs- und Darstellungstests für klassische Listen und Dokumentbibliotheken ergänzen.
### Release 2.0
- [x] Versionsstände von Solution, Feature und SPFx-Komponenten auf `2.0.0` anheben.
- [x] README um V2-Regeln, Classic-Aktivierung und Deaktivierung ergänzen.
- [x] Pakete mit Node.js 8.17.0 erstellen und lokale Package-Validierung erfolgreich ausführen.
- [ ] Feature-Upgrade in SharePoint Server Subscription Edition testen.
- [ ] Modern-/Classic-Abnahmetest auf einer Testliste und einer Test-Dokumentbibliothek durchführen.
## Abnahmekriterien ## Abnahmekriterien
- Die Solution enthält und provisioniert keine Definition für eine fachliche Ablaufdatumsspalte. - Die Solution enthält und provisioniert keine Definition für eine fachliche Ablaufdatumsspalte.

View File

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

17631
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -22,8 +22,10 @@ $context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
try { try {
$list = $context.Web.Lists.GetByTitle($ListTitle) $list = $context.Web.Lists.GetByTitle($ListTitle)
$field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName)
$customActions = $list.UserCustomActions
$context.Load($list) $context.Load($list)
$context.Load($field) $context.Load($field)
$context.Load($customActions)
$context.ExecuteQuery() $context.ExecuteQuery()
if ($field.ClientSideComponentId -ne $FieldCustomizerComponentId) { if ($field.ClientSideComponentId -ne $FieldCustomizerComponentId) {
@@ -34,8 +36,16 @@ try {
$field.ClientSideComponentId = [Guid]::Empty $field.ClientSideComponentId = [Guid]::Empty
$field.ClientSideComponentProperties = '' $field.ClientSideComponentProperties = ''
$field.Update() $field.Update()
$customActions | Where-Object {
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or
$_.Name -eq 'ExpiryIndicator.Classic.Ribbon'
} | ForEach-Object {
$_.DeleteObject()
}
$context.ExecuteQuery() $context.ExecuteQuery()
Write-Host 'ExpiryIndicator-Verknüpfung wurde entfernt. Feld und Daten bleiben unverändert.' Write-Host 'ExpiryIndicator-Verknüpfung und Classic-Registrierungen wurden entfernt. Feld und Daten bleiben unverändert.'
} }
} }
finally { finally {

View File

@@ -9,7 +9,11 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ExpiryFieldInternalName, [string]$ExpiryFieldInternalName,
[Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde' [Guid]$FieldCustomizerComponentId = 'a555f4fc-d6a6-4421-8189-457449d9bbde',
[string]$ClassicScriptUrl = '~site/SiteAssets/ExpiryIndicator/ExpiryIndicatorClassic.js',
[switch]$SkipClassic
) )
$isapiPath = Join-Path ([Environment]::GetFolderPath('CommonProgramFiles')) 'microsoft shared\Web Server Extensions\16\ISAPI' $isapiPath = Join-Path ([Environment]::GetFolderPath('CommonProgramFiles')) 'microsoft shared\Web Server Extensions\16\ISAPI'
@@ -41,16 +45,67 @@ try {
} }
$field.Update() $field.Update()
# Remove the temporary list-scoped command registration used by older # Remove old diagnostic registrations and make the V2 Classic
# diagnostic scripts. The app feature owns the production registration. # registrations idempotent.
$customActions | Where-Object { $customActions | Where-Object {
$_.Name -eq 'ExpiryIndicator.CommandSet' $_.Name -eq 'ExpiryIndicator.CommandSet' -or
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or
$_.Name -eq 'ExpiryIndicator.Classic.Ribbon'
} | ForEach-Object { } | ForEach-Object {
$_.DeleteObject() $_.DeleteObject()
} }
$context.ExecuteQuery() $context.ExecuteQuery()
if (-not $SkipClassic) {
$scriptAction = $list.UserCustomActions.Add()
$scriptAction.Name = 'ExpiryIndicator.Classic.ScriptLink'
$scriptAction.Title = 'ExpiryIndicator Classic runtime'
$scriptAction.Location = 'ScriptLink'
$separator = if ($ClassicScriptUrl.Contains('?')) { '&' } else { '?' }
$scriptAction.ScriptSrc = $ClassicScriptUrl + $separator + 'expiryField=' + [Uri]::EscapeDataString($field.InternalName)
$scriptAction.Sequence = 650
$scriptAction.Update()
$ribbonLocation = if ($list.BaseType -eq [Microsoft.SharePoint.Client.BaseType]::DocumentLibrary) {
'Ribbon.Library.Actions.Controls._children'
}
else {
'Ribbon.List.Actions.Controls._children'
}
$ribbonXml = @"
<CommandUIExtension xmlns="http://schemas.microsoft.com/sharepoint/">
<CommandUIDefinitions>
<CommandUIDefinition Location="$ribbonLocation">
<Button Id="ExpiryIndicator.Classic.ExtendOneYear.Button"
Command="ExpiryIndicator.Classic.ExtendOneYear"
Sequence="90"
LabelText="Ablaufdatum +1 Jahr"
Description="Verlängert das Ablaufdatum der ausgewählten Elemente um ein Kalenderjahr."
TemplateAlias="o1" />
</CommandUIDefinition>
</CommandUIDefinitions>
<CommandUIHandlers>
<CommandUIHandler Command="ExpiryIndicator.Classic.ExtendOneYear"
CommandAction="javascript:ExpiryIndicatorClassic.extendSelected();"
EnabledScript="javascript:ExpiryIndicatorClassic.canExtend();" />
</CommandUIHandlers>
</CommandUIExtension>
"@
$ribbonAction = $list.UserCustomActions.Add()
$ribbonAction.Name = 'ExpiryIndicator.Classic.Ribbon'
$ribbonAction.Title = 'ExpiryIndicator Classic ribbon command'
$ribbonAction.Location = 'CommandUI.Ribbon'
$ribbonAction.CommandUIExtension = $ribbonXml
$ribbonAction.Sequence = 651
$ribbonAction.Update()
$context.ExecuteQuery()
Write-Host 'Classic-Ansicht: CSR/JSLink und Ribbon-Befehl wurden registriert.'
}
Write-Host "ExpiryIndicator wurde für '$($list.Title)' an '$($field.InternalName)' gebunden." Write-Host "ExpiryIndicator wurde für '$($list.Title)' an '$($field.InternalName)' gebunden."
} }
finally { finally {

View 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);

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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