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

@@ -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). */