feat(14.2.1-05): cover mlmarkdown, child adopt, and regen admin artifacts
MLMarkdownField and relation-child forms share the enabled-locale inject and adopt merge; OpenAPI, types, docs, and boardwalk dist match. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -913,6 +913,12 @@
|
|||||||
},
|
},
|
||||||
"cabana.FormMeta": {
|
"cabana.FormMeta": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"enabledLocales": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"locale": {
|
"locale": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
1
admin/src/api/schema.d.ts
vendored
1
admin/src/api/schema.d.ts
vendored
@@ -4506,6 +4506,7 @@ export interface components {
|
|||||||
update: components["schemas"]["cabana.MessageForms"];
|
update: components["schemas"]["cabana.MessageForms"];
|
||||||
};
|
};
|
||||||
"cabana.FormMeta": {
|
"cabana.FormMeta": {
|
||||||
|
enabledLocales?: string[];
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
};
|
||||||
"cabana.FormOption": {
|
"cabana.FormOption": {
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import type { FieldControlProps } from '../control'
|
import type { FieldControlProps } from '../control'
|
||||||
import MarkdownField from './MarkdownField.vue'
|
import MarkdownField from './MarkdownField.vue'
|
||||||
|
import { FORM_ENABLED_LOCALES } from '../formContext'
|
||||||
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
import { ML_LOCALE_CHANGE, broadcastMLLocale, localeRecord } from '../mlLocale'
|
||||||
|
|
||||||
const props = defineProps<FieldControlProps>()
|
const props = defineProps<FieldControlProps>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
|
||||||
|
|
||||||
const values = computed(() => localeRecord(props.modelValue))
|
const values = computed(() => localeRecord(props.modelValue))
|
||||||
|
const enabledLocales = inject(FORM_ENABLED_LOCALES, null)
|
||||||
const locales = computed(() => {
|
const locales = computed(() => {
|
||||||
const keys = Object.keys(values.value)
|
const codes = enabledLocales?.value
|
||||||
return keys.length > 0 ? keys : ['en']
|
return codes && codes.length > 0 ? [...codes] : []
|
||||||
})
|
})
|
||||||
const activeLocale = ref(locales.value[0] ?? 'en')
|
const activeLocale = ref(locales.value[0] ?? 'en')
|
||||||
const copyFrom = ref('')
|
const copyFrom = ref('')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, provide, readonly, ref, watch } from 'vue'
|
import { computed, inject, nextTick, provide, readonly, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,7 +17,7 @@ import { CHILD_SESSION_HEADER, SESSION_HEADER, newSessionKey } from '../../app/s
|
|||||||
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
import FormErrorBanner from '../form/FormErrorBanner.vue'
|
||||||
import FormGrid from '../form/FormGrid.vue'
|
import FormGrid from '../form/FormGrid.vue'
|
||||||
import FormTabs from '../form/FormTabs.vue'
|
import FormTabs from '../form/FormTabs.vue'
|
||||||
import { FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate, type FormSession } from '../form/formContext'
|
import { FORM_ENABLED_LOCALES, FORM_PATCH, FORM_SESSION, FORM_VALUES, createUploadGate, type FormSession } from '../form/formContext'
|
||||||
import {
|
import {
|
||||||
DEFAULT_TAB,
|
DEFAULT_TAB,
|
||||||
contextAllows,
|
contextAllows,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
fieldErrors,
|
fieldErrors,
|
||||||
focusField,
|
focusField,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
mergeMLValue,
|
||||||
panelDomId,
|
panelDomId,
|
||||||
snapshot,
|
snapshot,
|
||||||
tabDomId,
|
tabDomId,
|
||||||
@@ -198,6 +199,8 @@ const session: FormSession = {
|
|||||||
provide(FORM_SESSION, session)
|
provide(FORM_SESSION, session)
|
||||||
provide(FORM_VALUES, computed(() => values.value))
|
provide(FORM_VALUES, computed(() => values.value))
|
||||||
provide(FORM_PATCH, update)
|
provide(FORM_PATCH, update)
|
||||||
|
const enabledLocales = inject(FORM_ENABLED_LOCALES, computed(() => [] as readonly string[]))
|
||||||
|
provide(FORM_ENABLED_LOCALES, enabledLocales)
|
||||||
|
|
||||||
function update(name: string, value: unknown): void {
|
function update(name: string, value: unknown): void {
|
||||||
values.value = { ...values.value, [name]: value }
|
values.value = { ...values.value, [name]: value }
|
||||||
@@ -209,7 +212,16 @@ function update(name: string, value: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adopt(record: AdminRecord, meta?: RecordMeta): void {
|
function adopt(record: AdminRecord, meta?: RecordMeta): void {
|
||||||
values.value = { ...record }
|
const locales = enabledLocales.value
|
||||||
|
const next: AdminRecord = { ...record }
|
||||||
|
for (const field of fields.value) {
|
||||||
|
if (field.type === 'password') {
|
||||||
|
next[field.name] = ''
|
||||||
|
} else if (field.type === 'mltext' || field.type === 'mlmarkdown') {
|
||||||
|
next[field.name] = mergeMLValue(record[field.name], locales)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.value = next
|
||||||
labels.value = meta?.labels ?? {}
|
labels.value = meta?.labels ?? {}
|
||||||
saved.value = snapshot(editablePayload(fields.value, values.value))
|
saved.value = snapshot(editablePayload(fields.value, values.value))
|
||||||
}
|
}
|
||||||
@@ -239,7 +251,7 @@ async function load(): Promise<void> {
|
|||||||
activeTab.value = DEFAULT_TAB
|
activeTab.value = DEFAULT_TAB
|
||||||
if (props.mode === 'create' || props.childId === null) {
|
if (props.mode === 'create' || props.childId === null) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
adopt(initialValues(fields.value))
|
adopt(initialValues(fields.value, enabledLocales.value))
|
||||||
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||||
await focusFirst()
|
await focusFirst()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ async function goNext(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
step.value = 2
|
step.value = 2
|
||||||
pivotValues.value = initialValues(pivotFields.value)
|
pivotValues.value = initialValues(pivotFields.value, [])
|
||||||
pivotErrors.value = {}
|
pivotErrors.value = {}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
stepTwo.value?.querySelector<HTMLElement>('input, select, textarea, button, [contenteditable="true"]')?.focus()
|
stepTwo.value?.querySelector<HTMLElement>('input, select, textarea, button, [contenteditable="true"]')?.focus()
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ function field(type: string, name = 'title'): FormField {
|
|||||||
|
|
||||||
const enabledLocales = ref<readonly string[]>(['en', 'pl'])
|
const enabledLocales = ref<readonly string[]>(['en', 'pl'])
|
||||||
|
|
||||||
function mlMount(component: typeof MLTextField | typeof MLMarkdownField, props: Record<string, unknown>) {
|
function mlMount(component: typeof MLTextField | typeof MLMarkdownField, props: object) {
|
||||||
return mount(component, {
|
return mount(component, {
|
||||||
props,
|
props: props as never,
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
@@ -135,4 +135,33 @@ describe('ML field registry and nested save body', () => {
|
|||||||
await wrapper.find('input').setValue('Witaj')
|
await wrapper.find('input').setValue('Witaj')
|
||||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: 'Hello', pl: 'Witaj' }])
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: 'Hello', pl: 'Witaj' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('seeds empty maps and lists every enabled locale on mlmarkdown create', () => {
|
||||||
|
const fields = [field('mlmarkdown', 'body')]
|
||||||
|
const values = initialValues(fields, ['en', 'pl'])
|
||||||
|
expect(values.body).toEqual({ en: '', pl: '' })
|
||||||
|
const wrapper = mlMount(MLMarkdownField, {
|
||||||
|
field: fields[0],
|
||||||
|
modelValue: values.body,
|
||||||
|
controlId: 'f-body',
|
||||||
|
})
|
||||||
|
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true)
|
||||||
|
expect(wrapper.findAll('[data-ml-locale] option').map((node) => node.text())).toEqual(['en', 'pl'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps sibling locales when adopt merges a GET host string on mlmarkdown', async () => {
|
||||||
|
const fields = [field('mlmarkdown', 'body')]
|
||||||
|
const merged = mergeMLValue('# Hello', ['en', 'pl'])
|
||||||
|
expect(merged).toEqual({ en: '# Hello', pl: '' })
|
||||||
|
const wrapper = mlMount(MLMarkdownField, {
|
||||||
|
field: fields[0],
|
||||||
|
modelValue: merged,
|
||||||
|
controlId: 'f-body',
|
||||||
|
})
|
||||||
|
expect(wrapper.findComponent(MarkdownField).exists()).toBe(true)
|
||||||
|
expect(wrapper.findAll('[data-ml-locale] option').map((node) => node.text())).toEqual(['en', 'pl'])
|
||||||
|
await wrapper.find('[data-ml-locale]').setValue('pl')
|
||||||
|
await wrapper.find('textarea').setValue('# Witaj')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{ en: '# Hello', pl: '# Witaj' }])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
fieldErrors,
|
fieldErrors,
|
||||||
focusField,
|
focusField,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
mergeMLValue,
|
||||||
panelDomId,
|
panelDomId,
|
||||||
presetValue,
|
presetValue,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -55,7 +56,7 @@ describe('form state helpers', () => {
|
|||||||
const fields = [formField('name'), ...extensionFormSchemaFixture.data.fields.filter((field) => field.type === 'widget' || field.type === 'partial')]
|
const fields = [formField('name'), ...extensionFormSchemaFixture.data.fields.filter((field) => field.type === 'widget' || field.type === 'partial')]
|
||||||
expect(fields.map((field) => field.type)).toEqual(['text', 'widget', 'partial'])
|
expect(fields.map((field) => field.type)).toEqual(['text', 'widget', 'partial'])
|
||||||
expect(editablePayload(fields, { name: 'Blue', lookup: 'x', summary: { nodes: [] } })).toEqual({ name: 'Blue' })
|
expect(editablePayload(fields, { name: 'Blue', lookup: 'x', summary: { nodes: [] } })).toEqual({ name: 'Blue' })
|
||||||
expect(initialValues(fields.slice(1))).toEqual({})
|
expect(initialValues(fields.slice(1), [])).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts a new record from defaults, toggles off and empty multiple relations', () => {
|
it('starts a new record from defaults, toggles off and empty multiple relations', () => {
|
||||||
@@ -67,7 +68,7 @@ describe('form state helpers', () => {
|
|||||||
formField('maker'),
|
formField('maker'),
|
||||||
{ ...formField('weight'), default: 0 },
|
{ ...formField('weight'), default: 0 },
|
||||||
]
|
]
|
||||||
expect(initialValues(fields)).toEqual({ name: 'Unnamed', active: false, featured: false, tags: [], weight: 0 })
|
expect(initialValues(fields, [])).toEqual({ name: 'Unnamed', active: false, featured: false, tags: [], weight: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('normalizes 422 details into string lists and drops empty entries', () => {
|
it('normalizes 422 details into string lists and drops empty entries', () => {
|
||||||
@@ -119,7 +120,7 @@ describe('password and permission values in the save body (UI-SPEC S5 and S7)',
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('starts password fields empty, whatever the record holds', () => {
|
it('starts password fields empty, whatever the record holds', () => {
|
||||||
const initial = initialValues(fields.filter((field) => field.type === 'password'))
|
const initial = initialValues(fields.filter((field) => field.type === 'password'), [])
|
||||||
expect(initial.password ?? '').toBe('')
|
expect(initial.password ?? '').toBe('')
|
||||||
expect(initial.password_confirmation ?? '').toBe('')
|
expect(initial.password_confirmation ?? '').toBe('')
|
||||||
})
|
})
|
||||||
@@ -178,3 +179,22 @@ describe('preset values (D-27 G7)', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('ML create seed and adopt merge', () => {
|
||||||
|
it('seeds every enabled locale as an empty string', () => {
|
||||||
|
const fields = [
|
||||||
|
{ name: 'title', type: 'mltext', label: 'Title' } as ReturnType<typeof formField>,
|
||||||
|
{ name: 'body', type: 'mlmarkdown', label: 'Body' } as ReturnType<typeof formField>,
|
||||||
|
]
|
||||||
|
expect(initialValues(fields, ['en', 'pl'])).toEqual({
|
||||||
|
title: { en: '', pl: '' },
|
||||||
|
body: { en: '', pl: '' },
|
||||||
|
})
|
||||||
|
expect(initialValues(fields, [])).toEqual({ title: {}, body: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges a GET host string onto the seed without dropping sibling locales', () => {
|
||||||
|
expect(mergeMLValue('Hello', ['en', 'pl'])).toEqual({ en: 'Hello', pl: '' })
|
||||||
|
expect(mergeMLValue({ en: 'Hello', pl: 'Witaj' }, ['en', 'pl'])).toEqual({ en: 'Hello', pl: 'Witaj' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ Every controller gets the same routes, relative to `<prefix>/api/v1/{vendor}/{pl
|
|||||||
|
|
||||||
The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (`cabana.Envelope`), and a validation failure is a 422 `validation_failed` error with messages per field.
|
The cabana README lists the full route table, including relation, options, widget, toolbar and partial routes. Every response uses one JSON envelope (`cabana.Envelope`), and a validation failure is a 422 `validation_failed` error with messages per field.
|
||||||
|
|
||||||
Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through `lagoon.Fill` and validated through `lagoon.Validate` in a transaction. A `mltext` or `mlmarkdown` field arrives as a nested locale map: cabana lifts it before `cabana.ProjectWritableFields` drops nested values, writes the default locale as the host scalar, and calls a published `cabana.TranslationWriter` for the other locales inside the same permissioned save transaction. There is no separate translate-write route; see [Markdown and multilingual fields](forms.md#markdown-and-multilingual-fields).
|
Request parameters never reach SQL directly: search, sort and filters apply only to declared columns, and a write passes only the form's writable fields, filled through `lagoon.Fill` and validated through `lagoon.Validate` in a transaction. A `mltext` or `mlmarkdown` field arrives as a nested locale map: cabana lifts it before `cabana.ProjectWritableFields` drops nested values, writes the default locale as the host scalar, and calls a published `cabana.TranslationWriter` for the other locales inside the same permissioned save transaction. The show and save record payload then carries that nested map for every enabled locale (`hydrateMLRecord` via `TranslationWriter.TranslatedExact`; missing non-default codes stay empty). There is no separate translate-write or translate-read route; see [Markdown and multilingual fields](forms.md#markdown-and-multilingual-fields).
|
||||||
|
|
||||||
## Hooks
|
## Hooks
|
||||||
|
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
|
|||||||
|
|
||||||
`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor and may preview HTML from `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content.
|
`type: markdown` edits markdown source on a host text column. The admin SPA shows a source editor and may preview HTML from `cabana.RenderMarkdown`, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content.
|
||||||
|
|
||||||
`type: mltext` and `type: mlmarkdown` reuse the ordinary text and markdown editors with a locale selector. `mlmarkdown` composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Copy-from-locale fills the active locale from another code already in the value.
|
`type: mltext` and `type: mlmarkdown` reuse the ordinary text and markdown editors with a locale selector. `mlmarkdown` composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Copy-from-locale fills the active locale from another code already in the value. Selector options come from `cabana.FormMeta.EnabledLocales` on the form schema (filled from `cabana.TranslationWriter.EnabledLocales` after Lookup; `FormSchema.Localize` stays cache-only). Create seeds `{[code]: ""}` for every enabled code. A GET or save of a host scalar is merged onto that seed so sibling locales are not dropped.
|
||||||
|
|
||||||
The save body sends every locale as a JSON object of code to string. Extra YAML keys are not accepted: the types reuse `label`, `comment`, `span`, `size`, `required`, `tab` and `context`. An unknown type such as `mlunknown` stops the start-up.
|
The save body sends every locale as a JSON object of code to string. Extra YAML keys are not accepted: the types reuse `label`, `comment`, `span`, `size`, `required`, `tab` and `context`. An unknown type such as `mlunknown` stops the start-up.
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ fields:
|
|||||||
tab: acme.blog::lang.posts.tab_content
|
tab: acme.blog::lang.posts.tab_content
|
||||||
```
|
```
|
||||||
|
|
||||||
A save of `{ "title": { "en": "Hello", "pl": "Witaj" } }` lifts the nested map before `cabana.ProjectWritableFields` drops nested values. The default locale fills the host column; other locales are written through a published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. The default locale is not duplicated into the translation store. A non-string value, an undeclared locale, a missing default locale, or a nested map on a field that is not declared multilingual is a 422 on that field. Generic nested objects on other field types are still dropped. Cabana never imports a translate plugin and never exposes a public translate-write route: if no writer is published, a nested ML map is a 422.
|
A save of `{ "title": { "en": "Hello", "pl": "Witaj" } }` lifts the nested map before `cabana.ProjectWritableFields` drops nested values. The default locale fills the host column; other locales are written through a published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. The default locale is not duplicated into the translation store. Show and save then run `hydrateMLRecord`: each declared ML field in the record payload is a map of every enabled locale (default from the host column, others from `TranslationWriter.TranslatedExact`; a missing non-default code is an empty string, never D-11 fallback). A non-string value, an undeclared locale, a missing default locale, or a nested map on a field that is not declared multilingual is a 422 on that field. Generic nested objects on other field types are still dropped. Cabana never imports a translate plugin and never exposes a public translate-write or translate-read route: if no writer is published, a nested ML map is a 422.
|
||||||
|
|
||||||
## What a save may write
|
## What a save may write
|
||||||
|
|
||||||
|
|||||||
9
modules/boardwalk/dist/assets/index-B08bxLq5.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-B08bxLq5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-CT96jdWe.js"></script>
|
<script type="module" crossorigin src="./assets/index-B08bxLq5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-Bd6s2WVD.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-Bd6s2WVD.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
|||||||
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
|
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
|
||||||
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
|
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
|
||||||
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
|
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
|
||||||
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. There is no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
|
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. There is no standalone translate-write or translate-read route. An unknown field type, including an unknown `ml*` type, fails boot.
|
||||||
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
|
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
|
||||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||||
@@ -216,7 +216,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
|||||||
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
|
||||||
| `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. |
|
| `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. |
|
||||||
| `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. |
|
| `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. |
|
||||||
| `cabana.TranslationWriter` | Optional plugin-published service that persists non-default locale values during a host save. Cabana never imports a translate plugin; writes run only inside the permissioned, scoped save transaction after the host row has a primary key. |
|
| `cabana.FormMeta` | Form (and optional list) schema meta: `Locale` is the UI locale of the response; `EnabledLocales` lists content locales for ML fields when a `cabana.TranslationWriter` is published (`json:"enabledLocales,omitempty"` so list schemas omit the key). |
|
||||||
|
| `cabana.TranslationWriter` | Optional plugin-published service that persists non-default locale values during a host save and reads exact stored translations through `TranslatedExact`. Cabana never imports a translate plugin; writes run only inside the permissioned, scoped save transaction after the host row has a primary key; Show/save hydration uses `hydrateMLRecord`. |
|
||||||
|
| `cabana.TranslationWriter.TranslatedExact` | Returns the stored value for a field in a locale without D-11 fallback; `ok` is false when the non-default key is missing. The default locale still reads the host column. |
|
||||||
|
| `hydrateMLRecord` | Replaces host scalars of declared `mltext`/`mlmarkdown` fields on a Show/save record with a locale map for every enabled code. Not used from list row projection. |
|
||||||
| `cabana.RenderMarkdown` | Converts markdown source to HTML with the pinned goldmark engine without unsafe HTML; leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. |
|
| `cabana.RenderMarkdown` | Converts markdown source to HTML with the pinned goldmark engine without unsafe HTML; leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. |
|
||||||
| `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
|
| `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
|
||||||
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
|
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) {
|
|||||||
if locale := schemaLocale(r.Context(), tr); locale != "" {
|
if locale := schemaLocale(r.Context(), tr); locale != "" {
|
||||||
meta["locale"] = locale
|
meta["locale"] = locale
|
||||||
}
|
}
|
||||||
|
if codes := s.enabledContentLocales(r.Context()); len(codes) > 0 {
|
||||||
|
meta["enabledLocales"] = append([]string(nil), codes...)
|
||||||
|
}
|
||||||
WriteData(w, http.StatusOK, view, meta)
|
WriteData(w, http.StatusOK, view, meta)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user