Füge Application Customizer für ExpiryIndicator hinzu; aktualisiere Versionsnummern auf 1.0.10 in package.json, package-solution.json, ExpiryIndicatorFieldCustomizer.manifest.json und ExpiryIndicatorCommandSet.manifest.json; verbessere die Konfiguration und Benutzeroberfläche in README.md und ToDo.md.

This commit is contained in:
Torsten Brendgen
2026-07-17 21:59:34 +02:00
parent 76a0fcd898
commit b2337e9548
17 changed files with 280 additions and 176 deletions

View File

@@ -58,7 +58,7 @@ Beispiel der intern gespeicherten Gesamtstruktur (nicht vollständig in eines de
} }
``` ```
## Konfiguration über "Expiry-Einstellungen" ## Konfiguration über den Application Customizer
### Voraussetzungen ### Voraussetzungen
@@ -67,9 +67,18 @@ Beispiel der intern gespeicherten Gesamtstruktur (nicht vollständig in eines de
3. Der angemeldete Benutzer benötigt die SharePoint-Berechtigung `Listen verwalten`. 3. Der angemeldete Benutzer benötigt die SharePoint-Berechtigung `Listen verwalten`.
4. Das konfigurierte Ablaufdatumsfeld muss bereits vorhanden sein. Die Solution legt dieses Feld nicht an. 4. Das konfigurierte Ablaufdatumsfeld muss bereits vorhanden sein. Die Solution legt dieses Feld nicht an.
Nach der Installation erscheint in modernen Listen und Dokumentbibliotheken der Befehl Der Einstellungsdialog wird ohne dauerhaftes Element in der Befehlsleiste durch den Application Customizer
**Expiry-Einstellungen** in der Befehlsleiste. Falls der Befehl nicht sichtbar ist, zunächst prüfen, ob bereitgestellt. Dazu an die URL einer modernen Listen- oder Bibliotheksansicht den Parameter
die App in der Site installiert und die Berechtigung `Listen verwalten` vorhanden ist. `expiryIndicatorSettings=1` anhängen:
```text
/Shared Documents/Forms/AllItems.aspx?expiryIndicatorSettings=1
```
Sind bereits andere Query-Parameter vorhanden, wird der Parameter mit `&expiryIndicatorSettings=1` ergänzt.
Nach dem Speichern wird der Parameter entfernt und die Ansicht mit der neuen Konfiguration geladen. Ohne den
Parameter bleibt der Application Customizer unsichtbar. Nur Benutzer mit `Listen verwalten` können den Dialog
öffnen und speichern.
### Interne Feldnamen ermitteln ### Interne Feldnamen ermitteln
@@ -208,8 +217,16 @@ Die Regeln werden von oben nach unten geprüft. Dabei gilt:
3. Die erste passende Regel liefert Laufzeit und Farbregeln. 3. Die erste passende Regel liefert Laufzeit und Farbregeln.
4. Existiert das Feld nicht oder passt kein Wert, werden Standardlaufzeit und Standard-Farbregeln verwendet. 4. Existiert das Feld nicht oder passt kein Wert, werden Standardlaufzeit und Standard-Farbregeln verwendet.
Nach **Speichern** wird die Konfiguration pro Liste beziehungsweise Bibliothek in der ausgeblendeten Liste Nach **Speichern** wird die Konfiguration als JSON direkt in `ClientSideComponentProperties` des mit dem
`_ExpiryIndicatorConfiguration` der aktuellen Site gespeichert. Anschließend die Ansicht neu laden. ExpiryIndicator gebundenen lokalen Listenfeldes gespeichert. Damit verwendet die App denselben
Konfigurationsmechanismus wie andere SPFx-Extensions und benötigt keine zusätzliche Konfigurationsliste.
PortalSettings kann optional dieselben Component Properties verwalten, ist aber keine Voraussetzung für den
Betrieb des ExpiryIndicators.
Bei einem Upgrade wird eine vorhandene Konfiguration aus der früheren ausgeblendeten Liste
`_ExpiryIndicatorConfiguration` weiterhin gelesen, solange noch keine Component Properties gespeichert wurden.
Beim nächsten Speichern wird sie in das gebundene Feld übernommen. Die alte Liste wird aus Sicherheitsgründen
nicht automatisch gelöscht.
### Anzeige aktivieren ### Anzeige aktivieren
@@ -223,8 +240,9 @@ an der Ablaufdatumsspalte registriert:
-ExpiryFieldInternalName 'CustomerExpiryDate' -ExpiryFieldInternalName 'CustomerExpiryDate'
``` ```
Der Wert von `ExpiryFieldInternalName` muss mit **Internes Ablaufdatumsfeld** in den Einstellungen Der Field Customizer und seine Component Properties werden am mit `ExpiryFieldInternalName` angegebenen lokalen
übereinstimmen. Das Command Set wird durch die Installation der App registriert. Danach die Listenansicht Listenfeld registriert. Der interne Name wird anschließend aus dieser Bindung ermittelt und im Einstellungsdialog
nur lesbar angezeigt. Das Command Set wird durch die Installation der App registriert. Danach die Listenansicht
neu laden. neu laden.
### Ablaufdatum um ein Jahr verlängern ### Ablaufdatum um ein Jahr verlängern
@@ -252,7 +270,7 @@ Das Paket wird als `sharepoint/solution/expiry-indicator.sppkg` erzeugt.
1. `expiry-indicator.sppkg` in den App Catalog der On-Premises-Farm laden. 1. `expiry-indicator.sppkg` in den App Catalog der On-Premises-Farm laden.
2. Die App in der gewünschten Site installieren. 2. Die App in der gewünschten Site installieren.
3. Eine moderne Liste/Bibliothek öffnen und wie im Abschnitt 3. Eine moderne Liste/Bibliothek öffnen und wie im Abschnitt
**Konfiguration über "Expiry-Einstellungen"** beschrieben konfigurieren. **Konfiguration über den Application Customizer** beschrieben konfigurieren.
4. Den Field Customizer mit der vorhandenen Ablaufdatumsspalte verbinden: 4. Den Field Customizer mit der vorhandenen Ablaufdatumsspalte verbinden:
```powershell ```powershell

24
ToDo.md
View File

@@ -8,7 +8,7 @@ Eine SharePoint-Framework-Solution für SharePoint Server Subscription Edition a
- Berechnung eines effektiven Ablaufdatums aus `Created`, wenn `ExpiryDate` leer ist, - Berechnung eines effektiven Ablaufdatums aus `Created`, wenn `ExpiryDate` leer ist,
- frei konfigurierbare Laufzeit-, Schwellenwert- und Farbregeln pro Liste/Bibliothek, - frei konfigurierbare Laufzeit-, Schwellenwert- und Farbregeln pro Liste/Bibliothek,
- einen Befehl **„Ablaufdatum +1 Jahr“** für ein oder mehrere ausgewählte Elemente, - einen Befehl **„Ablaufdatum +1 Jahr“** für ein oder mehrere ausgewählte Elemente,
- einen nur für Listenverwalter sichtbaren Befehl **„Expiry-Einstellungen“**. - einen unsichtbar laufenden Application Customizer, der den Einstellungsdialog per URL-Parameter für Listenverwalter öffnet.
## Wichtige Randbedingung ## Wichtige Randbedingung
@@ -33,7 +33,7 @@ Die Berechnung arbeitet mit Kalenderjahren/-monaten/-tagen, nicht mit pauschalen
### 2. ListView Command Set ### 2. ListView Command Set
`ExpiryIndicatorCommandSet` stellt in der modernen Befehlsleiste zwei Aktionen bereit: `ExpiryIndicatorCommandSet` stellt in der modernen Befehlsleiste eine fachliche Aktion bereit:
- **Ablaufdatum +1 Jahr** - **Ablaufdatum +1 Jahr**
- sichtbar, wenn mindestens ein Element gewählt wurde und `ExpiryDate` vorhanden ist, - sichtbar, wenn mindestens ein Element gewählt wurde und `ExpiryDate` vorhanden ist,
@@ -42,13 +42,16 @@ Die Berechnung arbeitet mit Kalenderjahren/-monaten/-tagen, nicht mit pauschalen
- verwendet bei leerem `ExpiryDate` zunächst das berechnete effektive Ablaufdatum, - verwendet bei leerem `ExpiryDate` zunächst das berechnete effektive Ablaufdatum,
- addiert ein Kalenderjahr und schreibt das Ergebnis nach `ExpiryDate`, - addiert ein Kalenderjahr und schreibt das Ergebnis nach `ExpiryDate`,
- zeigt eine Zusammenfassung über erfolgreiche und fehlgeschlagene Änderungen. - zeigt eine Zusammenfassung über erfolgreiche und fehlgeschlagene Änderungen.
- **Expiry-Einstellungen**
- nur sichtbar für Benutzer mit `ManageLists`-Berechtigung,
- öffnet einen Konfigurationsdialog für die aktuelle Liste/Bibliothek.
### 3. Konfiguration ### 3. Application Customizer
Die Konfiguration wird pro Liste anhand ihrer GUID gespeichert. Dafür verwendet die App eine eigene, versteckte Konfigurationsliste im aktuellen Web. Diese technische Liste darf von der Solution beziehungsweise beim ersten Speichern der Einstellungen erzeugt werden; die fachliche Spalte `ExpiryDate` bleibt davon unberührt. `ExpiryIndicatorApplicationCustomizer` läuft ohne permanente Oberfläche. Auf einer modernen Listen- oder
Bibliotheksansicht öffnet `expiryIndicatorSettings=1` den Konfigurationsdialog für Benutzer mit
`ManageLists`-Berechtigung. Die Befehlsleiste enthält dadurch keinen Einstellungsbefehl.
### 4. Konfiguration
Die Konfiguration wird pro Liste als JSON in `ClientSideComponentProperties` des gebundenen lokalen Listenfeldes gespeichert. Eine zusätzliche Konfigurationsliste wird bei neuen Installationen nicht erzeugt. Für Upgrades darf eine bestehende `_ExpiryIndicatorConfiguration` ausschließlich als Lese-Fallback verwendet werden, bis die Konfiguration beim nächsten Speichern in die Component Properties migriert wurde. Die fachliche Spalte `ExpiryDate` bleibt davon unberührt.
Konfigurierbar sind mindestens: Konfigurierbar sind mindestens:
@@ -90,10 +93,10 @@ Beispiel einer Laufzeitkonfiguration:
} }
``` ```
### 4. Aktivierung und Deployment ### 5. Aktivierung und Deployment
- Erstellung eines `.sppkg`-Pakets mit eingebetteten Client-Side Assets. - Erstellung eines `.sppkg`-Pakets mit eingebetteten Client-Side Assets.
- Registrierung des Command Sets für moderne generische Listen und Dokumentbibliotheken. - Registrierung des Command Sets und des Settings-Application-Customizers.
- Keine Provisionierung fachlicher Ablaufdatumsspalten, Site Columns oder Content Types. - Keine Provisionierung fachlicher Ablaufdatumsspalten, Site Columns oder Content Types.
- Bereitstellung eines PowerShell-/CSOM-Aktivierungsskripts, das den Field Customizer mit einer als Parameter angegebenen vorhandenen Ablaufdatumsspalte verknüpft. - Bereitstellung eines PowerShell-/CSOM-Aktivierungsskripts, das den Field Customizer mit einer als Parameter angegebenen vorhandenen Ablaufdatumsspalte verknüpft.
- Bereitstellung eines entsprechenden Deaktivierungsskripts, das nur diese Verknüpfung und App-Custom-Actions entfernt, nicht aber fachliche Daten oder Spalten. - Bereitstellung eines entsprechenden Deaktivierungsskripts, das nur diese Verknüpfung und App-Custom-Actions entfernt, nicht aber fachliche Daten oder Spalten.
@@ -106,7 +109,8 @@ Beispiel einer Laufzeitkonfiguration:
- [ ] Regel-Engine für feldwertabhängige Laufzeiten und Farbschwellen implementieren. - [ ] Regel-Engine für feldwertabhängige Laufzeiten und Farbschwellen implementieren.
- [ ] REST-Service für Konfiguration und Elementaktualisierungen implementieren. - [ ] REST-Service für Konfiguration und Elementaktualisierungen implementieren.
- [ ] Field Customizer implementieren. - [ ] Field Customizer implementieren.
- [ ] ListView Command Set und Einstellungsdialog implementieren. - [x] ListView Command Set und Einstellungsdialog implementieren.
- [x] Settings-Application-Customizer mit URL-Aktivierung implementieren.
- [ ] Feature-XML für Command-Bar-Registrierung erstellen. - [ ] Feature-XML für Command-Bar-Registrierung erstellen.
- [ ] CSOM-Aktivierungs- und Deaktivierungsskripte für die vorhandene `ExpiryDate`-Spalte erstellen. - [ ] CSOM-Aktivierungs- und Deaktivierungsskripte für die vorhandene `ExpiryDate`-Spalte erstellen.
- [ ] Lokalisierung Deutsch/Englisch ergänzen. - [ ] Lokalisierung Deutsch/Englisch ergänzen.

View File

@@ -17,6 +17,14 @@
"manifest": "./src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json" "manifest": "./src/extensions/expiryIndicatorCommandSet/ExpiryIndicatorCommandSet.manifest.json"
} }
] ]
},
"expiry-indicator-application-customizer": {
"components": [
{
"entrypoint": "./lib/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.js",
"manifest": "./src/extensions/expiryIndicatorApplicationCustomizer/ExpiryIndicatorApplicationCustomizer.manifest.json"
}
]
} }
}, },
"externals": {}, "externals": {},
@@ -25,4 +33,3 @@
"ExpiryIndicatorCommandSetStrings": "lib/extensions/expiryIndicatorCommandSet/loc/{locale}.js" "ExpiryIndicatorCommandSetStrings": "lib/extensions/expiryIndicatorCommandSet/loc/{locale}.js"
} }
} }

View File

@@ -3,15 +3,15 @@
"solution": { "solution": {
"name": "expiry-indicator-client-side-solution", "name": "expiry-indicator-client-side-solution",
"id": "68bb6d2d-9895-45b4-88e8-b98835faa981", "id": "68bb6d2d-9895-45b4-88e8-b98835faa981",
"version": "1.0.8.0", "version": "1.0.10.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": false, "skipFeatureDeployment": false,
"features": [ "features": [
{ {
"title": "ExpiryIndicator command set registration", "title": "ExpiryIndicator extension registration",
"description": "Registers ExpiryIndicator commands for modern lists and document libraries. Does not provision ExpiryDate.", "description": "Registers ExpiryIndicator commands and its settings Application Customizer. Does not provision ExpiryDate.",
"id": "913402af-ab9a-4974-9f86-5c2159ae41db", "id": "913402af-ab9a-4974-9f86-5c2159ae41db",
"version": "1.0.8.0", "version": "1.0.10.0",
"assets": { "assets": {
"elementManifests": [ "elementManifests": [
"elements.xml" "elements.xml"

View File

@@ -1,6 +1,6 @@
{ {
"name": "expiry-indicator", "name": "expiry-indicator",
"version": "1.0.8", "version": "1.0.10",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=6.9.0 <9.0.0" "node": ">=6.9.0 <9.0.0"
@@ -13,6 +13,7 @@
"test": "gulp test" "test": "gulp test"
}, },
"dependencies": { "dependencies": {
"@microsoft/sp-application-base": "1.4.1",
"@microsoft/sp-core-library": "1.4.1", "@microsoft/sp-core-library": "1.4.1",
"@microsoft/sp-dialog": "1.4.1", "@microsoft/sp-dialog": "1.4.1",
"@microsoft/sp-http": "1.4.1", "@microsoft/sp-http": "1.4.1",

View File

@@ -32,8 +32,13 @@ try {
Write-Warning "Das Feld wurde über seinen Anzeigenamen gefunden. Verwende künftig den internen Namen '$($field.InternalName)'." Write-Warning "Das Feld wurde über seinen Anzeigenamen gefunden. Verwende künftig den internen Namen '$($field.InternalName)'."
} }
$alreadyBound = $field.ClientSideComponentId -eq $FieldCustomizerComponentId
$field.ClientSideComponentId = $FieldCustomizerComponentId $field.ClientSideComponentId = $FieldCustomizerComponentId
$field.ClientSideComponentProperties = '{}' if (-not $alreadyBound -or [string]::IsNullOrWhiteSpace($field.ClientSideComponentProperties)) {
$field.ClientSideComponentProperties = (@{
expiryField = $field.InternalName
} | ConvertTo-Json -Compress)
}
$field.Update() $field.Update()
# Remove the temporary list-scoped command registration used by older # Remove the temporary list-scoped command registration used by older

View File

@@ -15,4 +15,9 @@
Location="ClientSideExtension.ListViewCommandSet.CommandBar" Location="ClientSideExtension.ListViewCommandSet.CommandBar"
ClientSideComponentId="cd58f5d9-ffc8-4df7-a910-3054842d7abf" ClientSideComponentId="cd58f5d9-ffc8-4df7-a910-3054842d7abf"
ClientSideComponentProperties="{}" /> ClientSideComponentProperties="{}" />
<CustomAction
Title="ExpiryIndicator settings Application Customizer"
Location="ClientSideExtension.ApplicationCustomizer"
ClientSideComponentId="95691218-bcb1-4fad-adda-02bb12014d15"
ClientSideComponentProperties="{}" />
</Elements> </Elements>

View File

@@ -17,8 +17,16 @@ interface IConfigurationItem {
ExpiryIndicatorJson: string; ExpiryIndicatorJson: string;
} }
interface IBoundFieldInfo {
id: string;
internalName: string;
entityType: string;
properties: string;
}
const CONFIGURATION_LIST_TITLE: string = '_ExpiryIndicatorConfiguration'; const CONFIGURATION_LIST_TITLE: string = '_ExpiryIndicatorConfiguration';
const CONFIGURATION_FIELD: string = 'ExpiryIndicatorJson'; const CONFIGURATION_FIELD: string = 'ExpiryIndicatorJson';
const FIELD_CUSTOMIZER_COMPONENT_ID: string = 'a555f4fc-d6a6-4421-8189-457449d9bbde';
export class ExpiryConfigService { export class ExpiryConfigService {
private readonly _spHttpClient: SPHttpClient; private readonly _spHttpClient: SPHttpClient;
@@ -30,59 +38,119 @@ export class ExpiryConfigService {
} }
public getConfig(listId: string): Promise<IExpiryConfig> { public getConfig(listId: string): Promise<IExpiryConfig> {
return this._getConfigurationList(false).then((list: IConfigurationListInfo | undefined): Promise<IExpiryConfig> => { return this._getBoundField(listId).then((field: IBoundFieldInfo): Promise<IExpiryConfig> => {
if (!list) { const propertyConfig: IExpiryConfig | undefined = this._parseProperties(field.properties, field.internalName);
return Promise.resolve(createDefaultConfig()); if (propertyConfig) {
return Promise.resolve(propertyConfig);
} }
return this._getConfigurationItem(listId).then((item: IConfigurationItem | undefined): IExpiryConfig => { // Upgrade compatibility: read an existing hidden-list configuration,
if (!item || !item[CONFIGURATION_FIELD]) { // but never create that list. The next save migrates the JSON to the field.
return createDefaultConfig(); return this._getLegacyConfig(listId).then((legacyConfig: IExpiryConfig | undefined): IExpiryConfig => {
} if (legacyConfig) {
legacyConfig.expiryField = field.internalName;
try { return legacyConfig;
return normalizeConfig(JSON.parse(item[CONFIGURATION_FIELD]));
} catch (error) {
return createDefaultConfig();
} }
const defaults: IExpiryConfig = createDefaultConfig();
defaults.expiryField = field.internalName;
return defaults;
}); });
}); });
} }
public saveConfig(listId: string, config: IExpiryConfig): Promise<void> { public saveConfig(listId: string, config: IExpiryConfig): Promise<void> {
return this._getBoundField(listId).then((field: IBoundFieldInfo): Promise<void> => {
const normalized: IExpiryConfig = normalizeConfig(config); const normalized: IExpiryConfig = normalizeConfig(config);
return this._getConfigurationList(true).then((list: IConfigurationListInfo): Promise<void> => { normalized.expiryField = field.internalName;
return this._getConfigurationItem(listId).then((item: IConfigurationItem | undefined): Promise<void> => {
const body: any = { const body: any = {
'__metadata': { 'type': list.entityType }, '__metadata': { 'type': field.entityType },
'Title': listId, 'ClientSideComponentProperties': JSON.stringify(normalized)
'ExpiryIndicatorJson': JSON.stringify(normalized)
}; };
const options: ISPHttpClientOptions = { const options: ISPHttpClientOptions = {
headers: this._headers(item ? 'MERGE' : undefined), headers: this._headers('MERGE'),
body: JSON.stringify(body) body: JSON.stringify(body)
}; };
const url: string = this._configurationListUrl() + '/items' + (item ? '(' + item.Id + ')' : ''); const fieldId: string = field.id.replace(/[{}]/g, '');
const url: string = this._listUrl(listId) + "/fields(guid'" + fieldId + "')";
return this._spHttpClient.post(url, SPHttpClient.configurations.v1, options) return this._spHttpClient.post(url, SPHttpClient.configurations.v1, options)
.then((response: SPHttpClientResponse): Promise<void> => { .then((response: SPHttpClientResponse): Promise<void> => {
if (!response.ok) { if (!response.ok) {
return responseError(response, 'Konfiguration konnte nicht gespeichert werden') return responseError(response, 'Konfiguration konnte nicht am Field Customizer gespeichert werden')
.then((error: Error): Promise<void> => Promise.reject(error)); .then((error: Error): Promise<void> => Promise.reject(error));
} }
return Promise.resolve(); return Promise.resolve();
}); });
}); });
}
private _getLegacyConfig(listId: string): Promise<IExpiryConfig | undefined> {
return this._getConfigurationList().then((list: IConfigurationListInfo | undefined): Promise<IExpiryConfig | undefined> => {
if (!list) {
return Promise.resolve(undefined);
}
return this._getConfigurationItem(listId).then((item: IConfigurationItem | undefined): IExpiryConfig | undefined => {
if (!item || !item[CONFIGURATION_FIELD]) {
return undefined;
}
try {
return normalizeConfig(JSON.parse(item[CONFIGURATION_FIELD]));
} catch (error) {
return undefined;
}
});
}); });
} }
private _getConfigurationList(createIfMissing: boolean): Promise<any> { private _getBoundField(listId: string): Promise<IBoundFieldInfo> {
const url: string = this._listUrl(listId) + '/fields' +
'?$select=Id,InternalName,ClientSideComponentId,ClientSideComponentProperties';
return this._spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this._headers() })
.then((response: SPHttpClientResponse): Promise<IBoundFieldInfo> => {
if (!response.ok) {
return responseError(response, 'Field-Customizer-Bindung konnte nicht gelesen werden')
.then((error: Error): Promise<IBoundFieldInfo> => Promise.reject(error));
}
return response.json().then((data: any): IBoundFieldInfo => {
const values: any[] = data.value || (data.d && data.d.results) || [];
for (let index: number = 0; index < values.length; index++) {
const source: any = values[index];
const componentId: string = String(source.ClientSideComponentId || '')
.replace(/[{}]/g, '').toLowerCase();
if (componentId === FIELD_CUSTOMIZER_COMPONENT_ID) {
return {
id: source.Id,
internalName: source.InternalName,
entityType: source.__metadata && source.__metadata.type ? source.__metadata.type : 'SP.Field',
properties: source.ClientSideComponentProperties || ''
};
}
}
throw new Error('In dieser Liste ist kein ExpiryIndicator-Field-Customizer gebunden.');
});
});
}
private _parseProperties(value: string, internalName: string): IExpiryConfig | undefined {
if (!value || value.trim() === '' || value.trim() === '{}') {
return undefined;
}
try {
const config: IExpiryConfig = normalizeConfig(JSON.parse(value));
config.expiryField = internalName;
return config;
} catch (error) {
return undefined;
}
}
private _getConfigurationList(): Promise<any> {
const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName'; const url: string = this._configurationListUrl() + '?$select=Id,ListItemEntityTypeFullName';
return this._spHttpClient.get(url, SPHttpClient.configurations.v1) return this._spHttpClient.get(url, SPHttpClient.configurations.v1)
.then((response: SPHttpClientResponse): Promise<any> => { .then((response: SPHttpClientResponse): Promise<any> => {
if (response.status === 404) { if (response.status === 404) {
return createIfMissing ? this._createConfigurationList() : Promise.resolve(undefined); return Promise.resolve(undefined);
} }
if (!response.ok) { if (!response.ok) {
return responseError(response, 'Konfigurationsliste konnte nicht gelesen werden') return responseError(response, 'Konfigurationsliste konnte nicht gelesen werden')
@@ -92,85 +160,6 @@ export class ExpiryConfigService {
}); });
} }
private _createConfigurationList(): Promise<IConfigurationListInfo> {
const body: any = {
'__metadata': { 'type': 'SP.List' },
'BaseTemplate': 100,
'Description': 'Technische Konfiguration der ExpiryIndicator-App.',
'Title': CONFIGURATION_LIST_TITLE
};
const options: ISPHttpClientOptions = {
headers: this._headers(),
body: JSON.stringify(body)
};
return this._spHttpClient.post(this._webUrl + '/_api/web/lists', SPHttpClient.configurations.v1, options)
.then((response: SPHttpClientResponse): Promise<IConfigurationListInfo> => {
if (!response.ok) {
// Another request may have created the list in parallel.
return this._getConfigurationList(false).then((existing: IConfigurationListInfo | undefined): any => {
if (existing) {
return existing;
}
return responseError(response, 'Konfigurationsliste konnte nicht erstellt werden')
.then((error: Error): Promise<any> => Promise.reject(error));
});
}
return this._createConfigurationField()
.then((): Promise<void> => this._hideConfigurationList())
.then((): Promise<any> => this._getConfigurationList(false));
});
}
private _createConfigurationField(): Promise<void> {
const body: any = {
'__metadata': { 'type': 'SP.Field' },
'FieldTypeKind': 3,
'Required': false,
'Title': CONFIGURATION_FIELD
};
const options: ISPHttpClientOptions = {
headers: this._headers(),
body: JSON.stringify(body)
};
return this._spHttpClient.post(
this._configurationListUrl() + '/fields',
SPHttpClient.configurations.v1,
options
).then((response: SPHttpClientResponse): Promise<void> => {
if (!response.ok) {
return responseError(response, 'Konfigurationsfeld konnte nicht erstellt werden')
.then((error: Error): Promise<void> => Promise.reject(error));
}
return Promise.resolve();
});
}
private _hideConfigurationList(): Promise<void> {
const options: ISPHttpClientOptions = {
headers: this._headers('MERGE'),
body: JSON.stringify({
'__metadata': { 'type': 'SP.List' },
'Hidden': true,
'OnQuickLaunch': false
})
};
return this._spHttpClient.post(
this._configurationListUrl(),
SPHttpClient.configurations.v1,
options
).then((response: SPHttpClientResponse): Promise<void> => {
if (!response.ok) {
return responseError(response, 'Konfigurationsliste konnte nicht ausgeblendet werden')
.then((error: Error): Promise<void> => Promise.reject(error));
}
return Promise.resolve();
});
}
private _getConfigurationItem(listId: string): Promise<IConfigurationItem | undefined> { private _getConfigurationItem(listId: string): Promise<IConfigurationItem | undefined> {
const filter: string = escapeODataString(listId.toLowerCase()); const filter: string = escapeODataString(listId.toLowerCase());
const url: string = this._configurationListUrl() + '/items' + const url: string = this._configurationListUrl() + '/items' +
@@ -195,6 +184,10 @@ export class ExpiryConfigService {
return this._webUrl + "/_api/web/lists/getbytitle('" + CONFIGURATION_LIST_TITLE + "')"; return this._webUrl + "/_api/web/lists/getbytitle('" + CONFIGURATION_LIST_TITLE + "')";
} }
private _listUrl(listId: string): string {
return this._webUrl + "/_api/web/lists(guid'" + listId.replace(/[{}]/g, '') + "')";
}
private _mapListInfo(data: any): IConfigurationListInfo { private _mapListInfo(data: any): IConfigurationListInfo {
const source: any = data.d || data; const source: any = data.d || data;
return { return {

View File

@@ -4,7 +4,7 @@
"alias": "ExpiryIndicatorFieldCustomizer", "alias": "ExpiryIndicatorFieldCustomizer",
"componentType": "Extension", "componentType": "Extension",
"extensionType": "FieldCustomizer", "extensionType": "FieldCustomizer",
"version": "1.0.8", "version": "1.0.10",
"manifestVersion": 2, "manifestVersion": 2,
"requiresCustomScript": false "requiresCustomScript": false
} }

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-extension-manifest.schema.json",
"id": "95691218-bcb1-4fad-adda-02bb12014d15",
"alias": "ExpiryIndicatorApplicationCustomizer",
"componentType": "Extension",
"extensionType": "ApplicationCustomizer",
"version": "1.0.10",
"manifestVersion": 2,
"requiresCustomScript": false
}

View File

@@ -0,0 +1,79 @@
import { override } from '@microsoft/decorators';
import { BaseApplicationCustomizer } from '@microsoft/sp-application-base';
import { Dialog } from '@microsoft/sp-dialog';
import { SPPermission } from '@microsoft/sp-page-context';
import { ExpiryConfigService } from '../../common/ExpiryConfigService';
import { IExpiryConfig } from '../../common/ExpiryModels';
import { ExpirySettingsDialog } from '../expiryIndicatorCommandSet/ExpirySettingsDialog';
import * as strings from 'ExpiryIndicatorCommandSetStrings';
export interface IExpiryIndicatorApplicationCustomizerProperties {
}
export default class ExpiryIndicatorApplicationCustomizer
extends BaseApplicationCustomizer<IExpiryIndicatorApplicationCustomizerProperties> {
@override
public onInit(): Promise<void> {
if (!this._isSettingsRequested()) {
return Promise.resolve();
}
if (!this.context.pageContext.list) {
Dialog.alert(strings.SettingsListRequired);
return Promise.resolve();
}
if (!this.context.pageContext.list.permissions.hasPermission(SPPermission.manageLists)) {
Dialog.alert(strings.SettingsPermissionRequired);
return Promise.resolve();
}
const listId: string = this.context.pageContext.list.id.toString();
const service: ExpiryConfigService = new ExpiryConfigService(
this.context.spHttpClient,
this.context.pageContext.web.absoluteUrl
);
service.getConfig(listId).then((config: IExpiryConfig): void => {
const dialog: ExpirySettingsDialog = new ExpirySettingsDialog(
config,
(newConfig: IExpiryConfig): Promise<void> => service.saveConfig(listId, newConfig)
);
dialog.show().then((): void => {
const cleanUrl: string = this._removeSettingsParameter();
if (dialog.wasSaved) {
window.location.replace(cleanUrl);
} else if (window.history && window.history.replaceState) {
window.history.replaceState(undefined, document.title, cleanUrl);
}
});
}).catch((error: Error): void => {
const message: string = error && error.message ? error.message : String(error);
Dialog.alert(strings.SettingsLoadFailed + ': ' + message);
});
return Promise.resolve();
}
private _isSettingsRequested(): boolean {
const values: string[] = window.location.search.replace(/^\?/, '').split('&');
for (let index: number = 0; index < values.length; index++) {
const pair: string[] = values[index].split('=');
if (decodeURIComponent(pair[0] || '').toLowerCase() === 'expiryindicatorsettings') {
const value: string = decodeURIComponent(pair.slice(1).join('=') || '1').toLowerCase();
return value === '1' || value === 'true' || value === 'yes';
}
}
return false;
}
private _removeSettingsParameter(): string {
const values: string[] = window.location.search.replace(/^\?/, '').split('&');
const remaining: string[] = values.filter((value: string): boolean => {
const name: string = decodeURIComponent((value.split('=')[0] || '')).toLowerCase();
return name !== 'expiryindicatorsettings' && value !== '';
});
return window.location.pathname + (remaining.length ? '?' + remaining.join('&') : '') + window.location.hash;
}
}

View File

@@ -4,7 +4,7 @@
"alias": "ExpiryIndicatorCommandSet", "alias": "ExpiryIndicatorCommandSet",
"componentType": "Extension", "componentType": "Extension",
"extensionType": "ListViewCommandSet", "extensionType": "ListViewCommandSet",
"version": "1.0.8", "version": "1.0.10",
"manifestVersion": 2, "manifestVersion": 2,
"requiresCustomScript": false, "requiresCustomScript": false,
"items": { "items": {
@@ -14,13 +14,6 @@
"de-de": "Ablaufdatum +1 Jahr" "de-de": "Ablaufdatum +1 Jahr"
}, },
"type": "command" "type": "command"
},
"SETTINGS": {
"title": {
"default": "Expiry settings",
"de-de": "Expiry-Einstellungen"
},
"type": "command"
} }
} }
} }

View File

@@ -10,7 +10,6 @@ import {
import { ExpiryConfigService } from '../../common/ExpiryConfigService'; import { ExpiryConfigService } from '../../common/ExpiryConfigService';
import { ExpiryItemService } from '../../common/ExpiryItemService'; import { ExpiryItemService } from '../../common/ExpiryItemService';
import { createDefaultConfig, IExpiryConfig, IUpdateResult } from '../../common/ExpiryModels'; import { createDefaultConfig, IExpiryConfig, IUpdateResult } from '../../common/ExpiryModels';
import { ExpirySettingsDialog } from './ExpirySettingsDialog';
import * as strings from 'ExpiryIndicatorCommandSetStrings'; import * as strings from 'ExpiryIndicatorCommandSetStrings';
export interface IExpiryIndicatorCommandSetProperties { export interface IExpiryIndicatorCommandSetProperties {
@@ -24,7 +23,6 @@ export default class ExpiryIndicatorCommandSet
private _itemService: ExpiryItemService; private _itemService: ExpiryItemService;
private _expiryFieldExists: boolean = false; private _expiryFieldExists: boolean = false;
private _canEdit: boolean = false; private _canEdit: boolean = false;
private _canManage: boolean = false;
@override @override
public onInit(): Promise<void> { public onInit(): Promise<void> {
@@ -37,7 +35,6 @@ export default class ExpiryIndicatorCommandSet
this._configService = new ExpiryConfigService(this.context.spHttpClient, webUrl); this._configService = new ExpiryConfigService(this.context.spHttpClient, webUrl);
this._itemService = new ExpiryItemService(this.context.spHttpClient, webUrl, listId); this._itemService = new ExpiryItemService(this.context.spHttpClient, webUrl, listId);
this._canEdit = this.context.pageContext.list.permissions.hasPermission(SPPermission.editListItems); this._canEdit = this.context.pageContext.list.permissions.hasPermission(SPPermission.editListItems);
this._canManage = this.context.pageContext.list.permissions.hasPermission(SPPermission.manageLists);
return this._configService.getConfig(listId).then((config: IExpiryConfig): Promise<void> => { return this._configService.getConfig(listId).then((config: IExpiryConfig): Promise<void> => {
this._config = config; this._config = config;
@@ -51,14 +48,9 @@ export default class ExpiryIndicatorCommandSet
@override @override
public onListViewUpdated(event: IListViewCommandSetListViewUpdatedParameters): void { public onListViewUpdated(event: IListViewCommandSetListViewUpdatedParameters): void {
const extendCommand: Command = this.tryGetCommand('EXTEND_ONE_YEAR'); const extendCommand: Command = this.tryGetCommand('EXTEND_ONE_YEAR');
const settingsCommand: Command = this.tryGetCommand('SETTINGS');
if (extendCommand) { if (extendCommand) {
extendCommand.visible = this._expiryFieldExists && this._canEdit && event.selectedRows.length > 0; extendCommand.visible = this._expiryFieldExists && this._canEdit && event.selectedRows.length > 0;
} }
if (settingsCommand) {
settingsCommand.visible = this._canManage;
}
} }
@override @override
@@ -68,11 +60,6 @@ export default class ExpiryIndicatorCommandSet
return; return;
} }
if (event.itemId === 'SETTINGS') {
this._openSettings();
return;
}
throw new Error('Unknown command: ' + event.itemId); throw new Error('Unknown command: ' + event.itemId);
} }
@@ -115,20 +102,6 @@ export default class ExpiryIndicatorCommandSet
} }
} }
private _openSettings(): void {
const listId: string = this.context.pageContext.list.id.toString();
const dialog: ExpirySettingsDialog = new ExpirySettingsDialog(
this._config,
(config: IExpiryConfig): Promise<void> => {
return this._configService.saveConfig(listId, config).then((): Promise<void> => {
this._config = config;
return this._refreshFieldState();
});
}
);
dialog.show();
}
private _refreshFieldState(): Promise<void> { private _refreshFieldState(): Promise<void> {
return this._itemService.fieldExists(this._config.expiryField).then((exists: boolean): void => { return this._itemService.fieldExists(this._config.expiryField).then((exists: boolean): void => {
this._expiryFieldExists = exists; this._expiryFieldExists = exists;

View File

@@ -10,6 +10,7 @@ import * as strings from 'ExpiryIndicatorCommandSetStrings';
export class ExpirySettingsDialog extends BaseDialog { export class ExpirySettingsDialog extends BaseDialog {
private _expiryConfig: IExpiryConfig; private _expiryConfig: IExpiryConfig;
private _save: (config: IExpiryConfig) => Promise<void>; private _save: (config: IExpiryConfig) => Promise<void>;
private _wasSaved: boolean = false;
public constructor(config: IExpiryConfig, save: (config: IExpiryConfig) => Promise<void>) { public constructor(config: IExpiryConfig, save: (config: IExpiryConfig) => Promise<void>) {
super(); super();
@@ -51,6 +52,7 @@ export class ExpirySettingsDialog extends BaseDialog {
this._setValue('baseField', this._expiryConfig.baseField); this._setValue('baseField', this._expiryConfig.baseField);
this._setValue('expiryField', this._expiryConfig.expiryField); this._setValue('expiryField', this._expiryConfig.expiryField);
(this._field('expiryField') as HTMLInputElement).readOnly = true;
this._setValue('lifetimeValue', String(this._expiryConfig.default.lifeTime.value)); this._setValue('lifetimeValue', String(this._expiryConfig.default.lifeTime.value));
this._setValue('lifetimeUnit', this._expiryConfig.default.lifeTime.unit); this._setValue('lifetimeUnit', this._expiryConfig.default.lifeTime.unit);
this._setValue('defaultColumnRule', JSON.stringify(this._expiryConfig.default.columnRule, undefined, 2)); this._setValue('defaultColumnRule', JSON.stringify(this._expiryConfig.default.columnRule, undefined, 2));
@@ -70,6 +72,10 @@ export class ExpirySettingsDialog extends BaseDialog {
return { isBlocking: true }; return { isBlocking: true };
} }
public get wasSaved(): boolean {
return this._wasSaved;
}
private _saveForm(): void { private _saveForm(): void {
const saveButton: HTMLButtonElement = this.domElement.querySelector('[data-action="save"]') as HTMLButtonElement; const saveButton: HTMLButtonElement = this.domElement.querySelector('[data-action="save"]') as HTMLButtonElement;
const errorElement: HTMLElement = this._field('error') as HTMLElement; const errorElement: HTMLElement = this._field('error') as HTMLElement;
@@ -96,6 +102,7 @@ export class ExpirySettingsDialog extends BaseDialog {
saveButton.disabled = true; saveButton.disabled = true;
this._save(config).then((): void => { this._save(config).then((): void => {
this._expiryConfig = config; this._expiryConfig = config;
this._wasSaved = true;
this.close(); this.close();
}).catch((error: Error): void => { }).catch((error: Error): void => {
saveButton.disabled = false; saveButton.disabled = false;

View File

@@ -15,5 +15,8 @@ define([], function() {
"NoItemsSelected": "Es wurden keine gültigen Elemente ausgewählt.", "NoItemsSelected": "Es wurden keine gültigen Elemente ausgewählt.",
"UpdateSummary": "Erfolgreich aktualisiert: {0}; Fehler: {1}", "UpdateSummary": "Erfolgreich aktualisiert: {0}; Fehler: {1}",
"ConfirmMessage": "Ablaufdatum für {0} Element(e) um ein Kalenderjahr verlängern?" "ConfirmMessage": "Ablaufdatum für {0} Element(e) um ein Kalenderjahr verlängern?"
,"SettingsListRequired": "Die ExpiryIndicator-Einstellungen m\u00fcssen aus einer modernen Listen- oder Bibliotheksansicht ge\u00f6ffnet werden."
,"SettingsPermissionRequired": "Zum Bearbeiten der ExpiryIndicator-Einstellungen ist die Berechtigung zum Verwalten dieser Liste erforderlich."
,"SettingsLoadFailed": "Die ExpiryIndicator-Einstellungen konnten nicht geladen werden"
}; };
}); });

View File

@@ -15,5 +15,8 @@ define([], function() {
"NoItemsSelected": "No valid items were selected.", "NoItemsSelected": "No valid items were selected.",
"UpdateSummary": "Successfully updated: {0}; errors: {1}", "UpdateSummary": "Successfully updated: {0}; errors: {1}",
"ConfirmMessage": "Extend the expiry date for {0} item(s) by one calendar year?" "ConfirmMessage": "Extend the expiry date for {0} item(s) by one calendar year?"
,"SettingsListRequired": "Open the ExpiryIndicator settings from a modern list or document library view."
,"SettingsPermissionRequired": "You need permission to manage this list to edit the ExpiryIndicator settings."
,"SettingsLoadFailed": "The ExpiryIndicator settings could not be loaded"
}; };
}); });

View File

@@ -14,6 +14,9 @@ declare interface IExpiryIndicatorCommandSetStrings {
NoItemsSelected: string; NoItemsSelected: string;
UpdateSummary: string; UpdateSummary: string;
ConfirmMessage: string; ConfirmMessage: string;
SettingsListRequired: string;
SettingsPermissionRequired: string;
SettingsLoadFailed: string;
} }
declare module 'ExpiryIndicatorCommandSetStrings' { declare module 'ExpiryIndicatorCommandSetStrings' {