feat(12.1-02): writable foreign keys, locked relation options, invisible columns

- FieldRelationContract.WritableForeignKey makes a belongsTo field over a
  protected foreign key writable; the protected key list is unchanged
- cabana.RelationLockProvider names related ids an administrator may not add
  or remove: options and labels carry locked, and a create or update that
  changes the locked subset is 403 before any row is written
- columns.yaml invisible keeps a column searchable and out of the rows
- a controller implementing pact.FilterOptions serves a scope filter's
  choices before the model
- SPA: locked chips and options in RelationField, DataTable skips invisible
  columns
- README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
Jakub Zych
2026-10-05 10:58:38 +02:00
parent f50d9b8f10
commit df5cace852
39 changed files with 1115 additions and 84 deletions

View File

@@ -1040,6 +1040,10 @@
},
"cabana.ListColumn": {
"properties": {
"invisible": {
"description": "Invisible marks a column that is searched and sorted on the server but\nnot shown: the admin renders no header or cell for it and list rows do\nnot carry its value (columns.yaml invisible).",
"type": "boolean"
},
"key": {
"type": "string"
},
@@ -1701,6 +1705,9 @@
"label": {
"type": "string"
},
"locked": {
"type": "boolean"
},
"value": {
"type": "integer"
}
@@ -3345,7 +3352,7 @@
},
"/{vendor}/{plugin}/{controller}/schema/form": {
"get": {
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. 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. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).",
"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. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.",
"parameters": [
{
"description": "Vendor",

View File

@@ -1216,7 +1216,7 @@ export interface paths {
};
/**
* Admin form schema
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. 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. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).
* @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. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.
*/
get: {
parameters: {
@@ -4540,6 +4540,12 @@ export interface components {
[key: string]: components["schemas"]["cabana.MessageForms"];
};
"cabana.ListColumn": {
/**
* @description Invisible marks a column that is searched and sorted on the server but
* not shown: the admin renders no header or cell for it and list rows do
* not carry its value (columns.yaml invisible).
*/
invisible?: boolean;
key: string;
label: string;
relation?: string;
@@ -4723,6 +4729,7 @@ export interface components {
};
"cabana.RelationOption": {
label: string;
locked?: boolean;
value: number;
};
"cabana.RelationPanel": {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ChevronDown, X } from '@lucide/vue'
import { ChevronDown, Lock, X } from '@lucide/vue'
import { api } from '../../../api/client'
import type { RelationOption } from '../../../api/types'
import { t } from '../../../app/i18n'
@@ -15,6 +15,11 @@ import type { FieldControlProps } from '../control'
// Options are searched with a 300 ms debounce, 20 per page, with more loaded
// on scroll or through the "more" action. Labels come from meta.labels and
// from every options page seen.
// Phase 12.1 (UI-SPEC S6, D-07): an option or label the server marks locked
// cannot be added or removed by this administrator. A locked chip has no
// remove button, a locked option cannot be chosen, and a locked single value
// renders as the read-only box. The lock here is a display aid; the server
// refuses the change in any case.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | number[] | null] }>()
@@ -25,17 +30,34 @@ const mode = computed(() => (props.field.readOnly ? 'readonly' : props.field.mul
const listId = computed(() => `${props.controlId}-listbox`)
const known = ref<Record<number, string>>({})
watch(
() => props.labels,
(labels) => {
const next = { ...known.value }
for (const option of labels ?? []) {
next[option.value] = option.label
// Ids the server marked locked, in labels or in any options page seen.
const lockedIds = ref<Set<number>>(new Set())
function remember(rows: RelationOption[]): void {
const next = { ...known.value }
let locked: Set<number> | null = null
for (const option of rows) {
next[option.value] = option.label
if (option.locked && !lockedIds.value.has(option.value)) {
locked ??= new Set(lockedIds.value)
locked.add(option.value)
}
known.value = next
},
{ immediate: true },
)
}
known.value = next
if (locked) {
lockedIds.value = locked
}
}
watch(() => props.labels, (labels) => remember(labels ?? []), { immediate: true })
function isLocked(id: number | null): boolean {
return id !== null && lockedIds.value.has(id)
}
const noteId = computed(() => `${props.controlId}-locked-note`)
const hasLocked = computed(() => lockedIds.value.size > 0)
const described = computed(() => [props.describedBy, hasLocked.value ? noteId.value : ''].filter(Boolean).join(' '))
function asId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
@@ -106,11 +128,7 @@ async function fetchPage(nextPage: number, append: boolean): Promise<void> {
return
}
const rows = data?.data ?? []
const labels = { ...known.value }
for (const option of rows) {
labels[option.value] = option.label
}
known.value = labels
remember(rows)
options.value = append ? [...options.value, ...rows] : rows
page.value = data?.meta.page ?? nextPage
lastPage.value = data?.meta.last_page ?? nextPage
@@ -130,19 +148,27 @@ interface Entry {
label: string
value: number | null
muted: boolean
/** Shown, but not selectable by this administrator. */
locked: boolean
}
const entries = computed<Entry[]>(() => {
const out: Entry[] = []
if (mode.value === 'single' && props.field.emptyOption !== undefined) {
out.push({ key: 'empty', label: props.field.emptyOption, value: null, muted: true })
out.push({ key: 'empty', label: props.field.emptyOption, value: null, muted: true, locked: false })
}
const taken = new Set(selectedIds.value)
for (const option of options.value) {
if (mode.value === 'multiple' && taken.has(option.value)) {
continue
}
out.push({ key: `option-${option.value}`, label: option.label, value: option.value, muted: false })
out.push({
key: `option-${option.value}`,
label: option.label,
value: option.value,
muted: false,
locked: !!option.locked || isLocked(option.value),
})
}
return out
})
@@ -197,6 +223,9 @@ function onScroll(event: Event): void {
}
function choose(entry: Entry): void {
if (entry.locked) {
return
}
if (mode.value === 'multiple') {
if (entry.value !== null && !selectedIds.value.includes(entry.value)) {
emit('update:modelValue', [...selectedIds.value, entry.value])
@@ -208,12 +237,25 @@ function choose(entry: Entry): void {
}
function remove(id: number): void {
if (isLocked(id)) {
return
}
emit(
'update:modelValue',
selectedIds.value.filter((item) => item !== id),
)
}
/** The next selectable entry from index in direction step; locked ones are skipped. */
function move(from: number, step: 1 | -1): number {
for (let index = from + step; index >= 0 && index < entries.value.length; index += step) {
if (!entries.value[index]!.locked) {
return index
}
}
return from
}
function onKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowDown':
@@ -221,12 +263,12 @@ function onKeydown(event: KeyboardEvent): void {
if (!open.value) {
openList()
} else {
active.value = Math.min(active.value + 1, entries.value.length - 1)
active.value = move(active.value, 1)
}
break
case 'ArrowUp':
event.preventDefault()
active.value = Math.max(active.value - 1, 0)
active.value = move(active.value < 0 ? entries.value.length : active.value, -1)
break
case 'Enter':
if (open.value) {
@@ -244,8 +286,12 @@ function onKeydown(event: KeyboardEvent): void {
}
break
case 'Backspace':
if (mode.value === 'multiple' && query.value === '' && selectedIds.value.length > 0) {
remove(selectedIds.value[selectedIds.value.length - 1]!)
// The last unlocked chip goes; with only locked chips nothing happens.
if (mode.value === 'multiple' && query.value === '') {
const last = [...selectedIds.value].reverse().find((id) => !isLocked(id))
if (last !== undefined) {
remove(last)
}
}
break
}
@@ -277,6 +323,22 @@ onBeforeUnmount(() => {
{{ readOnlyText }}
</div>
<template v-else-if="mode === 'single' && isLocked(singleId)">
<div
:id="controlId"
data-relation-locked
:aria-describedby="described || undefined"
class="flex min-h-input items-center justify-between gap-2 rounded-control border border-border bg-subtle px-3.5"
>
<span class="min-w-0 [overflow-wrap:anywhere]">{{ singleText }}</span>
<Lock :size="14" class="shrink-0 text-muted" aria-hidden="true" />
</div>
<p :id="noteId" data-locked-note class="mt-1.5 flex items-center gap-2 text-[13px] text-muted">
<Lock :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.form.locked_note') }}</span>
</p>
</template>
<div v-else class="relative" :data-relation="mode">
<div
v-if="mode === 'multiple'"
@@ -295,7 +357,12 @@ onBeforeUnmount(() => {
>{{ initials(labelOf(id)) }}</span
>
<span class="font-semibold">{{ labelOf(id) }}</span>
<span v-if="isLocked(id)" data-chip-locked class="flex size-6 items-center justify-center text-muted">
<Lock :size="14" aria-hidden="true" />
<span class="sr-only">{{ t('backend::lang.form.locked_item', { name: labelOf(id) }) }}</span>
</span>
<button
v-else
type="button"
:aria-label="t('backend::lang.form.remove_item', { name: labelOf(id) })"
class="flex size-6 items-center justify-center rounded-full text-muted hover:bg-hover hover:text-text"
@@ -315,7 +382,7 @@ onBeforeUnmount(() => {
:aria-activedescendant="open && active >= 0 ? optionId(active) : undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:aria-describedby="described || undefined"
:value="query"
:placeholder="t('backend::lang.form.add_item')"
class="h-[30px] min-w-[120px] flex-1 border-0 bg-transparent px-2 focus-visible:shadow-none"
@@ -338,7 +405,7 @@ onBeforeUnmount(() => {
:aria-activedescendant="open && active >= 0 ? optionId(active) : undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:aria-describedby="described || undefined"
:value="open ? query : singleText"
:placeholder="open ? singleText || t('backend::lang.form.select_placeholder') : t('backend::lang.form.select_placeholder')"
:class="[
@@ -372,11 +439,17 @@ onBeforeUnmount(() => {
role="option"
:data-value="entry.value ?? 'empty'"
:aria-selected="mode === 'single' && entry.value === singleId ? 'true' : 'false'"
:class="[entry.muted ? 'text-muted' : 'text-text', index === active ? 'bg-hover' : '']"
class="flex h-10 cursor-pointer items-center rounded-control px-3 hover:bg-hover"
:aria-disabled="entry.locked ? 'true' : undefined"
:class="[
entry.muted || entry.locked ? 'text-muted' : 'text-text',
entry.locked ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-hover',
index === active && !entry.locked ? 'bg-hover' : '',
]"
class="flex h-10 items-center justify-between gap-2 rounded-control px-3"
@mousedown.prevent="choose(entry)"
>
{{ entry.label }}
<span class="min-w-0 truncate">{{ entry.label }}</span>
<Lock v-if="entry.locked" :size="14" class="shrink-0" aria-hidden="true" />
</li>
<li v-if="loading" role="presentation" class="px-3 py-2 text-[13px] text-muted">
{{ t('backend::lang.form.loading_options') }}
@@ -399,5 +472,9 @@ onBeforeUnmount(() => {
</button>
</li>
</ul>
<p v-if="hasLocked" :id="noteId" data-locked-note class="mt-1.5 flex items-center gap-2 text-[13px] text-muted">
<Lock :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ t('backend::lang.form.locked_note') }}</span>
</p>
</div>
</template>

View File

@@ -187,8 +187,12 @@ function stateTextClass(states: string[]): string[] {
]
}
// An invisible column is searched on the server and never rendered (the
// rows do not carry it either).
const visibleColumns = computed(() => props.columns.filter((column) => !column.invisible))
const span = computed(() =>
Math.max(props.columns.length + (props.selectable ? 1 : 0) + (props.trailing ? 1 : 0), 1),
Math.max(visibleColumns.value.length + (props.selectable ? 1 : 0) + (props.trailing ? 1 : 0), 1),
)
function openFrom(event: MouseEvent, row: AdminRecord): void {
@@ -220,7 +224,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
</button>
</th>
<th
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.key"
scope="col"
:aria-sort="canSort(column) ? (sortState(column) ?? 'none') : undefined"
@@ -249,7 +253,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
<td v-if="selectable" class="pl-5">
<span class="block size-[18px] rounded-checkbox bg-skel" />
</td>
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
<td v-for="column in visibleColumns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
<span :class="widths[(n - 1) % widths.length]" class="block h-3 rounded-[6px] bg-skel" />
</td>
<td v-if="trailing" class="pr-5" />
@@ -290,7 +294,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
</button>
</td>
<td
v-for="(column, columnIndex) in columns"
v-for="(column, columnIndex) in visibleColumns"
:key="column.key"
:class="cellClass(column, columnIndex, statesOf(row))"
class="px-3.5 first:pl-5 last:pr-5"

View File

@@ -5,6 +5,8 @@
"fields": [
{ "name": "name", "type": "text", "label": "Name", "span": "left" },
{ "name": "email", "type": "text", "label": "Email", "span": "right" },
{ "name": "team", "type": "relation", "label": "Team", "nameFrom": "name", "emptyOption": "No team" },
{ "name": "tags", "type": "relation", "label": "Tags", "nameFrom": "name", "multiple": true },
{ "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"] },

View File

@@ -14,7 +14,8 @@
"assets": { "scripts": [], "styles": [] },
"columns": [
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
{ "key": "email", "label": "Email", "searchable": true, "sortable": true }
{ "key": "email", "label": "Email", "searchable": true, "sortable": true, "invisible": true },
{ "key": "slug", "label": "Slug", "searchable": false, "sortable": true }
],
"filters": [],
"rowActions": [],

View File

@@ -1,8 +1,8 @@
{
"data": [
{ "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" },
{ "id": 2, "name": "Bob Stone", "email": "bob@example.test" },
{ "id": 3, "name": "Cy Young", "email": "cy@example.test" }
{ "id": 1, "name": "Ada Lovelace", "slug": "ada-lovelace" },
{ "id": 2, "name": "Bob Stone", "slug": "bob-stone" },
{ "id": 3, "name": "Cy Young", "slug": "cy-young" }
],
"meta": {
"page": 1,

View File

@@ -1,7 +1,7 @@
{
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "slug": "ada-lovelace", "joined_ip": "203.0.113.7", "permissions": { "legacy.code": 1, "posts.edit": 1, "reports.export": 1 } },
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "team": 2, "tags": [5, 6], "slug": "ada-lovelace", "joined_ip": "203.0.113.7", "permissions": { "legacy.code": 1, "posts.edit": 1, "reports.export": 1 } },
"meta": {
"labels": {},
"labels": { "team": [{ "value": 2, "label": "Home" }], "tags": [{ "value": 5, "label": "staff", "locked": true }, { "value": 6, "label": "news" }] },
"actions": [
{ "name": "activate", "label": "Activate" },
{ "name": "reinstate", "label": "Reinstate", "confirm": "Lift the ban on this person?" }

View File

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

View File

@@ -1,13 +1,21 @@
// Phase 12.1 form seams in the admin SPA: the password field (UI-SPEC S7,
// D-19), preset fields (D-27 G7) and the permission editor (UI-SPEC S5,
// D-16). Fixtures are neutral acme.roster.* data;
// D-19), preset fields (D-27 G7), the permission editor (UI-SPEC S5, D-16),
// locked relation options (UI-SPEC S6, D-07) and invisible list columns
// (D-27 G6). 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'
import {
clone,
langFixture,
rosterFormSchemaFixture,
rosterListFixture,
rosterListSchemaFixture,
rosterRecordFixture,
} from '../fixtures/typed'
import { API, keydown, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const LIST = `${API}/acme/roster/people`
const RECORD = `${LIST}/1`
@@ -22,6 +30,9 @@ const strings = {
'backend::lang.permissioneditor.locked': { other: 'You cannot change this permission.' },
'backend::lang.permissioneditor.empty': { other: 'No permissions are defined yet.' },
'backend::lang.permissioneditor.other': { other: 'Other' },
'backend::lang.form.locked_item': { other: 'Locked: :name' },
'backend::lang.form.locked_note': { other: 'Items marked with a lock need an additional permission to change.' },
'backend::lang.form.remove_item': { other: 'Remove: :name' },
}
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
@@ -390,3 +401,159 @@ describe('permission editor (UI-SPEC S5, D-16)', () => {
expect(shown.find('[data-permission="posts.edit"] [data-state="checked"]').text()).toBe('Allow')
})
})
describe('locked relation options (UI-SPEC S6, D-07)', () => {
const NOTE = 'Items marked with a lock need an additional permission to change.'
const tagOptions = {
data: [
{ value: 5, label: 'staff', locked: true },
{ value: 6, label: 'news' },
{ value: 7, label: 'beta' },
{ value: 8, label: 'owners', locked: true },
],
meta: { page: 1, per_page: 20, total: 4, last_page: 1 },
}
const teamOptions = {
data: [
{ value: 2, label: 'Home' },
{ value: 3, label: 'Board', locked: true },
{ value: 4, label: 'Guests' },
],
meta: { page: 1, per_page: 20, total: 3, last_page: 1 },
}
function relationRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return routes({
[`GET ${LIST}/fields/tags/options`]: { body: tagOptions },
[`GET ${LIST}/fields/team/options`]: { body: teamOptions },
...overrides,
})
}
const field = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-field="${name}"]`)
const chips = (wrapper: VueWrapper) => field(wrapper, 'tags').findAll('[data-chip]')
const option = (wrapper: VueWrapper, name: string, value: number) => field(wrapper, name).find(`[role="option"][data-value="${value}"]`)
async function openOptions(wrapper: VueWrapper, name: string): Promise<void> {
await input(wrapper, name).trigger('click')
await flushPromises()
}
it('shows a locked chip without a remove button and keeps it on Backspace', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people/1', relationRoutes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }), { attach: true })
const [staff, news] = chips(wrapper)
expect(staff!.text()).toContain('staff')
expect(staff!.find('button').exists()).toBe(false)
expect(staff!.find('[data-chip-locked] svg').attributes('aria-hidden')).toBe('true')
expect(staff!.find('.sr-only').text()).toBe('Locked: staff')
expect(news!.find('button').attributes('aria-label')).toBe('Remove: news')
// Backspace in the empty search removes the last unlocked chip, then
// does nothing: only the locked chip is left.
keydown(input(wrapper, 'tags').element, 'Backspace')
await flushPromises()
expect(chips(wrapper).map((chip) => chip.find('.font-semibold').text())).toEqual(['staff'])
keydown(input(wrapper, 'tags').element, 'Backspace')
await flushPromises()
expect(chips(wrapper).map((chip) => chip.find('.font-semibold').text())).toEqual(['staff'])
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.tags).toEqual([5])
})
it('shows the note under the control and names it in aria-describedby', async () => {
const { wrapper } = await mountApp('/acme/roster/people/1', relationRoutes())
const note = field(wrapper, 'tags').find('[data-locked-note]')
expect(note.text()).toBe(NOTE)
expect(note.classes()).toEqual(expect.arrayContaining(['flex', 'items-center', 'gap-2', 'text-[13px]', 'text-muted']))
expect(input(wrapper, 'tags').attributes('aria-describedby')).toContain(note.attributes('id'))
// A field with no locked option seen has no note.
expect(field(wrapper, 'team').find('[data-locked-note]').exists()).toBe(false)
})
it('does not let a locked option be chosen by click, Enter or the arrow keys', async () => {
const { wrapper } = await mountApp('/acme/roster/people/1', relationRoutes(), { attach: true })
await openOptions(wrapper, 'tags')
// staff (5) and news (6) are selected, so the list offers beta and the
// locked owners.
const owners = option(wrapper, 'tags', 8)
expect(owners.attributes('aria-disabled')).toBe('true')
expect(owners.classes()).toEqual(expect.arrayContaining(['text-muted', 'cursor-not-allowed']))
expect(owners.classes()).not.toContain('hover:bg-hover')
expect(owners.find('svg').exists()).toBe(true)
expect(option(wrapper, 'tags', 7).attributes('aria-disabled')).toBeUndefined()
await owners.trigger('mousedown')
expect(chips(wrapper)).toHaveLength(2)
// Arrow keys stop on beta and never reach owners; Enter chooses beta.
const search = input(wrapper, 'tags')
keydown(search.element, 'ArrowDown')
keydown(search.element, 'ArrowDown')
keydown(search.element, 'ArrowDown')
await flushPromises()
expect(search.attributes('aria-activedescendant')).toBe(option(wrapper, 'tags', 7).attributes('id'))
keydown(search.element, 'Enter')
await flushPromises()
expect(chips(wrapper).map((chip) => chip.find('.font-semibold').text())).toEqual(['staff', 'news', 'beta'])
})
it('offers a locked option of a single field as disabled and shows the note once it was seen', async () => {
const { wrapper } = await mountApp('/acme/roster/people/1', relationRoutes(), { attach: true })
await openOptions(wrapper, 'team')
const board = option(wrapper, 'team', 3)
expect(board.attributes('aria-disabled')).toBe('true')
await board.trigger('mousedown')
await flushPromises()
expect(input(wrapper, 'team').element.value).not.toBe('Board')
expect(field(wrapper, 'team').find('[data-locked-note]').text()).toBe(NOTE)
await openOptions(wrapper, 'team')
await option(wrapper, 'team', 4).trigger('mousedown')
await flushPromises()
expect(input(wrapper, 'team').element.value).toBe('Guests')
})
it('renders a locked single value as the read-only box with a lock', async () => {
const record = clone(rosterRecordFixture)
record.meta.labels.team = [{ value: 2, label: 'Home', locked: true }]
const { wrapper, calls } = await mountApp('/acme/roster/people/1', relationRoutes({ [`GET ${RECORD}`]: { body: record }, [`PUT ${RECORD}`]: { body: record } }))
const box = field(wrapper, 'team').find('[data-relation-locked]')
expect(box.text()).toBe('Home')
expect(box.classes()).toEqual(expect.arrayContaining(['min-h-input', 'bg-subtle']))
expect(box.find('svg').attributes('aria-hidden')).toBe('true')
expect(field(wrapper, 'team').find('input').exists()).toBe(false)
expect(field(wrapper, 'team').find('[data-locked-note]').text()).toBe(NOTE)
// The value is still part of the save body, unchanged.
await save(wrapper)
const [request] = requestsTo(calls, 'PUT', RECORD)
const body = (await request!.clone().json()) as Record<string, unknown>
expect(body.team).toBe(2)
})
it('keeps chips without remove buttons on the preview', async () => {
const { wrapper } = await mountApp('/acme/roster/people/1/preview', relationRoutes())
await flushPromises()
const shown = wrapper.find('[data-preview-field="tags"]')
expect(shown.findAll('[data-chip]').map((chip) => chip.text())).toEqual(['Sstaff', 'Nnews'])
expect(shown.find('button').exists()).toBe(false)
})
})
describe('invisible list column (D-27 G6)', () => {
it('renders no header and no cell for it and keeps the first visible column first', async () => {
const { wrapper } = await mountApp('/acme/roster/people', {
[`GET ${LIST}/schema/list`]: { body: rosterListSchemaFixture },
[`GET ${LIST}`]: { body: rosterListFixture },
})
await wait(10)
await flushPromises()
const headers = wrapper.findAll('thead th').map((th) => th.text()).filter(Boolean)
expect(headers).toEqual(['Name', 'Slug'])
expect(wrapper.find('thead').text()).not.toContain('Email')
const row = wrapper.findAll('tbody tr').find((tr) => tr.text().includes('Ada Lovelace'))!
expect(row.findAll('td:not([data-select])').map((td) => td.text())).toEqual(['Ada Lovelace', 'ada-lovelace'])
})
})

View File

@@ -423,6 +423,7 @@ list: ~/plugins/acme/roster/models/person/columns.yaml
modelClass: Person
title: acme.roster::lang.people.title
recordUrl: acme/roster/people/preview/:id
filter: config_filter.yaml
recordsPerPage: 20
showCheckboxes: true
toolbar:

View File

@@ -88,6 +88,8 @@ for _, f := range list.Filters {
`type: date` and `type: time` are for `lagoon.Date` and `lagoon.TimeOfDay` columns (a `DATE` and a `TIME` column): the admin shows the stored `2026-10-02` or `14:30:00` as it is, with no time zone conversion, where `type: datetime` would read a date as midnight UTC and could show the previous day. A struct that stores itself in one column (it implements `sql.Scanner` or `driver.Valuer`, as both types do) is a plain column, never a relation.
`invisible: true` keeps a column out of the table while it stays part of the list: the search covers it when it is `searchable`, it can still be sorted by, and the schema reports it with `invisible`. The admin SPA renders no header and no cell for it, and the rows of a list response do not carry its value. Use it for a column administrators search by but do not need to read, such as an e-mail address next to a name.
Search runs over the searchable columns only; sort accepts only sortable columns. A request that names any other column is refused, never passed to SQL.
## Filters
@@ -110,7 +112,9 @@ scopes:
|------|---------|
| `switch` | A boolean `column`. With `options`, the two values are the options' keys, kept as typed scalars. |
| `daterange` | A date or timestamp `column` between two dates. |
| scope (a `scope` key with `modelClass` and `nameFrom`) | Records by a model-backed choice. The model implements `pact.FilterScope`, whose `FilterScopes` lists the scope names it answers, and `pact.FilterOptions` for the choices the SPA loads. |
| scope (a `scope` key with `modelClass` and `nameFrom`) | Records by a model-backed choice. The model implements `pact.FilterScope`, whose `FilterScopes` lists the scope names it answers. `pact.FilterOptions` serves the choices the SPA loads; the controller or the model implements it. |
The choices of a scope filter are asked from the controller first, when it implements `pact.FilterOptions`, and from the model otherwise. A model is a fresh value with no database handle, so choices that are rows of a table (the groups a user can be filtered by, for example) belong on the controller, which can hold the handle it was built with. The scope that filters the query is always the model's `pact.FilterScope`. A scope filter with no `pact.FilterOptions` on either stops the start-up.
WinterCMS `conditions` SQL fragments are not supported; use a `column` or a scope the model implements. A scope filter whose name the model does not list in `FilterScopes` stops the start-up, so request text can never select another method.

View File

@@ -23,6 +23,63 @@ The controller implements `cabana.FieldRelationProvider` and returns a `cabana.F
The SPA loads the choices, paginated, from `.../fields/{field}/options`, and every record response carries the display labels of the linked records. A controller that implements `pact.RelationExtendOptionsQuery` narrows the choices, and the same scoped query rechecks the submitted IDs on save, so a record it does not offer cannot be attached.
### Writable foreign keys
Some column names are never written from a request body, whatever the form declares: `id`, `created_at`, `updated_at`, `deleted_at`, `owner_id`, `user_id`, `collection_id`, `organisation_id`, `organization_id`, `scope_id`, `role_id`, `permissions`, `is_superuser`, `is_system`, `is_activated` and `password`. A belongsTo field whose `ForeignKey` is one of them is therefore read-only: it shows the linked record's label and has no options endpoint. A contract that sets `WritableForeignKey` makes this one field writable:
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminFieldRelations
// AdminFieldRelations binds the form's two relation fields. organisation_id
// is a protected column, so the team field would be read-only; the contract
// opts in to writing it through this field.
func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{
Field: "team",
Kind: "belongsTo",
NewRelated: func() any { return &Team{} },
ForeignKey: "organisation_id",
WritableForeignKey: true,
}, {
Field: "groups",
Kind: "belongsToMany",
NewRelated: func() any { return &Group{} },
NewPivot: func() any { return &MemberGroup{} },
ParentForeignKey: "member_id",
RelatedForeignKey: "group_id",
}}
}
```
The protection itself does not change. A plain field named `organisation_id` is still dropped from a body, and the id submitted through the relation field is still rechecked against the scoped options query, so only a record the controller offers can be set. `WritableForeignKey` on a belongsToMany contract stops the start-up.
### Locked options
A controller may let an administrator see a choice without letting them change it, for example membership of a group that grants access. It implements `cabana.RelationLockProvider` and returns, per field and per request, a `cabana.RelationLock`: the related ids that are locked for the signed-in administrator, and a message.
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminRelationLocks
// AdminRelationLocks names the groups an administrator without
// acme.roster.manage may not put a member into or take a member out of. Inside
// a save the ids are read with the save's transaction.
func (c MembersController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
principal, _ := bouncer.User(ctx)
if field != "groups" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
db := c.DB
if tx, ok := cabana.TxFromContext(ctx); ok {
db = tx
}
lock := cabana.RelationLock{Message: "acme.roster::lang.members.group_locked"}
err := db.WithContext(ctx).Model(&Group{}).Where("code = ?", "staff").Pluck("id", &lock.IDs).Error
return lock, err
}
```
- The options endpoint and the labels of every record response mark those records with `locked: true`. The admin SPA shows a locked chip without a remove button, does not let a locked option be chosen, and prints a short note under the field. A response without locks has no `locked` key.
- The server enforces the lock; the flag is only a display aid. A create or update is checked inside its transaction, after the scope check and before any row is written. For a belongsToMany field the locked ids among the record's current links and among the submitted ids must be the same set, so a locked record can be neither added nor removed while other links change freely. For a belongsTo field a change is refused when the current or the submitted record is locked.
- A refused save is answered 403 `forbidden` with the lock's `Message` (a translation key or text), also as a message on the field, and nothing is written. With an empty `Message` the admin shows its own text.
- A save that does not send the field is not checked: it changes nothing. A controller without the provider behaves as before.
- The provider is asked with the request context: `bouncer.User(ctx)` is the administrator, and inside a save `cabana.TxFromContext(ctx)` is the transaction.
## Relation managers
A `type: relation-manager` field embeds a relation manager in the form. Its `relation` key names an entry in the controller's `config_relation.yaml`, which describes the two panels: the linked records (`view`) and the candidates shown when linking (`manage`):

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="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-CvMS0tdW.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CXbMmgdh.css">
<script type="module" crossorigin src="./assets/index-CbohsCp4.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CqzF_Nki.css">
</head>
<body>
<div id="app"></div>

View File

@@ -13,11 +13,12 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Boot-time schema compilation: `cabana.CompileList` and `cabana.CompileForm` read a controller's YAML from the plugin's embedded tree, check that `modelClass` matches the controller's model name, and cache a locale-neutral schema. Each request gets a translated copy (`cabana.ListSchema.Localize`, `cabana.FormSchema.Localize`, `cabana.RelationSchema.Localize`) through [phrasebook](../phrasebook/README.md), with CLDR plural forms for the SPA's messages.
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count.
- Invisible columns and filter choices: `invisible: true` in `columns.yaml` keeps a column searchable and sortable on the server while the schema flags it, the SPA does not render it and list rows do not carry it. The choices of a scope filter are served by the controller when it implements `pact.FilterOptions`, else by the model.
- 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.
- 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.
@@ -200,7 +201,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. |
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field. |
| `cabana.RelationLock` | The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403. |
| `cabana.RelationLockProvider` | Controller capability that returns the `cabana.RelationLock` of a relation field per request; enforced on create and update. |
| `cabana.AdminRelationContractProvider` / `cabana.RelationContract` | Controller-supplied bindings for relation managers. |
| `cabana.RelationBelongsToMany` / `cabana.RelationHasMany` | The two `RelationContract.Kind` values; an empty `Kind` is a belongsToMany. |
| `cabana.AdminRelationChildCreate` / `cabana.AdminRelationChildShow` / `cabana.AdminRelationChildUpdate` / `cabana.AdminRelationChildDelete` / `cabana.AdminRelationPivotShow` / `cabana.AdminRelationPivotUpdate` | Swag annotations of the relation child and pivot routes. |

View File

@@ -262,7 +262,7 @@ func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
//
// @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. 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. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1).
// @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. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.
// @Tags admin
// @Produce json
// @Security BackendBearer

View File

@@ -651,6 +651,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err := checkRelationScope(ctx, tx, cc, relations); err != nil {
return err
}
// D-07: a change to related ids the controller locks for this
// administrator is refused before any row is written.
if err := checkRelationLocks(ctx, tx, cc, target, relations); err != nil {
return err
}
if err := assignBelongsTo(cc, target, relations); err != nil {
return err
}

View File

@@ -10,6 +10,7 @@ import (
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Member is the model behind the acme.roster members controller. Password
@@ -21,6 +22,26 @@ type Member struct {
Password string `gorm:"column:password" json:"-"`
// Permissions is a JSON object of permission code to value.
Permissions string `gorm:"column:permissions"`
// OrganisationID is a protected column: the form writes it only through
// the team relation field.
OrganisationID *uint `gorm:"column:organisation_id"`
}
// Team is what a member belongs to; Group is what a member is put into.
type Team struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
type Group struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Code string `gorm:"column:code"`
}
type MemberGroup struct {
MemberID uint `gorm:"column:member_id;primaryKey"`
GroupID uint `gorm:"column:group_id;primaryKey"`
}
func (Member) TableName() string { return "acme_roster_members" }
@@ -34,8 +55,11 @@ func (Member) Rules() map[string]string {
}
// MembersController is an admin controller whose form has fields that are
// not columns and rules of its own.
type MembersController struct{}
// not columns and rules of its own. DB is the application's database handle,
// for reads outside a save.
type MembersController struct {
DB *gorm.DB
}
var (
_ pact.AdminController = MembersController{}
@@ -46,6 +70,8 @@ var (
_ pact.FormBeforeUpdate = MembersController{}
_ cabana.PermissionEditorProvider = MembersController{}
_ cabana.FieldRelationProvider = MembersController{}
_ cabana.RelationLockProvider = MembersController{}
)
func (MembersController) ID() string { return "acme.roster.members" }
@@ -128,6 +154,43 @@ func (MembersController) AdminSetPermissionValues(_ context.Context, field strin
return nil
}
// AdminFieldRelations binds the form's two relation fields. organisation_id
// is a protected column, so the team field would be read-only; the contract
// opts in to writing it through this field.
func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{
Field: "team",
Kind: "belongsTo",
NewRelated: func() any { return &Team{} },
ForeignKey: "organisation_id",
WritableForeignKey: true,
}, {
Field: "groups",
Kind: "belongsToMany",
NewRelated: func() any { return &Group{} },
NewPivot: func() any { return &MemberGroup{} },
ParentForeignKey: "member_id",
RelatedForeignKey: "group_id",
}}
}
// AdminRelationLocks names the groups an administrator without
// acme.roster.manage may not put a member into or take a member out of. Inside
// a save the ids are read with the save's transaction.
func (c MembersController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
principal, _ := bouncer.User(ctx)
if field != "groups" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
db := c.DB
if tx, ok := cabana.TxFromContext(ctx); ok {
db = tx
}
lock := cabana.RelationLock{Message: "acme.roster::lang.members.group_locked"}
err := db.WithContext(ctx).Model(&Group{}).Where("code = ?", "staff").Pluck("id", &lock.IDs).Error
return lock, err
}
// hashPassword stands in for the application's password hasher.
func hashPassword(plain string) string {
sum := sha256.Sum256([]byte(plain))
@@ -157,6 +220,15 @@ func Example_formSeams() {
_ = ctl.AdminSetPermissionValues(ctx, "permissions", member, map[string]int{"posts.edit": 1, "posts.publish": -1})
values, _ := ctl.AdminPermissionValues(ctx, "permissions", member)
fmt.Println(member.Permissions, len(values))
// The relation fields: team writes a protected key, groups has locks.
for _, contract := range ctl.AdminFieldRelations() {
fmt.Println(contract.Field, contract.Kind, contract.WritableForeignKey)
}
// The team field has no locks; the groups field would read them from
// the database.
lock, err := ctl.AdminRelationLocks(ctx, "team")
fmt.Println(len(lock.IDs), err)
// Output:
// [password password_confirmation notify]
// create: required|between:8,255|confirmed
@@ -166,4 +238,7 @@ func Example_formSeams() {
// posts.publish false
// reports.export true
// {"posts.edit":1,"posts.publish":-1} 2
// team belongsTo true
// groups belongsToMany false
// 0 <nil>
}

View File

@@ -244,8 +244,8 @@ func validateFilter(ctl pact.AdminController, provider pact.FilterScope, filter
if !registered {
return fmt.Errorf("scope %s is not registered", filter.Scope)
}
if _, ok := provider.(pact.FilterOptions); !ok {
return fmt.Errorf("scope filter %s needs FilterOptions on the model to serve its choices (D-27)", filter.Name)
if filterOptionsProvider(ctl) == nil {
return fmt.Errorf("scope filter %s needs FilterOptions on the controller or the model to serve its choices (D-27)", filter.Name)
}
}
return nil
@@ -260,7 +260,8 @@ type FilterOption struct {
// filterOptions serves GET /{vendor}/{plugin}/{controller}/filters/{scope}/options
// for a declared scope filter of the controller's list. {scope} is the filter
// name (the filter[<name>] key); the model receives the filter's scope method.
// name (the filter[<name>] key); the controller, when it implements
// pact.FilterOptions, or else the model receives the filter's scope method.
func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
name := r.PathValue("scope")
@@ -277,8 +278,8 @@ func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) {
writeNotFound(w, r)
return
}
provider, ok := filterProvider(cc.Controller).(pact.FilterOptions)
if !ok || provider == nil {
provider := filterOptionsProvider(cc.Controller)
if provider == nil {
writeNotFound(w, r)
return
}
@@ -294,6 +295,19 @@ func (s *service) filterOptions(w http.ResponseWriter, r *http.Request) {
})
}
// filterOptionsProvider resolves who serves a scope filter's choices: the
// controller when it implements pact.FilterOptions (it can read them from the
// database), else the model that owns the scope.
func filterOptionsProvider(ctl pact.AdminController) pact.FilterOptions {
if provider, ok := ctl.(pact.FilterOptions); ok && provider != nil {
return provider
}
if provider, ok := filterProvider(ctl).(pact.FilterOptions); ok && provider != nil {
return provider
}
return nil
}
func filterProvider(ctl pact.AdminController) pact.FilterScope {
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {

View File

@@ -1016,6 +1016,10 @@ func projectRow(row any, controller pact.AdminController, cols []ListColumn) map
out["id"] = id.Interface()
}
for _, col := range cols {
// An invisible column is searched, never sent.
if col.Invisible {
continue
}
if col.Relation != "" {
if value, ok := relatedSelect(v, controller, col); ok {
out[col.Key] = value

View File

@@ -67,6 +67,7 @@ type columnDocument struct {
Type string `yaml:"type"`
Relation string `yaml:"relation"`
Select string `yaml:"select"`
Invisible bool `yaml:"invisible"`
}
// CompileListSchema compiles config_list.yaml and the columns.yaml it names.
@@ -265,6 +266,7 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
Type: typ,
Relation: spec.Relation,
Select: spec.Select,
Invisible: spec.Invisible,
})
}
return compiled, nil

View File

@@ -49,8 +49,11 @@ type rosterPerson struct {
Slug string `gorm:"column:slug"`
// Permissions is the permission editor's storage: a JSON object of code
// to value, or NULL.
Permissions *string `gorm:"column:permissions"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
Permissions *string `gorm:"column:permissions"`
// OrganisationID is a protected fill key: only the team relation field,
// whose contract sets WritableForeignKey, writes it.
OrganisationID *uint `gorm:"column:organisation_id"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (rosterPerson) TableName() string { return "roster_people" }
@@ -62,6 +65,42 @@ func (rosterPerson) Rules() map[string]string {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
// FilterScopes and FilterScope: the tagged filter keeps the people who carry
// one tag. Its choices come from the controller, which can read the tags.
func (rosterPerson) FilterScopes() []string { return []string{"tagged"} }
func (rosterPerson) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != "tagged" {
return db
}
return db.Where("roster_people.id IN (SELECT person_id FROM roster_person_tags WHERE tag_id = ?)", value)
}
// rosterTeam is the belongsTo target of the team field.
type rosterTeam struct {
ID uint `gorm:"column:id;primaryKey"`
Tenant string `gorm:"column:tenant"`
Name string `gorm:"column:name"`
}
func (rosterTeam) TableName() string { return "roster_teams" }
// rosterTag is the belongsToMany target of the tags field. The tag named
// staff is locked for an administrator without acme.roster.manage.
type rosterTag struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (rosterTag) TableName() string { return "roster_tags" }
type rosterPersonTag struct {
PersonID uint `gorm:"column:person_id;primaryKey"`
TagID uint `gorm:"column:tag_id;primaryKey"`
}
func (rosterPersonTag) TableName() string { return "roster_person_tags" }
// rosterHash is the fixture's stand-in for a password hash.
func rosterHash(plain string) string {
sum := sha256.Sum256([]byte(plain))
@@ -156,6 +195,12 @@ func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput {
type rosterPlugin struct {
spy *rosterSpy
fsys fs.FS
// db is the handle the controller reads filter choices and locked tags
// with outside a transaction.
db *gorm.DB
// relations, when set, rewrites the controller's relation contracts
// (boot tests).
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
}
func (rosterPlugin) ID() string { return "acme.roster" }
@@ -163,7 +208,7 @@ func (rosterPlugin) Requires() []string { return nil }
func (rosterPlugin) Register(*backpack.App) error { return nil }
func (rosterPlugin) Boot(*backpack.App) error { return nil }
func (p rosterPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{rosterController{spy: p.spy}}
return []pact.AdminController{rosterController{spy: p.spy, db: p.db, relations: p.relations}}
}
func (rosterPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
@@ -188,7 +233,74 @@ func (rosterPlugin) LangFS() fs.FS {
return out
}
type rosterController struct{ spy *rosterSpy }
type rosterController struct {
spy *rosterSpy
db *gorm.DB
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
}
// AdminFieldRelations: team writes the protected organisation_id through an
// explicit opt-in; tags is a plain belongsToMany.
func (c rosterController) AdminFieldRelations() []cabana.FieldRelationContract {
out := []cabana.FieldRelationContract{{
Field: "team", Kind: "belongsTo", NewRelated: func() any { return &rosterTeam{} },
ForeignKey: "organisation_id", WritableForeignKey: true,
}, {
Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &rosterTag{} },
NewPivot: func() any { return &rosterPersonTag{} }, ParentForeignKey: "person_id", RelatedForeignKey: "tag_id",
}}
if c.relations != nil {
out = c.relations(out)
}
return out
}
// RelationExtendOptionsQuery offers only the acme tenant's teams.
func (rosterController) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB {
if field == "team" {
return db.Where("tenant = ?", "acme")
}
return db
}
// handle is the save's transaction when there is one, else the plugin's
// database handle.
func (c rosterController) handle(ctx context.Context) *gorm.DB {
if tx, ok := cabana.TxFromContext(ctx); ok {
return tx
}
return c.db.WithContext(ctx)
}
// AdminRelationLocks locks the staff tag for an administrator without
// acme.roster.manage.
func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
principal, _ := bouncer.User(ctx)
if field != "tags" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
var ids []uint
if err := c.handle(ctx).Model(&rosterTag{}).Where("name = ?", "staff").Pluck("id", &ids).Error; err != nil {
return cabana.RelationLock{}, err
}
return cabana.RelationLock{IDs: ids, Message: "acme.roster::lang.people.tag_locked"}, nil
}
// FilterOptions serves the tagged filter's choices from the database.
func (c rosterController) FilterOptions(scope string) []pact.Option {
if scope != "tagged" || c.db == nil {
return nil
}
var tags []rosterTag
if err := c.db.Order("name").Find(&tags).Error; err != nil {
return nil
}
out := make([]pact.Option, len(tags))
for i, tag := range tags {
out[i] = pact.Option{Value: fmt.Sprint(tag.ID), Label: tag.Name}
}
return out
}
func (rosterController) ID() string { return "acme.roster.people" }
func (rosterController) ModelName() string { return "Person" }
@@ -496,9 +608,16 @@ type rosterEnv struct {
}
func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Helper()
return newRosterEnvWith(t, nil)
}
// newRosterEnvWith is newRosterEnv with the plugin adjusted by configure
// before it is assembled.
func newRosterEnvWith(t *testing.T, configure func(*rosterPlugin)) (*rosterEnv, *gorm.DB) {
t.Helper()
gdb := adminGorm(t)
models := []any{&rosterPerson{}}
models := []any{&rosterPerson{}, &rosterTeam{}, &rosterTag{}, &rosterPersonTag{}}
if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
@@ -537,7 +656,11 @@ func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Fatal(err)
}
spy := &rosterSpy{}
plugins := []party.Plugin{rosterPlugin{spy: spy}}
plugin := rosterPlugin{spy: spy, db: gdb}
if configure != nil {
configure(&plugin)
}
plugins := []party.Plugin{plugin}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
@@ -591,12 +714,18 @@ func rosterTree(t *testing.T, replace map[string]string) fstest.MapFS {
// rosterBoot activates the roster plugin over fsys and returns the boot error.
func rosterBoot(t *testing.T, fsys fs.FS) error {
t.Helper()
return rosterBootWith(t, rosterPlugin{spy: &rosterSpy{}, fsys: fsys})
}
// rosterBootWith activates one roster plugin value and returns the boot error.
func rosterBootWith(t *testing.T, plugin rosterPlugin) error {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{plugin})
return err
}

View File

@@ -7,10 +7,13 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// rosterFormHead is the smallest config_form.yaml of the roster fixture.
@@ -553,3 +556,275 @@ func TestPermissionEditorSmoke(t *testing.T) {
}
})
}
// rosterPivot reads a person's tag ids in ascending order.
func rosterPivot(t *testing.T, gdb *gorm.DB, person uint) []uint {
t.Helper()
ids := []uint{}
if err := gdb.Model(&rosterPersonTag{}).Where("person_id = ?", person).Order("tag_id").Pluck("tag_id", &ids).Error; err != nil {
t.Fatal(err)
}
return ids
}
// rosterSeed stores any fixture row.
func rosterSeed(t *testing.T, gdb *gorm.DB, row any) {
t.Helper()
if err := gdb.Create(row).Error; err != nil {
t.Fatal(err)
}
}
// TestWritableForeignKeySmoke drives FieldRelationContract.WritableForeignKey
// (D-27 G3; T-12.1-11): a belongsTo field over a protected foreign key is
// writable only with the explicit opt-in, and submitted ids still pass the
// scoped options query.
func TestWritableForeignKeySmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
home, away := rosterTeam{Tenant: "acme", Name: "Home"}, rosterTeam{Tenant: "other", Name: "Away"}
rosterSeed(t, gdb, &home)
rosterSeed(t, gdb, &away)
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tess", Active: true})
record := fmt.Sprintf("%s/%d", rosterPeople, id)
org := func(t *testing.T) uint {
t.Helper()
if person := rosterLoad(t, gdb, id); person.OrganisationID != nil {
return *person.OrganisationID
}
return 0
}
t.Run("the field is writable and offers the scoped options", func(t *testing.T) {
_, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"team","type":"relation","label":"Team","nameFrom":"name","emptyOption":"No team"}`) {
t.Fatalf("team field is read-only or missing: %s", raw)
}
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer")
if body := rec.Body.String(); !strings.Contains(body, `"label":"Home"`) || strings.Contains(body, "Away") {
t.Fatalf("options = %s", body)
}
})
t.Run("a save sets the protected key through the relation field only", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, record, fmt.Sprintf(`{"team":%d}`, home.ID), "bearer")
if org(t) != home.ID {
t.Fatalf("organisation_id = %d, want %d", org(t), home.ID)
}
body := rosterRecord(t, rec.Body.Bytes())
if body.Data["team"] != float64(home.ID) || len(body.Meta.Labels["team"]) != 1 || body.Meta.Labels["team"][0].Label != "Home" {
t.Fatalf("update answered data=%v labels=%v", body.Data["team"], body.Meta.Labels["team"])
}
if _, leaked := body.Data["organisation_id"]; leaked {
t.Fatalf("the response carries organisation_id: %v", body.Data)
}
// The scalar key stays protected: it is dropped from a body.
env.expect(t, http.StatusOK, http.MethodPut, record, fmt.Sprintf(`{"organisation_id":%d}`, away.ID), "bearer")
if org(t) != home.ID {
t.Fatalf("a scalar organisation_id was written: %d", org(t))
}
})
t.Run("an id outside the options scope is 422 and null clears the key", func(t *testing.T) {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, fmt.Sprintf(`{"team":%d}`, away.ID), "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "team", "The selected team is invalid.")
if org(t) != home.ID {
t.Fatalf("a refused save wrote organisation_id = %d", org(t))
}
env.expect(t, http.StatusOK, http.MethodPut, record, `{"team":null}`, "bearer")
if org(t) != 0 {
t.Fatalf("null did not clear organisation_id: %d", org(t))
}
})
t.Run("the same contract without the flag is read-only", func(t *testing.T) {
plain, plainDB := newRosterEnvWith(t, func(p *rosterPlugin) {
p.relations = func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract {
in[0].WritableForeignKey = false
return in
}
})
team := rosterTeam{Tenant: "acme", Name: "Home"}
rosterSeed(t, plainDB, &team)
person := rosterInsert(t, plainDB, rosterPerson{Tenant: "acme", Name: "Ro", Active: true})
_, raw := rosterFormSchema(t, plain, "bearer")
if !strings.Contains(raw, `"name":"team","type":"relation","label":"Team","nameFrom":"name","emptyOption":"No team","readOnly":true}`) {
t.Fatalf("team field is not read-only: %s", raw)
}
plain.expect(t, http.StatusNotFound, http.MethodGet, rosterPeople+"/fields/team/options", "", "bearer")
plain.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, person), fmt.Sprintf(`{"team":%d}`, team.ID), "bearer")
if stored := rosterLoad(t, plainDB, person); stored.OrganisationID != nil {
t.Fatalf("a read-only relation field wrote organisation_id = %d", *stored.OrganisationID)
}
})
t.Run("the flag is refused on belongsToMany", func(t *testing.T) {
err := rosterBootWith(t, rosterPlugin{spy: &rosterSpy{}, relations: func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract {
in[1].WritableForeignKey = true
return in
}})
const want = "field tags: WritableForeignKey is only valid on belongsTo"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want %q", err, want)
}
})
}
// TestRelationLockSmoke drives cabana.RelationLockProvider (D-27 G4, D-07;
// T-12.1-12): locked ids are flagged for the administrator they are locked
// for, and a create or update that changes the locked subset is 403 and
// writes nothing.
func TestRelationLockSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
staff, news, beta := rosterTag{Name: "staff"}, rosterTag{Name: "news"}, rosterTag{Name: "beta"}
for _, tag := range []*rosterTag{&staff, &news, &beta} {
rosterSeed(t, gdb, tag)
}
plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true})
member := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Member", Active: true})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: plain, TagID: news.ID})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: staff.ID})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: member, TagID: news.ID})
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
tags := func(ids ...uint) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = fmt.Sprint(id)
}
return `"tags":[` + strings.Join(parts, ",") + `]`
}
same := func(t *testing.T, person uint, want ...uint) {
t.Helper()
sort.Slice(want, func(i, j int) bool { return want[i] < want[j] })
if got := rosterPivot(t, gdb, person); fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("pivot of %d = %v, want %v", person, got, want)
}
}
const message = "You need an additional permission to change the staff tag."
refused := func(t *testing.T, method, rel, body string) {
t.Helper()
rec := env.expect(t, http.StatusForbidden, method, rel, body, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "tags", message)
var envelope cabana.ErrorEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil || envelope.Error.Message != message {
t.Fatalf("message = %q err=%v", envelope.Error.Message, err)
}
}
t.Run("options and labels carry locked for the limited admin only", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/tags/options", "", "limited")
if body := rec.Body.String(); !strings.Contains(body, fmt.Sprintf(`{"value":%d,"label":"staff","locked":true}`, staff.ID)) || strings.Count(body, `"locked"`) != 1 {
t.Fatalf("limited options = %s", body)
}
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/fields/tags/options", "", "bearer")
if strings.Contains(rec.Body.String(), `"locked"`) {
t.Fatalf("full admin options = %s", rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodGet, path(member), "", "limited")
if body := rec.Body.String(); !strings.Contains(body, fmt.Sprintf(`{"value":%d,"label":"staff","locked":true}`, staff.ID)) || strings.Count(body, `"locked"`) != 1 {
t.Fatalf("limited labels = %s", body)
}
rec = env.expect(t, http.StatusOK, http.MethodGet, path(member), "", "bearer")
if strings.Contains(rec.Body.String(), `"locked"`) {
t.Fatalf("full admin labels = %s", rec.Body.String())
}
})
t.Run("adding or removing a locked id on update is 403 and the pivot is unchanged", func(t *testing.T) {
refused(t, http.MethodPut, path(plain), `{"name":"Sneaky",`+tags(news.ID, staff.ID)+`}`)
same(t, plain, news.ID)
if person := rosterLoad(t, gdb, plain); person.Name != "Plain" {
t.Fatalf("a refused save renamed the person: %q", person.Name)
}
refused(t, http.MethodPut, path(member), `{`+tags(news.ID)+`}`)
refused(t, http.MethodPut, path(member), `{`+tags()+`}`)
same(t, member, staff.ID, news.ID)
})
t.Run("creating a record with a locked id is 403 and no row is created", func(t *testing.T) {
refused(t, http.MethodPost, rosterPeople, `{"name":"Smuggled","password":"long-enough-1","password_confirmation":"long-enough-1",`+tags(staff.ID)+`}`)
var count int64
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("name = ?", "Smuggled").Count(&count).Error; err != nil || count != 0 {
t.Fatalf("rows named Smuggled = %d err=%v", count, err)
}
})
t.Run("a change that leaves the locked subset alone passes", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, path(member), `{`+tags(staff.ID, beta.ID)+`}`, "limited")
same(t, member, staff.ID, beta.ID)
if !strings.Contains(rec.Body.String(), `"locked":true`) {
t.Fatalf("update response does not flag the locked label: %s", rec.Body.String())
}
env.expect(t, http.StatusOK, http.MethodPut, path(plain), `{`+tags(beta.ID)+`}`, "limited")
same(t, plain, beta.ID)
// A save that does not send the field is not checked.
env.expect(t, http.StatusOK, http.MethodPut, path(member), `{"name":"Member B"}`, "limited")
same(t, member, staff.ID, beta.ID)
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1",`+tags(news.ID)+`}`, "limited")
created, _ := rosterRecord(t, rec.Body.Bytes()).Data["id"].(float64)
same(t, uint(created), news.ID)
})
t.Run("the full admin may change the locked id", func(t *testing.T) {
env.expect(t, http.StatusOK, http.MethodPut, path(plain), `{`+tags(staff.ID)+`}`, "bearer")
same(t, plain, staff.ID)
env.expect(t, http.StatusOK, http.MethodPut, path(member), `{`+tags()+`}`, "bearer")
same(t, member)
})
}
// TestInvisibleColumnSmoke drives the columns.yaml invisible key (D-27 G6):
// the column is flagged in the schema, searched on the server and left out of
// the rows.
func TestInvisibleColumnSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@hidden.example.test", Active: true})
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", Email: "bob@example.test", Active: true})
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
if raw := rec.Body.String(); !strings.Contains(raw, `{"key":"email","label":"Email","searchable":true,"sortable":true,"invisible":true}`) || strings.Count(raw, `"invisible"`) != 1 {
t.Fatalf("list schema = %s", raw)
}
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople, "", "bearer")
if raw := rec.Body.String(); strings.Contains(raw, "email") || strings.Contains(raw, "example.test") || !strings.Contains(raw, `"name":"Ada"`) {
t.Fatalf("rows carry the invisible column: %s", raw)
}
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?search=hidden.example", "", "bearer")
if raw := rec.Body.String(); !strings.Contains(raw, `"name":"Ada"`) || strings.Contains(raw, `"name":"Bob"`) || strings.Contains(raw, "hidden.example") {
t.Fatalf("search by the invisible column = %s", raw)
}
// It still sorts on the server.
rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?sort=email&dir=desc", "", "bearer")
if raw := rec.Body.String(); strings.Index(raw, `"name":"Bob"`) > strings.Index(raw, `"name":"Ada"`) {
t.Fatalf("sort by the invisible column = %s", raw)
}
}
// TestFilterOptionsController checks that a controller implementing
// pact.FilterOptions serves a scope filter's choices before the model, so the
// choices can come from the database. The model-only path is covered by
// TestPhase10FilterOptions.
func TestFilterOptionsController(t *testing.T) {
env, gdb := newRosterEnv(t)
news, beta := rosterTag{Name: "news"}, rosterTag{Name: "beta"}
rosterSeed(t, gdb, &news)
rosterSeed(t, gdb, &beta)
tagged := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Tagged", Active: true})
rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
rosterSeed(t, gdb, &rosterPersonTag{PersonID: tagged, TagID: news.ID})
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/filters/tagged/options", "", "bearer")
want := fmt.Sprintf(`"data":[{"value":"%d","label":"beta"},{"value":"%d","label":"news"}]`, beta.ID, news.ID)
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("options = %s, want %s", rec.Body.String(), want)
}
// The scope itself is still the model's.
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s?filter[tagged]=%d", rosterPeople, news.ID), "", "bearer")
if raw := rec.Body.String(); !strings.Contains(raw, `"name":"Tagged"`) || strings.Contains(raw, `"name":"Bare"`) {
t.Fatalf("filtered list = %s", raw)
}
// The model does not implement FilterOptions: without the controller's
// the filter would not compile.
if _, ok := any(rosterPerson{}).(pact.FilterOptions); ok {
t.Fatal("the fixture model serves filter options itself")
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"net/http"
"reflect"
@@ -39,6 +40,31 @@ type FieldRelationContract struct {
// means the field's nameFrom, mapped through pact.ListRelationColumnMapper
// when the controller implements it.
LabelColumn string
// WritableForeignKey declares a belongsTo foreign key that is a protected
// fill key writable through this relation field.
WritableForeignKey bool
}
// RelationLock names the related records of one relation field that the
// requesting administrator may not add or remove. IDs are related primary
// keys. Message is a phrase key or text for the 403 a refused save answers;
// it may be empty, and the admin then shows its own text.
type RelationLock struct {
IDs []uint
Message string
}
// RelationLockProvider is implemented by an admin controller that locks some
// choices of its `type: relation` fields for some administrators (D-07). The
// framework asks it per request with the request context: the principal is
// read with bouncer.User, and inside a save the write transaction with
// TxFromContext. The lock is enforced on save: a create or update whose
// submitted value adds or removes a locked id, or replaces a belongsTo value
// that is locked or by one that is locked, is answered 403 and writes
// nothing. The `locked` flag on options and labels is a display aid only. A
// field with no locks returns the zero RelationLock.
type RelationLockProvider interface {
AdminRelationLocks(ctx context.Context, field string) (RelationLock, error)
}
// FieldRelationProvider is implemented by admin controllers whose form
@@ -48,10 +74,13 @@ type FieldRelationProvider interface {
}
// RelationOption is one relation choice or label: the related primary key and
// its label column value.
// its label column value. Locked is true when the controller's
// RelationLockProvider names the record for the requesting administrator; the
// key is omitted otherwise.
type RelationOption struct {
Value uint `json:"value"`
Label string `json:"label"`
Value uint `json:"value"`
Label string `json:"label"`
Locked bool `json:"locked,omitempty"`
}
// RecordMeta is the record envelope meta: display labels per relation field,
@@ -84,8 +113,8 @@ type CompiledFieldRelation struct {
// Multiple is true for belongsToMany.
Multiple bool
// ReadOnly is true for a belongsTo whose foreign key is a protected fill
// key (D-26): it is shown with its label but never written and has no
// options endpoint.
// key (D-26) and whose contract does not set WritableForeignKey: it is
// shown with its label but never written and has no options endpoint.
ReadOnly bool
// Nullable is true when a belongsTo foreign key accepts null.
Nullable bool
@@ -199,8 +228,11 @@ func compileFieldRelation(ctl pact.AdminController, field FormField, contract Fi
return nil, fmt.Errorf("foreign key %s must be an unsigned integer column", contract.ForeignKey)
}
out.Nullable = fk.Type.Kind() == reflect.Pointer
out.ReadOnly = protectedFillKey(contract.ForeignKey)
out.ReadOnly = protectedFillKey(contract.ForeignKey) && !contract.WritableForeignKey
case relationKindBelongsToMany:
if contract.WritableForeignKey {
return nil, errors.New("WritableForeignKey is only valid on belongsTo")
}
if contract.ForeignKey != "" {
return nil, errors.New("belongsToMany cannot declare a ForeignKey")
}
@@ -337,6 +369,11 @@ func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController
if err != nil {
return nil, ListMeta{}, lifecycleFailure(cc, err)
}
locked, _, err := relationLocks(ctx, cc, field)
if err != nil {
return nil, ListMeta{}, err
}
markLocked(rows, locked)
last := 1
if total > 0 {
last = int((total + int64(per) - 1) / int64(per))
@@ -344,6 +381,115 @@ func (s CRUDService) RelationOptions(ctx context.Context, cc *CompiledController
return rows, ListMeta{Page: page, PerPage: per, Total: total, LastPage: last}, nil
}
// relationLocks asks the controller's RelationLockProvider for the locked
// related ids of field, as a set, and the lock's message. A controller
// without the provider has no locks. A provider error is a lifecycle failure.
func relationLocks(ctx context.Context, cc *CompiledController, field string) (map[uint]bool, string, error) {
if cc == nil || cc.Controller == nil {
return nil, "", nil
}
provider, ok := cc.Controller.(RelationLockProvider)
if !ok || provider == nil {
return nil, "", nil
}
lock, err := provider.AdminRelationLocks(ctx, field)
if err != nil {
slog.Error("cabana: relation locks failed", "controller", controllerID(cc), "field", field, "error", err)
return nil, "", lifecycleFailure(cc, err)
}
if len(lock.IDs) == 0 {
return nil, lock.Message, nil
}
set := make(map[uint]bool, len(lock.IDs))
for _, id := range lock.IDs {
set[id] = true
}
return set, lock.Message, nil
}
// markLocked flags the options whose id is in locked.
func markLocked(options []RelationOption, locked map[uint]bool) {
if len(locked) == 0 {
return
}
for i := range options {
if locked[options[i].Value] {
options[i].Locked = true
}
}
}
// checkRelationLocks refuses a save whose relation values change what the
// controller's RelationLockProvider locks for the requesting administrator
// (D-07; T-12.1-12). It runs inside the save's transaction after the scope
// check and before any row or pivot write, on create and on update. For a
// belongsToMany value the locked subset of the parent's current pivot rows
// (none on create) and of the submitted ids must be equal as sets. For a
// belongsTo value a change is refused when the current or the submitted id is
// locked. Only relation fields present in the body are checked: an absent
// field changes nothing. The refusal is a ForbiddenError (403) with the
// lock's message, also on the field.
func checkRelationLocks(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any, values []relationValue) error {
for _, value := range values {
locked, message, err := relationLocks(ctx, cc, value.field)
if err != nil {
return err
}
if len(locked) == 0 {
continue
}
c := value.fr.Contract
changed := false
if value.fr.Multiple {
var current []uint
if parentPK := pkUint(model); parentPK != 0 {
err := tx.WithContext(ctx).Model(c.NewPivot()).
Where(clause.Eq{Column: clause.Column{Name: c.ParentForeignKey}, Value: parentPK}).
Pluck(c.RelatedForeignKey, &current).Error
if err != nil {
return lifecycleFailure(cc, err)
}
}
before, after := map[uint]bool{}, map[uint]bool{}
for _, id := range current {
if locked[id] {
before[id] = true
}
}
for _, id := range value.ids {
if locked[id] {
after[id] = true
}
}
changed = len(before) != len(after)
for id := range before {
if !after[id] {
changed = true
}
}
} else {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
v = v.Elem()
}
current, _ := foreignKeyValue(v, c.ForeignKey)
var submitted uint
if !value.null && len(value.ids) > 0 {
submitted = value.ids[0]
}
changed = current != submitted && (locked[current] || locked[submitted])
}
if changed {
detail := message
if detail == "" {
detail = "backend::lang.form.forbidden"
}
return &ForbiddenError{Message: message, Details: map[string]any{value.field: []string{detail}}}
}
}
return nil
}
// relationValue is one present, writable relation key lifted from a body.
type relationValue struct {
field string
@@ -479,10 +625,13 @@ func checkRelationScope(ctx context.Context, tx *gorm.DB, cc *CompiledController
}
// assignBelongsTo writes validated belongsTo ids (or null) onto the parent's
// foreign key before the row write. Read-only keys never reach this point.
// foreign key before the row write. Read-only keys never reach this point: a
// protected foreign key is written only when its contract declares
// WritableForeignKey, the same rule compileFieldRelation applies.
func assignBelongsTo(cc *CompiledController, model any, values []relationValue) error {
for _, value := range values {
if value.fr.Multiple || value.fr.ReadOnly || protectedFillKey(value.fr.Contract.ForeignKey) {
c := value.fr.Contract
if value.fr.Multiple || value.fr.ReadOnly || (protectedFillKey(c.ForeignKey) && !c.WritableForeignKey) {
continue
}
var id any
@@ -577,6 +726,13 @@ func projectRelationFields(ctx context.Context, tx *gorm.DB, cc *CompiledControl
if err != nil {
return RecordMeta{}, lifecycleFailure(cc, err)
}
if len(labels) > 0 {
locked, _, err := relationLocks(withTx(ctx, tx), cc, name)
if err != nil {
return RecordMeta{}, err
}
markLocked(labels, locked)
}
meta.Labels[name] = labels
}
return meta, nil

View File

@@ -22,6 +22,10 @@ type ListColumn struct {
Type string `json:"type,omitempty"`
Relation string `json:"relation,omitempty"`
Select string `json:"select,omitempty"`
// Invisible marks a column that is searched and sorted on the server but
// not shown: the admin renders no header or cell for it and list rows do
// not carry its value (columns.yaml invisible).
Invisible bool `json:"invisible,omitempty"`
}
// ListFilter is one compiled config_filter scope. Values stay typed scalars.

View File

@@ -0,0 +1,6 @@
scopes:
tagged:
label: acme.roster::lang.people.tagged
modelClass: Tag
nameFrom: name
scope: tagged

View File

@@ -2,6 +2,7 @@ list: ~/plugins/acme/roster/models/person/columns.yaml
modelClass: Person
title: acme.roster::lang.people.title
recordUrl: acme/roster/people/preview/:id
filter: config_filter.yaml
recordsPerPage: 20
showCheckboxes: true
toolbar:

View File

@@ -30,6 +30,11 @@ people:
notify: Send a welcome message
permissions: Permissions
tab_permissions: Permissions
team: Team
no_team: No team
tags: Tags
tagged: Tag
tag_locked: You need an additional permission to change the staff tag.
permissions:
tab_content: Content
tab_reports: Reports

View File

@@ -30,6 +30,11 @@ people:
notify: Wyślij wiadomość powitalną
permissions: Uprawnienia
tab_permissions: Uprawnienia
team: Zespół
no_team: Brak zespołu
tags: Tagi
tagged: Tag
tag_locked: Zmiana tagu staff wymaga dodatkowego uprawnienia.
permissions:
tab_content: Treści
tab_reports: Raporty

View File

@@ -5,3 +5,6 @@ columns:
email:
label: acme.roster::lang.people.email
searchable: true
invisible: true
slug:
label: acme.roster::lang.people.slug

View File

@@ -7,6 +7,15 @@ fields:
label: acme.roster::lang.people.email
type: text
span: right
team:
label: acme.roster::lang.people.team
type: relation
nameFrom: name
emptyOption: acme.roster::lang.people.no_team
tags:
label: acme.roster::lang.people.tags
type: relation
nameFrom: name
slug:
label: acme.roster::lang.people.slug
type: text

View File

@@ -123,6 +123,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `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.FilterOptions` | Serves the choices of a scope filter. The admin controller may implement it and is asked first, so choices can be read from the database; otherwise the model is asked. |
| `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.RelationAfterCreate` | Optional controller hook run after a relation manager creates a related record, before the commit. |

View File

@@ -583,8 +583,11 @@ type FilterScope interface {
}
// FilterOptions serves the choices of a model-backed config_filter scope
// (D-27). It is implemented by the same model as FilterScope and receives the
// filter's scope method name; labels may be phrase keys.
// (D-27). It receives the filter's scope method name; labels may be phrase
// keys. The admin controller may implement it and is asked first, so choices
// that are read from the database can use the handle the controller holds;
// otherwise the model that implements FilterScope is asked. The scope itself
// is always the model's FilterScope.
type FilterOptions interface {
FilterOptions(scope string) []Option
}

View File

@@ -76,6 +76,8 @@ form:
deleting: Deleting…
add: Add
remove_item: "Remove: :name"
locked_item: "Locked: :name"
locked_note: Items marked with a lock need an additional permission to change.
add_item: Add…
select_placeholder: Select…
loading_options: Loading…

View File

@@ -82,6 +82,8 @@ form:
deleting: Usuwanie…
add: Dodaj
remove_item: "Usuń: :name"
locked_item: "Zablokowane: :name"
locked_note: Zmiana pozycji oznaczonych kłódką wymaga dodatkowego uprawnienia.
add_item: Dodaj…
select_placeholder: Wybierz…
loading_options: Wczytywanie…