feat(12.1-02): password and form-only fields, rules per operation and preset

- 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
This commit is contained in:
Jakub Zych
2026-10-05 10:35:08 +02:00
parent a65c670574
commit a1c6bb1ce6
42 changed files with 1284 additions and 49 deletions

View File

@@ -581,6 +581,21 @@
],
"type": "object"
},
"cabana.FieldPreset": {
"properties": {
"field": {
"type": "string"
},
"type": {
"type": "string"
}
},
"required": [
"field",
"type"
],
"type": "object"
},
"cabana.FileItem": {
"properties": {
"content_type": {
@@ -772,6 +787,14 @@
"description": "Path names the controller partial of a `type: partial` field: the\ntemplate {ConfigDir}/_{path}.htm (D-09).",
"type": "string"
},
"preset": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.FieldPreset"
}
],
"description": "Preset makes a text field follow another field of the form while the\nadministrator has not edited it, on create only (fields.yaml preset)."
},
"prompt": {
"description": "Prompt is the upload button text, localized per request.",
"type": "string"
@@ -3291,7 +3314,7 @@
},
"/{vendor}/{plugin}/{controller}/schema/form": {
"get": {
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint.",
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it.",
"parameters": [
{
"description": "Vendor",

View File

@@ -1216,7 +1216,7 @@ export interface paths {
};
/**
* Admin form schema
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint.
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text field names the field it follows on the create form (type slug or exact) until the administrator edits it.
*/
get: {
parameters: {
@@ -4358,6 +4358,10 @@ export interface components {
"cabana.ErrorEnvelope": {
error: components["schemas"]["cabana.ErrorBody"];
};
"cabana.FieldPreset": {
field: string;
type: string;
};
"cabana.FileItem": {
content_type: string;
created_at: string;
@@ -4443,6 +4447,11 @@ export interface components {
* template {ConfigDir}/_{path}.htm (D-09).
*/
path?: string;
/**
* @description Preset makes a text field follow another field of the form while the
* administrator has not edited it, on create only (fields.yaml preset).
*/
preset?: components["schemas"]["cabana.FieldPreset"];
/** @description Prompt is the upload button text, localized per request. */
prompt?: string;
/**

View File

@@ -26,6 +26,7 @@ export type FormMessages = Schemas['cabana.FormMessages']
export type FormRedirects = Schemas['cabana.FormRedirects']
/** A form's preview screen: present when config_form.yaml declares `preview:`. */
export type FormPreview = Schemas['cabana.FormPreview']
export type FieldPreset = Schemas['cabana.FieldPreset']
export type RelationSchema = Schemas['cabana.RelationSchema']
export type RelationMessages = Schemas['cabana.RelationMessages']
export type RelationMutationResult = Schemas['cabana.RelationMutationResult']

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { Eye, EyeOff } from '@lucide/vue'
import { t } from '../../../app/i18n'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
import { FORM_SESSION } from '../formContext'
// Password control (UI-SPEC S7, D-19). The server never sends a value, so the
// field is empty on load; the value leaves only in the save body. The toggle
// switches the input between hidden and visible text. After a successful save
// the form clears the value and the control returns to hidden.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const shown = ref(false)
const text = computed(() => (typeof props.modelValue === 'string' ? props.modelValue : ''))
const attrs = computed(() => controlAttributes(props.field))
const session = inject(FORM_SESSION, null)
if (session) {
watch(session.revision, () => {
shown.value = false
})
}
</script>
<template>
<div class="relative">
<input
:id="controlId"
autocomplete="new-password"
spellcheck="false"
v-bind="attrs"
:name="field.name"
:type="shown ? 'text' : 'password'"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="controlClass(invalid)"
class="h-input pr-12"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<button
type="button"
data-password-toggle
:aria-pressed="shown ? 'true' : 'false'"
:aria-label="t(shown ? 'backend::lang.form.hide_password' : 'backend::lang.form.show_password')"
class="absolute top-1/2 right-1.5 flex size-8 -translate-y-1/2 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
@click="shown = !shown"
>
<component :is="shown ? EyeOff : Eye" :size="16" aria-hidden="true" />
</button>
</div>
</template>

View File

@@ -43,14 +43,19 @@ export function contextAllows(field: FormField, mode: FormMode): boolean {
/**
* The save body: values keyed by field name for every editable field that
* has a value. Read-only fields and types without a renderer are never sent.
* An empty password on update means "unchanged" and is left out; on create it
* is sent as entered and the server's rules decide (UI-SPEC S7).
*/
export function editablePayload(fields: FormField[], values: AdminRecord): AdminRecord {
export function editablePayload(fields: FormField[], values: AdminRecord, mode: FormMode = 'create'): AdminRecord {
const out: AdminRecord = {}
for (const field of fields) {
if (field.readOnly || !isRegistered(field.type)) {
continue
}
const value = values[field.name]
if (field.type === 'password' && mode === 'update' && (value === '' || value === null)) {
continue
}
if (value !== undefined) {
out[field.name] = value
}
@@ -58,6 +63,22 @@ export function editablePayload(fields: FormField[], values: AdminRecord): Admin
return out
}
/**
* The value a preset field takes from its source (fields.yaml `preset`). Type
* slug: lower-case ASCII letters and digits, every run of other characters
* one hyphen, no hyphen at either end. Type exact: the same text.
*/
export function presetValue(type: string, source: unknown): string {
const text = typeof source === 'string' || typeof source === 'number' ? String(source) : ''
if (type !== 'slug') {
return text
}
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/** Initial values of a new record: schema defaults, toggles off, no ids. */
export function initialValues(fields: FormField[]): AdminRecord {
const out: AdminRecord = {}

View File

@@ -10,6 +10,8 @@
// 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'
@@ -17,6 +19,7 @@ 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'
@@ -52,6 +55,7 @@ const renderers = new Map<string, Component>([
['partial', PartialField],
['fileupload', FileuploadField],
['datepicker', DatepickerField],
['password', PasswordField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */

View File

@@ -23,6 +23,7 @@ import {
focusField,
initialValues,
panelDomId,
presetValue,
snapshot,
tabDomId,
tabOf,
@@ -162,15 +163,17 @@ const dirty = computed(
!loading.value &&
(pendingChanges.value > 0 ||
activeUploads.value > 0 ||
snapshot(editablePayload(fields.value, values.value)) !== saved.value),
snapshot(editablePayload(fields.value, values.value, mode)) !== saved.value),
)
function adopt(record: RecordEnvelope | undefined): void {
if (record) {
// A record response never carries a password, so every password field
// is empty again after a save (UI-SPEC S7).
values.value = { ...record.data }
labels.value = record.meta.labels ?? {}
}
saved.value = snapshot(editablePayload(fields.value, values.value))
saved.value = snapshot(editablePayload(fields.value, values.value, mode))
}
async function load(): Promise<void> {
@@ -197,8 +200,22 @@ async function load(): Promise<void> {
loading.value = false
}
// Preset fields (fields.yaml `preset`, UI-SPEC S7): on create, a text field
// follows its source field until the administrator edits it by hand; that
// stops it for as long as the form is open. On update nothing follows.
const edited = new Set<string>()
function update(name: string, value: unknown): void {
values.value = { ...values.value, [name]: value }
const next = { ...values.value, [name]: value }
edited.add(name)
if (mode === 'create') {
for (const field of fields.value) {
if (field.preset?.field === name && field.type === 'text' && !edited.has(field.name)) {
next[field.name] = presetValue(field.preset.type, value)
}
}
}
values.value = next
if (errors.value[name]) {
const next = { ...errors.value }
delete next[name]
@@ -255,7 +272,7 @@ async function save(): Promise<RecordEnvelope | null> {
busy.value = true
forbidden.value = null
try {
const body = editablePayload(fields.value, values.value)
const body = editablePayload(fields.value, values.value, mode)
const result =
recordId === null
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path, header: sessionHeader }, body })

View File

@@ -5,6 +5,10 @@
"fields": [
{ "name": "name", "type": "text", "label": "Name", "span": "left" },
{ "name": "email", "type": "text", "label": "Email", "span": "right" },
{ "name": "slug", "type": "text", "label": "Slug", "preset": { "field": "name", "type": "slug" } },
{ "name": "password", "type": "password", "label": "Password", "span": "left", "context": ["create", "update"] },
{ "name": "password_confirmation", "type": "password", "label": "Repeat the password", "span": "right", "context": ["create", "update"] },
{ "name": "notify", "type": "checkbox", "label": "Send a welcome message", "default": true, "context": "create" },
{ "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" }
],
"preview": { "headerPartial": "status" },

View File

@@ -1,5 +1,5 @@
{
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "joined_ip": "203.0.113.7" },
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "slug": "ada-lovelace", "joined_ip": "203.0.113.7" },
"meta": {
"labels": {},
"actions": [

View File

@@ -102,7 +102,7 @@ describe('preview screen (UI-SPEC S3, D-11)', () => {
const grid = wrapper.find('[data-preview] dl')
expect(grid.exists()).toBe(true)
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Joined from IP address'])
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Slug', 'Joined from IP address'])
expect(value(wrapper, 'name').text()).toBe('Ada Lovelace')
expect(value(wrapper, 'email').text()).toBe('ada@example.test')
// Values wrap and are never truncated; labels carry no required mark.

View File

@@ -0,0 +1,204 @@
// Phase 12.1 form seams in the admin SPA: the password field (UI-SPEC S7,
// D-19) and preset fields (D-27 G7). Fixtures are neutral acme.roster.* data;
// no application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { editablePayload, presetValue } from '../../src/components/form/formState'
import { setBundle } from '../../src/app/i18n'
import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
const LIST = `${API}/acme/roster/people`
const RECORD = `${LIST}/1`
// The framework strings these controls use, as GET /lang serves them in en.
const strings = {
'backend::lang.form.show_password': { other: 'Show password' },
'backend::lang.form.hide_password': { other: 'Hide password' },
}
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture },
[`GET ${RECORD}`]: { body: rosterRecordFixture },
[`GET ${LIST}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
...overrides,
}
}
const input = (wrapper: VueWrapper, name: string) => wrapper.find<HTMLInputElement>(`#field-${name}`)
const toggle = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-field="${name}"] [data-password-toggle]`)
async function save(wrapper: VueWrapper): Promise<void> {
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('password field (UI-SPEC S7, D-19)', () => {
it('renders empty and masked although the record is loaded, with the toggle after the input', async () => {
// The record response carries no password, as on the server.
const { wrapper } = await mountApp('/acme/roster/people/1', routes())
const password = input(wrapper, 'password')
expect(password.element.value).toBe('')
expect(password.attributes('type')).toBe('password')
expect(password.attributes('autocomplete')).toBe('new-password')
expect(password.attributes('spellcheck')).toBe('false')
expect(password.classes()).toEqual(expect.arrayContaining(['h-input', 'pr-12']))
expect(input(wrapper, 'password_confirmation').element.value).toBe('')
const button = toggle(wrapper, 'password')
expect(button.attributes('type')).toBe('button')
expect(button.attributes('aria-pressed')).toBe('false')
expect(button.attributes('aria-label')).toBe('Show password')
// The toggle follows the input in the DOM, so Tab reaches it second.
expect(password.element.compareDocumentPosition(button.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('shows and hides the text with the toggle', async () => {
const { wrapper } = await mountApp('/acme/roster/people/1', routes())
await input(wrapper, 'password').setValue('correct horse')
await toggle(wrapper, 'password').trigger('click')
expect(input(wrapper, 'password').attributes('type')).toBe('text')
expect(input(wrapper, 'password').element.value).toBe('correct horse')
expect(toggle(wrapper, 'password').attributes('aria-pressed')).toBe('true')
expect(toggle(wrapper, 'password').attributes('aria-label')).toBe('Hide password')
// The confirmation has its own toggle and stays masked.
expect(input(wrapper, 'password_confirmation').attributes('type')).toBe('password')
await toggle(wrapper, 'password').trigger('click')
expect(input(wrapper, 'password').attributes('type')).toBe('password')
})
it('leaves an empty password out of the update body', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }))
await input(wrapper, 'name').setValue('Ada B')
// Typed and removed again: still "unchanged".
await input(wrapper, 'password').setValue('x')
await input(wrapper, 'password').setValue('')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.name).toBe('Ada B')
expect('password' in body).toBe(false)
expect('password_confirmation' in body).toBe(false)
// notify is a create-only field and joined_ip a preview-only one.
expect('notify' in body).toBe(false)
expect('joined_ip' in body).toBe(false)
})
it('sends the password pair as entered and clears and hides both after a successful save', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }))
await input(wrapper, 'password').setValue('correct horse')
await input(wrapper, 'password_confirmation').setValue('correct horse')
await toggle(wrapper, 'password').trigger('click')
await toggle(wrapper, 'password_confirmation').trigger('click')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.password).toBe('correct horse')
expect(body.password_confirmation).toBe('correct horse')
for (const name of ['password', 'password_confirmation']) {
expect(input(wrapper, name).element.value).toBe('')
expect(input(wrapper, name).attributes('type')).toBe('password')
expect(toggle(wrapper, name).attributes('aria-pressed')).toBe('false')
}
// The password never reaches a toast or the URL.
expect(document.body.textContent).not.toContain('correct horse')
expect(window.location.href).not.toContain('correct')
})
it('sends a lone password as entered and shows the server 422 on the field', async () => {
const refused: Reply = {
status: 422,
body: {
error: {
code: 'validation_failed',
message: 'Validation failed',
details: { password: ['The password confirmation does not match.'] },
},
},
}
const { wrapper, calls } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: refused }), { attach: true })
await input(wrapper, 'password').setValue('correct horse')
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
// The SPA does not compare the two fields: the save goes out as entered.
expect(body.password).toBe('correct horse')
expect('password_confirmation' in body).toBe(false)
expect(wrapper.find('[data-field="password"]').text()).toContain('The password confirmation does not match.')
expect(input(wrapper, 'password').attributes('aria-invalid')).toBe('true')
// A refused save keeps what was typed.
expect(input(wrapper, 'password').element.value).toBe('correct horse')
})
it('keeps an empty password in a create body and drops it only on update', () => {
const fields = rosterFormSchemaFixture.data.fields.filter((field) => field.type === 'password')
expect(editablePayload(fields, { password: '' }, 'create')).toEqual({ password: '' })
expect(editablePayload(fields, { password: '' }, 'update')).toEqual({})
expect(editablePayload(fields, { password: 'abc' }, 'update')).toEqual({ password: 'abc' })
})
})
describe('preset (D-27 G7)', () => {
it('slugs lower-case ASCII with single hyphens and no cut', () => {
expect(presetValue('slug', 'Ada Lovelace')).toBe('ada-lovelace')
expect(presetValue('slug', ' Hello, World!! ')).toBe('hello-world')
expect(presetValue('slug', 'Zażółć 42')).toBe('za-42')
expect(presetValue('slug', '')).toBe('')
expect(presetValue('exact', 'Ada Lovelace')).toBe('Ada Lovelace')
const long = 'word '.repeat(80).trim()
expect(presetValue('slug', long)).toHaveLength(long.length)
})
it('fills the target from the source on create until the target is edited by hand', async () => {
const { wrapper } = await mountApp('/acme/roster/people/create', routes())
expect(input(wrapper, 'slug').element.value).toBe('')
await input(wrapper, 'name').setValue('Grace Hopper')
expect(input(wrapper, 'slug').element.value).toBe('grace-hopper')
await input(wrapper, 'name').setValue('Grace B. Hopper')
expect(input(wrapper, 'slug').element.value).toBe('grace-b-hopper')
// An empty source leaves the target empty.
await input(wrapper, 'name').setValue('')
expect(input(wrapper, 'slug').element.value).toBe('')
// The first manual edit stops it for the session.
await input(wrapper, 'slug').setValue('admiral')
await input(wrapper, 'name').setValue('Grace Hopper')
expect(input(wrapper, 'slug').element.value).toBe('admiral')
// The target is an ordinary input with no marker.
expect(input(wrapper, 'slug').attributes('readonly')).toBeUndefined()
})
it('sends the preset value with the create body', async () => {
const created = clone(rosterRecordFixture)
created.data.id = 7
const { wrapper, calls } = await mountApp('/acme/roster/people/create', routes({ [`POST ${LIST}`]: { status: 201, body: created }, [`GET ${LIST}/7`]: { body: created } }))
await input(wrapper, 'name').setValue('Grace Hopper')
await save(wrapper)
const [request] = requestsTo(calls, 'POST', LIST)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.slug).toBe('grace-hopper')
expect(body.notify).toBe(true)
})
it('does not follow the source on update', async () => {
const { wrapper } = await mountApp('/acme/roster/people/1', routes())
expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace')
await input(wrapper, 'name').setValue('Ada King')
expect(input(wrapper, 'slug').element.value).toBe('ada-lovelace')
})
})