diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeDefinitionMapper.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeDefinitionMapper.java index 2e6bf941f..0391d4188 100644 --- a/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeDefinitionMapper.java +++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeDefinitionMapper.java @@ -115,6 +115,24 @@ static List copyFields(List fields) { return fields == null || fields.isEmpty() ? List.of() : List.copyOf(fields); } + static List tabs(Object value) { + if (!(value instanceof Collection tabs)) { + return List.of(); + } + + List result = new ArrayList<>(); + for (Object tab : tabs) { + if (tab instanceof Map map) { + result.add(FormTab.fromMap(objectMap(map))); + } + } + return List.copyOf(result); + } + + static List copyTabs(List tabs) { + return tabs == null || tabs.isEmpty() ? List.of() : List.copyOf(tabs); + } + private static FormField field(Map definition) { String type = string(definition.get("type"), ""); String name = string(definition.get("name"), ""); diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/FormDefinition.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/FormDefinition.java index 4196d6006..22c87a8bb 100644 --- a/cms-api/src/main/java/com/condation/cms/api/ui/elements/FormDefinition.java +++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/FormDefinition.java @@ -33,17 +33,25 @@ * objects before they are stored in this definition. */ @Builder -public record FormDefinition(List fields) { +public record FormDefinition(List fields, List tabs) { public FormDefinition { fields = ContentTypeDefinitionMapper.copyFields(fields); + tabs = ContentTypeDefinitionMapper.copyTabs(tabs); + } + + /** Keeps the original fields-only Java API source compatible. */ + public FormDefinition(List fields) { + this(fields, List.of()); } static FormDefinition fromMap(Map form) { - return new FormDefinition(ContentTypeDefinitionMapper.fields(form.get("fields"))); + return new FormDefinition( + ContentTypeDefinitionMapper.fields(form.get("fields")), + ContentTypeDefinitionMapper.tabs(form.get("tabs"))); } public static FormDefinition empty() { - return new FormDefinition(List.of()); + return new FormDefinition(List.of(), List.of()); } } diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/FormTab.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/FormTab.java new file mode 100644 index 000000000..a1641e2b3 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/FormTab.java @@ -0,0 +1,43 @@ +package com.condation.cms.api.ui.elements; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.ui.elements.fields.FormField; +import java.util.List; +import java.util.Map; +import lombok.Builder; + +/** A named group of fields rendered as one tab of a manager form. */ +@Builder +public record FormTab(String title, List fields) { + + public FormTab { + title = ContentTypeDefinitionMapper.string(title, ""); + fields = ContentTypeDefinitionMapper.copyFields(fields); + } + + static FormTab fromMap(Map tab) { + return new FormTab( + ContentTypeDefinitionMapper.string(tab.get("title"), ""), + ContentTypeDefinitionMapper.fields(tab.get("fields"))); + } +} diff --git a/cms-api/src/test/java/com/condation/cms/api/ui/elements/ContentTypesTest.java b/cms-api/src/test/java/com/condation/cms/api/ui/elements/ContentTypesTest.java index 853bea4e2..ec63fb477 100644 --- a/cms-api/src/test/java/com/condation/cms/api/ui/elements/ContentTypesTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/ui/elements/ContentTypesTest.java @@ -43,7 +43,16 @@ void convertsDynamicRegistrationIntoDetachedTypedDefinition() { "name", "count", "options", options)); List> fields = new ArrayList<>(List.of(field)); - Map settings = new HashMap<>(Map.of("fields", fields)); + Map tabField = new HashMap<>(Map.of( + "type", "text", + "name", "description")); + List> tabFields = new ArrayList<>(List.of(tabField)); + Map tab = new HashMap<>(Map.of( + "title", "Details", + "fields", tabFields)); + Map settings = new HashMap<>(Map.of( + "fields", fields, + "tabs", new ArrayList<>(List.of(tab)))); Map input = new HashMap<>(Map.of( "name", "StartPage", "template", "start.html", @@ -65,23 +74,37 @@ void convertsDynamicRegistrationIntoDetachedTypedDefinition() { assertThat(numberField.getName()).isEqualTo("count"); assertThat(numberField.getOptions().min()).isEqualTo(1); }); + assertThat(pageTemplate.getForm("settings").tabs()) + .singleElement() + .satisfies(storedTab -> { + assertThat(storedTab.title()).isEqualTo("Details"); + assertThat(storedTab.fields()).singleElement() + .isInstanceOfSatisfying(StringField.class, + storedTabField -> assertThat(storedTabField.getName()).isEqualTo("description")); + }); input.put("template", "changed.html"); field.put("name", "changed"); options.put("min", 99); + tab.put("title", "Changed"); + tabField.put("name", "changed"); assertThat(pageTemplate.template()).isEqualTo("start.html"); NumberField storedField = (NumberField) pageTemplate.getForm("settings").fields().getFirst(); assertThat(storedField.getName()).isEqualTo("count"); assertThat(storedField.getOptions().min()).isEqualTo(1); + assertThat(pageTemplate.getForm("settings").tabs().getFirst().title()).isEqualTo("Details"); + assertThat(pageTemplate.getForm("settings").tabs().getFirst().fields().getFirst().getName()) + .isEqualTo("description"); } @Test void supportsTypedJavaRegistration() { - FormDefinition form = new FormDefinition(List.of( - new StringField("title", "Title"), - new NumberField("count", "Count", 0, 100, 1), - new MarkdownField("description", "Description"))); + FormDefinition form = new FormDefinition( + List.of(new StringField("title", "Title")), + List.of(new FormTab("Details", List.of( + new NumberField("count", "Count", 0, 100, 1), + new MarkdownField("description", "Description"))))); PageTemplate pageTemplate = PageTemplate.builder() .name("Default") .template("default.html") @@ -106,5 +129,7 @@ void supportsTypedJavaRegistration() { assertThat(contentTypes.getListItemTypes()).containsExactly(listItemType); assertThat(pageTemplate.createButton()).isTrue(); assertThat(form.fields()).allMatch(FormField.class::isInstance); + assertThat(form.tabs()).singleElement() + .satisfies(tab -> assertThat(tab.fields()).allMatch(FormField.class::isInstance)); } } diff --git a/cms-extensions/src/test/java/com/condation/cms/extensions/ContentTypesJavaScriptInteropTest.java b/cms-extensions/src/test/java/com/condation/cms/extensions/ContentTypesJavaScriptInteropTest.java index 8e3765136..e2bbd9bec 100644 --- a/cms-extensions/src/test/java/com/condation/cms/extensions/ContentTypesJavaScriptInteropTest.java +++ b/cms-extensions/src/test/java/com/condation/cms/extensions/ContentTypesJavaScriptInteropTest.java @@ -64,7 +64,11 @@ void acceptsExistingJavaScriptObjectLiteralRegistrations() { contentFolder: 'content', forms: { settings: { - fields: [{ type: 'text', name: 'title', required: true }] + fields: [{ type: 'text', name: 'title', required: true }], + tabs: [{ + title: 'SEO', + fields: [{ type: 'text', name: 'meta.description' }] + }] } } }); @@ -85,6 +89,14 @@ void acceptsExistingJavaScriptObjectLiteralRegistrations() { assertThat(field.getName()).isEqualTo("title"); assertThat(field.isRequired()).isTrue(); }); + assertThat(pageTemplate.getForm("settings").tabs()) + .singleElement() + .satisfies(tab -> { + assertThat(tab.title()).isEqualTo("SEO"); + assertThat(tab.fields()).singleElement() + .isInstanceOfSatisfying(StringField.class, + field -> assertThat(field.getName()).isEqualTo("meta.description")); + }); assertThat(contentTypes.getSectionEntryTemplates("main")).hasSize(1); assertThat(contentTypes.getListItemTypes()).hasSize(1); } diff --git a/modules/ui-module/src/main/resources/manager/actions/media/edit-media-form.js b/modules/ui-module/src/main/resources/manager/actions/media/edit-media-form.js index e684e80eb..5c6db2f13 100644 --- a/modules/ui-module/src/main/resources/manager/actions/media/edit-media-form.js +++ b/modules/ui-module/src/main/resources/manager/actions/media/edit-media-form.js @@ -31,14 +31,13 @@ export async function runAction(params) { var mediaForm = (await getMediaForm({ form: params.options.form || 'meta' })).result; - const fields = [ - ...mediaForm?.form?.fields - ]; + const formDefinition = mediaForm?.form ?? {}; const values = { ...(await getMediaMetaData({ image: params.options.image })).result.meta }; const form = createForm({ - fields: fields, + fields: formDefinition.fields ?? [], + tabs: formDefinition.tabs ?? [], values: values }); openSidebar({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js index 45553aadf..997b7e902 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js @@ -18,7 +18,7 @@ * along with this program. If not, see . * #L% */ -import { createForm } from '@cms/modules/form/forms.js'; +import { createForm, getFormFields } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; import { reloadPreview } from '@cms/modules/preview.utils.js'; import { buildValuesFromFields, getValueByPath } from '@cms/modules/node.js'; @@ -39,19 +39,21 @@ export async function runAction(params) { templates = (await getPageTemplates()).result; } var selected = templates.filter(item => item.template === getContentResponse?.result?.meta?.template); - var attrForm = []; + var attrForm = { fields: [], tabs: [] }; if (selected.length === 1) { - attrForm = selected[0].forms?.[params.form]?.fields ?? []; + attrForm = selected[0].forms?.[params.form] ?? attrForm; } + const attrFields = getFormFields(attrForm); //const previewMetaForm = getMetaForm() const fields = [ - ...attrForm + ...(attrForm.fields ?? []) ]; const values = { - ...buildValuesFromFields(attrForm, getContentResponse?.result?.meta) + ...buildValuesFromFields(attrFields, getContentResponse?.result?.meta) }; const form = createForm({ fields: fields, + tabs: attrForm.tabs ?? [], values: values }); openSidebar({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js index e3871b81c..f26ddb737 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js @@ -19,7 +19,7 @@ * #L% */ import { openSidebar } from '@cms/modules/sidebar.js'; -import { createForm } from '@cms/modules/form/forms.js'; +import { createForm, getFormFields } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; import { reloadPreview } from '@cms/modules/preview.utils.js'; @@ -40,24 +40,26 @@ export async function runAction(params) { const getContentResponse = await getContent({}); var pageTemplates = (await getPageTemplates()).result; var selected = pageTemplates.filter(pageTemplate => pageTemplate.template === getContentResponse?.result?.meta?.template); - var pageSettingsForm = []; + var pageSettingsForm = { fields: [], tabs: [] }; if (selected.length === 1) { - pageSettingsForm = selected[0].forms?.settings?.fields ?? []; + pageSettingsForm = selected[0].forms?.settings ?? pageSettingsForm; } + const pageSettingsFields = getFormFields(pageSettingsForm); //const previewMetaForm = getMetaForm() const fields = [ ...DEFAULT_FIELDS, - ...pageSettingsForm + ...(pageSettingsForm.fields ?? []) ]; const values = { 'title': getContentResponse?.result?.meta?.title, 'status': getContentResponse?.result?.meta?.status || 'draft', 'publish_date': getContentResponse?.result?.meta?.publish_date, 'unpublish_date': getContentResponse?.result?.meta?.unpublish_date, - ...buildValuesFromFields(pageSettingsForm, getContentResponse?.result?.meta) + ...buildValuesFromFields(pageSettingsFields, getContentResponse?.result?.meta) }; const form = createForm({ fields: fields, + tabs: pageSettingsForm.tabs ?? [], values: values }); openSidebar({ diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.list.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.list.js index ed6f3ee85..08a280b80 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.list.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.list.js @@ -20,7 +20,7 @@ */ import { createID } from "@cms/modules/form/utils.js"; import { i18n } from "@cms/modules/localization.js"; -import { createForm } from "@cms/modules/form/forms.js"; +import { createForm, getFormFields } from "@cms/modules/form/forms.js"; import { openModal } from "@cms/modules/modal.js"; import { buildValuesFromFields } from "@cms/modules/node.js"; import { getListItemTypes, getPageTemplates } from "@cms/modules/rpc/rpc-manager.js"; @@ -102,20 +102,20 @@ const getItemForm = async (el) => { var selected = pageTemplates.filter((pageTemplate) => pageTemplate.template === getContentResponse?.result?.meta?.template); const listContainer = el.closest("[data-cms-form-field-type='list']"); const fieldName = listContainer?.getAttribute('name'); - var itemForm = []; + var itemForm = { fields: [], tabs: [] }; if (selected.length === 1) { - itemForm = fieldName ? selected[0].forms?.[fieldName]?.fields ?? [] : []; + itemForm = fieldName ? selected[0].forms?.[fieldName] ?? itemForm : itemForm; } - if (!itemForm || itemForm.length === 0) { + if (getFormFields(itemForm).length === 0) { let itemTypes = (await getListItemTypes({})).result; var selectedItemType = itemTypes.filter((itemType) => itemType.name === fieldName); - itemForm = (selectedItemType.length === 1) ? selectedItemType[0].form?.fields ?? [] : []; + itemForm = (selectedItemType.length === 1) ? selectedItemType[0].form ?? itemForm : itemForm; } return itemForm; } catch (e) { console.error("Error loading item form", e); - return []; + return { fields: [], tabs: [] }; } }; const handleDoubleClick = async (event, context) => { @@ -125,10 +125,12 @@ const handleDoubleClick = async (event, context) => { if (itemDataString) { const itemData = JSON.parse(itemDataString); var itemForm = await getItemForm(el); + const itemFields = getFormFields(itemForm); const form = createForm({ - fields: itemForm, + fields: itemForm.fields ?? [], + tabs: itemForm.tabs ?? [], values: { - ...buildValuesFromFields(itemForm, itemData) + ...buildValuesFromFields(itemFields, itemData) } }); openModal({ diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/forms.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/form/forms.d.ts index 01852028e..66d90daed 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/forms.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/forms.d.ts @@ -18,8 +18,9 @@ * along with this program. If not, see . * #L% */ +declare const getFormFields: (definition: any) => any[]; declare const createForm: (options: any) => Form; -export { createForm }; +export { createForm, getFormFields }; export interface FormContext { formElement: HTMLFormElement | null; fields: any[]; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/forms.js b/modules/ui-module/src/main/resources/manager/js/modules/form/forms.js index 1d16afcfa..a65313914 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/forms.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/forms.js @@ -39,16 +39,31 @@ import { TextAreaField } from "@cms/modules/form/field.textarea.js"; import { ReferenceField } from "@cms/modules/form/field.reference.js"; import { TagsField } from "@cms/modules/form/field.tags.js"; import { i18n } from "@cms/modules/localization.js"; +const getFormFields = (definition) => { + const fields = Array.isArray(definition?.fields) ? definition.fields : []; + const tabFields = Array.isArray(definition?.tabs) + ? definition.tabs.flatMap((tab) => Array.isArray(tab?.fields) ? tab.fields : []) + : []; + return [...fields, ...tabFields]; +}; +const escapeHtml = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); const createForm = (options) => { - const fields = options.fields || []; + const standaloneFields = Array.isArray(options.fields) ? options.fields : []; + const tabs = Array.isArray(options.tabs) ? options.tabs : []; + const fields = getFormFields({ fields: standaloneFields, tabs }); const values = options.values || {}; const formId = createID(); const context = { formElement: null, fields: fields }; - const fieldHtml = fields.map((field) => { - const val = values[field.name] || ''; + const renderField = (field) => { + const val = values[field.name] ?? ''; switch (field.type) { case 'email': return MailField.markup(field, val); @@ -91,12 +106,96 @@ const createForm = (options) => { default: return ''; } - }).join('\n'); + }; + const fieldHtml = standaloneFields.map(renderField).join('\n'); + const tabsHtml = tabs.length > 0 ? ` + +
+ ${tabs.map((tab, index) => { + const tabId = `${formId}-tab-${index}`; + const panelId = `${formId}-panel-${index}`; + const tabFieldHtml = (Array.isArray(tab?.fields) ? tab.fields : []).map(renderField).join('\n'); + return `
+ ${tabFieldHtml} +
`; + }).join('\n')} +
` : ''; const html = `
${fieldHtml} + ${tabsHtml}
`; + const activateTab = (button) => { + if (!context.formElement) { + return; + } + const targetId = button.dataset.cmsFormTabTarget; + if (!targetId) { + return; + } + context.formElement.querySelectorAll('[data-cms-form-tab]').forEach(tabButton => { + const active = tabButton === button; + tabButton.classList.toggle('active', active); + tabButton.setAttribute('aria-selected', String(active)); + tabButton.tabIndex = active ? 0 : -1; + }); + context.formElement.querySelectorAll('.tab-pane').forEach(panel => { + const active = panel.id === targetId; + panel.classList.toggle('active', active); + panel.classList.toggle('show', active); + panel.hidden = !active; + }); + requestAnimationFrame(() => { + const panel = context.formElement?.querySelector(`#${targetId}`); + panel?.querySelectorAll('.CodeMirror').forEach((editorElement) => { + editorElement.CodeMirror?.refresh(); + }); + window.dispatchEvent(new Event('resize')); + }); + }; + const initTabs = () => { + if (!context.formElement) { + return; + } + const tabButtons = Array.from(context.formElement.querySelectorAll('[data-cms-form-tab]')); + tabButtons.forEach((button, index) => { + button.addEventListener('click', () => activateTab(button)); + button.addEventListener('keydown', (event) => { + let targetIndex = index; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + targetIndex = (index + 1) % tabButtons.length; + } + else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + targetIndex = (index - 1 + tabButtons.length) % tabButtons.length; + } + else if (event.key === 'Home') { + targetIndex = 0; + } + else if (event.key === 'End') { + targetIndex = tabButtons.length - 1; + } + else { + return; + } + event.preventDefault(); + activateTab(tabButtons[targetIndex]); + tabButtons[targetIndex].focus(); + }); + }); + }; const init = (container) => { if (typeof container === 'string') { container = document.querySelector(container); @@ -121,6 +220,7 @@ const createForm = (options) => { e.stopPropagation(); validate(); }); + initTabs(); CodeField.init(context); MarkdownField.init(context); EasyMDEField.init(context); @@ -237,6 +337,13 @@ const createForm = (options) => { }); const firstInvalidContainer = invalidContainers[0]; if (firstInvalidContainer) { + const tabPanel = firstInvalidContainer.closest('.tab-pane'); + if (tabPanel) { + const tabButton = context.formElement.querySelector(`[data-cms-form-tab-target="${tabPanel.id}"]`); + if (tabButton) { + activateTab(tabButton); + } + } firstInvalidContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }); const focusTarget = firstInvalidContainer.querySelector('input:not([type="hidden"]), select, textarea, button, [tabindex]'); focusTarget?.focus(); @@ -274,5 +381,5 @@ const flattenFormData = (input) => { } return result; }; -export { createForm }; +export { createForm, getFormFields }; ; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.js b/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.js index fa9b22669..88f6dfd7e 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.js @@ -236,15 +236,14 @@ const openMediaMetadataForm = async (image, filename) => { image: image, ...getSiteOptions() }); - const fields = [ - ...(formResponse.result?.form?.fields || []) - ]; + const formDefinition = formResponse.result?.form || {}; const values = { ...metadataResponse.result.meta }; state.metadataImage = image; state.metadataForm = createForm({ - fields: fields, + fields: formDefinition.fields || [], + tabs: formDefinition.tabs || [], values: values }); const title = document.getElementById("cms-media-metadata-title"); diff --git a/modules/ui-module/src/main/ts/src/actions/media/edit-media-form.js b/modules/ui-module/src/main/ts/src/actions/media/edit-media-form.js index 02fe1ccf8..e756d4a82 100644 --- a/modules/ui-module/src/main/ts/src/actions/media/edit-media-form.js +++ b/modules/ui-module/src/main/ts/src/actions/media/edit-media-form.js @@ -34,10 +34,7 @@ export async function runAction(params) { form: params.options.form || 'meta' })).result - - const fields = [ - ...mediaForm?.form?.fields - ] + const formDefinition = mediaForm?.form ?? {} const values = { @@ -45,7 +42,8 @@ export async function runAction(params) { } const form = createForm({ - fields: fields, + fields: formDefinition.fields ?? [], + tabs: formDefinition.tabs ?? [], values: values }); @@ -112,4 +110,3 @@ const buildValuesFromFields = (fields, sourceObj) => { } return values; }; - diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js index 6bed39ecb..a3588a5dc 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js @@ -18,7 +18,7 @@ * along with this program. If not, see . * #L% */ -import {createForm} from '@cms/modules/form/forms.js' +import {createForm, getFormFields} from '@cms/modules/form/forms.js' import {showToast} from '@cms/modules/toast.js' import {reloadPreview} from '@cms/modules/preview.utils.js' import { buildValuesFromFields, getValueByPath } from '@cms/modules/node.js' @@ -45,24 +45,26 @@ export async function runAction(params) { var selected = templates.filter(item => item.template === getContentResponse?.result?.meta?.template) - var attrForm = [] + var attrForm = { fields: [], tabs: [] } if (selected.length === 1) { - attrForm = selected[0].forms?.[params.form]?.fields ?? [] + attrForm = selected[0].forms?.[params.form] ?? attrForm } + const attrFields = getFormFields(attrForm) //const previewMetaForm = getMetaForm() const fields = [ - ...attrForm + ...(attrForm.fields ?? []) ] const values = { - ...buildValuesFromFields(attrForm, getContentResponse?.result?.meta) + ...buildValuesFromFields(attrFields, getContentResponse?.result?.meta) } const form = createForm({ fields: fields, + tabs: attrForm.tabs ?? [], values: values }); diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js b/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js index 55d00724f..92ab7c1a2 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js @@ -19,7 +19,7 @@ * #L% */ import { openSidebar } from '@cms/modules/sidebar.js' -import { createForm } from '@cms/modules/form/forms.js' +import { createForm, getFormFields } from '@cms/modules/form/forms.js' import { showToast } from '@cms/modules/toast.js' import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' import { reloadPreview } from '@cms/modules/preview.utils.js' @@ -46,15 +46,16 @@ export async function runAction(params) { var selected = pageTemplates.filter(pageTemplate => pageTemplate.template === getContentResponse?.result?.meta?.template) - var pageSettingsForm = [] + var pageSettingsForm = { fields: [], tabs: [] } if (selected.length === 1) { - pageSettingsForm = selected[0].forms?.settings?.fields ?? [] + pageSettingsForm = selected[0].forms?.settings ?? pageSettingsForm } + const pageSettingsFields = getFormFields(pageSettingsForm) //const previewMetaForm = getMetaForm() const fields = [ ...DEFAULT_FIELDS, - ...pageSettingsForm + ...(pageSettingsForm.fields ?? []) ] @@ -63,11 +64,12 @@ export async function runAction(params) { 'status': getContentResponse?.result?.meta?.status || 'draft', 'publish_date': getContentResponse?.result?.meta?.publish_date, 'unpublish_date': getContentResponse?.result?.meta?.unpublish_date, - ...buildValuesFromFields(pageSettingsForm, getContentResponse?.result?.meta) + ...buildValuesFromFields(pageSettingsFields, getContentResponse?.result?.meta) } const form = createForm({ fields: fields, + tabs: pageSettingsForm.tabs ?? [], values: values }); diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.list.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.list.ts index 9e2a7f6cd..da3e59745 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.list.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.list.ts @@ -20,7 +20,7 @@ */ import { createID } from "@cms/modules/form/utils.js"; import { i18n } from "@cms/modules/localization.js" -import { createForm, FieldOptions, FormContext, FormField } from "@cms/modules/form/forms.js"; +import { createForm, getFormFields, FieldOptions, FormContext, FormField } from "@cms/modules/form/forms.js"; import { openModal } from "@cms/modules/modal.js"; import { buildValuesFromFields } from "@cms/modules/node.js"; import { getListItemTypes, getPageTemplates } from "@cms/modules/rpc/rpc-manager.js"; @@ -127,22 +127,22 @@ const getItemForm = async (el: HTMLElement) => { const listContainer = el.closest("[data-cms-form-field-type='list']"); const fieldName = listContainer?.getAttribute('name'); - var itemForm = [] + var itemForm: any = { fields: [], tabs: [] } if (selected.length === 1) { - itemForm = fieldName ? selected[0].forms?.[fieldName]?.fields ?? [] : []; + itemForm = fieldName ? selected[0].forms?.[fieldName] ?? itemForm : itemForm; } - if (!itemForm || itemForm.length === 0) { + if (getFormFields(itemForm).length === 0) { let itemTypes = (await getListItemTypes({})).result var selectedItemType = itemTypes.filter((itemType : any) => itemType.name === fieldName) - itemForm = (selectedItemType.length === 1) ? selectedItemType[0].form?.fields ?? [] : [] + itemForm = (selectedItemType.length === 1) ? selectedItemType[0].form ?? itemForm : itemForm } return itemForm } catch (e) { console.error("Error loading item form", e); - return []; + return { fields: [], tabs: [] }; } } @@ -154,11 +154,13 @@ const handleDoubleClick = async (event: Event, context: FormContext) => { const itemData = JSON.parse(itemDataString); var itemForm = await getItemForm(el) + const itemFields = getFormFields(itemForm) const form = createForm({ - fields: itemForm, + fields: itemForm.fields ?? [], + tabs: itemForm.tabs ?? [], values: { - ...buildValuesFromFields(itemForm, itemData) + ...buildValuesFromFields(itemFields, itemData) } }); diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/forms.ts b/modules/ui-module/src/main/ts/src/js/modules/form/forms.ts index e1d85fcdb..914d9fe5f 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/forms.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/forms.ts @@ -40,9 +40,26 @@ import { ReferenceField } from "@cms/modules/form/field.reference.js"; import { TagsField } from "@cms/modules/form/field.tags.js"; import { i18n } from "@cms/modules/localization.js"; +const getFormFields = (definition: any): any[] => { + const fields = Array.isArray(definition?.fields) ? definition.fields : []; + const tabFields = Array.isArray(definition?.tabs) + ? definition.tabs.flatMap((tab: any) => Array.isArray(tab?.fields) ? tab.fields : []) + : []; + return [...fields, ...tabFields]; +}; + +const escapeHtml = (value: any): string => String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const createForm = (options : any) : Form => { - const fields = options.fields || []; + const standaloneFields = Array.isArray(options.fields) ? options.fields : []; + const tabs = Array.isArray(options.tabs) ? options.tabs : []; + const fields = getFormFields({ fields: standaloneFields, tabs }); const values = options.values || {}; const formId = createID(); @@ -51,8 +68,8 @@ const createForm = (options : any) : Form => { fields: fields } - const fieldHtml = fields.map((field : any) => { - const val = values[field.name] || ''; + const renderField = (field : any) => { + const val = values[field.name] ?? ''; switch (field.type) { case 'email': return MailField.markup(field, val) @@ -95,14 +112,99 @@ const createForm = (options : any) : Form => { default: return ''; } - }).join('\n'); + }; + + const fieldHtml = standaloneFields.map(renderField).join('\n'); + const tabsHtml = tabs.length > 0 ? ` + +
+ ${tabs.map((tab: any, index: number) => { + const tabId = `${formId}-tab-${index}`; + const panelId = `${formId}-panel-${index}`; + const tabFieldHtml = (Array.isArray(tab?.fields) ? tab.fields : []).map(renderField).join('\n'); + return `
+ ${tabFieldHtml} +
`; + }).join('\n')} +
` : ''; const html = `
${fieldHtml} + ${tabsHtml}
`; + const activateTab = (button: HTMLElement) => { + if (!context.formElement) { + return; + } + const targetId = button.dataset.cmsFormTabTarget; + if (!targetId) { + return; + } + + context.formElement.querySelectorAll('[data-cms-form-tab]').forEach(tabButton => { + const active = tabButton === button; + tabButton.classList.toggle('active', active); + tabButton.setAttribute('aria-selected', String(active)); + tabButton.tabIndex = active ? 0 : -1; + }); + context.formElement.querySelectorAll('.tab-pane').forEach(panel => { + const active = panel.id === targetId; + panel.classList.toggle('active', active); + panel.classList.toggle('show', active); + panel.hidden = !active; + }); + + requestAnimationFrame(() => { + const panel = context.formElement?.querySelector(`#${targetId}`); + panel?.querySelectorAll('.CodeMirror').forEach((editorElement: any) => { + editorElement.CodeMirror?.refresh(); + }); + window.dispatchEvent(new Event('resize')); + }); + }; + + const initTabs = () => { + if (!context.formElement) { + return; + } + const tabButtons = Array.from(context.formElement.querySelectorAll('[data-cms-form-tab]')); + tabButtons.forEach((button, index) => { + button.addEventListener('click', () => activateTab(button)); + button.addEventListener('keydown', (event: KeyboardEvent) => { + let targetIndex = index; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + targetIndex = (index + 1) % tabButtons.length; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + targetIndex = (index - 1 + tabButtons.length) % tabButtons.length; + } else if (event.key === 'Home') { + targetIndex = 0; + } else if (event.key === 'End') { + targetIndex = tabButtons.length - 1; + } else { + return; + } + event.preventDefault(); + activateTab(tabButtons[targetIndex]); + tabButtons[targetIndex].focus(); + }); + }); + }; + const init = (container : any) => { if (typeof container === 'string') { container = document.querySelector(container); @@ -130,6 +232,7 @@ const createForm = (options : any) : Form => { e.stopPropagation(); validate(); }); + initTabs(); CodeField.init(context) MarkdownField.init(context) EasyMDEField.init(context) @@ -261,6 +364,14 @@ const createForm = (options : any) : Form => { const firstInvalidContainer = invalidContainers[0]; if (firstInvalidContainer) { + const tabPanel = firstInvalidContainer.closest('.tab-pane'); + if (tabPanel) { + const tabButton = context.formElement.querySelector( + `[data-cms-form-tab-target="${tabPanel.id}"]`); + if (tabButton) { + activateTab(tabButton); + } + } firstInvalidContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }); const focusTarget = firstInvalidContainer.querySelector('input:not([type="hidden"]), select, textarea, button, [tabindex]'); focusTarget?.focus(); @@ -299,7 +410,7 @@ const flattenFormData = (input : any) => { return result; } -export { createForm }; +export { createForm, getFormFields }; export interface FormContext { formElement: HTMLFormElement | null; diff --git a/modules/ui-module/src/main/ts/src/js/modules/media/mediabrowser.js b/modules/ui-module/src/main/ts/src/js/modules/media/mediabrowser.js index 554ee31f6..7ccab62a4 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/media/mediabrowser.js +++ b/modules/ui-module/src/main/ts/src/js/modules/media/mediabrowser.js @@ -258,16 +258,15 @@ const openMediaMetadataForm = async (image, filename) => { ...getSiteOptions() }); - const fields = [ - ...(formResponse.result?.form?.fields || []) - ]; + const formDefinition = formResponse.result?.form || {}; const values = { ...metadataResponse.result.meta }; state.metadataImage = image; state.metadataForm = createForm({ - fields: fields, + fields: formDefinition.fields || [], + tabs: formDefinition.tabs || [], values: values }); diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java index eafe96d0d..51b6c3dc5 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java @@ -23,6 +23,7 @@ import com.condation.cms.modules.ui.extensionpoints.remotemethods.RemoteFileEnpoints; import com.condation.cms.api.ui.elements.FormDefinition; +import com.condation.cms.api.ui.elements.FormTab; import com.condation.cms.api.ui.elements.PageTemplate; import com.condation.cms.api.ui.elements.fields.StringField; import com.google.gson.JsonObject; @@ -67,8 +68,10 @@ void serializesTypedPageTemplateAsFrontendContract() { .name("StartPage") .template("start.html") .contentFolder("content") - .forms(Map.of("settings", new FormDefinition(List.of( - new StringField("title", "Title"))))) + .forms(Map.of("settings", new FormDefinition( + List.of(new StringField("title", "Title")), + List.of(new FormTab("SEO", List.of( + new StringField("description", "Description"))))))) .build(); JsonObject json = JsonParser.parseString(UIGsonProvider.INSTANCE.toJson(pageTemplate)) @@ -86,6 +89,13 @@ void serializesTypedPageTemplateAsFrontendContract() { Assertions.assertThat(fields.get(0).getAsJsonObject().get("type").getAsString()).isEqualTo("text"); Assertions.assertThat(fields.get(0).getAsJsonObject().get("name").getAsString()).isEqualTo("title"); Assertions.assertThat(fields.get(0).getAsJsonObject().get("title").getAsString()).isEqualTo("Title"); + var tabs = json.getAsJsonObject("forms") + .getAsJsonObject("settings") + .getAsJsonArray("tabs"); + Assertions.assertThat(tabs).hasSize(1); + Assertions.assertThat(tabs.get(0).getAsJsonObject().get("title").getAsString()).isEqualTo("SEO"); + Assertions.assertThat(tabs.get(0).getAsJsonObject().getAsJsonArray("fields") + .get(0).getAsJsonObject().get("name").getAsString()).isEqualTo("description"); } } diff --git a/test-server/themes/demo/extensions/theme.manager.js b/test-server/themes/demo/extensions/theme.manager.js index ee8903195..a1661d47f 100644 --- a/test-server/themes/demo/extensions/theme.manager.js +++ b/test-server/themes/demo/extensions/theme.manager.js @@ -14,9 +14,9 @@ $hooks.registerFilter("manager/media/forms", (mediaForms) => { }) const TextField = (overrides = {}) => ({ - type: "text", - ...overrides -}); + type: "text", + ...overrides + }); const TitleField = TextField({ name: "title", @@ -45,80 +45,88 @@ $hooks.registerFilter("manager/contentTypes/register", (contentTypes) => { template: "start.html", forms: { settings: { - fields: - - [ - { - type: 'divider', - name: 'divider', - title: 'Custom attributes' - }, - { - type: "reference", - name: "linked_page", - title: "Verlinkte Seite" - }, - { - type: "textarea", - name: "seo.description", - title: "Seo Beschreibung" - }, - { - type: "media", - name: "media_url", - title: "Media" - }, - { - type: 'color', - name: 'background_color', - title: 'Background Color' - }, - { - type: "range", - name: "range_test", - title: "RangField" - }, - { - type: "radio", - name: "choose_color", - title: "Farbe wählen", - options: { - choices: [ - { label: "Rot", value: "red" }, - { label: "Grün", value: "green" }, - { label: "Blau", value: "blue" } - ] + tabs: [ + { + title: "References", + fields: [ + { + type: "reference", + name: "linked_page", + title: "Verlinkte Seite" + }, + { + type: "media", + name: "media_url", + title: "Media" + }, + { + name: "object.values", + title: "Objekt-Liste", + type: "list", + options: { + nameField: "title" + } } - }, - { - name: "features", - title: "Funktionen auswählen", - type: "checkbox", - options: { - choices: [ - { label: "Suche", value: "search" }, - { label: "Filter", value: "filter" }, - { label: "Export", value: "export" } - ] + ] + }, + { + title: "SEO", + fields: [ + { + type: "textarea", + name: "seo.description", + title: "Seo Beschreibung" } - }, - { - name: "object.values", - title: "Objekt-Liste", - type: "list", - options: { - nameField: "title" - } - }, - { - name: "taxonomy.tags", - title: "Tags", - type: "tags", - options: { - taxonomy: "tags" + ] + }, + { + title: "Different", + fields: [ + { + type: 'color', + name: 'background_color', + title: 'Background Color' + }, + { + type: "range", + name: "range_test", + title: "RangField" + }, + { + type: "radio", + name: "choose_color", + title: "Farbe wählen", + options: { + choices: [ + {label: "Rot", value: "red"}, + {label: "Grün", value: "green"}, + {label: "Blau", value: "blue"} + ] + } + }, + { + name: "features", + title: "Funktionen auswählen", + type: "checkbox", + options: { + choices: [ + {label: "Suche", value: "search"}, + {label: "Filter", value: "filter"}, + {label: "Export", value: "export"} + ] + } + }, + { + name: "taxonomy.tags", + title: "Tags", + type: "tags", + options: { + taxonomy: "tags" + } } - } - ] + ] + } + ] }, // override global definition of ListItemTypes 'object.values': { @@ -131,9 +139,9 @@ $hooks.registerFilter("manager/contentTypes/register", (contentTypes) => { type: "select", options: { choices: [ - { label: "Suche", value: "search" }, - { label: "Filter", value: "filter" }, - { label: "Export", value: "export" } + {label: "Suche", value: "search"}, + {label: "Filter", value: "filter"}, + {label: "Export", value: "export"} ] } }, @@ -276,8 +284,8 @@ $hooks.registerFilter("manager/contentTypes/register", (contentTypes) => { }); /* - global definition if ListItemTypes - */ + global definition if ListItemTypes + */ contentTypes.registerListItemType({ name: "object.values", form: {