From 37382d1dc0ab923552b7722259010692af5cf253 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 14:16:42 +0200 Subject: [PATCH] =?UTF-8?q?Aktualisiere=20Versionsnummer=20auf=203.0.1=20i?= =?UTF-8?q?n=20README.md,=20package.json,=20package-lock.json=20und=20pack?= =?UTF-8?q?age-solution.json.=20F=C3=BCge=20Unterst=C3=BCtzung=20f=C3=BCr?= =?UTF-8?q?=20zentrale=20Aktivierung=20von=20CustomBranding=20hinzu=20und?= =?UTF-8?q?=20verbessere=20die=20Logik=20zur=20Handhabung=20von=20UserCust?= =?UTF-8?q?omActions.=20Aktualisiere=20Tests=20zur=20Validierung=20der=20n?= =?UTF-8?q?euen=20Struktur.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- ToDo.md | 4 +- config/package-solution.json | 2 +- package-lock.json | 2 +- package.json | 2 +- src/core/ProviderContracts.ts | 10 +++ .../CustomBrandingSettingsProvider.ts | 26 +++++++- src/services/PortalSettingsDataService.ts | 66 ++++++++++++++++--- src/ui/PortalSettingsApp.ts | 41 +++++++++--- tests/ProviderRegistry.test.ts | 3 + tests/validate-static-assets.js | 4 +- 11 files changed, 138 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5ccb2ff..f7feb34 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.0.0 | +| Version | 3.0.1 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | @@ -29,6 +29,8 @@ Die frühere provisionierte `PortalSettings.aspx` und der leere Application Cust ### CustomBranding - zentrale `SPSite.UserCustomAction` +- Aktivierung der fehlenden Site-Collection-Action direkt aus PortalSettings +- versionierter `portalSettings`-Vertragsmarker mit Component-ID-Fallback - Aktivierung und Debug-Modus - CSS-Dateien inklusive `media` - Allowlist externer CSS-Hosts @@ -153,7 +155,7 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.0.0.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.0.1.0` ersetzen. 2. Die App im Root Web der gewünschten Site Collection installieren oder aktualisieren. 3. Eine moderne Seite erstellen. 4. Das WebPart **Portal Settings** einmal auf der Seite hinzufügen. diff --git a/ToDo.md b/ToDo.md index 1fe94fc..907729e 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.0.0 +Zielversion: 3.0.1 ## Architektur @@ -35,6 +35,8 @@ Zielversion: 3.0.0 - [x] MegaMenu vollständig auf Provider-Schema umgestellt. - [x] Termset, Modus, Cache, Cache-Version und Debug abgedeckt. - [x] CustomBranding vollständig auf Schema 2 abgebildet. +- [x] Fehlende CustomBranding-Site-Action kann beim ersten Speichern angelegt werden. +- [x] CustomBranding-Vertragsmarker und Component-ID-Fallback unterstützt. - [x] Branding-Sicherheitsprüfung vor dem Speichern. - [x] ExpiryIndicator-Site-Collection-Standard abgebildet. - [x] Expiry-Bindungsinventar dargestellt. diff --git a/config/package-solution.json b/config/package-solution.json index b38ad91..dfaf853 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.0.0.0", + "version": "3.0.1.0", "includeClientSideAssets": true, "skipFeatureDeployment": false }, diff --git a/package-lock.json b/package-lock.json index fff88c3..e012525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d1f9e7a..4eaa65c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.0", + "version": "3.0.1", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/core/ProviderContracts.ts b/src/core/ProviderContracts.ts index 260f5a2..806c2d3 100644 --- a/src/core/ProviderContracts.ts +++ b/src/core/ProviderContracts.ts @@ -59,6 +59,15 @@ export interface ISettingsSection { export interface IProviderStorageDefinition { kind: SettingsStorageKind; propertyKey?: string; + action?: IProviderActionDefinition; +} + +export interface IProviderActionDefinition { + name: string; + title: string; + description: string; + location: 'ClientSideExtension.ApplicationCustomizer'; + sequence: number; } export interface ISettingsProvider { @@ -97,6 +106,7 @@ export interface ILoadedSettingsProvider { config: any; // tslint:disable-line:no-any originalConfig: any; // tslint:disable-line:no-any context: IProviderLoadContext; + registered: boolean; dirty: boolean; } diff --git a/src/providers/CustomBrandingSettingsProvider.ts b/src/providers/CustomBrandingSettingsProvider.ts index 807e0ef..7872c88 100644 --- a/src/providers/CustomBrandingSettingsProvider.ts +++ b/src/providers/CustomBrandingSettingsProvider.ts @@ -2,6 +2,11 @@ import { ISettingsProvider } from '../core/ProviderContracts'; export interface ICustomBrandingSettings { schemaVersion: number; + portalSettings: { + providerKey: string; + contractVersion: number; + minimumPortalSettingsVersion: string; + }; enabled: boolean; debug: boolean; allowedCssHosts: string[]; @@ -19,7 +24,16 @@ export const CustomBrandingSettingsProvider: ISettingsProvider ({ schemaVersion: 2, + portalSettings: { + providerKey: 'custombranding', + contractVersion: 1, + minimumPortalSettingsVersion: '3.0.0' + }, enabled: true, debug: false, allowedCssHosts: [], @@ -82,6 +101,11 @@ export const CustomBrandingSettingsProvider: ISettingsProvider { result[key] = source[key]; }); result.schemaVersion = 2; + result.portalSettings = { + providerKey: 'custombranding', + contractVersion: 1, + minimumPortalSettingsVersion: '3.0.0' + }; result.enabled = source.enabled !== false; result.debug = source.debug === true; result.allowedCssHosts = Array.isArray(source.allowedCssHosts) ? source.allowedCssHosts : []; diff --git a/src/services/PortalSettingsDataService.ts b/src/services/PortalSettingsDataService.ts index 0ad09fe..de08a97 100644 --- a/src/services/PortalSettingsDataService.ts +++ b/src/services/PortalSettingsDataService.ts @@ -50,10 +50,14 @@ export class PortalSettingsDataService { if (errors.length > 0) { return Promise.reject(new Error(errors.join(' '))); } const merged: any = mergePreservingUnknown(item.originalConfig, item.provider.normalize(item.config)); if (item.provider.storage.kind === 'siteUserCustomAction') { - if (!item.context.action) { return Promise.reject(new Error('Die zentrale UserCustomAction wurde nicht gefunden.')); } - return this.saveUserCustomAction(item.context.action, merged).then((): void => { + const persist: Promise = item.context.action + ? this.saveUserCustomAction(item.context.action, merged).then((): IUserCustomActionInfo => item.context.action) + : this.createUserCustomAction(item.provider, merged); + return persist.then((action: IUserCustomActionInfo): void => { + item.context.action = action; item.config = item.provider.normalize(merged); item.originalConfig = cloneValue(merged); + item.registered = true; item.dirty = false; }); } @@ -181,9 +185,13 @@ export class PortalSettingsDataService { const context: IProviderLoadContext = {}; if (provider.storage.kind === 'siteUserCustomAction') { const action: IUserCustomActionInfo = this.findAction(provider, actions); - if (!action) { return undefined; } - context.action = action; - raw = this.parseJson(action.properties, {}); + if (!action && !provider.storage.action) { return undefined; } + if (action) { + context.action = action; + raw = this.parseJson(action.properties, {}); + } else { + raw = provider.createDefault(); + } } else { const propertyKey: string = provider.storage.propertyKey || ''; const stored: any = this.getProperty(properties, propertyKey); @@ -202,6 +210,7 @@ export class PortalSettingsDataService { config: cloneValue(normalized), originalConfig: cloneValue(raw || normalized), context: context, + registered: !!context.action || provider.storage.kind !== 'siteUserCustomAction', dirty: false }; } @@ -240,6 +249,39 @@ export class PortalSettingsDataService { }).then((response: SPHttpClientResponse): Promise => this.ensureOk(response, 'Konfiguration konnte nicht gespeichert werden.')); } + private createUserCustomAction(provider: ISettingsProvider, config: any): Promise { + const definition: any = provider.storage.action; + if (!definition || !provider.componentIds.length) { + return Promise.reject(new Error('Für diesen Provider ist keine zentrale Registrierung definiert.')); + } + const body: any = { + '__metadata': { 'type': 'SP.UserCustomAction' }, + 'Name': definition.name, + 'Title': definition.title, + 'Description': definition.description, + 'Location': definition.location, + 'Sequence': definition.sequence, + 'ClientSideComponentId': provider.componentIds[0], + 'ClientSideComponentProperties': JSON.stringify(config) + }; + return this.context.spHttpClient.post(this.siteUrl + '/_api/site/UserCustomActions', SPHttpClient.configurations.v1, { + headers: this.writeHeaders(), body: JSON.stringify(body) + }).then((response: SPHttpClientResponse): Promise => this.ensureJson(response, + 'CustomBranding konnte nicht für die Site Collection registriert werden.')) + .then((payload: any): IUserCustomActionInfo => { + const source: any = payload.d || payload; + return { + id: this.normalizeGuid(source.Id), + title: source.Title || definition.title, + name: source.Name || definition.name, + location: source.Location || definition.location, + componentId: this.normalizeGuid(source.ClientSideComponentId || provider.componentIds[0]), + properties: source.ClientSideComponentProperties || JSON.stringify(config), + entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.UserCustomAction' + }; + }); + } + private saveRootProperty(key: string, value: string): Promise { if (!key) { return Promise.reject(new Error('Der Property-Bag-Schlüssel fehlt.')); } const body: any = { '__metadata': { 'type': 'SP.PropertyValues' } }; @@ -251,7 +293,11 @@ export class PortalSettingsDataService { private findAction(provider: ISettingsProvider, actions: IUserCustomActionInfo[]): IUserCustomActionInfo | undefined { for (let i: number = 0; i < actions.length; i++) { - if (provider.componentIds.indexOf(actions[i].componentId) >= 0 && + const properties: any = this.parseJson(actions[i].properties, {}); + const providerKey: string = properties && properties.portalSettings + ? String(properties.portalSettings.providerKey || '').toLowerCase() + : ''; + if ((provider.componentIds.indexOf(actions[i].componentId) >= 0 || providerKey === provider.key.toLowerCase()) && actions[i].location.indexOf('ClientSideExtension.ApplicationCustomizer') >= 0) { return actions[i]; } @@ -321,12 +367,14 @@ export class PortalSettingsDataService { private encodePropertyName(value: string): string { return value.replace(/\./g, '_x002e_'); } private readHeaders(): any { return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; } - private writeHeaders(method: string): any { - return { + private writeHeaders(method?: string): any { + const headers: any = { 'Accept': 'application/json;odata=verbose', 'Content-Type': 'application/json;odata=verbose', - 'OData-Version': '3.0', 'IF-MATCH': '*', 'X-HTTP-Method': method + 'OData-Version': '3.0' }; + if (method) { headers['IF-MATCH'] = '*'; headers['X-HTTP-Method'] = method; } + return headers; } private ensureJson(response: SPHttpClientResponse, message: string): Promise { diff --git a/src/ui/PortalSettingsApp.ts b/src/ui/PortalSettingsApp.ts index 17b9aa9..b0153d2 100644 --- a/src/ui/PortalSettingsApp.ts +++ b/src/ui/PortalSettingsApp.ts @@ -58,7 +58,7 @@ export class PortalSettingsApp { const hero: HTMLElement = createElement('header', 'ps3-hero'); hero.appendChild(createElement('span', 'ps3-eyebrow', 'PORTAL SETTINGS V3')); hero.appendChild(createElement('h1', 'ps3-title', this.configuredTitle || 'Zentrale Verwaltung der Portal-Erweiterungen')); - hero.appendChild(createElement('p', 'ps3-lead', 'Installierte Solutions werden automatisch erkannt. Ihre versionierten Provider erzeugen strukturierte und validierte Einstellungsseiten.')); + hero.appendChild(createElement('p', 'ps3-lead', 'Registrierte Solutions werden automatisch erkannt. Unterstützte Provider können zentral aktiviert und über strukturierte, validierte Einstellungsseiten verwaltet werden.')); const meta: HTMLElement = createElement('div', 'ps3-heroMeta'); meta.appendChild(this.metric('Site Collection', this.dataService.getSiteUrl())); meta.appendChild(this.metric('Konfigurationsmodell', 'Providerbasiert')); @@ -78,7 +78,11 @@ export class PortalSettingsApp { this.items = items; this.activeKey = preferredKey && this.findItem(preferredKey) ? preferredKey : (items.length ? items[0].provider.key : ''); this.renderTabs(); this.renderActive(); - this.showStatus(items.length ? items.length + ' Erweiterung(en) wurden erkannt.' : 'Keine unterstützte Erweiterung wurde aktiv registriert.', items.length ? 'success' : 'warning'); + let registeredCount: number = 0; + for (let i: number = 0; i < items.length; i++) { if (items[i].registered) { registeredCount++; } } + this.showStatus(items.length + ? registeredCount + ' Erweiterung(en) registriert, ' + (items.length - registeredCount) + ' zur Aktivierung verfügbar.' + : 'Keine unterstützte Erweiterung wurde aktiv registriert.', registeredCount ? 'success' : 'warning'); }).catch((error: Error): void => { if (!this.disposed) { this.showStatus(error.message || String(error), 'danger', true); this.renderEmpty(true); } }); @@ -113,9 +117,18 @@ export class PortalSettingsApp { heading.appendChild(createElement('p', 'ps3-providerDescription', item.provider.description)); header.appendChild(heading); const storage: HTMLElement = createElement('div', 'ps3-storageBadge', - item.provider.storage.kind === 'siteUserCustomAction' ? 'Site-Collection-Action' : 'Root-Web-Standard'); + item.provider.storage.kind === 'siteUserCustomAction' + ? (item.registered ? 'Site-Collection-Action' : 'Bereit zur Aktivierung') + : 'Root-Web-Standard'); header.appendChild(storage); this.contentHost.appendChild(header); + if (!item.registered) { + const registrationNotice: HTMLElement = createElement('div', 'ps3-status ps3-status-warning'); + registrationNotice.setAttribute('role', 'status'); + registrationNotice.textContent = 'CustomBranding ist noch nicht zentral registriert. Beim ersten Speichern legt PortalSettings die Site-Collection-Action an. Das CustomBranding-Paket muss zuvor im App Catalog bereitgestellt sein.'; + this.contentHost.appendChild(registrationNotice); + } + const form: HTMLElement = createElement('div', 'ps3-form'); this.contentHost.appendChild(form); this.formRenderer.render(form, item, { changed: (): void => { this.updateSaveState(); this.renderTabs(); }, @@ -126,7 +139,8 @@ export class PortalSettingsApp { const actions: HTMLElement = createElement('div', 'ps3-actionBar'); const reset: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Änderungen verwerfen') as HTMLButtonElement; reset.type = 'button'; reset.addEventListener('click', (): void => this.resetCurrent()); - this.saveButton = createElement('button', 'ps3-button ps3-button-primary', 'Konfiguration speichern') as HTMLButtonElement; + this.saveButton = createElement('button', 'ps3-button ps3-button-primary', + item.registered ? 'Konfiguration speichern' : 'CustomBranding aktivieren') as HTMLButtonElement; this.saveButton.type = 'button'; this.saveButton.addEventListener('click', (): void => this.saveCurrent()); actions.appendChild(reset); actions.appendChild(this.saveButton); this.contentHost.appendChild(actions); this.updateSaveState(); @@ -138,19 +152,26 @@ export class PortalSettingsApp { empty.appendChild(createElement('h2', '', failed ? 'PortalSettings konnte nicht geladen werden' : 'Keine aktiven Provider gefunden')); empty.appendChild(createElement('p', '', failed ? 'Prüfen Sie die Statusmeldung und die REST-Berechtigungen.' - : 'MegaMenu und CustomBranding benötigen eine zentrale Site-Collection-Action. ExpiryIndicator wird über seinen Descriptor oder vorhandene Bindungen erkannt.')); + : 'MegaMenu wird über seine zentrale Site-Collection-Action erkannt. CustomBranding kann direkt aktiviert werden; ExpiryIndicator wird über seinen Descriptor oder vorhandene Bindungen erkannt.')); this.contentHost.appendChild(empty); } private saveCurrent(): void { const item: ILoadedSettingsProvider = this.currentItem(); - if (!item || !item.dirty) { return; } + if (!item || (item.registered && !item.dirty)) { return; } + const wasRegistered: boolean = item.registered; const errors: string[] = item.provider.validate(item.config); if (errors.length) { this.showStatus(errors.join(' '), 'danger', true); return; } - if (!window.confirm('Konfiguration für „' + item.provider.displayName + '“ speichern?')) { return; } + const question: string = wasRegistered + ? 'Konfiguration für „' + item.provider.displayName + '“ speichern?' + : 'CustomBranding zentral für diese Site Collection aktivieren?'; + if (!window.confirm(question)) { return; } this.saveButton.disabled = true; this.showStatus('Konfiguration wird gespeichert …', 'info', true); this.dataService.save(item).then((): void => { - this.showStatus('Die Konfiguration wurde erfolgreich gespeichert.', 'success'); this.renderTabs(); this.renderActive(); + this.showStatus(wasRegistered + ? 'Die Konfiguration wurde erfolgreich gespeichert.' + : 'CustomBranding wurde zentral aktiviert und gespeichert.', 'success'); + this.renderTabs(); this.renderActive(); }).catch((error: Error): void => { this.showStatus(error.message || String(error), 'danger', true); this.updateSaveState(); }); @@ -200,7 +221,7 @@ export class PortalSettingsApp { private updateSaveState(): void { const item: ILoadedSettingsProvider = this.currentItem(); - if (this.saveButton) { this.saveButton.disabled = !item || !item.dirty; } + if (this.saveButton) { this.saveButton.disabled = !item || (item.registered && !item.dirty); } } private openExpiryBindingDialog(item: ILoadedSettingsProvider): void { @@ -284,7 +305,7 @@ export class PortalSettingsApp { provider.sections = siteItem.provider.sections.filter(section => section.key === 'dates' || section.key === 'rules' || section.key === 'preview'); const editItem: ILoadedSettingsProvider = { provider: provider, config: provider.normalize(loaded.config), originalConfig: loaded.config, - context: {}, dirty: false + context: {}, registered: true, dirty: false }; const editor: DynamicFormRenderer = new DynamicFormRenderer(); editor.render(form, editItem, { diff --git a/tests/ProviderRegistry.test.ts b/tests/ProviderRegistry.test.ts index 2cf724d..4adbfa7 100644 --- a/tests/ProviderRegistry.test.ts +++ b/tests/ProviderRegistry.test.ts @@ -28,6 +28,9 @@ assert(normalizedMega.menuMode === 'megaMenu', 'MegaMenu Modus-Fallback fehlt.') assert(normalizedMega.unknownFuture === 'keep', 'Unbekannte MegaMenu-Properties gehen verloren.'); const branding: ISettingsProvider = getSettingsProvider('custombranding'); // tslint:disable-line:no-any +assert(!!branding.storage.action, 'CustomBranding-Aktivierungsdefinition fehlt.'); +assert(branding.storage.action.name === 'CustomBranding', 'CustomBranding Action-Name ist nicht stabil.'); +assert(branding.storage.action.location === 'ClientSideExtension.ApplicationCustomizer', 'CustomBranding Action-Location ist ungültig.'); const normalizedBranding: any = branding.normalize({ elements: [{ type: 'span' }], future: 7 }); // tslint:disable-line:no-any assert(normalizedBranding.placeholdertop.elements.length === 1, 'Legacy-Branding-Elemente werden nicht übernommen.'); assert(normalizedBranding.future === 7, 'Unbekannte Branding-Properties gehen verloren.'); diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 302efcd..472660d 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -20,8 +20,8 @@ var sourceFiles = [ 'src/ui/PortalSettingsApp.ts' ].map(read).join('\n'); -assert(packageJson.version === '3.0.0', 'Package-Version ist nicht 3.0.0.'); -assert(solution.version === '3.0.0.0', 'Solution-Version ist nicht 3.0.0.0.'); +assert(packageJson.version === '3.0.1', 'Package-Version ist nicht 3.0.1.'); +assert(solution.version === '3.0.1.0', 'Solution-Version ist nicht 3.0.1.0.'); assert(!solution.features, 'Alte ASPX-Feature-Assets sind noch registriert.'); assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.'); assert(manifest.componentType === 'WebPart', 'Komponente ist kein WebPart.');