From 10f746309402ddc59cc838ad2d2177a7e3b81ba4 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Jul 2026 22:43:35 +0200 Subject: [PATCH] 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. --- README.md | 220 ++++++++--- ToDo.md | 80 ++++ config/config.json | 8 +- config/copy-assets.json | 4 - config/deploy-azure-storage.json | 7 - config/package-solution.json | 23 +- config/serve.json | 8 +- deployment/Add-PortalSettingsPage.ps1 | 55 +++ gulpfile.js | 16 - package-lock.json | 2 +- package.json | 17 +- sharepoint/assets/PortalSettings.aspx | 112 ------ sharepoint/assets/elements-v1.1.xml | 11 - sharepoint/assets/elements-v2.xml | 11 - sharepoint/assets/elements.xml | 10 - sharepoint/assets/upgrade-actions-v1.1.xml | 3 - sharepoint/assets/upgrade-actions-v2.xml | 3 - src/assets/PortalSettings.aspx | 117 ------ src/assets/elements-v1.1.xml | 11 - src/assets/elements-v2.xml | 11 - src/assets/elements.xml | 10 - src/assets/upgrade-actions-v1.1.xml | 3 - src/assets/upgrade-actions-v2.xml | 3 - src/core/ObjectPath.ts | 46 +++ src/core/ProviderContracts.ts | 108 ++++++ ...ettingsApplicationCustomizer.manifest.json | 10 - .../PortalSettingsApplicationCustomizer.ts | 20 - src/extensions/portalSettings/loc/en-us.js | 5 - .../portalSettings/loc/myStrings.d.ts | 9 - src/index.ts | 3 +- .../CustomBrandingSettingsProvider.ts | 148 ++++++++ .../ExpiryIndicatorSettingsProvider.ts | 169 +++++++++ src/providers/MegaMenuSettingsProvider.ts | 116 ++++++ src/providers/ProviderRegistry.ts | 21 ++ src/services/PortalSettingsDataService.ts | 351 ++++++++++++++++++ src/services/TaxonomyService.ts | 50 +++ src/ui/Dom.ts | 20 + src/ui/DynamicFormRenderer.ts | 314 ++++++++++++++++ src/ui/PortalSettingsApp.ts | 344 +++++++++++++++++ .../portalSettings/PortalSettings.scss | 171 +++++++++ .../PortalSettingsWebPart.manifest.json | 24 ++ .../portalSettings/PortalSettingsWebPart.ts | 38 ++ src/webparts/portalSettings/loc/de-de.js | 7 + src/webparts/portalSettings/loc/en-us.js | 7 + .../portalSettings/loc/myStrings.d.ts | 10 + tests/ProviderRegistry.test.ts | 55 +++ tests/tsconfig.json | 20 + tests/validate-project.ps1 | 26 ++ tests/validate-static-assets.js | 36 ++ 49 files changed, 2411 insertions(+), 462 deletions(-) create mode 100644 ToDo.md delete mode 100644 config/copy-assets.json delete mode 100644 config/deploy-azure-storage.json create mode 100644 deployment/Add-PortalSettingsPage.ps1 delete mode 100644 sharepoint/assets/PortalSettings.aspx delete mode 100644 sharepoint/assets/elements-v1.1.xml delete mode 100644 sharepoint/assets/elements-v2.xml delete mode 100644 sharepoint/assets/elements.xml delete mode 100644 sharepoint/assets/upgrade-actions-v1.1.xml delete mode 100644 sharepoint/assets/upgrade-actions-v2.xml delete mode 100644 src/assets/PortalSettings.aspx delete mode 100644 src/assets/elements-v1.1.xml delete mode 100644 src/assets/elements-v2.xml delete mode 100644 src/assets/elements.xml delete mode 100644 src/assets/upgrade-actions-v1.1.xml delete mode 100644 src/assets/upgrade-actions-v2.xml create mode 100644 src/core/ObjectPath.ts create mode 100644 src/core/ProviderContracts.ts delete mode 100644 src/extensions/portalSettings/PortalSettingsApplicationCustomizer.manifest.json delete mode 100644 src/extensions/portalSettings/PortalSettingsApplicationCustomizer.ts delete mode 100644 src/extensions/portalSettings/loc/en-us.js delete mode 100644 src/extensions/portalSettings/loc/myStrings.d.ts create mode 100644 src/providers/CustomBrandingSettingsProvider.ts create mode 100644 src/providers/ExpiryIndicatorSettingsProvider.ts create mode 100644 src/providers/MegaMenuSettingsProvider.ts create mode 100644 src/providers/ProviderRegistry.ts create mode 100644 src/services/PortalSettingsDataService.ts create mode 100644 src/services/TaxonomyService.ts create mode 100644 src/ui/Dom.ts create mode 100644 src/ui/DynamicFormRenderer.ts create mode 100644 src/ui/PortalSettingsApp.ts create mode 100644 src/webparts/portalSettings/PortalSettings.scss create mode 100644 src/webparts/portalSettings/PortalSettingsWebPart.manifest.json create mode 100644 src/webparts/portalSettings/PortalSettingsWebPart.ts create mode 100644 src/webparts/portalSettings/loc/de-de.js create mode 100644 src/webparts/portalSettings/loc/en-us.js create mode 100644 src/webparts/portalSettings/loc/myStrings.d.ts create mode 100644 tests/ProviderRegistry.test.ts create mode 100644 tests/tsconfig.json create mode 100644 tests/validate-project.ps1 create mode 100644 tests/validate-static-assets.js diff --git a/README.md b/README.md index 2c6d251..5ccb2ff 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,201 @@ -# PortalSettings +# PortalSettings V3 -PortalSettings wird einmal im Root Web einer Site Collection installiert. Die Seite erkennt registrierte -Einstellungsanbieter automatisch und zeigt nur Tabs fuer tatsaechlich vorhandene Solutions. +PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server Subscription Edition. Es wird einmal im Root Web einer Site Collection installiert und verwaltet die zentralen beziehungsweise lokalen Einstellungen erkannter Portal-Erweiterungen. -Aktuell verwaltet sie: +## Version und Voraussetzungen -- Custom Branding -- Mega Menu -- ExpiryIndicator +| Eigenschaft | Wert | +|---|---| +| Version | 3.0.0 | +| SharePoint Framework | 1.4.1 | +| Build-Node | 8.17.0 | +| npm | 6.x | +| Gulp | lokal 3.9.1 | +| Komponente | Client-Side WebPart | -Fuer Mega Menu wird das Termset ueber einen durchsuchbaren Picker aus dem Default Site Collection Term Store ausgewaehlt. PortalSettings speichert Termset-ID, Namen, Cache-Dauer und Debug-Modus einmal zentral im Property Bag des Root Webs. Beim Speichern werden identische Runtime-Bindungen rekursiv auf das Root Web und alle vorhandenen Unterwebs synchronisiert. Individuelle Stylesheets werden ausschliesslich ueber Custom Branding eingebunden. +Die frühere provisionierte `PortalSettings.aspx` und der leere Application Customizer wurden entfernt. Die Oberfläche wird vollständig vom WebPart erzeugt und verwendet kein `innerHTML`. -## ExpiryIndicator 2.1 +## Unterstützte Solutions -- Site-Collection-Standard im Property Bag des Root Webs, -- Inventar aller durch das Aktivierungsskript registrierten Bindungen in Root Web und Subwebs, -- Popup zum Anbinden vorhandener Datumsfelder ohne PowerShell, -- Vererbung des zentralen Standards durch alle Bindungen, -- weiterhin moegliche lokale Ausnahmen in `ClientSideComponentProperties` des gebundenen Feldes, -- keine zusaetzliche Konfigurationsliste, -- Unterstuetzung alter Einzelregeln und verschachtelter V2-Regeln mit `and`/`or`, -- Validierung und Formatierung der vollstaendigen JSON-Konfiguration vor dem Speichern. +### MegaMenu -Die Prioritaet lautet: lokale Feldausnahme, Subweb-Override, Site-Collection-Standard, eingebauter Standard. -PortalSettings bleibt fuer den Betrieb des ExpiryIndicators optional. +- Erkennung der zentralen `SPSite.UserCustomAction` +- Auswahl des Navigationstermsets aus dem Default Site Collection Term Store +- Mega-Menu- oder Flyout-Modus +- Cache-Dauer und Cache-Version +- Debug-Modus +- schemaerhaltendes Speichern unbekannter Properties -## Erkennung +### CustomBranding -- Custom Branding wird anhand seiner aktiven Application-Customizer-UserCustomAction erkannt. -- Mega Menu wird anhand seiner zentralen Root-Web-Konfiguration oder einer aktiven Runtime-Bindung erkannt. -- ExpiryIndicator registriert einen Solution-Descriptor und sein Bindungsinventar im Property Bag des Root Webs. +- zentrale `SPSite.UserCustomAction` +- Aktivierung und Debug-Modus +- CSS-Dateien inklusive `media` +- Allowlist externer CSS-Hosts +- Header- und Footer-Elemente +- Sicherheitsprüfung von Tags, Event-Attributen, URLs, Tiefe und Elementanzahl +- Kompatibilität mit dem Legacy-Root-Array `elements` -Custom Branding kann beim Speichern auf eine site-scoped UserCustomAction angehoben werden. Mega Menu verwendet wegen der On-Premises-Loader-Eigenheiten bewusst web-scoped Runtime-Bindungen. Die fachliche Konfiguration bleibt dennoch einmalig und zentral: PortalSettings speichert sie im Root Web und verteilt sie auf alle vorhandenen Webs. Fuer spaeter neu angelegte Unterwebs wird die Konfiguration einfach erneut gespeichert, wodurch die Synchronisierung idempotent nachgezogen wird. +### ExpiryIndicator -Neue eigene Solutions werden zentral im Provider-Register in `PortalSettings.aspx` hinterlegt. Ein Provider -definiert Kennung, Anzeigename, Component-ID, Panel und Erkennungsart. So bleibt PortalSettings die gemeinsame -Admin-Oberflaeche, ohne dass nicht installierte Solutions leere Tabs erzeugen. +- Site-Collection-Standard aus dem Root-Web-Property-Bag +- Übersicht registrierter Listen- und Bibliotheksbindungen +- Web-, Listen- und Datumsfeldauswahl ohne Eingabe technischer IDs +- Anbinden eines vorhandenen Datumsfeldes +- zentrale Laufzeiten, Farben und AND-/OR-Regeln +- lokale Ausnahmen pro Bindung +- Umschalten einer Bindung auf Vererbung +- Validierung von internen Feldnamen, Laufzeiten, Farbregeln und Bedingungsgruppen + +## Architektur + +```text +PortalSettingsWebPart +│ +├── ProviderRegistry +│ ├── MegaMenuSettingsProvider +│ ├── CustomBrandingSettingsProvider +│ └── ExpiryIndicatorSettingsProvider +│ +├── DynamicFormRenderer +│ ├── Sections und Überschriften +│ ├── Hinweise und Trennlinien +│ ├── Text, Zahl, Toggle und Choice Cards +│ ├── Termset-Picker +│ ├── JSON- und Listen-Editoren +│ ├── Bindungsinventar +│ └── Zusammenfassung und JSON-Vorschau +│ +└── Storage + ├── SPSite.UserCustomActions + ├── Root-Web-Property-Bag + └── Field.ClientSideComponentProperties +``` + +Provider beschreiben fachliche Felder und Layout deklarativ. Der Renderer kennt keine MegaMenu-, Branding- oder ExpiryIndicator-Sonderformulare. Neue Standardfelder benötigen deshalb keine Änderung am Renderer. + +Spezielle Infrastruktur wie Taxonomy oder Field-Customizer-Bindungen wird über geprüfte Services angebunden. Provider dürfen kein freies HTML, JavaScript oder CSS liefern. + +## Layout-Elemente + +Ein Provider kann folgende Elementtypen verwenden: + +- `heading`, `text`, `notice`, `divider`, `group` +- `textField`, `number`, `boolean` +- `choice`, `choiceCards` +- `termSet`, `stringList`, `json` +- `bindingInventory` +- `propertySummary`, `jsonPreview` + +Sections unterstützen ein-, zwei- und dreispaltige Layouts, optionale Beschreibungstexte sowie einklappbare Expertenbereiche. Elemente können über `visibleWhen` abhängig von anderen Konfigurationswerten erscheinen. + +Das Styling verwendet ausschließlich SharePoint-Themeslots wie `themePrimary`, `neutralPrimary`, `neutralLight` und `white`. Responsive Layout, Tastaturfokus, Forced Colors und reduzierte Bewegung sind berücksichtigt. + +## Provider-Vertrag + +Ein Provider implementiert: + +```ts +interface ISettingsProvider { + key: string; + displayName: string; + description: string; + schemaVersion: number; + minimumPortalSettingsVersion: string; + componentIds: string[]; + storage: IProviderStorageDefinition; + sections: ISettingsSection[]; + + createDefault(): TConfig; + normalize(value: any): TConfig; + validate(value: TConfig): string[]; +} +``` + +Neue Provider werden unter `src/providers` angelegt und einmal in `ProviderRegistry.ts` registriert. Ein Provider definiert selbst: + +- Erkennungs-IDs +- Speicherart +- Konfigurationsversion +- Sections und Layout +- Felder und Hilfetexte +- Normalisierung +- Validierung +- Standardwerte + +## Schutz vor Datenverlust + +PortalSettings normalisiert gelesene Werte für die Anzeige, überschreibt beim Speichern aber nicht blind das komplette Objekt. Bearbeitete Werte werden rekursiv in die ursprüngliche Konfiguration gemergt. Unbekannte zukünftige Properties bleiben dadurch erhalten. + +JSON-Editoren ändern das Modell nur, wenn der aktuelle Inhalt gültiges JSON ist. Unsichere CustomBranding-Strukturen und ungültige ExpiryIndicator-Regeln werden vor dem Speichern abgelehnt. + +## Berechtigungen + +Das WebPart zeigt die Verwaltungsoberfläche nur Site-Collection-Administratoren. Die eigentliche Sicherheitsgrenze bleiben die SharePoint-Berechtigungen der REST-Endpunkte. + +Für ExpiryIndicator-Bindungen werden zusätzlich Berechtigungen zum Verwalten der betreffenden Liste und Felder benötigt. ## Build -Die Solution verwendet die SPFx-1.4.1-Toolchain und wird reproduzierbar mit Node.js 8.17.0 gebaut: - ```powershell npm install +npm test npm run package ``` -Das Paket liegt danach unter `sharepoint/solution/portal-settings.sppkg`. +Das Paket entsteht unter: -## Installation und Upgrade +```text +sharepoint/solution/portal-settings.sppkg +``` -1. `portal-settings.sppkg` im App Catalog durch Version `2.2.0.0` ersetzen. -2. PortalSettings im Root Web der Site Collection installieren beziehungsweise aktualisieren. -3. `expiry-indicator.sppkg` durch Version `2.1.0.0` ersetzen und die App aktualisieren. -4. `/SitePages/PortalSettings.aspx` mit geleertem Browsercache laden. -5. **ExpiryIndicator anbinden** auswaehlen und Web-URL, Listentitel und internen Namen des vorhandenen - Datumsfeldes eingeben. Optional werden Classic ScriptLink und Ribbon-Befehl mitregistriert. +`npm run package` führt die Provider- und statischen Sicherheitsprüfungen vor dem Ship-Build aus. -Das PowerShell-Skript bleibt fuer automatisierte Rollouts und Massenbereitstellungen erhalten, ist fuer eine -manuelle Bindung ueber PortalSettings aber nicht mehr erforderlich. +## Installation -Der ExpiryIndicator-Tab erscheint erst, wenn mindestens eine Bindung registriert ist, ein zentraler Standard -existiert oder der Solution-Descriptor im Root Web vorhanden ist. +1. `portal-settings.sppkg` im App Catalog durch Version `3.0.0.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. +5. Die Seite veröffentlichen und nur für die gewünschte Administratorgruppe verlinken. -Zum Anbinden werden mindestens die Berechtigungen **Listen verwalten** und zum Registrieren der optionalen -Classic-Webaktion **Web verwalten** benoetigt. Die ExpiryIndicator-App muss zuvor im Root Web installiert sein. +Optional kann eine Seite per PnP PowerShell erzeugt werden: + +```powershell +.\deployment\Add-PortalSettingsPage.ps1 ` + -SiteUrl 'http://clshp001/sites/target' ` + -PageName 'PortalSettingsV3' ` + -Publish +``` + +Die Standardseite heißt absichtlich `PortalSettingsV3.aspx`, damit die alte V2-Seite während der Abnahme erhalten bleiben kann. + +## Migration von V2 + +V3 liest die vorhandenen Konfigurationsorte direkt weiter: + +- MegaMenu und CustomBranding aus zentralen UserCustomActions +- ExpiryIndicator-Defaults, Descriptor und Bindungsinventar aus dem Root Web +- lokale ExpiryIndicator-Ausnahmen aus dem gebundenen Feld + +Es ist keine Datenmigration in eine neue Liste erforderlich. Empfohlenes Vorgehen: + +1. V3-Paket installieren. +2. `PortalSettingsV3.aspx` parallel zur alten Seite bereitstellen. +3. Alle drei Provider laden und Konfigurationen testweise speichern. +4. Root Web und Subwebs prüfen. +5. Erst danach die alte `PortalSettings.aspx` löschen oder archivieren. + +## Diagnose + +Wenn kein Tab erscheint, muss zunächst die zentrale Registrierung der jeweiligen Solution vorhanden sein. PortalSettings erkennt keine bloß im App Catalog hochgeladene, aber nicht für die Site Collection registrierte Runtime. + +Browserkonsole: + +```javascript +performance.getEntriesByType('resource') + .map(function (entry) { return entry.name; }) + .filter(function (url) { return url.toLowerCase().indexOf('portal-settings') >= 0; }); +``` + +Die sichtbare Statusmeldung enthält REST-Statuscode und SharePoint-Fehlertext, wenn Lesen oder Speichern fehlschlägt. diff --git a/ToDo.md b/ToDo.md new file mode 100644 index 0000000..1fe94fc --- /dev/null +++ b/ToDo.md @@ -0,0 +1,80 @@ +# PortalSettings V3 – Umsetzungsstand + +Stand: 21.07.2026 +Branch: `dev-3.0` +Zielversion: 3.0.0 + +## Architektur + +- [x] Branch `dev-3.0` angelegt. +- [x] Monolithische ASPX-Seite entfernt. +- [x] Leeren Application Customizer entfernt. +- [x] V3 als SPFx-WebPart aufgebaut. +- [x] Provider-Vertrag, Registry und versionierte Schemas eingeführt. +- [x] Provider-Erkennung von UI und Speicherlogik getrennt. +- [x] Keine dynamische Ausführung fremder Provider-Scripts. +- [x] Unbekannte Properties bleiben beim Speichern erhalten. + +## Dynamische Oberfläche + +- [x] Sections mit Titel, Beschreibung und Layout. +- [x] Überschriften, Text, Hinweise, Divider und Gruppen. +- [x] Text-, Zahlen-, Boolean- und Auswahlfelder. +- [x] Choice Cards für visuelle Modusauswahl. +- [x] String-Listen- und JSON-Editoren. +- [x] Termset-Picker. +- [x] Bindungsinventar. +- [x] Property-Zusammenfassung und einklappbare JSON-Vorschau. +- [x] Bedingte Sichtbarkeit über `visibleWhen`. +- [x] Responsive 12-Spalten-Darstellung. +- [x] SharePoint-Themeslots statt fester Portal-Farben. +- [x] Tastaturfokus, Forced Colors und Reduced Motion berücksichtigt. + +## Provider + +- [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] Branding-Sicherheitsprüfung vor dem Speichern. +- [x] ExpiryIndicator-Site-Collection-Standard abgebildet. +- [x] Expiry-Bindungsinventar dargestellt. +- [x] Web-, Listen- und Datumsfeld-Picker umgesetzt. +- [x] Neue Field-Customizer-Bindung aus dem WebPart möglich. +- [x] Lokale Expiry-Ausnahme bearbeiten. +- [x] Bindung auf Site-Standard-Vererbung stellen. +- [x] Legacy- und verschachtelte AND-/OR-Regeln validieren. + +## Build und Bereinigung + +- [x] Versionen auf 3.0.0 angehoben. +- [x] Node-Engine auf SPFx-1.4.1-Buildumgebung begrenzt. +- [x] Alte Feature-, Upgrade-, Copy- und Azure-Assets entfernt. +- [x] Altes Gulp-Copy-Task entfernt. +- [x] Serve-Konfiguration auf WebPart Workbench umgestellt. +- [x] Tests laufen vor der Paketierung. +- [x] PnP-Provisionierungsskript für eine moderne V3-Seite ergänzt. +- [x] README vollständig neu erstellt. + +## Automatisierte Prüfungen + +- [x] Provider-Registry und alle drei Provider geprüft. +- [x] Normalisierung und Erhalt unbekannter Properties geprüft. +- [x] Legacy-Branding-Migration geprüft. +- [x] Branding-XSS-Negativfälle geprüft. +- [x] Expiry-Regel-Negativfälle geprüft. +- [x] Objektpfade, Deep Clone und schemaerhaltendes Merge geprüft. +- [x] Paket-, Manifest- und Bundle-Struktur geprüft. +- [x] Verbot von `innerHTML` statisch geprüft. +- [x] SharePoint-Themeslots statisch geprüft. + +## Zielserver-Abnahme + +- [ ] Ship-Paket mit Node.js 8.17.0 erstellen. +- [ ] App im Root Web aktualisieren. +- [ ] Moderne `PortalSettingsV3.aspx` provisionieren. +- [ ] MegaMenu-Termsets laden und speichern. +- [ ] CustomBranding laden und schemaerhaltend speichern. +- [ ] ExpiryIndicator-Standard speichern. +- [ ] Expiry-Bindung hinzufügen, lokal konfigurieren und wieder auf Vererbung stellen. +- [ ] Bedienung mit Tastatur, 200-Prozent-Zoom und Windows-Hochkontrast prüfen. +- [ ] Alte V2-Seite nach erfolgreicher Parallelabnahme archivieren oder entfernen. diff --git a/config/config.json b/config/config.json index dc3656b..fb22ee7 100644 --- a/config/config.json +++ b/config/config.json @@ -2,17 +2,17 @@ "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json", "version": "2.0", "bundles": { - "portal-settings-application-customizer": { + "portal-settings-web-part": { "components": [ { - "entrypoint": "./lib/extensions/portalSettings/PortalSettingsApplicationCustomizer.js", - "manifest": "./src/extensions/portalSettings/PortalSettingsApplicationCustomizer.manifest.json" + "entrypoint": "./lib/webparts/portalSettings/PortalSettingsWebPart.js", + "manifest": "./src/webparts/portalSettings/PortalSettingsWebPart.manifest.json" } ] } }, "externals": {}, "localizedResources": { - "PortalSettingsApplicationCustomizerStrings": "lib/extensions/portalSettings/loc/{locale}.js" + "PortalSettingsWebPartStrings": "lib/webparts/portalSettings/loc/{locale}.js" } } diff --git a/config/copy-assets.json b/config/copy-assets.json deleted file mode 100644 index 3771fd0..0000000 --- a/config/copy-assets.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", - "deployCdnPath": "temp/deploy" -} diff --git a/config/deploy-azure-storage.json b/config/deploy-azure-storage.json deleted file mode 100644 index 43d465b..0000000 --- a/config/deploy-azure-storage.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json", - "workingDir": "./release/assets/", - "account": "[account]", - "container": "portal-settings", - "accessKey": "[access-key]" -} diff --git a/config/package-solution.json b/config/package-solution.json index b3c5676..b38ad91 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,28 +3,9 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "2.2.0.0", + "version": "3.0.0.0", "includeClientSideAssets": true, - "skipFeatureDeployment": false, - "features": [ - { - "title": "Portal Settings", - "description": "Provisioniert die zentrale Settings-Seite fuer aktive Portal-Erweiterungen.", - "id": "57bd9daf-48f9-49e2-aebd-11b15a0b5e93", - "version": "2.2.0.0", - "assets": { - "elementManifests": [ - "elements-v2.xml" - ], - "elementFiles": [ - "PortalSettings.aspx" - ], - "upgradeActions": [ - "upgrade-actions-v2.xml" - ] - } - } - ] + "skipFeatureDeployment": false }, "paths": { "zippedPackage": "solution/portal-settings.sppkg" diff --git a/config/serve.json b/config/serve.json index def82de..8d5838b 100644 --- a/config/serve.json +++ b/config/serve.json @@ -4,13 +4,7 @@ "https": true, "serveConfigurations": { "default": { - "pageUrl": "http://clshp001/SitePages/PortalSettings.aspx", - "customActions": { - "cd6b9e8d-07b9-4468-b230-6bb35acd9a5f": { - "location": "ClientSideExtension.ApplicationCustomizer", - "properties": {} - } - } + "pageUrl": "http://clshp001/_layouts/15/workbench.aspx" } } } diff --git a/deployment/Add-PortalSettingsPage.ps1 b/deployment/Add-PortalSettingsPage.ps1 new file mode 100644 index 0000000..19089d8 --- /dev/null +++ b/deployment/Add-PortalSettingsPage.ps1 @@ -0,0 +1,55 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SiteUrl, + + [string]$PageName = 'PortalSettingsV3', + + [switch]$Publish +) + +$ErrorActionPreference = 'Stop' +$componentId = 'c41d05ec-0682-4af0-b807-649806ed56b3' + +if (-not (Get-Command Connect-PnPOnline -ErrorAction SilentlyContinue)) { + throw 'PnP PowerShell wurde nicht gefunden. Bitte das zur SharePoint-SE-Umgebung passende PnP-Modul laden.' +} + +Connect-PnPOnline -Url $SiteUrl -CurrentCredentials + +$pageFileName = if ($PageName.EndsWith('.aspx', [StringComparison]::OrdinalIgnoreCase)) { $PageName } else { $PageName + '.aspx' } +$pageIdentity = $pageFileName.Substring(0, $pageFileName.Length - 5) +$page = Get-PnPClientSidePage -Identity $pageFileName -ErrorAction SilentlyContinue + +if (-not $page) { + Write-Host "Erstelle moderne Seite $pageFileName ..." -ForegroundColor Yellow + $page = Add-PnPClientSidePage -Name $pageIdentity -LayoutType Article +} +else { + Write-Host "Verwende vorhandene moderne Seite $pageFileName." -ForegroundColor Yellow +} + +$existingControl = @($page.Controls | Where-Object { + $_.WebPartId -and $_.WebPartId.ToString().Equals($componentId, [StringComparison]::OrdinalIgnoreCase) +}) | Select-Object -First 1 + +if (-not $existingControl) { + if (-not $page.Sections -or $page.Sections.Count -eq 0) { + Add-PnPClientSidePageSection -Page $page -SectionTemplate OneColumn -Order 1 | Out-Null + } + $component = Get-PnPAvailableClientSideComponents -Page $page | Where-Object { + $_.Id.ToString().Equals($componentId, [StringComparison]::OrdinalIgnoreCase) + } | Select-Object -First 1 + if (-not $component) { + throw 'Das Portal Settings V3 WebPart ist nicht verfügbar. Ist die App im Root Web installiert beziehungsweise aktualisiert?' + } + Add-PnPClientSideWebPart -Page $page -Component $component -Section 1 -Column 1 -Order 1 | Out-Null + Write-Host 'Portal Settings WebPart wurde hinzugefügt.' -ForegroundColor Green +} +else { + Write-Host 'Portal Settings WebPart ist bereits vorhanden.' -ForegroundColor Green +} + +$page.Save() +if ($Publish) { $page.Publish() } + +Write-Host ("Fertig: " + $SiteUrl.TrimEnd('/') + '/SitePages/' + $pageFileName) -ForegroundColor Green diff --git a/gulpfile.js b/gulpfile.js index a687795..9398505 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -2,20 +2,4 @@ const build = require('@microsoft/sp-build-web'); -build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`); - -// Custom Task: Kopiere Feature- und Settings-Assets in den Output-Ordner -const copyAssets = build.subTask('copy-settings-assets', function (gulp, buildOptions, done) { - return gulp.src([ - 'src/assets/*.aspx', - 'src/assets/*.html', - 'src/assets/*.css', - 'src/assets/*.xml' - ]) - .pipe(gulp.dest('sharepoint/assets')); -}); - -// Task vor dem Bundle ausfuehren -build.rig.addPreBuildTask(copyAssets); - build.initialize(require('gulp')); diff --git a/package-lock.json b/package-lock.json index 91bbf8e..fff88c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "2.2.0", + "version": "3.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 28809fc..d1f9e7a 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,23 @@ { "name": "portal-settings", - "version": "2.2.0", + "version": "3.0.0", "private": true, "main": "lib/index.js", "engines": { - "node": ">=0.10.0" + "node": ">=6.9.0 <9.0.0" }, "scripts": { "build": "gulp bundle", "clean": "gulp clean", - "package": "gulp clean && gulp bundle --ship && gulp package-solution --ship", - "test": "gulp test" + "package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship", + "test": "tsc --noEmit -p . && tsc -p tests/tsconfig.json && node temp/tests/tests/ProviderRegistry.test.js && node tests/validate-static-assets.js && powershell -NoProfile -ExecutionPolicy Bypass -File ./tests/validate-project.ps1" }, "dependencies": { - "@microsoft/sp-core-library": "~1.4.0", - "@microsoft/decorators": "~1.4.0", + "@microsoft/sp-http": "~1.4.1", + "@microsoft/sp-webpart-base": "~1.4.1", + "@microsoft/sp-office-ui-fabric-core": "~1.4.1", "@types/webpack-env": "1.13.1", - "@types/es6-promise": "0.0.33", - "@microsoft/sp-dialog": "~1.4.0", - "@microsoft/sp-application-base": "~1.4.0" + "@types/es6-promise": "0.0.33" }, "devDependencies": { "@microsoft/sp-build-web": "~1.4.1", diff --git a/sharepoint/assets/PortalSettings.aspx b/sharepoint/assets/PortalSettings.aspx deleted file mode 100644 index caf68b5..0000000 --- a/sharepoint/assets/PortalSettings.aspx +++ /dev/null @@ -1,112 +0,0 @@ -<%@ Page language="C#" MasterPageFile="~masterurl/default.master" Inherits="Microsoft.SharePoint.WebPartPages.WebPartPage, Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> -<%@ Register Tagprefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> -<%@ Register Tagprefix="Utilities" Namespace="Microsoft.SharePoint.Utilities" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> -<%@ Register Tagprefix="WebPartPages" Namespace="Microsoft.SharePoint.WebPartPages" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> - - - - - - -
Portal Settings

Zentrale Verwaltung fuer aktive Portal-Erweiterungen

Diese Seite erkennt installierte Einstellungsanbieter automatisch und zeigt nur deren Tabs. Konfigurationen werden in UserCustomActions, Feld-Properties oder im Property Bag des Root Webs gespeichert.

0
-
-
Keine
- - - - - -
-
diff --git a/sharepoint/assets/elements-v1.1.xml b/sharepoint/assets/elements-v1.1.xml deleted file mode 100644 index e026656..0000000 --- a/sharepoint/assets/elements-v1.1.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/sharepoint/assets/elements-v2.xml b/sharepoint/assets/elements-v2.xml deleted file mode 100644 index 013bdac..0000000 --- a/sharepoint/assets/elements-v2.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/sharepoint/assets/elements.xml b/sharepoint/assets/elements.xml deleted file mode 100644 index cf9a062..0000000 --- a/sharepoint/assets/elements.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - diff --git a/sharepoint/assets/upgrade-actions-v1.1.xml b/sharepoint/assets/upgrade-actions-v1.1.xml deleted file mode 100644 index bf40abd..0000000 --- a/sharepoint/assets/upgrade-actions-v1.1.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/sharepoint/assets/upgrade-actions-v2.xml b/sharepoint/assets/upgrade-actions-v2.xml deleted file mode 100644 index b757cdf..0000000 --- a/sharepoint/assets/upgrade-actions-v2.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/PortalSettings.aspx b/src/assets/PortalSettings.aspx deleted file mode 100644 index e2df4b1..0000000 --- a/src/assets/PortalSettings.aspx +++ /dev/null @@ -1,117 +0,0 @@ -<%@ Page language="C#" MasterPageFile="~masterurl/default.master" Inherits="Microsoft.SharePoint.WebPartPages.WebPartPage, Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> -<%@ Register Tagprefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> -<%@ Register Tagprefix="Utilities" Namespace="Microsoft.SharePoint.Utilities" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> -<%@ Register Tagprefix="WebPartPages" Namespace="Microsoft.SharePoint.WebPartPages" Assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> - - - - - - -
Portal Settings

Zentrale Verwaltung fuer aktive Portal-Erweiterungen

Diese Seite erkennt installierte Einstellungsanbieter automatisch und zeigt nur deren Tabs. Konfigurationen werden in UserCustomActions, Feld-Properties oder im Property Bag des Root Webs gespeichert.

0
-
-
Keine
- - - - - -
-
diff --git a/src/assets/elements-v1.1.xml b/src/assets/elements-v1.1.xml deleted file mode 100644 index e026656..0000000 --- a/src/assets/elements-v1.1.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/src/assets/elements-v2.xml b/src/assets/elements-v2.xml deleted file mode 100644 index 013bdac..0000000 --- a/src/assets/elements-v2.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/src/assets/elements.xml b/src/assets/elements.xml deleted file mode 100644 index ccbb59e..0000000 --- a/src/assets/elements.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - diff --git a/src/assets/upgrade-actions-v1.1.xml b/src/assets/upgrade-actions-v1.1.xml deleted file mode 100644 index bf40abd..0000000 --- a/src/assets/upgrade-actions-v1.1.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/assets/upgrade-actions-v2.xml b/src/assets/upgrade-actions-v2.xml deleted file mode 100644 index b757cdf..0000000 --- a/src/assets/upgrade-actions-v2.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/core/ObjectPath.ts b/src/core/ObjectPath.ts new file mode 100644 index 0000000..dc206a4 --- /dev/null +++ b/src/core/ObjectPath.ts @@ -0,0 +1,46 @@ +// tslint:disable:no-any +export function getPathValue(source: any, path: string): any { + if (!path) { return source; } + const parts: string[] = path.split('.'); + let current: any = source; + for (let i: number = 0; i < parts.length; i++) { + if (current === undefined || current === null) { return undefined; } + current = current[parts[i]]; + } + return current; +} + +export function setPathValue(target: any, path: string, value: any): void { + const parts: string[] = path.split('.'); + let current: any = target; + for (let i: number = 0; i < parts.length - 1; i++) { + if (!current[parts[i]] || typeof current[parts[i]] !== 'object') { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = value; +} + +export function cloneValue(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +export function mergePreservingUnknown(original: any, edited: any): any { + if (!isObject(original) || !isObject(edited)) { return cloneValue(edited); } + const result: any = cloneValue(original); + Object.keys(edited).forEach((key: string): void => { + result[key] = isObject(result[key]) && isObject(edited[key]) + ? mergePreservingUnknown(result[key], edited[key]) + : cloneValue(edited[key]); + }); + return result; +} + +export function isEmptyValue(value: any): boolean { + return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0); +} + +function isObject(value: any): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/core/ProviderContracts.ts b/src/core/ProviderContracts.ts new file mode 100644 index 0000000..260f5a2 --- /dev/null +++ b/src/core/ProviderContracts.ts @@ -0,0 +1,108 @@ +export type SettingsStorageKind = 'siteUserCustomAction' | 'rootPropertyBag'; +export type SettingsLayout = 'singleColumn' | 'twoColumns' | 'threeColumns'; +export type SettingsAppearance = 'default' | 'subtle' | 'emphasized' | 'info' | 'success' | 'warning' | 'danger'; +export type SettingsElementType = + 'heading' | 'text' | 'notice' | 'divider' | 'group' | 'propertySummary' | 'jsonPreview' | + 'textField' | 'number' | 'boolean' | 'choice' | 'choiceCards' | 'termSet' | 'stringList' | + 'json' | 'bindingInventory'; + +export interface ISettingsOption { + key: string; + text: string; + description?: string; + iconName?: string; +} + +export interface IVisibilityCondition { + field: string; + operator: 'equals' | 'notEquals' | 'isEmpty' | 'isNotEmpty' | 'contains' | 'greaterThan' | 'lessThan'; + value?: any; // tslint:disable-line:no-any +} + +export interface ISettingsElement { + type: SettingsElementType; + key?: string; + path?: string; + companionNamePath?: string; + text?: string; + title?: string; + label?: string; + description?: string; + placeholder?: string; + level?: 2 | 3 | 4; + appearance?: SettingsAppearance; + columnSpan?: 1 | 2 | 3 | 4 | 6 | 8 | 12; + required?: boolean; + readOnly?: boolean; + minimum?: number; + maximum?: number; + step?: number; + suffix?: string; + options?: ISettingsOption[]; + elements?: ISettingsElement[]; + visibleWhen?: IVisibilityCondition; + collapsedByDefault?: boolean; +} + +export interface ISettingsSection { + key: string; + title: string; + description?: string; + iconName?: string; + layout?: SettingsLayout; + appearance?: SettingsAppearance; + collapsible?: boolean; + collapsedByDefault?: boolean; + elements: ISettingsElement[]; +} + +export interface IProviderStorageDefinition { + kind: SettingsStorageKind; + propertyKey?: string; +} + +export interface ISettingsProvider { + key: string; + displayName: string; + description: string; + iconName?: string; + schemaVersion: number; + minimumPortalSettingsVersion: string; + componentIds: string[]; + storage: IProviderStorageDefinition; + sections: ISettingsSection[]; + createDefault(): TConfig; + normalize(value: any): TConfig; // tslint:disable-line:no-any + validate(value: TConfig): string[]; +} + +export interface IUserCustomActionInfo { + id: string; + title: string; + name: string; + location: string; + componentId: string; + properties: string; + entityType: string; +} + +export interface IProviderLoadContext { + action?: IUserCustomActionInfo; + propertyBag?: { [key: string]: any }; // tslint:disable-line:no-any + bindings?: any[]; // tslint:disable-line:no-any +} + +export interface ILoadedSettingsProvider { + provider: ISettingsProvider; // tslint:disable-line:no-any + config: any; // tslint:disable-line:no-any + originalConfig: any; // tslint:disable-line:no-any + context: IProviderLoadContext; + dirty: boolean; +} + +export interface ITermSetInfo { + id: string; + name: string; + groupName: string; + description: string; +} diff --git a/src/extensions/portalSettings/PortalSettingsApplicationCustomizer.manifest.json b/src/extensions/portalSettings/PortalSettingsApplicationCustomizer.manifest.json deleted file mode 100644 index 8c4ffcc..0000000 --- a/src/extensions/portalSettings/PortalSettingsApplicationCustomizer.manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/spfx/component-manifest.schema.json", - "id": "cd6b9e8d-07b9-4468-b230-6bb35acd9a5f", - "alias": "PortalSettingsApplicationCustomizer", - "componentType": "Extension", - "extensionType": "ApplicationCustomizer", - "version": "*", - "manifestVersion": 2, - "requiresCustomScript": false -} diff --git a/src/extensions/portalSettings/PortalSettingsApplicationCustomizer.ts b/src/extensions/portalSettings/PortalSettingsApplicationCustomizer.ts deleted file mode 100644 index 5eb065f..0000000 --- a/src/extensions/portalSettings/PortalSettingsApplicationCustomizer.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { override } from '@microsoft/decorators'; -import { Log } from '@microsoft/sp-core-library'; -import { BaseApplicationCustomizer } from '@microsoft/sp-application-base'; - -import * as strings from 'PortalSettingsApplicationCustomizerStrings'; - -const LOG_SOURCE: string = 'PortalSettingsApplicationCustomizer'; - -export interface IPortalSettingsApplicationCustomizerProperties { -} - -export default class PortalSettingsApplicationCustomizer - extends BaseApplicationCustomizer { - - @override - public onInit(): Promise { - Log.info(LOG_SOURCE, 'Initialized ' + strings.Title); - return Promise.resolve(); - } -} diff --git a/src/extensions/portalSettings/loc/en-us.js b/src/extensions/portalSettings/loc/en-us.js deleted file mode 100644 index 1bf0f9e..0000000 --- a/src/extensions/portalSettings/loc/en-us.js +++ /dev/null @@ -1,5 +0,0 @@ -define([], function() { - return { - "Title": "PortalSettingsApplicationCustomizer" - } -}); diff --git a/src/extensions/portalSettings/loc/myStrings.d.ts b/src/extensions/portalSettings/loc/myStrings.d.ts deleted file mode 100644 index ea6d223..0000000 --- a/src/extensions/portalSettings/loc/myStrings.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -declare interface IPortalSettingsApplicationCustomizerStrings { - Title: string; -} - -declare module 'PortalSettingsApplicationCustomizerStrings' { - const strings: IPortalSettingsApplicationCustomizerStrings; - // tslint:disable-next-line:export-name - export = strings; -} diff --git a/src/index.ts b/src/index.ts index fb81db1..d1c805d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -// A file is required to be in the root of the /src directory by the TypeScript compiler +export * from './core/ProviderContracts'; +export * from './providers/ProviderRegistry'; diff --git a/src/providers/CustomBrandingSettingsProvider.ts b/src/providers/CustomBrandingSettingsProvider.ts new file mode 100644 index 0000000..807e0ef --- /dev/null +++ b/src/providers/CustomBrandingSettingsProvider.ts @@ -0,0 +1,148 @@ +import { ISettingsProvider } from '../core/ProviderContracts'; + +export interface ICustomBrandingSettings { + schemaVersion: number; + enabled: boolean; + debug: boolean; + allowedCssHosts: string[]; + cssfiles: Array<{ path: string; media?: string }>; + placeholdertop: { elements: any[] }; // tslint:disable-line:no-any + placeholderbottom: { elements: any[] }; // tslint:disable-line:no-any + [key: string]: any; // tslint:disable-line:no-any +} + +export const CustomBrandingSettingsProvider: ISettingsProvider = { + key: 'custombranding', + displayName: 'Custom Branding', + description: 'Stylesheets und strukturierte Inhalte für Header und Footer.', + iconName: 'Color', + schemaVersion: 2, + minimumPortalSettingsVersion: '3.0.0', + componentIds: ['035ba968-6488-4d42-86b3-0470ffcc95b9'], + storage: { kind: 'siteUserCustomAction' }, + sections: [ + { + key: 'general', title: 'Allgemein', layout: 'twoColumns', iconName: 'Settings', + elements: [ + { + type: 'boolean', key: 'enabled', path: 'enabled', label: 'CustomBranding aktivieren', + description: 'Deaktiviert Rendering und Stylesheets, erhält aber die Konfiguration.', columnSpan: 6 + }, + { + type: 'boolean', key: 'debug', path: 'debug', label: 'Debug-Ausgaben aktivieren', + description: 'Protokolliert Validierungs- und Ladeinformationen.', columnSpan: 6 + } + ] + }, + { + key: 'stylesheets', title: 'Stylesheets', layout: 'twoColumns', iconName: 'FileCSS', + description: 'Lokale Stylesheets sind direkt erlaubt; externe Hosts benötigen eine Freigabe.', + elements: [ + { + type: 'json', key: 'cssfiles', path: 'cssfiles', label: 'CSS-Dateien', columnSpan: 8, + description: 'Array mit path und optional media.' + }, + { + type: 'stringList', key: 'allowedCssHosts', path: 'allowedCssHosts', label: 'Freigegebene CSS-Hosts', + description: 'Nur Hostnamen; externe Stylesheets müssen HTTPS verwenden.', columnSpan: 4 + }, + { + type: 'notice', appearance: 'warning', title: 'Sicherheitsgrenze', columnSpan: 12, + text: 'Freigaben gelten für die gesamte Site Collection. Nehmen Sie nur kontrollierte Hosts auf.' + } + ] + }, + { + key: 'content', title: 'Header und Footer', layout: 'twoColumns', iconName: 'WebAppBuilderFragment', + description: 'Die Elementstrukturen werden vor dem Speichern als JSON geprüft.', + elements: [ + { type: 'json', key: 'placeholdertop', path: 'placeholdertop.elements', label: 'Header-Elemente', columnSpan: 6 }, + { type: 'json', key: 'placeholderbottom', path: 'placeholderbottom.elements', label: 'Footer-Elemente', columnSpan: 6 } + ] + }, + { + key: 'preview', title: 'Zusammenfassung', layout: 'singleColumn', + elements: [ + { type: 'propertySummary', title: 'Aktuelle Werte', columnSpan: 12 }, + { type: 'jsonPreview', title: 'ClientSideComponentProperties', collapsedByDefault: true, columnSpan: 12 } + ] + } + ], + createDefault: (): ICustomBrandingSettings => ({ + schemaVersion: 2, + enabled: true, + debug: false, + allowedCssHosts: [], + cssfiles: [], + placeholdertop: { elements: [] }, + placeholderbottom: { elements: [] } + }), + normalize: (value: any): ICustomBrandingSettings => { // tslint:disable-line:no-any + const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any + const result: any = {}; // tslint:disable-line:no-any + Object.keys(source).forEach((key: string): void => { result[key] = source[key]; }); + result.schemaVersion = 2; + result.enabled = source.enabled !== false; + result.debug = source.debug === true; + result.allowedCssHosts = Array.isArray(source.allowedCssHosts) ? source.allowedCssHosts : []; + result.cssfiles = Array.isArray(source.cssfiles) ? source.cssfiles : []; + result.placeholdertop = source.placeholdertop && Array.isArray(source.placeholdertop.elements) + ? source.placeholdertop : { elements: Array.isArray(source.elements) ? source.elements : [] }; + result.placeholderbottom = source.placeholderbottom && Array.isArray(source.placeholderbottom.elements) + ? source.placeholderbottom : { elements: [] }; + delete result.elements; + return result as ICustomBrandingSettings; + }, + validate: (value: ICustomBrandingSettings): string[] => { + const errors: string[] = []; + if (!Array.isArray(value.cssfiles)) { errors.push('CSS-Dateien müssen ein Array sein.'); } + if (!Array.isArray(value.allowedCssHosts)) { errors.push('CSS-Hosts müssen ein Array sein.'); } + if (!value.placeholdertop || !Array.isArray(value.placeholdertop.elements)) { errors.push('Header-Elemente sind ungültig.'); } + if (!value.placeholderbottom || !Array.isArray(value.placeholderbottom.elements)) { errors.push('Footer-Elemente sind ungültig.'); } + const cssFiles: Array<{ path: string; media?: string }> = Array.isArray(value.cssfiles) ? value.cssfiles : []; + if (cssFiles.length > 20) { errors.push('Es sind maximal 20 Stylesheets erlaubt.'); } + for (let i: number = 0; i < cssFiles.length; i++) { + if (!cssFiles[i] || typeof cssFiles[i].path !== 'string' || !cssFiles[i].path.trim()) { + errors.push('CSS-Datei ' + (i + 1) + ' besitzt keinen gültigen Pfad.'); + } + } + if (value.placeholdertop && Array.isArray(value.placeholdertop.elements)) { + validateElements(value.placeholdertop.elements, 'Header', 1, { count: 0 }, errors); + } + if (value.placeholderbottom && Array.isArray(value.placeholderbottom.elements)) { + validateElements(value.placeholderbottom.elements, 'Footer', 1, { count: 0 }, errors); + } + return errors; + } +}; + +const AllowedTags: string[] = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section']; +function validateElements(elements: any[], path: string, depth: number, state: { count: number }, errors: string[]): void { // tslint:disable-line:no-any + if (depth > 8) { errors.push(path + ' überschreitet die maximale Tiefe von 8.'); return; } + if (!Array.isArray(elements)) { errors.push(path + ' muss ein Array sein.'); return; } + for (let i: number = 0; i < elements.length; i++) { + const element: any = elements[i]; // tslint:disable-line:no-any + state.count++; if (state.count > 200) { errors.push(path + ' enthält mehr als 200 Elemente.'); return; } + const tag: string = String(element && element.type || '').toLowerCase(); + if (!element || AllowedTags.indexOf(tag) < 0) { + errors.push(path + '[' + i + '] verwendet einen nicht erlaubten Elementtyp.'); continue; + } + if (tag === 'img' && (!element.attributes || typeof element.attributes.alt !== 'string')) { + errors.push(path + '[' + i + '] ist ein Bild ohne alt-Attribut.'); + } + if (element.attributes && typeof element.attributes === 'object') { + Object.keys(element.attributes).forEach((name: string): void => { + const lower: string = name.toLowerCase(); const attributeValue: string = String(element.attributes[name] || ''); + if (lower.indexOf('on') === 0) { errors.push(path + '[' + i + '] enthält ein Ereignisattribut.'); } + if (lower === 'href' || lower === 'src') { + const protocol: RegExpMatchArray = attributeValue.trim().match(/^([a-z][a-z0-9+.-]*):/i); + const allowed: string[] = lower === 'href' ? ['http', 'https', 'mailto'] : ['http', 'https']; + if (protocol && allowed.indexOf(protocol[1].toLowerCase()) < 0) { + errors.push(path + '[' + i + '] enthält eine unsichere URL.'); + } + } + }); + } + if (element.children) { validateElements(element.children, path + '[' + i + '].children', depth + 1, state, errors); } + } +} diff --git a/src/providers/ExpiryIndicatorSettingsProvider.ts b/src/providers/ExpiryIndicatorSettingsProvider.ts new file mode 100644 index 0000000..312df53 --- /dev/null +++ b/src/providers/ExpiryIndicatorSettingsProvider.ts @@ -0,0 +1,169 @@ +import { ISettingsProvider } from '../core/ProviderContracts'; + +export interface IExpiryIndicatorSettings { + baseField: string; + expiryField: string; + default: { + lifeTime: { value: number; unit: 'days' | 'months' | 'years' }; + columnRule: any[]; // tslint:disable-line:no-any + }; + rules: any[]; // tslint:disable-line:no-any + nullText: string; + confirmExtension: boolean; + [key: string]: any; // tslint:disable-line:no-any +} + +export const ExpiryIndicatorSettingsProvider: ISettingsProvider = { + key: 'expiryindicator', + displayName: 'ExpiryIndicator', + description: 'Zentrale Standards, Vererbung und Bindungen für Listen und Bibliotheken.', + iconName: 'DateTime2', + schemaVersion: 2, + minimumPortalSettingsVersion: '3.0.0', + componentIds: [ + 'a555f4fc-d6a6-4421-8189-457449d9bbde', + '95691218-bcb1-4fad-adda-02bb12014d15', + 'cd58f5d9-ffc8-4df7-a910-3054842d7abf' + ], + storage: { kind: 'rootPropertyBag', propertyKey: 'PortalSettings.ExpiryIndicator.Defaults' }, + sections: [ + { + key: 'scope', title: 'Gültigkeitsbereich', layout: 'singleColumn', iconName: 'Globe', + elements: [ + { + type: 'notice', appearance: 'info', title: 'Site-Collection-Standard', + text: 'Diese Werte gelten für alle vererbenden Bindungen im Root Web und in Unterwebs.', columnSpan: 12 + }, + { type: 'bindingInventory', key: 'bindings', title: 'Registrierte Bindungen', columnSpan: 12 } + ] + }, + { + key: 'dates', title: 'Datumsberechnung', layout: 'twoColumns', iconName: 'Calendar', + elements: [ + { + type: 'textField', key: 'baseField', path: 'baseField', label: 'Basis-Datumsfeld', + description: 'Interner Feldname, normalerweise Created.', required: true, columnSpan: 6 + }, + { + type: 'textField', key: 'expiryField', path: 'expiryField', label: 'Ablaufdatumsfeld', + description: 'Standardwert für neue Bindungen; vorhandene Bindungen behalten ihren Feldnamen.', required: true, columnSpan: 6 + }, + { + type: 'number', key: 'lifeTimeValue', path: 'default.lifeTime.value', label: 'Standardlaufzeit', + minimum: 1, maximum: 10000, columnSpan: 6 + }, + { + type: 'choice', key: 'lifeTimeUnit', path: 'default.lifeTime.unit', label: 'Einheit', columnSpan: 6, + options: [ + { key: 'days', text: 'Tage' }, { key: 'months', text: 'Monate' }, { key: 'years', text: 'Jahre' } + ] + }, + { + type: 'textField', key: 'nullText', path: 'nullText', label: 'Text ohne Ablaufdatum', columnSpan: 6 + }, + { + type: 'boolean', key: 'confirmExtension', path: 'confirmExtension', + label: 'Verlängerung bestätigen', description: 'Zeigt vor der Verlängerung eine Sicherheitsabfrage.', columnSpan: 6 + } + ] + }, + { + key: 'rules', title: 'Farben und fachliche Regeln', layout: 'twoColumns', iconName: 'BranchFork', + elements: [ + { + type: 'json', key: 'columnRule', path: 'default.columnRule', label: 'Standard-Farbregeln', + description: 'Die erste passende Schwelle gewinnt.', columnSpan: 6 + }, + { + type: 'json', key: 'rules', path: 'rules', label: 'Feldabhängige Regeln', + description: 'Unterstützt Legacy-Regeln und verschachtelte AND-/OR-Bedingungen.', columnSpan: 6 + } + ] + }, + { + key: 'preview', title: 'Zusammenfassung', layout: 'singleColumn', + elements: [ + { type: 'propertySummary', title: 'Aktuelle Werte', columnSpan: 12 }, + { type: 'jsonPreview', title: 'Zentraler Standard', collapsedByDefault: true, columnSpan: 12 } + ] + } + ], + createDefault: (): IExpiryIndicatorSettings => ({ + baseField: 'Created', + expiryField: 'ExpiryDate', + default: { + lifeTime: { value: 2, unit: 'years' }, + columnRule: [ + { daysUntilExpiry: 0, operator: 'lessOrEqual', backgroundColor: '#a4262c', textColor: '#ffffff', label: 'Abgelaufen' }, + { daysUntilExpiry: 30, operator: 'lessOrEqual', backgroundColor: '#ffaa44', textColor: '#000000', label: 'Läuft bald ab' }, + { daysUntilExpiry: 90, operator: 'lessOrEqual', backgroundColor: '#fff4ce', textColor: '#000000', label: 'Beobachten' } + ] + }, + rules: [], nullText: 'Kein Ablaufdatum', confirmExtension: true + }), + normalize: (value: any): IExpiryIndicatorSettings => { // tslint:disable-line:no-any + const defaults: IExpiryIndicatorSettings = ExpiryIndicatorSettingsProvider.createDefault(); + const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any + const result: any = {}; // tslint:disable-line:no-any + Object.keys(source).forEach((key: string): void => { result[key] = source[key]; }); + result.baseField = safeName(source.baseField) ? source.baseField : defaults.baseField; + result.expiryField = safeName(source.expiryField) ? source.expiryField : defaults.expiryField; + result.default = source.default && typeof source.default === 'object' ? source.default : defaults.default; + result.default.lifeTime = result.default.lifeTime || defaults.default.lifeTime; + result.default.columnRule = Array.isArray(result.default.columnRule) ? result.default.columnRule : defaults.default.columnRule; + result.rules = Array.isArray(source.rules) ? source.rules : []; + result.nullText = typeof source.nullText === 'string' ? source.nullText : defaults.nullText; + result.confirmExtension = source.confirmExtension !== false; + return result as IExpiryIndicatorSettings; + }, + validate: (value: IExpiryIndicatorSettings): string[] => { + const errors: string[] = []; + if (!safeName(value.baseField)) { errors.push('Das Basis-Datumsfeld ist ungültig.'); } + if (!safeName(value.expiryField)) { errors.push('Das Ablaufdatumsfeld ist ungültig.'); } + if (!value.default || !value.default.lifeTime || value.default.lifeTime.value < 1 || + ['days', 'months', 'years'].indexOf(value.default.lifeTime.unit) < 0) { errors.push('Die Standardlaufzeit ist ungültig.'); } + if (!value.default || !Array.isArray(value.default.columnRule)) { errors.push('Die Standard-Farbregeln sind ungültig.'); } + if (!Array.isArray(value.rules)) { errors.push('Die fachlichen Regeln sind ungültig.'); } + if (value.default) { validateColorRules(value.default.columnRule, 'Standard-Farbregeln', errors); } + for (let i: number = 0; i < value.rules.length; i++) { + const rule: any = value.rules[i]; // tslint:disable-line:no-any + if (!rule || (!rule.condition && (!safeName(rule.columnName) || rule.columnValue === undefined))) { + errors.push('Regel ' + (i + 1) + ' benötigt eine Bedingung oder columnName/columnValue.'); + } + if (rule && rule.condition) { validateCondition(rule.condition, 'Regel ' + (i + 1), 1, errors); } + if (!rule || !rule.lifeTime || Number(rule.lifeTime.value) < 1 || ['days', 'months', 'years'].indexOf(rule.lifeTime.unit) < 0) { + errors.push('Regel ' + (i + 1) + ' enthält eine ungültige Laufzeit.'); + } + if (rule) { validateColorRules(rule.columnRule, 'Regel ' + (i + 1), errors); } + } + return errors; + } +}; + +function safeName(value: string): boolean { + return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); +} + +function validateColorRules(rules: any[], path: string, errors: string[]): void { // tslint:disable-line:no-any + if (!Array.isArray(rules)) { return; } + const operators: string[] = ['lessThan', 'lessOrEqual', 'equal', 'greaterOrEqual', 'greaterThan']; + for (let i: number = 0; i < rules.length; i++) { + const rule: any = rules[i]; // tslint:disable-line:no-any + if (!rule || typeof rule.daysUntilExpiry !== 'number' || operators.indexOf(rule.operator) < 0 || + typeof rule.backgroundColor !== 'string' || typeof rule.textColor !== 'string') { + errors.push(path + '[' + i + '] ist ungültig.'); + } + } +} + +function validateCondition(condition: any, path: string, depth: number, errors: string[]): void { // tslint:disable-line:no-any + if (depth > 10) { errors.push(path + ' überschreitet die maximale Bedingungstiefe.'); return; } + if (!condition || (condition.operator !== 'and' && condition.operator !== 'or') || !Array.isArray(condition.conditions) || !condition.conditions.length) { + errors.push(path + ' enthält eine ungültige AND-/OR-Bedingungsgruppe.'); return; + } + for (let i: number = 0; i < condition.conditions.length; i++) { + const child: any = condition.conditions[i]; // tslint:disable-line:no-any + if (child && child.operator) { validateCondition(child, path + '.' + i, depth + 1, errors); } + else if (!child || !safeName(child.columnName) || child.columnValue === undefined) { errors.push(path + '.' + i + ' ist keine gültige Feldbedingung.'); } + } +} diff --git a/src/providers/MegaMenuSettingsProvider.ts b/src/providers/MegaMenuSettingsProvider.ts new file mode 100644 index 0000000..e4d1a6c --- /dev/null +++ b/src/providers/MegaMenuSettingsProvider.ts @@ -0,0 +1,116 @@ +import { ISettingsProvider } from '../core/ProviderContracts'; + +export interface IMegaMenuSettings { + termSetId: string; + termSetName: string; + cacheMinutes: number; + cacheVersion: string; + menuMode: 'megaMenu' | 'flyout'; + debug: boolean; + [key: string]: any; // tslint:disable-line:no-any +} + +export const MegaMenuSettingsProvider: ISettingsProvider = { + key: 'megamenu', + displayName: 'Mega Menu', + description: 'Zentrale Navigation für das Root Web und alle Unterwebs.', + iconName: 'GlobalNavButton', + schemaVersion: 1, + minimumPortalSettingsVersion: '3.0.0', + componentIds: [ + 'c0abbffb-355d-4d4e-bd38-9e15fb811506', + 'abc3361f-bb2d-491f-aba3-cd51c19a299b' + ], + storage: { kind: 'siteUserCustomAction' }, + sections: [ + { + key: 'navigation', + title: 'Navigation', + description: 'Navigationsquelle und Darstellung des Menüs.', + iconName: 'BranchFork', + layout: 'twoColumns', + elements: [ + { type: 'heading', level: 3, text: 'Navigationsquelle', columnSpan: 12 }, + { + type: 'termSet', key: 'termSetId', path: 'termSetId', companionNamePath: 'termSetName', + label: 'Navigations-Termset', description: 'Das Termset liefert die Navigationseinträge.', + required: true, columnSpan: 12 + }, + { type: 'divider', columnSpan: 12 }, + { type: 'heading', level: 3, text: 'Darstellung', columnSpan: 12 }, + { + type: 'choiceCards', key: 'menuMode', path: 'menuMode', label: 'Darstellungsmodus', columnSpan: 12, + options: [ + { key: 'megaMenu', text: 'Mega Menu', description: 'Breites, mehrspaltiges Navigationspanel.' }, + { key: 'flyout', text: 'Strukturelles Flyout', description: 'Kompaktes Menü mit seitlichen Untermenüs.' } + ] + }, + { + type: 'notice', appearance: 'info', title: 'Styling', columnSpan: 12, + text: 'Farben und individuelle Styles werden zentral über CustomBranding verwaltet.' + } + ] + }, + { + key: 'performance', title: 'Performance und Cache', iconName: 'SpeedHigh', layout: 'twoColumns', + description: 'Steuert die lokale Zwischenspeicherung der Navigation.', + elements: [ + { + type: 'number', key: 'cacheMinutes', path: 'cacheMinutes', label: 'Cache-Dauer', + description: 'Gültigkeitsdauer des Browser-Caches.', minimum: 1, maximum: 1440, suffix: 'Minuten', columnSpan: 6 + }, + { + type: 'textField', key: 'cacheVersion', path: 'cacheVersion', label: 'Cache-Version', + description: 'Eine Änderung verwirft vorhandene Browser-Caches.', placeholder: '1', columnSpan: 6 + } + ] + }, + { + key: 'diagnostics', title: 'Diagnose', iconName: 'Diagnostic', layout: 'singleColumn', + collapsible: true, collapsedByDefault: true, + elements: [ + { + type: 'boolean', key: 'debug', path: 'debug', label: 'Debug-Ausgaben aktivieren', + description: 'Schreibt technische Meldungen in die Browserkonsole.', columnSpan: 12 + }, + { + type: 'notice', appearance: 'warning', title: 'Nur vorübergehend aktivieren', + text: 'Deaktivieren Sie die Debug-Ausgaben nach Abschluss der Diagnose.', columnSpan: 12, + visibleWhen: { field: 'debug', operator: 'equals', value: true } + } + ] + }, + { + key: 'preview', title: 'Zusammenfassung', layout: 'singleColumn', + elements: [ + { type: 'propertySummary', title: 'Aktuelle Werte', columnSpan: 12 }, + { type: 'jsonPreview', title: 'ClientSideComponentProperties', collapsedByDefault: true, columnSpan: 12 } + ] + } + ], + createDefault: (): IMegaMenuSettings => ({ + termSetId: '', termSetName: '', cacheMinutes: 15, cacheVersion: '1', menuMode: 'megaMenu', debug: false + }), + normalize: (value: any): IMegaMenuSettings => { // tslint:disable-line:no-any + const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any + const minutes: number = Number(source.cacheMinutes); + const result: any = {}; // tslint:disable-line:no-any + Object.keys(source).forEach((key: string): void => { result[key] = source[key]; }); + result.termSetId = typeof source.termSetId === 'string' ? source.termSetId.trim() : ''; + result.termSetName = typeof source.termSetName === 'string' ? source.termSetName.trim() : ''; + result.cacheMinutes = isFinite(minutes) ? Math.min(Math.max(Math.floor(minutes), 1), 1440) : 15; + result.cacheVersion = typeof source.cacheVersion === 'string' && source.cacheVersion.trim() ? source.cacheVersion.trim() : '1'; + result.menuMode = source.menuMode === 'flyout' ? 'flyout' : 'megaMenu'; + result.debug = source.debug === true; + return result as IMegaMenuSettings; + }, + validate: (value: IMegaMenuSettings): string[] => { + const errors: string[] = []; + if (!value.termSetId && !value.termSetName) { errors.push('Bitte ein Navigations-Termset auswählen.'); } + if (value.termSetId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.termSetId)) { + errors.push('Die Termset-ID ist keine gültige GUID.'); + } + if (value.cacheMinutes < 1 || value.cacheMinutes > 1440) { errors.push('Die Cache-Dauer muss zwischen 1 und 1440 liegen.'); } + return errors; + } +}; diff --git a/src/providers/ProviderRegistry.ts b/src/providers/ProviderRegistry.ts new file mode 100644 index 0000000..666de7e --- /dev/null +++ b/src/providers/ProviderRegistry.ts @@ -0,0 +1,21 @@ +import { ISettingsProvider } from '../core/ProviderContracts'; +import { CustomBrandingSettingsProvider } from './CustomBrandingSettingsProvider'; +import { ExpiryIndicatorSettingsProvider } from './ExpiryIndicatorSettingsProvider'; +import { MegaMenuSettingsProvider } from './MegaMenuSettingsProvider'; + +const Providers: Array> = [ // tslint:disable-line:no-any + MegaMenuSettingsProvider, + CustomBrandingSettingsProvider, + ExpiryIndicatorSettingsProvider +]; + +export function getSettingsProviders(): Array> { // tslint:disable-line:no-any + return Providers.slice(); +} + +export function getSettingsProvider(key: string): ISettingsProvider | undefined { // tslint:disable-line:no-any + for (let i: number = 0; i < Providers.length; i++) { + if (Providers[i].key === key) { return Providers[i]; } + } + return undefined; +} diff --git a/src/services/PortalSettingsDataService.ts b/src/services/PortalSettingsDataService.ts new file mode 100644 index 0000000..0ad09fe --- /dev/null +++ b/src/services/PortalSettingsDataService.ts @@ -0,0 +1,351 @@ +// tslint:disable:no-any max-line-length +import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { + ILoadedSettingsProvider, + IProviderLoadContext, + ISettingsProvider, + IUserCustomActionInfo +} from '../core/ProviderContracts'; +import { cloneValue, mergePreservingUnknown } from '../core/ObjectPath'; + +const BindingsProperty: string = 'PortalSettings.ExpiryIndicator.Bindings'; +const DescriptorProperty: string = 'PortalSettings.Solutions.ExpiryIndicator'; +const ExpiryFieldComponentId: string = 'a555f4fc-d6a6-4421-8189-457449d9bbde'; + +export interface IListOption { id: string; title: string; } +export interface IFieldOption { + id: string; + title: string; + internalName: string; + componentId: string; + properties: string; + entityType: string; +} +export interface IExpiryBindingLoadResult { config: any; inherited: boolean; field: IFieldOption; } + +export class PortalSettingsDataService { + private readonly siteUrl: string; + + public constructor(private context: WebPartContext) { + this.siteUrl = context.pageContext.site.absoluteUrl.replace(/\/$/, ''); + } + + public load(providers: Array>): Promise { + return Promise.all([this.getSiteActions(), this.getRootProperties()]) + .then((values: any[]): ILoadedSettingsProvider[] => { + const actions: IUserCustomActionInfo[] = values[0]; + const properties: any = values[1]; + const loaded: ILoadedSettingsProvider[] = []; + for (let i: number = 0; i < providers.length; i++) { + const result: ILoadedSettingsProvider = this.loadProvider(providers[i], actions, properties); + if (result) { loaded.push(result); } + } + return loaded; + }); + } + + public save(item: ILoadedSettingsProvider): Promise { + const errors: string[] = item.provider.validate(item.config); + 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 => { + item.config = item.provider.normalize(merged); + item.originalConfig = cloneValue(merged); + item.dirty = false; + }); + } + const propertyKey: string = item.provider.storage.propertyKey || ''; + return this.saveRootProperty(propertyKey, JSON.stringify(merged)).then((): void => { + item.config = item.provider.normalize(merged); + item.originalConfig = cloneValue(merged); + item.dirty = false; + }); + } + + public getSiteUrl(): string { return this.siteUrl; } + + public getLists(webUrl: string): Promise { + let base: string; + try { base = this.normalizeScopedWebUrl(webUrl); } catch (error) { return Promise.reject(error); } + const url: string = base + '/_api/web/lists?$select=Id,Title,Hidden&$filter=Hidden eq false&$orderby=Title'; + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders() }) + .then((response: SPHttpClientResponse): Promise => this.ensureJson(response, 'Listen konnten nicht geladen werden.')) + .then((payload: any): IListOption[] => this.results(payload).map((source: any): IListOption => ({ + id: this.normalizeGuid(source.Id), title: source.Title || source.Id + }))); + } + + public getDateFields(webUrl: string, listId: string): Promise { + let base: string; + try { base = this.normalizeScopedWebUrl(webUrl); } catch (error) { return Promise.reject(error); } + const url: string = base + "/_api/web/lists(guid'" + this.normalizeGuid(listId) + "')/fields" + + '?$select=Id,Title,InternalName,TypeAsString,Hidden,ReadOnlyField,ClientSideComponentId,ClientSideComponentProperties'; + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders() }) + .then((response: SPHttpClientResponse): Promise => this.ensureJson(response, 'Felder konnten nicht geladen werden.')) + .then((payload: any): IFieldOption[] => this.results(payload) + .filter((source: any): boolean => source.TypeAsString === 'DateTime' && source.Hidden !== true && source.ReadOnlyField !== true) + .map((source: any): IFieldOption => ({ + id: this.normalizeGuid(source.Id), title: source.Title || source.InternalName, internalName: source.InternalName, + componentId: this.normalizeGuid(source.ClientSideComponentId), properties: source.ClientSideComponentProperties || '', + entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.Field' + }))); + } + + public bindExpiryField( + webUrl: string, + list: IListOption, + field: IFieldOption, + currentBindings: any[] + ): Promise { + if (field.componentId && field.componentId !== '00000000-0000-0000-0000-000000000000' && field.componentId !== ExpiryFieldComponentId) { + return Promise.reject(new Error('Das Feld ist bereits mit einem anderen Field Customizer verbunden.')); + } + let base: string; + try { base = this.normalizeScopedWebUrl(webUrl); } catch (error) { return Promise.reject(error); } + const fieldUrl: string = base + "/_api/web/lists(guid'" + this.normalizeGuid(list.id) + "')/fields(guid'" + field.id + "')"; + const properties: string = field.properties && field.properties.trim() + ? field.properties + : JSON.stringify({ schemaVersion: 2, expiryField: field.internalName, inheritSiteDefaults: true }); + const body: any = { + '__metadata': { 'type': field.entityType || 'SP.Field' }, + 'ClientSideComponentId': ExpiryFieldComponentId, + 'ClientSideComponentProperties': properties + }; + return this.context.spHttpClient.post(fieldUrl, SPHttpClient.configurations.v1, { + headers: this.writeHeaders('MERGE'), body: JSON.stringify(body) + }).then((response: SPHttpClientResponse): Promise => this.ensureOk(response, 'Das Feld konnte nicht angebunden werden.')) + .then((): Promise => { + const bindings: any[] = (currentBindings || []).filter((binding: any): boolean => + !(String(binding.webUrl || '').replace(/\/$/, '').toLowerCase() === base.toLowerCase() && this.normalizeGuid(binding.listId) === this.normalizeGuid(list.id))); + bindings.push({ webUrl: base, listId: this.normalizeGuid(list.id), listTitle: list.title, fieldInternalName: field.internalName, fieldId: field.id }); + const descriptor: any = { + key: 'ExpiryIndicator', displayName: 'Expiry Indicator', version: '2.1.2', provider: 'expiryIndicator', + componentIds: [ExpiryFieldComponentId, 'cd58f5d9-ffc8-4df7-a910-3054842d7abf', '95691218-bcb1-4fad-adda-02bb12014d15'], + settingsScope: ['siteCollection', 'web', 'list'] + }; + return this.saveRootProperty(DescriptorProperty, JSON.stringify(descriptor)) + .then((): Promise => this.saveRootProperty(BindingsProperty, JSON.stringify(bindings))) + .then((): any[] => bindings); + }); + } + + public loadExpiryBinding(binding: any, inheritedConfig: any): Promise { + let baseUrl: string; + try { baseUrl = this.expiryBindingFieldUrl(binding); } catch (error) { return Promise.reject(error); } + const url: string = baseUrl + '?$select=Id,Title,InternalName,ClientSideComponentId,ClientSideComponentProperties'; + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders() }) + .then((response: SPHttpClientResponse): Promise => this.ensureJson(response, 'Die lokale Bindung konnte nicht geladen werden.')) + .then((payload: any): IExpiryBindingLoadResult => { + const source: any = payload.d || payload; + const field: IFieldOption = { + id: this.normalizeGuid(source.Id), title: source.Title || source.InternalName, internalName: source.InternalName, + componentId: this.normalizeGuid(source.ClientSideComponentId), properties: source.ClientSideComponentProperties || '', + entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.Field' + }; + if (field.componentId !== ExpiryFieldComponentId) { throw new Error('Das Feld ist nicht mit dem ExpiryIndicator verbunden.'); } + const properties: any = this.parseJson(field.properties, {}); + let config: any; let inherited: boolean = false; + if (!field.properties || Object.keys(properties).length === 0 || properties.inheritSiteDefaults === true || this.isBindingOnly(properties)) { + config = cloneValue(inheritedConfig); inherited = true; + if (properties.override) { config = mergePreservingUnknown(config, properties.override); inherited = false; } + } else { config = properties; } + config.expiryField = field.internalName; + return { config: config, inherited: inherited, field: field }; + }); + } + + public saveExpiryBinding(binding: any, field: IFieldOption, config: any, inherit: boolean): Promise { + let fieldUrl: string; + try { fieldUrl = this.expiryBindingFieldUrl(binding); } catch (error) { return Promise.reject(error); } + const properties: any = inherit + ? { schemaVersion: 2, expiryField: field.internalName, inheritSiteDefaults: true } + : mergePreservingUnknown(config, { expiryField: field.internalName }); + const body: any = { + '__metadata': { 'type': field.entityType || 'SP.Field' }, + 'ClientSideComponentProperties': JSON.stringify(properties) + }; + return this.context.spHttpClient.post(fieldUrl, SPHttpClient.configurations.v1, { + headers: this.writeHeaders('MERGE'), body: JSON.stringify(body) + }).then((response: SPHttpClientResponse): Promise => this.ensureOk(response, 'Die lokale Konfiguration konnte nicht gespeichert werden.')); + } + + private loadProvider( + provider: ISettingsProvider, + actions: IUserCustomActionInfo[], + properties: any + ): ILoadedSettingsProvider | undefined { + let raw: any; + 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, {}); + } else { + const propertyKey: string = provider.storage.propertyKey || ''; + const stored: any = this.getProperty(properties, propertyKey); + const descriptor: any = this.getProperty(properties, DescriptorProperty); + const bindingsValue: any = this.getProperty(properties, BindingsProperty); + const registration: IUserCustomActionInfo = this.findAnyAction(provider, actions); + if (!stored && !descriptor && !bindingsValue && !registration) { return undefined; } + if (registration) { context.action = registration; } + raw = this.parseJson(stored, provider.createDefault()); + context.propertyBag = properties; + context.bindings = this.normalizeBindings(this.parseJson(bindingsValue, [])); + } + const normalized: any = provider.normalize(raw || provider.createDefault()); + return { + provider: provider, + config: cloneValue(normalized), + originalConfig: cloneValue(raw || normalized), + context: context, + dirty: false + }; + } + + private getSiteActions(): Promise { + const url: string = this.siteUrl + '/_api/site/UserCustomActions' + + '?$select=Id,Title,Name,Location,ClientSideComponentId,ClientSideComponentProperties'; + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders() }) + .then((response: SPHttpClientResponse): Promise => this.ensureJson(response, 'UserCustomActions konnten nicht gelesen werden.')) + .then((payload: any): IUserCustomActionInfo[] => this.results(payload).map((source: any): IUserCustomActionInfo => ({ + id: this.normalizeGuid(source.Id), + title: source.Title || '', + name: source.Name || '', + location: source.Location || '', + componentId: this.normalizeGuid(source.ClientSideComponentId), + properties: source.ClientSideComponentProperties || '{}', + entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.UserCustomAction' + }))); + } + + private getRootProperties(): Promise { + return this.context.spHttpClient.get(this.siteUrl + '/_api/web/AllProperties', SPHttpClient.configurations.v1, { + headers: this.readHeaders() + }).then((response: SPHttpClientResponse): Promise => this.ensureJson(response, 'Root-Web-Properties konnten nicht gelesen werden.')) + .then((payload: any): any => payload.d || payload); + } + + private saveUserCustomAction(action: IUserCustomActionInfo, config: any): Promise { + const url: string = this.siteUrl + "/_api/site/UserCustomActions(guid'" + action.id + "')"; + const body: any = { + '__metadata': { 'type': action.entityType || 'SP.UserCustomAction' }, + 'ClientSideComponentProperties': JSON.stringify(config) + }; + return this.context.spHttpClient.post(url, SPHttpClient.configurations.v1, { + headers: this.writeHeaders('MERGE'), body: JSON.stringify(body) + }).then((response: SPHttpClientResponse): Promise => this.ensureOk(response, 'Konfiguration konnte nicht gespeichert werden.')); + } + + 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' } }; + body[this.encodePropertyName(key)] = value; + return this.context.spHttpClient.post(this.siteUrl + '/_api/web/AllProperties', SPHttpClient.configurations.v1, { + headers: this.writeHeaders('MERGE'), body: JSON.stringify(body) + }).then((response: SPHttpClientResponse): Promise => this.ensureOk(response, 'Der Site-Collection-Standard konnte nicht gespeichert werden.')); + } + + 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 && + actions[i].location.indexOf('ClientSideExtension.ApplicationCustomizer') >= 0) { + return actions[i]; + } + } + return undefined; + } + + private findAnyAction(provider: ISettingsProvider, actions: IUserCustomActionInfo[]): IUserCustomActionInfo | undefined { + for (let i: number = 0; i < actions.length; i++) { + if (provider.componentIds.indexOf(actions[i].componentId) >= 0) { return actions[i]; } + } + return undefined; + } + + private getProperty(properties: any, key: string): any { + if (!properties) { return undefined; } + const normalizedExpected: string = this.normalizePropertyName(key); + const names: string[] = Object.keys(properties); + for (let i: number = 0; i < names.length; i++) { + if (this.normalizePropertyName(names[i]) === normalizedExpected) { return properties[names[i]]; } + } + return undefined; + } + + private parseJson(value: any, fallback: any): any { + if (typeof value !== 'string' || !value.trim()) { return cloneValue(fallback); } + try { return JSON.parse(value); } catch (error) { return cloneValue(fallback); } + } + + private normalizeBindings(value: any): any[] { + if (Array.isArray(value)) { return value; } + if (!value || typeof value !== 'object') { return []; } + const result: any[] = []; + Object.keys(value).forEach((key: string): void => { + if (Array.isArray(value[key])) { Array.prototype.push.apply(result, value[key]); } + else if (value[key] && typeof value[key] === 'object') { result.push(value[key]); } + }); + return result; + } + + private expiryBindingFieldUrl(binding: any): string { + const webUrl: string = this.normalizeScopedWebUrl(binding.webUrl); + const listId: string = this.normalizeGuid(binding.listId); + const fieldId: string = this.normalizeGuid(binding.fieldId); + if (!webUrl || !listId || !fieldId) { throw new Error('Die Bindung enthält keine vollständigen Web-, Listen- oder Feldinformationen.'); } + return webUrl + "/_api/web/lists(guid'" + listId + "')/fields(guid'" + fieldId + "')"; + } + + private isBindingOnly(properties: any): boolean { + if (!properties || typeof properties.expiryField !== 'string') { return false; } + const keys: string[] = ['baseField', 'default', 'rules', 'nullText', 'confirmExtension']; + for (let i: number = 0; i < keys.length; i++) { if (properties[keys[i]] !== undefined) { return false; } } + return true; + } + + private results(payload: any): any[] { return payload.value || (payload.d && payload.d.results) || []; } + private normalizeGuid(value: any): string { return String(value || '').replace(/[{}]/g, '').toLowerCase(); } + private normalizeScopedWebUrl(value: any): string { + const url: string = String(value || '').trim().replace(/\/$/, ''); + const expected: string = this.siteUrl.toLowerCase(); const normalized: string = url.toLowerCase(); + if (!url || (normalized !== expected && normalized.indexOf(expected + '/') !== 0)) { + throw new Error('Die Web-URL muss innerhalb der aktuellen Site Collection liegen.'); + } + return url; + } + private normalizePropertyName(value: string): string { return String(value || '').replace(/_x002e_/gi, '.').toLowerCase(); } + 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 { + 'Accept': 'application/json;odata=verbose', + 'Content-Type': 'application/json;odata=verbose', + 'OData-Version': '3.0', 'IF-MATCH': '*', 'X-HTTP-Method': method + }; + } + + private ensureJson(response: SPHttpClientResponse, message: string): Promise { + if (!response.ok) { return this.responseError(response, message).then((error: Error): Promise => Promise.reject(error)); } + return response.json(); + } + + private ensureOk(response: SPHttpClientResponse, message: string): Promise { + return response.ok ? Promise.resolve() : this.responseError(response, message).then((error: Error): Promise => Promise.reject(error)); + } + + private responseError(response: SPHttpClientResponse, prefix: string): Promise { + return response.text().then((text: string): Error => { + let detail: string = text; + try { + const parsed: any = JSON.parse(text); + detail = parsed.error && parsed.error.message ? (parsed.error.message.value || parsed.error.message) : text; + } catch (error) { /* Textantwort verwenden. */ } + return new Error(prefix + ' (' + response.status + '): ' + detail); + }); + } +} diff --git a/src/services/TaxonomyService.ts b/src/services/TaxonomyService.ts new file mode 100644 index 0000000..f29785b --- /dev/null +++ b/src/services/TaxonomyService.ts @@ -0,0 +1,50 @@ +// tslint:disable:no-any max-line-length +import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { ITermSetInfo } from '../core/ProviderContracts'; + +export class TaxonomyService { + public constructor(private context: WebPartContext) { } + + public getTermSets(): Promise { + const requestXml: string = ''; + const url: string = this.context.pageContext.site.absoluteUrl.replace(/\/$/, '') + '/_vti_bin/client.svc/ProcessQuery'; + return this.context.spHttpClient.post(url, SPHttpClient.configurations.v1, { + headers: { 'Accept': 'application/json', 'Content-Type': 'application/xml' }, body: requestXml + }).then((response: SPHttpClientResponse): Promise => { + if (!response.ok) { return Promise.reject(new Error('Termsets konnten nicht geladen werden (' + response.status + ').')); } + return response.text(); + }).then((text: string): ITermSetInfo[] => this.parse(JSON.parse(text || '[]'))); + } + + private parse(payload: any[]): ITermSetInfo[] { + if (!Array.isArray(payload)) { return []; } + if (payload.length && payload[0] && payload[0].ErrorInfo) { + throw new Error(payload[0].ErrorInfo.ErrorMessage || 'Taxonomy-Abfrage fehlgeschlagen.'); + } + let store: any; + for (let i: number = 0; i < payload.length; i++) { + if (payload[i] && payload[i]._ObjectType_ === 'SP.Taxonomy.TermStore') { store = payload[i]; break; } + } + const groups: any[] = store && store.Groups && Array.isArray(store.Groups._Child_Items_) + ? store.Groups._Child_Items_ : []; + const result: ITermSetInfo[] = []; + for (let g: number = 0; g < groups.length; g++) { + if (!groups[g] || groups[g].IsSystemGroup === true) { continue; } + const sets: any[] = groups[g].TermSets && Array.isArray(groups[g].TermSets._Child_Items_) + ? groups[g].TermSets._Child_Items_ : []; + for (let s: number = 0; s < sets.length; s++) { + const id: string = this.cleanGuid(sets[s] && sets[s].Id); + if (id) { + result.push({ id: id, name: sets[s].Name || id, groupName: groups[g].Name || 'Ohne Gruppe', description: sets[s].Description || '' }); + } + } + } + return result.sort((left: ITermSetInfo, right: ITermSetInfo): number => + (left.groupName + left.name).localeCompare(right.groupName + right.name)); + } + + private cleanGuid(value: any): string { + return String(value || '').replace('/Guid(', '').replace(')/', '').replace(')', '').replace(/[{}]/g, '').toLowerCase(); + } +} diff --git a/src/ui/Dom.ts b/src/ui/Dom.ts new file mode 100644 index 0000000..5933cac --- /dev/null +++ b/src/ui/Dom.ts @@ -0,0 +1,20 @@ +export function createElement( + tag: string, + className?: string, + text?: string +): HTMLElement { + const element: HTMLElement = document.createElement(tag); + if (className) { element.className = className; } + if (text !== undefined) { element.textContent = text; } + return element; +} + +export function clearElement(element: HTMLElement): void { + while (element.firstChild) { element.removeChild(element.firstChild); } +} + +export function appendText(parent: HTMLElement, tag: 'p' | 'span' | 'strong', className: string, text: string): HTMLElement { + const element: HTMLElement = createElement(tag, className, text); + parent.appendChild(element); + return element; +} diff --git a/src/ui/DynamicFormRenderer.ts b/src/ui/DynamicFormRenderer.ts new file mode 100644 index 0000000..2e2f90b --- /dev/null +++ b/src/ui/DynamicFormRenderer.ts @@ -0,0 +1,314 @@ +// tslint:disable:no-any max-line-length +import { ILoadedSettingsProvider, ISettingsElement, ISettingsOption, ISettingsSection } from '../core/ProviderContracts'; +import { getPathValue, isEmptyValue, setPathValue } from '../core/ObjectPath'; +import { appendText, clearElement, createElement } from './Dom'; + +export interface IDynamicFormCallbacks { + changed: () => void; + selectTermSet: (element: ISettingsElement) => void; + addBinding: () => void; + editBinding: (binding: any) => void; // tslint:disable-line:no-any +} + +export class DynamicFormRenderer { + private host: HTMLElement; + private item: ILoadedSettingsProvider; + private callbacks: IDynamicFormCallbacks; + + public render(host: HTMLElement, item: ILoadedSettingsProvider, callbacks: IDynamicFormCallbacks): void { + this.host = host; + this.item = item; + this.callbacks = callbacks; + clearElement(host); + for (let i: number = 0; i < item.provider.sections.length; i++) { + host.appendChild(this.renderSection(item.provider.sections[i])); + } + this.refreshDerivedElements(); + } + + public refreshDerivedElements(): void { + if (!this.host || !this.item) { return; } + const previews: NodeListOf = this.host.querySelectorAll('[data-ps3-json-preview]') as NodeListOf; + for (let i: number = 0; i < previews.length; i++) { previews[i].textContent = JSON.stringify(this.item.config, null, 2); } + const summaries: NodeListOf = this.host.querySelectorAll('[data-ps3-summary]') as NodeListOf; + for (let j: number = 0; j < summaries.length; j++) { this.renderSummary(summaries[j]); } + const conditional: NodeListOf = this.host.querySelectorAll('[data-ps3-condition-path]') as NodeListOf; + for (let c: number = 0; c < conditional.length; c++) { + const element: HTMLElement = conditional[c]; + const path: string = element.getAttribute('data-ps3-condition-path') || ''; + const operator: string = element.getAttribute('data-ps3-condition-operator') || ''; + const expectedRaw: string = element.getAttribute('data-ps3-condition-value') || ''; + let expected: any = expectedRaw; + try { expected = JSON.parse(expectedRaw); } catch (error) { /* String verwenden. */ } + element.hidden = !this.matchesCondition(getPathValue(this.item.config, path), operator, expected); + } + } + + private renderSection(section: ISettingsSection): HTMLElement { + const sectionElement: HTMLElement = section.collapsible ? createElement('details', 'ps3-section') : createElement('section', 'ps3-section'); + sectionElement.setAttribute('data-layout', section.layout || 'singleColumn'); + if (section.collapsible && !section.collapsedByDefault) { (sectionElement as any).open = true; } + const header: HTMLElement = section.collapsible ? createElement('summary', 'ps3-sectionHeader') : createElement('div', 'ps3-sectionHeader'); + const heading: HTMLElement = createElement('h2', 'ps3-sectionTitle', section.title); + header.appendChild(heading); + if (section.description) { appendText(header, 'p', 'ps3-sectionDescription', section.description); } + sectionElement.appendChild(header); + const grid: HTMLElement = createElement('div', 'ps3-grid'); + for (let i: number = 0; i < section.elements.length; i++) { grid.appendChild(this.renderElement(section.elements[i])); } + sectionElement.appendChild(grid); + return sectionElement; + } + + private renderElement(definition: ISettingsElement): HTMLElement { + const wrapper: HTMLElement = createElement('div', 'ps3-element ps3-span-' + (definition.columnSpan || 12)); + if (definition.visibleWhen) { + wrapper.setAttribute('data-ps3-condition-path', definition.visibleWhen.field); + wrapper.setAttribute('data-ps3-condition-operator', definition.visibleWhen.operator); + wrapper.setAttribute('data-ps3-condition-value', JSON.stringify(definition.visibleWhen.value)); + } + switch (definition.type) { + case 'heading': return this.renderHeading(wrapper, definition); + case 'text': appendText(wrapper, 'p', 'ps3-copy', definition.text || ''); return wrapper; + case 'notice': return this.renderNotice(wrapper, definition); + case 'divider': wrapper.className += ' ps3-divider'; return wrapper; + case 'group': return this.renderGroup(wrapper, definition); + case 'propertySummary': return this.renderSummaryElement(wrapper, definition); + case 'jsonPreview': return this.renderPreview(wrapper, definition); + case 'boolean': return this.renderBoolean(wrapper, definition); + case 'choice': return this.renderChoice(wrapper, definition); + case 'choiceCards': return this.renderChoiceCards(wrapper, definition); + case 'number': return this.renderInput(wrapper, definition, 'number'); + case 'textField': return this.renderInput(wrapper, definition, 'text'); + case 'json': return this.renderJson(wrapper, definition); + case 'stringList': return this.renderStringList(wrapper, definition); + case 'termSet': return this.renderTermSet(wrapper, definition); + case 'bindingInventory': return this.renderBindings(wrapper, definition); + default: appendText(wrapper, 'p', 'ps3-errorText', 'Nicht unterstütztes Element: ' + definition.type); return wrapper; + } + } + + private renderHeading(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + const tag: 'h2' | 'h3' | 'h4' = definition.level === 2 ? 'h2' : (definition.level === 4 ? 'h4' : 'h3'); + wrapper.appendChild(createElement(tag, 'ps3-elementHeading', definition.text || definition.title || '')); + return wrapper; + } + + private renderNotice(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + wrapper.className += ' ps3-notice ps3-notice-' + (definition.appearance || 'info'); + wrapper.setAttribute('role', definition.appearance === 'danger' ? 'alert' : 'note'); + if (definition.title) { appendText(wrapper, 'strong', 'ps3-noticeTitle', definition.title); } + appendText(wrapper, 'p', 'ps3-noticeText', definition.text || ''); + return wrapper; + } + + private renderGroup(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + wrapper.className += ' ps3-group'; + if (definition.title) { wrapper.appendChild(createElement('h3', 'ps3-elementHeading', definition.title)); } + const grid: HTMLElement = createElement('div', 'ps3-grid'); + const elements: ISettingsElement[] = definition.elements || []; + for (let i: number = 0; i < elements.length; i++) { grid.appendChild(this.renderElement(elements[i])); } + wrapper.appendChild(grid); + return wrapper; + } + + private renderInput(wrapper: HTMLElement, definition: ISettingsElement, type: string): HTMLElement { + const input: HTMLInputElement = createElement('input', 'ps3-input') as HTMLInputElement; + input.type = type; + input.id = this.controlId(definition); + input.value = String(getPathValue(this.item.config, definition.path || '') || ''); + input.readOnly = definition.readOnly === true; + if (definition.placeholder) { input.placeholder = definition.placeholder; } + if (definition.minimum !== undefined) { input.min = String(definition.minimum); } + if (definition.maximum !== undefined) { input.max = String(definition.maximum); } + if (definition.step !== undefined) { input.step = String(definition.step); } + this.appendLabel(wrapper, definition, input.id); + const controlRow: HTMLElement = createElement('div', 'ps3-controlRow'); + controlRow.appendChild(input); + if (definition.suffix) { appendText(controlRow, 'span', 'ps3-suffix', definition.suffix); } + wrapper.appendChild(controlRow); + this.appendDescription(wrapper, definition); + input.addEventListener('input', (): void => { + const value: any = type === 'number' ? Number(input.value) : input.value; + setPathValue(this.item.config, definition.path || '', value); + this.notifyChanged(); + }); + return wrapper; + } + + private renderBoolean(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + const row: HTMLElement = createElement('label', 'ps3-toggle'); + const input: HTMLInputElement = createElement('input') as HTMLInputElement; + input.type = 'checkbox'; input.checked = getPathValue(this.item.config, definition.path || '') === true; + const visual: HTMLElement = createElement('span', 'ps3-toggleVisual'); + const content: HTMLElement = createElement('span', 'ps3-toggleContent'); + appendText(content, 'strong', 'ps3-label', definition.label || definition.key || ''); + if (definition.description) { appendText(content, 'span', 'ps3-description', definition.description); } + row.appendChild(input); row.appendChild(visual); row.appendChild(content); wrapper.appendChild(row); + input.addEventListener('change', (): void => { + setPathValue(this.item.config, definition.path || '', input.checked); + this.notifyChanged(); + }); + return wrapper; + } + + private renderChoice(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + const select: HTMLSelectElement = createElement('select', 'ps3-input') as HTMLSelectElement; + select.id = this.controlId(definition); + const value: string = String(getPathValue(this.item.config, definition.path || '') || ''); + (definition.options || []).forEach((option: ISettingsOption): void => { + const optionElement: HTMLOptionElement = createElement('option') as HTMLOptionElement; + optionElement.value = option.key; optionElement.textContent = option.text; optionElement.selected = option.key === value; + select.appendChild(optionElement); + }); + this.appendLabel(wrapper, definition, select.id); wrapper.appendChild(select); this.appendDescription(wrapper, definition); + select.addEventListener('change', (): void => { setPathValue(this.item.config, definition.path || '', select.value); this.notifyChanged(); }); + return wrapper; + } + + private renderChoiceCards(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + this.appendLabel(wrapper, definition, ''); + const group: HTMLElement = createElement('div', 'ps3-choiceCards'); group.setAttribute('role', 'radiogroup'); + const current: string = String(getPathValue(this.item.config, definition.path || '') || ''); + (definition.options || []).forEach((option: ISettingsOption): void => { + const card: HTMLLabelElement = createElement('label', 'ps3-choiceCard' + (option.key === current ? ' is-selected' : '')) as HTMLLabelElement; + const input: HTMLInputElement = createElement('input') as HTMLInputElement; + input.type = 'radio'; input.name = this.controlId(definition); input.value = option.key; input.checked = option.key === current; + const content: HTMLElement = createElement('span', 'ps3-choiceContent'); + appendText(content, 'strong', 'ps3-choiceTitle', option.text); + if (option.description) { appendText(content, 'span', 'ps3-description', option.description); } + card.appendChild(input); card.appendChild(content); group.appendChild(card); + input.addEventListener('change', (): void => { + if (input.checked) { + const cards: NodeListOf = group.querySelectorAll('.ps3-choiceCard') as NodeListOf; + for (let index: number = 0; index < cards.length; index++) { cards[index].className = cards[index].className.replace(/\s+is-selected/g, ''); } + card.className += ' is-selected'; + setPathValue(this.item.config, definition.path || '', option.key); this.notifyChanged(); + } + }); + }); + wrapper.appendChild(group); return wrapper; + } + + private renderJson(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + const textarea: HTMLTextAreaElement = createElement('textarea', 'ps3-textarea ps3-code') as HTMLTextAreaElement; + textarea.id = this.controlId(definition); textarea.spellcheck = false; + textarea.value = JSON.stringify(getPathValue(this.item.config, definition.path || ''), null, 2); + const error: HTMLElement = createElement('div', 'ps3-fieldError'); error.hidden = true; + this.appendLabel(wrapper, definition, textarea.id); wrapper.appendChild(textarea); wrapper.appendChild(error); this.appendDescription(wrapper, definition); + textarea.addEventListener('input', (): void => { + try { + setPathValue(this.item.config, definition.path || '', JSON.parse(textarea.value)); + textarea.removeAttribute('aria-invalid'); error.hidden = true; this.notifyChanged(); + } catch (parseError) { + textarea.setAttribute('aria-invalid', 'true'); error.textContent = 'Ungültiges JSON: ' + parseError.message; error.hidden = false; + } + }); + return wrapper; + } + + private renderStringList(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + const textarea: HTMLTextAreaElement = createElement('textarea', 'ps3-textarea ps3-listEditor') as HTMLTextAreaElement; + textarea.id = this.controlId(definition); textarea.value = (getPathValue(this.item.config, definition.path || '') || []).join('\n'); + this.appendLabel(wrapper, definition, textarea.id); wrapper.appendChild(textarea); this.appendDescription(wrapper, definition); + textarea.addEventListener('input', (): void => { + const values: string[] = textarea.value.split(/\r?\n/).map((value: string): string => value.trim()).filter((value: string): boolean => !!value); + setPathValue(this.item.config, definition.path || '', values); this.notifyChanged(); + }); + return wrapper; + } + + private renderTermSet(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + this.appendLabel(wrapper, definition, ''); + const selected: HTMLElement = createElement('div', 'ps3-selectedItem'); + const text: HTMLElement = createElement('div'); + const name: string = String(getPathValue(this.item.config, definition.companionNamePath || '') || 'Kein Termset ausgewählt'); + const id: string = String(getPathValue(this.item.config, definition.path || '') || '-'); + appendText(text, 'strong', 'ps3-selectedTitle', name); appendText(text, 'span', 'ps3-mono', id); + const button: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Auswählen') as HTMLButtonElement; + button.type = 'button'; button.addEventListener('click', (): void => this.callbacks.selectTermSet(definition)); + selected.appendChild(text); selected.appendChild(button); wrapper.appendChild(selected); this.appendDescription(wrapper, definition); + return wrapper; + } + + private renderBindings(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + const header: HTMLElement = createElement('div', 'ps3-bindingHeader'); + if (definition.title) { header.appendChild(createElement('h3', 'ps3-elementHeading', definition.title)); } + const add: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Bindung hinzufügen') as HTMLButtonElement; + add.type = 'button'; add.addEventListener('click', (): void => this.callbacks.addBinding()); header.appendChild(add); wrapper.appendChild(header); + const bindings: any[] = this.item.context.bindings || []; + const list: HTMLElement = createElement('div', 'ps3-bindingList'); + if (!bindings.length) { appendText(list, 'p', 'ps3-emptyInline', 'Keine Bindungen registriert.'); } + for (let i: number = 0; i < bindings.length; i++) { + const binding: any = bindings[i]; + const card: HTMLElement = createElement('article', 'ps3-binding'); + const bindingText: HTMLElement = createElement('div'); + appendText(bindingText, 'strong', 'ps3-bindingTitle', binding.listTitle || binding.listId || 'Liste/Bibliothek'); + appendText(bindingText, 'span', 'ps3-mono', (binding.webUrl || '') + ' · ' + (binding.fieldInternalName || binding.fieldName || '')); + const edit: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Konfigurieren') as HTMLButtonElement; + edit.type = 'button'; edit.addEventListener('click', (): void => this.callbacks.editBinding(binding)); + card.appendChild(bindingText); card.appendChild(edit); + list.appendChild(card); + } + wrapper.appendChild(list); return wrapper; + } + + private renderSummaryElement(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + if (definition.title) { wrapper.appendChild(createElement('h3', 'ps3-elementHeading', definition.title)); } + const host: HTMLElement = createElement('div', 'ps3-summary'); host.setAttribute('data-ps3-summary', 'true'); + wrapper.appendChild(host); return wrapper; + } + + private renderPreview(wrapper: HTMLElement, definition: ISettingsElement): HTMLElement { + const details: any = createElement('details', 'ps3-preview'); + details.open = !definition.collapsedByDefault; + details.appendChild(createElement('summary', 'ps3-previewTitle', definition.title || 'JSON-Vorschau')); + const pre: HTMLElement = createElement('pre', 'ps3-code ps3-previewCode'); pre.setAttribute('data-ps3-json-preview', 'true'); + details.appendChild(pre); wrapper.appendChild(details); return wrapper; + } + + private renderSummary(host: HTMLElement): void { + clearElement(host); + const table: HTMLTableElement = createElement('table', 'ps3-summaryTable') as HTMLTableElement; + const keys: string[] = Object.keys(this.item.config).filter((key: string): boolean => key !== 'placeholdertop' && key !== 'placeholderbottom' && key !== 'rules'); + for (let i: number = 0; i < keys.length; i++) { + const row: HTMLTableRowElement = table.insertRow(-1); + const keyCell: HTMLTableCellElement = row.insertCell(-1); keyCell.textContent = keys[i]; + const valueCell: HTMLTableCellElement = row.insertCell(-1); + const value: any = this.item.config[keys[i]]; + valueCell.textContent = typeof value === 'object' ? JSON.stringify(value) : String(value); + } + host.appendChild(table); + } + + private appendLabel(wrapper: HTMLElement, definition: ISettingsElement, htmlFor: string): void { + const label: HTMLLabelElement = createElement('label', 'ps3-label', definition.label || definition.key || '') as HTMLLabelElement; + if (htmlFor) { label.htmlFor = htmlFor; } + if (definition.required) { label.appendChild(createElement('span', 'ps3-required', ' *')); } + wrapper.appendChild(label); + } + + private appendDescription(wrapper: HTMLElement, definition: ISettingsElement): void { + if (definition.description) { appendText(wrapper, 'p', 'ps3-description', definition.description); } + } + + private notifyChanged(): void { + this.item.dirty = true; this.refreshDerivedElements(); this.callbacks.changed(); + } + + private controlId(definition: ISettingsElement): string { + return 'ps3-' + this.item.provider.key + '-' + String(definition.key || definition.path || 'field').replace(/[^a-z0-9_-]/gi, '-'); + } + + private matchesCondition(actual: any, operator: string, expected: any): boolean { + switch (operator) { + case 'equals': return actual === expected; + case 'notEquals': return actual !== expected; + case 'isEmpty': return isEmptyValue(actual); + case 'isNotEmpty': return !isEmptyValue(actual); + case 'contains': return Array.isArray(actual) ? actual.indexOf(expected) >= 0 : String(actual || '').indexOf(String(expected)) >= 0; + case 'greaterThan': return Number(actual) > Number(expected); + case 'lessThan': return Number(actual) < Number(expected); + default: return true; + } + } +} diff --git a/src/ui/PortalSettingsApp.ts b/src/ui/PortalSettingsApp.ts new file mode 100644 index 0000000..17b9aa9 --- /dev/null +++ b/src/ui/PortalSettingsApp.ts @@ -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; + } +} diff --git a/src/webparts/portalSettings/PortalSettings.scss b/src/webparts/portalSettings/PortalSettings.scss new file mode 100644 index 0000000..1e9755b --- /dev/null +++ b/src/webparts/portalSettings/PortalSettings.scss @@ -0,0 +1,171 @@ +@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss'; + +$white: "[theme: white, default: #ffffff]"; +$neutral-lighter-alt: "[theme: neutralLighterAlt, default: #f8f8f8]"; +$neutral-lighter: "[theme: neutralLighter, default: #f4f4f4]"; +$neutral-light: "[theme: neutralLight, default: #eaeaea]"; +$neutral-tertiary: "[theme: neutralTertiary, default: #a6a6a6]"; +$neutral-secondary: "[theme: neutralSecondary, default: #666666]"; +$neutral-primary: "[theme: neutralPrimary, default: #333333]"; +$theme-lighter-alt: "[theme: themeLighterAlt, default: #eff6fc]"; +$theme-lighter: "[theme: themeLighter, default: #deecf9]"; +$theme-light: "[theme: themeLight, default: #c7e0f4]"; +$theme-primary: "[theme: themePrimary, default: #0078d7]"; +$theme-dark: "[theme: themeDark, default: #005a9e]"; +$red-dark: "[theme: redDark, default: #a80000]"; +$green: "[theme: green, default: #107c10]"; + +.ps3-root { + box-sizing: border-box; + width: 100%; + background: $neutral-lighter-alt; + color: $neutral-primary; + font-family: 'Segoe UI', 'Segoe UI Web (West European)', sans-serif; +} + +.ps3-root *, .ps3-root *::before, .ps3-root *::after { box-sizing: border-box; } +.ps3-shell { width: 100%; max-width: 1280px; margin: 0 auto; padding: 24px; } +.ps3-statusHost { min-height: 50px; } +.ps3-status { margin-bottom: 14px; padding: 12px 16px; border: 1px solid $neutral-light; border-radius: 4px; background: $white; } +.ps3-status-info { border-color: $theme-light; background: $theme-lighter-alt; color: $theme-dark; } +.ps3-status-success { border-color: $green; color: $green; } +.ps3-status-warning { border-color: #ca5010; background: #fff4ce; color: #8a3707; } +.ps3-status-danger { border-color: $red-dark; background: #fde7e9; color: $red-dark; } + +.ps3-hero { + padding: 28px 30px; + border-radius: 8px; + background: $theme-primary; + color: $white; + box-shadow: 0 4px 16px rgba(0, 0, 0, .12); +} +.ps3-eyebrow { display: block; margin-bottom: 8px; font-size: 11px; font-weight: 700; letter-spacing: .16em; } +.ps3-title { margin: 0; font-size: 30px; font-weight: 300; line-height: 1.2; } +.ps3-lead { max-width: 850px; margin: 12px 0 0; font-size: 15px; line-height: 1.6; } +.ps3-heroMeta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 20px; } +.ps3-heroMetric { min-width: 210px; padding: 10px 14px; border: 1px solid rgba(255,255,255,.32); border-radius: 4px; background: rgba(255,255,255,.1); } +.ps3-heroMetric span, .ps3-heroMetric strong { display: block; } +.ps3-heroMetric span { margin-bottom: 3px; font-size: 11px; opacity: .85; text-transform: uppercase; } +.ps3-heroMetric strong { font-size: 13px; word-break: break-word; } + +.ps3-tabs { display: flex; gap: 4px; margin: 22px 0 0; padding: 0 4px; border-bottom: 1px solid $neutral-light; } +.ps3-tab { position: relative; min-height: 44px; padding: 0 18px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: $neutral-secondary; cursor: pointer; font: inherit; font-weight: 600; } +.ps3-tab:hover { background: $neutral-lighter; color: $neutral-primary; } +.ps3-tab.is-active { border-bottom-color: $theme-primary; color: $theme-primary; } +.ps3-tab:focus { outline: 2px solid $theme-primary; outline-offset: -2px; } +.ps3-dirtyDot { margin-left: 8px; color: #ca5010; font-size: 9px; } + +.ps3-content { padding-top: 18px; } +.ps3-providerHeader { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; margin-bottom: 16px; padding: 20px 22px; border: 1px solid $neutral-light; border-radius: 6px; background: $white; } +.ps3-providerVersion { color: $theme-primary; font-size: 11px; font-weight: 700; text-transform: uppercase; } +.ps3-providerTitle { margin: 3px 0 5px; font-size: 24px; font-weight: 400; } +.ps3-providerDescription { margin: 0; color: $neutral-secondary; line-height: 1.5; } +.ps3-storageBadge { flex: 0 0 auto; padding: 6px 10px; border-radius: 12px; background: $theme-lighter-alt; color: $theme-dark; font-size: 12px; font-weight: 600; } + +.ps3-form { display: grid; gap: 16px; } +.ps3-section { display: block; margin: 0; padding: 0; border: 1px solid $neutral-light; border-radius: 6px; background: $white; box-shadow: 0 2px 8px rgba(0,0,0,.04); } +.ps3-sectionHeader { display: block; padding: 18px 20px 14px; border-bottom: 1px solid $neutral-light; } +details.ps3-section > summary { cursor: pointer; } +details.ps3-section > summary:focus { outline: 2px solid $theme-primary; outline-offset: -2px; } +.ps3-sectionTitle { margin: 0; font-size: 18px; font-weight: 600; } +.ps3-sectionDescription { margin: 5px 0 0; color: $neutral-secondary; line-height: 1.5; } +.ps3-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 18px; padding: 20px; } +.ps3-span-1 { grid-column: span 1; } .ps3-span-2 { grid-column: span 2; } .ps3-span-3 { grid-column: span 3; } +.ps3-span-4 { grid-column: span 4; } .ps3-span-6 { grid-column: span 6; } .ps3-span-8 { grid-column: span 8; } .ps3-span-12 { grid-column: span 12; } +.ps3-element { min-width: 0; } +.ps3-elementHeading { margin: 0; font-size: 15px; font-weight: 600; } +.ps3-label { display: block; margin: 0 0 7px; font-size: 13px; font-weight: 600; } +.ps3-required { color: $red-dark; } +.ps3-description { display: block; margin: 6px 0 0; color: $neutral-secondary; font-size: 12px; line-height: 1.5; } +.ps3-controlRow { display: flex; align-items: stretch; } +.ps3-input, .ps3-textarea { width: 100%; border: 1px solid $neutral-tertiary; border-radius: 2px; background: $white; color: $neutral-primary; font: inherit; font-size: 14px; } +.ps3-input { height: 36px; padding: 0 10px; } +.ps3-textarea { min-height: 180px; padding: 10px; resize: vertical; line-height: 1.5; } +.ps3-listEditor { min-height: 120px; } +.ps3-input:focus, .ps3-textarea:focus { border-color: $theme-primary; outline: 1px solid $theme-primary; } +.ps3-textarea[aria-invalid='true'] { border-color: $red-dark; } +.ps3-suffix { display: flex; align-items: center; padding: 0 10px; border: 1px solid $neutral-tertiary; border-left: 0; background: $neutral-lighter; font-size: 12px; } +.ps3-code { font-family: Consolas, 'Courier New', monospace; font-size: 12px; } +.ps3-fieldError, .ps3-errorText { margin-top: 5px; color: $red-dark; font-size: 12px; } +.ps3-divider { height: 1px; align-self: center; background: $neutral-light; } + +.ps3-notice { padding: 13px 15px; border-left: 4px solid $theme-primary; background: $theme-lighter-alt; } +.ps3-notice-warning { border-left-color: #ca5010; background: #fff4ce; } +.ps3-notice-danger { border-left-color: $red-dark; background: #fde7e9; } +.ps3-notice-success { border-left-color: $green; background: #dff6dd; } +.ps3-noticeTitle { display: block; margin-bottom: 3px; } +.ps3-noticeText { margin: 0; line-height: 1.5; } + +.ps3-toggle { display: grid; grid-template-columns: 42px minmax(0,1fr); gap: 10px; cursor: pointer; } +.ps3-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.ps3-toggleVisual { position: relative; width: 40px; height: 20px; margin-top: 2px; border: 1px solid $neutral-secondary; border-radius: 10px; background: $white; } +.ps3-toggleVisual::after { content: ''; position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: $neutral-secondary; transition: left .12s ease; } +.ps3-toggle input:checked + .ps3-toggleVisual { border-color: $theme-primary; background: $theme-primary; } +.ps3-toggle input:checked + .ps3-toggleVisual::after { left: 23px; background: $white; } +.ps3-toggle input:focus + .ps3-toggleVisual { outline: 2px solid $theme-primary; outline-offset: 2px; } +.ps3-toggleContent .ps3-description { margin-top: 2px; } + +.ps3-choiceCards { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; } +.ps3-choiceCard { position: relative; display: block; min-height: 90px; padding: 15px; border: 1px solid $neutral-light; border-radius: 4px; cursor: pointer; } +.ps3-choiceCard:hover { border-color: $theme-primary; background: $theme-lighter-alt; } +.ps3-choiceCard input { position: absolute; top: 14px; right: 14px; } +.ps3-choiceCard.is-selected { border: 2px solid $theme-primary; padding: 14px; background: $theme-lighter-alt; } +.ps3-choiceTitle, .ps3-choiceContent { display: block; } + +.ps3-selectedItem, .ps3-pickerItem { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 14px; border: 1px solid $neutral-light; border-radius: 4px; background: $neutral-lighter-alt; } +.ps3-selectedTitle, .ps3-mono { display: block; } +.ps3-mono { margin-top: 3px; color: $neutral-secondary; font-family: Consolas, 'Courier New', monospace; font-size: 11px; word-break: break-all; } +.ps3-bindingList, .ps3-pickerResults { display: grid; gap: 8px; } +.ps3-bindingHeader { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; } +.ps3-binding { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid $neutral-light; border-radius: 4px; } +.ps3-bindingTitle { display: block; } +.ps3-emptyInline { margin: 0; padding: 14px; color: $neutral-secondary; text-align: center; } + +.ps3-preview { border: 1px solid $neutral-light; border-radius: 4px; } +.ps3-previewTitle { padding: 12px 14px; cursor: pointer; font-weight: 600; } +.ps3-previewCode { max-height: 440px; margin: 0; padding: 14px; overflow: auto; border-top: 1px solid $neutral-light; background: $neutral-lighter-alt; white-space: pre-wrap; } +.ps3-summaryTable { width: 100%; border-collapse: collapse; } +.ps3-summaryTable td { padding: 8px 10px; border-bottom: 1px solid $neutral-light; word-break: break-word; } +.ps3-summaryTable td:first-child { width: 28%; color: $neutral-secondary; font-weight: 600; } + +.ps3-actionBar { position: sticky; bottom: 12px; z-index: 3; display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; padding: 14px; border: 1px solid $neutral-light; border-radius: 6px; background: rgba(255,255,255,.96); box-shadow: 0 4px 16px rgba(0,0,0,.12); } +.ps3-button { min-height: 36px; padding: 0 16px; border: 1px solid transparent; border-radius: 2px; cursor: pointer; font: inherit; font-size: 14px; font-weight: 600; } +.ps3-button-primary { border-color: $theme-primary; background: $theme-primary; color: $white; } +.ps3-button-primary:hover { border-color: $theme-dark; background: $theme-dark; } +.ps3-button-secondary { border-color: $neutral-tertiary; background: $white; color: $neutral-primary; } +.ps3-button-secondary:hover { border-color: $theme-primary; color: $theme-primary; } +.ps3-button:focus { outline: 2px solid $theme-primary; outline-offset: 2px; } +.ps3-button:disabled { cursor: default; opacity: .45; } + +.ps3-modalBackdrop { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 100000; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(0,0,0,.48); } +.ps3-modal { width: 100%; max-width: 760px; max-height: calc(100vh - 48px); padding: 22px; overflow: auto; border-radius: 6px; background: $white; color: $neutral-primary; box-shadow: 0 12px 40px rgba(0,0,0,.3); } +.ps3-modal-large { max-width: 1120px; } +.ps3-modal h2 { margin: 0 0 14px; } +.ps3-modal > .ps3-label { margin-top: 14px; } +.ps3-dialogStatus { min-height: 42px; padding: 10px 0; color: $neutral-secondary; } +.ps3-dialogActions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } +.ps3-pickerResults { max-height: 460px; margin: 14px 0; overflow: auto; } +.ps3-empty, .ps3-accessDenied { padding: 28px; border: 1px solid $neutral-light; border-radius: 6px; background: $white; text-align: center; } + +@media (max-width: 800px) { + .ps3-shell { padding: 12px; } + .ps3-hero { padding: 22px 18px; } + .ps3-title { font-size: 25px; } + .ps3-tabs { flex-direction: column; border-bottom: 0; } + .ps3-tab { width: 100%; border-bottom: 0; border-left: 3px solid transparent; text-align: left; } + .ps3-tab.is-active { border-left-color: $theme-primary; } + .ps3-span-1, .ps3-span-2, .ps3-span-3, .ps3-span-4, .ps3-span-6, .ps3-span-8 { grid-column: span 12; } + .ps3-choiceCards { grid-template-columns: 1fr; } + .ps3-providerHeader { display: block; } + .ps3-storageBadge { display: inline-block; margin-top: 12px; } + .ps3-actionBar { display: grid; } +} + +@media (forced-colors: active) { + .ps3-hero, .ps3-button-primary, .ps3-toggle input:checked + .ps3-toggleVisual { border: 1px solid CanvasText; background: Canvas; color: CanvasText; } + .ps3-section, .ps3-providerHeader, .ps3-actionBar { border: 1px solid CanvasText; } +} + +@media (prefers-reduced-motion: reduce) { + .ps3-toggleVisual::after { transition: none; } +} diff --git a/src/webparts/portalSettings/PortalSettingsWebPart.manifest.json b/src/webparts/portalSettings/PortalSettingsWebPart.manifest.json new file mode 100644 index 0000000..a22e661 --- /dev/null +++ b/src/webparts/portalSettings/PortalSettingsWebPart.manifest.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json", + "id": "c41d05ec-0682-4af0-b807-649806ed56b3", + "alias": "PortalSettingsWebPart", + "componentType": "WebPart", + "version": "*", + "manifestVersion": 2, + "requiresCustomScript": false, + "preconfiguredEntries": [ + { + "groupId": "5c03119e-3074-46fd-976b-c60198311f70", + "group": { "default": "Other" }, + "title": { "default": "Portal Settings", "de-de": "Portal-Einstellungen" }, + "description": { + "default": "Central administration for installed portal extensions.", + "de-de": "Zentrale Verwaltung installierter Portal-Erweiterungen." + }, + "officeFabricIconFontName": "Settings", + "properties": { + "title": "Zentrale Verwaltung der Portal-Erweiterungen" + } + } + ] +} diff --git a/src/webparts/portalSettings/PortalSettingsWebPart.ts b/src/webparts/portalSettings/PortalSettingsWebPart.ts new file mode 100644 index 0000000..97d530f --- /dev/null +++ b/src/webparts/portalSettings/PortalSettingsWebPart.ts @@ -0,0 +1,38 @@ +import { + BaseClientSideWebPart, + IPropertyPaneConfiguration, + PropertyPaneTextField +} from '@microsoft/sp-webpart-base'; +import * as strings from 'PortalSettingsWebPartStrings'; +import { PortalSettingsApp } from '../../ui/PortalSettingsApp'; +import './PortalSettings.scss'; + +export interface IPortalSettingsWebPartProperties { + title?: string; +} + +export default class PortalSettingsWebPart extends BaseClientSideWebPart { + private app: PortalSettingsApp; + + public render(): void { + if (this.app) { this.app.dispose(); } + this.app = new PortalSettingsApp(this.context, this.properties.title); + this.app.render(this.domElement); + } + + protected onDispose(): void { + if (this.app) { this.app.dispose(); this.app = undefined; } + } + + protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration { + return { + pages: [{ + header: { description: strings.PropertyPaneDescription }, + groups: [{ + groupName: strings.BasicGroupName, + groupFields: [PropertyPaneTextField('title', { label: strings.TitleFieldLabel })] + }] + }] + }; + } +} diff --git a/src/webparts/portalSettings/loc/de-de.js b/src/webparts/portalSettings/loc/de-de.js new file mode 100644 index 0000000..35ff997 --- /dev/null +++ b/src/webparts/portalSettings/loc/de-de.js @@ -0,0 +1,7 @@ +define([], function() { + return { + "PropertyPaneDescription": "Portal-Einstellungen", + "BasicGroupName": "Allgemein", + "TitleFieldLabel": "Titel" + }; +}); diff --git a/src/webparts/portalSettings/loc/en-us.js b/src/webparts/portalSettings/loc/en-us.js new file mode 100644 index 0000000..d0d514b --- /dev/null +++ b/src/webparts/portalSettings/loc/en-us.js @@ -0,0 +1,7 @@ +define([], function() { + return { + "PropertyPaneDescription": "Portal Settings", + "BasicGroupName": "General", + "TitleFieldLabel": "Title" + }; +}); diff --git a/src/webparts/portalSettings/loc/myStrings.d.ts b/src/webparts/portalSettings/loc/myStrings.d.ts new file mode 100644 index 0000000..b859455 --- /dev/null +++ b/src/webparts/portalSettings/loc/myStrings.d.ts @@ -0,0 +1,10 @@ +declare interface IPortalSettingsWebPartStrings { + PropertyPaneDescription: string; + BasicGroupName: string; + TitleFieldLabel: string; +} + +declare module 'PortalSettingsWebPartStrings' { + const strings: IPortalSettingsWebPartStrings; + export = strings; +} diff --git a/tests/ProviderRegistry.test.ts b/tests/ProviderRegistry.test.ts new file mode 100644 index 0000000..2cf724d --- /dev/null +++ b/tests/ProviderRegistry.test.ts @@ -0,0 +1,55 @@ +import { cloneValue, getPathValue, mergePreservingUnknown, setPathValue } from '../src/core/ObjectPath'; +import { ISettingsProvider } from '../src/core/ProviderContracts'; +import { getSettingsProvider, getSettingsProviders } from '../src/providers/ProviderRegistry'; + +let passed: number = 0; +function assert(condition: boolean, message: string): void { + if (!condition) { throw new Error(message); } + passed++; +} + +const providers: Array> = getSettingsProviders(); // tslint:disable-line:no-any +assert(providers.length === 3, 'Genau drei Provider werden erwartet.'); +assert(!!getSettingsProvider('megamenu'), 'MegaMenu-Provider fehlt.'); +assert(!!getSettingsProvider('custombranding'), 'CustomBranding-Provider fehlt.'); +assert(!!getSettingsProvider('expiryindicator'), 'ExpiryIndicator-Provider fehlt.'); + +providers.forEach((provider: ISettingsProvider): void => { // tslint:disable-line:no-any + assert(provider.sections.length > 0, provider.key + ' hat keine Sections.'); + assert(provider.sections.every(section => section.elements.length > 0), provider.key + ' enthält eine leere Section.'); + assert(provider.validate(provider.createDefault()) instanceof Array, provider.key + ' liefert keine Validierungsliste.'); +}); + +const mega: ISettingsProvider = getSettingsProvider('megamenu'); // tslint:disable-line:no-any +const normalizedMega: any = mega.normalize({ cacheMinutes: 5000, cacheVersion: '', unknownFuture: 'keep', menuMode: 'other' }); // tslint:disable-line:no-any +assert(normalizedMega.cacheMinutes === 1440, 'MegaMenu Cache-Maximum greift nicht.'); +assert(normalizedMega.cacheVersion === '1', 'MegaMenu Cache-Fallback fehlt.'); +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 +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.'); +assert(normalizedBranding.elements === undefined, 'Legacy-Root-Elemente werden nicht bereinigt.'); +const unsafeBranding: any = branding.createDefault(); // tslint:disable-line:no-any +unsafeBranding.placeholdertop.elements = [{ type: 'script', content: 'alert(1)' }, { type: 'a', attributes: { onclick: 'alert(1)', href: 'javascript:alert(1)' } }]; +assert(branding.validate(unsafeBranding).length >= 3, 'Unsichere Branding-Elemente werden nicht erkannt.'); + +const expiry: ISettingsProvider = getSettingsProvider('expiryindicator'); // tslint:disable-line:no-any +const normalizedExpiry: any = expiry.normalize({ baseField: 'Invalid field', future: true }); // tslint:disable-line:no-any +assert(normalizedExpiry.baseField === 'Created', 'Expiry-Feldname wird nicht abgesichert.'); +assert(normalizedExpiry.future === true, 'Unbekannte Expiry-Properties gehen verloren.'); +const invalidExpiry: any = expiry.createDefault(); // tslint:disable-line:no-any +invalidExpiry.rules = [{ condition: { operator: 'xor', conditions: [] }, lifeTime: { value: 0, unit: 'hours' }, columnRule: [{}] }]; +assert(expiry.validate(invalidExpiry).length >= 3, 'Ungültige Expiry-Regeln werden nicht erkannt.'); + +const value: any = {}; // tslint:disable-line:no-any +setPathValue(value, 'default.lifeTime.value', 3); +assert(getPathValue(value, 'default.lifeTime.value') === 3, 'Objektpfade funktionieren nicht.'); +const merged: any = mergePreservingUnknown({ known: 1, future: { keep: true } }, { known: 2 }); // tslint:disable-line:no-any +assert(merged.known === 2 && merged.future.keep === true, 'Schemaerhaltendes Mergen ist fehlerhaft.'); +const cloned: any = cloneValue(merged); cloned.future.keep = false; // tslint:disable-line:no-any +assert(merged.future.keep === true, 'Konfiguration wird nicht tief kopiert.'); + +console.log('PortalSettings Provider tests passed: ' + passed); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..8c75415 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "../temp/tests", + "skipLibCheck": true, + "types": [], + "lib": ["es5", "dom", "es2015.collection"] + }, + "files": [ + "../src/core/ProviderContracts.ts", + "../src/core/ObjectPath.ts", + "../src/providers/MegaMenuSettingsProvider.ts", + "../src/providers/CustomBrandingSettingsProvider.ts", + "../src/providers/ExpiryIndicatorSettingsProvider.ts", + "../src/providers/ProviderRegistry.ts", + "./ProviderRegistry.test.ts" + ] +} diff --git a/tests/validate-project.ps1 b/tests/validate-project.ps1 new file mode 100644 index 0000000..b7fb6a7 --- /dev/null +++ b/tests/validate-project.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot + +$tokens = $null +$errors = $null +[void][System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $root 'deployment\Add-PortalSettingsPage.ps1'), + [ref]$tokens, + [ref]$errors +) +if ($errors.Count -gt 0) { + throw ('PowerShell-Syntaxfehler: ' + $errors[0].Message) +} + +$jsonFiles = @( + 'package.json', + 'config\config.json', + 'config\package-solution.json', + 'config\serve.json', + 'src\webparts\portalSettings\PortalSettingsWebPart.manifest.json' +) +foreach ($relativePath in $jsonFiles) { + Get-Content -Raw -LiteralPath (Join-Path $root $relativePath) | ConvertFrom-Json | Out-Null +} + +Write-Host ("PortalSettings project validation passed: 1 PowerShell file and $($jsonFiles.Count) JSON files.") diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js new file mode 100644 index 0000000..302efcd --- /dev/null +++ b/tests/validate-static-assets.js @@ -0,0 +1,36 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var root = path.resolve(__dirname, '..'); +var passed = 0; + +function read(file) { return fs.readFileSync(path.join(root, file), 'utf8'); } +function json(file) { return JSON.parse(read(file)); } +function assert(condition, message) { if (!condition) { throw new Error(message); } passed++; } + +var packageJson = json('package.json'); +var solution = json('config/package-solution.json').solution; +var config = json('config/config.json'); +var manifest = json('src/webparts/portalSettings/PortalSettingsWebPart.manifest.json'); +var styles = read('src/webparts/portalSettings/PortalSettings.scss'); +var sourceFiles = [ + 'src/ui/Dom.ts', + 'src/ui/DynamicFormRenderer.ts', + '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(!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.'); +assert(!fs.existsSync(path.join(root, 'src/assets/PortalSettings.aspx')), 'Alte ASPX-Seite ist noch vorhanden.'); +assert(!fs.existsSync(path.join(root, 'src/extensions/portalSettings/PortalSettingsApplicationCustomizer.ts')), 'Alter Application Customizer ist noch vorhanden.'); +assert(sourceFiles.indexOf('innerHTML') < 0, 'UI darf innerHTML nicht verwenden.'); +['themePrimary', 'neutralPrimary', 'neutralLight', 'white'].forEach(function (slot) { + assert(styles.indexOf('[theme: ' + slot + ', default:') >= 0, 'Themeslot fehlt: ' + slot); +}); +assert(packageJson.scripts.package.indexOf('npm test') === 0, 'Paketierung startet nicht mit Tests.'); + +console.log('PortalSettings static tests passed: ' + passed);