feat: update to version 2.1.0 with central site collection configuration and inheritance support

This commit is contained in:
Torsten Brendgen
2026-07-18 20:42:00 +02:00
parent 0d6da3451d
commit 0b8b752837
17 changed files with 348 additions and 43 deletions

View File

@@ -10,7 +10,7 @@ SPFx-1.4.1-Solution für moderne und klassische Listen und Dokumentbibliotheken
- Verschachtelte V2-Bedingungsgruppen mit den logischen Verknüpfungen `AND` und `OR`. - 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. - CSR/JSLink-Darstellung und Ribbon-Befehl für klassische SharePoint-Ansichten.
- Pro Liste/Bibliothek gespeicherte Konfiguration. - Zentraler Site-Collection-Standard mit optionalen Subweb- und Listen-Ausnahmen.
Die Solution provisioniert ausdrücklich keine fachliche Ablaufdatumsspalte und keinen Content Type. Die Solution provisioniert ausdrücklich keine fachliche Ablaufdatumsspalte und keinen Content Type.
@@ -309,6 +309,22 @@ Konfigurationsmechanismus wie andere SPFx-Extensions und benötigt keine zusätz
PortalSettings kann optional dieselben Component Properties verwalten, ist aber keine Voraussetzung für den PortalSettings kann optional dieselben Component Properties verwalten, ist aber keine Voraussetzung für den
Betrieb des ExpiryIndicators. Betrieb des ExpiryIndicators.
### Zentrale Vererbung mit PortalSettings (Version 2.1)
Version 2.1 kann einen Standard aus dem Property Bag des Root Webs auf alle registrierten Bindungen der
Site Collection vererben. Die Aufloesung erfolgt in dieser Reihenfolge:
1. lokale Konfiguration am Listenfeld,
2. optionaler Override im Property Bag des aktuellen Subwebs,
3. Site-Collection-Standard im Property Bag des Root Webs,
4. eingebauter Standard der Extension.
`Enable-ExpiryIndicator.ps1` registriert jede Bindung im Root Web und verwendet fuer neue oder bisher leere
Feld-Properties eine schlanke Vererbungsreferenz. Bestehende vollstaendige Listenkonfigurationen bleiben beim
erneuten Ausfuehren des Skripts erhalten. In PortalSettings kann man den zentralen Standard bearbeiten, einzelne
Bindungen auswaehlen oder alle registrierten Bindungen bewusst auf Vererbung umstellen. Eine versteckte Liste
wird nicht verwendet.
Bei einem Upgrade wird eine vorhandene Konfiguration aus der früheren ausgeblendeten Liste Bei einem Upgrade wird eine vorhandene Konfiguration aus der früheren ausgeblendeten Liste
`_ExpiryIndicatorConfiguration` weiterhin gelesen, solange noch keine Component Properties gespeichert wurden. `_ExpiryIndicatorConfiguration` weiterhin gelesen, solange noch keine Component Properties gespeichert wurden.
Beim nächsten Speichern wird sie in das gebundene Feld übernommen. Die alte Liste wird aus Sicherheitsgründen Beim nächsten Speichern wird sie in das gebundene Feld übernommen. Die alte Liste wird aus Sicherheitsgründen
@@ -333,7 +349,7 @@ neu laden.
### Klassische SharePoint-Ansichten ### Klassische SharePoint-Ansichten
Version 2.0 legt bei der App-Installation `ExpiryIndicatorClassic.js` unter Version 2.1 legt bei der App-Installation `ExpiryIndicatorClassic.js` unter
`SiteAssets/ExpiryIndicator` ab. Das Aktivierungsskript registriert standardmäßig für die angegebene Liste: `SiteAssets/ExpiryIndicator` ab. Das Aktivierungsskript registriert standardmäßig für die angegebene Liste:
- einen einmaligen Web-`ScriptLink` für CSR/JSLink, der das gebundene Feld der aktuellen Liste selbst erkennt, - einen einmaligen Web-`ScriptLink` für CSR/JSLink, der das gebundene Feld der aktuellen Liste selbst erkennt,
@@ -351,8 +367,8 @@ Soll nur die moderne Ansicht aktiviert werden, kann die Classic-Registrierung au
-SkipClassic -SkipClassic
``` ```
Die klassische Laufzeit verwendet dieselbe JSON-Konfiguration aus den Die klassische Laufzeit verwendet dieselbe Vererbungskette und dieselben Feld-Properties wie die moderne
`ClientSideComponentProperties` des gebundenen Ablaufdatumsfeldes. Es gibt keine zweite Konfiguration. Laufzeit. Es gibt keine zweite Konfiguration.
### Ablaufdatum um ein Jahr verlängern ### Ablaufdatum um ein Jahr verlängern
@@ -391,14 +407,14 @@ 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 ### Upgrade auf Version 2.1
1. Das vorhandene Paket im App Catalog durch Version `2.0.1.0` ersetzen. 1. Das vorhandene Paket im App Catalog durch Version `2.1.0.0` ersetzen.
2. Die App in der Site aktualisieren. Die enthaltene Feature-UpgradeAction provisioniert ausschließlich das 2. Die App in der Site aktualisieren. Die enthaltene Feature-UpgradeAction provisioniert ausschließlich das
neue Classic-Asset und legt keine fachliche Spalte an. aktualisierten Assets und legt keine fachliche Spalte an.
3. `Enable-ExpiryIndicator.ps1` für jede bereits angebundene Liste erneut ausführen. Das Skript erhält die 3. `Enable-ExpiryIndicator.ps1` für jede bereits angebundene Liste erneut ausführen. Das Skript erhält die
vorhandene Feldkonfiguration und ergänzt idempotent den gemeinsamen Web-ScriptLink sowie die vorhandene Feldkonfiguration und ergänzt idempotent den gemeinsamen Web-ScriptLink sowie die
listenspezifische Ribbon-Registrierung. listenspezifische Ribbon-Registrierung sowie das zentrale Bindungsinventar.
4. Modern- und Classic-Ansicht mit geleertem Browsercache neu laden und prüfen. 4. Modern- und Classic-Ansicht mit geleertem Browsercache neu laden und prüfen.
## Deaktivierung ## Deaktivierung

16
ToDo.md
View File

@@ -150,6 +150,22 @@ Beispiel einer Laufzeitkonfiguration:
- [ ] Feature-Upgrade in SharePoint Server Subscription Edition testen. - [ ] Feature-Upgrade in SharePoint Server Subscription Edition testen.
- [ ] Modern-/Classic-Abnahmetest auf einer Testliste und einer Test-Dokumentbibliothek durchführen. - [ ] Modern-/Classic-Abnahmetest auf einer Testliste und einer Test-Dokumentbibliothek durchführen.
## Release 2.1 zentrale Site-Collection-Konfiguration
- [x] Hierarchie `Liste > Subweb > Site Collection > eingebauter Standard` implementieren.
- [x] Moderne und klassische Laufzeit auf dieselbe Vererbungskette umstellen.
- [x] Solution-Descriptor und Bindungsinventar im Property Bag des Root Webs registrieren.
- [x] Bestehende vollständige Listenkonfigurationen beim erneuten Aktivieren erhalten.
- [x] Neue beziehungsweise leere Bindungen standardmäßig auf zentrale Vererbung stellen.
- [x] PortalSettings um providerbasierte Erkennung installierter Solutions erweitern.
- [x] ExpiryIndicator-Tab nur bei erkanntem Descriptor, Standard oder Bindungsinventar anzeigen.
- [x] Site-Collection-Standard in PortalSettings laden, validieren und speichern.
- [x] Registrierte Bindungen aus Root Web und Subwebs in PortalSettings anzeigen.
- [x] Einzelne oder alle registrierten Bindungen auf Vererbung umstellen können.
- [x] Versionen anheben und ExpiryIndicator- sowie PortalSettings-Pakete mit Node.js 8.17.0 bauen.
- [ ] Upgrade und zentrale Vererbung in SharePoint Server Subscription Edition testen.
- [ ] Berechtigungen mit Site-Collection-Administrator und normalem Listenverwalter prüfen.
## Abnahmekriterien ## Abnahmekriterien
- 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": "2.0.1.0", "version": "2.1.0.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": false, "skipFeatureDeployment": false,
"features": [ "features": [
@@ -11,7 +11,7 @@
"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": "2.0.1.0", "version": "2.1.0.0",
"assets": { "assets": {
"elementManifests": [ "elementManifests": [
"elements.xml", "elements.xml",

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "expiry-indicator", "name": "expiry-indicator",
"version": "2.0.1", "version": "2.1.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "expiry-indicator", "name": "expiry-indicator",
"version": "2.0.1", "version": "2.1.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=6.9.0 <9.0.0" "node": ">=6.9.0 <9.0.0"

View File

@@ -24,10 +24,14 @@ $context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
try { try {
$list = $context.Web.Lists.GetByTitle($ListTitle) $list = $context.Web.Lists.GetByTitle($ListTitle)
$field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName)
$rootWeb = $context.Site.RootWeb
$customActions = $list.UserCustomActions $customActions = $list.UserCustomActions
$webCustomActions = $context.Web.UserCustomActions $webCustomActions = $context.Web.UserCustomActions
$context.Load($list) $context.Load($list)
$context.Load($field) $context.Load($field)
$context.Load($context.Web)
$context.Load($rootWeb)
$context.Load($rootWeb.AllProperties)
$context.Load($customActions) $context.Load($customActions)
$context.Load($webCustomActions) $context.Load($webCustomActions)
$context.ExecuteQuery() $context.ExecuteQuery()
@@ -41,6 +45,28 @@ try {
$field.ClientSideComponentProperties = '' $field.ClientSideComponentProperties = ''
$field.Update() $field.Update()
$bindingsKey = 'PortalSettings.ExpiryIndicator.Bindings'
$bindings = @()
$existingBindingsJson = [string]$rootWeb.AllProperties[$bindingsKey]
if (-not [string]::IsNullOrWhiteSpace($existingBindingsJson)) {
try {
$bindings = @($existingBindingsJson | ConvertFrom-Json)
}
catch {
$bindings = @()
}
}
$webUrl = $context.Web.Url.TrimEnd('/')
$listId = $list.Id.ToString('D')
$bindings = @($bindings | Where-Object {
-not (
([string]$_.webUrl).TrimEnd('/').Equals($webUrl, [StringComparison]::OrdinalIgnoreCase) -and
([string]$_.listId).Equals($listId, [StringComparison]::OrdinalIgnoreCase)
)
})
$rootWeb.AllProperties[$bindingsKey] = (ConvertTo-Json -InputObject @($bindings) -Compress -Depth 5)
$rootWeb.Update()
$customActions | Where-Object { $customActions | Where-Object {
$_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or $_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or
$_.Name -eq 'ExpiryIndicator.Classic.Ribbon' $_.Name -eq 'ExpiryIndicator.Classic.Ribbon'

View File

@@ -26,10 +26,14 @@ $context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
try { try {
$list = $context.Web.Lists.GetByTitle($ListTitle) $list = $context.Web.Lists.GetByTitle($ListTitle)
$field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName)
$rootWeb = $context.Site.RootWeb
$customActions = $list.UserCustomActions $customActions = $list.UserCustomActions
$webCustomActions = $context.Web.UserCustomActions $webCustomActions = $context.Web.UserCustomActions
$context.Load($list) $context.Load($list)
$context.Load($field) $context.Load($field)
$context.Load($context.Web)
$context.Load($rootWeb)
$context.Load($rootWeb.AllProperties)
$context.Load($customActions) $context.Load($customActions)
$context.Load($webCustomActions) $context.Load($webCustomActions)
$context.ExecuteQuery() $context.ExecuteQuery()
@@ -41,12 +45,58 @@ try {
$alreadyBound = $field.ClientSideComponentId -eq $FieldCustomizerComponentId $alreadyBound = $field.ClientSideComponentId -eq $FieldCustomizerComponentId
$field.ClientSideComponentId = $FieldCustomizerComponentId $field.ClientSideComponentId = $FieldCustomizerComponentId
if (-not $alreadyBound -or [string]::IsNullOrWhiteSpace($field.ClientSideComponentProperties)) { if (-not $alreadyBound -or [string]::IsNullOrWhiteSpace($field.ClientSideComponentProperties)) {
$field.ClientSideComponentProperties = (@{ $field.ClientSideComponentProperties = ([ordered]@{
schemaVersion = 2
expiryField = $field.InternalName expiryField = $field.InternalName
inheritSiteDefaults = $true
} | ConvertTo-Json -Compress) } | ConvertTo-Json -Compress)
} }
$field.Update() $field.Update()
$descriptorKey = 'PortalSettings.Solutions.ExpiryIndicator'
$bindingsKey = 'PortalSettings.ExpiryIndicator.Bindings'
$rootWeb.AllProperties[$descriptorKey] = ([ordered]@{
key = 'ExpiryIndicator'
displayName = 'Expiry Indicator'
version = '2.1.0'
provider = 'expiryIndicator'
componentIds = @(
'a555f4fc-d6a6-4421-8189-457449d9bbde',
'cd58f5d9-ffc8-4df7-a910-3054842d7abf',
'95691218-bcb1-4fad-adda-02bb12014d15'
)
settingsScope = @('siteCollection', 'web', 'list')
} | ConvertTo-Json -Compress -Depth 5)
$bindings = @()
$existingBindingsJson = [string]$rootWeb.AllProperties[$bindingsKey]
if (-not [string]::IsNullOrWhiteSpace($existingBindingsJson)) {
try {
$bindings = @($existingBindingsJson | ConvertFrom-Json)
}
catch {
Write-Warning 'Das vorhandene ExpiryIndicator-Bindungsinventar war ungültig und wird neu aufgebaut.'
}
}
$webUrl = $context.Web.Url.TrimEnd('/')
$listId = $list.Id.ToString('D')
$bindings = @($bindings | Where-Object {
-not (
([string]$_.webUrl).TrimEnd('/').Equals($webUrl, [StringComparison]::OrdinalIgnoreCase) -and
([string]$_.listId).Equals($listId, [StringComparison]::OrdinalIgnoreCase)
)
})
$bindings += [ordered]@{
webUrl = $webUrl
listId = $listId
listTitle = $list.Title
fieldInternalName = $field.InternalName
fieldId = $field.Id.ToString('D')
}
$rootWeb.AllProperties[$bindingsKey] = (ConvertTo-Json -InputObject @($bindings) -Compress -Depth 5)
$rootWeb.Update()
# Remove old diagnostic registrations and make the V2 Classic # Remove old diagnostic registrations and make the V2 Classic
# registrations idempotent. # registrations idempotent.
$customActions | Where-Object { $customActions | Where-Object {

View File

@@ -2,6 +2,8 @@
'use strict'; 'use strict';
var FIELD_CUSTOMIZER_ID = 'a555f4fc-d6a6-4421-8189-457449d9bbde'; var FIELD_CUSTOMIZER_ID = 'a555f4fc-d6a6-4421-8189-457449d9bbde';
var SITE_DEFAULTS_PROPERTY = 'PortalSettings.ExpiryIndicator.Defaults';
var WEB_OVERRIDE_PROPERTY = 'PortalSettings.ExpiryIndicator.Override';
var state = { context: null, field: null, config: null, loading: null }; var state = { context: null, field: null, config: null, loading: null };
function configuredExpiryField() { function configuredExpiryField() {
@@ -44,6 +46,10 @@
return String(pageContext().webAbsoluteUrl || '').replace(/\/$/, ''); return String(pageContext().webAbsoluteUrl || '').replace(/\/$/, '');
} }
function siteUrl() {
return String(pageContext().siteAbsoluteUrl || webUrl()).replace(/\/$/, '');
}
function listId(context) { function listId(context) {
return String((context && context.listName) || pageContext().pageListId || '').replace(/[{}]/g, ''); return String((context && context.listName) || pageContext().pageListId || '').replace(/[{}]/g, '');
} }
@@ -94,14 +100,55 @@
}; };
} }
function mergeConfig(baseValue, overrideValue) {
var base = baseValue || defaults();
var override = overrideValue || {};
var result = {
baseField: override.baseField || base.baseField || 'Created',
expiryField: override.expiryField || base.expiryField || 'ExpiryDate',
default: {
lifeTime: override.default && override.default.lifeTime ? override.default.lifeTime :
(base.default && base.default.lifeTime ? base.default.lifeTime : { value: 2, unit: 'years' }),
columnRule: override.default && override.default.columnRule instanceof Array ? override.default.columnRule :
(base.default && base.default.columnRule instanceof Array ? base.default.columnRule : [])
},
rules: override.rules instanceof Array ? override.rules : (base.rules instanceof Array ? base.rules : []),
nullText: typeof override.nullText === 'string' ? override.nullText : base.nullText,
confirmExtension: typeof override.confirmExtension === 'boolean' ? override.confirmExtension : base.confirmExtension
};
return result;
}
function propertyValue(payload, key) {
var properties = payload && payload.d ? payload.d : (payload || {});
for (var name in properties) {
if (Object.prototype.hasOwnProperty.call(properties, name) && name.toLowerCase() === key.toLowerCase()) {
try { return typeof properties[name] === 'string' ? JSON.parse(properties[name]) : properties[name]; }
catch (ignore) { return null; }
}
}
return null;
}
function isBindingOnly(value) {
if (!value || typeof value !== 'object') { return false; }
return typeof value.expiryField === 'string' && !value.default && !value.rules &&
!value.baseField && typeof value.nullText === 'undefined' && typeof value.confirmExtension === 'undefined';
}
function loadConfig(context) { function loadConfig(context) {
var id = listId(context); var id = listId(context);
if (!id) { return Promise.reject(new Error('Keine Liste erkannt.')); } if (!id) { return Promise.reject(new Error('Keine Liste erkannt.')); }
if (state.loading && state.context === id) { return state.loading; } if (state.loading && state.context === id) { return state.loading; }
state.context = id; state.context = id;
state.loading = request('GET', listUrl(id) + var fieldRequest = request('GET', listUrl(id) +
'/fields?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties') '/fields?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties');
.then(function (data) { var sitePropertiesRequest = request('GET', siteUrl() + '/_api/web/AllProperties').catch(function () { return {}; });
var webPropertiesRequest = siteUrl().toLowerCase() === webUrl().toLowerCase() ? Promise.resolve({}) :
request('GET', webUrl() + '/_api/web/AllProperties').catch(function () { return {}; });
state.loading = Promise.all([fieldRequest, sitePropertiesRequest, webPropertiesRequest])
.then(function (results) {
var data = results[0];
var fields = data.d ? data.d.results : (data.value || []); var fields = data.d ? data.d.results : (data.value || []);
var field; var field;
for (var index = 0; index < fields.length; index++) { for (var index = 0; index < fields.length; index++) {
@@ -109,12 +156,18 @@
if (componentId === FIELD_CUSTOMIZER_ID) { field = fields[index]; break; } if (componentId === FIELD_CUSTOMIZER_ID) { field = fields[index]; break; }
} }
if (!field) { throw new Error('Kein ExpiryIndicator-Feld gebunden.'); } if (!field) { throw new Error('Kein ExpiryIndicator-Feld gebunden.'); }
var config = defaults(field.InternalName); var inherited = mergeConfig(defaults(field.InternalName), propertyValue(results[1], SITE_DEFAULTS_PROPERTY));
inherited = mergeConfig(inherited, propertyValue(results[2], WEB_OVERRIDE_PROPERTY));
var config = inherited;
try { try {
if (field.ClientSideComponentProperties) { if (field.ClientSideComponentProperties) {
var parsed = JSON.parse(field.ClientSideComponentProperties); var parsed = JSON.parse(field.ClientSideComponentProperties);
for (var name in parsed) { if (parsed.inheritSiteDefaults === true || parsed.override || isBindingOnly(parsed)) {
if (Object.prototype.hasOwnProperty.call(parsed, name)) { config[name] = parsed[name]; } config = mergeConfig(inherited, parsed.override || {});
} else if (parsed.inheritSiteDefaults === false) {
config = mergeConfig(defaults(field.InternalName), parsed.override || {});
} else {
config = mergeConfig(defaults(field.InternalName), parsed);
} }
} }
} catch (ignore) { } } catch (ignore) { }

View File

@@ -3,7 +3,7 @@ import {
SPHttpClient, SPHttpClient,
SPHttpClientResponse SPHttpClientResponse
} from '@microsoft/sp-http'; } from '@microsoft/sp-http';
import { createDefaultConfig, IExpiryConfig, normalizeConfig } from './ExpiryModels'; import { createDefaultConfig, IExpiryConfig, mergeConfigValues, normalizeConfig } from './ExpiryModels';
import { escapeODataString, responseError } from './RestError'; import { escapeODataString, responseError } from './RestError';
interface IConfigurationListInfo { interface IConfigurationListInfo {
@@ -27,33 +27,43 @@ interface IBoundFieldInfo {
const CONFIGURATION_LIST_TITLE: string = '_ExpiryIndicatorConfiguration'; const CONFIGURATION_LIST_TITLE: string = '_ExpiryIndicatorConfiguration';
const CONFIGURATION_FIELD: string = 'ExpiryIndicatorJson'; const CONFIGURATION_FIELD: string = 'ExpiryIndicatorJson';
const FIELD_CUSTOMIZER_COMPONENT_ID: string = 'a555f4fc-d6a6-4421-8189-457449d9bbde'; const FIELD_CUSTOMIZER_COMPONENT_ID: string = 'a555f4fc-d6a6-4421-8189-457449d9bbde';
const SITE_DEFAULTS_PROPERTY: string = 'PortalSettings.ExpiryIndicator.Defaults';
const WEB_OVERRIDE_PROPERTY: string = 'PortalSettings.ExpiryIndicator.Override';
export class ExpiryConfigService { export class ExpiryConfigService {
private readonly _spHttpClient: SPHttpClient; private readonly _spHttpClient: SPHttpClient;
private readonly _webUrl: string; private readonly _webUrl: string;
private readonly _siteUrl: string;
private _inheritedConfigPromise: Promise<IExpiryConfig> | undefined;
public constructor(spHttpClient: SPHttpClient, webUrl: string) { public constructor(spHttpClient: SPHttpClient, webUrl: string, siteUrl?: string) {
this._spHttpClient = spHttpClient; this._spHttpClient = spHttpClient;
this._webUrl = webUrl.replace(/\/$/, ''); this._webUrl = webUrl.replace(/\/$/, '');
this._siteUrl = (siteUrl || webUrl).replace(/\/$/, '');
} }
public getConfig(listId: string): Promise<IExpiryConfig> { public getConfig(listId: string): Promise<IExpiryConfig> {
return this._getBoundField(listId).then((field: IBoundFieldInfo): Promise<IExpiryConfig> => { return this._getBoundField(listId).then((field: IBoundFieldInfo): Promise<IExpiryConfig> => {
const propertyConfig: IExpiryConfig | undefined = this._parseProperties(field.properties, field.internalName); return this._getInheritedConfig().then((inherited: IExpiryConfig): Promise<IExpiryConfig> => {
if (propertyConfig) { const propertyConfig: IExpiryConfig | undefined = this._parseProperties(
return Promise.resolve(propertyConfig); field.properties,
} field.internalName,
inherited
// Upgrade compatibility: read an existing hidden-list configuration, );
// but never create that list. The next save migrates the JSON to the field. if (propertyConfig) {
return this._getLegacyConfig(listId).then((legacyConfig: IExpiryConfig | undefined): IExpiryConfig => { return Promise.resolve(propertyConfig);
if (legacyConfig) {
legacyConfig.expiryField = field.internalName;
return legacyConfig;
} }
const defaults: IExpiryConfig = createDefaultConfig();
defaults.expiryField = field.internalName; // Upgrade compatibility: read an existing hidden-list configuration,
return defaults; // but never create that list. The next save migrates the JSON to the field.
return this._getLegacyConfig(listId).then((legacyConfig: IExpiryConfig | undefined): IExpiryConfig => {
if (legacyConfig) {
legacyConfig.expiryField = field.internalName;
return legacyConfig;
}
inherited.expiryField = field.internalName;
return inherited;
});
}); });
}); });
} }
@@ -132,12 +142,29 @@ export class ExpiryConfigService {
}); });
} }
private _parseProperties(value: string, internalName: string): IExpiryConfig | undefined { private _parseProperties(
value: string,
internalName: string,
inherited: IExpiryConfig
): IExpiryConfig | undefined {
if (!value || value.trim() === '' || value.trim() === '{}') { if (!value || value.trim() === '' || value.trim() === '{}') {
return undefined; return undefined;
} }
try { try {
const config: IExpiryConfig = normalizeConfig(JSON.parse(value)); const properties: any = JSON.parse(value);
let config: IExpiryConfig;
if (properties.inheritSiteDefaults === true || properties.override || this._isBindingOnly(properties)) {
config = properties.inheritSiteDefaults === false
? createDefaultConfig()
: mergeConfigValues(createDefaultConfig(), inherited);
if (properties.override) {
config = mergeConfigValues(config, properties.override);
}
} else {
// Existing V1/V2 field configurations remain list-local until an
// administrator explicitly enables inheritance.
config = normalizeConfig(properties);
}
config.expiryField = internalName; config.expiryField = internalName;
return config; return config;
} catch (error) { } catch (error) {
@@ -145,6 +172,62 @@ export class ExpiryConfigService {
} }
} }
private _getInheritedConfig(): Promise<IExpiryConfig> {
if (!this._inheritedConfigPromise) {
this._inheritedConfigPromise = Promise.all([
this._getPropertyBagValue(this._siteUrl, SITE_DEFAULTS_PROPERTY),
this._webUrl.toLowerCase() === this._siteUrl.toLowerCase()
? Promise.resolve(undefined)
: this._getPropertyBagValue(this._webUrl, WEB_OVERRIDE_PROPERTY)
]).then((values: any[]): IExpiryConfig => {
let config: IExpiryConfig = createDefaultConfig();
if (values[0]) {
config = mergeConfigValues(config, values[0]);
}
if (values[1]) {
config = mergeConfigValues(config, values[1]);
}
return config;
});
}
return this._inheritedConfigPromise;
}
private _getPropertyBagValue(webUrl: string, key: string): Promise<any | undefined> {
return this._spHttpClient.get(webUrl + '/_api/web/AllProperties', SPHttpClient.configurations.v1, {
headers: this._headers()
}).then((response: SPHttpClientResponse): Promise<any | undefined> => {
if (!response.ok) {
return Promise.resolve(undefined);
}
return response.json().then((data: any): any | undefined => {
const source: any = data.d || data;
let rawValue: any = source[key];
if (typeof rawValue === 'undefined') {
Object.keys(source).some((propertyName: string): boolean => {
if (propertyName.toLowerCase() === key.toLowerCase()) {
rawValue = source[propertyName];
return true;
}
return false;
});
}
if (typeof rawValue !== 'string' || rawValue.trim() === '') {
return undefined;
}
try {
return JSON.parse(rawValue);
} catch (error) {
return undefined;
}
});
});
}
private _isBindingOnly(properties: any): boolean {
return properties && isOnlyKnownBindingProperties(properties);
}
private _getConfigurationList(): Promise<any> { private _getConfigurationList(): Promise<any> {
const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName'; const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName';
return this._spHttpClient.get(url, SPHttpClient.configurations.v1) return this._spHttpClient.get(url, SPHttpClient.configurations.v1)
@@ -211,3 +294,15 @@ export class ExpiryConfigService {
return headers; return headers;
} }
} }
function isOnlyKnownBindingProperties(properties: any): boolean {
if (!properties || typeof properties.expiryField !== 'string') {
return false;
}
const configurationKeys: string[] = [
'baseField', 'default', 'rules', 'nullText', 'confirmExtension'
];
return !configurationKeys.some((key: string): boolean =>
typeof properties[key] !== 'undefined'
);
}

View File

@@ -4,6 +4,7 @@ import { expect } from 'chai';
import { import {
createDefaultConfig, createDefaultConfig,
IExpiryConfig, IExpiryConfig,
mergeConfigValues,
normalizeConfig, normalizeConfig,
resolveBehavior, resolveBehavior,
validateValueRules validateValueRules
@@ -84,4 +85,21 @@ describe('ExpiryModels V2 rules', (): void => {
}]); }]);
expect(errors.length).to.be.greaterThan(0); expect(errors.length).to.be.greaterThan(0);
}); });
it('merges partial site and web defaults without losing inherited values', (): void => {
const siteConfig: IExpiryConfig = mergeConfigValues(createDefaultConfig(), {
baseField: 'DocumentCreated',
default: { lifeTime: { value: 3, unit: 'years' } },
nullText: 'Nicht berechenbar'
});
const webConfig: IExpiryConfig = mergeConfigValues(siteConfig, {
default: { columnRule: [] },
confirmExtension: false
});
expect(webConfig.baseField).to.equal('DocumentCreated');
expect(webConfig.default.lifeTime.value).to.equal(3);
expect(webConfig.default.columnRule.length).to.equal(0);
expect(webConfig.nullText).to.equal('Nicht berechenbar');
expect(webConfig.confirmExtension).to.equal(false);
});
}); });

View File

@@ -101,6 +101,28 @@ export function normalizeConfig(value: any): IExpiryConfig {
}; };
} }
export function mergeConfigValues(baseValue: any, overrideValue: any): IExpiryConfig {
const base: IExpiryConfig = normalizeConfig(baseValue);
const override: any = overrideValue || {};
const overrideDefault: any = override.default || {};
return normalizeConfig({
baseField: isSafeInternalName(override.baseField) ? override.baseField : base.baseField,
expiryField: isSafeInternalName(override.expiryField) ? override.expiryField : base.expiryField,
default: {
lifeTime: overrideDefault.lifeTime || base.default.lifeTime,
columnRule: Array.isArray(overrideDefault.columnRule)
? overrideDefault.columnRule
: base.default.columnRule
},
rules: Array.isArray(override.rules) ? override.rules : base.rules,
nullText: typeof override.nullText === 'string' ? override.nullText : base.nullText,
confirmExtension: typeof override.confirmExtension === 'boolean'
? override.confirmExtension
: base.confirmExtension
});
}
export function getRuleFieldNames(config: IExpiryConfig): string[] { export function getRuleFieldNames(config: IExpiryConfig): string[] {
const fields: string[] = []; const fields: string[] = [];
config.rules.forEach((rule: IExpiryValueRule): void => { config.rules.forEach((rule: IExpiryValueRule): void => {
@@ -321,6 +343,9 @@ function normalizeColumnRules(value: any, fallback: IExpiryColumnRule[]): IExpir
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return fallback; return fallback;
} }
if (value.length === 0) {
return [];
}
const rules: IExpiryColumnRule[] = []; const rules: IExpiryColumnRule[] = [];
value.forEach((rule: any): void => { value.forEach((rule: any): void => {

View File

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

View File

@@ -33,7 +33,8 @@ export default class ExpiryIndicatorFieldCustomizer
const service: ExpiryConfigService = new ExpiryConfigService( const service: ExpiryConfigService = new ExpiryConfigService(
this.context.spHttpClient, this.context.spHttpClient,
this.context.pageContext.web.absoluteUrl this.context.pageContext.web.absoluteUrl,
this.context.pageContext.site.absoluteUrl
); );
this._itemService = new ExpiryItemService( this._itemService = new ExpiryItemService(

View File

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

View File

@@ -32,7 +32,8 @@ export default class ExpiryIndicatorApplicationCustomizer
const listId: string = this.context.pageContext.list.id.toString(); const listId: string = this.context.pageContext.list.id.toString();
const service: ExpiryConfigService = new ExpiryConfigService( const service: ExpiryConfigService = new ExpiryConfigService(
this.context.spHttpClient, this.context.spHttpClient,
this.context.pageContext.web.absoluteUrl this.context.pageContext.web.absoluteUrl,
this.context.pageContext.site.absoluteUrl
); );
service.getConfig(listId).then((config: IExpiryConfig): void => { service.getConfig(listId).then((config: IExpiryConfig): void => {

View File

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

View File

@@ -32,7 +32,11 @@ export default class ExpiryIndicatorCommandSet
const listId: string = this.context.pageContext.list.id.toString(); const listId: string = this.context.pageContext.list.id.toString();
const webUrl: string = this.context.pageContext.web.absoluteUrl; const webUrl: string = this.context.pageContext.web.absoluteUrl;
this._configService = new ExpiryConfigService(this.context.spHttpClient, webUrl); this._configService = new ExpiryConfigService(
this.context.spHttpClient,
webUrl,
this.context.pageContext.site.absoluteUrl
);
this._itemService = new ExpiryItemService(this.context.spHttpClient, webUrl, listId); this._itemService = new ExpiryItemService(this.context.spHttpClient, webUrl, listId);
this._canEdit = this.context.pageContext.list.permissions.hasPermission(SPPermission.editListItems); this._canEdit = this.context.pageContext.list.permissions.hasPermission(SPPermission.editListItems);