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

@@ -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')
})
})