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:
@@ -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<ICustomBrandingSettings> = {
|
||||
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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user