From 0c8605123ebf0bd151b4c1c3970ac9d05a2e6fb8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 13 Oct 2026 11:22:36 +0200 Subject: [PATCH] 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 --- admin/openapi/admin.json | 11 ++ admin/src/api/schema.d.ts | 13 ++ admin/src/components/form/RecordActions.vue | 5 +- admin/src/components/list/CellValue.vue | 31 ++- admin/src/components/list/DataTable.vue | 24 ++- admin/src/styles/main.css | 71 +++++++ admin/src/views/PreviewView.vue | 11 +- admin/tests/form/PreviewView.test.ts | 36 ++++ admin/tests/form/RecordActions.test.ts | 25 ++- admin/tests/list/CellValue.test.ts | 66 ++++++- docs/backend/admin-controllers.md | 3 +- docs/backend/lists-and-filters.md | 22 ++- ...{editor-q6O2bPl6.js => editor-CkvLclbQ.js} | 2 +- .../boardwalk/dist/assets/index-B8hF3DCG.js | 11 ++ .../boardwalk/dist/assets/index-BPd1k6k8.js | 11 -- .../boardwalk/dist/assets/index-BxyrlyKr.css | 1 - .../boardwalk/dist/assets/index-c6AgbZf1.css | 1 + modules/boardwalk/dist/index.html | 4 +- modules/cabana/README.md | 9 +- modules/cabana/crud.go | 1 + modules/cabana/field_money.go | 52 +++++ modules/cabana/http.go | 14 ++ modules/cabana/list_schema.go | 33 ++-- modules/cabana/payments_smoke_test.go | 180 +++++++++++++++++- modules/cabana/schema_types.go | 9 + .../controllers/invoices/config_form.yaml | 2 + .../payments/models/invoice/columns.yaml | 10 +- modules/pact/README.md | 2 +- modules/pact/capabilities.go | 11 +- 29 files changed, 615 insertions(+), 56 deletions(-) rename modules/boardwalk/dist/assets/{editor-q6O2bPl6.js => editor-CkvLclbQ.js} (99%) create mode 100644 modules/boardwalk/dist/assets/index-B8hF3DCG.js delete mode 100644 modules/boardwalk/dist/assets/index-BPd1k6k8.js delete mode 100644 modules/boardwalk/dist/assets/index-BxyrlyKr.css create mode 100644 modules/boardwalk/dist/assets/index-c6AgbZf1.css diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 3bb0cbd..82a8f77 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -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" }, diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index ecec282..402705e 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -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; }; diff --git a/admin/src/components/form/RecordActions.vue b/admin/src/components/form/RecordActions.vue index 2883d61..e60bc4c 100644 --- a/admin/src/components/form/RecordActions.vue +++ b/admin/src/components/form/RecordActions.vue @@ -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 { { message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }), confirmLabel: action.label, - danger: false, + danger: action.danger, }, async () => { running.value = true diff --git a/admin/src/components/list/CellValue.vue b/admin/src/components/list/CellValue.vue index 0cd6b26..4767267 100644 --- a/admin/src/components/list/CellValue.vue +++ b/admin/src/components/list/CellValue.vue @@ -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 }>() 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(() => { — {{ display }} + {{ display }} diff --git a/admin/src/components/list/DataTable.vue b/admin/src/components/list/DataTable.vue index 5b7745e..8870a14 100644 --- a/admin/src/components/list/DataTable.vue +++ b/admin/src/components/list/DataTable.vue @@ -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" > - + - + - + - + - + diff --git a/admin/src/styles/main.css b/admin/src/styles/main.css index ba0c47d..e1b29f7 100644 --- a/admin/src/styles/main.css +++ b/admin/src/styles/main.css @@ -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; diff --git a/admin/src/views/PreviewView.vue b/admin/src/views/PreviewView.vue index 6ebe2ef..985ccbf 100644 --- a/admin/src/views/PreviewView.vue +++ b/admin/src/views/PreviewView.vue @@ -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(() => (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(() => { @@ -290,7 +297,7 @@ void load() @gone="onGone" />