diff --git a/admin/src/app/router.ts b/admin/src/app/router.ts index d547d69..244fb97 100644 --- a/admin/src/app/router.ts +++ b/admin/src/app/router.ts @@ -5,6 +5,8 @@ import { homePath } from '../state/useNavigation' import LoginView from '../views/LoginView.vue' import ListView from '../views/ListView.vue' import FormView from '../views/FormView.vue' +import SettingsFormView from '../views/SettingsFormView.vue' +import SettingsIndexView from '../views/SettingsIndexView.vue' import NotFoundView from '../views/NotFoundView.vue' declare module 'vue-router' { @@ -43,6 +45,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt meta: { shell: true }, beforeEnter: () => homePath() ?? true, }, + { path: '/settings', name: 'settings', component: SettingsIndexView, meta: { shell: true } }, + { path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } }, { path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } }, { path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } }, { path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } }, diff --git a/admin/src/components/form/FormTabs.vue b/admin/src/components/form/FormTabs.vue new file mode 100644 index 0000000..b087e78 --- /dev/null +++ b/admin/src/components/form/FormTabs.vue @@ -0,0 +1,53 @@ + + + diff --git a/admin/src/components/form/fields/CheckboxField.vue b/admin/src/components/form/fields/CheckboxField.vue new file mode 100644 index 0000000..d8f3223 --- /dev/null +++ b/admin/src/components/form/fields/CheckboxField.vue @@ -0,0 +1,39 @@ + + + diff --git a/admin/src/components/form/fields/RelationField.vue b/admin/src/components/form/fields/RelationField.vue new file mode 100644 index 0000000..e3f6327 --- /dev/null +++ b/admin/src/components/form/fields/RelationField.vue @@ -0,0 +1,403 @@ + + + diff --git a/admin/src/components/form/fields/SwitchField.vue b/admin/src/components/form/fields/SwitchField.vue new file mode 100644 index 0000000..63de1ce --- /dev/null +++ b/admin/src/components/form/fields/SwitchField.vue @@ -0,0 +1,47 @@ + + + diff --git a/admin/src/components/form/formState.ts b/admin/src/components/form/formState.ts index 2b6c07e..c92fc1b 100644 --- a/admin/src/components/form/formState.ts +++ b/admin/src/components/form/formState.ts @@ -5,6 +5,30 @@ import { isRegistered } from './registry' export type FormMode = 'create' | 'update' +/** One form tab: the YAML tab label, or the default tab for untabbed fields. */ +export interface TabItem { + key: string + label: string + errors: number +} + +/** Key of the tab that holds fields without a `tab`. */ +export const DEFAULT_TAB = 'default' + +/** Tab key of a field: its tab label, or the default tab. */ +export function tabOf(field: FormField): string { + return field.tab ? `tab:${field.tab}` : DEFAULT_TAB +} + +/** DOM ids of the tab button and its panel, by tab position. */ +export function tabDomId(prefix: string, index: number): string { + return `${prefix}-tab-${index}` +} + +export function panelDomId(prefix: string, index: number): string { + return `${prefix}-panel-${index}` +} + /** Winter `context:` semantics: no context shows the field everywhere. */ export function contextAllows(field: FormField, mode: FormMode): boolean { const context = field.context diff --git a/admin/src/components/form/registry.ts b/admin/src/components/form/registry.ts index 9413198..3513ece 100644 --- a/admin/src/components/form/registry.ts +++ b/admin/src/components/form/registry.ts @@ -1,11 +1,15 @@ // 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. Phase 10.1 turns this -// seam into the plugin extension point. +// dashed box, so an unknown type never breaks the form. The relation manager +// arrives with its own component in a later plan. Phase 10.1 turns this seam +// into the plugin extension point. import type { Component } from 'vue' import type { ControllerParams, FormField, RelationOption } from '../../api/types' +import CheckboxField from './fields/CheckboxField.vue' import DropdownField from './fields/DropdownField.vue' import NumberField from './fields/NumberField.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' @@ -30,10 +34,13 @@ const renderers = new Map([ ['textarea', TextareaField], ['number', NumberField], ['dropdown', DropdownField], + ['switch', SwitchField], + ['checkbox', CheckboxField], + ['relation', RelationField], ]) /** Types whose control shows the label itself (toggle cards). */ -const selfLabelled = new Set() +const selfLabelled = new Set(['switch', 'checkbox']) export function rendererFor(type: string): Component { return renderers.get(type) ?? UnsupportedField @@ -82,3 +89,16 @@ export function controlClass(invalid: boolean | undefined): string { invalid ? 'border-danger' : 'border-border-strong', ].join(' ') } + +/** Truthiness of a stored toggle value (true, 1, "1", "true"). */ +export function toggleOn(value: unknown): boolean { + if (typeof value === 'string') { + return value !== '' && value !== '0' && value.toLowerCase() !== 'false' + } + return Boolean(value) +} + +/** A toggle's next value in the stored value's own type (1/0 or boolean). */ +export function toggleValue(current: unknown, on: boolean): boolean | number { + return typeof current === 'number' ? (on ? 1 : 0) : on +} diff --git a/admin/src/components/shell/AppShell.vue b/admin/src/components/shell/AppShell.vue index 8439e57..15aa24f 100644 --- a/admin/src/components/shell/AppShell.vue +++ b/admin/src/components/shell/AppShell.vue @@ -45,7 +45,10 @@ const initials = computed(() =>