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

@@ -102,10 +102,16 @@ export const winterIcons: Readonly<Record<string, string>> = {
/** Neutral icon for names neither lucide map knows. */
export const fallbackIcon: Component = Square
/** An own entry of a map: names come from the server, so inherited object
* members such as "constructor" or "toString" never count as icons. */
function own<T>(map: Readonly<Record<string, T>>, key: string): T | undefined {
return Object.hasOwn(map, key) ? map[key] : undefined
}
export function iconFor(name: string | null | undefined): Component {
if (!name) {
return fallbackIcon
}
const key = name.trim()
return icons[key] ?? icons[winterIcons[key] ?? ''] ?? fallbackIcon
return own(icons, key) ?? own(icons, own(winterIcons, key) ?? '') ?? fallbackIcon
}

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 {

View File

@@ -2,9 +2,11 @@
/* The generated API document and types carry prose, not classes: keep them
out of the class scan so an admin API change does not churn the built CSS
in boardwalk/dist (D-04 drift check). */
in boardwalk/dist (D-04 drift check). Tests are not shipped either: words
in test code (a "blur" event, say) must never add utilities to the bundle. */
@source not "../api/schema.d.ts";
@source not "../../openapi";
@source not "../../tests";
/* Self-hosted fonts (D-07). Whole-weight files carry latin and latin-ext with
unicode-range, so Polish diacritics and basic Latin both resolve. */

View File

@@ -39,7 +39,11 @@ const schema = ref<ListSchema | null>(null)
const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(true)
const failed = ref(false)
// The schema and the rows load in parallel; either failing is announced, so a
// list response that arrives after a failed schema never hides the failure.
const schemaFailed = ref(false)
const listFailed = ref(false)
const failed = computed(() => schemaFailed.value || listFailed.value)
const selected = ref<RowId[]>([])
const deleting = ref(false)
const confirm = useConfirm()
@@ -92,24 +96,22 @@ let generation = 0
async function loadList(): Promise<void> {
const current = ++generation
loading.value = true
failed.value = false
listFailed.value = false
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
if (current !== generation) {
return
}
rows.value = result.data?.data ?? []
meta.value = result.data?.meta ?? null
failed.value = !result.data
listFailed.value = !result.data
loading.value = false
}
async function load(): Promise<void> {
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
schema.value = result.data?.data ?? null
if (!schema.value) {
failed.value = true
loading.value = false
}
schemaFailed.value = !schema.value
}
void load()