Files
summercms/admin/src/components/form/registry.ts
Jakub Zych 4adb48a76e feat(16.2-01): translatable plugin fields, element uploads and save notices in the admin
- MLPluginField wraps PluginField in the MLField locale switcher; the
  element gets the active content locale and that locale's string
- isMLField treats a translatable plugin field as a locale map in the form
  state, FormView adopt and the relation child modal
- summer-upload posts the file with the admin session to the field upload
  route and answers summer-upload-result {id, url} or {id, error}
- noticeLines shows meta.notices under each field as a warning status line
  (text only) and in the saved toast; the stored value is reloaded
- docs/backend/field-types.md documents the full element contract;
  modules/boardwalk/dist rebuilt
2026-10-10 11:25:26 +02:00

146 lines
6.8 KiB
TypeScript

// Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's
// dashed box, so an unknown type never breaks the form. The relation manager
// is registered like any control but holds no form value: it edits its
// relation through its own endpoints and renders only on an existing record.
// Phase 10.1 adds the plugin extension types: a widget mounts a plugin custom
// element (D-04, D-09) and a partial renders a server template (D-17). They
// hold no form value either, but they render on create and update.
// Phase 12.2 adds the fileupload control: it edits its files through the file
// routes against the form's session key (D-02, D-03), so it holds no form
// value either and renders on create and update. The datepicker control is
// a plain value field: its string value is part of the save body (D-18).
// Phase 12.1 adds the password control: a plain value field whose value the
// server never sends back, so it is empty on load and after every save. The
// permissioneditor control is a value field too: an object of permission code
// to value, labelled as a group.
// The ML controls (mltext, mltextarea, mlmarkdown) are thin shells over one
// locale-switching wrapper, MLField, around the text, textarea and markdown
// controls; each holds a locale -> text map, labelled as a group.
// Phase 16.2 adds plugin field types (D-13): a field whose schema carries an
// `element` renders PluginField whatever its type alias (MLPluginField, the
// MLField locale switcher around it, when the type is translatable), holds a
// form value and is labelled as a group. Look such fields up with rendererForField and
// isRegisteredField, which know the field, not only its type.
import type { Component } from 'vue'
import type { FormField } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
import DatepickerField from './fields/DatepickerField.vue'
import DropdownField from './fields/DropdownField.vue'
import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue'
import PasswordField from './fields/PasswordField.vue'
import MLPluginField from './fields/MLPluginField.vue'
import PluginField from './fields/PluginField.vue'
import PermissionEditorField from './fields/PermissionEditorField.vue'
import RelationManager from '../relation/RelationManager.vue'
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 MLTextareaField from './fields/MLTextareaField.vue'
import MLMarkdownField from './fields/MLMarkdownField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
import WidgetField from './fields/WidgetField.vue'
// The control helpers live in ./control so the field components never import
// this module: registry -> field -> registry would be an import cycle whose
// evaluation order decides whether a renderer is registered at all.
export { controlAttributes, controlClass, toggleOn, toggleValue } from './control'
export type { FieldControlProps } from './control'
/** The relation manager's field type (design screen 5). */
export const RELATION_MANAGER = 'relation-manager'
// The relation manager renders child forms (FormGrid -> FormField -> this
// registry), so it sits on an import cycle with this module. It is looked up
// when a field renders, never while this module evaluates: whichever module
// of the cycle loads first, the binding is complete by then.
const lazyRenderers = new Map<string, () => Component>([[RELATION_MANAGER, () => RelationManager]])
const renderers = new Map<string, Component>([
['text', TextField],
['textarea', TextareaField],
['number', NumberField],
['dropdown', DropdownField],
['switch', SwitchField],
['checkbox', CheckboxField],
['relation', RelationField],
['widget', WidgetField],
['partial', PartialField],
['fileupload', FileuploadField],
['datepicker', DatepickerField],
['password', PasswordField],
['permissioneditor', PermissionEditorField],
['markdown', MarkdownField],
['mltext', MLTextField],
['mltextarea', MLTextareaField],
['mlmarkdown', MLMarkdownField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */
const selfLabelled = new Set<string>(['switch', 'checkbox', RELATION_MANAGER])
/**
* Types that need a saved record: never rendered on create (D-05, design
* screen 5), except a relation manager whose field is deferrable (Phase
* 12.2, D-03): FormView keeps it and it works on owner id 0.
*/
const recordBound = new Set<string>([RELATION_MANAGER])
/** Types that hold no form value: never part of the save body (D-09). */
const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileupload'])
/**
* 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<string>(['widget', 'partial', 'fileupload', 'permissioneditor', 'mltext', 'mltextarea', 'mlmarkdown'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField
}
/** Whether the SPA can edit values of this field type. */
export function isRegistered(type: string): boolean {
return (renderers.has(type) || lazyRenderers.has(type)) && !valueless.has(type)
}
/** Whether a field's type is registered by a plugin: its schema names an element. */
export function isPluginField(field: Pick<FormField, 'element'>): boolean {
return typeof field.element === 'string' && field.element !== ''
}
/** The control of one field: a plugin field type's host, else rendererFor its type. */
export function rendererForField(field: FormField): Component {
if (isPluginField(field)) {
return field.translatable ? MLPluginField : PluginField
}
return rendererFor(field.type)
}
/** Whether the SPA edits this field's value: a plugin field type always does. */
export function isRegisteredField(field: FormField): boolean {
return isPluginField(field) || isRegistered(field.type)
}
/** Whether a field type renders only on an existing record (relation manager). */
export function needsRecord(type: string): boolean {
return recordBound.has(type)
}
export function ownsLabel(type: string): boolean {
return selfLabelled.has(type)
}
/**
* Whether a field type's label names a group (widget, partial) instead of a
* control. Such a row shows no label row when the field declares no label.
*/
export function groupLabelled(type: string): boolean {
return groupLabelledTypes.has(type)
}