Aktualisiere Versionsnummer auf 3.0.1 in README.md, package.json, package-lock.json und package-solution.json. Füge Unterstützung für zentrale Aktivierung von CustomBranding hinzu und verbessere die Logik zur Handhabung von UserCustomActions. Aktualisiere Tests zur Validierung der neuen Struktur.

This commit is contained in:
Torsten Brendgen
2026-08-18 14:16:42 +02:00
parent 10f7463094
commit 37382d1dc0
11 changed files with 138 additions and 28 deletions
+4 -2
View File
@@ -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.
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "portal-settings",
"version": "3.0.0",
"version": "3.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "portal-settings",
"version": "3.0.0",
"version": "3.0.1",
"private": true,
"main": "lib/index.js",
"engines": {
+10
View File
@@ -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<TConfig> {
@@ -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;
}
@@ -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<ICustomBrandingSe
schemaVersion: 2,
minimumPortalSettingsVersion: '3.0.0',
componentIds: ['035ba968-6488-4d42-86b3-0470ffcc95b9'],
storage: { kind: 'siteUserCustomAction' },
storage: {
kind: 'siteUserCustomAction',
action: {
name: 'CustomBranding',
title: 'Custom Branding Application Customizer',
description: 'MSFT-Custom-Solution:CustomBranding',
location: 'ClientSideExtension.ApplicationCustomizer',
sequence: 100
}
},
sections: [
{
key: 'general', title: 'Allgemein', layout: 'twoColumns', iconName: 'Settings',
@@ -70,6 +84,11 @@ export const CustomBrandingSettingsProvider: ISettingsProvider<ICustomBrandingSe
],
createDefault: (): ICustomBrandingSettings => ({
schemaVersion: 2,
portalSettings: {
providerKey: 'custombranding',
contractVersion: 1,
minimumPortalSettingsVersion: '3.0.0'
},
enabled: true,
debug: false,
allowedCssHosts: [],
@@ -82,6 +101,11 @@ export const CustomBrandingSettingsProvider: ISettingsProvider<ICustomBrandingSe
const result: any = {}; // tslint:disable-line:no-any
Object.keys(source).forEach((key: string): void => { 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 : [];
+57 -9
View File
@@ -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<IUserCustomActionInfo> = 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<void> => this.ensureOk(response, 'Konfiguration konnte nicht gespeichert werden.'));
}
private createUserCustomAction(provider: ISettingsProvider<any>, config: any): Promise<IUserCustomActionInfo> {
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<any> => 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<void> {
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<any>, 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<any> {
+31 -10
View File
@@ -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, {
+3
View File
@@ -28,6 +28,9 @@ assert(normalizedMega.menuMode === 'megaMenu', 'MegaMenu Modus-Fallback fehlt.')
assert(normalizedMega.unknownFuture === 'keep', 'Unbekannte MegaMenu-Properties gehen verloren.');
const branding: ISettingsProvider<any> = 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.');
+2 -2
View File
@@ -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.');