From 0b8b7528371b1517356586944dfda779e41281e9 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Sat, 18 Jul 2026 20:42:00 +0200 Subject: [PATCH] feat: update to version 2.1.0 with central site collection configuration and inheritance support --- README.md | 32 +++-- ToDo.md | 16 +++ config/package-solution.json | 4 +- package-lock.json | 2 +- package.json | 2 +- scripts/Disable-ExpiryIndicator.ps1 | 26 ++++ scripts/Enable-ExpiryIndicator.ps1 | 52 ++++++- sharepoint/assets/ExpiryIndicatorClassic.js | 65 ++++++++- src/common/ExpiryConfigService.ts | 131 +++++++++++++++--- src/common/ExpiryModels.test.ts | 18 +++ src/common/ExpiryModels.ts | 25 ++++ ...piryIndicatorFieldCustomizer.manifest.json | 2 +- .../ExpiryIndicatorFieldCustomizer.ts | 3 +- ...dicatorApplicationCustomizer.manifest.json | 2 +- .../ExpiryIndicatorApplicationCustomizer.ts | 3 +- .../ExpiryIndicatorCommandSet.manifest.json | 2 +- .../ExpiryIndicatorCommandSet.ts | 6 +- 17 files changed, 348 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 3aa9b40..9d67058 100644 --- a/README.md +++ b/README.md @@ -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`. - 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. +- Zentraler Site-Collection-Standard mit optionalen Subweb- und Listen-Ausnahmen. 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 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 `_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 @@ -333,7 +349,7 @@ neu laden. ### 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: - 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 ``` -Die klassische Laufzeit verwendet dieselbe JSON-Konfiguration aus den -`ClientSideComponentProperties` des gebundenen Ablaufdatumsfeldes. Es gibt keine zweite Konfiguration. +Die klassische Laufzeit verwendet dieselbe Vererbungskette und dieselben Feld-Properties wie die moderne +Laufzeit. Es gibt keine zweite Konfiguration. ### Ablaufdatum um ein Jahr verlängern @@ -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. -### 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 - 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 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. ## Deaktivierung diff --git a/ToDo.md b/ToDo.md index c0bfc31..1642d03 100644 --- a/ToDo.md +++ b/ToDo.md @@ -150,6 +150,22 @@ Beispiel einer Laufzeitkonfiguration: - [ ] Feature-Upgrade in SharePoint Server Subscription Edition testen. - [ ] Modern-/Classic-Abnahmetest auf einer Testliste und einer Test-Dokumentbibliothek durchführen. +## Release 2.1 – zentrale Site-Collection-Konfiguration + +- [x] Hierarchie `Liste > Subweb > Site Collection > eingebauter Standard` implementieren. +- [x] Moderne und klassische Laufzeit auf dieselbe Vererbungskette umstellen. +- [x] Solution-Descriptor und Bindungsinventar im Property Bag des Root Webs registrieren. +- [x] Bestehende vollständige Listenkonfigurationen beim erneuten Aktivieren erhalten. +- [x] Neue beziehungsweise leere Bindungen standardmäßig auf zentrale Vererbung stellen. +- [x] PortalSettings um providerbasierte Erkennung installierter Solutions erweitern. +- [x] ExpiryIndicator-Tab nur bei erkanntem Descriptor, Standard oder Bindungsinventar anzeigen. +- [x] Site-Collection-Standard in PortalSettings laden, validieren und speichern. +- [x] Registrierte Bindungen aus Root Web und Subwebs in PortalSettings anzeigen. +- [x] Einzelne oder alle registrierten Bindungen auf Vererbung umstellen können. +- [x] 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 - Die Solution enthält und provisioniert keine Definition für eine fachliche Ablaufdatumsspalte. diff --git a/config/package-solution.json b/config/package-solution.json index e7ca6f6..ee59a18 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "expiry-indicator-client-side-solution", "id": "68bb6d2d-9895-45b4-88e8-b98835faa981", - "version": "2.0.1.0", + "version": "2.1.0.0", "includeClientSideAssets": true, "skipFeatureDeployment": false, "features": [ @@ -11,7 +11,7 @@ "title": "ExpiryIndicator extension registration", "description": "Registers ExpiryIndicator commands and its settings Application Customizer. Does not provision ExpiryDate.", "id": "913402af-ab9a-4974-9f86-5c2159ae41db", - "version": "2.0.1.0", + "version": "2.1.0.0", "assets": { "elementManifests": [ "elements.xml", diff --git a/package-lock.json b/package-lock.json index 843cce2..bd2a4db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "expiry-indicator", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 2914c78..a4cd24b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "expiry-indicator", - "version": "2.0.1", + "version": "2.1.0", "private": true, "engines": { "node": ">=6.9.0 <9.0.0" diff --git a/scripts/Disable-ExpiryIndicator.ps1 b/scripts/Disable-ExpiryIndicator.ps1 index bd52e21..d0077fd 100644 --- a/scripts/Disable-ExpiryIndicator.ps1 +++ b/scripts/Disable-ExpiryIndicator.ps1 @@ -24,10 +24,14 @@ $context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials try { $list = $context.Web.Lists.GetByTitle($ListTitle) $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) + $rootWeb = $context.Site.RootWeb $customActions = $list.UserCustomActions $webCustomActions = $context.Web.UserCustomActions $context.Load($list) $context.Load($field) + $context.Load($context.Web) + $context.Load($rootWeb) + $context.Load($rootWeb.AllProperties) $context.Load($customActions) $context.Load($webCustomActions) $context.ExecuteQuery() @@ -41,6 +45,28 @@ try { $field.ClientSideComponentProperties = '' $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 { $_.Name -eq 'ExpiryIndicator.Classic.ScriptLink' -or $_.Name -eq 'ExpiryIndicator.Classic.Ribbon' diff --git a/scripts/Enable-ExpiryIndicator.ps1 b/scripts/Enable-ExpiryIndicator.ps1 index 725e90d..d288394 100644 --- a/scripts/Enable-ExpiryIndicator.ps1 +++ b/scripts/Enable-ExpiryIndicator.ps1 @@ -26,10 +26,14 @@ $context.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials try { $list = $context.Web.Lists.GetByTitle($ListTitle) $field = $list.Fields.GetByInternalNameOrTitle($ExpiryFieldInternalName) + $rootWeb = $context.Site.RootWeb $customActions = $list.UserCustomActions $webCustomActions = $context.Web.UserCustomActions $context.Load($list) $context.Load($field) + $context.Load($context.Web) + $context.Load($rootWeb) + $context.Load($rootWeb.AllProperties) $context.Load($customActions) $context.Load($webCustomActions) $context.ExecuteQuery() @@ -41,12 +45,58 @@ try { $alreadyBound = $field.ClientSideComponentId -eq $FieldCustomizerComponentId $field.ClientSideComponentId = $FieldCustomizerComponentId if (-not $alreadyBound -or [string]::IsNullOrWhiteSpace($field.ClientSideComponentProperties)) { - $field.ClientSideComponentProperties = (@{ + $field.ClientSideComponentProperties = ([ordered]@{ + schemaVersion = 2 expiryField = $field.InternalName + inheritSiteDefaults = $true } | ConvertTo-Json -Compress) } $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 # registrations idempotent. $customActions | Where-Object { diff --git a/sharepoint/assets/ExpiryIndicatorClassic.js b/sharepoint/assets/ExpiryIndicatorClassic.js index 99b00e7..b48d052 100644 --- a/sharepoint/assets/ExpiryIndicatorClassic.js +++ b/sharepoint/assets/ExpiryIndicatorClassic.js @@ -2,6 +2,8 @@ 'use strict'; var FIELD_CUSTOMIZER_ID = 'a555f4fc-d6a6-4421-8189-457449d9bbde'; + var SITE_DEFAULTS_PROPERTY = 'PortalSettings.ExpiryIndicator.Defaults'; + var WEB_OVERRIDE_PROPERTY = 'PortalSettings.ExpiryIndicator.Override'; var state = { context: null, field: null, config: null, loading: null }; function configuredExpiryField() { @@ -44,6 +46,10 @@ return String(pageContext().webAbsoluteUrl || '').replace(/\/$/, ''); } + function siteUrl() { + return String(pageContext().siteAbsoluteUrl || webUrl()).replace(/\/$/, ''); + } + function listId(context) { return String((context && context.listName) || pageContext().pageListId || '').replace(/[{}]/g, ''); } @@ -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) { 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 fieldRequest = request('GET', listUrl(id) + + '/fields?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties'); + var sitePropertiesRequest = request('GET', siteUrl() + '/_api/web/AllProperties').catch(function () { return {}; }); + var webPropertiesRequest = siteUrl().toLowerCase() === webUrl().toLowerCase() ? Promise.resolve({}) : + request('GET', webUrl() + '/_api/web/AllProperties').catch(function () { return {}; }); + state.loading = Promise.all([fieldRequest, sitePropertiesRequest, webPropertiesRequest]) + .then(function (results) { + var data = results[0]; var fields = data.d ? data.d.results : (data.value || []); var field; for (var index = 0; index < fields.length; index++) { @@ -109,12 +156,18 @@ if (componentId === FIELD_CUSTOMIZER_ID) { field = fields[index]; break; } } 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 { 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]; } + if (parsed.inheritSiteDefaults === true || parsed.override || isBindingOnly(parsed)) { + config = mergeConfig(inherited, parsed.override || {}); + } else if (parsed.inheritSiteDefaults === false) { + config = mergeConfig(defaults(field.InternalName), parsed.override || {}); + } else { + config = mergeConfig(defaults(field.InternalName), parsed); } } } catch (ignore) { } diff --git a/src/common/ExpiryConfigService.ts b/src/common/ExpiryConfigService.ts index 456fbb1..f264fe7 100644 --- a/src/common/ExpiryConfigService.ts +++ b/src/common/ExpiryConfigService.ts @@ -3,7 +3,7 @@ import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http'; -import { createDefaultConfig, IExpiryConfig, normalizeConfig } from './ExpiryModels'; +import { createDefaultConfig, IExpiryConfig, mergeConfigValues, normalizeConfig } from './ExpiryModels'; import { escapeODataString, responseError } from './RestError'; interface IConfigurationListInfo { @@ -27,33 +27,43 @@ interface IBoundFieldInfo { const CONFIGURATION_LIST_TITLE: string = '_ExpiryIndicatorConfiguration'; const CONFIGURATION_FIELD: string = 'ExpiryIndicatorJson'; 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 { private readonly _spHttpClient: SPHttpClient; private readonly _webUrl: string; + private readonly _siteUrl: string; + private _inheritedConfigPromise: Promise | undefined; - public constructor(spHttpClient: SPHttpClient, webUrl: string) { + public constructor(spHttpClient: SPHttpClient, webUrl: string, siteUrl?: string) { this._spHttpClient = spHttpClient; this._webUrl = webUrl.replace(/\/$/, ''); + this._siteUrl = (siteUrl || webUrl).replace(/\/$/, ''); } public getConfig(listId: string): Promise { return this._getBoundField(listId).then((field: IBoundFieldInfo): Promise => { - const propertyConfig: IExpiryConfig | undefined = this._parseProperties(field.properties, field.internalName); - if (propertyConfig) { - return Promise.resolve(propertyConfig); - } - - // Upgrade compatibility: read an existing hidden-list configuration, - // 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; + return this._getInheritedConfig().then((inherited: IExpiryConfig): Promise => { + const propertyConfig: IExpiryConfig | undefined = this._parseProperties( + field.properties, + field.internalName, + inherited + ); + if (propertyConfig) { + return Promise.resolve(propertyConfig); } - const defaults: IExpiryConfig = createDefaultConfig(); - defaults.expiryField = field.internalName; - return defaults; + + // Upgrade compatibility: read an existing hidden-list configuration, + // 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() === '{}') { return undefined; } 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; return config; } catch (error) { @@ -145,6 +172,62 @@ export class ExpiryConfigService { } } + private _getInheritedConfig(): Promise { + 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 { + return this._spHttpClient.get(webUrl + '/_api/web/AllProperties', SPHttpClient.configurations.v1, { + headers: this._headers() + }).then((response: SPHttpClientResponse): Promise => { + 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 { const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName'; return this._spHttpClient.get(url, SPHttpClient.configurations.v1) @@ -211,3 +294,15 @@ export class ExpiryConfigService { 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' + ); +} diff --git a/src/common/ExpiryModels.test.ts b/src/common/ExpiryModels.test.ts index 0063d50..409dc17 100644 --- a/src/common/ExpiryModels.test.ts +++ b/src/common/ExpiryModels.test.ts @@ -4,6 +4,7 @@ import { expect } from 'chai'; import { createDefaultConfig, IExpiryConfig, + mergeConfigValues, normalizeConfig, resolveBehavior, validateValueRules @@ -84,4 +85,21 @@ describe('ExpiryModels V2 rules', (): void => { }]); 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); + }); }); diff --git a/src/common/ExpiryModels.ts b/src/common/ExpiryModels.ts index dde3ca8..51dbf10 100644 --- a/src/common/ExpiryModels.ts +++ b/src/common/ExpiryModels.ts @@ -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[] { const fields: string[] = []; config.rules.forEach((rule: IExpiryValueRule): void => { @@ -321,6 +343,9 @@ function normalizeColumnRules(value: any, fallback: IExpiryColumnRule[]): IExpir if (!Array.isArray(value)) { return fallback; } + if (value.length === 0) { + return []; + } const rules: IExpiryColumnRule[] = []; value.forEach((rule: any): void => { diff --git a/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.manifest.json b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.manifest.json index 7b8172e..8e51f66 100644 --- a/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.manifest.json +++ b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.manifest.json @@ -4,7 +4,7 @@ "alias": "ExpiryIndicatorFieldCustomizer", "componentType": "Extension", "extensionType": "FieldCustomizer", - "version": "2.0.1", + "version": "2.1.0", "manifestVersion": 2, "requiresCustomScript": false } diff --git a/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts index f2dfe7f..200ee1c 100644 --- a/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts +++ b/src/extensions/expiryIndicator/ExpiryIndicatorFieldCustomizer.ts @@ -33,7 +33,8 @@ export default class ExpiryIndicatorFieldCustomizer const service: ExpiryConfigService = new ExpiryConfigService( this.context.spHttpClient, - this.context.pageContext.web.absoluteUrl + this.context.pageContext.web.absoluteUrl, + this.context.pageContext.site.absoluteUrl ); this._itemService = new ExpiryItemService( diff --git a/src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.manifest.json b/src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.manifest.json index 7963ad3..d5bfd30 100644 --- a/src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.manifest.json +++ b/src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.manifest.json @@ -4,7 +4,7 @@ "alias": "ExpiryIndicatorApplicationCustomizer", "componentType": "Extension", "extensionType": "ApplicationCustomizer", - "version": "2.0.1", + "version": "2.1.0", "manifestVersion": 2, "requiresCustomScript": false } diff --git a/src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.ts b/src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.ts index 6e23f94..ecc4eb5 100644 --- a/src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.ts +++ b/src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.ts @@ -32,7 +32,8 @@ export default class ExpiryIndicatorApplicationCustomizer const listId: string = this.context.pageContext.list.id.toString(); const service: ExpiryConfigService = new ExpiryConfigService( 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 => { diff --git a/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json b/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json index e5e5872..ee08918 100644 --- a/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json +++ b/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json @@ -4,7 +4,7 @@ "alias": "ExpiryIndicatorCommandSet", "componentType": "Extension", "extensionType": "ListViewCommandSet", - "version": "2.0.1", + "version": "2.1.0", "manifestVersion": 2, "requiresCustomScript": false, "items": { diff --git a/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.ts b/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.ts index 39cd05b..234392c 100644 --- a/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.ts +++ b/src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.ts @@ -32,7 +32,11 @@ export default class ExpiryIndicatorCommandSet const listId: string = this.context.pageContext.list.id.toString(); 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._canEdit = this.context.pageContext.list.permissions.hasPermission(SPPermission.editListItems);