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
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
View File

@@ -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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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 {

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

@@ -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",

View File

@@ -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",