diff --git a/Build/Scripts/checkIntegritySetLabels.php b/Build/Scripts/checkIntegritySetLabels.php index b001664560f72e61348c3209b9eb37315ae7b94b..2c7d23a8a2d66c607887568e427e8c586ac665bc 100755 --- a/Build/Scripts/checkIntegritySetLabels.php +++ b/Build/Scripts/checkIntegritySetLabels.php @@ -93,10 +93,14 @@ final readonly class CheckIntegritySetLabels ]; $settingsDefinitions = Yaml::parseFile(dirname($labelFile) . '/settings.definitions.yaml'); - foreach ($settingsDefinitions['settings'] as $key => $settingsDefinition) { + foreach (($settingsDefinitions['settings'] ?? []) as $key => $_) { $requiredLabels[] = 'settings.' . $key; $optionalLabels[] = 'settings.description.' . $key; } + foreach (($settingsDefinitions['categories'] ?? []) as $key => $_) { + $requiredLabels[] = 'categories.' . $key; + $optionalLabels[] = 'categories.description.' . $key; + } $setName = Yaml::parseFile(dirname($labelFile) . '/config.yaml')['name']; diff --git a/Build/Sources/Sass/backend.scss b/Build/Sources/Sass/backend.scss index 1fc8b62a7e1d382b456d92c0c724900d977bf335..5d3bf18ad7d4ad3e42fcb434ecea88a486484329 100644 --- a/Build/Sources/Sass/backend.scss +++ b/Build/Sources/Sass/backend.scss @@ -48,6 +48,7 @@ @import "component/treelist"; @import "component/indent"; @import "component/pagination"; +@import "component/settings"; @import "component/example"; // diff --git a/Build/Sources/Sass/component/_settings.scss b/Build/Sources/Sass/component/_settings.scss new file mode 100644 index 0000000000000000000000000000000000000000..21b40957f8f363431df3b252e5b997710726043f --- /dev/null +++ b/Build/Sources/Sass/component/_settings.scss @@ -0,0 +1,264 @@ +:root { + --settings-color: var(--typo3-component-color); + --settings-padding: calc(var(--typo3-spacing) * 2); + --settings-bg: var(--typo3-component-bg); + --settings-border-width: var(--typo3-component-border-width); + --settings-border-color: var(--typo3-component-border-color); + --settings-border-radius: var(--typo3-component-border-radius); + --settings-box-shadow: var(--typo3-component-box-shadow); + --settings-highlight: var(--typo3-component-primary-color); + --settings-indicator-bg: transparent; + --settings-item-color: var(--settings-color); + --settings-item-bg: var(--settings-bg); +} + +.module[data-module-name="site_settings"] { + .module-body { + width: 100%; + max-width: 1320px; + margin: 0 auto; + } +} + +.settings-container { + container-type: inline-size; +} + +.settings { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + color: var(--settings-color); + box-shadow: var(--settings-box-shadow); + + &-navigation, + &-body { + padding: var(--settings-padding); + } + + &-body { + background: var(--settings-bg); + border: var(--settings-border-width) solid var(--settings-border-color); + border-radius: var(--settings-border-radius); + } + + &-navigation { + border-inline-start: var(--settings-border-width) solid var(--settings-border-color); + border-inline-end: var(--settings-border-width) solid var(--settings-border-color); + border-top: var(--settings-border-width) solid var(--settings-border-color); + border-bottom: 0; + flex: 1 1 auto; + } +} + +/* clean-css ignore:start */ +@container (min-width: 780px) { + .settings { + flex-wrap: nowrap; + box-shadow: none; + + &-body { + flex: 1 1 auto; + border-inline-start: var(--settings-border-width) solid var(--settings-border-color); + border-start-start-radius: 0; + box-shadow: var(--settings-box-shadow); + } + + &-navigation { + box-shadow: var(--settings-box-shadow); + flex: 0 0 300px; + position: sticky; + top: calc(var(--module-body-padding-y) + var(--module-docheader-height)); + border-inline-end: 0; + border-inline-start: var(--settings-border-width) solid var(--settings-border-color); + border-top: var(--settings-border-width) solid var(--settings-border-color); + border-bottom: var(--settings-border-width) solid var(--settings-border-color); + } + } +} + +/* clean-css ignore:end */ + +.settings-navigation { + ul { + list-style: none; + margin: 0; + padding: 0; + + ul { + padding-inline-start: 1rem; + } + } +} + +.settings-navigation-item { + color: var(--typo3-component-color); + position: relative; + display: flex; + border-radius: calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width)); + gap: .5em; + padding: var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x); + cursor: pointer; + text-decoration: none; + + &.active, + &:hover, + &:focus { + z-index: 1; + outline-offset: -1px; + } + + &:hover { + color: var(--typo3-component-hover-color); + background-color: var(--typo3-component-hover-bg); + outline: 1px solid var(--typo3-component-hover-border-color); + } + + &.active, + &:focus { + color: var(--typo3-component-focus-color); + background-color: var(--typo3-component-focus-bg); + outline: 1px solid var(--typo3-component-focus-border-color); + } + + &-icon { + user-select: none; + flex-shrink: 0; + flex-grow: 0; + width: var(--icon-size-small); + } + + &-label { + user-select: none; + flex-grow: 1; + } +} + +.settings-category { + max-width: 600px; + text-wrap: balance; + + &-list + &-list { + margin-top: calc(var(--typo3-spacing) * 2); + } +} + +.settings-item { + position: relative; + color: var(--settings-item-color); + background: var(--settings-item-bg); + border-radius: calc(var(--settings-border-radius) / 2); + padding-block: var(--typo3-component-padding-y); + padding-inline-start: calc(var(--typo3-component-padding-x) + 4px); + padding-inline-end: calc(var(--typo3-component-padding-x) + 3rem); + margin-inline-start: calc(-1 * var(--typo3-component-padding-x)); + margin-inline-end: calc(-1 * var(--typo3-component-padding-x)); + + &:focus-within, + &:focus-within * { + --settings-item-bg: var(--typo3-component-focus-bg); + --settings-item-color: var(--typo3-component-focus-color); + } + + &:focus-within { + outline-offset: -1px; + outline: 1px solid var(--typo3-component-focus-border-color); + } + + &:hover, + &:focus, + &:focus-within { + .settings-item-actions { + opacity: 1; + } + } + + &-indicator { + position: absolute; + background: var(--settings-indicator-bg); + inset-inline-start: 1px; + inset-block-start: 1px; + inset-block-end: 1px; + width: 4px; + border-radius: 0; + } + + &[data-status="modified"], + &[data-status="modified"] * { + --settings-indicator-bg: #{$info}; + } + + &[data-status="error"], + &[data-status="error"] * { + --settings-indicator-bg: #{$danger}; + } + + &-actions { + opacity: 0; + position: absolute; + display: flex; + justify-content: center; + inset-inline-end: 0; + inset-block-start: 0; + inset-block-end: 0; + padding-block: var(--typo3-component-padding-y); + width: 3rem; + transition: opacity .3s ease-in-out; + + & > .dropdown > button { + display: flex; + justify-content: center; + align-items: center; + background-color: transparent; + border: none; + color: inherit; + outline: none; + width: 32px; + height: 32px; + padding: 0; + margin-top: -4px; + border-radius: 50%; + + &:hover { + background: color-mix(in srgb, var(--settings-item-bg), var(--settings-item-color) 10%); + } + + &:focus { + background: color-mix(in srgb, var(--typo3-component-focus-bg), var(--typo3-component-focus-border-color) 20%); + color: var(--typo3-component-focus-color); + } + + &:after { + display: none; + } + } + } + + &-title { + margin-bottom: calc(var(--typo3-spacing) / 2); + } + + &-label { + font-weight: bold; + } + + &-description { + color: color-mix(in srgb, var(--settings-color), var(--settings-bg) 25%); + } + + &-key { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--typo3-font-family-code); + color: var(--settings-highlight); + } + + &-message { + margin-top: calc(var(--typo3-spacing) / 2); + + &:empty { + display: none; + } + } +} diff --git a/Build/Sources/TypeScript/backend/copy-to-clipboard.ts b/Build/Sources/TypeScript/backend/copy-to-clipboard.ts index 4e74d4e13bfadb85353066496b7c55a297f578dc..26a7d0eec92d28610c7571c28e05c1529c0dcf79 100644 --- a/Build/Sources/TypeScript/backend/copy-to-clipboard.ts +++ b/Build/Sources/TypeScript/backend/copy-to-clipboard.ts @@ -16,6 +16,37 @@ import { customElement, property } from 'lit/decorators'; import Notification from '@typo3/backend/notification'; import { lll } from '@typo3/core/lit-helper'; +export function copyToClipboard(text: string): void { + if (!text.length) { + console.warn('No text for copy to clipboard given.'); + Notification.error(lll('copyToClipboard.error')); + return; + } + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then((): void => { + Notification.success(lll('copyToClipboard.success'), '', 1); + }).catch((): void => { + Notification.error(lll('copyToClipboard.error')); + }); + } else { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + if (document.execCommand('copy')) { + Notification.success(lll('copyToClipboard.success'), '', 1); + } else { + Notification.error(lll('copyToClipboard.error')); + } + } catch { + Notification.error(lll('copyToClipboard.error')); + } + document.body.removeChild(textarea); + } +} + /** * Module: @typo3/backend/copy-to-clipboard * @@ -60,34 +91,12 @@ export class CopyToClipboard extends LitElement { } private copyToClipboard(): void { - if (typeof this.text !== 'string' || !this.text.length) { + if (typeof this.text !== 'string') { console.warn('No text for copy to clipboard given.'); Notification.error(lll('copyToClipboard.error')); return; } - if (navigator.clipboard) { - navigator.clipboard.writeText(this.text).then((): void => { - Notification.success(lll('copyToClipboard.success'), '', 1); - }).catch((): void => { - Notification.error(lll('copyToClipboard.error')); - }); - } else { - const textarea = document.createElement('textarea'); - textarea.value = this.text; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - try { - if (document.execCommand('copy')) { - Notification.success(lll('copyToClipboard.success'), '', 1); - } else { - Notification.error(lll('copyToClipboard.error')); - } - } catch { - Notification.error(lll('copyToClipboard.error')); - } - document.body.removeChild(textarea); - } + copyToClipboard(this.text); } } diff --git a/Build/Sources/TypeScript/backend/settings/editor.ts b/Build/Sources/TypeScript/backend/settings/editor.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c4b81ea341172f72e98cb793212ff3dc57e7123 --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/editor.ts @@ -0,0 +1,210 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import { html, LitElement, TemplateResult, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators'; +import '@typo3/backend/element/spinner-element'; +import '@typo3/backend/element/icon-element'; +import Notification from '@typo3/backend/notification'; +import AjaxRequest from '@typo3/core/ajax/ajax-request'; +import { copyToClipboard } from '@typo3/backend/copy-to-clipboard'; +import { lll } from '@typo3/core/lit-helper'; +import '@typo3/backend/settings/editor/editable-setting'; + +// preload known/common types +import '@typo3/backend/settings/type/bool'; +import '@typo3/backend/settings/type/int'; +import '@typo3/backend/settings/type/number'; +import '@typo3/backend/settings/type/string'; +import '@typo3/backend/settings/type/stringlist'; + +type ValueType = string|number|boolean|string[]|null; + + +export interface Category { + key: string, + label: string, + description: string, + icon: string, + settings: EditableSetting[], + categories: Category[], +} + +/** @see \TYPO3\CMS\Core\Settings\SettingDefinition */ +export interface SettingDefinition { + key: string, + type: string, + default: ValueType, + label: string, + description?: string|null, + enum: ValueType[], + categories: string[], + tags: string[], +} + +/** @see \TYPO3\CMS\Backend\Dto\Settings\EditableSetting */ +export interface EditableSetting { + definition: SettingDefinition, + value: ValueType, + systemDefault: ValueType, + status: string, + warnings: string[], + typeImplementation: string, +} + +@customElement('typo3-backend-settings-editor') +export class SettingsEditorElement extends LitElement { + + @property({ type: Array }) categories: Category[]; + @property({ type: String, attribute: 'action-url' }) actionUrl: string; + @property({ type: String, attribute: 'dump-url' }) dumpUrl: string; + @property({ type: String, attribute: 'return-url' }) returnUrl: string; + + @state() activeCategory: string = ''; + + visibleCategories: Record<string, boolean> = {}; + observer: IntersectionObserver = null + + protected createRenderRoot(): HTMLElement | ShadowRoot { + return this; + } + + protected override firstUpdated(): void { + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach(entry => { + const key = (entry.target as HTMLElement).dataset.key; + this.visibleCategories[key] = entry.isIntersecting; + }) + const flatten = (list: Category[]): string[] => list.reduce((acc, c) => [...acc, c.key, ...flatten(c.categories)], []); + const active = flatten(this.categories).filter(key => this.visibleCategories[key])[0] || ''; + if (active) { + this.activeCategory = active; + } + }, + { + root: document.querySelector('.module'), + threshold: 0.1, + rootMargin: `-${getComputedStyle(document.querySelector('.module-docheader')).getPropertyValue('min-height')} 0px 0px 0px` + } + ) + } + + protected override updated(): void { + [...this.renderRoot.querySelectorAll('.settings-category')].map(entry => this.observer?.observe(entry)); + } + + protected renderCategoryTree(categories: Category[], level: number): TemplateResult { + return html` + <ul data-level=${level}> + ${categories.map(category => html` + <li> + <a href=${`#category-headline-${category.key}`} + @click=${() => this.activeCategory = category.key} + class="settings-navigation-item ${this.activeCategory === category.key ? 'active' : ''}"> + <span class="settings-navigation-item-icon"> + <typo3-backend-icon identifier=${category.icon ? category.icon : 'actions-dot'} size="small"></typo3-backend-icon> + </span> + <span class="settings-navigation-item-label">${category.label}</span> + </a> + ${category.categories.length === 0 ? nothing : html` + ${this.renderCategoryTree(category.categories, level + 1)} + `} + </li> + `)} + </ul> + `; + } + + protected renderSettings(categories: Category[], level: number): TemplateResult[] { + return categories.map(category => html` + <div class="settings-category-list" data-key=${category.key}> + <div class="settings-category" data-key=${category.key}> + ${this.renderHeadline(Math.min(level + 1, 6), `category-headline-${category.key}`, html`${category.label}`)} + ${category.description ? html`<p>${category.description}</p>` : nothing} + </div> + ${category.settings.map((setting): TemplateResult => html` + <typo3-backend-editable-setting .setting=${setting} .dumpuri=${this.dumpUrl}></typo3-backend-editable-setting> + `)} + </div> + ${category.categories.length === 0 ? nothing : html` + ${this.renderSettings(category.categories, level + 1)} + `} + `); + } + + protected renderHeadline(level: number, id: string, content: TemplateResult): TemplateResult { + switch (level) { + case 1: + return html`<h1 id=${id}>${content}</h1>`; + case 2: + return html`<h2 id=${id}>${content}</h2>`; + case 3: + return html`<h3 id=${id}>${content}</h3>`; + case 4: + return html`<h4 id=${id}>${content}</h4>`; + case 5: + return html`<h5 id=${id}>${content}</h5>`; + case 6: + return html`<h6 id=${id}>${content}</h6>`; + default: + throw new Error(`Invalid header level: ${level}`); + } + } + + protected async onSubmit(e: SubmitEvent): Promise<void> { + const form = e.target as HTMLFormElement; + + if ((e.submitter as HTMLButtonElement|null)?.value === 'export') { + e.preventDefault(); + const formData = new FormData(form); + const response = await new AjaxRequest(this.dumpUrl).post(formData); + + const result = await response.resolve(); + if (typeof result.yaml === 'string') { + copyToClipboard(result.yaml); + } else { + console.warn('Value can not be copied to clipboard.', typeof result.yaml); + Notification.error(lll('copyToClipboard.error')); + } + } + } + + protected render(): TemplateResult { + return html` + <form class="settings-container" + id="sitesettings_form" + name="sitesettings_form" + action=${this.actionUrl} + method="post" + @submit=${(e: SubmitEvent) => this.onSubmit(e)} + > + ${this.returnUrl ? html`<input type="hidden" name="returnUrl" value=${this.returnUrl} />` : nothing} + <div class="settings"> + <div class="settings-navigation"> + ${this.renderCategoryTree(this.categories ?? [], 1)} + </div> + <div class="settings-body"> + ${this.renderSettings(this.categories ?? [], 1)} + </div> + </div> + </form> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'typo3-backend-settings-editor': SettingsEditorElement; + } +} diff --git a/Build/Sources/TypeScript/backend/settings/editor/editable-setting.ts b/Build/Sources/TypeScript/backend/settings/editor/editable-setting.ts new file mode 100644 index 0000000000000000000000000000000000000000..b431246efca25e3fc81153fc70ac9cbf151dafe0 --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/editor/editable-setting.ts @@ -0,0 +1,197 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import { html, LitElement, TemplateResult, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators'; +import { until } from 'lit/directives/until.js'; +import '@typo3/backend/element/spinner-element'; +import '@typo3/backend/element/icon-element'; +import { copyToClipboard } from '@typo3/backend/copy-to-clipboard'; +import Notification from '@typo3/backend/notification'; +import { lll } from '@typo3/core/lit-helper'; +import AjaxRequest from '@typo3/core/ajax/ajax-request'; +import type { BaseElement } from '@typo3/backend/settings/type/base'; + +type ValueType = string|number|boolean|string[]|null; + +/** @see \TYPO3\CMS\Core\Settings\SettingDefinition */ +interface SettingDefinition { + key: string, + type: string, + default: ValueType, + label: string, + description?: string|null, + enum: ValueType[], + categories: string[], + tags: string[], +} + +/** @see \TYPO3\CMS\Backend\Dto\Settings\EditableSetting */ +interface EditableSetting { + definition: SettingDefinition, + value: ValueType, + systemDefault: ValueType, + status: string, + warnings: string[], + typeImplementation: string, +} + +@customElement('typo3-backend-editable-setting') +export class EditableSettingElement extends LitElement { + + @property({ type: Object }) setting: EditableSetting; + @property({ type: String }) dumpuri: string; + + @state() + hasChange: boolean = false; + + typeElement: BaseElement<unknown> = null; + + protected createRenderRoot(): HTMLElement | ShadowRoot { + return this; + } + + protected render(): TemplateResult { + const { value, systemDefault, definition } = this.setting; + return html` + <div + class=${`settings-item settings-item-${definition.type} ${this.hasChange ? 'has-change' : ''}`} + tabindex="0" + data-status=${JSON.stringify(value) === JSON.stringify(systemDefault) ? 'none' : 'modified'} + > + <!-- data-status=modified|error|none--> + <div class="settings-item-indicator"></div> + <div class="settings-item-title"> + <label for=${`setting-${definition.key}`} class="settings-item-label">${definition.label}</label> + <div class="settings-item-description">${definition.description}</div> + <div class="settings-item-key">${definition.key}</div> + </div> + <div class="settings-item-control"> + ${until(this.renderField(), html`<typo3-backend-spinner></typo3-backend-spinner>`)} + </div> + <div class="settings-item-message"></div> + <div class="settings-item-actions"> + ${this.renderActions()} + </div> + </div> + `; + } + + protected async renderField(): Promise<HTMLElement> { + const { definition, value, typeImplementation } = this.setting; + let element = this.typeElement + if (!element) { + const implementation = await import(typeImplementation); + if (!('componentName' in implementation)) { + throw new Error(`module ${typeImplementation} is missing the "componentName" export`); + } + element = document.createElement(implementation.componentName); + this.typeElement = element; + + element.addEventListener('typo3:setting:changed', (e: CustomEvent) => { + this.hasChange = JSON.stringify(this.setting.value) !== JSON.stringify(e.detail.value); + }); + } + + const attributes = { + key: definition.key, + formid: `setting-${definition.key}`, + name: `settings[${definition.key}]`, + value: Array.isArray(value) ? JSON.stringify(value) : String(value), + default: Array.isArray(definition.default) ? JSON.stringify(definition.default) : String(definition.default), + }; + for (const [key, value] of Object.entries(attributes)) { + if (element.getAttribute(key) !== value) { + element.setAttribute(key, value); + } + } + + return element; + } + + protected renderActions(): TemplateResult { + const { definition } = this.setting; + return html` + <div class="dropdown"> + <button class="dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> + <typo3-backend-icon identifier="actions-cog" size="small"></typo3-backend-icon> + <span class="visually-hidden">More actions</span> + </button> + <ul class="dropdown-menu"> + <li> + <button class="dropdown-item dropdown-item-spaced" + type="button" + @click="${() => this.setToDefaultValue()}"> + <typo3-backend-icon identifier="actions-undo" size="small"></typo3-backend-icon> ${lll('edit.resetSetting')} + </button> + </li> + <li><hr class="dropdown-divider"></li> + <li> + <typo3-copy-to-clipboard + text=${definition.key} + class="dropdown-item dropdown-item-spaced" + > + <typo3-backend-icon identifier="actions-clipboard" size="small"></typo3-backend-icon> ${lll('edit.copySettingsIdentifier')} + </typo3-copy-to-clipboard> + </li> + ${this.dumpuri ? html` + <li> + <button class="dropdown-item dropdown-item-spaced" + type="button" + @click="${() => this.copyAsYaml()}"> + <typo3-backend-icon identifier="actions-clipboard-paste" size="small"></typo3-backend-icon> ${lll('edit.copyAsYaml')} + + </a> + </li> + ` : nothing} + </ul> + </div> + ` + } + + protected setToDefaultValue(): void { + if (this.typeElement) { + this.typeElement.value = this.setting.systemDefault as unknown; + } + } + + protected async copyAsYaml(): Promise<void> { + const formData = new FormData(this.typeElement.form); + const name = `settings[${this.setting.definition.key}]` + const value = formData.get(name); + + const data = new FormData(); + data.append('specificSetting', this.setting.definition.key); + data.append(name, value); + + // @todo hookup with NProgress + const response = await new AjaxRequest(this.dumpuri).post( + data + ); + + const result = await response.resolve(); + + if (typeof result.yaml === 'string') { + copyToClipboard(result.yaml); + } else { + console.warn('Value can not be copied to clipboard.', typeof result.yaml); + Notification.error(lll('copyToClipboard.error')); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'typo3-backend-editable-setting': EditableSettingElement; + } +} diff --git a/Build/Sources/TypeScript/backend/settings/type/base.ts b/Build/Sources/TypeScript/backend/settings/type/base.ts new file mode 100644 index 0000000000000000000000000000000000000000..db6781ae726a83ac02eb7eba3267435b43600c6f --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/type/base.ts @@ -0,0 +1,194 @@ + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +/* eslint-disable @typescript-eslint/member-ordering */ + +import { LitElement, PropertyDeclaration, ReactiveElement } from 'lit'; +import { defaultConverter } from '@lit/reactive-element'; +import { property } from 'lit/decorators'; + +export const internals = Symbol('internals'); +const privateInternals = Symbol('privateInternals'); +export const getFormValue = Symbol('getFormValue'); +export const getFormState = Symbol('getFormState'); + +/** + * Base element class for settings type to act as + * a form associated custom element. + * + * See https://web.dev/articles/more-capable-form-controls#defining_a_form-associated_custom_element + */ +export abstract class BaseElement<T = string> extends LitElement { + + @property({ type: String }) key: string; + @property({ type: String }) formid: string; + + static readonly formAssociated = true; + + /* @property annotation needs to be provided by extending class */ + value: T; + + [privateInternals]?: ElementInternals; + + protected createRenderRoot(): HTMLElement | ShadowRoot { + return this; + } + + protected get [internals]() { + // Create internals in getter so that it can be used in methods called on + // construction in `ReactiveElement`, such as `requestUpdate()`. + if (!this[privateInternals]) { + this[privateInternals] = this.attachInternals(); + } + + return this[privateInternals]; + } + + public get form() { + return this[internals].form; + } + + protected get labels() { + return this[internals].labels; + } + + // Use @property for the `name` and `disabled` properties to add them to the + // `observedAttributes` array and trigger `attributeChangedCallback()`. + // + // We don't use Lit's default getter/setter (`noAccessor: true`) because + // the attributes need to be updated synchronously to work with synchronous + // form APIs, and Lit updates attributes async by default. + @property({ noAccessor: true }) + public get name() { + return this.getAttribute('name') ?? ''; + } + public set name(name: string) { + // Note: setting name to null or empty does not remove the attribute. + this.setAttribute('name', name); + // We don't need to call `requestUpdate()` since it's called synchronously + // in `attributeChangedCallback()`. + } + + @property({ type: Boolean, noAccessor: true }) + public get disabled() { + return this.hasAttribute('disabled'); + } + + public set disabled(disabled: boolean) { + this.toggleAttribute('disabled', disabled); + // We don't need to call `requestUpdate()` since it's called synchronously + // in `attributeChangedCallback()`. + } + + public override attributeChangedCallback( + name: string, + old: string | null, + value: string | null, + ) { + // Manually `requestUpdate()` for `name` and `disabled` when their + // attribute or property changes. + // The properties update their attributes, so this callback is invoked + // immediately when the properties are set. We call `requestUpdate()` here + // instead of letting Lit set the properties from the attribute change. + // That would cause the properties to re-set the attribute and invoke this + // callback again in a loop. This leads to stale state when Lit tries to + // determine if a property changed or not. + if (name === 'name' || name === 'disabled') { + // Disabled's value is only false if the attribute is missing and null. + const oldValue = name === 'disabled' ? old !== null : old; + // Trigger a lit update when the attribute changes. + this.requestUpdate(name, oldValue); + return; + } + + super.attributeChangedCallback(name, old, value); + } + + public override requestUpdate( + name?: PropertyKey, + oldValue?: unknown, + options?: PropertyDeclaration, + ) { + super.requestUpdate(name, oldValue, options); + if (name === 'value') { + this.dispatchEvent(new CustomEvent('typo3:setting:changed', { detail: { value: this.value } })); + // Update the form value synchronously in `requestUpdate()` rather than + // `update()` or `updated()`, which are async. This is necessary to ensure + // that form data is updated in time for synchronous event listeners. + this[internals].setFormValue(this[getFormValue](), this[getFormState]()); + } + } + + public formDisabledCallback(disabled: boolean) { + this.disabled = disabled; + } + + /** + * Callback triggered when <button type=reset> or form.reset() is triggered. + */ + public formResetCallback() { + const oldValue = this.value; + const defaultValue = this.getAttribute('value'); + + // Workaround to trigger string to property conversion + this.attributeChangedCallback('value', this.valueToString(oldValue), null); + this.attributeChangedCallback('value', null, defaultValue); + } + + /** + * Callback triggered when form is (re-)loaded by browser-back button. + */ + public formStateRestoreCallback(state: FormValue): void { + if (typeof state === 'string') { + this.attributeChangedCallback('value', this.valueToString(this.value), null); + this.attributeChangedCallback('value', null, state); + } else { + throw new Error(`formStateRestoreCallback() needs to be implemented for <${this.localName}> for state type "${typeof state}"`); + } + } + + protected [getFormState](): FormValue | null { + return this[getFormValue](); + } + + protected [getFormValue](): string { + return this.valueToString(this.value); + } + + protected valueToString(value: T): string { + const ctor = this.constructor as typeof ReactiveElement; + const options = ctor.getPropertyOptions('value'); + const converter = typeof options.converter === 'object' && typeof options.converter?.toAttribute === 'function' ? + options.converter.toAttribute : defaultConverter.toAttribute; + return converter(value, options.type) as string; + } +} + +export type FormValue = File | string | FormData; + +/** + * A value to be restored for a component's form value. If a component's form + * state is a `FormData` object, its entry list of name and values will be + * provided. + */ +export type FormRestoreState = + | File + | string + | Array<[string, FormDataEntryValue]>; + +/** + * The reason a form component is being restored for, either `'restore'` for + * browser restoration or `'autocomplete'` for restoring user values. + */ +export type FormRestoreReason = 'restore' | 'autocomplete'; diff --git a/Build/Sources/TypeScript/backend/settings/type/bool.ts b/Build/Sources/TypeScript/backend/settings/type/bool.ts new file mode 100644 index 0000000000000000000000000000000000000000..782e1d5e92455eb7d6da8ed23dcaf3b8826c2bd1 --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/type/bool.ts @@ -0,0 +1,55 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import { html, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { BaseElement } from './base'; + +export const componentName = 'typo3-backend-settings-type-bool'; + +@customElement(componentName) +export class BoolTypeElement extends BaseElement<boolean> { + + @property({ + type: Boolean, + converter: { + toAttribute: (value: boolean): string => { + return value ? '1' : '0'; + }, + fromAttribute: (value: string): boolean => { + return value === '1' || value === 'true'; + } + } + }) value: boolean; + + protected render(): TemplateResult { + return html` + <div class="form-check form-check-type-toggle"> + <input + type="checkbox" + id=${this.formid} + class="form-check-input" + value="1" + .checked=${this.value} + @change=${(e: InputEvent) => this.value = (e.target as HTMLInputElement).checked ? true : false} + /> + </div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'typo3-backend-settings-type-bool': BoolTypeElement; + } +} diff --git a/Build/Sources/TypeScript/backend/settings/type/color.ts b/Build/Sources/TypeScript/backend/settings/type/color.ts new file mode 100644 index 0000000000000000000000000000000000000000..66a591a2fa6851bcedc345a5f1bff5323b395182 --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/type/color.ts @@ -0,0 +1,63 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import { html, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { BaseElement } from './base'; +import Alwan from 'alwan'; + +export const componentName = 'typo3-backend-settings-type-color'; + +@customElement(componentName) +export class ColorTypeElement extends BaseElement<string> { + + @property({ type: String }) value: string; + + private alwan: Alwan|null = null; + + protected firstUpdated(): void { + this.alwan = new Alwan(this.querySelector('input'), { + position: 'bottom-start', + format: 'hex', + opacity: false, + preset: false, + color: this.value, + }); + this.alwan.on('color', (e): void => { + this.value = e.hex; + }); + } + + protected updateValue(value: string) { + this.value = value; + this.alwan?.setColor(value); + } + + protected render(): TemplateResult { + return html` + <input + type="text" + id=${this.formid} + class="form-control" + .value=${this.value} + @change=${(e: InputEvent) => this.updateValue((e.target as HTMLInputElement).value)} + /> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'typo3-backend-settings-type-color': ColorTypeElement; + } +} diff --git a/Build/Sources/TypeScript/backend/settings/type/int.ts b/Build/Sources/TypeScript/backend/settings/type/int.ts new file mode 100644 index 0000000000000000000000000000000000000000..08d807b7ea8762328fdadd243642feeec5df7c71 --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/type/int.ts @@ -0,0 +1,42 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import { html, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { BaseElement } from './base'; + +export const componentName = 'typo3-backend-settings-type-int'; + +@customElement(componentName) +export class IntTypeElement extends BaseElement<number> { + + @property({ type: Number }) value: number; + + protected render(): TemplateResult { + return html` + <input + type="number" + id=${this.formid} + class="form-control" + .value=${this.value} + @change=${(e: InputEvent) => this.value = parseInt((e.target as HTMLInputElement).value, 10)} + /> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'typo3-backend-settings-type-int': IntTypeElement; + } +} diff --git a/Build/Sources/TypeScript/backend/settings/type/number.ts b/Build/Sources/TypeScript/backend/settings/type/number.ts new file mode 100644 index 0000000000000000000000000000000000000000..efd06786b17e2f6d9415c5c1f37a07c45aacc602 --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/type/number.ts @@ -0,0 +1,43 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import { html, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { BaseElement } from './base'; + +export const componentName = 'typo3-backend-settings-type-number'; + +@customElement(componentName) +export class NumberTypeElement extends BaseElement<number> { + + @property({ type: Number }) value: number; + + protected render(): TemplateResult { + return html` + <input + type="number" + id=${this.formid} + class="form-control" + step="0.01" + .value=${this.value} + @change=${(e: InputEvent) => this.value = parseFloat((e.target as HTMLInputElement).value)} + /> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'typo3-backend-settings-type-number': NumberTypeElement; + } +} diff --git a/Build/Sources/TypeScript/backend/settings/type/string.ts b/Build/Sources/TypeScript/backend/settings/type/string.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9f500121ef3eacae67dce41947d95d8c69ff77a --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/type/string.ts @@ -0,0 +1,42 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import { html, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { BaseElement } from './base'; + +export const componentName = 'typo3-backend-settings-type-string'; + +@customElement(componentName) +export class StringTypeElement extends BaseElement<string> { + + @property({ type: String }) value: string; + + protected render(): TemplateResult { + return html` + <input + type="text" + id=${this.formid} + class="form-control" + .value=${this.value} + @change=${(e: InputEvent) => this.value = (e.target as HTMLInputElement).value} + /> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'typo3-backend-settings-type-string': StringTypeElement; + } +} diff --git a/Build/Sources/TypeScript/backend/settings/type/stringlist.ts b/Build/Sources/TypeScript/backend/settings/type/stringlist.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0221ac98c32085ebdb1e226bdd1c7dce422748c --- /dev/null +++ b/Build/Sources/TypeScript/backend/settings/type/stringlist.ts @@ -0,0 +1,86 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +import { html, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators'; +import { BaseElement } from './base'; +import { live } from 'lit/directives/live.js'; + +export const componentName = 'typo3-backend-settings-type-stringlist'; + +@customElement(componentName) +export class StringlistTypeElement extends BaseElement<string[]> { + + @property({ type: Array }) value: string[]; + + protected updateValue(value: string, index: number) { + const copy = [...this.value]; + copy[index] = value; + this.value = copy; + } + + protected addValue(index: number, value: string = '') { + this.value = this.value.toSpliced(index + 1, 0, value); + } + + protected removeValue(index: number) { + this.value = this.value.toSpliced(index, 1); + } + + protected renderItem(value: string, index: number): TemplateResult { + return html` + <tr> + <td width="99%"> + <input + id=${`${this.formid}${index > 0 ? '-' + index : ''}`} + type="text" + class="form-control" + .value=${live(value)} + @change=${(e: InputEvent) => this.updateValue((e.target as HTMLInputElement).value, index)} + /> + </td> + <td> + <div class="btn-group" role="group"> + <button class="btn btn-default" type="button" @click=${() => this.addValue(index)}> + <typo3-backend-icon identifier="actions-plus" size="small"></typo3-backend-icon> + </button> + <button class="btn btn-default" type="button" @click=${() => this.removeValue(index)}> + <typo3-backend-icon identifier="actions-delete" size="small"></typo3-backend-icon> + </button> + </div> + </td> + </tr> + `; + } + + protected render(): TemplateResult { + const value = this.value || []; + return html` + <div class="form-control-wrap"> + <div class="table-fit"> + <table class="table table-hover"> + <tbody> + ${value.map((v, i) => this.renderItem(v, i))} + </tbody> + </table> + </div> + </div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'typo3-backend-settings-type-stringlist': StringlistTypeElement; + } +} diff --git a/Build/tests/playwright/accessibility/modules.spec.ts b/Build/tests/playwright/accessibility/modules.spec.ts index f8631df126aa91959d15f0b804026a9e8222bde8..0f10c141e5e65e99161ff9de2861c932c99ad2a4 100644 --- a/Build/tests/playwright/accessibility/modules.spec.ts +++ b/Build/tests/playwright/accessibility/modules.spec.ts @@ -20,6 +20,10 @@ test.describe('modules', () => { 'label': 'the info module', 'route': 'module/web/info', }, + 'mod_site_settings': { + 'label': 'the site settings module', + 'route': 'module/site/settings', + }, 'mod_reports': { 'label': 'the reports module', 'route': 'module/system/reports', diff --git a/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php b/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php index b1397c528f04feb1a3db9f84d674a406105df9b0..c8c26e451f7f9b4925bd5f7c77347fbb4de5b5c5 100644 --- a/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php +++ b/typo3/sysext/backend/Classes/Controller/SiteConfigurationController.php @@ -150,7 +150,9 @@ class SiteConfigurationController throw new \RuntimeException('Existing config for site ' . $siteIdentifier . ' not found', 1521561226); } - $returnUrl = $this->uriBuilder->buildUriFromRoute('site_configuration'); + $returnUrl = GeneralUtility::sanitizeLocalUrl( + (string)($request->getQueryParams()['returnUrl'] ?? '') + ) ?: $this->uriBuilder->buildUriFromRoute('site_configuration'); $formDataCompilerInput = [ 'request' => $request, @@ -183,7 +185,7 @@ class SiteConfigurationController ]); $this->pageRenderer->getJavaScriptRenderer()->includeTaggedImports('backend.form'); - $this->configureEditViewDocHeader($view); + $this->configureEditViewDocHeader($view, $siteIdentifier); $view->setTitle( $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_module.xlf:mlang_tabs_tab'), $siteIdentifier ?? '' @@ -212,11 +214,16 @@ class SiteConfigurationController $siteTca = GeneralUtility::makeInstance(SiteTcaConfiguration::class)->getTca(); - $overviewRoute = $this->uriBuilder->buildUriFromRoute('site_configuration'); + $queryParams = $request->getQueryParams(); $parsedBody = $request->getParsedBody(); + + $returnUrl = GeneralUtility::sanitizeLocalUrl( + (string)($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '') + ) ?: $this->uriBuilder->buildUriFromRoute('site_configuration'); + if (isset($parsedBody['closeDoc']) && (int)$parsedBody['closeDoc'] === 1) { // Closing means no save, just redirect to overview - return new RedirectResponse($overviewRoute); + return new RedirectResponse($returnUrl); } $isSave = $parsedBody['_savedok'] ?? $parsedBody['doSave'] ?? false; $isSaveClose = $parsedBody['_saveandclosedok'] ?? false; @@ -433,7 +440,7 @@ class SiteConfigurationController $saveRoute = $this->uriBuilder->buildUriFromRoute('site_configuration.edit', ['site' => $siteIdentifier]); if ($isSaveClose) { - return new RedirectResponse($overviewRoute); + return new RedirectResponse($returnUrl); } return new RedirectResponse($saveRoute); } @@ -678,7 +685,7 @@ class SiteConfigurationController /** * Create document header buttons of "edit" action */ - protected function configureEditViewDocHeader(ModuleTemplate $view): void + protected function configureEditViewDocHeader(ModuleTemplate $view, ?string $siteIdentifier): void { $buttonBar = $view->getDocHeaderComponent()->getButtonBar(); $lang = $this->getLanguageService(); @@ -697,6 +704,19 @@ class SiteConfigurationController ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL)); $buttonBar->addButton($closeButton); $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 2); + if ($siteIdentifier) { + $exportButton = $buttonBar->makeLinkButton() + ->setTitle($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:edit.editSiteSettings')) + ->setIcon($this->iconFactory->getIcon('actions-cog', IconSize::SMALL)) + ->setShowLabelText(true) + ->setHref((string)$this->uriBuilder->buildUriFromRoute('site_settings.edit', [ + 'site' => $siteIdentifier, + 'returnUrl' => $this->uriBuilder->buildUriFromRoute('site_configuration.edit', [ + 'site' => $siteIdentifier, + ]), + ])); + $buttonBar->addButton($exportButton, ButtonBar::BUTTON_POSITION_RIGHT, 2); + } } /** diff --git a/typo3/sysext/backend/Classes/Controller/SiteSettingsController.php b/typo3/sysext/backend/Classes/Controller/SiteSettingsController.php new file mode 100644 index 0000000000000000000000000000000000000000..778c1165f0035261f712fde02be3074ad82f7073 --- /dev/null +++ b/typo3/sysext/backend/Classes/Controller/SiteSettingsController.php @@ -0,0 +1,326 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\Controller; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Symfony\Component\Yaml\Yaml; +use TYPO3\CMS\Backend\Attribute\AsController; +use TYPO3\CMS\Backend\Dto\Settings\EditableSetting; +use TYPO3\CMS\Backend\Routing\UriBuilder; +use TYPO3\CMS\Backend\Template\Components\ButtonBar; +use TYPO3\CMS\Backend\Template\ModuleTemplate; +use TYPO3\CMS\Backend\Template\ModuleTemplateFactory; +use TYPO3\CMS\Backend\Utility\BackendUtility; +use TYPO3\CMS\Core\Authentication\BackendUserAuthentication; +use TYPO3\CMS\Core\Http\JsonResponse; +use TYPO3\CMS\Core\Http\RedirectResponse; +use TYPO3\CMS\Core\Imaging\IconFactory; +use TYPO3\CMS\Core\Imaging\IconSize; +use TYPO3\CMS\Core\Localization\LanguageService; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Page\PageRenderer; +use TYPO3\CMS\Core\Settings\Category; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeRegistry; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Set\CategoryRegistry; +use TYPO3\CMS\Core\Site\SiteFinder; +use TYPO3\CMS\Core\Site\SiteSettingsService; +use TYPO3\CMS\Core\SysLog\Action\Setting as SettingAction; +use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification; +use TYPO3\CMS\Core\SysLog\Type; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; +use TYPO3\CMS\Core\Utility\ArrayUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * Backend controller: The "Site settings" module + * + * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API. + */ +#[AsController] +readonly class SiteSettingsController +{ + public function __construct( + protected ModuleTemplateFactory $moduleTemplateFactory, + protected SiteFinder $siteFinder, + protected SiteSettingsService $siteSettingsService, + protected SettingsTypeRegistry $settingsTypeRegistry, + protected CategoryRegistry $categoryRegistry, + protected UriBuilder $uriBuilder, + protected PageRenderer $pageRenderer, + protected FlashMessageService $flashMessageService, + protected IconFactory $iconFactory, + ) {} + + public function overviewAction(ServerRequestInterface $request): ResponseInterface + { + $view = $this->moduleTemplateFactory->create($request); + $view->assign('sites', array_map( + fn(Site $site): array => [ + 'site' => $site, + 'siteTitle' => $this->getSiteTitle($site), + 'hasSettingsDefinitions' => $this->siteSettingsService->hasSettingsDefinitions($site), + 'localSettings' => $this->siteSettingsService->getLocalSettings($site), + ], + array_filter( + $this->siteFinder->getAllSites(), + static fn(Site $site): bool => $site->getSets() !== [] + ) + )); + + return $view->renderResponse('SiteSettings/Overview'); + } + + public function editAction(ServerRequestInterface $request): ResponseInterface + { + $identifier = $request->getQueryParams()['site'] ?? null; + if ($identifier === null) { + throw new \RuntimeException('Site identifier to edit must be set', 1713394528); + } + + $returnUrl = GeneralUtility::sanitizeLocalUrl( + (string)($request->getQueryParams()['returnUrl'] ?? '') + ) ?: null; + $overviewUrl = (string)$this->uriBuilder->buildUriFromRoute('site_settings'); + + $site = $this->siteFinder->getSiteByIdentifier($identifier); + $view = $this->moduleTemplateFactory->create($request); + + $settings = $this->siteSettingsService->getUncachedSettings($site); + $setSettings = $this->siteSettingsService->getSetSettings($site); + + $categoryEnhancer = function (Category $category) use (&$categoryEnhancer, $settings, $setSettings): Category { + return new Category(...[ + ...get_object_vars($category), + 'label' => $this->getLanguageService()->sL($category->label), + 'description' => $category->description !== null ? $this->getLanguageService()->sL($category->description) : $category->description, + 'categories' => array_map($categoryEnhancer, $category->categories), + 'settings' => array_map( + fn(SettingDefinition $definition): EditableSetting => new EditableSetting( + definition: $this->resolveSettingLabels($definition), + value: $settings->get($definition->key), + systemDefault: $setSettings->get($definition->key), + typeImplementation: $this->settingsTypeRegistry->get($definition->type)->getJavaScriptModule(), + ), + $category->settings + ), + ]); + }; + + $categories = array_map( + $categoryEnhancer, + $this->categoryRegistry->getCategories(...$site->getSets()) + ); + $hasSettings = count($categories) > 0; + + $this->addDocHeaderCloseAndSaveButtons($view, $site, $returnUrl ?? $overviewUrl, $hasSettings); + if ($hasSettings) { + $this->addDocHeaderExportButton($view, $site); + } + + $this->addDocHeaderSiteConfigurationButton($view, $site); + $this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_copytoclipboard.xlf'); + $this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf'); + + $view->assign('siteIdentifier', $site->getIdentifier()); + $view->assign('siteTitle', $this->getSiteTitle($site)); + $view->assign('rootPageId', $site->getRootPageId()); + + $view->assign('actionUrl', (string)$this->uriBuilder->buildUriFromRoute('site_settings.save', array_filter([ + 'site' => $site->getIdentifier(), + 'returnUrl' => $returnUrl, + ], static fn(?string $v): bool => $v !== null))); + $view->assign('returnUrl', $returnUrl); + $view->assign('dumpUrl', (string)$this->uriBuilder->buildUriFromRoute('site_settings.dump', ['site' => $site->getIdentifier()])); + $view->assign('categories', $categories); + + return $view->renderResponse('SiteSettings/Edit'); + } + + private function resolveSettingLabels(SettingDefinition $definition): SettingDefinition + { + $languageService = $this->getLanguageService(); + return new SettingDefinition(...[ + ...get_object_vars($definition), + 'label' => $languageService->sL($definition->label), + 'description' => $definition->description !== null ? $languageService->sL($definition->description) : null, + ]); + } + + public function saveAction(ServerRequestInterface $request): ResponseInterface + { + $identifier = $request->getQueryParams()['site'] ?? null; + if ($identifier === null) { + throw new \RuntimeException('Site identifier to edit must be set', 1713394529); + } + + $site = $this->siteFinder->getSiteByIdentifier($identifier); + + $view = $this->moduleTemplateFactory->create($request); + + $parsedBody = $request->getParsedBody(); + + $returnUrl = GeneralUtility::sanitizeLocalUrl( + (string)($parsedBody['returnUrl']) + ) ?: null; + $overviewUrl = $this->uriBuilder->buildUriFromRoute('site_settings'); + $CMD = $parsedBody['CMD'] ?? ''; + $isSave = $CMD === 'save' || $CMD === 'saveclose'; + $isSaveClose = $parsedBody['CMD'] === 'saveclose'; + if (!$isSave) { + return new RedirectResponse($returnUrl ?? $overviewUrl); + } + + $changes = $this->siteSettingsService->computeSettingsDiff($site, $parsedBody['settings'] ?? []); + $this->siteSettingsService->writeSettings($site, $changes['settings']); + + if ($changes['changes'] !== [] || $changes['deletions'] !== []) { + $this->getBackendUser()->writelog( + Type::SITE, + SettingAction::CHANGE, + SystemLogErrorClassification::MESSAGE, + 0, + 'Site settings changed for \'%s\': %s', + [$site->getIdentifier(), json_encode($changes)], + 'site' + ); + + $languageService = $this->getLanguageService(); + $message = $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:save.message.updated'); + $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $message, '', ContextualFeedbackSeverity::OK, true); + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + + if ($isSaveClose) { + return new RedirectResponse($returnUrl ?? $overviewUrl); + } + $editRoute = $this->uriBuilder->buildUriFromRoute('site_settings.edit', array_filter([ + 'site' => $site->getIdentifier(), + 'returnUrl' => $returnUrl, + ], static fn(?string $v): bool => $v !== null)); + return new RedirectResponse($editRoute); + } + + public function dumpAction(ServerRequestInterface $request): ResponseInterface + { + $identifier = $request->getQueryParams()['site'] ?? null; + if ($identifier === null) { + throw new \RuntimeException('Site identifier to edit must be set', 1724772561); + } + + $site = $this->siteFinder->getSiteByIdentifier($identifier); + $parsedBody = $request->getParsedBody(); + $specificSetting = (string)($parsedBody['specificSetting'] ?? ''); + + $minify = $specificSetting !== '' ? false : true; + $changes = $this->siteSettingsService->computeSettingsDiff($site, $parsedBody['settings'] ?? [], $minify); + $settings = $changes['settings']; + if ($specificSetting !== '') { + $value = ArrayUtility::getValueByPath($settings, $specificSetting, '.'); + $settings = ArrayUtility::setValueByPath([], $specificSetting, $value, '.'); + } + + $yamlContents = Yaml::dump($settings, 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP); + + return new JsonResponse([ + 'yaml' => $yamlContents, + ]); + } + + protected function addDocHeaderCloseAndSaveButtons(ModuleTemplate $moduleTemplate, Site $site, string $closeUrl, bool $saveEnabled): void + { + $languageService = $this->getLanguageService(); + $buttonBar = $moduleTemplate->getDocHeaderComponent()->getButtonBar(); + $closeButton = $buttonBar->makeLinkButton() + ->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:close')) + ->setIcon($this->iconFactory->getIcon('actions-close', IconSize::SMALL)) + ->setShowLabelText(true) + ->setHref($closeUrl); + $buttonBar->addButton($closeButton, ButtonBar::BUTTON_POSITION_LEFT, 2); + $saveButton = $buttonBar->makeInputButton() + ->setName('CMD') + ->setValue('save') + ->setForm('sitesettings_form') + ->setIcon($this->iconFactory->getIcon('actions-document-save', IconSize::SMALL)) + ->setTitle($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:save')) + ->setShowLabelText(true) + ->setDisabled(!$saveEnabled); + $buttonBar->addButton($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 4); + } + + protected function addDocHeaderExportButton(ModuleTemplate $moduleTemplate, Site $site): void + { + $languageService = $this->getLanguageService(); + $buttonBar = $moduleTemplate->getDocHeaderComponent()->getButtonBar(); + $exportButton = $buttonBar->makeInputButton() + ->setTitle($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.yamlExport')) + ->setIcon($this->iconFactory->getIcon('actions-database-export', IconSize::SMALL)) + ->setShowLabelText(true) + ->setName('CMD') + ->setValue('export') + ->setForm('sitesettings_form'); + $buttonBar->addButton($exportButton, ButtonBar::BUTTON_POSITION_RIGHT, 2); + } + + protected function addDocHeaderSiteConfigurationButton(ModuleTemplate $moduleTemplate, Site $site): void + { + $languageService = $this->getLanguageService(); + $buttonBar = $moduleTemplate->getDocHeaderComponent()->getButtonBar(); + $exportButton = $buttonBar->makeLinkButton() + ->setTitle($languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.editSiteConfiguration')) + ->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL)) + ->setShowLabelText(true) + ->setHref((string)$this->uriBuilder->buildUriFromRoute('site_configuration.edit', [ + 'site' => $site->getIdentifier(), + 'returnUrl' => $this->uriBuilder->buildUriFromRoute('site_settings.edit', [ + 'site' => $site->getIdentifier(), + ]), + ])); + $buttonBar->addButton($exportButton, ButtonBar::BUTTON_POSITION_RIGHT, 3); + } + + protected function getSiteTitle(Site $site): string + { + $websiteTitle = $site->getConfiguration()['websiteTitle'] ?? ''; + if ($websiteTitle !== '') { + return $websiteTitle; + } + $rootPage = BackendUtility::getRecord('pages', $site->getRootPageId()); + $title = $rootPage['title'] ?? ''; + if ($title !== '') { + return $title; + } + + return '(unkown)'; + } + + protected function getLanguageService(): LanguageService + { + return $GLOBALS['LANG']; + } + + protected function getBackendUser(): BackendUserAuthentication + { + return $GLOBALS['BE_USER']; + } +} diff --git a/typo3/sysext/backend/Classes/Dto/Settings/EditableSetting.php b/typo3/sysext/backend/Classes/Dto/Settings/EditableSetting.php new file mode 100644 index 0000000000000000000000000000000000000000..1059956500bad071a49cb85f7c497d342cdd35b7 --- /dev/null +++ b/typo3/sysext/backend/Classes/Dto/Settings/EditableSetting.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Backend\Dto\Settings; + +use TYPO3\CMS\Core\Settings\SettingDefinition; + +/** + * @internal + */ +final readonly class EditableSetting implements \JsonSerializable +{ + public function __construct( + public SettingDefinition $definition, + public string|int|float|bool|array|null $value, + public string|int|float|bool|array|null $systemDefault, + public string $typeImplementation, + ) {} + + public function jsonSerialize(): array + { + return get_object_vars($this); + } +} diff --git a/typo3/sysext/backend/Configuration/Backend/Modules.php b/typo3/sysext/backend/Configuration/Backend/Modules.php index a28cf40c883309f538a1a0e16395a0a47938dd17..57b174d903ae34b9830cd2a3c09e2cab20b1f68e 100644 --- a/typo3/sysext/backend/Configuration/Backend/Modules.php +++ b/typo3/sysext/backend/Configuration/Backend/Modules.php @@ -7,6 +7,7 @@ use TYPO3\CMS\Backend\Controller\PageTsConfig\PageTsConfigIncludesController; use TYPO3\CMS\Backend\Controller\PageTsConfig\PageTsConfigRecordsOverviewController; use TYPO3\CMS\Backend\Controller\RecordListController; use TYPO3\CMS\Backend\Controller\SiteConfigurationController; +use TYPO3\CMS\Backend\Controller\SiteSettingsController; use TYPO3\CMS\Backend\Security\ContentSecurityPolicy\CspModuleController; /** @@ -73,6 +74,31 @@ return [ ], ], ], + 'site_settings' => [ + 'parent' => 'site', + 'position' => ['after' => 'site_configuration'], + // @todo implement access=user + 'access' => 'admin', + 'path' => '/module/site/settings', + 'iconIdentifier' => 'module-site-settings', + 'labels' => 'LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings_module.xlf', + 'routes' => [ + '_default' => [ + 'target' => SiteSettingsController::class . '::overviewAction', + ], + 'edit' => [ + 'target' => SiteSettingsController::class . '::editAction', + ], + 'save' => [ + 'target' => SiteSettingsController::class . '::saveAction', + 'methods' => ['POST'], + ], + 'dump' => [ + 'target' => SiteSettingsController::class . '::dumpAction', + 'methods' => ['POST'], + ], + ], + ], 'about' => [ 'parent' => 'help', 'position' => ['before' => '*'], diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration.xlf index e95c73ad861c42845ec2f786dba7f6926f409e1d..a8e36e11cecee91cafeba3d245af1e62d61d8ee3 100644 --- a/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration.xlf +++ b/typo3/sysext/backend/Resources/Private/Language/locallang_siteconfiguration.xlf @@ -27,6 +27,12 @@ <trans-unit id="overview.editSiteConfiguration" resname="overview.editSiteConfiguration"> <source>Edit site configuration</source> </trans-unit> + <trans-unit id="overview.editSiteSettings" resname="overview.editSiteSettings"> + <source>Edit site settings</source> + </trans-unit> + <trans-unit id="overview.editSiteSettingsUnavailable" resname="overview.editSiteSettingsUnavailable"> + <source>This site provides no configurable settings</source> + </trans-unit> <trans-unit id="overview.deleteSiteConfiguration" resname="overview.deleteSiteConfiguration"> <source>Delete site configuration</source> </trans-unit> @@ -51,6 +57,9 @@ <trans-unit id="overview.duplicatedRootPage.message" resname="overview.duplicatedRootPage.message"> <source>The page with ID "%1s" is used in the following site configurations:</source> </trans-unit> + <trans-unit id="edit.editSiteSettings" resname="edit.editSiteSettings"> + <source>Edit site settings</source> + </trans-unit> <trans-unit id="validation.identifierRenamed.title" resname="validation.identifierRenamed.title"> <source>Renamed identifier</source> </trans-unit> diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings.xlf new file mode 100644 index 0000000000000000000000000000000000000000..9263bf44ae510b52cd1d85cf36a45e3c43b56f17 --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings.xlf @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> + <file source-language="en" datatype="plaintext" original="EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf" date="2024-09-10T12:13:00Z" product-name="backend"> + <header/> + <body> + <trans-unit id="overview.title" resname="overview.title"> + <source>Site Settings Overview</source> + </trans-unit> + <trans-unit id="overview.setSummary" resname="overview.setSummary"> + <source>Sets (%d)</source> + </trans-unit> + <trans-unit id="overview.customSettingsSummary" resname="overview.customSettingsSummary"> + <source>Custom Settings (%d)</source> + </trans-unit> + <trans-unit id="overview.editSettings" resname="overview.editSettings"> + <source>Edit Settings</source> + </trans-unit> + <trans-unit id="overview.message.notEditable" resname="overview.message.notEditable"> + <source>This sets of this sites do not provide any configurable settings.</source> + </trans-unit> + <trans-unit id="edit.title" resname="edit.noSettings.title"> + <source>Site Settings for "{siteTitle}"</source> + </trans-unit> + <trans-unit id="edit.noSettings.title" resname="edit.noSettings.title"> + <source>No Settings available</source> + </trans-unit> + <trans-unit id="edit.noSettings.message" resname="edit.noSettings.message"> + <source>The site '%s' does provide configurable settings.</source> + </trans-unit> + <trans-unit id="edit.yamlExport" resname="edit.yamlExport"> + <source>YAML export</source> + </trans-unit> + <trans-unit id="edit.editSiteConfiguration" resname="edit.editSiteConfiguration"> + <source>Edit site configuration</source> + </trans-unit> + <trans-unit id="edit.resetSetting" resname="edit.resetSetting"> + <source>Reset setting</source> + </trans-unit> + <trans-unit id="edit.copySettingsIdentifier" resname="edit.copySettingsIdentifier"> + <source>Copy identifier</source> + </trans-unit> + <trans-unit id="edit.copyAsYaml" resname="edit.copyAsYaml"> + <source>Copy as YAML</source> + </trans-unit> + <trans-unit id="save.message.updated" resname="save.message.updated"> + <source>Settings updated.</source> + </trans-unit> + <trans-unit id="categories.other" resname="categories.other"> + <source>Other</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings_module.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings_module.xlf new file mode 100644 index 0000000000000000000000000000000000000000..e87a0f3dedb6bcb0c929aa98ef31178024fad63b --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Language/locallang_sitesettings_module.xlf @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> + <file source-language="en" datatype="plaintext" original="EXT:backend/Resources/Private/Language/locallang_sitesettings_module.xlf" date="2024-02-28T22:22:22Z" product-name="backend"> + <header/> + <body> + <trans-unit id="mlang_tabs_tab" resname="mlang_tabs_tab"> + <source>Settings</source> + </trans-unit> + <trans-unit id="mlang_labels_tablabel" resname="mlang_labels_tablabel"> + <source>Site settings</source> + </trans-unit> + <trans-unit id="mlang_labels_tabdescr" resname="mlang_labels_tabdescr"> + <source>This module allows you to configure site settings.</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/backend/Resources/Private/Templates/SiteConfiguration/Overview.html b/typo3/sysext/backend/Resources/Private/Templates/SiteConfiguration/Overview.html index 17b1308879d7d54eb0bc72c4c3e000258ceec302..ef7368d5c8647a239276469909444c64e1896542 100644 --- a/typo3/sysext/backend/Resources/Private/Templates/SiteConfiguration/Overview.html +++ b/typo3/sysext/backend/Resources/Private/Templates/SiteConfiguration/Overview.html @@ -148,6 +148,31 @@ <f:be.link route="site_configuration.edit" parameters="{site: page.siteIdentifier}" title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:overview.editSiteConfiguration')}" class="btn btn-default"> <core:icon identifier="actions-open" /> </f:be.link> + + <f:if condition="{page.siteConfiguration.sets->f:count()} > 0"> + <f:then> + <f:variable name="returnUrl">{f:be.uri(route: 'site_configuration')}</f:variable> + <f:be.link + route="site_settings.edit" + parameters="{site: page.siteIdentifier, returnUrl: returnUrl}" + title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:overview.editSiteSettings')}" + class="btn btn-default" + > + <core:icon identifier="actions-cog" /> + </f:be.link> + </f:then> + <f:else> + <button + disabled + type="button" + class="btn btn-default" + title="{f:translate(key:'LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:overview.editSiteSettingsUnavailable')}" + > + <core:icon identifier="actions-cog" /> + </button> + </f:else> + </f:if> + <button type="submit" class="btn btn-default t3js-modal-trigger" diff --git a/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Edit.html b/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Edit.html new file mode 100644 index 0000000000000000000000000000000000000000..ed8dce6871e3545a4ac603529e0325ce6f97b081 --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Edit.html @@ -0,0 +1,35 @@ +<f:layout name="Module" /> + +<f:section name="Content"> + + <f:be.pageRenderer includeJavaScriptModules="{0: '@typo3/backend/settings/editor.js'}"/> + + <h1> + Site Settings for "{siteTitle}" + <br> + <small class="text-muted">{siteIdentifier} <code>[pid: {rootPageId}]</code></small> + </h1> + + <f:if condition="{categories->f:count()} == 0"> + <f:then> + <f:be.infobox + state="{f:constant(name: 'TYPO3\CMS\Fluid\ViewHelpers\Be\InfoboxViewHelper::STATE_INFO')}" + title="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.noSettings.title')}" + > + <f:translate + key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:edit.noSettings.message" + arguments="{0: siteIdentifier}" + /> + </f:be.infobox> + </f:then> + <f:else> + <typo3-backend-settings-editor + action-url="{actionUrl}" + return-url="{returnUrl}" + dump-url="{dumpUrl}" + categories="{categories->f:format.json()}" + ></typo3-backend-settings-editor> + + </f:else> + </f:if> +</f:section> diff --git a/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Overview.html b/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Overview.html new file mode 100644 index 0000000000000000000000000000000000000000..1c2f3abcba87ea9b6456ac8183975670a71095c0 --- /dev/null +++ b/typo3/sysext/backend/Resources/Private/Templates/SiteSettings/Overview.html @@ -0,0 +1,54 @@ +<f:layout name="Module" /> + +<f:section name="Content"> + + <h1><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.title"/></h1> + + <div class="card-container"> + <f:for each="{sites}" as="c"> + <div class="card card-size-small"> + <div class="card-header"> + <div class="card-header-body"> + <h2 class="card-title">{c.siteTitle}</h2> + <span class="card-subtitle">{c.site.identifier} [pid: {c.site.rootPageId}]</span> + </div> + </div> + <div class="card-body"> + <details open name="details-{c.site.identifier}"> + <summary><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.setSummary" arguments="{0: '{c.site.sets->f:count()}'}"/></summary> + <p> + <f:for each="{c.site.sets}" as="set" iteration="i"> + <code>{set}</code><f:if condition="{i.isLast}"><f:else><br></f:else></f:if> + </f:for> + </p> + </details> + + <f:if condition="{c.localSettings.allFlat->f:count()} > 0"> + <details open name="details-{c.site.identifier}"> + <summary><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.customSettingsSummary" arguments="{0: '{c.localSettings.map->f:count()}'}"/></summary> + <p> + <f:for each="{c.localSettings.map}" as="setting" key="key" iteration="i"> + <code>{key}: <strong>{setting->f:format.json()}</strong></code><br> + </f:for> + </p> + </details> + </f:if> + </div> + <f:if condition="{c.hasSettingsDefinitions}"> + <f:then> + <div class="card-footer"> + <f:be.link route="site_settings.edit" parameters="{site: c.site.identifier}" class="btn btn-default"> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.editSettings"/></h1> + </f:be.link> + </div> + </f:then> + <f:else> + <div class="card-footer text-body-secondary"> + <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:overview.message.notEditable"/></h1> + </div> + </f:else> + </f:if> + </div> + </f:for> + </div> +</f:section> diff --git a/typo3/sysext/backend/Resources/Public/Css/backend.css b/typo3/sysext/backend/Resources/Public/Css/backend.css index 6d410ff2ac14b6fe2501c3299bc2d0e5a72f2924..1aa6f7e3bc1d627b1cadc685911404a384ebd132 100644 --- a/typo3/sysext/backend/Resources/Public/Css/backend.css +++ b/typo3/sysext/backend/Resources/Public/Css/backend.css @@ -3655,6 +3655,64 @@ typo3-backend-live-search-result-item-action>* .livesearch-result-item-title .sm .indent{--indent-base:16px;--indent-level:0;margin-inline-start:calc(var(--indent-base) * var(--indent-level))} .indent-inline-block{display:inline-block} .pagination{flex-wrap:wrap;row-gap:4px} +:root{--settings-color:var(--typo3-component-color);--settings-padding:calc(var(--typo3-spacing) * 2);--settings-bg:var(--typo3-component-bg);--settings-border-width:var(--typo3-component-border-width);--settings-border-color:var(--typo3-component-border-color);--settings-border-radius:var(--typo3-component-border-radius);--settings-box-shadow:var(--typo3-component-box-shadow);--settings-highlight:var(--typo3-component-primary-color);--settings-indicator-bg:transparent;--settings-item-color:var(--settings-color);--settings-item-bg:var(--settings-bg)} +.module[data-module-name=site_settings] .module-body{width:100%;max-width:1320px;margin:0 auto} +.settings-container{container-type:inline-size} +.settings{display:flex;align-items:flex-start;flex-wrap:wrap;color:var(--settings-color);box-shadow:var(--settings-box-shadow)} +.settings-body,.settings-navigation{padding:var(--settings-padding)} +.settings-body{background:var(--settings-bg);border:var(--settings-border-width) solid var(--settings-border-color);border-radius:var(--settings-border-radius)} +.settings-navigation{border-inline-start:var(--settings-border-width) solid var(--settings-border-color);border-inline-end:var(--settings-border-width) solid var(--settings-border-color);border-top:var(--settings-border-width) solid var(--settings-border-color);border-bottom:0;flex:1 1 auto} + +@container (min-width: 780px) { + .settings { + flex-wrap: nowrap; + box-shadow: none; + } + .settings-body { + flex: 1 1 auto; + border-inline-start: var(--settings-border-width) solid var(--settings-border-color); + border-start-start-radius: 0; + box-shadow: var(--settings-box-shadow); + } + .settings-navigation { + box-shadow: var(--settings-box-shadow); + flex: 0 0 300px; + position: sticky; + top: calc(var(--module-body-padding-y) + var(--module-docheader-height)); + border-inline-end: 0; + border-inline-start: var(--settings-border-width) solid var(--settings-border-color); + border-top: var(--settings-border-width) solid var(--settings-border-color); + border-bottom: var(--settings-border-width) solid var(--settings-border-color); + } +} +.settings-navigation ul{list-style:none;margin:0;padding:0} +.settings-navigation ul ul{padding-inline-start:1rem} +.settings-navigation-item{color:var(--typo3-component-color);position:relative;display:flex;border-radius:calc(var(--typo3-component-border-radius) - var(--typo3-component-border-width));gap:.5em;padding:var(--typo3-list-item-padding-y) var(--typo3-list-item-padding-x);cursor:pointer;text-decoration:none} +.settings-navigation-item.active,.settings-navigation-item:focus,.settings-navigation-item:hover{z-index:1;outline-offset:-1px} +.settings-navigation-item:hover{color:var(--typo3-component-hover-color);background-color:var(--typo3-component-hover-bg);outline:1px solid var(--typo3-component-hover-border-color)} +.settings-navigation-item.active,.settings-navigation-item:focus{color:var(--typo3-component-focus-color);background-color:var(--typo3-component-focus-bg);outline:1px solid var(--typo3-component-focus-border-color)} +.settings-navigation-item-icon{-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;flex-grow:0;width:var(--icon-size-small)} +.settings-navigation-item-label{-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-grow:1} +.settings-category{max-width:600px;text-wrap:balance} +.settings-category-list+.settings-category-list{margin-top:calc(var(--typo3-spacing) * 2)} +.settings-item{position:relative;color:var(--settings-item-color);background:var(--settings-item-bg);border-radius:calc(var(--settings-border-radius)/ 2);padding-block:var(--typo3-component-padding-y);padding-inline-start:calc(var(--typo3-component-padding-x) + 4px);padding-inline-end:calc(var(--typo3-component-padding-x) + 3rem);margin-inline-start:calc(-1 * var(--typo3-component-padding-x));margin-inline-end:calc(-1 * var(--typo3-component-padding-x))} +.settings-item:focus-within,.settings-item:focus-within *{--settings-item-bg:var(--typo3-component-focus-bg);--settings-item-color:var(--typo3-component-focus-color)} +.settings-item:focus-within{outline-offset:-1px;outline:1px solid var(--typo3-component-focus-border-color)} +.settings-item:focus .settings-item-actions,.settings-item:focus-within .settings-item-actions,.settings-item:hover .settings-item-actions{opacity:1} +.settings-item-indicator{position:absolute;background:var(--settings-indicator-bg);inset-inline-start:1px;inset-block-start:1px;inset-block-end:1px;width:4px;border-radius:0} +.settings-item[data-status=modified],.settings-item[data-status=modified] *{--settings-indicator-bg:#abdced} +.settings-item[data-status=error],.settings-item[data-status=error] *{--settings-indicator-bg:#d13a2e} +.settings-item-actions{opacity:0;position:absolute;display:flex;justify-content:center;inset-inline-end:0;inset-block-start:0;inset-block-end:0;padding-block:var(--typo3-component-padding-y);width:3rem;transition:opacity .3s ease-in-out} +.settings-item-actions>.dropdown>button{display:flex;justify-content:center;align-items:center;background-color:transparent;border:none;color:inherit;outline:0;width:32px;height:32px;padding:0;margin-top:-4px;border-radius:50%} +.settings-item-actions>.dropdown>button:hover{background:color-mix(in srgb,var(--settings-item-bg),var(--settings-item-color) 10%)} +.settings-item-actions>.dropdown>button:focus{background:color-mix(in srgb,var(--typo3-component-focus-bg),var(--typo3-component-focus-border-color) 20%);color:var(--typo3-component-focus-color)} +.settings-item-actions>.dropdown>button:after{display:none} +.settings-item-title{margin-bottom:calc(var(--typo3-spacing)/ 2)} +.settings-item-label{font-weight:700} +.settings-item-description{color:color-mix(in srgb,var(--settings-color),var(--settings-bg) 25%)} +.settings-item-key{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--typo3-font-family-code);color:var(--settings-highlight)} +.settings-item-message{margin-top:calc(var(--typo3-spacing)/ 2)} +.settings-item-message:empty{display:none} .example{color:var(--typo3-component-color);background-color:var(--typo3-surface-base);border:var(--typo3-component-border-width) solid var(--typo3-component-border-color);border-radius:var(--typo3-component-border-radius);padding:var(--typo3-spacing);margin-bottom:var(--typo3-spacing)} .example+:not(.example){margin-top:var(--typo3-component-spacing)} .example>:last-child{margin-bottom:0} diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/copy-to-clipboard.js b/typo3/sysext/backend/Resources/Public/JavaScript/copy-to-clipboard.js index 68ec8919e6d1068052433caad040cd7dfffaa890..2a0592b52960d4db3986fdba15a809b84f393918 100644 --- a/typo3/sysext/backend/Resources/Public/JavaScript/copy-to-clipboard.js +++ b/typo3/sysext/backend/Resources/Public/JavaScript/copy-to-clipboard.js @@ -10,4 +10,4 @@ * * The TYPO3 project - inspiring people to share! */ -var __decorate=function(t,o,e,r){var i,c=arguments.length,l=c<3?o:null===r?r=Object.getOwnPropertyDescriptor(o,e):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)l=Reflect.decorate(t,o,e,r);else for(var p=t.length-1;p>=0;p--)(i=t[p])&&(l=(c<3?i(l):c>3?i(o,e,l):i(o,e))||l);return c>3&&l&&Object.defineProperty(o,e,l),l};import{html,css,LitElement}from"lit";import{customElement,property}from"lit/decorators.js";import Notification from"@typo3/backend/notification.js";import{lll}from"@typo3/core/lit-helper.js";let CopyToClipboard=class extends LitElement{constructor(){super(),this.addEventListener("click",(t=>{t.preventDefault(),this.copyToClipboard()})),this.addEventListener("keydown",(t=>{"Enter"!==t.key&&" "!==t.key||(t.preventDefault(),this.copyToClipboard())}))}connectedCallback(){this.hasAttribute("role")||this.setAttribute("role","button"),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0")}render(){return html`<slot></slot>`}copyToClipboard(){if("string"!=typeof this.text||!this.text.length)return console.warn("No text for copy to clipboard given."),void Notification.error(lll("copyToClipboard.error"));if(navigator.clipboard)navigator.clipboard.writeText(this.text).then((()=>{Notification.success(lll("copyToClipboard.success"),"",1)})).catch((()=>{Notification.error(lll("copyToClipboard.error"))}));else{const t=document.createElement("textarea");t.value=this.text,document.body.appendChild(t),t.focus(),t.select();try{document.execCommand("copy")?Notification.success(lll("copyToClipboard.success"),"",1):Notification.error(lll("copyToClipboard.error"))}catch{Notification.error(lll("copyToClipboard.error"))}document.body.removeChild(t)}}};CopyToClipboard.styles=[css`:host { cursor: pointer; appearance: button; }`],__decorate([property({type:String})],CopyToClipboard.prototype,"text",void 0),CopyToClipboard=__decorate([customElement("typo3-copy-to-clipboard")],CopyToClipboard);export{CopyToClipboard}; \ No newline at end of file +var __decorate=function(o,t,e,r){var i,c=arguments.length,l=c<3?t:null===r?r=Object.getOwnPropertyDescriptor(t,e):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)l=Reflect.decorate(o,t,e,r);else for(var p=o.length-1;p>=0;p--)(i=o[p])&&(l=(c<3?i(l):c>3?i(t,e,l):i(t,e))||l);return c>3&&l&&Object.defineProperty(t,e,l),l};import{html,css,LitElement}from"lit";import{customElement,property}from"lit/decorators.js";import Notification from"@typo3/backend/notification.js";import{lll}from"@typo3/core/lit-helper.js";export function copyToClipboard(o){if(!o.length)return console.warn("No text for copy to clipboard given."),void Notification.error(lll("copyToClipboard.error"));if(navigator.clipboard)navigator.clipboard.writeText(o).then((()=>{Notification.success(lll("copyToClipboard.success"),"",1)})).catch((()=>{Notification.error(lll("copyToClipboard.error"))}));else{const t=document.createElement("textarea");t.value=o,document.body.appendChild(t),t.focus(),t.select();try{document.execCommand("copy")?Notification.success(lll("copyToClipboard.success"),"",1):Notification.error(lll("copyToClipboard.error"))}catch{Notification.error(lll("copyToClipboard.error"))}document.body.removeChild(t)}}let CopyToClipboard=class extends LitElement{constructor(){super(),this.addEventListener("click",(o=>{o.preventDefault(),this.copyToClipboard()})),this.addEventListener("keydown",(o=>{"Enter"!==o.key&&" "!==o.key||(o.preventDefault(),this.copyToClipboard())}))}connectedCallback(){this.hasAttribute("role")||this.setAttribute("role","button"),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0")}render(){return html`<slot></slot>`}copyToClipboard(){if("string"!=typeof this.text)return console.warn("No text for copy to clipboard given."),void Notification.error(lll("copyToClipboard.error"));copyToClipboard(this.text)}};CopyToClipboard.styles=[css`:host { cursor: pointer; appearance: button; }`],__decorate([property({type:String})],CopyToClipboard.prototype,"text",void 0),CopyToClipboard=__decorate([customElement("typo3-copy-to-clipboard")],CopyToClipboard);export{CopyToClipboard}; \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor.js new file mode 100644 index 0000000000000000000000000000000000000000..61a41bd6c761f6e3a2624ea9756259d133dcbcd1 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor.js @@ -0,0 +1,62 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(t,e,r,i){var o,n=arguments.length,s=n<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,r):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,r,i);else for(var a=t.length-1;a>=0;a--)(o=t[a])&&(s=(n<3?o(s):n>3?o(e,r,s):o(e,r))||s);return n>3&&s&&Object.defineProperty(e,r,s),s};import{html,LitElement,nothing}from"lit";import{customElement,property,state}from"lit/decorators.js";import"@typo3/backend/element/spinner-element.js";import"@typo3/backend/element/icon-element.js";import Notification from"@typo3/backend/notification.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import{copyToClipboard}from"@typo3/backend/copy-to-clipboard.js";import{lll}from"@typo3/core/lit-helper.js";import"@typo3/backend/settings/editor/editable-setting.js";import"@typo3/backend/settings/type/bool.js";import"@typo3/backend/settings/type/int.js";import"@typo3/backend/settings/type/number.js";import"@typo3/backend/settings/type/string.js";import"@typo3/backend/settings/type/stringlist.js";let SettingsEditorElement=class extends LitElement{constructor(){super(...arguments),this.activeCategory="",this.visibleCategories={},this.observer=null}createRenderRoot(){return this}firstUpdated(){this.observer=new IntersectionObserver((t=>{t.forEach((t=>{const e=t.target.dataset.key;this.visibleCategories[e]=t.isIntersecting}));const e=t=>t.reduce(((t,r)=>[...t,r.key,...e(r.categories)]),[]),r=e(this.categories).filter((t=>this.visibleCategories[t]))[0]||"";r&&(this.activeCategory=r)}),{root:document.querySelector(".module"),threshold:.1,rootMargin:`-${getComputedStyle(document.querySelector(".module-docheader")).getPropertyValue("min-height")} 0px 0px 0px`})}updated(){[...this.renderRoot.querySelectorAll(".settings-category")].map((t=>this.observer?.observe(t)))}renderCategoryTree(t,e){return html` + <ul data-level=${e}> + ${t.map((t=>html` + <li> + <a href=${`#category-headline-${t.key}`} + @click=${()=>this.activeCategory=t.key} + class="settings-navigation-item ${this.activeCategory===t.key?"active":""}"> + <span class="settings-navigation-item-icon"> + <typo3-backend-icon identifier=${t.icon?t.icon:"actions-dot"} size="small"></typo3-backend-icon> + </span> + <span class="settings-navigation-item-label">${t.label}</span> + </a> + ${0===t.categories.length?nothing:html` + ${this.renderCategoryTree(t.categories,e+1)} + `} + </li> + `))} + </ul> + `}renderSettings(t,e){return t.map((t=>html` + <div class="settings-category-list" data-key=${t.key}> + <div class="settings-category" data-key=${t.key}> + ${this.renderHeadline(Math.min(e+1,6),`category-headline-${t.key}`,html`${t.label}`)} + ${t.description?html`<p>${t.description}</p>`:nothing} + </div> + ${t.settings.map((t=>html` + <typo3-backend-editable-setting .setting=${t} .dumpuri=${this.dumpUrl}></typo3-backend-editable-setting> + `))} + </div> + ${0===t.categories.length?nothing:html` + ${this.renderSettings(t.categories,e+1)} + `} + `))}renderHeadline(t,e,r){switch(t){case 1:return html`<h1 id=${e}>${r}</h1>`;case 2:return html`<h2 id=${e}>${r}</h2>`;case 3:return html`<h3 id=${e}>${r}</h3>`;case 4:return html`<h4 id=${e}>${r}</h4>`;case 5:return html`<h5 id=${e}>${r}</h5>`;case 6:return html`<h6 id=${e}>${r}</h6>`;default:throw new Error(`Invalid header level: ${t}`)}}async onSubmit(t){const e=t.target;if("export"===t.submitter?.value){t.preventDefault();const r=new FormData(e),i=await new AjaxRequest(this.dumpUrl).post(r),o=await i.resolve();"string"==typeof o.yaml?copyToClipboard(o.yaml):(console.warn("Value can not be copied to clipboard.",typeof o.yaml),Notification.error(lll("copyToClipboard.error")))}}render(){return html` + <form class="settings-container" + id="sitesettings_form" + name="sitesettings_form" + action=${this.actionUrl} + method="post" + @submit=${t=>this.onSubmit(t)} + > + ${this.returnUrl?html`<input type="hidden" name="returnUrl" value=${this.returnUrl} />`:nothing} + <div class="settings"> + <div class="settings-navigation"> + ${this.renderCategoryTree(this.categories??[],1)} + </div> + <div class="settings-body"> + ${this.renderSettings(this.categories??[],1)} + </div> + </div> + </form> + `}};__decorate([property({type:Array})],SettingsEditorElement.prototype,"categories",void 0),__decorate([property({type:String,attribute:"action-url"})],SettingsEditorElement.prototype,"actionUrl",void 0),__decorate([property({type:String,attribute:"dump-url"})],SettingsEditorElement.prototype,"dumpUrl",void 0),__decorate([property({type:String,attribute:"return-url"})],SettingsEditorElement.prototype,"returnUrl",void 0),__decorate([state()],SettingsEditorElement.prototype,"activeCategory",void 0),SettingsEditorElement=__decorate([customElement("typo3-backend-settings-editor")],SettingsEditorElement);export{SettingsEditorElement}; \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor/editable-setting.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor/editable-setting.js new file mode 100644 index 0000000000000000000000000000000000000000..08813c02d256b442daf41394d85e57653544ed98 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/editor/editable-setting.js @@ -0,0 +1,69 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(t,e,i,n){var o,s=arguments.length,a=s<3?e:null===n?n=Object.getOwnPropertyDescriptor(e,i):n;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,i,n);else for(var l=t.length-1;l>=0;l--)(o=t[l])&&(a=(s<3?o(a):s>3?o(e,i,a):o(e,i))||a);return s>3&&a&&Object.defineProperty(e,i,a),a};import{html,LitElement,nothing}from"lit";import{customElement,property,state}from"lit/decorators.js";import{until}from"lit/directives/until.js";import"@typo3/backend/element/spinner-element.js";import"@typo3/backend/element/icon-element.js";import{copyToClipboard}from"@typo3/backend/copy-to-clipboard.js";import Notification from"@typo3/backend/notification.js";import{lll}from"@typo3/core/lit-helper.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";let EditableSettingElement=class extends LitElement{constructor(){super(...arguments),this.hasChange=!1,this.typeElement=null}createRenderRoot(){return this}render(){const{value:t,systemDefault:e,definition:i}=this.setting;return html` + <div + class=${`settings-item settings-item-${i.type} ${this.hasChange?"has-change":""}`} + tabindex="0" + data-status=${JSON.stringify(t)===JSON.stringify(e)?"none":"modified"} + > + <!-- data-status=modified|error|none--> + <div class="settings-item-indicator"></div> + <div class="settings-item-title"> + <label for=${`setting-${i.key}`} class="settings-item-label">${i.label}</label> + <div class="settings-item-description">${i.description}</div> + <div class="settings-item-key">${i.key}</div> + </div> + <div class="settings-item-control"> + ${until(this.renderField(),html`<typo3-backend-spinner></typo3-backend-spinner>`)} + </div> + <div class="settings-item-message"></div> + <div class="settings-item-actions"> + ${this.renderActions()} + </div> + </div> + `}async renderField(){const{definition:t,value:e,typeImplementation:i}=this.setting;let n=this.typeElement;if(!n){const t=await import(i);if(!("componentName"in t))throw new Error(`module ${i} is missing the "componentName" export`);n=document.createElement(t.componentName),this.typeElement=n,n.addEventListener("typo3:setting:changed",(t=>{this.hasChange=JSON.stringify(this.setting.value)!==JSON.stringify(t.detail.value)}))}const o={key:t.key,formid:`setting-${t.key}`,name:`settings[${t.key}]`,value:Array.isArray(e)?JSON.stringify(e):String(e),default:Array.isArray(t.default)?JSON.stringify(t.default):String(t.default)};for(const[t,e]of Object.entries(o))n.getAttribute(t)!==e&&n.setAttribute(t,e);return n}renderActions(){const{definition:t}=this.setting;return html` + <div class="dropdown"> + <button class="dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> + <typo3-backend-icon identifier="actions-cog" size="small"></typo3-backend-icon> + <span class="visually-hidden">More actions</span> + </button> + <ul class="dropdown-menu"> + <li> + <button class="dropdown-item dropdown-item-spaced" + type="button" + @click="${()=>this.setToDefaultValue()}"> + <typo3-backend-icon identifier="actions-undo" size="small"></typo3-backend-icon> ${lll("edit.resetSetting")} + </button> + </li> + <li><hr class="dropdown-divider"></li> + <li> + <typo3-copy-to-clipboard + text=${t.key} + class="dropdown-item dropdown-item-spaced" + > + <typo3-backend-icon identifier="actions-clipboard" size="small"></typo3-backend-icon> ${lll("edit.copySettingsIdentifier")} + </typo3-copy-to-clipboard> + </li> + ${this.dumpuri?html` + <li> + <button class="dropdown-item dropdown-item-spaced" + type="button" + @click="${()=>this.copyAsYaml()}"> + <typo3-backend-icon identifier="actions-clipboard-paste" size="small"></typo3-backend-icon> ${lll("edit.copyAsYaml")} + + </a> + </li> + `:nothing} + </ul> + </div> + `}setToDefaultValue(){this.typeElement&&(this.typeElement.value=this.setting.systemDefault)}async copyAsYaml(){const t=new FormData(this.typeElement.form),e=`settings[${this.setting.definition.key}]`,i=t.get(e),n=new FormData;n.append("specificSetting",this.setting.definition.key),n.append(e,i);const o=await new AjaxRequest(this.dumpuri).post(n),s=await o.resolve();"string"==typeof s.yaml?copyToClipboard(s.yaml):(console.warn("Value can not be copied to clipboard.",typeof s.yaml),Notification.error(lll("copyToClipboard.error")))}};__decorate([property({type:Object})],EditableSettingElement.prototype,"setting",void 0),__decorate([property({type:String})],EditableSettingElement.prototype,"dumpuri",void 0),__decorate([state()],EditableSettingElement.prototype,"hasChange",void 0),EditableSettingElement=__decorate([customElement("typo3-backend-editable-setting")],EditableSettingElement);export{EditableSettingElement}; \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/base.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/base.js new file mode 100644 index 0000000000000000000000000000000000000000..b0594e2ad7ce34360def341daf55866c391416b7 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/base.js @@ -0,0 +1,13 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(t,e,r,a){var l,o=arguments.length,n=o<3?e:null===a?a=Object.getOwnPropertyDescriptor(e,r):a;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(t,e,r,a);else for(var s=t.length-1;s>=0;s--)(l=t[s])&&(n=(o<3?l(n):o>3?l(e,r,n):l(e,r))||n);return o>3&&n&&Object.defineProperty(e,r,n),n};import{LitElement}from"lit";import{defaultConverter}from"@lit/reactive-element";import{property}from"lit/decorators.js";export const internals=Symbol("internals");const privateInternals=Symbol("privateInternals");export const getFormValue=Symbol("getFormValue");export const getFormState=Symbol("getFormState");export class BaseElement extends LitElement{createRenderRoot(){return this}get[internals](){return this[privateInternals]||(this[privateInternals]=this.attachInternals()),this[privateInternals]}get form(){return this[internals].form}get labels(){return this[internals].labels}get name(){return this.getAttribute("name")??""}set name(t){this.setAttribute("name",t)}get disabled(){return this.hasAttribute("disabled")}set disabled(t){this.toggleAttribute("disabled",t)}attributeChangedCallback(t,e,r){if("name"!==t&&"disabled"!==t)super.attributeChangedCallback(t,e,r);else{const r="disabled"===t?null!==e:e;this.requestUpdate(t,r)}}requestUpdate(t,e,r){super.requestUpdate(t,e,r),"value"===t&&(this.dispatchEvent(new CustomEvent("typo3:setting:changed",{detail:{value:this.value}})),this[internals].setFormValue(this[getFormValue](),this[getFormState]()))}formDisabledCallback(t){this.disabled=t}formResetCallback(){const t=this.value,e=this.getAttribute("value");this.attributeChangedCallback("value",this.valueToString(t),null),this.attributeChangedCallback("value",null,e)}formStateRestoreCallback(t){if("string"!=typeof t)throw new Error(`formStateRestoreCallback() needs to be implemented for <${this.localName}> for state type "${typeof t}"`);this.attributeChangedCallback("value",this.valueToString(this.value),null),this.attributeChangedCallback("value",null,t)}[getFormState](){return this[getFormValue]()}[getFormValue](){return this.valueToString(this.value)}valueToString(t){const e=this.constructor.getPropertyOptions("value");return("object"==typeof e.converter&&"function"==typeof e.converter?.toAttribute?e.converter.toAttribute:defaultConverter.toAttribute)(t,e.type)}}BaseElement.formAssociated=!0,__decorate([property({type:String})],BaseElement.prototype,"key",void 0),__decorate([property({type:String})],BaseElement.prototype,"formid",void 0),__decorate([property({noAccessor:!0})],BaseElement.prototype,"name",null),__decorate([property({type:Boolean,noAccessor:!0})],BaseElement.prototype,"disabled",null); \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/bool.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/bool.js new file mode 100644 index 0000000000000000000000000000000000000000..847539a455f74ae4a31d596a1e95ff5b09113fb8 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/bool.js @@ -0,0 +1,24 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(e,t,o,r){var c,l=arguments.length,n=l<3?t:null===r?r=Object.getOwnPropertyDescriptor(t,o):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,o,r);else for(var p=e.length-1;p>=0;p--)(c=e[p])&&(n=(l<3?c(n):l>3?c(t,o,n):c(t,o))||n);return l>3&&n&&Object.defineProperty(t,o,n),n};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";export const componentName="typo3-backend-settings-type-bool";let BoolTypeElement=class extends BaseElement{render(){return html` + <div class="form-check form-check-type-toggle"> + <input + type="checkbox" + id=${this.formid} + class="form-check-input" + value="1" + .checked=${this.value} + @change=${e=>this.value=!!e.target.checked} + /> + </div> + `}};__decorate([property({type:Boolean,converter:{toAttribute:e=>e?"1":"0",fromAttribute:e=>"1"===e||"true"===e}})],BoolTypeElement.prototype,"value",void 0),BoolTypeElement=__decorate([customElement(componentName)],BoolTypeElement);export{BoolTypeElement}; \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/color.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/color.js new file mode 100644 index 0000000000000000000000000000000000000000..4bff7e97bc021ca48237c580a020fad19494b306 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/color.js @@ -0,0 +1,21 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(e,t,o,r){var l,a=arguments.length,n=a<3?t:null===r?r=Object.getOwnPropertyDescriptor(t,o):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,o,r);else for(var p=e.length-1;p>=0;p--)(l=e[p])&&(n=(a<3?l(n):a>3?l(t,o,n):l(t,o))||n);return a>3&&n&&Object.defineProperty(t,o,n),n};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";import Alwan from"alwan";export const componentName="typo3-backend-settings-type-color";let ColorTypeElement=class extends BaseElement{constructor(){super(...arguments),this.alwan=null}firstUpdated(){this.alwan=new Alwan(this.querySelector("input"),{position:"bottom-start",format:"hex",opacity:!1,preset:!1,color:this.value}),this.alwan.on("color",(e=>{this.value=e.hex}))}updateValue(e){this.value=e,this.alwan?.setColor(e)}render(){return html` + <input + type="text" + id=${this.formid} + class="form-control" + .value=${this.value} + @change=${e=>this.updateValue(e.target.value)} + /> + `}};__decorate([property({type:String})],ColorTypeElement.prototype,"value",void 0),ColorTypeElement=__decorate([customElement(componentName)],ColorTypeElement);export{ColorTypeElement}; \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/int.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/int.js new file mode 100644 index 0000000000000000000000000000000000000000..c0096f2e119087b132a022a78e3102954a304327 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/int.js @@ -0,0 +1,21 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(e,t,r,o){var n,l=arguments.length,p=l<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)p=Reflect.decorate(e,t,r,o);else for(var m=e.length-1;m>=0;m--)(n=e[m])&&(p=(l<3?n(p):l>3?n(t,r,p):n(t,r))||p);return l>3&&p&&Object.defineProperty(t,r,p),p};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";export const componentName="typo3-backend-settings-type-int";let IntTypeElement=class extends BaseElement{render(){return html` + <input + type="number" + id=${this.formid} + class="form-control" + .value=${this.value} + @change=${e=>this.value=parseInt(e.target.value,10)} + /> + `}};__decorate([property({type:Number})],IntTypeElement.prototype,"value",void 0),IntTypeElement=__decorate([customElement(componentName)],IntTypeElement);export{IntTypeElement}; \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/number.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/number.js new file mode 100644 index 0000000000000000000000000000000000000000..4631762a071dc3e20841279a3ebbd8b4272eb629 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/number.js @@ -0,0 +1,22 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(e,t,r,o){var n,m=arguments.length,l=m<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)l=Reflect.decorate(e,t,r,o);else for(var p=e.length-1;p>=0;p--)(n=e[p])&&(l=(m<3?n(l):m>3?n(t,r,l):n(t,r))||l);return m>3&&l&&Object.defineProperty(t,r,l),l};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";export const componentName="typo3-backend-settings-type-number";let NumberTypeElement=class extends BaseElement{render(){return html` + <input + type="number" + id=${this.formid} + class="form-control" + step="0.01" + .value=${this.value} + @change=${e=>this.value=parseFloat(e.target.value)} + /> + `}};__decorate([property({type:Number})],NumberTypeElement.prototype,"value",void 0),NumberTypeElement=__decorate([customElement(componentName)],NumberTypeElement);export{NumberTypeElement}; \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/string.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/string.js new file mode 100644 index 0000000000000000000000000000000000000000..22196ed140c1bac1c756dd806ae2fbaf12f03229 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/string.js @@ -0,0 +1,21 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(e,t,r,o){var n,l=arguments.length,p=l<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)p=Reflect.decorate(e,t,r,o);else for(var i=e.length-1;i>=0;i--)(n=e[i])&&(p=(l<3?n(p):l>3?n(t,r,p):n(t,r))||p);return l>3&&p&&Object.defineProperty(t,r,p),p};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";export const componentName="typo3-backend-settings-type-string";let StringTypeElement=class extends BaseElement{render(){return html` + <input + type="text" + id=${this.formid} + class="form-control" + .value=${this.value} + @change=${e=>this.value=e.target.value} + /> + `}};__decorate([property({type:String})],StringTypeElement.prototype,"value",void 0),StringTypeElement=__decorate([customElement(componentName)],StringTypeElement);export{StringTypeElement}; \ No newline at end of file diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/stringlist.js b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/stringlist.js new file mode 100644 index 0000000000000000000000000000000000000000..2cb792e936b3808346715a7f288c5fa437530170 --- /dev/null +++ b/typo3/sysext/backend/Resources/Public/JavaScript/settings/type/stringlist.js @@ -0,0 +1,45 @@ +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ +var __decorate=function(t,e,l,o){var i,r=arguments.length,n=r<3?e:null===o?o=Object.getOwnPropertyDescriptor(e,l):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(t,e,l,o);else for(var a=t.length-1;a>=0;a--)(i=t[a])&&(n=(r<3?i(n):r>3?i(e,l,n):i(e,l))||n);return r>3&&n&&Object.defineProperty(e,l,n),n};import{html}from"lit";import{customElement,property}from"lit/decorators.js";import{BaseElement}from"@typo3/backend/settings/type/base.js";import{live}from"lit/directives/live.js";export const componentName="typo3-backend-settings-type-stringlist";let StringlistTypeElement=class extends BaseElement{updateValue(t,e){const l=[...this.value];l[e]=t,this.value=l}addValue(t,e=""){this.value=this.value.toSpliced(t+1,0,e)}removeValue(t){this.value=this.value.toSpliced(t,1)}renderItem(t,e){return html` + <tr> + <td width="99%"> + <input + id=${`${this.formid}${e>0?"-"+e:""}`} + type="text" + class="form-control" + .value=${live(t)} + @change=${t=>this.updateValue(t.target.value,e)} + /> + </td> + <td> + <div class="btn-group" role="group"> + <button class="btn btn-default" type="button" @click=${()=>this.addValue(e)}> + <typo3-backend-icon identifier="actions-plus" size="small"></typo3-backend-icon> + </button> + <button class="btn btn-default" type="button" @click=${()=>this.removeValue(e)}> + <typo3-backend-icon identifier="actions-delete" size="small"></typo3-backend-icon> + </button> + </div> + </td> + </tr> + `}render(){const t=this.value||[];return html` + <div class="form-control-wrap"> + <div class="table-fit"> + <table class="table table-hover"> + <tbody> + ${t.map(((t,e)=>this.renderItem(t,e)))} + </tbody> + </table> + </div> + </div> + `}};__decorate([property({type:Array})],StringlistTypeElement.prototype,"value",void 0),StringlistTypeElement=__decorate([customElement(componentName)],StringlistTypeElement);export{StringlistTypeElement}; \ No newline at end of file diff --git a/typo3/sysext/core/Classes/Configuration/SiteWriter.php b/typo3/sysext/core/Classes/Configuration/SiteWriter.php index a25919f451a034d437d9c80b9c2482a001aef8ef..c1ffb446711af8f4d83c7bfe68a9e71cc11ae31b 100644 --- a/typo3/sysext/core/Classes/Configuration/SiteWriter.php +++ b/typo3/sysext/core/Classes/Configuration/SiteWriter.php @@ -96,7 +96,15 @@ class SiteWriter public function writeSettings(string $siteIdentifier, array $settings): void { $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName; - $yamlFileContents = Yaml::dump($settings, 99, 2); + if ($settings === []) { + if (!is_file($fileName)) { + return; + } + if (is_writable($fileName) && @unlink($fileName)) { + return; + } + } + $yamlFileContents = Yaml::dump($settings, 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP); if (!GeneralUtility::writeFile($fileName, $yamlFileContents)) { throw new SiteConfigurationWriteException('Unable to write site settings in sites/' . $siteIdentifier . '/' . $this->configFileName, 1590487411); } diff --git a/typo3/sysext/core/Classes/Settings/Category.php b/typo3/sysext/core/Classes/Settings/Category.php new file mode 100644 index 0000000000000000000000000000000000000000..df74b7a3b2eff6956e00e28e72c1be4a3723929f --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/Category.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal + */ +class Category +{ + public function __construct( + public string $key, + public string $label, + public ?string $description = null, + public ?string $icon = null, + public array $settings = [], + public array $categories = [], + ) {} + + public function toArray(): array + { + return array_filter(get_object_vars($this), fn(mixed $value) => $value !== null && $value !== []); + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/typo3/sysext/core/Classes/Settings/CategoryAccumulator.php b/typo3/sysext/core/Classes/Settings/CategoryAccumulator.php new file mode 100644 index 0000000000000000000000000000000000000000..72d31756d2c1792aeefee908eeb3b63aceb17da1 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/CategoryAccumulator.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +class CategoryAccumulator +{ + /** + * Retrieve list of ordered sets, matched by + * $setNames, including their dependencies (recursive) + * + * @param CategoryDefinition[] $categoryDefinitions + * @param SettingDefinition[] $settingsDefinitions + * @return list<Category> + */ + public function getCategories(iterable $categoryDefinitions, iterable $settingsDefinitions): array + { + $categories = []; + foreach ($categoryDefinitions as $category) { + $data = $category->toArray(); + $parent = $data['parent'] ?? null; + unset($data['parent']); + $categories[$category->key] = [ + 'children' => [], + 'parent' => $parent, + 'data' => $data, + ]; + } + foreach ($categoryDefinitions as $category) { + if ($category->parent === null) { + continue; + } + if (!isset($categories[$category->parent])) { + throw new \RuntimeException('Missing parent category: ' . $category->parent, 1716291554); + } + $categories[$category->parent]['children'][] = $category->key; + } + + $categorizedSettings = []; + foreach ($settingsDefinitions as $definition) { + $category = $definition->category ?? null; + $categorizedSettings[isset($categories[$category]) ? $category : 'other'][] = $definition; + } + + if (isset($categorizedSettings['other'])) { + $categories['other'] = [ + 'children' => [], + 'parent' => null, + 'data' => [ + 'key' => 'other', + 'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang_sitesettings.xlf:categories.other', + 'description' => '', + ], + ]; + } + + $instances = []; + foreach ($categories as $key => $category) { + if ($category['parent'] === null) { + $instances[] = $this->createInstance($categories, $key, $categorizedSettings); + } + } + + return $instances; + } + + private function createInstance(array $categories, string $key, array $categorizedSettings): Category + { + try { + return new Category(...[ + ...$categories[$key]['data'], + 'settings' => $categorizedSettings[$key] ?? [], + 'categories' => array_map(fn($key) => $this->createInstance($categories, $key, $categorizedSettings), $categories[$key]['children']), + ]); + } catch (\Error $e) { + throw new \Exception('Invalid category definition: ' . json_encode($categories[$key]['data']), 1720528084, $e); + } + } +} diff --git a/typo3/sysext/core/Classes/Settings/CategoryDefinition.php b/typo3/sysext/core/Classes/Settings/CategoryDefinition.php new file mode 100644 index 0000000000000000000000000000000000000000..4e176271154732b357acb58554925d1aa2c0a254 --- /dev/null +++ b/typo3/sysext/core/Classes/Settings/CategoryDefinition.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Settings; + +/** + * @internal + */ +readonly class CategoryDefinition +{ + public function __construct( + public string $key, + public string $label, + public ?string $description = null, + public ?string $icon = null, + public ?string $parent = null, + ) {} + + public function toArray(): array + { + return array_filter(get_object_vars($this), fn(mixed $value) => $value !== null && $value !== []); + } + + public static function __set_state(array $state): self + { + return new self(...$state); + } +} diff --git a/typo3/sysext/core/Classes/Settings/SettingDefinition.php b/typo3/sysext/core/Classes/Settings/SettingDefinition.php index 99238f17ebf1736acdd92fc70a873cdf14ebc9f5..f74cb89756852b52ed4772297997645f20b5fb3d 100644 --- a/typo3/sysext/core/Classes/Settings/SettingDefinition.php +++ b/typo3/sysext/core/Classes/Settings/SettingDefinition.php @@ -29,7 +29,7 @@ readonly class SettingDefinition public string $label, public ?string $description = null, public array $enum = [], - public array $categories = [], + public ?string $category = null, public array $tags = [], ) {} diff --git a/typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php b/typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php index 43529a174689023ff5e59313ae3ef0d18f6307da..18e8ea11ccbd5dbc7bbac2a284186605e4944b2c 100644 --- a/typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php +++ b/typo3/sysext/core/Classes/Settings/SettingsTypeInterface.php @@ -28,4 +28,6 @@ interface SettingsTypeInterface public function validate(mixed $value, SettingDefinition $definition): bool; public function transformValue(mixed $value, SettingDefinition $definition): mixed; + + public function getJavaScriptModule(): string; } diff --git a/typo3/sysext/core/Classes/Settings/Type/BoolType.php b/typo3/sysext/core/Classes/Settings/Type/BoolType.php index 3306cc3ee992abc2d6a4c746f2c48bb30677be15..64f25acd0e39dde63e86399d3879091a58edd06b 100644 --- a/typo3/sysext/core/Classes/Settings/Type/BoolType.php +++ b/typo3/sysext/core/Classes/Settings/Type/BoolType.php @@ -74,4 +74,9 @@ readonly class BoolType implements SettingsTypeInterface } return false; } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/bool.js'; + } } diff --git a/typo3/sysext/core/Classes/Settings/Type/ColorType.php b/typo3/sysext/core/Classes/Settings/Type/ColorType.php index 034dc17d57b83adcf5b61e8f8133febd0bdeae38..06d229234da30838d365878311dbf3200c3641bf 100644 --- a/typo3/sysext/core/Classes/Settings/Type/ColorType.php +++ b/typo3/sysext/core/Classes/Settings/Type/ColorType.php @@ -141,4 +141,9 @@ readonly class ColorType implements SettingsTypeInterface return '#' . $values; } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/color.js'; + } } diff --git a/typo3/sysext/core/Classes/Settings/Type/IntType.php b/typo3/sysext/core/Classes/Settings/Type/IntType.php index ecda4f255d12f02357d9da750b40cd3ec351d1df..8601ff0c6db2cec47d37ab400df72cf2227bbb6c 100644 --- a/typo3/sysext/core/Classes/Settings/Type/IntType.php +++ b/typo3/sysext/core/Classes/Settings/Type/IntType.php @@ -52,4 +52,9 @@ readonly class IntType implements SettingsTypeInterface return (int)$value; } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/int.js'; + } } diff --git a/typo3/sysext/core/Classes/Settings/Type/NumberType.php b/typo3/sysext/core/Classes/Settings/Type/NumberType.php index 1b76dbc46e45d710af33878c0afc59558f98264e..f3528741cbe07bb9f0498c083f4ac5b60d33a41c 100644 --- a/typo3/sysext/core/Classes/Settings/Type/NumberType.php +++ b/typo3/sysext/core/Classes/Settings/Type/NumberType.php @@ -64,4 +64,9 @@ readonly class NumberType implements SettingsTypeInterface } return $value; } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/number.js'; + } } diff --git a/typo3/sysext/core/Classes/Settings/Type/StringListType.php b/typo3/sysext/core/Classes/Settings/Type/StringListType.php index fb96faa66ca220ff749aef67009129741faf9da4..511818c9aee15d3aaba633fefebf3b529b7a4806 100644 --- a/typo3/sysext/core/Classes/Settings/Type/StringListType.php +++ b/typo3/sysext/core/Classes/Settings/Type/StringListType.php @@ -40,6 +40,15 @@ readonly class StringListType implements SettingsTypeInterface public function transformValue(mixed $value, SettingDefinition $definition): array { $stringType = new StringType($this->logger); + if (is_string($value)) { + // A json-encoded stringlist only needs 2-levels + $depth = 2; + try { + $value = json_decode($value, false, 2, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + // invalid json, ignore and handle below + } + } if (!is_array($value) || !$this->doValidate($stringType, $value, $definition)) { $this->logger->warning('Setting validation field, reverting to default: {key}', ['key' => $definition->key]); return $definition->default; @@ -60,4 +69,9 @@ readonly class StringListType implements SettingsTypeInterface } return true; } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/stringlist.js'; + } } diff --git a/typo3/sysext/core/Classes/Settings/Type/StringType.php b/typo3/sysext/core/Classes/Settings/Type/StringType.php index 4ee3f7bd5207c8fe9ce53e0232168bd0f1e17fb0..dca3c1c6c996d8a231363947e35fb796f342fadd 100644 --- a/typo3/sysext/core/Classes/Settings/Type/StringType.php +++ b/typo3/sysext/core/Classes/Settings/Type/StringType.php @@ -51,4 +51,9 @@ readonly class StringType implements SettingsTypeInterface } return (string)$value; } + + public function getJavaScriptModule(): string + { + return '@typo3/backend/settings/type/string.js'; + } } diff --git a/typo3/sysext/core/Classes/Site/Entity/Site.php b/typo3/sysext/core/Classes/Site/Entity/Site.php index e237fbaeb270c137d18ec82590f6f980876f1cd7..8f41763774da31586aaf877bba3bf284cc2738a9 100644 --- a/typo3/sysext/core/Classes/Site/Entity/Site.php +++ b/typo3/sysext/core/Classes/Site/Entity/Site.php @@ -67,6 +67,12 @@ class Site implements SiteInterface */ protected $configuration; + /** + * Raw attributes for this site + * @var array + */ + protected $rawConfiguration; + /** * @var array<LanguageRef, SiteLanguage> */ @@ -102,6 +108,7 @@ class Site implements SiteInterface $this->settings = $settings; $this->typoscript = $typoscript; $this->tsConfig = $tsConfig; + $this->rawConfiguration = $configuration; // Merge settings back in configuration for backwards-compatibility $configuration['settings'] = $this->settings->getAll(); $this->configuration = $configuration; @@ -329,6 +336,11 @@ class Site implements SiteInterface return $this->configuration; } + public function getRawConfiguration(): array + { + return $this->rawConfiguration; + } + public function getSettings(): SiteSettings { return $this->settings; diff --git a/typo3/sysext/core/Classes/Site/Set/CategoryRegistry.php b/typo3/sysext/core/Classes/Site/Set/CategoryRegistry.php new file mode 100644 index 0000000000000000000000000000000000000000..5f8e373cecf39ac4d9e6a3abc5676f851d7ff06e --- /dev/null +++ b/typo3/sysext/core/Classes/Site/Set/CategoryRegistry.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site\Set; + +use TYPO3\CMS\Core\Settings\Category; +use TYPO3\CMS\Core\Settings\CategoryAccumulator; + +class CategoryRegistry +{ + public function __construct( + protected SetRegistry $setRegistry, + ) {} + + /** + * Retrieve list of instantiated categories for the list of + * provided $setNames, including their dependencies (recursive) + * + * @return list<Category> + */ + public function getCategories(string ...$setNames): array + { + $sets = $this->setRegistry->getSets(...$setNames); + $categories = []; + + $categoryDefinitions = []; + foreach ($sets as $set) { + foreach ($set->categoryDefinitions as $definition) { + $categoryDefinitions[] = $definition; + } + } + $settingsDefinitions = []; + foreach ($sets as $set) { + foreach ($set->settingsDefinitions as $definition) { + $settingsDefinitions[] = $definition; + } + } + + $cateryAccumulator = new CategoryAccumulator(); + return $cateryAccumulator->getCategories( + $categoryDefinitions, + $settingsDefinitions, + ); + } +} diff --git a/typo3/sysext/core/Classes/Site/Set/SetDefinition.php b/typo3/sysext/core/Classes/Site/Set/SetDefinition.php index f62864e557a1410cce847d279ea661d70bda3c4a..7e80e03993a43198c562dd020cb6bbc28bfc6048 100644 --- a/typo3/sysext/core/Classes/Site/Set/SetDefinition.php +++ b/typo3/sysext/core/Classes/Site/Set/SetDefinition.php @@ -17,6 +17,7 @@ declare(strict_types=1); namespace TYPO3\CMS\Core\Site\Set; +use TYPO3\CMS\Core\Settings\CategoryDefinition; use TYPO3\CMS\Core\Settings\SettingDefinition; readonly class SetDefinition @@ -24,6 +25,7 @@ readonly class SetDefinition /** * @param list<string> $dependencies * @param SettingDefinition[] $settingsDefinitions + * @param CategoryDefinition[] $categoryDefinitions */ public function __construct( public string $name, @@ -31,6 +33,7 @@ readonly class SetDefinition public array $dependencies = [], public array $optionalDependencies = [], public array $settingsDefinitions = [], + public array $categoryDefinitions = [], public ?string $typoscript = null, public ?string $pagets = null, public array $settings = [], diff --git a/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php b/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php index 1d7e48b416f36c28e66c74a94ec4db27f505e7ab..0a20d7aca78a2f621ad6a30d227e895f40e3b87c 100644 --- a/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php +++ b/typo3/sysext/core/Classes/Site/Set/YamlSetDefinitionProvider.php @@ -20,6 +20,7 @@ namespace TYPO3\CMS\Core\Site\Set; use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; +use TYPO3\CMS\Core\Settings\CategoryDefinition; use TYPO3\CMS\Core\Settings\SettingDefinition; /** @@ -66,7 +67,8 @@ class YamlSetDefinitionProvider if (!is_array($settingsDefinitions['settings'] ?? null)) { throw new \RuntimeException('Missing "settings" key in settings definitions. Filename: ' . $settingsDefinitionsFile, 1711024378); } - $set['settingsDefinitions'] = $settingsDefinitions['settings']; + $set['settingsDefinitions'] = $settingsDefinitions['settings'] ?? []; + $set['categoryDefinitions'] = $settingsDefinitions['categories'] ?? []; } $settingsFile = $path . '/settings.yaml'; @@ -115,9 +117,25 @@ class YamlSetDefinitionProvider } $settingsDefinitions[] = $definition; } + + $categoryDefinitions = []; + foreach (($set['categoryDefinitions'] ?? []) as $category => $options) { + if ($labels) { + $options['label'] ??= 'LLL:' . $labels . ':categories.' . $category; + $options['description'] ??= 'LLL:' . $labels . ':categories.description.' . $category; + } + try { + $definition = new CategoryDefinition(...[...['key' => $category], ...$options]); + } catch (\Error $e) { + throw new \Exception('Invalid category-category definition: ' . json_encode($options), 1702623313, $e); + } + $categoryDefinitions[] = $definition; + } + $setData = [ ...$set, 'settingsDefinitions' => $settingsDefinitions, + 'categoryDefinitions' => $categoryDefinitions, ]; $setData['typoscript'] ??= $basePath; $setData['pagets'] ??= $basePath . '/page.tsconfig'; diff --git a/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php b/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php index 5413f36bbabff88205450a9966fdab047c2bc3a2..2507ffab844c5d3813dc2ae45a119ed9c32c6b47 100644 --- a/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php +++ b/typo3/sysext/core/Classes/Site/SiteSettingsFactory.php @@ -135,6 +135,31 @@ readonly class SiteSettingsFactory ); } + public function createSettingsForKeys(array $settingKeys, string $siteIdentifier, array $inlineSettings = []): SiteSettings + { + $fileName = $this->configPath . '/' . $siteIdentifier . '/' . $this->settingsFileName; + if (file_exists($fileName)) { + $settingsTree = $this->yamlFileLoader->load(GeneralUtility::fixWindowsFilePath($fileName)); + } else { + $settingsTree = $inlineSettings; + } + + /** @var array<string, string|int|float|bool|array|null> $settingsMap */ + $settingsMap = []; + foreach ($settingKeys as $key) { + if (!ArrayUtility::isValidPath($settingsTree, $key, '.')) { + continue; + } + $settingsMap[$key] = ArrayUtility::getValueByPath($settingsTree, $key, '.'); + } + $flatSettings = $settingsTree === [] ? [] : ArrayUtility::flattenPlain($settingsTree); + return new SiteSettings( + settings: $settingsMap, + settingsTree: $settingsTree, + flatSettings: $flatSettings, + ); + } + protected function validateSettings(array $settings, array $definitions): array { foreach ($definitions as $definition) { diff --git a/typo3/sysext/core/Classes/Site/SiteSettingsService.php b/typo3/sysext/core/Classes/Site/SiteSettingsService.php new file mode 100644 index 0000000000000000000000000000000000000000..8117e463cdddaebe95a0ce3d40e2f89a76975585 --- /dev/null +++ b/typo3/sysext/core/Classes/Site/SiteSettingsService.php @@ -0,0 +1,180 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Site; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\Yaml\Yaml; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException; +use TYPO3\CMS\Core\Configuration\SiteWriter; +use TYPO3\CMS\Core\Messaging\FlashMessage; +use TYPO3\CMS\Core\Messaging\FlashMessageService; +use TYPO3\CMS\Core\Settings\SettingDefinition; +use TYPO3\CMS\Core\Settings\SettingsTypeRegistry; +use TYPO3\CMS\Core\Site\Entity\Site; +use TYPO3\CMS\Core\Site\Entity\SiteSettings; +use TYPO3\CMS\Core\Site\Set\SetRegistry; +use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity; +use TYPO3\CMS\Core\Utility\ArrayUtility; +use TYPO3\CMS\Core\Utility\GeneralUtility; + +/** + * @internal + */ +readonly class SiteSettingsService +{ + public function __construct( + protected SiteWriter $siteWriter, + #[Autowire(service: 'cache.core')] + protected PhpFrontend $codeCache, + protected SetRegistry $setRegistry, + protected SiteSettingsFactory $siteSettingsFactory, + protected SettingsTypeRegistry $settingsTypeRegistry, + protected FlashMessageService $flashMessageService, + ) {} + + public function hasSettingsDefinitions(Site $site): bool + { + return count($this->getDefinitions($site)) > 0; + } + + public function getUncachedSettings(Site $site): SiteSettings + { + // create a fresh Settings instance instead of using + // $site->getSettings() which may have been loaded from cache + return $settings = $this->siteSettingsFactory->createSettings( + $site->getSets(), + $site->getIdentifier(), + $site->getRawConfiguration()['settings'] ?? [], + ); + } + + public function getSetSettings(Site $site): SiteSettings + { + return $this->siteSettingsFactory->createSettings($site->getSets()); + } + + public function getLocalSettings(Site $site): SiteSettings + { + $definitions = $this->getDefinitions($site); + return $this->siteSettingsFactory->createSettingsForKeys( + array_map(static fn(SettingDefinition $d) => $d->key, $definitions), + $site->getIdentifier(), + $site->getRawConfiguration()['settings'] ?? [] + ); + } + + public function computeSettingsDiff(Site $site, array $rawSettings, bool $minify = true): array + { + $settings = []; + $localSettings = []; + + $definitions = $this->getDefinitions($site); + foreach ($rawSettings as $key => $value) { + $definition = $definitions[$key] ?? null; + if ($definition === null) { + throw new \RuntimeException('Unexpected setting ' . $key . ' is not defined', 1724067004); + } + $type = $this->settingsTypeRegistry->get($definition->type); + $settings[$key] = $type->transformValue($value, $definition); + } + + // Settings from sets – setting values without config/sites/*/settings.yaml applied + $setSettings = $this->siteSettingsFactory->createSettings($site->getSets()); + // Settings from config/sites/*/settings.yaml only (our persistence target) + $localSettings = $this->siteSettingsFactory->createSettingsForKeys( + array_map(static fn(SettingDefinition $d) => $d->key, $definitions), + $site->getIdentifier(), + $site->getRawConfiguration()['settings'] ?? [] + ); + + // Read existing settings, as we *must* not remove any settings that may be present because of + // * "undefined" settings that were supported since TYPO3 v12 + // * (temporary) inactive sets + $settingsTree = $localSettings->getAll(); + + // Merge incoming settings into current settingsTree + $changes = []; + $deletions = []; + foreach ($settings as $key => $value) { + if ($minify && $value === $setSettings->get($key)) { + if (ArrayUtility::isValidPath($settingsTree, $key, '.')) { + $settingsTree = $this->removeByPathWithAncestors($settingsTree, $key, '.'); + $deletions[] = $key; + } + continue; + } + $settingsTree = ArrayUtility::setValueByPath($settingsTree, $key, $value, '.'); + $changes[] = $key; + } + return [ + 'settings' => $settingsTree, + 'changes' => $changes, + 'deletions' => $deletions, + ]; + } + + public function writeSettings(Site $site, array $settings): void + { + try { + $this->siteWriter->writeSettings($site->getIdentifier(), $settings); + } catch (SiteConfigurationWriteException $e) { + $flashMessage = GeneralUtility::makeInstance(FlashMessage::class, $e->getMessage(), '', ContextualFeedbackSeverity::ERROR, true); + $flashMessageService = GeneralUtility::makeInstance(FlashMessageService::class); + $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); + $defaultFlashMessageQueue->enqueue($flashMessage); + } + // SiteWriter currently does not invalidate the code cache, see #103804 + $this->codeCache->flush(); + } + + private function getDefinitions(Site $site): array + { + $sets = $this->setRegistry->getSets(...$site->getSets()); + $definitions = []; + foreach ($sets as $set) { + foreach ($set->settingsDefinitions as $settingDefinition) { + $definitions[$settingDefinition->key] = $settingDefinition; + } + } + return $definitions; + } + + private function removeByPathWithAncestors(array $array, string $path, string $delimiter): array + { + if ($path === '') { + return $array; + } + if (!ArrayUtility::isValidPath($array, $path, $delimiter)) { + return $array; + } + + $array = ArrayUtility::removeByPath($array, $path, $delimiter); + $parts = explode($delimiter, $path); + array_pop($parts); + $parentPath = implode($delimiter, $parts); + + if ($parentPath !== '' && ArrayUtility::isValidPath($array, $parentPath, $delimiter)) { + $parent = ArrayUtility::getValueByPath($array, $parentPath, $delimiter); + if ($parent === []) { + return $this->removeByPathWithAncestors($array, $parentPath, $delimiter); + } + } + return $array; + } +} diff --git a/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104794-IntroduceSiteSettingsEditor.rst b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104794-IntroduceSiteSettingsEditor.rst new file mode 100644 index 0000000000000000000000000000000000000000..ff55fa8f52eeeff06a35aa57e939c5a624425014 --- /dev/null +++ b/typo3/sysext/core/Documentation/Changelog/13.3/Feature-104794-IntroduceSiteSettingsEditor.rst @@ -0,0 +1,81 @@ +.. include:: /Includes.rst.txt + +.. _feature-104794-1725980585: + +================================================= +Feature: #104794 - Introduce Site Settings Editor +================================================= + +See :issue:`104794` + +Description +=========== + +A new Site Settings editor has been introduced that allows to configure per-site +settings in file:`config/sites/*/settings.yaml`. + +The new backend module :guilabel:`Site Management > Settings` +provides an overview of sites which offer configurable settings and makes +them editable based on +:doc:`Site Set provided Settings Definitions <../13.1/Feature-103437-IntroduceSiteSets>`. + +The editor shows a list of settings categories and respective settings. It will +persist all settings into file:`config/sites/*/settings.yaml`. The module will +only persist settings that deviate from the site-scoped default value. That +means it will only change the minimal difference to the settings set defined +by the active sets for the respective site. + +The backend module is currently available for administrators only, but will +likely be extended to be made available for editors in future. + +Anonymous (undefined) site settings – as supported since TYPO3 v10 – +will not be made editable, but will be preserved as-is when persisting changes +through the settings editor. + + +Categorization +-------------- + +Sets can define categories and settings definitions can reference the category +by ID in order to assign a setting to a specific category. +These definitions are placed in :file:`settings.definitions.yaml` +next to the site set file :file:`config.yaml`. + +.. code-block:: yaml + :caption: EXT:my_extension/Configuration/Sets/MySet/settings.definitions.yaml + + categories: + myCategory: + label: 'My Category' + + settings: + my.example.setting: + label: 'My example setting' + category: myCategory + type: string + default: '' + + my.seoRelevantSetting: + label: 'My SEO relevant setting' + # show in EXT:seo provided category "seo" + category: seo + type: int + default: 5 + +The settings ordering is defined through the loading order of extensions and by +the order of categories. Uncategorized settings will be grouped into a virtual +"Other" category and shown at the end of the list of available settings. + + +Impact +====== + +Site-scoped settings will most likely be the place to configure site-wide +configuration, which was previously only possible to modify via Constant Editor, +modifying TypoScript constants. + +It is recommended to use site-sets and their UI configuration in favor of +Typoscript Constants in the future. + + +.. index:: Backend, Frontend, YAML, ext:backend diff --git a/typo3/sysext/felogin/Configuration/Sets/Felogin/labels.xlf b/typo3/sysext/felogin/Configuration/Sets/Felogin/labels.xlf index 6209e971b1d39cef34218dac7699106ef1987568..fd462d9ab14a155c560b2b6372cd73e35874bfee 100644 --- a/typo3/sysext/felogin/Configuration/Sets/Felogin/labels.xlf +++ b/typo3/sysext/felogin/Configuration/Sets/Felogin/labels.xlf @@ -6,6 +6,11 @@ <trans-unit id="label" resname="label"> <source>Frontend Login</source> </trans-unit> + + <trans-unit id="categories.felogin" resname="categories.felogin"> + <source>Frontend Login</source> + </trans-unit> + <trans-unit id="settings.felogin.pid" resname="settings.felogin.pid"> <source>User Storage Page</source> </trans-unit> diff --git a/typo3/sysext/felogin/Configuration/Sets/Felogin/settings.definitions.yaml b/typo3/sysext/felogin/Configuration/Sets/Felogin/settings.definitions.yaml index 4f0f94cf1254b06b2ec5bbbfe089267f702aeec8..1c5987de4f75fb51e5e6c51cfe5f8c104cf7028d 100644 --- a/typo3/sysext/felogin/Configuration/Sets/Felogin/settings.definitions.yaml +++ b/typo3/sysext/felogin/Configuration/Sets/Felogin/settings.definitions.yaml @@ -1,7 +1,11 @@ +categories: + felogin: ~ + settings: felogin.pid: default: '0' type: string + category: felogin felogin.recursive: default: '0' type: string @@ -12,72 +16,96 @@ settings: '3': '3' '4': '4' '255': '255' + category: felogin felogin.showForgotPassword: default: false type: bool + category: felogin felogin.showPermaLogin: default: false type: bool + category: felogin felogin.showLogoutFormAfterLogin: default: false type: bool + category: felogin felogin.emailFrom: default: '' type: string + category: felogin felogin.emailFromName: default: '' type: string + category: felogin felogin.replyToEmail: default: '' type: string + category: felogin felogin.dateFormat: default: 'Y-m-d H:i' type: string + category: felogin felogin.email.layoutRootPath: default: '' type: string + category: felogin felogin.email.templateRootPath: default: 'EXT:felogin/Resources/Private/Email/Templates/' type: string + category: felogin felogin.email.partialRootPath: default: '' type: string + category: felogin felogin.email.templateName: default: PasswordRecovery type: string + category: felogin felogin.redirectMode: default: '' type: string + category: felogin felogin.redirectFirstMethod: default: false type: bool + category: felogin felogin.redirectPageLogin: default: 0 type: int + category: felogin felogin.redirectPageLoginError: default: 0 type: int + category: felogin felogin.redirectPageLogout: default: 0 type: int + category: felogin felogin.redirectDisable: default: false type: bool + category: felogin felogin.forgotLinkHashValidTime: default: 12 type: int + category: felogin felogin.domains: default: '' type: string + category: felogin felogin.exposeNonexistentUserInForgotPasswordDialog: default: false type: bool + category: felogin felogin.view.templateRootPath: default: '' type: string + category: felogin felogin.view.partialRootPath: default: '' type: string + category: felogin felogin.view.layoutRootPath: default: '' type: string + category: felogin diff --git a/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/labels.xlf b/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/labels.xlf index e418034c8c34e3853d85ef1c9777cf9e02226583..eec39bda99b5366fbf12c140c1ed1e11b1fca694 100644 --- a/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/labels.xlf +++ b/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/labels.xlf @@ -6,6 +6,17 @@ <trans-unit id="label" resname="label"> <source>Fluid Styled Content</source> </trans-unit> + + <trans-unit id="categories.fsc" resname="categories.fsc"> + <source>Fluid Styled Content</source> + </trans-unit> + <trans-unit id="categories.fsc.templates" resname="categories.fsc.templates"> + <source>Templates</source> + </trans-unit> + <trans-unit id="categories.fsc.content" resname="categories.fsc.content"> + <source>Content Elements</source> + </trans-unit> + <trans-unit id="settings.styles.content.defaultHeaderType" resname="settings.styles.content.defaultHeaderType"> <source>Default Header type</source> </trans-unit> diff --git a/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/settings.definitions.yaml b/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/settings.definitions.yaml index da97c227c184c2689d3d3cb0b49fc9a454c42c47..b9b96085724242f37a1c44621e2c3a1a5c695a57 100644 --- a/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/settings.definitions.yaml +++ b/typo3/sysext/fluid_styled_content/Configuration/Sets/FluidStyledContent/settings.definitions.yaml @@ -1,13 +1,23 @@ +categories: + fsc: ~ + fsc.templates: + parent: fsc + fsc.content: + parent: fsc + settings: styles.content.defaultHeaderType: default: 2 type: int + category: fsc.content styles.content.shortcut.tables: default: tt_content type: string + category: fsc.content styles.content.allowTags: default: 'a, abbr, acronym, address, article, aside, b, bdo, big, blockquote, br, caption, center, cite, code, col, colgroup, dd, del, dfn, dl, div, dt, em, figure, font, footer, header, h1, h2, h3, h4, h5, h6, hr, i, img, ins, kbd, label, li, link, mark, meta, nav, ol, p, pre, q, s, samp, sdfield, section, small, span, strike, strong, style, sub, sup, table, thead, tbody, tfoot, td, th, tr, title, tt, u, ul, var' type: string + category: fsc.content styles.content.image.lazyLoading: default: lazy type: string @@ -15,6 +25,7 @@ settings: lazy: 'Lazy' eager: 'Eager' auto: 'Auto' + category: fsc.content styles.content.image.imageDecoding: default: '' type: string @@ -22,60 +33,80 @@ settings: sync: 'Sync' async: 'Asynchronous' auto: 'Auto' + category: fsc.content styles.content.textmedia.maxW: default: 600 type: int + category: fsc.content styles.content.textmedia.maxWInText: default: 300 type: int + category: fsc.content styles.content.textmedia.columnSpacing: default: 10 type: int + category: fsc.content styles.content.textmedia.rowSpacing: default: 10 type: int + category: fsc.content styles.content.textmedia.textMargin: default: 10 type: int + category: fsc.content styles.content.textmedia.borderColor: default: '#000000' type: color + category: fsc.content styles.content.textmedia.borderWidth: default: 2 type: int + category: fsc.content styles.content.textmedia.borderPadding: default: 0 type: int + category: fsc.content styles.content.textmedia.linkWrap.width: default: 800m type: string + category: fsc.content styles.content.textmedia.linkWrap.height: default: 600m type: string + category: fsc.content styles.content.textmedia.linkWrap.newWindow: default: false type: bool + category: fsc.content styles.content.textmedia.linkWrap.lightboxEnabled: default: false type: bool + category: fsc.content styles.content.textmedia.linkWrap.lightboxCssClass: default: lightbox type: string + category: fsc.content styles.content.textmedia.linkWrap.lightboxRelAttribute: default: 'lightbox[{field:uid}]' type: string + category: fsc.content styles.content.links.extTarget: default: _blank type: string + category: fsc.content styles.content.links.keep: default: path type: string + category: fsc.content styles.templates.templateRootPath: default: '' type: string + category: fsc.templates styles.templates.partialRootPath: default: '' type: string + category: fsc.templates styles.templates.layoutRootPath: default: '' type: string + category: fsc.templates diff --git a/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/labels.xlf b/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/labels.xlf index 3531deaf42d91e3ecf61b01ba430cfb947e1c1e6..5690f89050c84308d91eb6600c1fbfc84df44a69 100644 --- a/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/labels.xlf +++ b/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/labels.xlf @@ -7,6 +7,13 @@ <source>Indexed Search</source> </trans-unit> + <trans-unit id="categories.indexedsearch" resname="categories.indexedsearch"> + <source>Indexed Search</source> + </trans-unit> + <trans-unit id="categories.indexedsearch.templates" resname="categories.indexedsearch.templates"> + <source>Templates</source> + </trans-unit> + <trans-unit id="settings.indexedsearch.view.templateRootPath" resname="settings.indexedsearch.view.templateRootPath"> <source>Path to template root (FE)</source> </trans-unit> diff --git a/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/settings.definitions.yaml b/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/settings.definitions.yaml index 26e579f799bc506869d5efb3b4056971cd6ce003..621cb185125cad3c760f3b42208fb2693839f652 100644 --- a/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/settings.definitions.yaml +++ b/typo3/sysext/indexed_search/Configuration/Sets/IndexedSearch/settings.definitions.yaml @@ -1,16 +1,26 @@ +categories: + indexedsearch: ~ + indexedsearch.templates: + parent: indexedsearch + settings: indexedsearch.view.templateRootPath: default: 'EXT:indexed_search/Resources/Private/Templates/' type: string + category: indexedsearch.templates indexedsearch.view.partialRootPath: default: 'EXT:indexed_search/Resources/Private/Partials/' type: string + category: indexedsearch.templates indexedsearch.view.layoutRootPath: default: 'EXT:indexed_search/Resources/Private/Layouts/' type: string + category: indexedsearch.templates indexedsearch.targetPid: default: 0 type: int + category: indexedsearch indexedsearch.rootPidList: default: '' type: string + category: indexedsearch diff --git a/typo3/sysext/redirects/Configuration/Backend/Modules.php b/typo3/sysext/redirects/Configuration/Backend/Modules.php index a54ebe9cc6969e7ac660101f06b31e19aeebf262..548d1df1e73efd2f7b7a4c0cee234e5eb10cb046 100644 --- a/typo3/sysext/redirects/Configuration/Backend/Modules.php +++ b/typo3/sysext/redirects/Configuration/Backend/Modules.php @@ -8,7 +8,7 @@ use TYPO3\CMS\Redirects\Controller\ManagementController; return [ 'site_redirects' => [ 'parent' => 'site', - 'position' => ['after' => 'site_configuration'], + 'position' => ['after' => 'site_settings'], 'access' => 'user', 'path' => '/module/site/redirects', 'iconIdentifier' => 'module-redirects', diff --git a/typo3/sysext/redirects/Configuration/Sets/redirects/config.yaml b/typo3/sysext/redirects/Configuration/Sets/redirects/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0bdb82bff694bc4d844941b2ea9c9bce1e571e06 --- /dev/null +++ b/typo3/sysext/redirects/Configuration/Sets/redirects/config.yaml @@ -0,0 +1 @@ +name: typo3/redirects diff --git a/typo3/sysext/redirects/Configuration/Sets/redirects/labels.xlf b/typo3/sysext/redirects/Configuration/Sets/redirects/labels.xlf new file mode 100644 index 0000000000000000000000000000000000000000..667f44fb767165e3d2031169aa3f2b62115d1248 --- /dev/null +++ b/typo3/sysext/redirects/Configuration/Sets/redirects/labels.xlf @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> + <file source-language="en" datatype="plaintext" original="EXT:redirects/Configuration/Sets/redirects/labels.xlf" date="2024-09-10T12:30:00Z" + product-name="redirects"> + <header/> + <body> + <trans-unit id="label" resname="label"> + <source>Redirects</source> + </trans-unit> + + <trans-unit id="categories.redirects" resname="categories.redirects"> + <source>Redirects</source> + </trans-unit> + + <trans-unit id="settings.redirects.autoUpdateSlugs" resname="settings.redirects.autoUpdateSlugs"> + <source>Automatically update slugs of all sub pages</source> + </trans-unit> + + <trans-unit id="settings.redirects.autoCreateRedirects" resname="settings.redirects.autoCreateRedirects"> + <source>Automatically create redirects for pages with a new slug (works only in LIVE workspace)</source> + </trans-unit> + <trans-unit id="settings.description.redirects.autoCreateRedirects" resname="settings.description.redirects.autoCreateRedirects"> + <source>This feature works only in LIVE workspace.</source> + </trans-unit> + + <trans-unit id="settings.redirects.redirectTTL" resname="settings.redirects.redirectTTL"> + <source>Time To Live in days for redirect records to be created - `0` disables TTL, no expiration</source> + </trans-unit> + + <trans-unit id="settings.redirects.httpStatusCode" resname="settings.redirects.httpStatusCode"> + <source>HTTP status code for automatically created redirects</source> + </trans-unit> + + <trans-unit id="settings.description.redirects.httpStatusCode" resname="settings.description.redirects.httpStatusCode"> + <source>See https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#Temporary_redirections for more information.</source> + </trans-unit> + </body> + </file> +</xliff> diff --git a/typo3/sysext/redirects/Configuration/Sets/redirects/settings.definitions.yaml b/typo3/sysext/redirects/Configuration/Sets/redirects/settings.definitions.yaml new file mode 100644 index 0000000000000000000000000000000000000000..be227986169b8c6fce97f5980bbb7fae01d4fb3f --- /dev/null +++ b/typo3/sysext/redirects/Configuration/Sets/redirects/settings.definitions.yaml @@ -0,0 +1,20 @@ +categories: + redirects: ~ + +settings: + redirects.autoUpdateSlugs: + type: bool + default: true + category: redirects + redirects.autoCreateRedirects: + type: bool + default: true + category: redirects + redirects.redirectTTL: + type: int + default: 0 + category: redirects + redirects.httpStatusCode: + type: int + default: 307 + category: redirects diff --git a/typo3/sysext/seo/Configuration/Sets/Sitemap/labels.xlf b/typo3/sysext/seo/Configuration/Sets/Sitemap/labels.xlf index 9696019fb34b6a14b006fa6195f23678ce5cb2f6..8648dc1af831e9fd3ef3f9552040a9cacc8d5ca3 100644 --- a/typo3/sysext/seo/Configuration/Sets/Sitemap/labels.xlf +++ b/typo3/sysext/seo/Configuration/Sets/Sitemap/labels.xlf @@ -8,6 +8,13 @@ <source>SEO Sitemap</source> </trans-unit> + <trans-unit id="categories.seo" resname="categories.seo"> + <source>SEO Sitemap</source> + </trans-unit> + <trans-unit id="categories.seo.templates" resname="categories.seo.templates"> + <source>Template Paths</source> + </trans-unit> + <trans-unit id="settings.seo.sitemap.view.templateRootPath" resname="settings.seo.sitemap.view.templateRootPath"> <source>Path to template root (FE)</source> </trans-unit> diff --git a/typo3/sysext/seo/Configuration/Sets/Sitemap/settings.definitions.yaml b/typo3/sysext/seo/Configuration/Sets/Sitemap/settings.definitions.yaml index 8d55e56eb8adc5283e23a2803cc8a91f3ac0880d..52e69f005bcfaf9367b8c32db22151796e6ebe70 100644 --- a/typo3/sysext/seo/Configuration/Sets/Sitemap/settings.definitions.yaml +++ b/typo3/sysext/seo/Configuration/Sets/Sitemap/settings.definitions.yaml @@ -1,19 +1,30 @@ +categories: + seo: ~ + seo.templates: + parent: seo + settings: seo.sitemap.view.templateRootPath: default: 'EXT:seo/Resources/Private/Templates/' type: string + category: seo.templates seo.sitemap.view.partialRootPath: default: 'EXT:seo/Resources/Private/Partials/' type: string + category: seo.templates seo.sitemap.view.layoutRootPath: default: 'EXT:seo/Resources/Private/Layouts/' type: string + category: seo.templates seo.sitemap.pages.excludedDoktypes: default: '3, 4, 6, 7, 199, 254' type: string + category: seo seo.sitemap.pages.excludePagesRecursive: default: '' type: string + category: seo seo.sitemap.pages.additionalWhere: default: "{#no_index} = 0 AND {#canonical_link} = ''" type: string + category: seo