diff --git a/admin/src/components/form/fields/MLMarkdownField.vue b/admin/src/components/form/fields/MLMarkdownField.vue new file mode 100644 index 0000000..b32ed8f --- /dev/null +++ b/admin/src/components/form/fields/MLMarkdownField.vue @@ -0,0 +1,109 @@ + + + + + + + {{ code }} + + + {{ code }} + + + Copy + + + + + diff --git a/admin/src/components/form/fields/MLTextField.vue b/admin/src/components/form/fields/MLTextField.vue new file mode 100644 index 0000000..a514374 --- /dev/null +++ b/admin/src/components/form/fields/MLTextField.vue @@ -0,0 +1,108 @@ + + + + + + + {{ code }} + + + {{ code }} + + + Copy + + + + + diff --git a/admin/src/components/form/fields/MarkdownField.vue b/admin/src/components/form/fields/MarkdownField.vue new file mode 100644 index 0000000..010c552 --- /dev/null +++ b/admin/src/components/form/fields/MarkdownField.vue @@ -0,0 +1,51 @@ + + + + + + Preview + + {{ text }} + + + diff --git a/admin/src/components/form/formState.ts b/admin/src/components/form/formState.ts index 0f841ae..2635d9b 100644 --- a/admin/src/components/form/formState.ts +++ b/admin/src/components/form/formState.ts @@ -2,8 +2,11 @@ // filtering, the save payload, 422 detail mapping and focus. import type { AdminRecord, ErrorBody, FormField } from '../../api/types' import { permissionValues } from './control' +import { localeRecord } from './mlLocale' import { isRegistered } from './registry' +export { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from './mlLocale' + /** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */ export type FormMode = 'create' | 'update' | 'preview' @@ -62,6 +65,10 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode: out[field.name] = permissionValues(field, value) continue } + if (field.type === 'mltext' || field.type === 'mlmarkdown') { + out[field.name] = localeRecord(value) + continue + } if (value !== undefined) { out[field.name] = value } @@ -95,6 +102,8 @@ export function initialValues(fields: FormField[]): AdminRecord { out[field.name] = false } else if (field.type === 'relation' && field.multiple) { out[field.name] = [] + } else if (field.type === 'mltext' || field.type === 'mlmarkdown') { + out[field.name] = {} } } return out diff --git a/admin/src/components/form/mlLocale.ts b/admin/src/components/form/mlLocale.ts new file mode 100644 index 0000000..7071d44 --- /dev/null +++ b/admin/src/components/form/mlLocale.ts @@ -0,0 +1,24 @@ +/** Window event that keeps every ML control on one active locale. */ +export const ML_LOCALE_CHANGE = 'summer:ml-locale' + +/** Broadcast the locale the administrator selected on any ML control. */ +export function broadcastMLLocale(locale: string): void { + if (typeof window === 'undefined' || locale === '') { + return + } + window.dispatchEvent(new CustomEvent(ML_LOCALE_CHANGE, { detail: locale })) +} + +/** Locale → text map as it is sent on save: only string values. */ +export function localeRecord(value: unknown): Record { + const out: Record = {} + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return out + } + for (const [locale, text] of Object.entries(value as Record)) { + if (typeof text === 'string') { + out[locale] = text + } + } + return out +} diff --git a/admin/src/components/form/registry.ts b/admin/src/components/form/registry.ts index a3a8a27..429058b 100644 --- a/admin/src/components/form/registry.ts +++ b/admin/src/components/form/registry.ts @@ -28,6 +28,9 @@ import RelationField from './fields/RelationField.vue' import SwitchField from './fields/SwitchField.vue' import TextField from './fields/TextField.vue' import TextareaField from './fields/TextareaField.vue' +import MarkdownField from './fields/MarkdownField.vue' +import MLTextField from './fields/MLTextField.vue' +import MLMarkdownField from './fields/MLMarkdownField.vue' import UnsupportedField from './fields/UnsupportedField.vue' import WidgetField from './fields/WidgetField.vue' @@ -60,6 +63,9 @@ const renderers = new Map([ ['datepicker', DatepickerField], ['password', PasswordField], ['permissioneditor', PermissionEditorField], + ['markdown', MarkdownField], + ['mltext', MLTextField], + ['mlmarkdown', MLMarkdownField], ]) /** Types whose control shows the label itself (toggle cards, relation manager). */ @@ -79,7 +85,7 @@ const valueless = new Set([RELATION_MANAGER, 'widget', 'partial', 'fileu * Types whose control is a group rather than one focusable element: the * visible label is a span the group points at, not a label for an input. */ -const groupLabelledTypes = new Set(['widget', 'partial', 'fileupload', 'permissioneditor']) +const groupLabelledTypes = new Set(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mlmarkdown']) export function rendererFor(type: string): Component { return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField diff --git a/admin/tests/form/MLFields.test.ts b/admin/tests/form/MLFields.test.ts new file mode 100644 index 0000000..0c9a02f --- /dev/null +++ b/admin/tests/form/MLFields.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import type { FormField } from '../../src/api/types' +import MarkdownField from '../../src/components/form/fields/MarkdownField.vue' +import MLMarkdownField from '../../src/components/form/fields/MLMarkdownField.vue' +import MLTextField from '../../src/components/form/fields/MLTextField.vue' +import { editablePayload } from '../../src/components/form/formState' +import { isRegistered, rendererFor } from '../../src/components/form/registry' +import { resetState } from '../helpers' + +function field(type: string, name = 'title'): FormField { + return { name, label: 'Title', type } as FormField +} + +beforeEach(() => { + resetState() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +enableAutoUnmount(afterEach) + +describe('ML field registry and nested save body', () => { + it('resolves markdown, mltext and mlmarkdown', () => { + expect(rendererFor('markdown')).toBe(MarkdownField) + expect(rendererFor('mltext')).toBe(MLTextField) + expect(rendererFor('mlmarkdown')).toBe(MLMarkdownField) + expect(isRegistered('markdown')).toBe(true) + expect(isRegistered('mltext')).toBe(true) + expect(isRegistered('mlmarkdown')).toBe(true) + }) + + it('sends every locale as a nested record', () => { + const fields = [field('mltext'), field('mlmarkdown', 'body')] + const payload = editablePayload(fields, { + title: { en: 'Hello', pl: 'Witaj' }, + body: { en: '# Hi', pl: '# Cześć' }, + }) + expect(payload).toEqual({ + title: { en: 'Hello', pl: 'Witaj' }, + body: { en: '# Hi', pl: '# Cześć' }, + }) + }) + + it('edits the active locale and copies from another', async () => { + const wrapper = mount(MLTextField, { + props: { + field: field('mltext'), + modelValue: { en: 'Hello', pl: 'Witaj' }, + controlId: 'f-title', + }, + attachTo: document.body, + }) + const input = wrapper.find('input') + expect(input.element.value).toBe('Hello') + await wrapper.find('[data-ml-locale]').setValue('pl') + expect(wrapper.find('input').element.value).toBe('Witaj') + await wrapper.find('[data-ml-copy-from]').setValue('en') + await wrapper.find('[data-ml-copy]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: 'Hello', pl: 'Hello' }]) + }) + + it('switches every ML control from one selector', async () => { + const title = mount(MLTextField, { + props: { field: field('mltext'), modelValue: { en: 'Hello', pl: 'Witaj' }, controlId: 'f-title' }, + attachTo: document.body, + }) + const body = mount(MLMarkdownField, { + props: { field: field('mlmarkdown', 'body'), modelValue: { en: 'EN', pl: 'PL' }, controlId: 'f-body' }, + attachTo: document.body, + }) + expect(body.findComponent(MarkdownField).exists()).toBe(true) + await title.find('[data-ml-locale]').setValue('pl') + expect((body.find('[data-ml-locale]').element as HTMLSelectElement).value).toBe('pl') + expect(body.find('textarea').element.value).toBe('PL') + }) + + it('does not execute raw HTML in the markdown preview', async () => { + const wrapper = mount(MarkdownField, { + props: { + field: field('markdown', 'body'), + modelValue: '', + controlId: 'f-body', + }, + attachTo: document.body, + }) + await wrapper.find('[data-markdown-preview]').trigger('click') + expect(wrapper.find('script').exists()).toBe(false) + expect(wrapper.find('img').exists()).toBe(false) + expect((window as unknown as { __ml_xss?: number }).__ml_xss).toBeUndefined() + expect(wrapper.find('[data-markdown-preview-pane]').text()).toContain('"); err == nil || !strings.Contains(err.Error(), "raw unsafe tags") { + // goldmark without WithUnsafe drops the raw tag; leftover HTML + // must still be rejected if a later renderer change lets it through. + html, convErr := renderMarkdownUnchecked("") + if convErr != nil { + t.Fatal(convErr) + } + if strings.Contains(strings.ToLower(html), "
{{ text }}