test(10-05): bring every SPA module, composable and component under Vitest

- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
  covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
  left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 17:53:57 +02:00
parent 8259a456bb
commit 1c2a66df45
61 changed files with 4299 additions and 91 deletions

View File

@@ -0,0 +1,69 @@
// Shared props and helpers of the field controls (D-05). Kept apart from the
// registry, which imports every control: a control importing the registry
// would close an import cycle.
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
/** Props every field control receives from FieldRenderer. */
export interface FieldControlProps {
field: FormField
modelValue: unknown
/** id of the focusable control; the label points at it. */
controlId: string
invalid?: boolean
/** Space-separated ids of the comment and error lines. */
describedBy?: string
/** Display labels of the field's current relation values (meta.labels). */
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
/** Id of the record being edited; null on create and on settings pages. */
recordId?: number | null
}
// Winter `attributes:` are HTML attributes on the control. Only these are
// passed through; anything else (event handlers included) is dropped.
const allowedAttributes = new Set([
'readonly',
'placeholder',
'maxlength',
'minlength',
'min',
'max',
'step',
'autocomplete',
'spellcheck',
'pattern',
])
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {}
for (const [name, value] of Object.entries(field.attributes ?? {})) {
const key = name.toLowerCase()
if (!allowedAttributes.has(key) || value === null || value === false) {
continue
}
out[key] = value
}
return out
}
/** Shared control styling: 44px, radius 10, danger border when invalid. */
export function controlClass(invalid: boolean | undefined): string {
return [
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
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
}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toggleOn, toggleValue, type FieldControlProps } from '../registry'
import { toggleOn, toggleValue, type FieldControlProps } from '../control'
// Toggle card (design screen 4) with a 20px checkbox on the right.
const props = defineProps<FieldControlProps>()

View File

@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { ChevronDown } from '@lucide/vue'
import type { FormOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import { controlClass, type FieldControlProps } from '../registry'
import { controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
@@ -63,7 +63,9 @@ function onChange(event: Event): void {
@change="onChange"
>
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
<option v-else-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
<!-- A stored value no option matches shows the placeholder, never a
wrong choice (also next to an emptyOption). -->
<option v-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
{{ t('backend::lang.form.select_placeholder') }}
</option>
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>()

View File

@@ -4,7 +4,7 @@ import { ChevronDown, X } from '@lucide/vue'
import { api } from '../../../api/client'
import type { RelationOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry'
import type { FieldControlProps } from '../control'
// Relation field (D-17, D-18, D-26), three modes from the schema:
// readOnly the label from meta.labels only, no control

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toggleOn, toggleValue, type FieldControlProps } from '../registry'
import { toggleOn, toggleValue, type FieldControlProps } from '../control'
// Toggle card (design screen 4): the label as title, the comment as helper
// text and a 44x26 role="switch" control; the off track uses borderStrong.

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { Puzzle } from '@lucide/vue'
import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry'
import type { FieldControlProps } from '../control'
// D-05 fallback for any field type without a renderer. The translated
// sentence is split around the type so the type renders in DM Mono, as text.

View File

@@ -5,7 +5,6 @@
// relation through its own endpoints and renders only on an existing record.
// 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'
@@ -16,22 +15,11 @@ import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
/** Props every field control receives from FieldRenderer. */
export interface FieldControlProps {
field: FormField
modelValue: unknown
/** id of the focusable control; the label points at it. */
controlId: string
invalid?: boolean
/** Space-separated ids of the comment and error lines. */
describedBy?: string
/** Display labels of the field's current relation values (meta.labels). */
labels?: RelationOption[]
/** Controller whose option endpoints serve this form; null for settings. */
source?: ControllerParams | null
/** Id of the record being edited; null on create and on settings pages. */
recordId?: number | null
}
// 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'
@@ -73,51 +61,3 @@ export function needsRecord(type: string): boolean {
export function ownsLabel(type: string): boolean {
return selfLabelled.has(type)
}
// Winter `attributes:` are HTML attributes on the control. Only these are
// passed through; anything else (event handlers included) is dropped.
const allowedAttributes = new Set([
'readonly',
'placeholder',
'maxlength',
'minlength',
'min',
'max',
'step',
'autocomplete',
'spellcheck',
'pattern',
])
export function controlAttributes(field: FormField): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {}
for (const [name, value] of Object.entries(field.attributes ?? {})) {
const key = name.toLowerCase()
if (!allowedAttributes.has(key) || value === null || value === false) {
continue
}
out[key] = value
}
return out
}
/** Shared control styling: 44px, radius 10, danger border when invalid. */
export function controlClass(invalid: boolean | undefined): string {
return [
'w-full rounded-control border bg-surface px-3.5 text-text transition-colors duration-150 ease-out',
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
}

View File

@@ -11,7 +11,7 @@ import Pagination from '../list/Pagination.vue'
import Button from '../ui/Button.vue'
import ConfirmDialog from '../ui/ConfirmDialog.vue'
import { useConfirm } from '../ui/confirm'
import type { FieldControlProps } from '../form/registry'
import type { FieldControlProps } from '../form/control'
import RelationPickerModal from './RelationPickerModal.vue'
// Relation manager field (D-05, design screen 5). Renders only for an

View File

@@ -9,7 +9,7 @@ import { activeEntry, firstControllerPath } from '../../state/useNavigation'
import { settingsEntry } from '../../state/useSettings'
// Header breadcrumbs (design: Top header): plugin, controller and record
// labels, e.g. "Płytarium › Albumy › Enigmatic". Parent crumbs are muted
// labels, e.g. "Demo › Widgets › Blue widget". Parent crumbs are muted
// links; the current crumb uses the text colour at 600 with
// aria-current="page". Separators are 14px chevron-right icons.
interface Crumb {