// 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; } }