- pact.FormVirtualFields lists form fields that are not model columns: never bound, filled or projected; their values reach the Form hooks through cabana.VirtualFieldsFromContext when the field's context allows the operation - type: password is a masked field that must be listed as virtual - pact.FormRules supplies the rule set per operation and replaces the model's Rules() for admin saves; a rule on a virtual field sees the submitted value - preset on a text field follows another text field on the create form - SPA: PasswordField, preset handling in FormView, empty password left out of an update - README, docs, OpenAPI document, TS types and dist updated
105 lines
4.7 KiB
TypeScript
105 lines
4.7 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.
|
|
import type { Component } from 'vue'
|
|
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 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 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],
|
|
])
|
|
|
|
/** 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'])
|
|
|
|
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 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)
|
|
}
|