Add Portal Settings Web Part with styles, localization, and tests

- Implemented the PortalSettings Web Part with a new manifest and TypeScript file.
- Created SCSS styles for the Web Part, defining various UI elements and responsive design.
- Added localization support for English and German languages.
- Developed tests for Provider Registry and static assets validation.
- Included PowerShell script for project validation.
This commit is contained in:
Torsten Brendgen
2026-07-21 22:43:35 +02:00
parent 62fef65c12
commit 10f7463094
49 changed files with 2411 additions and 462 deletions

344
src/ui/PortalSettingsApp.ts Normal file
View File

@@ -0,0 +1,344 @@
// tslint:disable:no-any max-line-length
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ILoadedSettingsProvider, ISettingsElement, ITermSetInfo } from '../core/ProviderContracts';
import { setPathValue } from '../core/ObjectPath';
import { getSettingsProviders } from '../providers/ProviderRegistry';
import { PortalSettingsDataService } from '../services/PortalSettingsDataService';
import { TaxonomyService } from '../services/TaxonomyService';
import { IListOption, IFieldOption } from '../services/PortalSettingsDataService';
import { clearElement, createElement } from './Dom';
import { DynamicFormRenderer } from './DynamicFormRenderer';
export class PortalSettingsApp {
private readonly dataService: PortalSettingsDataService;
private readonly taxonomyService: TaxonomyService;
private readonly formRenderer: DynamicFormRenderer = new DynamicFormRenderer();
private host: HTMLElement;
private contentHost: HTMLElement;
private tabsHost: HTMLElement;
private statusHost: HTMLElement;
private saveButton: HTMLButtonElement;
private items: ILoadedSettingsProvider[] = [];
private activeKey: string = '';
private disposed: boolean = false;
public constructor(private context: WebPartContext, private configuredTitle?: string) {
this.dataService = new PortalSettingsDataService(context);
this.taxonomyService = new TaxonomyService(context);
}
public render(host: HTMLElement): void {
this.host = host;
this.disposed = false;
clearElement(host);
host.className += ' ps3-root';
if (!this.isSiteCollectionAdmin()) {
const denied: HTMLElement = createElement('div', 'ps3-accessDenied');
denied.appendChild(createElement('h2', '', 'Zugriff nicht möglich'));
denied.appendChild(createElement('p', '', 'PortalSettings kann nur von Site-Collection-Administratoren verwendet werden.'));
host.appendChild(denied);
return;
}
this.buildShell();
this.load();
}
public dispose(): void {
this.disposed = true;
if (this.host) { clearElement(this.host); }
this.items = [];
}
private buildShell(): void {
const shell: HTMLElement = createElement('div', 'ps3-shell');
this.statusHost = createElement('div', 'ps3-statusHost');
this.statusHost.setAttribute('aria-live', 'polite');
shell.appendChild(this.statusHost);
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.'));
const meta: HTMLElement = createElement('div', 'ps3-heroMeta');
meta.appendChild(this.metric('Site Collection', this.dataService.getSiteUrl()));
meta.appendChild(this.metric('Konfigurationsmodell', 'Providerbasiert'));
hero.appendChild(meta); shell.appendChild(hero);
this.tabsHost = createElement('nav', 'ps3-tabs');
this.tabsHost.setAttribute('role', 'tablist'); this.tabsHost.setAttribute('aria-label', 'Erweiterungen');
shell.appendChild(this.tabsHost);
this.contentHost = createElement('main', 'ps3-content'); shell.appendChild(this.contentHost);
this.host.appendChild(shell);
}
private load(preferredKey?: string): void {
this.showStatus('Installierte Einstellungsanbieter werden geladen …', 'info', true);
this.dataService.load(getSettingsProviders()).then((items: ILoadedSettingsProvider[]): void => {
if (this.disposed) { return; }
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');
}).catch((error: Error): void => {
if (!this.disposed) { this.showStatus(error.message || String(error), 'danger', true); this.renderEmpty(true); }
});
}
private renderTabs(): void {
clearElement(this.tabsHost);
for (let i: number = 0; i < this.items.length; i++) {
const item: ILoadedSettingsProvider = this.items[i];
const button: HTMLButtonElement = createElement('button', 'ps3-tab', item.provider.displayName) as HTMLButtonElement;
button.type = 'button'; button.setAttribute('role', 'tab');
button.setAttribute('aria-selected', String(item.provider.key === this.activeKey));
if (item.provider.key === this.activeKey) { button.className += ' is-active'; }
if (item.dirty) { button.appendChild(createElement('span', 'ps3-dirtyDot', '●')); }
button.addEventListener('click', (): void => {
if (this.activeKey !== item.provider.key && this.currentItem() && this.currentItem().dirty &&
!window.confirm('Nicht gespeicherte Änderungen verwerfen und den Tab wechseln?')) { return; }
this.activeKey = item.provider.key; this.renderTabs(); this.renderActive();
});
this.tabsHost.appendChild(button);
}
}
private renderActive(): void {
clearElement(this.contentHost);
const item: ILoadedSettingsProvider = this.currentItem();
if (!item) { this.renderEmpty(false); return; }
const header: HTMLElement = createElement('div', 'ps3-providerHeader');
const heading: HTMLElement = createElement('div');
heading.appendChild(createElement('span', 'ps3-providerVersion', 'Schema ' + item.provider.schemaVersion));
heading.appendChild(createElement('h2', 'ps3-providerTitle', item.provider.displayName));
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');
header.appendChild(storage); this.contentHost.appendChild(header);
const form: HTMLElement = createElement('div', 'ps3-form'); this.contentHost.appendChild(form);
this.formRenderer.render(form, item, {
changed: (): void => { this.updateSaveState(); this.renderTabs(); },
selectTermSet: (element: ISettingsElement): void => this.openTermSetPicker(item, element),
addBinding: (): void => this.openExpiryBindingDialog(item),
editBinding: (binding: any): void => this.openExpiryBindingEditor(item, binding)
});
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.type = 'button'; this.saveButton.addEventListener('click', (): void => this.saveCurrent());
actions.appendChild(reset); actions.appendChild(this.saveButton); this.contentHost.appendChild(actions);
this.updateSaveState();
}
private renderEmpty(failed: boolean): void {
clearElement(this.contentHost);
const empty: HTMLElement = createElement('section', 'ps3-empty');
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.'));
this.contentHost.appendChild(empty);
}
private saveCurrent(): void {
const item: ILoadedSettingsProvider = this.currentItem();
if (!item || !item.dirty) { return; }
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; }
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();
}).catch((error: Error): void => {
this.showStatus(error.message || String(error), 'danger', true); this.updateSaveState();
});
}
private resetCurrent(): void {
const item: ILoadedSettingsProvider = this.currentItem();
if (!item || !item.dirty || !window.confirm('Alle nicht gespeicherten Änderungen verwerfen?')) { return; }
item.config = item.provider.normalize(item.originalConfig); item.dirty = false; this.renderTabs(); this.renderActive();
}
private openTermSetPicker(item: ILoadedSettingsProvider, definition: ISettingsElement): void {
const backdrop: HTMLElement = createElement('div', 'ps3-modalBackdrop');
const dialog: HTMLElement = createElement('section', 'ps3-modal');
dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'ps3-termset-title');
dialog.appendChild(createElement('h2', '', 'Termset auswählen')).id = 'ps3-termset-title';
const search: HTMLInputElement = createElement('input', 'ps3-input') as HTMLInputElement;
search.type = 'search'; search.placeholder = 'Gruppe, Termset oder GUID durchsuchen'; search.setAttribute('aria-label', 'Termsets durchsuchen');
dialog.appendChild(search);
const results: HTMLElement = createElement('div', 'ps3-pickerResults'); results.appendChild(createElement('p', 'ps3-emptyInline', 'Termsets werden geladen …'));
dialog.appendChild(results);
const close: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Abbrechen') as HTMLButtonElement;
close.type = 'button'; close.addEventListener('click', (): void => { document.body.removeChild(backdrop); }); dialog.appendChild(close);
backdrop.appendChild(dialog); document.body.appendChild(backdrop); search.focus();
this.taxonomyService.getTermSets().then((termSets: ITermSetInfo[]): void => {
const renderResults: () => void = (): void => {
clearElement(results); const filter: string = search.value.trim().toLowerCase(); let count: number = 0;
for (let i: number = 0; i < termSets.length; i++) {
const termSet: ITermSetInfo = termSets[i];
if (filter && (termSet.groupName + ' ' + termSet.name + ' ' + termSet.id + ' ' + termSet.description).toLowerCase().indexOf(filter) < 0) { continue; }
const row: HTMLElement = createElement('article', 'ps3-pickerItem');
const text: HTMLElement = createElement('div'); text.appendChild(createElement('strong', '', termSet.name));
text.appendChild(createElement('span', 'ps3-mono', termSet.groupName + ' · ' + termSet.id));
const select: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Auswählen') as HTMLButtonElement;
select.type = 'button'; select.addEventListener('click', (): void => {
setPathValue(item.config, definition.path || '', termSet.id);
setPathValue(item.config, definition.companionNamePath || '', termSet.name);
item.dirty = true; document.body.removeChild(backdrop); this.renderTabs(); this.renderActive();
});
row.appendChild(text); row.appendChild(select); results.appendChild(row); count++;
}
if (!count) { results.appendChild(createElement('p', 'ps3-emptyInline', 'Keine passenden Termsets gefunden.')); }
};
search.addEventListener('input', renderResults); renderResults();
}).catch((error: Error): void => { clearElement(results); results.appendChild(createElement('p', 'ps3-errorText', error.message || String(error))); });
}
private updateSaveState(): void {
const item: ILoadedSettingsProvider = this.currentItem();
if (this.saveButton) { this.saveButton.disabled = !item || !item.dirty; }
}
private openExpiryBindingDialog(item: ILoadedSettingsProvider): void {
const backdrop: HTMLElement = createElement('div', 'ps3-modalBackdrop');
const dialog: HTMLElement = createElement('section', 'ps3-modal');
dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
dialog.appendChild(createElement('h2', '', 'ExpiryIndicator-Bindung hinzufügen'));
dialog.appendChild(createElement('p', 'ps3-providerDescription', 'Wählen Sie ein Web, eine Liste oder Bibliothek und ein vorhandenes Datumsfeld aus. Die Solution legt keine Spalte an.'));
const webLabel: HTMLLabelElement = createElement('label', 'ps3-label', 'Web-URL') as HTMLLabelElement;
const webInput: HTMLInputElement = createElement('input', 'ps3-input') as HTMLInputElement;
webInput.value = this.context.pageContext.web.absoluteUrl; webLabel.htmlFor = 'ps3-bind-web'; webInput.id = 'ps3-bind-web';
const loadLists: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Listen laden') as HTMLButtonElement;
loadLists.type = 'button';
const listLabel: HTMLLabelElement = createElement('label', 'ps3-label', 'Liste oder Bibliothek') as HTMLLabelElement;
const listSelect: HTMLSelectElement = createElement('select', 'ps3-input') as HTMLSelectElement; listSelect.id = 'ps3-bind-list'; listLabel.htmlFor = listSelect.id;
const fieldLabel: HTMLLabelElement = createElement('label', 'ps3-label', 'Ablaufdatumsfeld') as HTMLLabelElement;
const fieldSelect: HTMLSelectElement = createElement('select', 'ps3-input') as HTMLSelectElement; fieldSelect.id = 'ps3-bind-field'; fieldLabel.htmlFor = fieldSelect.id;
const status: HTMLElement = createElement('div', 'ps3-dialogStatus'); status.setAttribute('aria-live', 'polite');
const actions: HTMLElement = createElement('div', 'ps3-dialogActions');
const cancel: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Abbrechen') as HTMLButtonElement;
const bind: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-primary', 'Feld anbinden') as HTMLButtonElement;
cancel.type = 'button'; bind.type = 'button'; bind.disabled = true;
actions.appendChild(cancel); actions.appendChild(bind);
dialog.appendChild(webLabel); const webRow: HTMLElement = createElement('div', 'ps3-controlRow'); webRow.appendChild(webInput); webRow.appendChild(loadLists); dialog.appendChild(webRow);
dialog.appendChild(listLabel); dialog.appendChild(listSelect); dialog.appendChild(fieldLabel); dialog.appendChild(fieldSelect); dialog.appendChild(status); dialog.appendChild(actions);
backdrop.appendChild(dialog); document.body.appendChild(backdrop);
const closeDialog: () => void = (): void => { if (backdrop.parentNode) { backdrop.parentNode.removeChild(backdrop); } };
cancel.addEventListener('click', closeDialog);
const setOptions: (select: HTMLSelectElement, options: Array<{ id: string; title: string }>) => void =
(select: HTMLSelectElement, options: Array<{ id: string; title: string }>): void => {
clearElement(select); const empty: HTMLOptionElement = createElement('option') as HTMLOptionElement; empty.value = ''; empty.textContent = 'Bitte auswählen'; select.appendChild(empty);
options.forEach((option: { id: string; title: string }): void => {
const element: HTMLOptionElement = createElement('option') as HTMLOptionElement; element.value = option.id; element.textContent = option.title; select.appendChild(element);
});
};
const loadWebLists: () => void = (): void => {
status.textContent = 'Listen werden geladen …'; bind.disabled = true; setOptions(listSelect, []); setOptions(fieldSelect, []);
this.dataService.getLists(webInput.value).then((lists: IListOption[]): void => {
setOptions(listSelect, lists.map((list: IListOption): { id: string; title: string } => ({ id: list.id, title: list.title })));
status.textContent = lists.length ? '' : 'Keine Listen oder Bibliotheken gefunden.';
}).catch((error: Error): void => { status.textContent = error.message || String(error); });
};
loadLists.addEventListener('click', loadWebLists);
listSelect.addEventListener('change', (): void => {
bind.disabled = true; setOptions(fieldSelect, []); if (!listSelect.value) { return; }
status.textContent = 'Datumsfelder werden geladen …';
this.dataService.getDateFields(webInput.value, listSelect.value).then((fields: IFieldOption[]): void => {
setOptions(fieldSelect, fields.map((field: IFieldOption): { id: string; title: string } => ({ id: field.id, title: field.title + ' (' + field.internalName + ')' })));
(fieldSelect as any)._ps3Fields = fields; status.textContent = fields.length ? '' : 'Keine verwendbaren Datumsfelder gefunden.';
}).catch((error: Error): void => { status.textContent = error.message || String(error); });
});
fieldSelect.addEventListener('change', (): void => { bind.disabled = !fieldSelect.value; });
bind.addEventListener('click', (): void => {
const lists: HTMLOptionElement = listSelect.options[listSelect.selectedIndex];
const fields: IFieldOption[] = (fieldSelect as any)._ps3Fields || [];
let selectedField: IFieldOption;
for (let i: number = 0; i < fields.length; i++) { if (fields[i].id === fieldSelect.value) { selectedField = fields[i]; break; } }
if (!selectedField) { return; }
bind.disabled = true; status.textContent = 'Feld wird angebunden …';
this.dataService.bindExpiryField(webInput.value, { id: listSelect.value, title: lists.text }, selectedField, item.context.bindings || [])
.then((bindings: any[]): void => {
item.context.bindings = bindings; closeDialog(); this.renderActive(); this.showStatus('ExpiryIndicator wurde erfolgreich an das Feld gebunden.', 'success');
}).catch((error: Error): void => { bind.disabled = false; status.textContent = error.message || String(error); });
});
loadWebLists();
}
private openExpiryBindingEditor(siteItem: ILoadedSettingsProvider, binding: any): void {
const backdrop: HTMLElement = createElement('div', 'ps3-modalBackdrop');
const dialog: HTMLElement = createElement('section', 'ps3-modal ps3-modal-large');
dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
dialog.appendChild(createElement('h2', '', 'Lokale ExpiryIndicator-Konfiguration'));
dialog.appendChild(createElement('p', 'ps3-providerDescription', (binding.listTitle || binding.listId) + ' · ' + (binding.fieldInternalName || '')));
const status: HTMLElement = createElement('div', 'ps3-dialogStatus'); status.textContent = 'Konfiguration wird geladen …'; status.setAttribute('aria-live', 'polite');
const form: HTMLElement = createElement('div', 'ps3-form'); const actions: HTMLElement = createElement('div', 'ps3-dialogActions');
dialog.appendChild(status); dialog.appendChild(form); dialog.appendChild(actions); backdrop.appendChild(dialog); document.body.appendChild(backdrop);
const closeDialog: () => void = (): void => { if (backdrop.parentNode) { backdrop.parentNode.removeChild(backdrop); } };
this.dataService.loadExpiryBinding(binding, siteItem.config).then((loaded: any): void => {
status.textContent = loaded.inherited ? 'Die Bindung erbt derzeit den Site-Collection-Standard.' : 'Die Bindung verwendet eine lokale Ausnahme.';
const provider: any = {}; Object.keys(siteItem.provider).forEach((key: string): void => { provider[key] = (siteItem.provider as any)[key]; });
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
};
const editor: DynamicFormRenderer = new DynamicFormRenderer();
editor.render(form, editItem, {
changed: (): void => { saveLocal.disabled = false; },
selectTermSet: (): void => undefined,
addBinding: (): void => undefined,
editBinding: (): void => undefined
});
const cancel: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Abbrechen') as HTMLButtonElement;
const inherit: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Site-Standard erben') as HTMLButtonElement;
const saveLocal: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-primary', 'Als lokale Ausnahme speichern') as HTMLButtonElement;
cancel.type = 'button'; inherit.type = 'button'; saveLocal.type = 'button'; saveLocal.disabled = !editItem.dirty;
cancel.addEventListener('click', closeDialog);
inherit.addEventListener('click', (): void => {
inherit.disabled = true; saveLocal.disabled = true; status.textContent = 'Vererbung wird aktiviert …';
this.dataService.saveExpiryBinding(binding, loaded.field, editItem.config, true).then((): void => {
closeDialog(); this.showStatus('Die Bindung erbt jetzt den Site-Collection-Standard.', 'success');
}).catch((error: Error): void => { inherit.disabled = false; saveLocal.disabled = false; status.textContent = error.message || String(error); });
});
saveLocal.addEventListener('click', (): void => {
const errors: string[] = provider.validate(editItem.config); if (errors.length) { status.textContent = errors.join(' '); return; }
inherit.disabled = true; saveLocal.disabled = true; status.textContent = 'Lokale Ausnahme wird gespeichert …';
this.dataService.saveExpiryBinding(binding, loaded.field, editItem.config, false).then((): void => {
closeDialog(); this.showStatus('Die lokale ExpiryIndicator-Ausnahme wurde gespeichert.', 'success');
}).catch((error: Error): void => { inherit.disabled = false; saveLocal.disabled = false; status.textContent = error.message || String(error); });
});
actions.appendChild(cancel); actions.appendChild(inherit); actions.appendChild(saveLocal);
}).catch((error: Error): void => {
status.textContent = error.message || String(error);
const close: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Schließen') as HTMLButtonElement;
close.type = 'button'; close.addEventListener('click', closeDialog); actions.appendChild(close);
});
}
private showStatus(message: string, appearance: string, persistent?: boolean): void {
if (!this.statusHost) { return; }
clearElement(this.statusHost);
const status: HTMLElement = createElement('div', 'ps3-status ps3-status-' + appearance, message);
status.setAttribute('role', appearance === 'danger' ? 'alert' : 'status'); this.statusHost.appendChild(status);
if (!persistent) { window.setTimeout((): void => { if (!this.disposed && this.statusHost.contains(status)) { clearElement(this.statusHost); } }, 5000); }
}
private metric(label: string, value: string): HTMLElement {
const metric: HTMLElement = createElement('div', 'ps3-heroMetric'); metric.appendChild(createElement('span', '', label)); metric.appendChild(createElement('strong', '', value)); return metric;
}
private currentItem(): ILoadedSettingsProvider { return this.findItem(this.activeKey); }
private findItem(key: string): ILoadedSettingsProvider {
for (let i: number = 0; i < this.items.length; i++) { if (this.items[i].provider.key === key) { return this.items[i]; } }
return undefined;
}
private isSiteCollectionAdmin(): boolean {
const legacy: any = this.context.pageContext.legacyPageContext;
return legacy && legacy.isSiteAdmin === true;
}
}