From 10f746309402ddc59cc838ad2d2177a7e3b81ba4 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 21 Jul 2026 22:43:35 +0200 Subject: [PATCH 01/10] 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); -- 2.39.5 From 37382d1dc0ab923552b7722259010692af5cf253 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 14:16:42 +0200 Subject: [PATCH 02/10] =?UTF-8?q?Aktualisiere=20Versionsnummer=20auf=203.0?= =?UTF-8?q?.1=20in=20README.md,=20package.json,=20package-lock.json=20und?= =?UTF-8?q?=20package-solution.json.=20F=C3=BCge=20Unterst=C3=BCtzung=20f?= =?UTF-8?q?=C3=BCr=20zentrale=20Aktivierung=20von=20CustomBranding=20hinzu?= =?UTF-8?q?=20und=20verbessere=20die=20Logik=20zur=20Handhabung=20von=20Us?= =?UTF-8?q?erCustomActions.=20Aktualisiere=20Tests=20zur=20Validierung=20d?= =?UTF-8?q?er=20neuen=20Struktur.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- ToDo.md | 4 +- config/package-solution.json | 2 +- package-lock.json | 2 +- package.json | 2 +- src/core/ProviderContracts.ts | 10 +++ .../CustomBrandingSettingsProvider.ts | 26 +++++++- src/services/PortalSettingsDataService.ts | 66 ++++++++++++++++--- src/ui/PortalSettingsApp.ts | 41 +++++++++--- tests/ProviderRegistry.test.ts | 3 + tests/validate-static-assets.js | 4 +- 11 files changed, 138 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5ccb2ff..f7feb34 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.0.0 | +| Version | 3.0.1 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | @@ -29,6 +29,8 @@ Die frühere provisionierte `PortalSettings.aspx` und der leere Application Cust ### CustomBranding - zentrale `SPSite.UserCustomAction` +- Aktivierung der fehlenden Site-Collection-Action direkt aus PortalSettings +- versionierter `portalSettings`-Vertragsmarker mit Component-ID-Fallback - Aktivierung und Debug-Modus - CSS-Dateien inklusive `media` - Allowlist externer CSS-Hosts @@ -153,7 +155,7 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.0.0.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.0.1.0` ersetzen. 2. Die App im Root Web der gewünschten Site Collection installieren oder aktualisieren. 3. Eine moderne Seite erstellen. 4. Das WebPart **Portal Settings** einmal auf der Seite hinzufügen. diff --git a/ToDo.md b/ToDo.md index 1fe94fc..907729e 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.0.0 +Zielversion: 3.0.1 ## Architektur @@ -35,6 +35,8 @@ Zielversion: 3.0.0 - [x] MegaMenu vollständig auf Provider-Schema umgestellt. - [x] Termset, Modus, Cache, Cache-Version und Debug abgedeckt. - [x] CustomBranding vollständig auf Schema 2 abgebildet. +- [x] Fehlende CustomBranding-Site-Action kann beim ersten Speichern angelegt werden. +- [x] CustomBranding-Vertragsmarker und Component-ID-Fallback unterstützt. - [x] Branding-Sicherheitsprüfung vor dem Speichern. - [x] ExpiryIndicator-Site-Collection-Standard abgebildet. - [x] Expiry-Bindungsinventar dargestellt. diff --git a/config/package-solution.json b/config/package-solution.json index b38ad91..dfaf853 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.0.0.0", + "version": "3.0.1.0", "includeClientSideAssets": true, "skipFeatureDeployment": false }, diff --git a/package-lock.json b/package-lock.json index fff88c3..e012525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d1f9e7a..4eaa65c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.0", + "version": "3.0.1", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/core/ProviderContracts.ts b/src/core/ProviderContracts.ts index 260f5a2..806c2d3 100644 --- a/src/core/ProviderContracts.ts +++ b/src/core/ProviderContracts.ts @@ -59,6 +59,15 @@ export interface ISettingsSection { export interface IProviderStorageDefinition { kind: SettingsStorageKind; propertyKey?: string; + action?: IProviderActionDefinition; +} + +export interface IProviderActionDefinition { + name: string; + title: string; + description: string; + location: 'ClientSideExtension.ApplicationCustomizer'; + sequence: number; } export interface ISettingsProvider { @@ -97,6 +106,7 @@ export interface ILoadedSettingsProvider { config: any; // tslint:disable-line:no-any originalConfig: any; // tslint:disable-line:no-any context: IProviderLoadContext; + registered: boolean; dirty: boolean; } diff --git a/src/providers/CustomBrandingSettingsProvider.ts b/src/providers/CustomBrandingSettingsProvider.ts index 807e0ef..7872c88 100644 --- a/src/providers/CustomBrandingSettingsProvider.ts +++ b/src/providers/CustomBrandingSettingsProvider.ts @@ -2,6 +2,11 @@ import { ISettingsProvider } from '../core/ProviderContracts'; export interface ICustomBrandingSettings { schemaVersion: number; + portalSettings: { + providerKey: string; + contractVersion: number; + minimumPortalSettingsVersion: string; + }; enabled: boolean; debug: boolean; allowedCssHosts: string[]; @@ -19,7 +24,16 @@ export const CustomBrandingSettingsProvider: ISettingsProvider ({ schemaVersion: 2, + portalSettings: { + providerKey: 'custombranding', + contractVersion: 1, + minimumPortalSettingsVersion: '3.0.0' + }, enabled: true, debug: false, allowedCssHosts: [], @@ -82,6 +101,11 @@ export const CustomBrandingSettingsProvider: ISettingsProvider { result[key] = source[key]; }); result.schemaVersion = 2; + result.portalSettings = { + providerKey: 'custombranding', + contractVersion: 1, + minimumPortalSettingsVersion: '3.0.0' + }; result.enabled = source.enabled !== false; result.debug = source.debug === true; result.allowedCssHosts = Array.isArray(source.allowedCssHosts) ? source.allowedCssHosts : []; diff --git a/src/services/PortalSettingsDataService.ts b/src/services/PortalSettingsDataService.ts index 0ad09fe..de08a97 100644 --- a/src/services/PortalSettingsDataService.ts +++ b/src/services/PortalSettingsDataService.ts @@ -50,10 +50,14 @@ export class PortalSettingsDataService { if (errors.length > 0) { return Promise.reject(new Error(errors.join(' '))); } const merged: any = mergePreservingUnknown(item.originalConfig, item.provider.normalize(item.config)); if (item.provider.storage.kind === 'siteUserCustomAction') { - if (!item.context.action) { return Promise.reject(new Error('Die zentrale UserCustomAction wurde nicht gefunden.')); } - return this.saveUserCustomAction(item.context.action, merged).then((): void => { + const persist: Promise = item.context.action + ? this.saveUserCustomAction(item.context.action, merged).then((): IUserCustomActionInfo => item.context.action) + : this.createUserCustomAction(item.provider, merged); + return persist.then((action: IUserCustomActionInfo): void => { + item.context.action = action; item.config = item.provider.normalize(merged); item.originalConfig = cloneValue(merged); + item.registered = true; item.dirty = false; }); } @@ -181,9 +185,13 @@ export class PortalSettingsDataService { const context: IProviderLoadContext = {}; if (provider.storage.kind === 'siteUserCustomAction') { const action: IUserCustomActionInfo = this.findAction(provider, actions); - if (!action) { return undefined; } - context.action = action; - raw = this.parseJson(action.properties, {}); + if (!action && !provider.storage.action) { return undefined; } + if (action) { + context.action = action; + raw = this.parseJson(action.properties, {}); + } else { + raw = provider.createDefault(); + } } else { const propertyKey: string = provider.storage.propertyKey || ''; const stored: any = this.getProperty(properties, propertyKey); @@ -202,6 +210,7 @@ export class PortalSettingsDataService { config: cloneValue(normalized), originalConfig: cloneValue(raw || normalized), context: context, + registered: !!context.action || provider.storage.kind !== 'siteUserCustomAction', dirty: false }; } @@ -240,6 +249,39 @@ export class PortalSettingsDataService { }).then((response: SPHttpClientResponse): Promise => this.ensureOk(response, 'Konfiguration konnte nicht gespeichert werden.')); } + private createUserCustomAction(provider: ISettingsProvider, config: any): Promise { + const definition: any = provider.storage.action; + if (!definition || !provider.componentIds.length) { + return Promise.reject(new Error('Für diesen Provider ist keine zentrale Registrierung definiert.')); + } + const body: any = { + '__metadata': { 'type': 'SP.UserCustomAction' }, + 'Name': definition.name, + 'Title': definition.title, + 'Description': definition.description, + 'Location': definition.location, + 'Sequence': definition.sequence, + 'ClientSideComponentId': provider.componentIds[0], + 'ClientSideComponentProperties': JSON.stringify(config) + }; + return this.context.spHttpClient.post(this.siteUrl + '/_api/site/UserCustomActions', SPHttpClient.configurations.v1, { + headers: this.writeHeaders(), body: JSON.stringify(body) + }).then((response: SPHttpClientResponse): Promise => this.ensureJson(response, + 'CustomBranding konnte nicht für die Site Collection registriert werden.')) + .then((payload: any): IUserCustomActionInfo => { + const source: any = payload.d || payload; + return { + id: this.normalizeGuid(source.Id), + title: source.Title || definition.title, + name: source.Name || definition.name, + location: source.Location || definition.location, + componentId: this.normalizeGuid(source.ClientSideComponentId || provider.componentIds[0]), + properties: source.ClientSideComponentProperties || JSON.stringify(config), + entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.UserCustomAction' + }; + }); + } + private saveRootProperty(key: string, value: string): Promise { if (!key) { return Promise.reject(new Error('Der Property-Bag-Schlüssel fehlt.')); } const body: any = { '__metadata': { 'type': 'SP.PropertyValues' } }; @@ -251,7 +293,11 @@ export class PortalSettingsDataService { private findAction(provider: ISettingsProvider, actions: IUserCustomActionInfo[]): IUserCustomActionInfo | undefined { for (let i: number = 0; i < actions.length; i++) { - if (provider.componentIds.indexOf(actions[i].componentId) >= 0 && + const properties: any = this.parseJson(actions[i].properties, {}); + const providerKey: string = properties && properties.portalSettings + ? String(properties.portalSettings.providerKey || '').toLowerCase() + : ''; + if ((provider.componentIds.indexOf(actions[i].componentId) >= 0 || providerKey === provider.key.toLowerCase()) && actions[i].location.indexOf('ClientSideExtension.ApplicationCustomizer') >= 0) { return actions[i]; } @@ -321,12 +367,14 @@ export class PortalSettingsDataService { private encodePropertyName(value: string): string { return value.replace(/\./g, '_x002e_'); } private readHeaders(): any { return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; } - private writeHeaders(method: string): any { - return { + private writeHeaders(method?: string): any { + const headers: any = { 'Accept': 'application/json;odata=verbose', 'Content-Type': 'application/json;odata=verbose', - 'OData-Version': '3.0', 'IF-MATCH': '*', 'X-HTTP-Method': method + 'OData-Version': '3.0' }; + if (method) { headers['IF-MATCH'] = '*'; headers['X-HTTP-Method'] = method; } + return headers; } private ensureJson(response: SPHttpClientResponse, message: string): Promise { diff --git a/src/ui/PortalSettingsApp.ts b/src/ui/PortalSettingsApp.ts index 17b9aa9..b0153d2 100644 --- a/src/ui/PortalSettingsApp.ts +++ b/src/ui/PortalSettingsApp.ts @@ -58,7 +58,7 @@ export class PortalSettingsApp { const hero: HTMLElement = createElement('header', 'ps3-hero'); hero.appendChild(createElement('span', 'ps3-eyebrow', 'PORTAL SETTINGS V3')); hero.appendChild(createElement('h1', 'ps3-title', this.configuredTitle || 'Zentrale Verwaltung der Portal-Erweiterungen')); - hero.appendChild(createElement('p', 'ps3-lead', 'Installierte Solutions werden automatisch erkannt. Ihre versionierten Provider erzeugen strukturierte und validierte Einstellungsseiten.')); + hero.appendChild(createElement('p', 'ps3-lead', 'Registrierte Solutions werden automatisch erkannt. Unterstützte Provider können zentral aktiviert und über strukturierte, validierte Einstellungsseiten verwaltet werden.')); const meta: HTMLElement = createElement('div', 'ps3-heroMeta'); meta.appendChild(this.metric('Site Collection', this.dataService.getSiteUrl())); meta.appendChild(this.metric('Konfigurationsmodell', 'Providerbasiert')); @@ -78,7 +78,11 @@ export class PortalSettingsApp { this.items = items; this.activeKey = preferredKey && this.findItem(preferredKey) ? preferredKey : (items.length ? items[0].provider.key : ''); this.renderTabs(); this.renderActive(); - this.showStatus(items.length ? items.length + ' Erweiterung(en) wurden erkannt.' : 'Keine unterstützte Erweiterung wurde aktiv registriert.', items.length ? 'success' : 'warning'); + let registeredCount: number = 0; + for (let i: number = 0; i < items.length; i++) { if (items[i].registered) { registeredCount++; } } + this.showStatus(items.length + ? registeredCount + ' Erweiterung(en) registriert, ' + (items.length - registeredCount) + ' zur Aktivierung verfügbar.' + : 'Keine unterstützte Erweiterung wurde aktiv registriert.', registeredCount ? 'success' : 'warning'); }).catch((error: Error): void => { if (!this.disposed) { this.showStatus(error.message || String(error), 'danger', true); this.renderEmpty(true); } }); @@ -113,9 +117,18 @@ export class PortalSettingsApp { heading.appendChild(createElement('p', 'ps3-providerDescription', item.provider.description)); header.appendChild(heading); const storage: HTMLElement = createElement('div', 'ps3-storageBadge', - item.provider.storage.kind === 'siteUserCustomAction' ? 'Site-Collection-Action' : 'Root-Web-Standard'); + item.provider.storage.kind === 'siteUserCustomAction' + ? (item.registered ? 'Site-Collection-Action' : 'Bereit zur Aktivierung') + : 'Root-Web-Standard'); header.appendChild(storage); this.contentHost.appendChild(header); + if (!item.registered) { + const registrationNotice: HTMLElement = createElement('div', 'ps3-status ps3-status-warning'); + registrationNotice.setAttribute('role', 'status'); + registrationNotice.textContent = 'CustomBranding ist noch nicht zentral registriert. Beim ersten Speichern legt PortalSettings die Site-Collection-Action an. Das CustomBranding-Paket muss zuvor im App Catalog bereitgestellt sein.'; + this.contentHost.appendChild(registrationNotice); + } + const form: HTMLElement = createElement('div', 'ps3-form'); this.contentHost.appendChild(form); this.formRenderer.render(form, item, { changed: (): void => { this.updateSaveState(); this.renderTabs(); }, @@ -126,7 +139,8 @@ export class PortalSettingsApp { const actions: HTMLElement = createElement('div', 'ps3-actionBar'); const reset: HTMLButtonElement = createElement('button', 'ps3-button ps3-button-secondary', 'Änderungen verwerfen') as HTMLButtonElement; reset.type = 'button'; reset.addEventListener('click', (): void => this.resetCurrent()); - this.saveButton = createElement('button', 'ps3-button ps3-button-primary', 'Konfiguration speichern') as HTMLButtonElement; + this.saveButton = createElement('button', 'ps3-button ps3-button-primary', + item.registered ? 'Konfiguration speichern' : 'CustomBranding aktivieren') as HTMLButtonElement; this.saveButton.type = 'button'; this.saveButton.addEventListener('click', (): void => this.saveCurrent()); actions.appendChild(reset); actions.appendChild(this.saveButton); this.contentHost.appendChild(actions); this.updateSaveState(); @@ -138,19 +152,26 @@ export class PortalSettingsApp { empty.appendChild(createElement('h2', '', failed ? 'PortalSettings konnte nicht geladen werden' : 'Keine aktiven Provider gefunden')); empty.appendChild(createElement('p', '', failed ? 'Prüfen Sie die Statusmeldung und die REST-Berechtigungen.' - : 'MegaMenu und CustomBranding benötigen eine zentrale Site-Collection-Action. ExpiryIndicator wird über seinen Descriptor oder vorhandene Bindungen erkannt.')); + : 'MegaMenu wird über seine zentrale Site-Collection-Action erkannt. CustomBranding kann direkt aktiviert werden; ExpiryIndicator wird über seinen Descriptor oder vorhandene Bindungen erkannt.')); this.contentHost.appendChild(empty); } private saveCurrent(): void { const item: ILoadedSettingsProvider = this.currentItem(); - if (!item || !item.dirty) { return; } + if (!item || (item.registered && !item.dirty)) { return; } + const wasRegistered: boolean = item.registered; const errors: string[] = item.provider.validate(item.config); if (errors.length) { this.showStatus(errors.join(' '), 'danger', true); return; } - if (!window.confirm('Konfiguration für „' + item.provider.displayName + '“ speichern?')) { return; } + const question: string = wasRegistered + ? 'Konfiguration für „' + item.provider.displayName + '“ speichern?' + : 'CustomBranding zentral für diese Site Collection aktivieren?'; + if (!window.confirm(question)) { return; } this.saveButton.disabled = true; this.showStatus('Konfiguration wird gespeichert …', 'info', true); this.dataService.save(item).then((): void => { - this.showStatus('Die Konfiguration wurde erfolgreich gespeichert.', 'success'); this.renderTabs(); this.renderActive(); + this.showStatus(wasRegistered + ? 'Die Konfiguration wurde erfolgreich gespeichert.' + : 'CustomBranding wurde zentral aktiviert und gespeichert.', 'success'); + this.renderTabs(); this.renderActive(); }).catch((error: Error): void => { this.showStatus(error.message || String(error), 'danger', true); this.updateSaveState(); }); @@ -200,7 +221,7 @@ export class PortalSettingsApp { private updateSaveState(): void { const item: ILoadedSettingsProvider = this.currentItem(); - if (this.saveButton) { this.saveButton.disabled = !item || !item.dirty; } + if (this.saveButton) { this.saveButton.disabled = !item || (item.registered && !item.dirty); } } private openExpiryBindingDialog(item: ILoadedSettingsProvider): void { @@ -284,7 +305,7 @@ export class PortalSettingsApp { provider.sections = siteItem.provider.sections.filter(section => section.key === 'dates' || section.key === 'rules' || section.key === 'preview'); const editItem: ILoadedSettingsProvider = { provider: provider, config: provider.normalize(loaded.config), originalConfig: loaded.config, - context: {}, dirty: false + context: {}, registered: true, dirty: false }; const editor: DynamicFormRenderer = new DynamicFormRenderer(); editor.render(form, editItem, { diff --git a/tests/ProviderRegistry.test.ts b/tests/ProviderRegistry.test.ts index 2cf724d..4adbfa7 100644 --- a/tests/ProviderRegistry.test.ts +++ b/tests/ProviderRegistry.test.ts @@ -28,6 +28,9 @@ assert(normalizedMega.menuMode === 'megaMenu', 'MegaMenu Modus-Fallback fehlt.') assert(normalizedMega.unknownFuture === 'keep', 'Unbekannte MegaMenu-Properties gehen verloren.'); const branding: ISettingsProvider = getSettingsProvider('custombranding'); // tslint:disable-line:no-any +assert(!!branding.storage.action, 'CustomBranding-Aktivierungsdefinition fehlt.'); +assert(branding.storage.action.name === 'CustomBranding', 'CustomBranding Action-Name ist nicht stabil.'); +assert(branding.storage.action.location === 'ClientSideExtension.ApplicationCustomizer', 'CustomBranding Action-Location ist ungültig.'); const normalizedBranding: any = branding.normalize({ elements: [{ type: 'span' }], future: 7 }); // tslint:disable-line:no-any assert(normalizedBranding.placeholdertop.elements.length === 1, 'Legacy-Branding-Elemente werden nicht übernommen.'); assert(normalizedBranding.future === 7, 'Unbekannte Branding-Properties gehen verloren.'); diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 302efcd..472660d 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -20,8 +20,8 @@ var sourceFiles = [ 'src/ui/PortalSettingsApp.ts' ].map(read).join('\n'); -assert(packageJson.version === '3.0.0', 'Package-Version ist nicht 3.0.0.'); -assert(solution.version === '3.0.0.0', 'Solution-Version ist nicht 3.0.0.0.'); +assert(packageJson.version === '3.0.1', 'Package-Version ist nicht 3.0.1.'); +assert(solution.version === '3.0.1.0', 'Solution-Version ist nicht 3.0.1.0.'); assert(!solution.features, 'Alte ASPX-Feature-Assets sind noch registriert.'); assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.'); assert(manifest.componentType === 'WebPart', 'Komponente ist kein WebPart.'); -- 2.39.5 From ff12b9c127a8bad06b1f3cda4973e75d02bea3b1 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 14:32:00 +0200 Subject: [PATCH 03/10] =?UTF-8?q?F=C3=BCge=20neue=20Konfigurationsdatei=20?= =?UTF-8?q?copy-assets.json=20hinzu,=20um=20das=20Schema=20und=20den=20CDN?= =?UTF-8?q?-Pfad=20f=C3=BCr=20die=20Bereitstellung=20festzulegen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/copy-assets.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 config/copy-assets.json diff --git a/config/copy-assets.json b/config/copy-assets.json new file mode 100644 index 0000000..3771fd0 --- /dev/null +++ b/config/copy-assets.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json", + "deployCdnPath": "temp/deploy" +} -- 2.39.5 From 64a157f7a88db452cc5132b2dc5dee4ea66774da Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 14:53:03 +0200 Subject: [PATCH 04/10] =?UTF-8?q?Aktualisiere=20Versionsnummer=20auf=203.0?= =?UTF-8?q?.2=20in=20README.md,=20package.json,=20package-lock.json=20und?= =?UTF-8?q?=20package-solution.json.=20F=C3=BCge=20neue=20Eigenschaften=20?= =?UTF-8?q?in=20IMegaMenuSettings=20hinzu=20und=20verbessere=20die=20Logik?= =?UTF-8?q?=20zur=20Handhabung=20von=20CustomBranding.=20Aktualisiere=20Te?= =?UTF-8?q?sts=20zur=20Validierung=20der=20=C3=84nderungen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +-- ToDo.md | 2 +- config/package-solution.json | 2 +- package-lock.json | 2 +- package.json | 2 +- src/providers/MegaMenuSettingsProvider.ts | 29 ++++++++++++++++++- src/services/PortalSettingsDataService.ts | 2 +- src/ui/PortalSettingsApp.ts | 10 +++---- .../portalSettings/PortalSettings.scss | 1 - tests/ProviderRegistry.test.ts | 4 +++ tests/validate-project.ps1 | 1 + tests/validate-static-assets.js | 6 ++-- 12 files changed, 49 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f7feb34..3753a29 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.0.1 | +| Version | 3.0.2 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | @@ -155,7 +155,7 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.0.1.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.0.2.0` ersetzen. 2. Die App im Root Web der gewünschten Site Collection installieren oder aktualisieren. 3. Eine moderne Seite erstellen. 4. Das WebPart **Portal Settings** einmal auf der Seite hinzufügen. diff --git a/ToDo.md b/ToDo.md index 907729e..dbd7d66 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.0.1 +Zielversion: 3.0.2 ## Architektur diff --git a/config/package-solution.json b/config/package-solution.json index dfaf853..31725ee 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.0.1.0", + "version": "3.0.2.0", "includeClientSideAssets": true, "skipFeatureDeployment": false }, diff --git a/package-lock.json b/package-lock.json index e012525..f95df1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.1", + "version": "3.0.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 4eaa65c..c074eb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.1", + "version": "3.0.2", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/providers/MegaMenuSettingsProvider.ts b/src/providers/MegaMenuSettingsProvider.ts index e4d1a6c..44acdd4 100644 --- a/src/providers/MegaMenuSettingsProvider.ts +++ b/src/providers/MegaMenuSettingsProvider.ts @@ -1,6 +1,12 @@ import { ISettingsProvider } from '../core/ProviderContracts'; export interface IMegaMenuSettings { + schemaVersion: number; + portalSettings: { + providerKey: string; + contractVersion: number; + minimumPortalSettingsVersion: string; + }; termSetId: string; termSetName: string; cacheMinutes: number; @@ -21,7 +27,16 @@ export const MegaMenuSettingsProvider: ISettingsProvider = { 'c0abbffb-355d-4d4e-bd38-9e15fb811506', 'abc3361f-bb2d-491f-aba3-cd51c19a299b' ], - storage: { kind: 'siteUserCustomAction' }, + storage: { + kind: 'siteUserCustomAction', + action: { + name: 'MegaMenu', + title: 'MegaMenu Application Customizer', + description: 'MSFT-Custom-Solution:MegaMenu', + location: 'ClientSideExtension.ApplicationCustomizer', + sequence: 100 + } + }, sections: [ { key: 'navigation', @@ -89,6 +104,12 @@ export const MegaMenuSettingsProvider: ISettingsProvider = { } ], createDefault: (): IMegaMenuSettings => ({ + schemaVersion: 1, + portalSettings: { + providerKey: 'megamenu', + contractVersion: 1, + minimumPortalSettingsVersion: '3.0.0' + }, termSetId: '', termSetName: '', cacheMinutes: 15, cacheVersion: '1', menuMode: 'megaMenu', debug: false }), normalize: (value: any): IMegaMenuSettings => { // tslint:disable-line:no-any @@ -96,6 +117,12 @@ export const MegaMenuSettingsProvider: ISettingsProvider = { 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.schemaVersion = 1; + result.portalSettings = { + providerKey: 'megamenu', + contractVersion: 1, + minimumPortalSettingsVersion: '3.0.0' + }; 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; diff --git a/src/services/PortalSettingsDataService.ts b/src/services/PortalSettingsDataService.ts index de08a97..eb22779 100644 --- a/src/services/PortalSettingsDataService.ts +++ b/src/services/PortalSettingsDataService.ts @@ -267,7 +267,7 @@ export class PortalSettingsDataService { return this.context.spHttpClient.post(this.siteUrl + '/_api/site/UserCustomActions', SPHttpClient.configurations.v1, { headers: this.writeHeaders(), body: JSON.stringify(body) }).then((response: SPHttpClientResponse): Promise => this.ensureJson(response, - 'CustomBranding konnte nicht für die Site Collection registriert werden.')) + provider.displayName + ' konnte nicht für die Site Collection registriert werden.')) .then((payload: any): IUserCustomActionInfo => { const source: any = payload.d || payload; return { diff --git a/src/ui/PortalSettingsApp.ts b/src/ui/PortalSettingsApp.ts index b0153d2..78815ec 100644 --- a/src/ui/PortalSettingsApp.ts +++ b/src/ui/PortalSettingsApp.ts @@ -125,7 +125,7 @@ export class PortalSettingsApp { if (!item.registered) { const registrationNotice: HTMLElement = createElement('div', 'ps3-status ps3-status-warning'); registrationNotice.setAttribute('role', 'status'); - registrationNotice.textContent = 'CustomBranding ist noch nicht zentral registriert. Beim ersten Speichern legt PortalSettings die Site-Collection-Action an. Das CustomBranding-Paket muss zuvor im App Catalog bereitgestellt sein.'; + registrationNotice.textContent = item.provider.displayName + ' ist noch nicht zentral registriert. Beim ersten Speichern legt PortalSettings die Site-Collection-Action an. Das zugehörige SPFx-Paket muss zuvor im App Catalog bereitgestellt sein.'; this.contentHost.appendChild(registrationNotice); } @@ -140,7 +140,7 @@ export class PortalSettingsApp { 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', - item.registered ? 'Konfiguration speichern' : 'CustomBranding aktivieren') as HTMLButtonElement; + item.registered ? 'Konfiguration speichern' : item.provider.displayName + ' aktivieren') as HTMLButtonElement; this.saveButton.type = 'button'; this.saveButton.addEventListener('click', (): void => this.saveCurrent()); actions.appendChild(reset); actions.appendChild(this.saveButton); this.contentHost.appendChild(actions); this.updateSaveState(); @@ -152,7 +152,7 @@ export class PortalSettingsApp { empty.appendChild(createElement('h2', '', failed ? 'PortalSettings konnte nicht geladen werden' : 'Keine aktiven Provider gefunden')); empty.appendChild(createElement('p', '', failed ? 'Prüfen Sie die Statusmeldung und die REST-Berechtigungen.' - : 'MegaMenu wird über seine zentrale Site-Collection-Action erkannt. CustomBranding kann direkt aktiviert werden; ExpiryIndicator wird über seinen Descriptor oder vorhandene Bindungen erkannt.')); + : 'MegaMenu und CustomBranding können direkt für die Site Collection aktiviert werden; ExpiryIndicator wird über seinen Descriptor oder vorhandene Bindungen erkannt.')); this.contentHost.appendChild(empty); } @@ -164,13 +164,13 @@ export class PortalSettingsApp { if (errors.length) { this.showStatus(errors.join(' '), 'danger', true); return; } const question: string = wasRegistered ? 'Konfiguration für „' + item.provider.displayName + '“ speichern?' - : 'CustomBranding zentral für diese Site Collection aktivieren?'; + : item.provider.displayName + ' zentral für diese Site Collection aktivieren?'; if (!window.confirm(question)) { return; } this.saveButton.disabled = true; this.showStatus('Konfiguration wird gespeichert …', 'info', true); this.dataService.save(item).then((): void => { this.showStatus(wasRegistered ? 'Die Konfiguration wurde erfolgreich gespeichert.' - : 'CustomBranding wurde zentral aktiviert und gespeichert.', 'success'); + : item.provider.displayName + ' wurde zentral aktiviert und gespeichert.', 'success'); this.renderTabs(); this.renderActive(); }).catch((error: Error): void => { this.showStatus(error.message || String(error), 'danger', true); this.updateSaveState(); diff --git a/src/webparts/portalSettings/PortalSettings.scss b/src/webparts/portalSettings/PortalSettings.scss index 1e9755b..397ed49 100644 --- a/src/webparts/portalSettings/PortalSettings.scss +++ b/src/webparts/portalSettings/PortalSettings.scss @@ -18,7 +18,6 @@ $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; } diff --git a/tests/ProviderRegistry.test.ts b/tests/ProviderRegistry.test.ts index 4adbfa7..8e46f67 100644 --- a/tests/ProviderRegistry.test.ts +++ b/tests/ProviderRegistry.test.ts @@ -21,10 +21,14 @@ providers.forEach((provider: ISettingsProvider): void => { // tslint:disabl }); const mega: ISettingsProvider = getSettingsProvider('megamenu'); // tslint:disable-line:no-any +assert(!!mega.storage.action, 'MegaMenu-Aktivierungsdefinition fehlt.'); +assert(mega.storage.action.name === 'MegaMenu', 'MegaMenu Action-Name ist nicht stabil.'); +assert(mega.storage.action.location === 'ClientSideExtension.ApplicationCustomizer', 'MegaMenu Action-Location ist ungültig.'); 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.portalSettings.providerKey === 'megamenu', 'MegaMenu-Vertragsmarker fehlt.'); assert(normalizedMega.unknownFuture === 'keep', 'Unbekannte MegaMenu-Properties gehen verloren.'); const branding: ISettingsProvider = getSettingsProvider('custombranding'); // tslint:disable-line:no-any diff --git a/tests/validate-project.ps1 b/tests/validate-project.ps1 index b7fb6a7..a0e0f46 100644 --- a/tests/validate-project.ps1 +++ b/tests/validate-project.ps1 @@ -15,6 +15,7 @@ if ($errors.Count -gt 0) { $jsonFiles = @( 'package.json', 'config\config.json', + 'config\copy-assets.json', 'config\package-solution.json', 'config\serve.json', 'src\webparts\portalSettings\PortalSettingsWebPart.manifest.json' diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 472660d..60ddb4e 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -12,6 +12,7 @@ function assert(condition, message) { if (!condition) { throw new Error(message) var packageJson = json('package.json'); var solution = json('config/package-solution.json').solution; var config = json('config/config.json'); +var copyAssets = json('config/copy-assets.json'); var manifest = json('src/webparts/portalSettings/PortalSettingsWebPart.manifest.json'); var styles = read('src/webparts/portalSettings/PortalSettings.scss'); var sourceFiles = [ @@ -20,10 +21,11 @@ var sourceFiles = [ 'src/ui/PortalSettingsApp.ts' ].map(read).join('\n'); -assert(packageJson.version === '3.0.1', 'Package-Version ist nicht 3.0.1.'); -assert(solution.version === '3.0.1.0', 'Solution-Version ist nicht 3.0.1.0.'); +assert(packageJson.version === '3.0.2', 'Package-Version ist nicht 3.0.2.'); +assert(solution.version === '3.0.2.0', 'Solution-Version ist nicht 3.0.2.0.'); assert(!solution.features, 'Alte ASPX-Feature-Assets sind noch registriert.'); assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.'); +assert(copyAssets.deployCdnPath === 'temp/deploy', 'Ship-Assets werden nicht nach temp/deploy geschrieben.'); 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.'); -- 2.39.5 From eef1ac73941541658f8b449e9a9d72cc896a529a Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 15:56:41 +0200 Subject: [PATCH 05/10] Add default PortalSettings footer link --- README.md | 4 ++-- ToDo.md | 2 +- config/package-solution.json | 2 +- package-lock.json | 2 +- package.json | 2 +- src/providers/CustomBrandingSettingsProvider.ts | 16 +++++++++++++++- tests/ProviderRegistry.test.ts | 4 ++++ tests/validate-static-assets.js | 4 ++-- 8 files changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3753a29..0b936c8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.0.2 | +| Version | 3.0.3 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | @@ -155,7 +155,7 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.0.2.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.0.3.0` ersetzen. 2. Die App im Root Web der gewünschten Site Collection installieren oder aktualisieren. 3. Eine moderne Seite erstellen. 4. Das WebPart **Portal Settings** einmal auf der Seite hinzufügen. diff --git a/ToDo.md b/ToDo.md index dbd7d66..0c19ba3 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.0.2 +Zielversion: 3.0.3 ## Architektur diff --git a/config/package-solution.json b/config/package-solution.json index 31725ee..ef20a2d 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.0.2.0", + "version": "3.0.3.0", "includeClientSideAssets": true, "skipFeatureDeployment": false }, diff --git a/package-lock.json b/package-lock.json index f95df1c..5057ad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.2", + "version": "3.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index c074eb9..8c1f15c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.2", + "version": "3.0.3", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/providers/CustomBrandingSettingsProvider.ts b/src/providers/CustomBrandingSettingsProvider.ts index 7872c88..09b0a03 100644 --- a/src/providers/CustomBrandingSettingsProvider.ts +++ b/src/providers/CustomBrandingSettingsProvider.ts @@ -94,7 +94,21 @@ export const CustomBrandingSettingsProvider: ISettingsProvider { // tslint:disable-line:no-any const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any diff --git a/tests/ProviderRegistry.test.ts b/tests/ProviderRegistry.test.ts index 8e46f67..451887d 100644 --- a/tests/ProviderRegistry.test.ts +++ b/tests/ProviderRegistry.test.ts @@ -35,6 +35,10 @@ const branding: ISettingsProvider = getSettingsProvider('custombranding'); assert(!!branding.storage.action, 'CustomBranding-Aktivierungsdefinition fehlt.'); assert(branding.storage.action.name === 'CustomBranding', 'CustomBranding Action-Name ist nicht stabil.'); assert(branding.storage.action.location === 'ClientSideExtension.ApplicationCustomizer', 'CustomBranding Action-Location ist ungültig.'); +const defaultBranding: any = branding.createDefault(); // tslint:disable-line:no-any +assert(defaultBranding.placeholderbottom.elements.length === 1, 'Standard-Footer für CustomBranding fehlt.'); +assert(defaultBranding.placeholderbottom.elements[0].children[0].attributes.href === + '~sitecollection/SitePages/PortalSettings.aspx', 'Standard-Footer verweist nicht auf PortalSettings.'); const normalizedBranding: any = branding.normalize({ elements: [{ type: 'span' }], future: 7 }); // tslint:disable-line:no-any assert(normalizedBranding.placeholdertop.elements.length === 1, 'Legacy-Branding-Elemente werden nicht übernommen.'); assert(normalizedBranding.future === 7, 'Unbekannte Branding-Properties gehen verloren.'); diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 60ddb4e..ee5376b 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -21,8 +21,8 @@ var sourceFiles = [ 'src/ui/PortalSettingsApp.ts' ].map(read).join('\n'); -assert(packageJson.version === '3.0.2', 'Package-Version ist nicht 3.0.2.'); -assert(solution.version === '3.0.2.0', 'Solution-Version ist nicht 3.0.2.0.'); +assert(packageJson.version === '3.0.3', 'Package-Version ist nicht 3.0.3.'); +assert(solution.version === '3.0.3.0', 'Solution-Version ist nicht 3.0.3.0.'); assert(!solution.features, 'Alte ASPX-Feature-Assets sind noch registriert.'); assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.'); assert(copyAssets.deployCdnPath === 'temp/deploy', 'Ship-Assets werden nicht nach temp/deploy geschrieben.'); -- 2.39.5 From beaa3ce21d2e8210bfe33f23d21d95db0eef3a75 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 16:19:08 +0200 Subject: [PATCH 06/10] Aktualisiere Versionsnummer auf 3.0.4 in README.md, package.json, package-lock.json und package-solution.json. Entferne Standard-Footer aus CustomBranding und passe Tests entsprechend an. --- README.md | 4 ++-- ToDo.md | 2 +- config/package-solution.json | 2 +- package-lock.json | 2 +- package.json | 2 +- src/providers/CustomBrandingSettingsProvider.ts | 16 +--------------- tests/ProviderRegistry.test.ts | 5 ++--- tests/validate-static-assets.js | 4 ++-- 8 files changed, 11 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 0b936c8..41d7aad 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.0.3 | +| Version | 3.0.4 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | @@ -155,7 +155,7 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.0.3.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.0.4.0` ersetzen. 2. Die App im Root Web der gewünschten Site Collection installieren oder aktualisieren. 3. Eine moderne Seite erstellen. 4. Das WebPart **Portal Settings** einmal auf der Seite hinzufügen. diff --git a/ToDo.md b/ToDo.md index 0c19ba3..7f578eb 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.0.3 +Zielversion: 3.0.4 ## Architektur diff --git a/config/package-solution.json b/config/package-solution.json index ef20a2d..0cd202b 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.0.3.0", + "version": "3.0.4.0", "includeClientSideAssets": true, "skipFeatureDeployment": false }, diff --git a/package-lock.json b/package-lock.json index 5057ad8..d855362 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.3", + "version": "3.0.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8c1f15c..6bb17c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.3", + "version": "3.0.4", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/providers/CustomBrandingSettingsProvider.ts b/src/providers/CustomBrandingSettingsProvider.ts index 09b0a03..7872c88 100644 --- a/src/providers/CustomBrandingSettingsProvider.ts +++ b/src/providers/CustomBrandingSettingsProvider.ts @@ -94,21 +94,7 @@ export const CustomBrandingSettingsProvider: ISettingsProvider { // tslint:disable-line:no-any const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any diff --git a/tests/ProviderRegistry.test.ts b/tests/ProviderRegistry.test.ts index 451887d..4a5bd25 100644 --- a/tests/ProviderRegistry.test.ts +++ b/tests/ProviderRegistry.test.ts @@ -36,9 +36,8 @@ assert(!!branding.storage.action, 'CustomBranding-Aktivierungsdefinition fehlt.' assert(branding.storage.action.name === 'CustomBranding', 'CustomBranding Action-Name ist nicht stabil.'); assert(branding.storage.action.location === 'ClientSideExtension.ApplicationCustomizer', 'CustomBranding Action-Location ist ungültig.'); const defaultBranding: any = branding.createDefault(); // tslint:disable-line:no-any -assert(defaultBranding.placeholderbottom.elements.length === 1, 'Standard-Footer für CustomBranding fehlt.'); -assert(defaultBranding.placeholderbottom.elements[0].children[0].attributes.href === - '~sitecollection/SitePages/PortalSettings.aspx', 'Standard-Footer verweist nicht auf PortalSettings.'); +assert(defaultBranding.placeholderbottom.elements.length === 0, + 'PortalSettings darf keinen Runtime-Standard-Footer in die Konfiguration schreiben.'); const normalizedBranding: any = branding.normalize({ elements: [{ type: 'span' }], future: 7 }); // tslint:disable-line:no-any assert(normalizedBranding.placeholdertop.elements.length === 1, 'Legacy-Branding-Elemente werden nicht übernommen.'); assert(normalizedBranding.future === 7, 'Unbekannte Branding-Properties gehen verloren.'); diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index ee5376b..1628d6d 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -21,8 +21,8 @@ var sourceFiles = [ 'src/ui/PortalSettingsApp.ts' ].map(read).join('\n'); -assert(packageJson.version === '3.0.3', 'Package-Version ist nicht 3.0.3.'); -assert(solution.version === '3.0.3.0', 'Solution-Version ist nicht 3.0.3.0.'); +assert(packageJson.version === '3.0.4', 'Package-Version ist nicht 3.0.4.'); +assert(solution.version === '3.0.4.0', 'Solution-Version ist nicht 3.0.4.0.'); assert(!solution.features, 'Alte ASPX-Feature-Assets sind noch registriert.'); assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.'); assert(copyAssets.deployCdnPath === 'temp/deploy', 'Ship-Assets werden nicht nach temp/deploy geschrieben.'); -- 2.39.5 From c0e7c316e31614e9292b69a0450b3ff850d9e725 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 17:13:33 +0200 Subject: [PATCH 07/10] =?UTF-8?q?Aktualisiere=20Versionsnummer=20auf=203.1?= =?UTF-8?q?.0=20in=20README.md,=20package.json,=20package-lock.json=20und?= =?UTF-8?q?=20package-solution.json.=20F=C3=BCge=20CurrentNavigationSettin?= =?UTF-8?q?gsProvider=20hinzu=20und=20integriere=20zugeh=C3=B6rige=20Tests?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +- ToDo.md | 3 +- config/package-solution.json | 2 +- package-lock.json | 2 +- package.json | 2 +- .../CurrentNavigationSettingsProvider.ts | 135 ++++++++++++++++++ src/providers/ProviderRegistry.ts | 2 + tests/ProviderRegistry.test.ts | 19 ++- tests/validate-static-assets.js | 4 +- 9 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 src/providers/CurrentNavigationSettingsProvider.ts diff --git a/README.md b/README.md index 41d7aad..336088a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.0.4 | +| Version | 3.1.0 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | @@ -26,6 +26,14 @@ Die frühere provisionierte `PortalSettings.aspx` und der leere Application Cust - Debug-Modus - schemaerhaltendes Speichern unbekannter Properties +### CurrentNavigation + +- Auswahl des Navigationstermsets mit demselben Termset-Picker wie beim MegaMenu +- Cache-Dauer, Cache-Version und Debug-Modus +- maximale Navigationstiefe und optionale Anzeige des Root-Terms +- direkte Aktivierung als zentrale SPSite.UserCustomAction +- Hinweise zu CurrentNavigation.Root und CurrentNavigation.Hidden + ### CustomBranding - zentrale `SPSite.UserCustomAction` @@ -56,6 +64,7 @@ PortalSettingsWebPart │ ├── ProviderRegistry │ ├── MegaMenuSettingsProvider +│ ├── CurrentNavigationSettingsProvider │ ├── CustomBrandingSettingsProvider │ └── ExpiryIndicatorSettingsProvider │ @@ -155,7 +164,7 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.0.4.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.1.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. diff --git a/ToDo.md b/ToDo.md index 7f578eb..b346713 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.0.4 +Zielversion: 3.1.0 ## Architektur @@ -33,6 +33,7 @@ Zielversion: 3.0.4 ## Provider - [x] MegaMenu vollständig auf Provider-Schema umgestellt. +- [x] CurrentNavigation mit Termset-Picker, Cache, Debug und Aktivierung integriert. - [x] Termset, Modus, Cache, Cache-Version und Debug abgedeckt. - [x] CustomBranding vollständig auf Schema 2 abgebildet. - [x] Fehlende CustomBranding-Site-Action kann beim ersten Speichern angelegt werden. diff --git a/config/package-solution.json b/config/package-solution.json index 0cd202b..a5509dd 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.0.4.0", + "version": "3.1.0.0", "includeClientSideAssets": true, "skipFeatureDeployment": false }, diff --git a/package-lock.json b/package-lock.json index d855362..1f0ad71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.4", + "version": "3.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6bb17c6..23dde93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.0.4", + "version": "3.1.0", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/providers/CurrentNavigationSettingsProvider.ts b/src/providers/CurrentNavigationSettingsProvider.ts new file mode 100644 index 0000000..8e91842 --- /dev/null +++ b/src/providers/CurrentNavigationSettingsProvider.ts @@ -0,0 +1,135 @@ +import { ISettingsProvider } from '../core/ProviderContracts'; + +export interface ICurrentNavigationSettings { + schemaVersion: number; + portalSettings: { + providerKey: string; + contractVersion: number; + minimumPortalSettingsVersion: string; + }; + termSetId: string; + termSetName: string; + cacheMinutes: number; + cacheVersion: string; + maxDepth: number; + showRoot: boolean; + debug: boolean; + [key: string]: any; // tslint:disable-line:no-any +} + +export const CurrentNavigationSettingsProvider: ISettingsProvider = { + key: 'currentnavigation', + displayName: 'Current Navigation', + description: 'Navigation für das jeweils aktuelle Root Web oder Subweb.', + iconName: 'BulletedTreeList', + schemaVersion: 1, + minimumPortalSettingsVersion: '3.1.0', + componentIds: ['e37b828f-4315-461a-b1b0-fcf991deda3d'], + storage: { + kind: 'siteUserCustomAction', + action: { + name: 'CurrentNavigation', + title: 'Current Navigation Application Customizer', + description: 'MSFT-Custom-Solution:CurrentNavigation', + location: 'ClientSideExtension.ApplicationCustomizer', + sequence: 110 + } + }, + sections: [ + { + key: 'navigation', title: 'Navigation', iconName: 'BranchFork', layout: 'twoColumns', + description: 'Termset und Darstellung der Navigation für die aktuelle Website.', + elements: [ + { + type: 'termSet', key: 'termSetId', path: 'termSetId', companionNamePath: 'termSetName', + label: 'Navigations-Termset', description: 'Das Termset enthält die Root- und Navigationsterms.', + required: true, columnSpan: 12 + }, + { + type: 'number', key: 'maxDepth', path: 'maxDepth', label: 'Maximale Tiefe', + description: 'Anzahl der gerenderten Ebenen ab dem Root-Term.', minimum: 1, maximum: 8, + suffix: 'Ebenen', columnSpan: 6 + }, + { + type: 'boolean', key: 'showRoot', path: 'showRoot', label: 'Root-Term anzeigen', + description: 'Zeigt den zugeordneten Root-Term als erste Ebene an.', columnSpan: 6 + }, + { + type: 'notice', appearance: 'info', title: 'Local Custom Properties', columnSpan: 12, + text: 'CurrentNavigation.Root = true ordnet einen Term über seine URL einer Website zu. CurrentNavigation.Hidden = true blendet einen Zweig aus.' + } + ] + }, + { + key: 'performance', title: 'Performance und Cache', iconName: 'SpeedHigh', layout: 'twoColumns', + 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 + }] + }, + { + key: 'preview', title: 'Zusammenfassung', layout: 'singleColumn', + elements: [ + { type: 'propertySummary', title: 'Aktuelle Werte', columnSpan: 12 }, + { type: 'jsonPreview', title: 'ClientSideComponentProperties', collapsedByDefault: true, columnSpan: 12 } + ] + } + ], + createDefault: (): ICurrentNavigationSettings => ({ + schemaVersion: 1, + portalSettings: { + providerKey: 'currentnavigation', contractVersion: 1, minimumPortalSettingsVersion: '3.1.0' + }, + termSetId: '', termSetName: '', cacheMinutes: 15, cacheVersion: '1', + maxDepth: 3, showRoot: false, debug: false + }), + normalize: (value: any): ICurrentNavigationSettings => { // 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]; }); + const minutes: number = Number(source.cacheMinutes); + const depth: number = Number(source.maxDepth); + result.schemaVersion = 1; + result.portalSettings = { + providerKey: 'currentnavigation', contractVersion: 1, minimumPortalSettingsVersion: '3.1.0' + }; + 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.maxDepth = isFinite(depth) ? Math.min(Math.max(Math.floor(depth), 1), 8) : 3; + result.showRoot = source.showRoot === true; + result.debug = source.debug === true; + return result as ICurrentNavigationSettings; + }, + validate: (value: ICurrentNavigationSettings): string[] => { + const errors: string[] = []; + if (!value.termSetId) { 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.'); + } + if (value.maxDepth < 1 || value.maxDepth > 8) { + errors.push('Die maximale Tiefe muss zwischen 1 und 8 liegen.'); + } + return errors; + } +}; diff --git a/src/providers/ProviderRegistry.ts b/src/providers/ProviderRegistry.ts index 666de7e..4ed0c56 100644 --- a/src/providers/ProviderRegistry.ts +++ b/src/providers/ProviderRegistry.ts @@ -1,10 +1,12 @@ import { ISettingsProvider } from '../core/ProviderContracts'; import { CustomBrandingSettingsProvider } from './CustomBrandingSettingsProvider'; +import { CurrentNavigationSettingsProvider } from './CurrentNavigationSettingsProvider'; import { ExpiryIndicatorSettingsProvider } from './ExpiryIndicatorSettingsProvider'; import { MegaMenuSettingsProvider } from './MegaMenuSettingsProvider'; const Providers: Array> = [ // tslint:disable-line:no-any MegaMenuSettingsProvider, + CurrentNavigationSettingsProvider, CustomBrandingSettingsProvider, ExpiryIndicatorSettingsProvider ]; diff --git a/tests/ProviderRegistry.test.ts b/tests/ProviderRegistry.test.ts index 4a5bd25..3678b41 100644 --- a/tests/ProviderRegistry.test.ts +++ b/tests/ProviderRegistry.test.ts @@ -9,8 +9,9 @@ function assert(condition: boolean, message: string): void { } const providers: Array> = getSettingsProviders(); // tslint:disable-line:no-any -assert(providers.length === 3, 'Genau drei Provider werden erwartet.'); +assert(providers.length === 4, 'Genau vier Provider werden erwartet.'); assert(!!getSettingsProvider('megamenu'), 'MegaMenu-Provider fehlt.'); +assert(!!getSettingsProvider('currentnavigation'), 'CurrentNavigation-Provider fehlt.'); assert(!!getSettingsProvider('custombranding'), 'CustomBranding-Provider fehlt.'); assert(!!getSettingsProvider('expiryindicator'), 'ExpiryIndicator-Provider fehlt.'); @@ -31,6 +32,22 @@ assert(normalizedMega.menuMode === 'megaMenu', 'MegaMenu Modus-Fallback fehlt.') assert(normalizedMega.portalSettings.providerKey === 'megamenu', 'MegaMenu-Vertragsmarker fehlt.'); assert(normalizedMega.unknownFuture === 'keep', 'Unbekannte MegaMenu-Properties gehen verloren.'); +const current: ISettingsProvider = getSettingsProvider('currentnavigation'); // tslint:disable-line:no-any +assert(!!current.storage.action, 'CurrentNavigation-Aktivierungsdefinition fehlt.'); +assert(current.storage.action.name === 'CurrentNavigation', 'CurrentNavigation Action-Name ist nicht stabil.'); +assert(current.storage.action.sequence === 110, 'CurrentNavigation Action-Sequenz ist nicht stabil.'); +const normalizedCurrent: any = current.normalize({ // tslint:disable-line:no-any + cacheMinutes: 5000, cacheVersion: '', maxDepth: 99, showRoot: true, future: 'keep' +}); +assert(normalizedCurrent.cacheMinutes === 1440, 'CurrentNavigation Cache-Maximum greift nicht.'); +assert(normalizedCurrent.cacheVersion === '1', 'CurrentNavigation Cache-Fallback fehlt.'); +assert(normalizedCurrent.maxDepth === 8, 'CurrentNavigation Tiefenlimit greift nicht.'); +assert(normalizedCurrent.showRoot === true, 'CurrentNavigation Root-Anzeige geht verloren.'); +assert(normalizedCurrent.future === 'keep', 'Unbekannte CurrentNavigation-Properties gehen verloren.'); +const invalidCurrent: any = current.createDefault(); // tslint:disable-line:no-any +invalidCurrent.termSetName = 'Nur ein Name'; +assert(current.validate(invalidCurrent).length > 0, 'CurrentNavigation muss eine Termset-ID verlangen.'); + const branding: ISettingsProvider = getSettingsProvider('custombranding'); // tslint:disable-line:no-any assert(!!branding.storage.action, 'CustomBranding-Aktivierungsdefinition fehlt.'); assert(branding.storage.action.name === 'CustomBranding', 'CustomBranding Action-Name ist nicht stabil.'); diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 1628d6d..5990c5e 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -21,8 +21,8 @@ var sourceFiles = [ 'src/ui/PortalSettingsApp.ts' ].map(read).join('\n'); -assert(packageJson.version === '3.0.4', 'Package-Version ist nicht 3.0.4.'); -assert(solution.version === '3.0.4.0', 'Solution-Version ist nicht 3.0.4.0.'); +assert(packageJson.version === '3.1.0', 'Package-Version ist nicht 3.1.0.'); +assert(solution.version === '3.1.0.0', 'Solution-Version ist nicht 3.1.0.0.'); assert(!solution.features, 'Alte ASPX-Feature-Assets sind noch registriert.'); assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.'); assert(copyAssets.deployCdnPath === 'temp/deploy', 'Ship-Assets werden nicht nach temp/deploy geschrieben.'); -- 2.39.5 From eb1ad7ba1ea582341671197d057e2b59074976bd Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 21:06:28 +0200 Subject: [PATCH 08/10] =?UTF-8?q?Aktualisiere=20Versionsnummer=20auf=203.2?= =?UTF-8?q?.0=20in=20README.md,=20package.json,=20package-lock.json=20und?= =?UTF-8?q?=20package-solution.json.=20F=C3=BCge=20Provisionierungsfeature?= =?UTF-8?q?=20f=C3=BCr=20moderne=20PortalSettings.aspx=20hinzu=20und=20int?= =?UTF-8?q?egriere=20zugeh=C3=B6rige=20Tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++++++++++------------- ToDo.md | 7 ++++--- config/package-solution.json | 17 +++++++++++++++-- deployment/Add-PortalSettingsPage.ps1 | 2 +- package-lock.json | 2 +- package.json | 2 +- sharepoint/assets/PortalSettings.aspx | 16 ++++++++++++++++ sharepoint/assets/elements.xml | 14 ++++++++++++++ sharepoint/assets/upgrade-actions.xml | 3 +++ tests/validate-static-assets.js | 23 ++++++++++++++++++++--- 10 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 sharepoint/assets/PortalSettings.aspx create mode 100644 sharepoint/assets/elements.xml create mode 100644 sharepoint/assets/upgrade-actions.xml diff --git a/README.md b/README.md index 336088a..ae94563 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.1.0 | +| Version | 3.2.0 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | | Gulp | lokal 3.9.1 | | Komponente | Client-Side WebPart | -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`. +Die Oberfläche wird vollständig vom WebPart erzeugt und verwendet kein `innerHTML`. Ein schlankes SharePoint-Feature provisioniert beim Installieren der App im Root-Web automatisch die moderne `SitePages/PortalSettings.aspx` und platziert dort genau eine Instanz des WebParts. ## Unterstützte Solutions @@ -164,22 +164,20 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.1.0.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.2.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. +3. Die App provisioniert automatisch die veröffentlichte Seite `SitePages/PortalSettings.aspx` mit dem WebPart. +4. Eine bereits vorhandene `PortalSettings.aspx` wird nicht überschrieben. 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. +Das Skript bleibt als Reparaturweg erhalten und verwendet standardmäßig ebenfalls `PortalSettings.aspx`. ## Migration von V2 @@ -191,11 +189,10 @@ V3 liest die vorhandenen Konfigurationsorte direkt weiter: 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. +1. Paket aktualisieren und die App im Root-Web aktualisieren. +2. Fehlt `PortalSettings.aspx`, wird sie durch das Feature angelegt. Eine vorhandene Seite bleibt unverändert. +3. Falls eine vorhandene Seite das WebPart noch nicht enthält, das PnP-Skript als Reparaturweg ausführen. +4. Alle Provider laden, Konfigurationen testweise speichern und Root Web sowie Subwebs prüfen. ## Diagnose diff --git a/ToDo.md b/ToDo.md index b346713..f38e462 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.1.0 +Zielversion: 3.2.0 ## Architektur @@ -51,7 +51,8 @@ Zielversion: 3.1.0 - [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] Alte monolithische Seiten-, Copy- und Azure-Assets entfernt. +- [x] Schlankes Feature zur automatischen Provisionierung der modernen PortalSettings.aspx ergänzt. - [x] Altes Gulp-Copy-Task entfernt. - [x] Serve-Konfiguration auf WebPart Workbench umgestellt. - [x] Tests laufen vor der Paketierung. @@ -74,7 +75,7 @@ Zielversion: 3.1.0 - [ ] Ship-Paket mit Node.js 8.17.0 erstellen. - [ ] App im Root Web aktualisieren. -- [ ] Moderne `PortalSettingsV3.aspx` provisionieren. +- [ ] Automatisch provisionierte `PortalSettings.aspx` und enthaltenes WebPart prüfen. - [ ] MegaMenu-Termsets laden und speichern. - [ ] CustomBranding laden und schemaerhaltend speichern. - [ ] ExpiryIndicator-Standard speichern. diff --git a/config/package-solution.json b/config/package-solution.json index a5509dd..167fe7d 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,9 +3,22 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.1.0.0", + "version": "3.2.0.0", "includeClientSideAssets": true, - "skipFeatureDeployment": false + "skipFeatureDeployment": false, + "features": [ + { + "title": "Portal Settings Page", + "description": "Provisioniert die moderne PortalSettings.aspx im Root-Web.", + "id": "71841e33-8bbd-4007-83a6-d950453226a2", + "version": "3.2.0.0", + "assets": { + "elementManifests": ["elements.xml"], + "elementFiles": ["PortalSettings.aspx"], + "upgradeActions": ["upgrade-actions.xml"] + } + } + ] }, "paths": { "zippedPackage": "solution/portal-settings.sppkg" diff --git a/deployment/Add-PortalSettingsPage.ps1 b/deployment/Add-PortalSettingsPage.ps1 index 19089d8..a929311 100644 --- a/deployment/Add-PortalSettingsPage.ps1 +++ b/deployment/Add-PortalSettingsPage.ps1 @@ -2,7 +2,7 @@ param( [Parameter(Mandatory = $true)] [string]$SiteUrl, - [string]$PageName = 'PortalSettingsV3', + [string]$PageName = 'PortalSettings', [switch]$Publish ) diff --git a/package-lock.json b/package-lock.json index 1f0ad71..28827f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 23dde93..92ad000 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.1.0", + "version": "3.2.0", "private": true, "main": "lib/index.js", "engines": { diff --git a/sharepoint/assets/PortalSettings.aspx b/sharepoint/assets/PortalSettings.aspx new file mode 100644 index 0000000..edb5378 --- /dev/null +++ b/sharepoint/assets/PortalSettings.aspx @@ -0,0 +1,16 @@ +<%@ Page language="C#" Inherits="Microsoft.SharePoint.WebControls.ClientSidePage, Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> +<%@ Register Tagprefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> + + + + + + diff --git a/sharepoint/assets/elements.xml b/sharepoint/assets/elements.xml new file mode 100644 index 0000000..fc0dedd --- /dev/null +++ b/sharepoint/assets/elements.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/sharepoint/assets/upgrade-actions.xml b/sharepoint/assets/upgrade-actions.xml new file mode 100644 index 0000000..400d2a6 --- /dev/null +++ b/sharepoint/assets/upgrade-actions.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 5990c5e..611dbec 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -15,19 +15,36 @@ var config = json('config/config.json'); var copyAssets = json('config/copy-assets.json'); var manifest = json('src/webparts/portalSettings/PortalSettingsWebPart.manifest.json'); var styles = read('src/webparts/portalSettings/PortalSettings.scss'); +var pageAsset = read('sharepoint/assets/PortalSettings.aspx'); +var elements = read('sharepoint/assets/elements.xml'); +var upgradeActions = read('sharepoint/assets/upgrade-actions.xml'); var sourceFiles = [ 'src/ui/Dom.ts', 'src/ui/DynamicFormRenderer.ts', 'src/ui/PortalSettingsApp.ts' ].map(read).join('\n'); -assert(packageJson.version === '3.1.0', 'Package-Version ist nicht 3.1.0.'); -assert(solution.version === '3.1.0.0', 'Solution-Version ist nicht 3.1.0.0.'); -assert(!solution.features, 'Alte ASPX-Feature-Assets sind noch registriert.'); +assert(packageJson.version === '3.2.0', 'Package-Version ist nicht 3.2.0.'); +assert(solution.version === '3.2.0.0', 'Solution-Version ist nicht 3.2.0.0.'); +assert(solution.features && solution.features.length === 1, 'Das Seiten-Provisioning-Feature fehlt.'); +assert(solution.features[0].assets.elementManifests.indexOf('elements.xml') >= 0 && + solution.features[0].assets.elementFiles.indexOf('PortalSettings.aspx') >= 0 && + solution.features[0].assets.upgradeActions.indexOf('upgrade-actions.xml') >= 0, + 'Die Provisioning-Assets sind nicht vollständig registriert.'); assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.'); assert(copyAssets.deployCdnPath === 'temp/deploy', 'Ship-Assets werden nicht nach temp/deploy geschrieben.'); 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(pageAsset.indexOf('Microsoft.SharePoint.WebControls.ClientSidePage') >= 0, + 'Das provisionierte Asset ist keine moderne SharePoint-Seite.'); +assert(elements.indexOf('Url="PortalSettings.aspx"') >= 0 && elements.indexOf('RootWebOnly="TRUE"') >= 0 && + elements.indexOf('IgnoreIfAlreadyExists="TRUE"') >= 0 && elements.indexOf('Level="Published"') >= 0, + 'PortalSettings.aspx wird nicht sicher im Root-Web provisioniert.'); +assert(elements.indexOf('CanvasContent1') >= 0 && + elements.indexOf('c41d05ec-0682-4af0-b807-649806ed56b3') >= 0, + 'Das PortalSettings-WebPart fehlt im provisionierten Canvas.'); +assert(upgradeActions.indexOf('71841e33-8bbd-4007-83a6-d950453226a2\\elements.xml') >= 0, + 'Die Upgrade-Provisionierung fehlt.'); 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) { -- 2.39.5 From 84307cd078fba839a29a03c8f796e12ce98c3918 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 21:53:37 +0200 Subject: [PATCH 09/10] =?UTF-8?q?Aktualisiere=20Versionsnummer=20auf=203.2?= =?UTF-8?q?.1=20in=20README.md,=20package.json,=20package-lock.json=20und?= =?UTF-8?q?=20package-solution.json.=20F=C3=BCge=20Unterst=C3=BCtzung=20f?= =?UTF-8?q?=C3=BCr=20Root-Web-UserCustomActions=20in=20PortalSettingsDataS?= =?UTF-8?q?ervice=20hinzu=20und=20aktualisiere=20Tests=20entsprechend.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++-- ToDo.md | 3 ++- config/package-solution.json | 2 +- package-lock.json | 2 +- package.json | 2 +- src/services/PortalSettingsDataService.ts | 30 +++++++++++++++++------ tests/validate-static-assets.js | 8 ++++-- 7 files changed, 36 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ae94563..e55a0de 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.2.0 | +| Version | 3.2.1 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | @@ -48,6 +48,7 @@ Die Oberfläche wird vollständig vom WebPart erzeugt und verwendet kein `innerH ### ExpiryIndicator +- Erkennung direkt nach der App-Installation über den Application Customizer im Root Web - Site-Collection-Standard aus dem Root-Web-Property-Bag - Übersicht registrierter Listen- und Bibliotheksbindungen - Web-, Listen- und Datumsfeldauswahl ohne Eingabe technischer IDs @@ -164,7 +165,7 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.2.0.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.2.1.0` ersetzen. 2. Die App im Root Web der gewünschten Site Collection installieren oder aktualisieren. 3. Die App provisioniert automatisch die veröffentlichte Seite `SitePages/PortalSettings.aspx` mit dem WebPart. 4. Eine bereits vorhandene `PortalSettings.aspx` wird nicht überschrieben. diff --git a/ToDo.md b/ToDo.md index f38e462..9d3dee6 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.2.0 +Zielversion: 3.2.1 ## Architektur @@ -40,6 +40,7 @@ Zielversion: 3.2.0 - [x] CustomBranding-Vertragsmarker und Component-ID-Fallback unterstützt. - [x] Branding-Sicherheitsprüfung vor dem Speichern. - [x] ExpiryIndicator-Site-Collection-Standard abgebildet. +- [x] ExpiryIndicator direkt über seine Root-Web-Application-Customizer-Action erkennen. - [x] Expiry-Bindungsinventar dargestellt. - [x] Web-, Listen- und Datumsfeld-Picker umgesetzt. - [x] Neue Field-Customizer-Bindung aus dem WebPart möglich. diff --git a/config/package-solution.json b/config/package-solution.json index 167fe7d..01c895d 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.2.0.0", + "version": "3.2.1.0", "includeClientSideAssets": true, "skipFeatureDeployment": false, "features": [ diff --git a/package-lock.json b/package-lock.json index 28827f9..63f46bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.2.0", + "version": "3.2.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 92ad000..5c98f66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.2.0", + "version": "3.2.1", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/services/PortalSettingsDataService.ts b/src/services/PortalSettingsDataService.ts index eb22779..e839b23 100644 --- a/src/services/PortalSettingsDataService.ts +++ b/src/services/PortalSettingsDataService.ts @@ -32,13 +32,16 @@ export class PortalSettingsDataService { } public load(providers: Array>): Promise { - return Promise.all([this.getSiteActions(), this.getRootProperties()]) + return Promise.all([this.getSiteActions(), this.getRootWebActions(), this.getRootProperties()]) .then((values: any[]): ILoadedSettingsProvider[] => { - const actions: IUserCustomActionInfo[] = values[0]; - const properties: any = values[1]; + const siteActions: IUserCustomActionInfo[] = values[0]; + const registrationActions: IUserCustomActionInfo[] = siteActions.concat(values[1]); + const properties: any = values[2]; const loaded: ILoadedSettingsProvider[] = []; for (let i: number = 0; i < providers.length; i++) { - const result: ILoadedSettingsProvider = this.loadProvider(providers[i], actions, properties); + const result: ILoadedSettingsProvider = this.loadProvider( + providers[i], siteActions, registrationActions, properties + ); if (result) { loaded.push(result); } } return loaded; @@ -178,13 +181,14 @@ export class PortalSettingsDataService { private loadProvider( provider: ISettingsProvider, - actions: IUserCustomActionInfo[], + siteActions: IUserCustomActionInfo[], + registrationActions: IUserCustomActionInfo[], properties: any ): ILoadedSettingsProvider | undefined { let raw: any; const context: IProviderLoadContext = {}; if (provider.storage.kind === 'siteUserCustomAction') { - const action: IUserCustomActionInfo = this.findAction(provider, actions); + const action: IUserCustomActionInfo = this.findAction(provider, siteActions); if (!action && !provider.storage.action) { return undefined; } if (action) { context.action = action; @@ -197,7 +201,7 @@ export class PortalSettingsDataService { 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); + const registration: IUserCustomActionInfo = this.findAnyAction(provider, registrationActions); if (!stored && !descriptor && !bindingsValue && !registration) { return undefined; } if (registration) { context.action = registration; } raw = this.parseJson(stored, provider.createDefault()); @@ -218,8 +222,18 @@ export class PortalSettingsDataService { private getSiteActions(): Promise { const url: string = this.siteUrl + '/_api/site/UserCustomActions' + '?$select=Id,Title,Name,Location,ClientSideComponentId,ClientSideComponentProperties'; + return this.getActions(url, 'Site-Collection-UserCustomActions konnten nicht gelesen werden.'); + } + + private getRootWebActions(): Promise { + const url: string = this.siteUrl + '/_api/site/rootweb/UserCustomActions' + + '?$select=Id,Title,Name,Location,ClientSideComponentId,ClientSideComponentProperties'; + return this.getActions(url, 'Root-Web-UserCustomActions konnten nicht gelesen werden.'); + } + + private getActions(url: string, errorMessage: string): Promise { 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((response: SPHttpClientResponse): Promise => this.ensureJson(response, errorMessage)) .then((payload: any): IUserCustomActionInfo[] => this.results(payload).map((source: any): IUserCustomActionInfo => ({ id: this.normalizeGuid(source.Id), title: source.Title || '', diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 611dbec..6fc5901 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -23,9 +23,10 @@ var sourceFiles = [ 'src/ui/DynamicFormRenderer.ts', 'src/ui/PortalSettingsApp.ts' ].map(read).join('\n'); +var dataService = read('src/services/PortalSettingsDataService.ts'); -assert(packageJson.version === '3.2.0', 'Package-Version ist nicht 3.2.0.'); -assert(solution.version === '3.2.0.0', 'Solution-Version ist nicht 3.2.0.0.'); +assert(packageJson.version === '3.2.1', 'Package-Version ist nicht 3.2.1.'); +assert(solution.version === '3.2.1.0', 'Solution-Version ist nicht 3.2.1.0.'); assert(solution.features && solution.features.length === 1, 'Das Seiten-Provisioning-Feature fehlt.'); assert(solution.features[0].assets.elementManifests.indexOf('elements.xml') >= 0 && solution.features[0].assets.elementFiles.indexOf('PortalSettings.aspx') >= 0 && @@ -47,6 +48,9 @@ assert(upgradeActions.indexOf('71841e33-8bbd-4007-83a6-d950453226a2\\elements.xm 'Die Upgrade-Provisionierung fehlt.'); 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.'); +assert(dataService.indexOf('/_api/site/rootweb/UserCustomActions') >= 0 && + dataService.indexOf('siteActions.concat(values[1])') >= 0, + 'Root-Web-Actions werden nicht für die Provider-Erkennung berücksichtigt.'); ['themePrimary', 'neutralPrimary', 'neutralLight', 'white'].forEach(function (slot) { assert(styles.indexOf('[theme: ' + slot + ', default:') >= 0, 'Themeslot fehlt: ' + slot); }); -- 2.39.5 From 094f0933e753207b2a8202de520760284fa24902 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Tue, 18 Aug 2026 22:03:55 +0200 Subject: [PATCH 10/10] Aktualisiere Versionsnummer auf 3.2.2 in README.md, package.json, package-lock.json und package-solution.json. Entferne inkompatibles Seiten-Provisioning-Feature und passe Tests entsprechend an. --- README.md | 18 +++++++----------- ToDo.md | 6 +++--- config/package-solution.json | 17 ++--------------- package-lock.json | 2 +- package.json | 2 +- tests/validate-static-assets.js | 23 +++-------------------- 6 files changed, 17 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index e55a0de..d056c0b 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ PortalSettings V3 ist ein providerbasiertes SPFx-WebPart für SharePoint Server | Eigenschaft | Wert | |---|---| -| Version | 3.2.1 | +| Version | 3.2.2 | | SharePoint Framework | 1.4.1 | | Build-Node | 8.17.0 | | npm | 6.x | | Gulp | lokal 3.9.1 | | Komponente | Client-Side WebPart | -Die Oberfläche wird vollständig vom WebPart erzeugt und verwendet kein `innerHTML`. Ein schlankes SharePoint-Feature provisioniert beim Installieren der App im Root-Web automatisch die moderne `SitePages/PortalSettings.aspx` und platziert dort genau eine Instanz des WebParts. +Die Oberfläche wird vollständig vom WebPart erzeugt und verwendet kein `innerHTML`. Die moderne `SitePages/PortalSettings.aspx` wird bewusst per PnP-PowerShell provisioniert, damit das SPFx-WebPart auf SharePoint Server zuverlässig registriert bleibt. ## Unterstützte Solutions @@ -165,12 +165,9 @@ sharepoint/solution/portal-settings.sppkg ## Installation -1. `portal-settings.sppkg` im App Catalog durch Version `3.2.1.0` ersetzen. +1. `portal-settings.sppkg` im App Catalog durch Version `3.2.2.0` ersetzen. 2. Die App im Root Web der gewünschten Site Collection installieren oder aktualisieren. -3. Die App provisioniert automatisch die veröffentlichte Seite `SitePages/PortalSettings.aspx` mit dem WebPart. -4. Eine bereits vorhandene `PortalSettings.aspx` wird nicht überschrieben. - -Optional kann eine Seite per PnP PowerShell erzeugt werden: +3. Die Seite mit dem PnP-PowerShell-Skript erzeugen beziehungsweise reparieren: ```powershell .\deployment\Add-PortalSettingsPage.ps1 ` @@ -178,7 +175,7 @@ Optional kann eine Seite per PnP PowerShell erzeugt werden: -Publish ``` -Das Skript bleibt als Reparaturweg erhalten und verwendet standardmäßig ebenfalls `PortalSettings.aspx`. +Das Skript verwendet standardmäßig `PortalSettings.aspx` und platziert dort das Portal-Settings-WebPart. ## Migration von V2 @@ -191,9 +188,8 @@ V3 liest die vorhandenen Konfigurationsorte direkt weiter: Es ist keine Datenmigration in eine neue Liste erforderlich. Empfohlenes Vorgehen: 1. Paket aktualisieren und die App im Root-Web aktualisieren. -2. Fehlt `PortalSettings.aspx`, wird sie durch das Feature angelegt. Eine vorhandene Seite bleibt unverändert. -3. Falls eine vorhandene Seite das WebPart noch nicht enthält, das PnP-Skript als Reparaturweg ausführen. -4. Alle Provider laden, Konfigurationen testweise speichern und Root Web sowie Subwebs prüfen. +2. Das PnP-Skript ausführen, wenn `PortalSettings.aspx` fehlt oder das WebPart noch nicht enthält. +3. Alle Provider laden, Konfigurationen testweise speichern und Root Web sowie Subwebs prüfen. ## Diagnose diff --git a/ToDo.md b/ToDo.md index 9d3dee6..1224a34 100644 --- a/ToDo.md +++ b/ToDo.md @@ -2,7 +2,7 @@ Stand: 21.07.2026 Branch: `dev-3.0` -Zielversion: 3.2.1 +Zielversion: 3.2.2 ## Architektur @@ -53,7 +53,7 @@ Zielversion: 3.2.1 - [x] Versionen auf 3.0.0 angehoben. - [x] Node-Engine auf SPFx-1.4.1-Buildumgebung begrenzt. - [x] Alte monolithische Seiten-, Copy- und Azure-Assets entfernt. -- [x] Schlankes Feature zur automatischen Provisionierung der modernen PortalSettings.aspx ergänzt. +- [x] Automatisches Feature-Provisioning wegen fehlender WebPart-Registrierung auf SharePoint Server zurückgenommen. - [x] Altes Gulp-Copy-Task entfernt. - [x] Serve-Konfiguration auf WebPart Workbench umgestellt. - [x] Tests laufen vor der Paketierung. @@ -76,7 +76,7 @@ Zielversion: 3.2.1 - [ ] Ship-Paket mit Node.js 8.17.0 erstellen. - [ ] App im Root Web aktualisieren. -- [ ] Automatisch provisionierte `PortalSettings.aspx` und enthaltenes WebPart prüfen. +- [ ] Per PnP-Skript provisionierte `PortalSettings.aspx` und enthaltenes WebPart prüfen. - [ ] MegaMenu-Termsets laden und speichern. - [ ] CustomBranding laden und schemaerhaltend speichern. - [ ] ExpiryIndicator-Standard speichern. diff --git a/config/package-solution.json b/config/package-solution.json index 01c895d..d4dd341 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,22 +3,9 @@ "solution": { "name": "portal-settings-client-side-solution", "id": "1c98e32d-bb7b-43b7-9625-074d6e4ea286", - "version": "3.2.1.0", + "version": "3.2.2.0", "includeClientSideAssets": true, - "skipFeatureDeployment": false, - "features": [ - { - "title": "Portal Settings Page", - "description": "Provisioniert die moderne PortalSettings.aspx im Root-Web.", - "id": "71841e33-8bbd-4007-83a6-d950453226a2", - "version": "3.2.0.0", - "assets": { - "elementManifests": ["elements.xml"], - "elementFiles": ["PortalSettings.aspx"], - "upgradeActions": ["upgrade-actions.xml"] - } - } - ] + "skipFeatureDeployment": false }, "paths": { "zippedPackage": "solution/portal-settings.sppkg" diff --git a/package-lock.json b/package-lock.json index 63f46bf..6cb8087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.2.1", + "version": "3.2.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 5c98f66..9819b0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portal-settings", - "version": "3.2.1", + "version": "3.2.2", "private": true, "main": "lib/index.js", "engines": { diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 6fc5901..d60014c 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -15,9 +15,6 @@ var config = json('config/config.json'); var copyAssets = json('config/copy-assets.json'); var manifest = json('src/webparts/portalSettings/PortalSettingsWebPart.manifest.json'); var styles = read('src/webparts/portalSettings/PortalSettings.scss'); -var pageAsset = read('sharepoint/assets/PortalSettings.aspx'); -var elements = read('sharepoint/assets/elements.xml'); -var upgradeActions = read('sharepoint/assets/upgrade-actions.xml'); var sourceFiles = [ 'src/ui/Dom.ts', 'src/ui/DynamicFormRenderer.ts', @@ -25,27 +22,13 @@ var sourceFiles = [ ].map(read).join('\n'); var dataService = read('src/services/PortalSettingsDataService.ts'); -assert(packageJson.version === '3.2.1', 'Package-Version ist nicht 3.2.1.'); -assert(solution.version === '3.2.1.0', 'Solution-Version ist nicht 3.2.1.0.'); -assert(solution.features && solution.features.length === 1, 'Das Seiten-Provisioning-Feature fehlt.'); -assert(solution.features[0].assets.elementManifests.indexOf('elements.xml') >= 0 && - solution.features[0].assets.elementFiles.indexOf('PortalSettings.aspx') >= 0 && - solution.features[0].assets.upgradeActions.indexOf('upgrade-actions.xml') >= 0, - 'Die Provisioning-Assets sind nicht vollständig registriert.'); +assert(packageJson.version === '3.2.2', 'Package-Version ist nicht 3.2.2.'); +assert(solution.version === '3.2.2.0', 'Solution-Version ist nicht 3.2.2.0.'); +assert(!solution.features, 'Das inkompatible Seiten-Provisioning-Feature darf nicht paketiert werden.'); assert(!!config.bundles['portal-settings-web-part'], 'WebPart-Bundle fehlt.'); assert(copyAssets.deployCdnPath === 'temp/deploy', 'Ship-Assets werden nicht nach temp/deploy geschrieben.'); 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(pageAsset.indexOf('Microsoft.SharePoint.WebControls.ClientSidePage') >= 0, - 'Das provisionierte Asset ist keine moderne SharePoint-Seite.'); -assert(elements.indexOf('Url="PortalSettings.aspx"') >= 0 && elements.indexOf('RootWebOnly="TRUE"') >= 0 && - elements.indexOf('IgnoreIfAlreadyExists="TRUE"') >= 0 && elements.indexOf('Level="Published"') >= 0, - 'PortalSettings.aspx wird nicht sicher im Root-Web provisioniert.'); -assert(elements.indexOf('CanvasContent1') >= 0 && - elements.indexOf('c41d05ec-0682-4af0-b807-649806ed56b3') >= 0, - 'Das PortalSettings-WebPart fehlt im provisionierten Canvas.'); -assert(upgradeActions.indexOf('71841e33-8bbd-4007-83a6-d950453226a2\\elements.xml') >= 0, - 'Die Upgrade-Provisionierung fehlt.'); 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.'); assert(dataService.indexOf('/_api/site/rootweb/UserCustomActions') >= 0 && -- 2.39.5