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

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