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:
11
admin/src/api/schema.d.ts
vendored
11
admin/src/api/schema.d.ts
vendored
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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']
|
||||
|
||||
56
admin/src/components/form/fields/PasswordField.vue
Normal file
56
admin/src/components/form/fields/PasswordField.vue
Normal 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>
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user