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" "type": "object"
}, },
"cabana.FieldPreset": {
"properties": {
"field": {
"type": "string"
},
"type": {
"type": "string"
}
},
"required": [
"field",
"type"
],
"type": "object"
},
"cabana.FileItem": { "cabana.FileItem": {
"properties": { "properties": {
"content_type": { "content_type": {
@@ -772,6 +787,14 @@
"description": "Path names the controller partial of a `type: partial` field: the\ntemplate {ConfigDir}/_{path}.htm (D-09).", "description": "Path names the controller partial of a `type: partial` field: the\ntemplate {ConfigDir}/_{path}.htm (D-09).",
"type": "string" "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": { "prompt": {
"description": "Prompt is the upload button text, localized per request.", "description": "Prompt is the upload button text, localized per request.",
"type": "string" "type": "string"
@@ -3291,7 +3314,7 @@
}, },
"/{vendor}/{plugin}/{controller}/schema/form": { "/{vendor}/{plugin}/{controller}/schema/form": {
"get": { "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": [ "parameters": [
{ {
"description": "Vendor", "description": "Vendor",

View File

@@ -1216,7 +1216,7 @@ export interface paths {
}; };
/** /**
* Admin form schema * 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: { get: {
parameters: { parameters: {
@@ -4358,6 +4358,10 @@ export interface components {
"cabana.ErrorEnvelope": { "cabana.ErrorEnvelope": {
error: components["schemas"]["cabana.ErrorBody"]; error: components["schemas"]["cabana.ErrorBody"];
}; };
"cabana.FieldPreset": {
field: string;
type: string;
};
"cabana.FileItem": { "cabana.FileItem": {
content_type: string; content_type: string;
created_at: string; created_at: string;
@@ -4443,6 +4447,11 @@ export interface components {
* template {ConfigDir}/_{path}.htm (D-09). * template {ConfigDir}/_{path}.htm (D-09).
*/ */
path?: string; 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. */ /** @description Prompt is the upload button text, localized per request. */
prompt?: string; prompt?: string;
/** /**

View File

@@ -26,6 +26,7 @@ export type FormMessages = Schemas['cabana.FormMessages']
export type FormRedirects = Schemas['cabana.FormRedirects'] export type FormRedirects = Schemas['cabana.FormRedirects']
/** A form's preview screen: present when config_form.yaml declares `preview:`. */ /** A form's preview screen: present when config_form.yaml declares `preview:`. */
export type FormPreview = Schemas['cabana.FormPreview'] export type FormPreview = Schemas['cabana.FormPreview']
export type FieldPreset = Schemas['cabana.FieldPreset']
export type RelationSchema = Schemas['cabana.RelationSchema'] export type RelationSchema = Schemas['cabana.RelationSchema']
export type RelationMessages = Schemas['cabana.RelationMessages'] export type RelationMessages = Schemas['cabana.RelationMessages']
export type RelationMutationResult = Schemas['cabana.RelationMutationResult'] 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 * 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. * 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 = {} const out: AdminRecord = {}
for (const field of fields) { for (const field of fields) {
if (field.readOnly || !isRegistered(field.type)) { if (field.readOnly || !isRegistered(field.type)) {
continue continue
} }
const value = values[field.name] const value = values[field.name]
if (field.type === 'password' && mode === 'update' && (value === '' || value === null)) {
continue
}
if (value !== undefined) { if (value !== undefined) {
out[field.name] = value out[field.name] = value
} }
@@ -58,6 +63,22 @@ export function editablePayload(fields: FormField[], values: AdminRecord): Admin
return out 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. */ /** Initial values of a new record: schema defaults, toggles off, no ids. */
export function initialValues(fields: FormField[]): AdminRecord { export function initialValues(fields: FormField[]): AdminRecord {
const out: 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 // 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 // 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). // 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 type { Component } from 'vue'
import CheckboxField from './fields/CheckboxField.vue' import CheckboxField from './fields/CheckboxField.vue'
import DatepickerField from './fields/DatepickerField.vue' import DatepickerField from './fields/DatepickerField.vue'
@@ -17,6 +19,7 @@ import DropdownField from './fields/DropdownField.vue'
import FileuploadField from './fields/FileuploadField.vue' import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue' import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue' import PartialField from './fields/PartialField.vue'
import PasswordField from './fields/PasswordField.vue'
import RelationManager from '../relation/RelationManager.vue' import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue' import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue' import SwitchField from './fields/SwitchField.vue'
@@ -52,6 +55,7 @@ const renderers = new Map<string, Component>([
['partial', PartialField], ['partial', PartialField],
['fileupload', FileuploadField], ['fileupload', FileuploadField],
['datepicker', DatepickerField], ['datepicker', DatepickerField],
['password', PasswordField],
]) ])
/** Types whose control shows the label itself (toggle cards, relation manager). */ /** Types whose control shows the label itself (toggle cards, relation manager). */

View File

@@ -23,6 +23,7 @@ import {
focusField, focusField,
initialValues, initialValues,
panelDomId, panelDomId,
presetValue,
snapshot, snapshot,
tabDomId, tabDomId,
tabOf, tabOf,
@@ -162,15 +163,17 @@ const dirty = computed(
!loading.value && !loading.value &&
(pendingChanges.value > 0 || (pendingChanges.value > 0 ||
activeUploads.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 { function adopt(record: RecordEnvelope | undefined): void {
if (record) { 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 } values.value = { ...record.data }
labels.value = record.meta.labels ?? {} 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> { async function load(): Promise<void> {
@@ -197,8 +200,22 @@ async function load(): Promise<void> {
loading.value = false 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 { 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]) { if (errors.value[name]) {
const next = { ...errors.value } const next = { ...errors.value }
delete next[name] delete next[name]
@@ -255,7 +272,7 @@ async function save(): Promise<RecordEnvelope | null> {
busy.value = true busy.value = true
forbidden.value = null forbidden.value = null
try { try {
const body = editablePayload(fields.value, values.value) const body = editablePayload(fields.value, values.value, mode)
const result = const result =
recordId === null recordId === null
? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path, header: sessionHeader }, body }) ? await api.POST('/{vendor}/{plugin}/{controller}', { params: { path, header: sessionHeader }, body })

View File

@@ -5,6 +5,10 @@
"fields": [ "fields": [
{ "name": "name", "type": "text", "label": "Name", "span": "left" }, { "name": "name", "type": "text", "label": "Name", "span": "left" },
{ "name": "email", "type": "text", "label": "Email", "span": "right" }, { "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" } { "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" }
], ],
"preview": { "headerPartial": "status" }, "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": { "meta": {
"labels": {}, "labels": {},
"actions": [ "actions": [

View File

@@ -102,7 +102,7 @@ describe('preview screen (UI-SPEC S3, D-11)', () => {
const grid = wrapper.find('[data-preview] dl') const grid = wrapper.find('[data-preview] dl')
expect(grid.exists()).toBe(true) 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, 'name').text()).toBe('Ada Lovelace')
expect(value(wrapper, 'email').text()).toBe('ada@example.test') expect(value(wrapper, 'email').text()).toBe('ada@example.test')
// Values wrap and are never truncated; labels carry no required mark. // 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')
})
})

View File

@@ -221,6 +221,47 @@ A hook or scope that has to read the database during a write should use the tran
Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes. Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` rather than checking in a hook: the scope then applies to every route, including relation and action routes.
### Form-only values and rules
A form may collect values that are not columns of the record (see [Form-only fields](forms.md#form-only-fields)). The Form hooks read what the administrator submitted with `cabana.VirtualFieldsFromContext(ctx)`. The map holds only the fields that were sent and that the field's `context` allows for this operation, so a missing key means "not submitted". Values are scalars as decoded from the request: a string, a bool, a `json.Number` or nil. The map is a copy, and the second result is false outside a create or update.
```go src=modules/cabana/example_form_seams_test.go#MembersController.FormBeforeCreate
// FormBeforeCreate reads the submitted virtual values, which have passed the
// rules by now, and stores what the model needs.
func (MembersController) FormBeforeCreate(ctx context.Context, model any) error {
values, _ := cabana.VirtualFieldsFromContext(ctx)
member := model.(*Member)
if plain, ok := values["password"].(string); ok && plain != "" {
member.Password = hashPassword(plain)
}
if notify, _ := values["notify"].(bool); notify {
// Queue the welcome message here.
}
return nil
}
```
A save validates the record against the model's `Rules()`. Those are often the rules of a public sign-up, which an admin form cannot meet: an update that changes only a name would have to repeat the password. A controller implementing `pact.FormRules` returns the rules of an admin save for `create` or `update`, and that set replaces the model's:
```go src=modules/cabana/example_form_seams_test.go#MembersController.FormRules
// FormRules replaces the model's rules for admin saves: a create needs a
// password, an update takes one only when the administrator types it.
func (MembersController) FormRules(_ context.Context, op string) map[string]string {
rules := map[string]string{"name": "required"}
if op == "create" {
rules["password"] = "required|between:8,255|confirmed"
} else {
rules["password"] = "nullable|between:8,255|confirmed"
}
return rules
}
```
- The form's `required` flags are still merged in, also for form-only fields.
- A rule on a form-only field is checked against the submitted value, or against nothing when the field was not sent; the model column of the same name is never read. `confirmed` compares with the submitted `<field>_confirmation`.
- Rule strings use the tokens `lagoon.Validate` supports. An unknown token is the opaque 500 on every save, so checks outside that set belong in a hook that returns a `cabana.ValidationError`.
- The model must still have a `Rules` method; relation forms and settings forms keep using the model's rules.
## Refusing a write ## Refusing a write
A hook or an action stops a write by returning an error. Which error decides what the administrator sees: A hook or an action stops a write by returning an error. Which error decides what the administrator sees:

View File

@@ -95,15 +95,29 @@ for _, f := range form.Fields {
| `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). | | `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). |
| `fileupload` | Uploads for an attachOne or attachMany relation; see [File uploads](#file-uploads). | | `fileupload` | Uploads for an attachOne or attachMany relation; see [File uploads](#file-uploads). |
| `datepicker` | A date, date and time, or time of day; see [Date pickers](#date-pickers). | | `datepicker` | A date, date and time, or time of day; see [Date pickers](#date-pickers). |
| `password` | A masked input with a show and hide button. It is a form-only field: the value is sent with a save and never returned; see [Form-only fields](#form-only-fields). |
The WinterCMS widgets that are not in this list (the rich editor, the markdown editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up. The WinterCMS widgets that are not in this list (the rich editor, the markdown editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up.
### Field options ### Field options
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused. A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields and `preset` on text fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused.
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds. `context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
`preset` makes a `text` field follow another text field of the same form while the administrator has not edited it, as a slug field follows a title. It is the source field's name, or a mapping with `field` and `type`:
```yaml
slug:
label: acme.blog::lang.posts.slug
type: text
preset:
field: title
type: slug
```
`type: slug` (the default, and what the short form `preset: title` means) lower-cases the text and turns every run of characters other than ASCII letters and digits into one hyphen; `type: exact` copies the text. The admin SPA applies it on the create form only, and the first manual edit of the field stops it. The server does not fill the field: a model that needs a slug even when none is sent sets it in its own `BeforeValidate`. The schema reports the key as `preset` (a `cabana.FieldPreset`). A `preset` on another field type, an unknown type, or a source that is not a text field of the same form stops the start-up, and the key is not accepted on settings forms or relation forms.
## Date pickers ## Date pickers
A `type: datepicker` field edits one model column. Its `mode` decides the column's Go type, and the start-up stops when they do not match: A `type: datepicker` field edits one model column. Its `mode` decides the column's Go type, and the start-up stops when they do not match:
@@ -216,6 +230,26 @@ messages:
- `recordUrl` in `config_list.yaml` and the form's `create.redirect`, `update.redirectClose` and the other redirects may point at the screen as `<vendor>/<plugin>/<controller>/preview/:id`. On the update form of a record with a preview, the back arrow and Cancel return to the preview; after a delete the form goes to the list. - `recordUrl` in `config_list.yaml` and the form's `create.redirect`, `update.redirectClose` and the other redirects may point at the screen as `<vendor>/<plugin>/<controller>/preview/:id`. On the update form of a record with a preview, the back arrow and Cancel return to the preview; after a delete the form goes to the list.
- The footer holds the record actions the show response offers in `meta.actions`, then the edit button. After an action the record and the status hint are loaded again in place. - The footer holds the record actions the show response offers in `meta.actions`, then the edit button. After an action the record and the status hint are loaded again in place.
## Form-only fields
Some fields of a form are not columns of the record: a password and its confirmation, or a "send an invitation" checkbox. A controller lists them through `pact.FormVirtualFields`:
```go src=modules/cabana/example_form_seams_test.go#MembersController.FormVirtualFields
// FormVirtualFields names the fields of fields.yaml that are not columns of
// the form. cabana never fills or returns them.
func (MembersController) FormVirtualFields() []string {
return []string{"password", "password_confirmation", "notify"}
}
```
- A listed field needs no model column. cabana never binds it, never fills it into the model and never puts it in a record response, on any route. A model column with the same name (a stored password hash) is not touched by the form.
- The values an administrator submits reach the controller's Form hooks through `cabana.VirtualFieldsFromContext(ctx)`; see [Admin controllers](admin-controllers.md#form-only-values-and-rules). Only fields that were sent and whose `context` allows the operation are there. A value must be a scalar: an object or a list is a 422 on the field.
- Every listed name must be a field of the form's `fields.yaml` with the type `password`, `text`, `textarea`, `number`, `checkbox`, `switch` or `dropdown`. Anything else stops the start-up.
- `type: password` is always a form-only field: a password field the controller does not list stops the start-up. The admin SPA shows it empty on every load, leaves an empty password out of an update (empty means unchanged), and clears it after a save. A confirmation is a second `password` field, compared by the `confirmed` rule on the server.
- Form-only fields are not available on settings forms or relation forms.
## What a save may write ## What a save may write
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.
[Form-only fields](#form-only-fields) are never written by cabana. Their submitted values are validated, when a rule names them, and handed to the controller's hooks; what is stored from them is the hook's decision.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" /> <meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title> <title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-DEJgWNHv.js"></script> <script type="module" crossorigin src="./assets/index-DEO3TzjB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-57SuA8gQ.css"> <link rel="stylesheet" crossorigin href="./assets/index-B2epb8_Z.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -21,6 +21,8 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`. - Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id. - Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot. - Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation. - File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
@@ -206,6 +208,8 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. | | `cabana.BackendUser` / `cabana.BackendUserRole` / `cabana.BackendUsers` | GORM models of the backend user tables and the principal loader used by the guard. |
| `cabana.Allows` | Checks a principal against required permission codes. | | `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. | | `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
| `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. | | `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. | | `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. | | `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |

View File

@@ -262,7 +262,7 @@ func AdminListSchema() {}
// AdminFormSchema documents the form schema route. // AdminFormSchema documents the form schema route.
// //
// @Summary Admin form schema // @Summary 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.
// @Tags admin // @Tags admin
// @Produce json // @Produce json
// @Security BackendBearer // @Security BackendBearer

View File

@@ -102,6 +102,9 @@ type CompiledController struct {
// dates are the form's `type: datepicker` fields after the Go type // dates are the form's `type: datepicker` fields after the Go type
// check, keyed by field name. // check, keyed by field name.
dates map[string]*compiledDate dates map[string]*compiledDate
// virtual is the set of form field names the controller lists through
// pact.FormVirtualFields: never bound, filled or projected.
virtual map[string]bool
} }
// Registry is the immutable controller map keyed by controller ID. // Registry is the immutable controller map keyed by controller ID.

View File

@@ -176,7 +176,9 @@ func ProjectWritableFields(cc *CompiledController, body map[string]any) map[stri
} }
// BindWritableFields records schema field names onto model column fill keys. // BindWritableFields records schema field names onto model column fill keys.
// Protected columns are omitted. A scalar field with no column fails activation. // Protected columns and the fields the controller lists through
// pact.FormVirtualFields are omitted. Any other scalar field with no column
// fails activation.
func BindWritableFields(cc *CompiledController) error { func BindWritableFields(cc *CompiledController) error {
if cc == nil || cc.Form == nil { if cc == nil || cc.Form == nil {
return nil return nil
@@ -190,9 +192,13 @@ func BindWritableFields(cc *CompiledController) error {
return nil return nil
} }
cols := modelColumns(src.NewRecord()) cols := modelColumns(src.NewRecord())
// A virtual field (pact.FormVirtualFields) is not a column of the form:
// it gets no column check and no binding, so Fill never receives it and
// no response returns it.
cc.virtual = virtualFieldSet(cc.Controller)
bindings := make([]WritableField, 0, len(cc.Form.Fields)) bindings := make([]WritableField, 0, len(cc.Form.Fields))
for _, field := range cc.Form.Fields { for _, field := range cc.Form.Fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) { if !scalarFormField(field.Type) || protectedFillKey(field.Name) || cc.virtual[field.Name] {
continue continue
} }
if _, known := cols[field.Name]; !known { if _, known := cols[field.Name]; !known {
@@ -568,9 +574,15 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil { if err != nil {
return RecordResult{}, err return RecordResult{}, err
} }
// Virtual field values never reach Fill: they are handed to the Form
// hooks on the context and to the rules.
virtual, err := liftVirtualValues(cc, in.Body, op)
if err != nil {
return RecordResult{}, err
}
var result RecordResult var result RecordResult
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error { err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx) ctx = withVirtualFields(withTx(ctx, tx), virtual)
target, err := newWritableModel(cc) target, err := newWritableModel(cc)
if err != nil { if err != nil {
return err return err
@@ -599,8 +611,8 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return &CapabilityError{ControllerID: controllerID(cc)} return &CapabilityError{ControllerID: controllerID(cc)}
} }
} }
rules := mergedRules(cc, target, op) rules := mergedRules(ctx, cc, target, op)
msgs, err := lagoon.Validate(ctx, tx, target, rules, valuesForRules(target, rules), nil) msgs, err := lagoon.Validate(ctx, tx, target, rules, virtualValuesForRules(cc, target, rules, virtual), nil)
if err != nil { if err != nil {
return &CapabilityError{ControllerID: controllerID(cc)} return &CapabilityError{ControllerID: controllerID(cc)}
} }
@@ -991,12 +1003,23 @@ func fillAllowed(cc *CompiledController, model any, op string) []string {
return out return out
} }
// mergedRules combines the model's rules with the form's `required` flags. A // mergedRules combines the base rules with the form's `required` flags. The
// field whose `context` hides it on op cannot be supplied there, so its form // base is the model's Rules(), or the set a controller implementing
// level `required` does not apply to that operation. // pact.FormRules returns for op (create or update), which replaces the model's
func mergedRules(cc *CompiledController, model any, op string) map[string]string { // rules for admin saves. A field whose `context` hides it on op cannot be
// supplied there, so its form level `required` does not apply to that
// operation.
func mergedRules(ctx context.Context, cc *CompiledController, model any, op string) map[string]string {
out := map[string]string{} out := map[string]string{}
if rules, ok := model.(hasRules); ok && rules != nil { var own pact.FormRules
if cc != nil && cc.Controller != nil && (op == "create" || op == "update") {
own, _ = cc.Controller.(pact.FormRules)
}
if own != nil {
for key, rule := range own.FormRules(ctx, op) {
out[key] = rule
}
} else if rules, ok := model.(hasRules); ok && rules != nil {
for key, rule := range rules.Rules() { for key, rule := range rules.Rules() {
out[key] = rule out[key] = rule
} }
@@ -1006,14 +1029,77 @@ func mergedRules(cc *CompiledController, model any, op string) map[string]string
} }
for _, field := range cc.Form.Fields { for _, field := range cc.Form.Fields {
// Relation fields are not writable columns. required stays on the // Relation fields are not writable columns. required stays on the
// schema for the client, but it cannot be checked by Fill. // schema for the client, but it cannot be checked by Fill. A virtual
if field.Required && scalarFormField(field.Type) && contextAllows(cc, field.Name, op) { // field is checked against its submitted value.
if field.Required && (scalarFormField(field.Type) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
out[field.Name] = mergeRequired(out[field.Name]) out[field.Name] = mergeRequired(out[field.Name])
} }
} }
return out return out
} }
// virtualFieldSet is the set of names a controller lists through
// pact.FormVirtualFields; nil when it lists none.
func virtualFieldSet(ctl pact.AdminController) map[string]bool {
src, ok := ctl.(pact.FormVirtualFields)
if !ok || src == nil {
return nil
}
names := src.FormVirtualFields()
if len(names) == 0 {
return nil
}
out := make(map[string]bool, len(names))
for _, name := range names {
out[name] = true
}
return out
}
// liftVirtualValues collects the submitted values of the form's virtual
// fields (pact.FormVirtualFields) for op: only fields present in the body
// whose context allows the operation. A nested value is validation_failed on
// the field. The result is never nil.
func liftVirtualValues(cc *CompiledController, body map[string]any, op string) (map[string]any, error) {
out := map[string]any{}
if cc == nil || cc.Form == nil || len(cc.virtual) == 0 {
return out, nil
}
for _, field := range cc.Form.Fields {
if !cc.virtual[field.Name] || !contextAllows(cc, field.Name, op) {
continue
}
value, present := body[field.Name]
if !present {
continue
}
if nestedValue(value) {
return nil, &ValidationError{Details: fillTypeDetails(field.Name)}
}
out[field.Name] = value
}
return out, nil
}
// virtualValuesForRules is valuesForRules for a controller save: a rule on a
// virtual field sees the submitted value, or nothing when the field was not
// submitted, and never the model column of the same name (a stored password
// hash, for example). Every submitted virtual value is present, so `confirmed`
// and `different` can read a field no rule names.
func virtualValuesForRules(cc *CompiledController, model any, rules map[string]string, virtual map[string]any) map[string]any {
out := valuesForRules(model, rules)
if cc == nil || len(cc.virtual) == 0 {
return out
}
for name := range cc.virtual {
delete(out, name)
}
for name, value := range virtual {
out[name] = value
}
return out
}
func mergeRequired(rule string) string { func mergeRequired(rule string) string {
if strings.TrimSpace(rule) == "" { if strings.TrimSpace(rule) == "" {
return "required" return "required"

View File

@@ -0,0 +1,115 @@
package cabana_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Member is the model behind the acme.roster members controller. Password
// holds a hash; the form never reads or writes the column itself.
type Member struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Slug string `gorm:"column:slug"`
Password string `gorm:"column:password" json:"-"`
}
func (Member) TableName() string { return "acme_roster_members" }
// Fillable lists the columns the admin form may write.
func (Member) Fillable() []string { return []string{"name", "slug"} }
// Rules are the model's own rules, used wherever the controller sets none.
func (Member) Rules() map[string]string {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
// MembersController is an admin controller whose form has fields that are
// not columns and rules of its own.
type MembersController struct{}
var (
_ pact.AdminController = MembersController{}
_ pact.AdminRecordSource = MembersController{}
_ pact.FormVirtualFields = MembersController{}
_ pact.FormRules = MembersController{}
_ pact.FormBeforeCreate = MembersController{}
_ pact.FormBeforeUpdate = MembersController{}
)
func (MembersController) ID() string { return "acme.roster.members" }
func (MembersController) ModelName() string { return "Member" }
func (MembersController) ConfigDir() string { return "controllers/members" }
func (MembersController) NewRecord() any { return &Member{} }
// FormVirtualFields names the fields of fields.yaml that are not columns of
// the form. cabana never fills or returns them.
func (MembersController) FormVirtualFields() []string {
return []string{"password", "password_confirmation", "notify"}
}
// FormRules replaces the model's rules for admin saves: a create needs a
// password, an update takes one only when the administrator types it.
func (MembersController) FormRules(_ context.Context, op string) map[string]string {
rules := map[string]string{"name": "required"}
if op == "create" {
rules["password"] = "required|between:8,255|confirmed"
} else {
rules["password"] = "nullable|between:8,255|confirmed"
}
return rules
}
// FormBeforeCreate reads the submitted virtual values, which have passed the
// rules by now, and stores what the model needs.
func (MembersController) FormBeforeCreate(ctx context.Context, model any) error {
values, _ := cabana.VirtualFieldsFromContext(ctx)
member := model.(*Member)
if plain, ok := values["password"].(string); ok && plain != "" {
member.Password = hashPassword(plain)
}
if notify, _ := values["notify"].(bool); notify {
// Queue the welcome message here.
}
return nil
}
// FormBeforeUpdate changes the password only when one was submitted.
func (MembersController) FormBeforeUpdate(ctx context.Context, model any) error {
values, _ := cabana.VirtualFieldsFromContext(ctx)
if plain, ok := values["password"].(string); ok && plain != "" {
model.(*Member).Password = hashPassword(plain)
}
return nil
}
// hashPassword stands in for the application's password hasher.
func hashPassword(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
// Example_formSeams shows what the controller declares. Outside a save there
// are no submitted values, so the hook stores nothing.
func Example_formSeams() {
ctl := MembersController{}
ctx := context.Background()
fmt.Println(ctl.FormVirtualFields())
fmt.Println("create:", ctl.FormRules(ctx, "create")["password"])
fmt.Println("update:", ctl.FormRules(ctx, "update")["password"])
member := &Member{Name: "Ada"}
_, inSave := cabana.VirtualFieldsFromContext(ctx)
err := ctl.FormBeforeCreate(ctx, member)
fmt.Println(inSave, err, member.Password == "")
// Output:
// [password password_confirmation notify]
// create: required|between:8,255|confirmed
// update: nullable|between:8,255|confirmed
// false <nil> true
}

View File

@@ -113,6 +113,12 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
for _, field := range cc.Form.Fields { for _, field := range cc.Form.Fields {
fields[field.Name] = field fields[field.Name] = field
} }
if err := checkVirtualFields(cc, fields); err != nil {
return bootErr(pluginID, id, file, err)
}
if err := checkPresets(cc.Form.Fields); err != nil {
return bootErr(pluginID, id, file, err)
}
writable := map[string]bool{} writable := map[string]bool{}
for _, field := range cc.Writable { for _, field := range cc.Writable {
writable[field.Name] = true writable[field.Name] = true
@@ -147,6 +153,44 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
return nil return nil
} }
// virtualFieldTypes are the field types a controller may list through
// pact.FormVirtualFields: the value is one scalar.
var virtualFieldTypes = map[string]bool{
"password": true, "text": true, "textarea": true, "number": true,
"checkbox": true, "switch": true, "dropdown": true,
}
// checkVirtualFields checks the controller's pact.FormVirtualFields list
// against the form: every `type: password` field must be listed (its value is
// never a model column), and every listed name must be a field of the form
// with a scalar type.
func checkVirtualFields(cc *CompiledController, fields map[string]FormField) error {
for _, field := range cc.Form.Fields {
if field.Type == "password" && !cc.virtual[field.Name] {
return fmt.Errorf("field %s: type password needs the controller to list it in FormVirtualFields", field.Name)
}
}
src, ok := cc.Controller.(pact.FormVirtualFields)
if !ok || src == nil {
return nil
}
seen := map[string]bool{}
for _, name := range src.FormVirtualFields() {
if seen[name] {
return fmt.Errorf("FormVirtualFields lists field %s twice", name)
}
seen[name] = true
field, exists := fields[name]
if !exists {
return fmt.Errorf("FormVirtualFields: field %s is not a field of this form", name)
}
if !virtualFieldTypes[field.Type] {
return fmt.Errorf("FormVirtualFields: field %s has type %s (want password, text, textarea, number, checkbox, switch or dropdown)", name, field.Type)
}
}
return nil
}
// compilePartials reads and parses every partial the controller declares: // compilePartials reads and parses every partial the controller declares:
// config_list.yaml headerPartial, config_form.yaml preview.headerPartial and // config_list.yaml headerPartial, config_form.yaml preview.headerPartial and
// each `type: partial` field's path, all resolving to {ConfigDir}/_{name}.htm. // each `type: partial` field's path, all resolving to {ConfigDir}/_{name}.htm.

View File

@@ -25,6 +25,7 @@ var (
"text": {}, "textarea": {}, "number": {}, "checkbox": {}, "text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {}, "widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
"password": {},
} }
formSpans = map[string]struct{}{ formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {}, "left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
@@ -42,6 +43,7 @@ var (
"thumbOptions": {}, "useCaption": {}, "prompt": {}, "thumbOptions": {}, "useCaption": {}, "prompt": {},
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {}, "format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {}, "firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
"preset": {},
} }
// widgetKeys are valid only on `type: widget` (D-06). // widgetKeys are valid only on `type: widget` (D-06).
widgetKeys = []string{"widget", "action", "fill"} widgetKeys = []string{"widget", "action", "fill"}
@@ -244,6 +246,10 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
field.ActionLabel = translateKey(ctx, tr, src.ActionLabel) field.ActionLabel = translateKey(ctx, tr, src.ActionLabel)
field.Prompt = translateKey(ctx, tr, src.Prompt) field.Prompt = translateKey(ctx, tr, src.Prompt)
field.Fill = append([]string(nil), src.Fill...) field.Fill = append([]string(nil), src.Fill...)
if src.Preset != nil {
preset := *src.Preset
field.Preset = &preset
}
field.FileTypes = append([]string(nil), src.FileTypes...) field.FileTypes = append([]string(nil), src.FileTypes...)
field.MimeTypes = append([]string(nil), src.MimeTypes...) field.MimeTypes = append([]string(nil), src.MimeTypes...)
field.YearRange = append([]int(nil), src.YearRange...) field.YearRange = append([]int(nil), src.YearRange...)
@@ -548,6 +554,9 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
if err := compileDatepickerKeys(typ, values, &field); err != nil { if err := compileDatepickerKeys(typ, values, &field); err != nil {
return FormField{}, err return FormField{}, err
} }
if err := compilePresetKey(typ, values, &field); err != nil {
return FormField{}, err
}
if node, ok := values["required"]; ok { if node, ok := values["required"]; ok {
field.Required, err = nodeBool(node) field.Required, err = nodeBool(node)
if err != nil { if err != nil {
@@ -624,6 +633,81 @@ func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField)
return nil return nil
} }
// compilePresetKey decodes `preset` (D-27): the name of the field this text
// field follows while the administrator has not edited it, on create only. It
// is a string (the source field; type slug) or a mapping with the keys field
// and type, where type is slug or exact. The source is checked against the
// form in checkPresets.
func compilePresetKey(typ string, values map[string]ast.Node, field *FormField) error {
node, ok := values["preset"]
if !ok {
return nil
}
if typ != "text" {
return fmt.Errorf("preset is only valid on type: text")
}
preset := FieldPreset{Type: "slug"}
switch n := node.(type) {
case *ast.StringNode:
preset.Field = n.Value
case *ast.MappingNode:
for _, entry := range n.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return fmt.Errorf("preset: %w", err)
}
value, err := nodeString(unwrapNode(entry.Value))
if err != nil {
return fmt.Errorf("preset: %s: %w", key, err)
}
switch key {
case "field":
preset.Field = value
case "type":
preset.Type = value
default:
return fmt.Errorf("preset: unknown field %s", key)
}
}
default:
return fmt.Errorf("preset must be a field name or a mapping with field and type")
}
if !identifier(preset.Field) {
return fmt.Errorf("preset field %q is not an identifier", preset.Field)
}
if preset.Type != "slug" && preset.Type != "exact" {
return fmt.Errorf("preset type %s is not supported (want slug or exact)", preset.Type)
}
field.Preset = &preset
return nil
}
// checkPresets checks every preset of a form against its fields: the source
// must be another text field of the same form.
func checkPresets(fields []FormField) error {
types := make(map[string]string, len(fields))
for _, field := range fields {
types[field.Name] = field.Type
}
for _, field := range fields {
if field.Preset == nil {
continue
}
source := field.Preset.Field
if source == field.Name {
return fmt.Errorf("field %s: preset names the field itself", field.Name)
}
typ, ok := types[source]
if !ok {
return fmt.Errorf("field %s: preset field %s is not a field of this form", field.Name, source)
}
if typ != "text" {
return fmt.Errorf("field %s: preset field %s must be a text field", field.Name, source)
}
}
return nil
}
// partialPathHint is the D-11 path rule shared by every partial path error. // partialPathHint is the D-11 path rule shared by every partial path error.
const partialPathHint = "path must be a partial name such as summary (resolves to CONFIG_DIR/_summary.htm); Winter $/ and ~/ paths are not supported" const partialPathHint = "path must be a partial name such as summary (resolves to CONFIG_DIR/_summary.htm); Winter $/ and ~/ paths are not supported"

View File

@@ -304,7 +304,7 @@ func TestRecordActionSmoke(t *testing.T) {
}) })
t.Run("create and update responses carry no actions", func(t *testing.T) { t.Run("create and update responses carry no actions", func(t *testing.T) {
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh"}`, "bearer") rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
if strings.Contains(rec.Body.String(), `"actions"`) { if strings.Contains(rec.Body.String(), `"actions"`) {
t.Fatalf("create response: %s", rec.Body.String()) t.Fatalf("create response: %s", rec.Body.String())
} }

View File

@@ -2,6 +2,8 @@ package cabana_test
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http" "net/http"
@@ -39,12 +41,34 @@ type rosterPerson struct {
Banned bool `gorm:"column:banned"` Banned bool `gorm:"column:banned"`
// JoinedIP is shown on the preview screen only (context: preview). // JoinedIP is shown on the preview screen only (context: preview).
JoinedIP *string `gorm:"column:joined_ip"` JoinedIP *string `gorm:"column:joined_ip"`
// Password is a stored hash. The form's password field is virtual: the
// controller's hooks derive this column from the submitted value.
Password string `gorm:"column:password" json:"-"`
Slug string `gorm:"column:slug"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
} }
func (rosterPerson) TableName() string { return "roster_people" } func (rosterPerson) TableName() string { return "roster_people" }
func (rosterPerson) Fillable() []string { return []string{"name", "email"} } func (rosterPerson) Fillable() []string { return []string{"name", "email", "slug"} }
func (rosterPerson) Rules() map[string]string { return map[string]string{"name": "required"} }
// Rules are the model's own (sign-up) rules: every save needs a confirmed
// password. The admin form replaces them through the controller's FormRules.
func (rosterPerson) Rules() map[string]string {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
// rosterHash is the fixture's stand-in for a password hash.
func rosterHash(plain string) string {
sum := sha256.Sum256([]byte(plain))
return "sha256:" + hex.EncodeToString(sum[:])
}
// rosterVirtual is what one Form hook read from VirtualFieldsFromContext.
type rosterVirtual struct {
Hook string
Values map[string]any
Found bool
}
// rosterSpy records what each registered action's Run receives. // rosterSpy records what each registered action's Run receives.
type rosterSpy struct { type rosterSpy struct {
@@ -53,6 +77,31 @@ type rosterSpy struct {
record []pact.AdminRecordActionInput record []pact.AdminRecordActionInput
// states counts ListRowStates calls and keeps the size of each page. // states counts ListRowStates calls and keeps the size of each page.
states []int states []int
// virtual keeps what each Form hook read from the context.
virtual []rosterVirtual
}
func (s *rosterSpy) recordVirtual(hook string, ctx context.Context) map[string]any {
values, found := cabana.VirtualFieldsFromContext(ctx)
if s == nil {
return values
}
s.mu.Lock()
defer s.mu.Unlock()
kept := make(map[string]any, len(values))
for name, value := range values {
kept[name] = value
}
s.virtual = append(s.virtual, rosterVirtual{Hook: hook, Values: kept, Found: found})
return values
}
func (s *rosterSpy) takeVirtual() []rosterVirtual {
s.mu.Lock()
defer s.mu.Unlock()
out := s.virtual
s.virtual = nil
return out
} }
func (s *rosterSpy) recordStates(n int) { func (s *rosterSpy) recordStates(n int) {
@@ -216,9 +265,49 @@ const rosterLocked = "Locked"
// copy and never write into it. // copy and never write into it.
var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"} var rosterRefused = &cabana.ForbiddenError{Message: "acme.roster::lang.people.locked"}
// FormBeforeUpdate refuses the reserved name with a ForbiddenError naming // FormVirtualFields lists the form fields that are not columns of the form:
// the field, and fails with a plain error for the name Boom. // the password pair and the create-only notify checkbox.
func (rosterController) FormBeforeUpdate(_ context.Context, model any) error { func (rosterController) FormVirtualFields() []string {
return []string{"password", "password_confirmation", "notify"}
}
// FormRules are the admin form's rules: a create needs a confirmed password,
// an update takes one only when it is submitted.
func (rosterController) FormRules(_ context.Context, op string) map[string]string {
if op == "create" {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
return map[string]string{"name": "required", "password": "nullable|between:8,255|confirmed"}
}
// storePassword derives the stored hash from a submitted password.
func storePassword(person *rosterPerson, values map[string]any) {
if plain, ok := values["password"].(string); ok && plain != "" {
person.Password = rosterHash(plain)
}
}
// FormBeforeCreate stamps the tenant and stores the hash of the submitted
// password. It also drops notify from its own copy of the virtual values: the
// after hook must still see it.
func (c rosterController) FormBeforeCreate(ctx context.Context, model any) error {
values := c.spy.recordVirtual("before-create", ctx)
model.(*rosterPerson).Tenant = "acme"
storePassword(model.(*rosterPerson), values)
delete(values, "notify")
return nil
}
func (c rosterController) FormAfterCreate(ctx context.Context, _ any) error {
c.spy.recordVirtual("after-create", ctx)
return nil
}
// FormBeforeUpdate stores a submitted password, refuses the reserved name
// with a ForbiddenError naming the field, and fails with a plain error for
// the name Boom.
func (c rosterController) FormBeforeUpdate(ctx context.Context, model any) error {
storePassword(model.(*rosterPerson), c.spy.recordVirtual("before-update", ctx))
switch model.(*rosterPerson).Name { switch model.(*rosterPerson).Name {
case "Reserved": case "Reserved":
return &cabana.ForbiddenError{ return &cabana.ForbiddenError{

View File

@@ -1,9 +1,12 @@
package cabana_test package cabana_test
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -93,7 +96,7 @@ func TestPreviewSmoke(t *testing.T) {
if stored.Name != "Ada L" || stored.JoinedIP == nil || *stored.JoinedIP != ip { if stored.Name != "Ada L" || stored.JoinedIP == nil || *stored.JoinedIP != ip {
t.Fatalf("stored = %+v ip=%v", stored, stored.JoinedIP) t.Fatalf("stored = %+v ip=%v", stored, stored.JoinedIP)
} }
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2"}`, "bearer") rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2","password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
created := rosterRecord(t, rec.Body.Bytes()) created := rosterRecord(t, rec.Body.Bytes())
id, _ := created.Data["id"].(float64) id, _ := created.Data["id"].(float64)
if got := rosterLoad(t, gdb, uint(id)); got.JoinedIP != nil { if got := rosterLoad(t, gdb, uint(id)); got.JoinedIP != nil {
@@ -136,3 +139,250 @@ func TestPreviewSmoke(t *testing.T) {
} }
}) })
} }
// rosterFields is the fixture's fields.yaml with old replaced by new (boot
// tests); an empty old appends new.
func rosterFields(t *testing.T, old, new string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(rosterDir, rosterFieldsFile))
if err != nil {
t.Fatal(err)
}
text := string(raw)
if old == "" {
return text + new
}
if !strings.Contains(text, old) {
t.Fatalf("fields.yaml does not contain %q", old)
}
return strings.Replace(text, old, new, 1)
}
const rosterFieldsFile = "models/person/fields.yaml"
// rosterErrorDetail asserts a 4xx body's code and one field message.
func rosterErrorDetail(t *testing.T, raw []byte, code, field, message string) {
t.Helper()
var body cabana.ErrorEnvelope
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("error body %s: %v", raw, err)
}
if body.Error.Code != code {
t.Fatalf("code = %q, want %s; body %s", body.Error.Code, code, raw)
}
list, _ := body.Error.Details[field].([]any)
for _, item := range list {
if item == message {
return
}
}
t.Fatalf("details[%s] = %v, want %q; body %s", field, body.Error.Details[field], message, raw)
}
// TestPasswordFieldSmoke drives `type: password` through the assembled router
// on PostgreSQL (D-27 G1; T-12.1-10): the value reaches the controller's hook,
// which stores a hash, and no record response on any route carries the key or
// the plain text.
func TestPasswordFieldSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
const plain, next = "s3cret-plain-text", "another-plain-9"
leaks := func(t *testing.T, route, body string) {
t.Helper()
for _, part := range []string{"password", plain, next, "sha256:"} {
if strings.Contains(body, part) {
t.Fatalf("%s response carries %q: %s", route, part, body)
}
}
}
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople,
fmt.Sprintf(`{"name":"Pat","password":%q,"password_confirmation":%q}`, plain, plain), "bearer")
leaks(t, "create", rec.Body.String())
idFloat, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
id := uint(idFloat)
record := fmt.Sprintf("%s/%d", rosterPeople, id)
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(plain) || stored.Password == plain {
t.Fatalf("stored password = %q", stored.Password)
}
rec = env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
leaks(t, "show", rec.Body.String())
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer")
leaks(t, "list", rec.Body.String())
rec = env.expect(t, http.StatusOK, http.MethodPut, record,
fmt.Sprintf(`{"name":"Pat B","password":%q,"password_confirmation":%q}`, next, next), "bearer")
leaks(t, "update", rec.Body.String())
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat B" {
t.Fatalf("after update: %+v", stored)
}
t.Run("an update without a password keeps the stored one", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Pat C"}`, "bearer")
leaks(t, "update", rec.Body.String())
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat C" {
t.Fatalf("stored = %+v", stored)
}
})
t.Run("a mismatch, a lone confirmation and a short password are 422 on password", func(t *testing.T) {
const mismatch = "The password confirmation does not match."
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"name":"Pat D","password":"long-enough-1","password_confirmation":"long-enough-2"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password_confirmation":"long-enough-2"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password":"short","password_confirmation":"short"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password must be between 8 and 255 characters.")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Mis","password":"long-enough-1","password_confirmation":"other-enough-1"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", mismatch)
// Nothing of the refused saves was written.
if stored := rosterLoad(t, gdb, id); stored.Password != rosterHash(next) || stored.Name != "Pat C" {
t.Fatalf("a refused save wrote: %+v", stored)
}
})
t.Run("the schema serves the field without a value", func(t *testing.T) {
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"password","type":"password","label":"Password","span":"left","context":["create","update"]`) {
t.Fatalf("password field is not in the schema: %s", raw)
}
})
}
// TestVirtualFieldsSmoke drives pact.FormVirtualFields (D-27 G2; T-12.1-09):
// submitted values reach the Form hooks through VirtualFieldsFromContext only
// when the field's context allows the operation, and are never filled or
// returned.
func TestVirtualFieldsSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
if _, ok := cabana.VirtualFieldsFromContext(context.Background()); ok {
t.Fatal("virtual fields reported outside a save")
}
const body = `{"name":"Vic","notify":true,"password":"long-enough-1","password_confirmation":"long-enough-1"}`
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, body, "bearer")
for _, name := range []string{"notify", "password", "password_confirmation"} {
if strings.Contains(rec.Body.String(), name) {
t.Fatalf("create response carries %s: %s", name, rec.Body.String())
}
}
idFloat, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
record := fmt.Sprintf("%s/%d", rosterPeople, uint(idFloat))
seen := env.spy.takeVirtual()
if len(seen) != 2 || seen[0].Hook != "before-create" || seen[1].Hook != "after-create" {
t.Fatalf("hooks = %+v", seen)
}
for _, hook := range seen {
// The before hook deleted notify from its copy; the after hook
// still sees it.
if !hook.Found || hook.Values["notify"] != true || hook.Values["password"] != "long-enough-1" || hook.Values["password_confirmation"] != "long-enough-1" || len(hook.Values) != 3 {
t.Fatalf("%s saw %+v found=%v", hook.Hook, hook.Values, hook.Found)
}
}
t.Run("a field whose context hides it on update never reaches the hook", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Vic B","notify":true}`, "bearer")
if strings.Contains(rec.Body.String(), "notify") {
t.Fatalf("update response: %s", rec.Body.String())
}
seen := env.spy.takeVirtual()
if len(seen) != 1 || seen[0].Hook != "before-update" || !seen[0].Found || len(seen[0].Values) != 0 {
t.Fatalf("update hook saw %+v", seen)
}
})
t.Run("a nested value is 422 on the field and nothing is written", func(t *testing.T) {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"Nest","notify":{"on":true},"password":"long-enough-1","password_confirmation":"long-enough-1"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "notify", "The notify field has an invalid value.")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"password":["a","b"]}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field has an invalid value.")
var count int64
if err := gdb.Model(&rosterPerson{}).Where("name = ?", "Nest").Count(&count).Error; err != nil || count != 0 {
t.Fatalf("nested create wrote %d rows err=%v", count, err)
}
if seen := env.spy.takeVirtual(); len(seen) != 0 {
t.Fatalf("a refused save reached a hook: %+v", seen)
}
})
t.Run("a virtual name is never a fill key", func(t *testing.T) {
// The model has a password column; the submitted text must reach it
// only through the hook (as a hash), never through Fill.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"password":"plain-through-fill","password_confirmation":"plain-through-fill"}`, "bearer")
var stored rosterPerson
if err := gdb.Unscoped().First(&stored, uint(idFloat)).Error; err != nil {
t.Fatal(err)
}
if stored.Password != rosterHash("plain-through-fill") {
t.Fatalf("stored password = %q", stored.Password)
}
})
}
// TestFormRulesSmoke drives pact.FormRules (D-28 G5): the controller's rule
// set per operation replaces the model's Rules() for admin saves.
func TestFormRulesSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Rae", Active: true, Password: rosterHash("stored-before")})
record := fmt.Sprintf("%s/%d", rosterPeople, id)
t.Run("an update of name alone passes although the model demands a confirmed password", func(t *testing.T) {
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Rae B"}`, "bearer")
if stored := rosterLoad(t, gdb, id); stored.Name != "Rae B" || stored.Password != rosterHash("stored-before") {
t.Fatalf("stored = %+v", stored)
}
})
t.Run("the create rules need a password and the update rules a name", func(t *testing.T) {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, rosterPeople, `{"name":"No password"}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "password", "The password field is required.")
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, `{"name":""}`, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "name", "The name field is required.")
})
t.Run("boot rules", func(t *testing.T) {
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, "", " secret:\n type: password\n")},
"field secret: type password needs the controller to list it in FormVirtualFields", "acme.roster.people", rosterFieldsFile)
// A form-only text field the controller does not list is still a
// column error.
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, "", " nickname:\n type: text\n")},
"field nickname is not a model column")
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " notify:\n label: acme.roster::lang.people.notify\n type: checkbox\n default: true\n context: create\n", "")},
"FormVirtualFields: field notify is not a field of this form", rosterFieldsFile)
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: partial\n path: status\n")},
"FormVirtualFields: field notify has type partial")
})
}
// TestPresetSchema checks the fields.yaml preset key (D-27 G7): the schema
// carries it and its boot rules hold.
func TestPresetSchema(t *testing.T) {
env, _ := newRosterEnv(t)
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"slug","type":"text","label":"Slug","preset":{"field":"name","type":"slug"}`) {
t.Fatalf("preset is not in the schema: %s", raw)
}
for _, tc := range []struct {
name, old, new string
want string
}{
{"mapping with exact", " preset: name\n", " preset:\n field: name\n type: exact\n", ""},
{"unsupported type", " preset: name\n", " preset:\n field: name\n type: camel\n", "preset type camel is not supported (want slug or exact)"},
{"unknown key", " preset: name\n", " preset:\n field: name\n prefix: x\n", "preset: unknown field prefix"},
{"not a text target", " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n", "preset is only valid on type: text"},
{"unknown source", " preset: name\n", " preset: title\n", "field slug: preset field title is not a field of this form"},
{"source is not text", " preset: name\n", " preset: notify\n", "field slug: preset field notify must be a text field"},
{"itself", " preset: name\n", " preset: slug\n", "field slug: preset names the field itself"},
} {
t.Run(tc.name, func(t *testing.T) {
replace := map[string]string{rosterFieldsFile: rosterFields(t, tc.old, tc.new)}
if tc.want == "" {
if err := rosterBoot(t, rosterTree(t, replace)); err != nil {
t.Fatalf("did not boot: %v", err)
}
return
}
rosterBootFails(t, replace, tc.want, rosterFieldsFile)
})
}
}

View File

@@ -1131,7 +1131,7 @@ func (s RelationService) fillPivot(ctx context.Context, tx *gorm.DB, cr *Compile
return &CapabilityError{ControllerID: controllerID(form)} return &CapabilityError{ControllerID: controllerID(form)}
} }
rules := map[string]string{} rules := map[string]string{}
for key, rule := range mergedRules(form, pivot, "") { for key, rule := range mergedRules(ctx, form, pivot, "") {
if slices.Contains(allowed, key) { if slices.Contains(allowed, key) {
rules[key] = rule rules[key] = rule
} }

View File

@@ -113,7 +113,7 @@ func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *Compi
return &CapabilityError{ControllerID: controllerID(form)} return &CapabilityError{ControllerID: controllerID(form)}
} }
} }
rules := mergedRules(form, model, op) rules := mergedRules(ctx, form, model, op)
msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil) msgs, err := lagoon.Validate(ctx, tx, model, rules, valuesForRules(model, rules), nil)
if err != nil { if err != nil {
return &CapabilityError{ControllerID: controllerID(form)} return &CapabilityError{ControllerID: controllerID(form)}

View File

@@ -19,6 +19,8 @@ import (
// plugin extensions need a parent record scope a child modal does not have. // plugin extensions need a parent record scope a child modal does not have.
var relationFormRefusedTypes = map[string]bool{ var relationFormRefusedTypes = map[string]bool{
"relation": true, "relation-manager": true, "widget": true, "partial": true, "relation": true, "relation-manager": true, "widget": true, "partial": true,
// A password is a virtual field, and only an admin controller lists those.
"password": true,
} }
// pivotFieldPattern is WinterCMS's pivot form field name, pivot[column]. // pivotFieldPattern is WinterCMS's pivot form field name, pivot[column].
@@ -152,6 +154,9 @@ func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS,
if relationFormRefusedTypes[field.Type] { if relationFormRefusedTypes[field.Type] {
return nil, fail(fmt.Errorf("field %s: type %s is not supported in a relation form (%s)", field.Name, field.Type, purpose)) return nil, fail(fmt.Errorf("field %s: type %s is not supported in a relation form (%s)", field.Name, field.Type, purpose))
} }
if field.Preset != nil {
return nil, fail(fmt.Errorf("field %s: preset is not supported in a relation form (%s)", field.Name, purpose))
}
if cr.hasMany() && field.Name == cr.Contract.ForeignKey { if cr.hasMany() && field.Name == cr.Contract.ForeignKey {
return nil, fail(fmt.Errorf("field %s is the relation's ForeignKey; the server sets it", field.Name)) return nil, fail(fmt.Errorf("field %s is the relation's ForeignKey; the server sets it", field.Name))
} }

View File

@@ -301,10 +301,23 @@ type FormField struct {
// managed before the record is first saved (RelationSchema.Deferrable): // managed before the record is first saved (RelationSchema.Deferrable):
// the SPA shows it on the create screen. // the SPA shows it on the create screen.
Deferrable bool `json:"deferrable,omitempty"` Deferrable bool `json:"deferrable,omitempty"`
// 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 *FieldPreset `json:"preset,omitempty"`
optionsMethod string optionsMethod string
} }
// FieldPreset is a text field's `preset`: Field names the text field of the
// same form whose value it follows, and Type is how the value is taken over,
// slug (lower-case ASCII with hyphens) or exact (the same text). The admin SPA
// applies it on the create form until the administrator edits the field; the
// server does not fill the field.
type FieldPreset struct {
Field string `json:"field"`
Type string `json:"type"`
}
// ThumbOptions is a fileupload field's thumbOptions mapping. Mode is one of // ThumbOptions is a fileupload field's thumbOptions mapping. Mode is one of
// auto, exact, crop or fit. // auto, exact, crop or fit.
type ThumbOptions struct { type ThumbOptions struct {

View File

@@ -63,9 +63,12 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
} }
for _, field := range fields { for _, field := range fields {
// A settings screen has no admin controller to own actions or view models. // A settings screen has no admin controller to own actions or view models.
if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" { if field.Type == "widget" || field.Type == "partial" || field.Type == "fileupload" || field.Type == "password" {
return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type) return nil, fmt.Errorf("cabana: setting %s field %s: type %s is not supported on a settings form", item.Code, field.Name, field.Type)
} }
if field.Preset != nil {
return nil, fmt.Errorf("cabana: setting %s field %s: preset is not supported on a settings form", item.Code, field.Name)
}
} }
form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields} form := &FormSchema{Name: item.Label, ModelClass: item.Model, Fields: fields}
columns := modelColumns(model) columns := modelColumns(model)

View File

@@ -24,3 +24,7 @@ people:
deleted_text: An archived person is hidden from the directory. deleted_text: An archived person is hidden from the directory.
inactive_title: This person is not active inactive_title: This person is not active
inactive_text: Activate the person to let them sign in. inactive_text: Activate the person to let them sign in.
slug: Slug
password: Password
password_confirmation: Repeat the password
notify: Send a welcome message

View File

@@ -24,3 +24,7 @@ people:
deleted_text: Zarchiwizowana osoba jest ukryta w katalogu. deleted_text: Zarchiwizowana osoba jest ukryta w katalogu.
inactive_title: Ta osoba jest nieaktywna inactive_title: Ta osoba jest nieaktywna
inactive_text: Aktywuj osobę, aby mogła się zalogować. inactive_text: Aktywuj osobę, aby mogła się zalogować.
slug: Slug
password: Hasło
password_confirmation: Powtórz hasło
notify: Wyślij wiadomość powitalną

View File

@@ -7,6 +7,25 @@ fields:
label: acme.roster::lang.people.email label: acme.roster::lang.people.email
type: text type: text
span: right span: right
slug:
label: acme.roster::lang.people.slug
type: text
preset: name
password:
label: acme.roster::lang.people.password
type: password
span: left
context: [create, update]
password_confirmation:
label: acme.roster::lang.people.password_confirmation
type: password
span: right
context: [create, update]
notify:
label: acme.roster::lang.people.notify
type: checkbox
default: true
context: create
joined_ip: joined_ip:
label: acme.roster::lang.people.joined_ip label: acme.roster::lang.people.joined_ip
type: text type: text

View File

@@ -31,3 +31,37 @@ func TxFromContext(ctx context.Context) (*gorm.DB, bool) {
tx, ok := ctx.Value(txContextKey{}).(*gorm.DB) tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
return tx, ok && tx != nil return tx, ok && tx != nil
} }
type virtualFieldsKey struct{}
// withVirtualFields returns ctx carrying the virtual field values of a save.
func withVirtualFields(ctx context.Context, values map[string]any) context.Context {
if values == nil {
values = map[string]any{}
}
return context.WithValue(ctx, virtualFieldsKey{}, values)
}
// VirtualFieldsFromContext returns the values an administrator submitted for
// the form's virtual fields (pact.FormVirtualFields), keyed by field name, for
// the context handed to a Form hook (pact.FormBeforeCreate,
// pact.FormAfterCreate, pact.FormBeforeUpdate, pact.FormAfterUpdate). Only
// fields that were present in the request body and whose `context` allows the
// operation are in the map, so a missing key means "not submitted". Values
// are scalars as decoded from the JSON body: a string, a bool, a json.Number
// or nil. The map is a copy. The second result is false outside a create or
// update save.
func VirtualFieldsFromContext(ctx context.Context) (map[string]any, bool) {
if ctx == nil {
return nil, false
}
values, ok := ctx.Value(virtualFieldsKey{}).(map[string]any)
if !ok {
return nil, false
}
out := make(map[string]any, len(values))
for name, value := range values {
out[name] = value
}
return out, true
}

View File

@@ -15,7 +15,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. - Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`. - Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders). - Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`). - Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library. - A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either. - A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
- `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package. - `pact.OptionalMessage`, a service an optional plugin can publish so others integrate with it without importing its package.
@@ -120,6 +120,8 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. | | `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
| `pact.ListRowStates` | Optional controller hook called once per list page; returns the states of the page's records, index-aligned. | | `pact.ListRowStates` | Optional controller hook called once per list page; returns the states of the page's records, index-aligned. |
| `pact.RowState` | One state of a list row: `pact.RowStateDeleted`, `pact.RowStateNegative` or `pact.RowStateDisabled`. | | `pact.RowState` | One state of a list row: `pact.RowStateDeleted`, `pact.RowStateNegative` or `pact.RowStateDisabled`. |
| `pact.FormVirtualFields` | Optional controller list of form fields that are not model columns for the form (a password and its confirmation, for example): never bound, filled or returned; their submitted values reach the Form hooks through the admin framework's context accessor. |
| `pact.FormRules` | Optional controller hook returning the validation rules of an admin save for `create` or `update`; the set replaces the model's `Rules()` for those saves and may name virtual fields. |
| `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. | | `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. |
| `pact.RelationBeforeLink` | Optional controller hook that checks or fills pivot columns before a relation link is written. | | `pact.RelationBeforeLink` | Optional controller hook that checks or fills pivot columns before a relation link is written. |
| `pact.RelationBeforeCreate` | Optional controller hook run in the write transaction before a relation manager creates a related record. | | `pact.RelationBeforeCreate` | Optional controller hook run in the write transaction before a relation manager creates a related record. |

View File

@@ -486,6 +486,32 @@ type FormAfterDelete interface {
FormAfterDelete(ctx context.Context, model any) error FormAfterDelete(ctx context.Context, model any) error
} }
// FormVirtualFields optionally names fields of the controller's form that are
// not model columns for the purpose of the form, such as a password and its
// confirmation or a "send an invitation" checkbox. The admin framework never
// binds such a field to a column, never fills it into the model and never
// returns it in a record response. The values an administrator submits for
// them reach the Form hooks (FormBeforeCreate, FormAfterCreate,
// FormBeforeUpdate, FormAfterUpdate) through cabana.VirtualFieldsFromContext,
// and only for the fields whose `context` allows the operation. Every name
// must be a field of the form's fields.yaml of type password, text, textarea,
// number, checkbox, switch or dropdown; a `type: password` field must be
// listed here.
type FormVirtualFields interface {
FormVirtualFields() []string
}
// FormRules optionally supplies the validation rules of an admin save. op is
// "create" or "update". When a controller implements it, the returned set
// replaces the model's Rules() for saves through the admin form; the form's
// `required` flags are still merged in. Rule strings use the tokens
// lagoon.Validate supports. A rule may name a field listed by
// FormVirtualFields: it is then checked against the submitted value, never
// against a model column of the same name.
type FormRules interface {
FormRules(ctx context.Context, op string) map[string]string
}
// RelationExtendManageQuery optionally narrows relation-manager candidates. // RelationExtendManageQuery optionally narrows relation-manager candidates.
type RelationExtendManageQuery interface { type RelationExtendManageQuery interface {
RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB

View File

@@ -259,3 +259,35 @@ func TestRowStateValues(t *testing.T) {
} }
} }
} }
type formSeams struct{}
func (formSeams) FormVirtualFields() []string { return []string{"password", "password_confirmation"} }
func (formSeams) FormRules(_ context.Context, op string) map[string]string {
if op == "create" {
return map[string]string{"password": "required|confirmed"}
}
return map[string]string{"password": "nullable|confirmed"}
}
func TestFormSeamsDiscoveredByTypeAssertion(t *testing.T) {
var ctl any = formSeams{}
virtual, ok := ctl.(FormVirtualFields)
if !ok || len(virtual.FormVirtualFields()) != 2 {
t.Fatalf("FormVirtualFields = %v ok=%v", virtual, ok)
}
rules, ok := ctl.(FormRules)
if !ok {
t.Fatal("FormRules is not implemented")
}
if got := rules.FormRules(context.Background(), "create")["password"]; got != "required|confirmed" {
t.Fatalf("create rules = %q", got)
}
if got := rules.FormRules(context.Background(), "update")["password"]; got != "nullable|confirmed" {
t.Fatalf("update rules = %q", got)
}
if _, ok := any(neither{}).(FormVirtualFields); ok {
t.Fatal("a plugin without the method implements FormVirtualFields")
}
}

View File

@@ -68,6 +68,8 @@ form:
create: Create create: Create
return_to_list: Back to list return_to_list: Back to list
return_to_preview: Back to preview return_to_preview: Back to preview
show_password: Show password
hide_password: Hide password
close: Close close: Close
confirm: Confirm confirm: Confirm
saving: Saving… saving: Saving…

View File

@@ -74,6 +74,8 @@ form:
create: Utwórz create: Utwórz
return_to_list: Wróć do listy return_to_list: Wróć do listy
return_to_preview: Wróć do podglądu return_to_preview: Wróć do podglądu
show_password: Pokaż hasło
hide_password: Ukryj hasło
close: Zamknij close: Zamknij
confirm: Potwierdź confirm: Potwierdź
saving: Zapisywanie… saving: Zapisywanie…