feat(18-01): money list column, danger record actions, preview Edit rule and table kit (task 2)

- columns.yaml type: money with currency or currencyFrom (exactly one),
  never searchable, not sortable by default; every list row carries the
  currencyFrom column's value under its own key; the schema serves
  currency and currencyFrom on the column
- pact.AdminRecordAction.Danger, served as danger on cabana.RecordAction
  (absent when false); RecordActions.vue passes it to the confirm dialog
- CellValue money kind through formatMoney with the row's code, the em
  dash when empty and the amount alone without a code; DataTable aligns
  money headers and cells to the right
- PreviewView renders Edit only when a registered value field allows
  update (F6)
- main.css .summer-table* and .summer-stat__note kit classes (F7) over
  --c-* variables; cabana README kit rows
- fixture record actions void (danger) and resend; TestMoneyListCompile,
  TestMoneyListSmoke and TestRecordActionDangerSmoke; regenerated OpenAPI
  and TS types; rebuilt dist; lists-and-filters and admin-controllers docs
This commit is contained in:
Jakub Zych
2026-10-13 11:22:36 +02:00
parent 20385e53ee
commit 0c8605123e
29 changed files with 615 additions and 56 deletions

View File

@@ -1719,6 +1719,13 @@
},
"cabana.ListColumn": {
"properties": {
"currency": {
"description": "Currency is the ISO 4217 code of a `type: money` column; CurrencyFrom\nnames the model column that holds each row's code instead. Every list\nrow carries the CurrencyFrom column's value under its own key, so the\nadmin never guesses a currency (D-07).",
"type": "string"
},
"currencyFrom": {
"type": "string"
},
"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"
@@ -2209,6 +2216,10 @@
"confirm": {
"type": "string"
},
"danger": {
"description": "Danger marks an action whose confirm dialog takes the danger variant\n(pact.AdminRecordAction.Danger); an ordinary action omits the key.",
"type": "boolean"
},
"label": {
"type": "string"
},

View File

@@ -5802,6 +5802,14 @@ export interface components {
[key: string]: components["schemas"]["cabana.MessageForms"];
};
"cabana.ListColumn": {
/**
* @description Currency is the ISO 4217 code of a `type: money` column; CurrencyFrom
* names the model column that holds each row's code instead. Every list
* row carries the CurrencyFrom column's value under its own key, so the
* admin never guesses a currency (D-07).
*/
currency?: string;
currencyFrom?: string;
/**
* @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
@@ -5949,6 +5957,11 @@ export interface components {
};
"cabana.RecordAction": {
confirm?: string;
/**
* @description Danger marks an action whose confirm dialog takes the danger variant
* (pact.AdminRecordAction.Danger); an ordinary action omits the key.
*/
danger?: boolean;
label: string;
name: string;
};

View File

@@ -14,7 +14,8 @@ import { useConfirm } from '../ui/confirm'
// that is not listed is not rendered. Every action asks for confirmation; the
// dialog stays open and busy while the POST runs, and every button here is
// disabled meanwhile. Labels, confirm texts and messages come from the server
// and are rendered as text.
// and are rendered as text. A `danger` action (UI-SPEC F5) turns the dialog's
// confirm button into the danger variant; the footer button stays outline.
const props = withDefaults(
defineProps<{
source: ControllerParams
@@ -49,7 +50,7 @@ async function run(action: RecordAction): Promise<void> {
{
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
confirmLabel: action.label,
danger: false,
danger: action.danger,
},
async () => {
running.value = true

View File

@@ -2,7 +2,8 @@
import { computed } from 'vue'
import { ArrowUpRight } from '@lucide/vue'
import type { ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
import { currentLocale, t } from '../../app/i18n'
import { formatMoney } from '../../app/money'
// One cell rendered by column type (D-12): text by default (arrays
// comma-joined, a muted dash when empty), datetime as YYYY-MM-DD HH:mm with
@@ -10,7 +11,11 @@ import { t } from '../../app/i18n'
// Phase 12.2: a `date` column shows the stored YYYY-MM-DD and a `time`
// column the stored HH:mm, both as strings: a calendar date or a time of day
// has no zone, so it never goes through the global Date (Pitfall 12).
const props = defineProps<{ column: ListColumn; value: unknown }>()
// Phase 18: a `money` column formats the row's decimal string through Intl
// with the column's currency or the row's currencyFrom value (UI-SPEC F1);
// without a code it shows the amount alone. The string never becomes a
// number.
const props = defineProps<{ column: ListColumn; value: unknown; row?: Record<string, unknown> }>()
function isEmpty(value: unknown): boolean {
return value === null || value === undefined || value === ''
@@ -72,10 +77,29 @@ function truthy(value: unknown): boolean {
return Boolean(value)
}
/** The row's currency code for a money column: the literal or the sibling value. */
function currencyOf(): string {
if (props.column.currencyFrom) {
const code = props.row?.[props.column.currencyFrom]
return typeof code === 'string' ? code.trim() : ''
}
return props.column.currency ?? ''
}
function money(value: unknown): string {
if (typeof value !== 'string' || value.trim() === '') {
return ''
}
return formatMoney(value.trim(), currencyOf(), currentLocale.value, 4)
}
const kind = computed(() => {
if (props.column.type === 'switch') {
return 'switch'
}
if (props.column.type === 'money') {
return 'money'
}
if (props.column.type === 'datetime') {
return 'datetime'
}
@@ -96,6 +120,8 @@ const display = computed(() => {
return storedDate(props.value)
case 'time':
return storedTime(props.value)
case 'money':
return money(props.value)
default:
return text(props.value)
}
@@ -120,5 +146,6 @@ const display = computed(() => {
</template>
<span v-else-if="display === ''" class="text-muted" data-empty>—</span>
<span v-else-if="kind === 'datetime' || kind === 'date' || kind === 'time'" class="text-[13px] text-muted tabular-nums">{{ display }}</span>
<span v-else-if="kind === 'money'" data-money class="tabular-nums whitespace-nowrap">{{ display }}</span>
<template v-else>{{ display }}</template>
</template>

View File

@@ -172,10 +172,16 @@ function statesOf(row: AdminRecord): string[] {
function cellClass(column: ListColumn, columnIndex: number, states: string[] = []): string {
// A deleted or disabled row mutes every cell; the first keeps weight 600.
const muted = states.includes('deleted') || states.includes('disabled')
const align = alignClass(column)
if (columnIndex === 0) {
return muted ? 'font-semibold text-muted' : 'font-semibold'
return [muted ? 'font-semibold text-muted' : 'font-semibold', align].filter(Boolean).join(' ')
}
return muted || relation.value || column.relation ? 'text-muted' : ''
return [muted || relation.value || column.relation ? 'text-muted' : '', align].filter(Boolean).join(' ')
}
/** A money column aligns its header and cells to the right (UI-SPEC F1). */
function alignClass(column: ListColumn): string {
return column.type === 'money' ? 'text-right' : ''
}
/** First-cell text of a row with states: struck through, danger or muted. */
@@ -228,7 +234,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
:key="column.key"
scope="col"
:aria-sort="canSort(column) ? (sortState(column) ?? 'none') : undefined"
:class="sortState(column) ? 'text-text' : 'text-muted'"
:class="[sortState(column) ? 'text-text' : 'text-muted', alignClass(column)]"
class="px-3.5 text-[12px] font-semibold first:pl-5 last:pr-5"
>
<button
@@ -313,23 +319,23 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
class="min-w-0 truncate text-left font-semibold text-text"
@click="openFrom($event, row)"
>
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</button>
<CellValue v-else :column="column" :value="row[column.key]" />
<CellValue v-else :column="column" :value="row[column.key]" :row="row" />
</span>
<span v-else-if="columnIndex === 0 && statesOf(row).length > 0" class="flex items-center gap-2">
<RouterLink v-if="linkOf(row)" :to="linkOf(row)!" :class="stateTextClass(statesOf(row))">
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</RouterLink>
<span v-else :class="stateTextClass(statesOf(row))">
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</span>
<RowStateBadges :states="statesOf(row)" :labels="stateLabels" />
</span>
<RouterLink v-else-if="columnIndex === 0 && linkOf(row)" :to="linkOf(row)!" class="text-text no-underline">
<CellValue :column="column" :value="row[column.key]" />
<CellValue :column="column" :value="row[column.key]" :row="row" />
</RouterLink>
<CellValue v-else :column="column" :value="row[column.key]" />
<CellValue v-else :column="column" :value="row[column.key]" :row="row" />
</td>
<td v-if="trailing" data-trailing class="w-[56px] pr-5">
<slot name="trailing" :row="row" />

View File

@@ -384,6 +384,77 @@
font-variant-numeric: tabular-nums;
}
/* A note under a stat value (Phase 18, UI-SPEC F7): placed inside
.summer-stat__value after the value text, because .summer-stat is
column-reverse. */
.summer-stat__note {
display: block;
margin-top: 4px;
font-size: 13px;
font-weight: 400;
line-height: 1.5;
color: var(--c-muted);
}
/* Table kit (Phase 18, UI-SPEC F7): a native-looking table inside a
partial card. The wrapper scrolls horizontally inside the card. */
.summer-table-wrap {
overflow-x: auto;
max-width: 100%;
}
.summer-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
line-height: 1.5;
color: var(--c-text);
}
.summer-table th {
height: var(--spacing-row-head);
padding: 0 14px;
font-size: 12px;
font-weight: 600;
color: var(--c-muted);
text-align: start;
border-bottom: 1px solid var(--c-border);
white-space: nowrap;
}
.summer-table td {
padding: 12px 14px;
vertical-align: top;
border-bottom: 1px solid var(--c-border);
overflow-wrap: anywhere;
}
.summer-table :where(th, td):first-child {
padding-inline-start: 0;
}
.summer-table :where(th, td):last-child {
padding-inline-end: 0;
}
.summer-table__num {
text-align: end;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.summer-table__sub {
display: block;
font-size: 13px;
color: var(--c-muted);
}
.summer-table tfoot td {
padding-top: 16px;
border-bottom: 0;
font-weight: 600;
}
/* Callout (Phase 12.1, UI-SPEC): a status hint above a preview screen. */
.summer-callout {
display: flex;

View File

@@ -13,7 +13,7 @@ import { FIELD_SCOPE, FORM_SESSION, createUploadGate } from '../components/form/
import FormTabs from '../components/form/FormTabs.vue'
import PreviewField from '../components/form/PreviewField.vue'
import RecordActions from '../components/form/RecordActions.vue'
import { RELATION_MANAGER } from '../components/form/registry'
import { RELATION_MANAGER, isRegisteredField } from '../components/form/registry'
import {
DEFAULT_TAB,
contextAllows,
@@ -70,6 +70,13 @@ const fields = computed<FormField[]>(() =>
(schema.value?.fields ?? []).filter((field) => contextAllows(field, 'preview') && !hidden.has(field.type)),
)
// The Edit button renders only when the form holds at least one value field
// the SPA can edit whose context allows update (UI-SPEC F6): a record whose
// form shows only partials has nothing to edit and no primary button.
const hasEditableField = computed(() =>
(schema.value?.fields ?? []).some((field) => isRegisteredField(field) && contextAllows(field, 'update')),
)
// The same tabs as the form; a tab with no preview-visible field never
// appears, because tabs are built from the visible fields only.
const tabs = computed<TabItem[]>(() => {
@@ -290,7 +297,7 @@ void load()
@gone="onGone"
/>
<Button
v-if="!failed"
v-if="!failed && hasEditableField"
variant="primary"
data-action="edit"
:icon="Pencil"

View File

@@ -294,3 +294,39 @@ describe('preview view footer (UI-SPEC S2, D-10)', () => {
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
})
})
describe('preview edit rule (UI-SPEC F6)', () => {
it('shows no Edit button and no primary fill when the form holds only partial fields', async () => {
const partialOnly = schemaWith((schema) => {
schema.fields = [
{ name: 'cart', type: 'partial', label: 'Cart', path: 'cart' },
{ name: 'payments', type: 'partial', label: 'Payments', path: 'payments' },
] as typeof schema.fields
})
const { wrapper } = await mountApp(
PREVIEW,
routes({
[`GET ${LIST}/schema/form`]: partialOnly,
[`GET ${LIST}/partials/cart`]: { body: noHint },
[`GET ${LIST}/partials/payments`]: { body: noHint },
[`GET ${RECORD}`]: recordWith((record) => (record.meta.actions = [{ name: 'activate', label: 'Activate' }])),
}),
)
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
expect(wrapper.findAll('[data-preview] footer .bg-primary')).toHaveLength(0)
expect(footerButtons(wrapper)).toEqual(['activate'])
})
it('keeps the Edit button when the form has a text field the update context allows', async () => {
const { wrapper } = await mountApp(PREVIEW, routes())
expect(wrapper.find('[data-action="edit"]').exists()).toBe(true)
})
it('hides the Edit button when every value field is preview-only', async () => {
const previewOnly = schemaWith((schema) => {
schema.fields = [{ name: 'joined_ip', type: 'text', label: 'Joined from', context: 'preview' }] as typeof schema.fields
})
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: previewOnly }))
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
})
})

View File

@@ -14,6 +14,7 @@ const actions = [
{ name: 'reinstate', label: 'Reinstate', confirm: 'Lift the ban on this person?' },
{ name: 'activate', label: 'Activate' },
{ name: 'markup', label: '<b>Bold</b>' },
{ name: 'ban', label: 'Ban', confirm: 'Ban this person? This cannot be undone.', danger: true },
]
const strings = {
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
@@ -60,7 +61,7 @@ describe('record actions component (UI-SPEC S2, D-10)', () => {
it('renders the offered actions in the given order, labels as text', () => {
const { wrapper } = mountActions({})
const buttons = wrapper.findAll('[data-record-action]')
expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['reinstate', 'activate', 'markup'])
expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['reinstate', 'activate', 'markup', 'ban'])
expect(buttons[2]!.text()).toBe('<b>Bold</b>')
expect(buttons[2]!.find('b').exists()).toBe(false)
expect(buttons.every((item) => item.classes().includes('border-border-strong'))).toBe(true)
@@ -184,3 +185,25 @@ describe('record actions component (UI-SPEC S2, D-10)', () => {
expect(calls).toHaveLength(0)
})
})
describe('danger record actions (UI-SPEC F5)', () => {
it('gives the dialog confirm button the danger variant for a danger action, the default otherwise', async () => {
const { wrapper } = mountActions({})
await button(wrapper, 'ban').trigger('click')
await flushPromises()
const confirmButton = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
expect(confirmButton.className).toContain('bg-danger')
expect(confirmButton.className).not.toContain('bg-primary')
expect(confirmButton.textContent?.trim()).toBe('Ban')
// The footer button itself stays outline.
expect(button(wrapper, 'ban').classes()).toContain('border-border-strong')
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
await flushPromises()
await button(wrapper, 'activate').trigger('click')
await flushPromises()
const plain = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
expect(plain.className).toContain('bg-primary')
expect(plain.className).not.toContain('bg-danger')
})
})

View File

@@ -1,14 +1,24 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import CellValue from '../../src/components/list/CellValue.vue'
import DataTable from '../../src/components/list/DataTable.vue'
import type { ListColumn } from '../../src/api/types'
import { listColumn } from '../fixtures/typed'
import { resetState } from '../helpers'
import { langFixture, listColumn } from '../fixtures/typed'
import { resetState, routerAt, signIn } from '../helpers'
function cell(column: ListColumn, value: unknown) {
return mount({ components: { CellValue }, props: [], template: '<span data-cell><CellValue :column="column" :value="value" /></span>', data: () => ({ column, value }) })
function cell(column: ListColumn, value: unknown, row: Record<string, unknown> = {}) {
return mount({
components: { CellValue },
props: [],
template: '<span data-cell><CellValue :column="column" :value="value" :row="row" /></span>',
data: () => ({ column, value, row }),
})
}
const moneyFrom: ListColumn = { ...listColumn('name'), key: 'total', type: 'money', currencyFrom: 'currency', sortable: false }
const moneyEur: ListColumn = { ...listColumn('name'), key: 'fee', type: 'money', currency: 'EUR', sortable: false }
const text = listColumn('name')
const datetime = listColumn('created_at')
const toggle = listColumn('active')
@@ -108,3 +118,51 @@ describe('date and time cells (Phase 12.2)', () => {
}
})
})
describe('money column (UI-SPEC F1, D-07)', () => {
it("formats the decimal string with the row's currencyFrom code in en and pl", () => {
setBundle(langFixture.data, 'en')
const en = cell(moneyFrom, '99999999.9999', { currency: 'PLN' })
expect(en.find('[data-money]').text()).toBe('PLN\u00a099,999,999.9999')
expect(en.find('[data-money]').classes()).toEqual(expect.arrayContaining(['tabular-nums', 'whitespace-nowrap']))
setBundle(langFixture.data, 'pl')
const pl = cell(moneyFrom, '39.8400', { currency: 'PLN' })
expect(pl.find('[data-money]').text().replace(/\s/g, ' ')).toBe('39,84 PLN')
})
it('uses the literal currency of the column', () => {
setBundle(langFixture.data, 'en')
expect(cell(moneyEur, '1.5000').find('[data-money]').text()).toBe('EUR\u00a01.50')
})
it('shows the amount alone when the row has no currency, and the dash when empty', () => {
setBundle(langFixture.data, 'en')
expect(cell(moneyFrom, '12.5', { currency: '' }).find('[data-money]').text()).toBe('12.50')
expect(cell(moneyFrom, '12.5', {}).find('[data-money]').text()).toBe('12.50')
expect(cell(moneyFrom, null, { currency: 'PLN' }).find('[data-empty]').text()).toBe('—')
expect(cell(moneyFrom, '', { currency: 'PLN' }).find('[data-empty]').text()).toBe('—')
})
it('aligns the header and the cells of a money column to the right', async () => {
setBundle(langFixture.data, 'en')
signIn()
const router = await routerAt('/acme/demo/widgets')
const wrapper = mount(DataTable, {
props: {
columns: [listColumn('name'), moneyFrom],
rows: [
{ id: 1, name: 'One', total: '85.4193', currency: 'PLN' },
{ id: 2, name: 'Two', total: null, currency: 'PLN' },
],
},
global: { plugins: [router] },
})
const headers = wrapper.findAll('th')
expect(headers[1]!.classes()).toContain('text-right')
expect(headers[0]!.classes()).not.toContain('text-right')
const cells = wrapper.findAll('tbody tr')[0]!.findAll('td')
expect(cells[1]!.classes()).toContain('text-right')
expect(cells[1]!.find('[data-money]').text()).toBe('PLN\u00a085.4193')
expect(wrapper.findAll('tbody tr')[1]!.findAll('td')[1]!.find('[data-empty]').text()).toBe('—')
})
})

View File

@@ -480,11 +480,12 @@ messages:
edit: acme.roster::lang.people.edit
```
Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` text, its own `Permissions`, an optional `Applies` function and `Run`:
Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` text, its own `Permissions`, an optional `Danger` flag, an optional `Applies` function and `Run`:
- `Applies` reports whether the action fits the record's current state; without one the action always applies. It must only read, because it runs in two places: when a record is shown, to decide which actions to offer, and again inside the action's transaction, right before `Run`.
- The show response (`GET /{controller}/{id}`) lists the offered actions in `meta.actions` as `cabana.RecordAction` entries: only those the administrator may run and that apply to the record. The key is absent when none is offered, and create and update responses never carry it.
- `POST /{controller}/{id}/actions/{action}` takes an empty `{}` body. cabana loads the record through `pact.FormExtendQuery` with a row lock in one transaction and hands it to `Run` in `pact.AdminRecordActionInput`. A missing record and a record outside the scope are the same 404; an action whose `Applies` reports false answers 409 `conflict`; an administrator without the action's permissions gets 403.
- `Run` writes through `cabana.TxFromContext(ctx)` and returns a `pact.AdminRecordActionResult` with an optional `Message`. Any error rolls the transaction back.
- `Danger: true` marks an action that moves money out or cannot be undone, such as a refund. The record response serializes it as `danger` on the `cabana.RecordAction` entry (the key is absent for an ordinary action, so existing payloads are unchanged), and the admin's confirm dialog then shows its confirm button in the danger variant; the footer button itself stays the same. The flag changes nothing on the server: permissions and `Applies` are checked as for any action.
Record actions have their own namespace: a record action and a bulk action may share a name, such as `activate` here. `create` and `delete` are reserved. A name in `recordActions` that the controller does not register, a duplicate, or an action without a label stops the start-up.

View File

@@ -59,7 +59,7 @@ columns:
type: datetime
```
A column takes `label`, `type` (`text`, `datetime`, `date`, `time` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot, except the alias of a [plugin field type](field-types.md#list-columns), whose column shows the type's list text. When `type` is omitted, a `time.Time` column is `datetime`, a `lagoon.Date` column is `date` and a `lagoon.TimeOfDay` column is `time`; every other column stays untyped (the admin treats that as text). An explicit `type` always wins. A searchable relation column matches a record when any related record matches: a `categories` column with `relation: categories` and `select: name` on a blog post finds the posts with at least one matching category, for belongsTo, hasOne, hasMany and belongsToMany relations alike, each through a subquery, so a post is listed and counted once. A sortable hasMany or belongsToMany column sorts by its comma-joined values, the text the list shows. The compiled list keeps the columns in file order:
A column takes `label`, `type` (`text`, `datetime`, `date`, `time`, `switch` or `money`), `searchable` (default false) and `sortable` (default true, as in WinterCMS; false for a money column), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot, except the alias of a [plugin field type](field-types.md#list-columns), whose column shows the type's list text. When `type` is omitted, a `time.Time` column is `datetime`, a `lagoon.Date` column is `date` and a `lagoon.TimeOfDay` column is `time`; every other column stays untyped (the admin treats that as text). An explicit `type` always wins. A searchable relation column matches a record when any related record matches: a `categories` column with `relation: categories` and `select: name` on a blog post finds the posts with at least one matching category, for belongsTo, hasOne, hasMany and belongsToMany relations alike, each through a subquery, so a post is listed and counted once. A sortable hasMany or belongsToMany column sorts by its comma-joined values, the text the list shows. The compiled list keeps the columns in file order:
```go src=modules/cabana/example_test.go#ExampleCompileList
// The plugin embeds its controllers/ and models/ trees; the example reads
@@ -88,6 +88,26 @@ 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.
### Money columns
`type: money` shows an exact decimal amount column (the value of a [money field](forms.md#money)) formatted by the administrator's locale with its ISO code: `PLN 39.84` in English, `39,84 PLN` in Polish, with at least two and at most four decimals, so a stored `85.4193` is shown in full. The column takes the same currency keys as the field, exactly one of them:
```yaml
columns:
total:
label: acme.shop::lang.orders.total
type: money
currencyFrom: currency
fee:
label: acme.shop::lang.orders.fee
type: money
currency: EUR
```
- `currencyFrom` names a string column of the model. Every list row then carries that column's value under its own key, even when the file does not declare it as a column, so the admin formats each row with that row's code; a row whose code is empty shows the amount alone.
- A money column is not sortable unless the file says `sortable: true`, and is never searchable: `searchable: true` stops the start-up, as do two currency sources, none, a code that is not three upper-case letters, a `currencyFrom` that is not a string column, a `relation`, or a model column that is neither a string nor a `sql.Scanner` and `driver.Valuer` type.
- The admin aligns the header and the cells of a money column to the right in tabular numerals and never wraps an amount; an empty value shows a muted dash. The schema reports `currency` or `currencyFrom` on the column.
`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.

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

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,8 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-BPd1k6k8.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BxyrlyKr.css">
<script type="module" crossorigin src="./assets/index-B8hF3DCG.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-c6AgbZf1.css">
</head>
<body>
<div id="app"></div>

View File

@@ -46,9 +46,10 @@ Use cases:
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) fraction digits, or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field.
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) fraction digits, or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field. A list column `type: money` takes the same `currency` or `currencyFrom` key, is never searchable and not sortable by default, and every list row carries the `currencyFrom` column's value under its own key even when it is not a declared column; the admin right-aligns the locale-formatted amount with its ISO code.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics for JWT sessions: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it). When the principal carries a non-full `bouncer.ScopeLimit` (a personal API token), the scope is checked first: one method-allowed scope must match every required code and the owner must hold a required code inside that scope; a superuser with a scoped token is limited to the scope, and an empty requirement list is refused unless the scope is full.
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a composite `backend` guard (a guard another plugin already registered under that name fails `cabana.Activate`) that accepts an `Authorization: Bearer` personal API token (`cabana.APITokenPrefix` / `g15_…`) or, otherwise, the unchanged admin JWT path (Bearer JWT, then the admin cookie). Personal tokens never come from a cookie or the query string. Login throttling, token refresh and revocation, and the SPA cookie transport are unchanged. Cookie-authenticated requests that change state must carry `X-Requested-With: XMLHttpRequest`, which blocks cross-site request forgery.
@@ -143,6 +144,12 @@ The admin SPA ships a small set of stable CSS classes that partial templates may
| `summer-stat` | One item of the strip: the value is shown above the label while `<dt>` stays first in the DOM. |
| `summer-stat__label` | The item label: 13px, muted, wraps. |
| `summer-stat__value` | The item value: 20px, weight 600, tabular numbers. |
| `summer-stat__note` | A note under a stat value: 13px, weight 400, muted, 4px above. Place it inside `summer-stat__value` after the value text, because `summer-stat` is column-reverse. |
| `summer-table-wrap` | A horizontally scrolling wrapper for a wide table inside a card (`overflow-x: auto`); the page never scrolls sideways. |
| `summer-table` | A full-width collapsed table in 14px/1.5 body text. Headers are 44px high, 12px, weight 600 and muted with a bottom border; cells pad 12px 14px, align to the top, carry a bottom border and wrap long words; the first and last column drop their outer padding because the card already pads. |
| `summer-table__num` | A numeric cell or header: end-aligned, tabular numerals, never wraps. |
| `summer-table__sub` | A muted 13px line under a cell's main text (a unit price under an amount). |
| `summer-table tfoot td` | A totals row: 16px top padding, no bottom border, weight 600 (no class needed). |
| `summer-callout` | A status hint above a preview screen: a borderless block with a 12px radius and 14px 18px padding; long text wraps. |
| `summer-callout--warning`, `summer-callout--danger` | The callout's tone: the selection tint with body text, or the soft danger background with danger text. |
| `summer-callout__title` | The callout's first line, weight 600. |

View File

@@ -490,6 +490,7 @@ func (s CRUDService) offeredRecordActions(ctx context.Context, cc *CompiledContr
Name: name,
Label: translateKey(ctx, s.tr, action.Label),
Confirm: translateKey(ctx, s.tr, action.Confirm),
Danger: action.Danger,
})
}
return out, nil

View File

@@ -8,6 +8,7 @@ import (
"reflect"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml/ast"
@@ -325,3 +326,54 @@ func checkMoneyValues(ctx context.Context, tr *phrasebook.Translator, cc *Compil
}
return nil
}
// checkMoneyColumn gates the money keys of a columns.yaml entry: on a
// `type: money` column exactly one of currency (an ISO 4217 code) or
// currencyFrom (a string column of the model) is required, the column is
// never searchable, takes no relation, and when the controller names its
// model (pact.AdminRecordSource) the column must be a string or a
// sql.Scanner and driver.Valuer type; on any other type the two keys are
// refused.
func checkMoneyColumn(typ, key string, spec columnDocument, ctl pact.AdminController) error {
if typ != moneyType {
if spec.Currency != "" {
return fmt.Errorf("currency is only valid on type: money")
}
if spec.CurrencyFrom != "" {
return fmt.Errorf("currencyFrom is only valid on type: money")
}
return nil
}
if spec.Relation != "" || spec.Select != "" {
return fmt.Errorf("a money column is read from the model, not through a relation")
}
if spec.Searchable {
return fmt.Errorf("a money column is never searchable")
}
if spec.Currency != "" && !isoCurrencyCode(spec.Currency) {
return fmt.Errorf("currency %q must be a three-letter upper-case ISO 4217 code", spec.Currency)
}
if spec.CurrencyFrom != "" && !identifier(spec.CurrencyFrom) {
return fmt.Errorf("currencyFrom %q is not a column name", spec.CurrencyFrom)
}
if (spec.Currency == "") == (spec.CurrencyFrom == "") {
return fmt.Errorf("money needs exactly one of currency or currencyFrom")
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil
}
model := src.NewRecord()
if model == nil {
return nil
}
if sf, ok := structFieldByColumn(model, key); ok && !moneyColumnType(sf.Type) {
return fmt.Errorf("money needs a string column or a sql.Scanner and driver.Valuer type, found %s", sf.Type)
}
if spec.CurrencyFrom != "" {
if cf, ok := structFieldByColumn(model, spec.CurrencyFrom); !ok || !stringColumnType(cf.Type) {
return fmt.Errorf("currencyFrom %s is not a string column of the model", spec.CurrencyFrom)
}
}
return nil
}

View File

@@ -1166,6 +1166,20 @@ func projectRowCtx(ctx context.Context, row any, controller pact.AdminController
}
out[col.Key] = field.Interface()
}
// A money column's currencyFrom value rides along under its own key even
// when it is not a declared column, so the admin formats each row's
// amount with that row's code (D-07).
for _, col := range cols {
if col.CurrencyFrom == "" {
continue
}
if _, present := out[col.CurrencyFrom]; present {
continue
}
if code := fieldByColumn(v, col.CurrencyFrom); code.IsValid() && code.CanInterface() {
out[col.CurrencyFrom] = code.Interface()
}
}
return out
}

View File

@@ -20,7 +20,7 @@ import (
const listSearchTerm = "search"
var listColumnTypes = map[string]struct{}{
"text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {},
"text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {}, moneyType: {},
}
type listDocument struct {
@@ -68,6 +68,10 @@ type columnDocument struct {
Relation string `yaml:"relation"`
Select string `yaml:"select"`
Invisible bool `yaml:"invisible"`
// Currency and CurrencyFrom are the `type: money` column's currency
// source (D-07), exactly one of them.
Currency string `yaml:"currency"`
CurrencyFrom string `yaml:"currencyFrom"`
}
// CompileListSchema compiles config_list.yaml and the columns.yaml it names.
@@ -267,20 +271,27 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s is not on the model", key))
}
}
sortable := spec.Relation == ""
if err := checkMoneyColumn(typ, key, spec, ctl); err != nil {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err))
}
// A money column is not sortable unless the file says so (PHP:
// sortable: false), and never searchable.
sortable := spec.Relation == "" && typ != moneyType
if spec.Sortable != nil {
sortable = *spec.Sortable
}
compiled = append(compiled, ListColumn{
Key: key,
Label: spec.Label,
Searchable: spec.Searchable,
Sortable: sortable,
Type: typ,
Relation: spec.Relation,
Select: spec.Select,
Invisible: spec.Invisible,
fieldType: pluginType,
Key: key,
Label: spec.Label,
Searchable: spec.Searchable,
Sortable: sortable,
Type: typ,
Relation: spec.Relation,
Select: spec.Select,
Invisible: spec.Invisible,
Currency: spec.Currency,
CurrencyFrom: spec.CurrencyFrom,
fieldType: pluginType,
})
}
return compiled, nil

View File

@@ -1,6 +1,7 @@
package cabana_test
import (
"context"
"database/sql/driver"
"encoding/json"
"fmt"
@@ -115,6 +116,20 @@ func (paymentsInvoices) ConfigDir() string { return "controllers/inv
func (paymentsInvoices) RequiredPermissions() []string { return []string{"acme.payments.access"} }
func (paymentsInvoices) NewRecord() any { return &paymentsInvoice{} }
// AdminRecordActions registers a danger action (void) and an ordinary one
// (resend) for the F5 flag.
func (paymentsInvoices) AdminRecordActions() []pact.AdminRecordAction {
run := func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return pact.AdminRecordActionResult{}, nil
}
return []pact.AdminRecordAction{
{Name: "void", Label: "Void invoice", Confirm: "Void this invoice? This cannot be undone.", Danger: true, Run: run},
{Name: "resend", Label: "Send again", Run: run},
}
}
var _ pact.HasAdminRecordActions = paymentsInvoices{}
// paymentsAssemble boots plugins on the test database with the fixture's
// config: the assembled admin handler, or the boot error.
func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin) (http.Handler, error) {
@@ -372,8 +387,10 @@ func TestMoneyFieldSmoke(t *testing.T) {
// A boot whose money column cannot hold the string fails naming the field.
t.Run("column type", func(t *testing.T) {
// The list compiles before the form, so the columns.yaml gate names
// the column; both gates carry the same message naming total.
_, err := paymentsAssemble(t, e.db, []party.Plugin{moneyBadColumnPlugin{}})
if err == nil || !strings.Contains(err.Error(), "field total") {
if err == nil || !strings.Contains(err.Error(), "total: money needs a string column") {
t.Fatalf("boot with an int money column: %v", err)
}
})
@@ -401,3 +418,164 @@ func (moneyBadColumnPlugin) AdminControllers() []pact.AdminController {
type moneyBadInvoices struct{ paymentsInvoices }
func (moneyBadInvoices) NewRecord() any { return &moneyBadInvoice{} }
// moneyListFS is a one-controller plugin tree whose invoice columns.yaml is
// the given text, for compile-only checks of the money column keys.
func moneyListFS(columns string) fstest.MapFS {
return fstest.MapFS{
"controllers/invoices/config_list.yaml": {Data: []byte("list: ~/plugins/acme/payments/models/invoice/columns.yaml\nmodelClass: Invoice\ntitle: Invoices\nrecordsPerPage: 20\n")},
"models/invoice/columns.yaml": {Data: []byte(columns)},
}
}
// TestMoneyListCompile checks the money column's boot gates without a
// database: type money with exactly one currency source, never searchable,
// not sortable by default, the keys refused on other types, and the model
// column checks.
func TestMoneyListCompile(t *testing.T) {
schema, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS(`columns:
number:
label: Number
total:
label: Total
type: money
currencyFrom: currency
fee:
label: Fee
type: money
currency: EUR
sortable: true
`))
if err != nil {
t.Fatal(err)
}
byKey := map[string]cabana.ListColumn{}
for _, col := range schema.Columns {
byKey[col.Key] = col
}
if total := byKey["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Currency != "" || total.Sortable || total.Searchable {
t.Fatalf("total = %+v", total)
}
if fee := byKey["fee"]; fee.Type != "money" || fee.Currency != "EUR" || !fee.Sortable {
t.Fatalf("fee = %+v", fee)
}
raw, err := json.Marshal(byKey["total"])
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"currencyFrom":"currency"`) || strings.Contains(string(raw), `"currency":`) {
t.Fatalf("column JSON = %s", raw)
}
refused := map[string]string{
"searchable": "columns:\n total:\n type: money\n currency: PLN\n searchable: true\n",
"both": "columns:\n total:\n type: money\n currency: PLN\n currencyFrom: currency\n",
"none": "columns:\n total:\n type: money\n",
"code": "columns:\n total:\n type: money\n currency: zl\n",
"sibling": "columns:\n total:\n type: money\n currencyFrom: id\n",
"on text": "columns:\n number:\n currency: PLN\n",
"int column": "columns:\n id:\n type: money\n currency: PLN\n",
}
for name, columns := range refused {
t.Run(name, func(t *testing.T) {
if _, err := cabana.CompileList("acme.payments", paymentsInvoices{}, moneyListFS(columns)); err == nil {
t.Fatal("compiled")
}
})
}
}
// TestMoneyListSmoke checks the list projection on Postgres: a money column
// is served with its keys, each row carries the currencyFrom column's value
// although it is not a declared column, and the column is neither
// searchable nor sortable by default.
func TestMoneyListSmoke(t *testing.T) {
e := newPaymentsEnv(t)
for _, body := range []map[string]any{
{"number": "L-1", "currency": "PLN", "total": "85.4193", "fee": "1.50"},
{"number": "L-2", "currency": "EUR", "total": "5.0000"},
} {
if rec := e.send(t, http.MethodPost, "/acme/payments/invoices", body, true); rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
}
rec := e.send(t, http.MethodGet, "/acme/payments/invoices/schema/list", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("schema status=%d body=%s", rec.Code, rec.Body.String())
}
var schema cabana.Envelope[cabana.ListSchema]
if err := json.Unmarshal(rec.Body.Bytes(), &schema); err != nil {
t.Fatal(err)
}
keys := map[string]cabana.ListColumn{}
for _, col := range schema.Data.Columns {
keys[col.Key] = col
}
if _, declared := keys["currency"]; declared {
t.Fatalf("currency is not a declared column: %+v", schema.Data.Columns)
}
if total := keys["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Sortable || total.Searchable {
t.Fatalf("total column = %+v", total)
}
if fee := keys["fee"]; fee.Type != "money" || fee.Currency != "EUR" {
t.Fatalf("fee column = %+v", fee)
}
rec = e.send(t, http.MethodGet, "/acme/payments/invoices?sort=number&dir=asc", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
}
var list cabana.ListEnvelope[[]cabana.AdminRecord]
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
if len(list.Data) != 2 {
t.Fatalf("rows = %+v", list.Data)
}
if row := list.Data[0]; row["total"] != "85.4193" || row["currency"] != "PLN" || row["fee"] != "1.5000" {
t.Fatalf("row 1 = %+v (want the amount strings and the currencyFrom value)", row)
}
if row := list.Data[1]; row["total"] != "5.0000" || row["currency"] != "EUR" || row["fee"] != nil {
t.Fatalf("row 2 = %+v", row)
}
if rec := e.send(t, http.MethodGet, "/acme/payments/invoices?sort=total&dir=asc", nil, true); rec.Code == http.StatusOK {
t.Fatalf("sort by a money column was accepted: %s", rec.Body.String())
}
}
// TestRecordActionDangerSmoke checks that the record response carries the
// F5 danger flag for a danger action and omits it for an ordinary one.
func TestRecordActionDangerSmoke(t *testing.T) {
e := newPaymentsEnv(t)
rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "D-1", "currency": "PLN", "total": "1.00"}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
id := dataID(t, rec.Body.Bytes())
show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/payments/invoices/%d", id), nil, true)
if show.Code != http.StatusOK {
t.Fatalf("show status=%d body=%s", show.Code, show.Body.String())
}
var out struct {
Meta struct {
Actions []json.RawMessage `json:"actions"`
} `json:"meta"`
}
if err := json.Unmarshal(show.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out.Meta.Actions) != 2 {
t.Fatalf("actions = %s", show.Body.String())
}
var void, resend cabana.RecordAction
if err := json.Unmarshal(out.Meta.Actions[0], &void); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(out.Meta.Actions[1], &resend); err != nil {
t.Fatal(err)
}
if void.Name != "void" || !void.Danger || resend.Name != "resend" || resend.Danger {
t.Fatalf("actions = %+v %+v", void, resend)
}
if !strings.Contains(string(out.Meta.Actions[0]), `"danger":true`) || strings.Contains(string(out.Meta.Actions[1]), "danger") {
t.Fatalf("raw actions = %s %s (danger true on void, the key absent on resend)", out.Meta.Actions[0], out.Meta.Actions[1])
}
}

View File

@@ -26,6 +26,12 @@ type ListColumn struct {
// 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"`
// Currency is the ISO 4217 code of a `type: money` column; CurrencyFrom
// names the model column that holds each row's code instead. Every list
// row carries the CurrencyFrom column's value under its own key, so the
// admin never guesses a currency (D-07).
Currency string `json:"currency,omitempty"`
CurrencyFrom string `json:"currencyFrom,omitempty"`
// fieldType is the plugin field type a column names as its type; the
// list then shows the type's ListValue.
@@ -224,6 +230,9 @@ type RecordAction struct {
Name string `json:"name"`
Label string `json:"label"`
Confirm string `json:"confirm,omitempty"`
// Danger marks an action whose confirm dialog takes the danger variant
// (pact.AdminRecordAction.Danger); an ordinary action omits the key.
Danger bool `json:"danger,omitempty"`
}
// FormRedirects are config_form.yaml defaultRedirect, create.* and update.*.

View File

@@ -2,3 +2,5 @@ name: New invoice
form: ~/plugins/acme/payments/models/invoice/fields.yaml
modelClass: Invoice
defaultRedirect: acme/payments/invoices
preview: {}
recordActions: [void, resend]

View File

@@ -2,5 +2,11 @@ columns:
number:
label: Number
searchable: true
currency:
label: Currency
total:
label: Total
type: money
currencyFrom: currency
fee:
label: Fee
type: money
currency: EUR

View File

@@ -133,7 +133,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope. |
| `pact.AdminBulkActionResult` | What a bulk action returns: an optional message for the toast and `Affected`, the number of records it changed. |
| `pact.HasAdminBulkActions` | Registers a controller's bulk actions for the `bulkActions` list of `config_list.yaml`. |
| `pact.AdminRecordAction` | One named record action: name, label, optional confirm text, extra permissions, the optional `Applies` rule and the Go `Run` function. |
| `pact.AdminRecordAction` | One named record action: name, label, optional confirm text, extra permissions, the `Danger` flag (the admin confirms it in the danger variant), the optional `Applies` rule and the Go `Run` function. |
| `pact.AdminRecordActionInput` | What a record action receives: `RecordID` and `Record`, the record loaded and row-locked through the form scope. |
| `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. |
| `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. |

View File

@@ -327,14 +327,19 @@ type HasAdminBulkActions interface {
// RequiredPermissions. Applies reports whether the action applies to the
// record in its current state: a nil Applies means the action always applies.
// Applies must be a pure read, because it also runs when a record is shown, to
// decide which actions the admin is offered.
// decide which actions the admin is offered. Danger only changes how the
// admin asks for confirmation; permissions and Applies are unchanged.
type AdminRecordAction struct {
Name string
Label string
Confirm string
Permissions []string
Applies func(ctx context.Context, record any) (bool, error) `json:"-"`
Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"`
// Danger marks an action that moves money out or cannot be undone: the
// admin's confirm dialog then uses its danger variant (serialized as
// `danger` in the record response). The footer button itself is unchanged.
Danger bool
Applies func(ctx context.Context, record any) (bool, error) `json:"-"`
Run func(ctx context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) `json:"-"`
}
// AdminRecordActionInput is what the framework hands an AdminRecordAction.