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:
@@ -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
|
||||
}
|
||||
|
||||
69
admin/src/components/form/control.ts
Normal file
69
admin/src/components/form/control.ts
Normal 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
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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] }>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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] }>()
|
||||
|
||||
@@ -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] }>()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user