feat(12.1-01): list row states from one controller call per page

- pact.ListRowStates with the fixed RowState set deleted, negative, disabled
- list response meta.row_states keyed by row id; unknown values dropped
- list messages rowStateDeleted, rowStateNegative, rowStateDisabled
- update writes through the scope the load used, so a soft-deleted record
  a controller includes stays soft-deleted
- DataTable row state badges and text styles
- roster fixture, smoke tests, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:45:44 +02:00
parent e0ccced76a
commit 61d5fc72ad
35 changed files with 749 additions and 44 deletions

View File

@@ -1116,6 +1116,20 @@
"recordCount": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"rowStateDeleted": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.MessageForms"
}
],
"description": "The row-state badge labels (D-12)."
},
"rowStateDisabled": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"rowStateNegative": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"searchPrompt": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
@@ -1132,6 +1146,9 @@
"emptySearch",
"emptySearchHint",
"recordCount",
"rowStateDeleted",
"rowStateDisabled",
"rowStateNegative",
"searchPrompt",
"selected"
],
@@ -1148,6 +1165,15 @@
"per_page": {
"type": "integer"
},
"row_states": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"type": "object"
},
"total": {
"type": "integer"
}
@@ -2412,6 +2438,7 @@
},
"/{vendor}/{plugin}/{controller}": {
"get": {
"description": "meta.row_states carries the states of the page's rows keyed by row id, each a subset of deleted, negative and disabled; it is absent when the controller reports no state.",
"parameters": [
{
"description": "Vendor",

View File

@@ -608,7 +608,10 @@ export interface paths {
path?: never;
cookie?: never;
};
/** List admin records */
/**
* List admin records
* @description meta.row_states carries the states of the page's rows keyed by row id, each a subset of deleted, negative and disabled; it is absent when the controller reports no state.
*/
get: {
parameters: {
query?: {
@@ -4546,6 +4549,10 @@ export interface components {
emptySearch: components["schemas"]["cabana.MessageForms"];
emptySearchHint: components["schemas"]["cabana.MessageForms"];
recordCount: components["schemas"]["cabana.MessageForms"];
/** @description The row-state badge labels (D-12). */
rowStateDeleted: components["schemas"]["cabana.MessageForms"];
rowStateDisabled: components["schemas"]["cabana.MessageForms"];
rowStateNegative: components["schemas"]["cabana.MessageForms"];
searchPrompt: components["schemas"]["cabana.MessageForms"];
selected: components["schemas"]["cabana.MessageForms"];
};
@@ -4553,6 +4560,9 @@ export interface components {
last_page: number;
page: number;
per_page: number;
row_states?: {
[key: string]: string[];
};
total: number;
};
"cabana.ListSchema": {

View File

@@ -6,6 +6,7 @@ import type { AdminRecord, ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
import type { RowId } from '../../app/listQuery'
import CellValue from './CellValue.vue'
import RowStateBadges from './RowStateBadges.vue'
// Schema-driven table (design screen 3, D-12, D-16): exactly the server's
// columns, each record read through its column keys. Optional checkbox
@@ -16,7 +17,11 @@ import CellValue from './CellValue.vue'
// before the first column's text and the remaining columns muted. Phase
// 12.2: with `openable` its rows open (the first cell's text is a button and
// a row click outside other controls does the same), and `trailing` adds a
// 56px last cell filled by the `trailing` slot.
// 56px last cell filled by the `trailing` slot. Phase 12.1 (UI-SPEC S4): a
// row may carry states from the fixed set deleted, negative, disabled; each
// shows as a text badge after the first cell's text plus a text style, and
// the row background, selection, sorting, links and row click stay as they
// are. A value outside the set is ignored.
const props = withDefaults(
defineProps<{
@@ -33,6 +38,10 @@ const props = withDefaults(
openable?: boolean
/** Adds a trailing 56px cell per row, filled by the `trailing` slot. */
trailing?: boolean
/** States per row id (list meta row_states). */
rowStates?: Record<string, string[]>
/** Badge text per state. */
stateLabels?: Record<string, string>
}>(),
{
loading: false,
@@ -44,6 +53,8 @@ const props = withDefaults(
variant: 'list',
openable: false,
trailing: false,
rowStates: () => ({}),
stateLabels: () => ({}),
},
)
const emit = defineEmits<{
@@ -146,11 +157,34 @@ function initials(value: unknown): string {
.join('')
}
function cellClass(column: ListColumn, columnIndex: number): string {
if (columnIndex === 0) {
return 'font-semibold'
const ROW_STATES = ['deleted', 'negative', 'disabled']
/** The row's known states in the fixed order; anything else is dropped. */
function statesOf(row: AdminRecord): string[] {
const id = rowId(row)
const states = id === null ? undefined : props.rowStates[String(id)]
if (!Array.isArray(states)) {
return []
}
return relation.value || column.relation ? 'text-muted' : ''
return ROW_STATES.filter((state) => states.includes(state))
}
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')
if (columnIndex === 0) {
return muted ? 'font-semibold text-muted' : 'font-semibold'
}
return muted || relation.value || column.relation ? 'text-muted' : ''
}
/** First-cell text of a row with states: struck through, danger or muted. */
function stateTextClass(states: string[]): string[] {
return [
'min-w-0 truncate',
states.includes('deleted') ? 'line-through' : 'no-underline',
states.includes('negative') ? 'text-danger' : states.includes('deleted') || states.includes('disabled') ? 'text-muted' : 'text-text',
]
}
const span = computed(() =>
@@ -238,6 +272,7 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
linkOf(row) || openable ? 'cursor-pointer' : '',
]"
:aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
:data-row-states="statesOf(row).length > 0 ? statesOf(row).join(' ') : undefined"
class="border-b border-border transition-colors duration-150 ease-out"
@click="openRow($event, row)"
>
@@ -257,7 +292,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-for="(column, columnIndex) in columns"
:key="column.key"
:class="cellClass(column, columnIndex)"
:class="cellClass(column, columnIndex, statesOf(row))"
class="px-3.5 first:pl-5 last:pr-5"
>
<span v-if="relation && columnIndex === 0" class="flex items-center gap-3">
@@ -278,6 +313,15 @@ const widths = ['w-3/5', 'w-2/5', 'w-1/2', 'w-3/4', 'w-1/3', 'w-2/3', 'w-1/2', '
</button>
<CellValue v-else :column="column" :value="row[column.key]" />
</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]" />
</RouterLink>
<span v-else :class="stateTextClass(statesOf(row))">
<CellValue :column="column" :value="row[column.key]" />
</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]" />
</RouterLink>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
// Row state badges (UI-SPEC S4, D-12): one text badge per state of a list
// row, in the fixed order deleted, negative, disabled. Colour and
// strike-through alone would fail WCAG 1.4.1, so every state is also said in
// text. The set is fixed: a value the SPA does not know renders nothing, and
// no class is ever built from a server value.
const props = defineProps<{
states: readonly string[]
labels: Record<string, string>
}>()
const STYLES: Record<string, string> = {
deleted: 'border border-border-strong text-muted',
negative: 'bg-danger-soft text-danger',
disabled: 'bg-subtle text-muted',
}
const ORDER = ['deleted', 'negative', 'disabled']
const badges = computed(() => ORDER.filter((state) => props.states.includes(state)))
</script>
<template>
<span v-if="badges.length > 0" class="flex shrink-0 gap-2">
<span
v-for="state in badges"
:key="state"
:data-row-state="state"
:class="STYLES[state]"
class="inline-flex h-6 shrink-0 items-center rounded-pill px-2.5 text-[12px] font-semibold whitespace-nowrap"
>{{ labels[state] ?? state }}</span
>
</span>
</template>

View File

@@ -78,6 +78,13 @@ const toolbarButtons = computed(() =>
buttons.value.filter((button) => button === 'delete' || toolbarActions.value.some((action) => action.name === button)),
)
const busyAction = ref<string | null>(null)
// Row states of the current page and their badge labels (D-12).
const rowStates = computed(() => meta.value?.row_states ?? {})
const stateLabels = computed(() => ({
deleted: message(messages.value?.rowStateDeleted),
negative: message(messages.value?.rowStateNegative),
disabled: message(messages.value?.rowStateDisabled),
}))
// Declared bulk actions the admin may run (D-09), in declared order. The
// built-in delete stays the toolbar button and is not part of the menu.
const bulkActions = computed(() => (schema.value?.bulkActions ?? []).filter((action) => action.name !== 'delete'))
@@ -393,6 +400,8 @@ async function onAction(name: string): Promise<void> {
:selected="selected"
:sortable="schema?.showSorting ?? false"
:sort="sort"
:row-states="rowStates"
:state-labels="stateLabels"
@update:selected="(ids: RowId[]) => (selected = ids)"
@sort="onSort"
>

View File

@@ -99,6 +99,15 @@
},
"selected": {
"other": "Selected :count"
},
"rowStateDeleted": {
"other": "Deleted"
},
"rowStateNegative": {
"other": "Blocked"
},
"rowStateDisabled": {
"other": "Inactive"
}
},
"meta": {

View File

@@ -33,7 +33,10 @@
"emptySearchHint": { "other": "No record matches “:term”. Try another search." },
"recordCount": { "one": ":count record", "other": ":count records" },
"searchPrompt": { "other": "Search…" },
"selected": { "other": ":count selected" }
"selected": { "other": ":count selected" },
"rowStateDeleted": { "other": "Deleted" },
"rowStateNegative": { "other": "Blocked" },
"rowStateDisabled": { "other": "Not active" }
},
"meta": { "locale": "en" }
},

View File

@@ -4,5 +4,14 @@
{ "id": 2, "name": "Bob Stone", "email": "bob@example.test" },
{ "id": 3, "name": "Cy Young", "email": "cy@example.test" }
],
"meta": { "page": 1, "per_page": 20, "total": 3, "last_page": 1 }
"meta": {
"page": 1,
"per_page": 20,
"total": 3,
"last_page": 1,
"row_states": {
"2": ["deleted", "negative"],
"3": ["disabled", "starred"]
}
}
}

View File

@@ -36,7 +36,10 @@
"emptySearchHint": { "other": "No widget matches “:term”." },
"recordCount": { "one": ":count widget", "other": ":count widgets" },
"searchPrompt": { "other": "Search widgets…" },
"selected": { "other": "Selected :count" }
"selected": { "other": "Selected :count" },
"rowStateDeleted": { "other": "Deleted" },
"rowStateNegative": { "other": "Blocked" },
"rowStateDisabled": { "other": "Inactive" }
},
"meta": { "locale": "en" }
},

View File

@@ -1,11 +1,13 @@
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
// (UI-SPEC S1, D-09) and the record action buttons (UI-SPEC S2, D-10).
// (UI-SPEC S1, D-09), the record action buttons (UI-SPEC S2, D-10) and the
// row state badges (UI-SPEC S4, D-12).
// Fixtures are neutral acme.roster.* data; no application
// names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import RecordActions from '../../src/components/form/RecordActions.vue'
import RowStateBadges from '../../src/components/list/RowStateBadges.vue'
import ToastHost from '../../src/components/ui/Toast.vue'
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
@@ -461,3 +463,85 @@ describe('record actions (UI-SPEC S2, D-10)', () => {
expect(dialog()).toBeNull()
})
})
describe('row state (UI-SPEC S4, D-12)', () => {
const rows = (wrapper: VueWrapper) => wrapper.findAll('tbody tr')
const firstCell = (wrapper: VueWrapper, index: number) => rows(wrapper)[index]!.findAll('td:not([data-select])')[0]!
const badges = (wrapper: VueWrapper, index: number) =>
firstCell(wrapper, index)
.findAll('[data-row-state]')
.map((badge) => [badge.attributes('data-row-state'), badge.text()])
it('leaves a row without states unchanged', async () => {
const { wrapper } = await mountApp('/acme/roster/people', routes())
const row = rows(wrapper)[0]!
expect(row.attributes('data-row-states')).toBeUndefined()
expect(row.find('[data-row-state]').exists()).toBe(false)
const link = firstCell(wrapper, 0).find('a')
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
expect(link.attributes('href')).toBe('/acme/roster/people/1')
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
})
it('renders two states as text badges in the fixed order and combines their text styles', async () => {
const { wrapper } = await mountApp('/acme/roster/people', routes())
const row = rows(wrapper)[1]!
expect(row.attributes('data-row-states')).toBe('deleted negative')
expect(badges(wrapper, 1)).toEqual([
['deleted', 'Deleted'],
['negative', 'Blocked'],
])
// deleted + negative: the first-cell text is struck through and danger;
// every cell is muted; the badges sit outside the struck text.
const cell = firstCell(wrapper, 1)
const link = cell.find('a')
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger', 'min-w-0', 'truncate']))
expect(link.find('[data-row-state]').exists()).toBe(false)
expect(link.attributes('href')).toBe('/acme/roster/people/2')
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
expect(row.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
const deleted = cell.find('[data-row-state="deleted"]')
expect(deleted.classes()).toEqual(expect.arrayContaining(['border-border-strong', 'text-muted', 'whitespace-nowrap', 'shrink-0']))
expect(deleted.classes()).not.toContain('line-through')
expect(cell.find('[data-row-state="negative"]').classes()).toEqual(expect.arrayContaining(['bg-danger-soft', 'text-danger']))
// The row background is not a state: selection still tints it.
expect(row.classes()).not.toContain('bg-danger-soft')
})
it('ignores a state outside the fixed set: no badge, no class, no attribute value', async () => {
const { wrapper } = await mountApp('/acme/roster/people', routes())
const row = rows(wrapper)[2]!
expect(row.attributes('data-row-states')).toBe('disabled')
expect(badges(wrapper, 2)).toEqual([['disabled', 'Not active']])
expect(row.html()).not.toContain('starred')
const cell = firstCell(wrapper, 2)
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
expect(cell.find('a').classes()).not.toContain('line-through')
expect(cell.find('[data-row-state="disabled"]').classes()).toEqual(expect.arrayContaining(['bg-subtle', 'text-muted']))
const only = clone(rosterListFixture)
only.meta.row_states = { '1': ['starred', '<b>x</b>'] }
const unknown = await mountApp('/acme/roster/people', routes({ [`GET ${LIST}`]: { body: only } }))
const first = rows(unknown.wrapper)[0]!
expect(first.attributes('data-row-states')).toBeUndefined()
expect(first.find('[data-row-state]').exists()).toBe(false)
expect(first.html()).not.toContain('starred')
expect(firstCell(unknown.wrapper, 0).find('a').classes()).toContain('text-text')
})
it('keeps selection and the row link working for a row with states', async () => {
const { wrapper } = await mountApp('/acme/roster/people', routes())
await rows(wrapper)[1]!.find('td[data-select] [role="checkbox"]').trigger('click')
expect(rows(wrapper)[1]!.classes()).toContain('bg-sel')
expect(rows(wrapper)[1]!.attributes('data-row-states')).toBe('deleted negative')
})
it('renders each known state once, in the fixed order, whatever the input order', () => {
const labels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Inactive' }
const wrapper = mount(RowStateBadges, { props: { states: ['disabled', 'bogus', 'deleted', 'negative'], labels } })
expect(wrapper.findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Deleted', 'Blocked', 'Inactive'])
const none = mount(RowStateBadges, { props: { states: ['bogus'], labels } })
expect(none.find('[data-row-state]').exists()).toBe(false)
expect(none.text()).toBe('')
})
})

View File

@@ -377,6 +377,7 @@ toolbar:
bulkActions: [activate, archive]
messages:
create: acme.roster::lang.people.create
rowStateDisabled: acme.roster::lang.people.state_inactive
```
The admin SPA shows the declared actions in a "Bulk actions" menu next to the selection, asks for confirmation (the action's `Confirm` text, or a default one), and posts the selected ids to `POST /{controller}/bulk/{action}`. cabana owns that route:

View File

@@ -29,6 +29,8 @@ Each record form makes a session key when it opens: 32 random bytes, base64url e
A list whose schema carries declared bulk actions shows a "Bulk actions" menu after the selection count. The menu lists only the actions the server offered to this administrator, and its button stays disabled until a row is selected. Choosing an action always asks for confirmation; the dialog stays open while the request runs, and the list reloads afterwards. See [Bulk actions](admin-controllers.md#bulk-actions).
A list row that carries a state (deleted, negative or disabled) shows a text badge for each state after its first cell, together with a text style; the row background is never changed. See [Row state](lists-and-filters.md#row-state).
## Types from OpenAPI
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date:

View File

@@ -114,6 +114,55 @@ scopes:
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.
## Row state
A list can mark rows with a state, as a WinterCMS list does with `listInjectRowClass`: a deleted record, a blocked account, an inactive one. The controller implements `pact.ListRowStates`:
```go src=modules/cabana/example_rowstate_test.go
package cabana_test
import (
"context"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
var _ pact.ListRowStates = PeopleController{}
// ListRowStates marks the rows of one list page. The framework calls it once
// per page with the page's records, in page order; the result is aligned
// with records, and a nil entry means the row has no state. db is the list's
// handle, for a hook that needs one query for the whole page.
func (PeopleController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
states := make([][]pact.RowState, len(records))
for i, record := range records {
person := record.(*Person)
if person.Banned {
states[i] = append(states[i], pact.RowStateNegative)
}
if !person.Active {
states[i] = append(states[i], pact.RowStateDisabled)
}
}
return states, nil
}
```
cabana calls the hook once per list page, with that page's records and the list's database handle, never once per row. A list does not run in a transaction, so `cabana.TxFromContext` reports none in this hook; use the handle it is given.
The set of states is fixed: `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`. A row may carry several. The list response sends them in `meta.row_states`, keyed by row id, always in that order and each at most once; a value outside the set is dropped and logged, and a controller without the hook sends no `row_states` key. The admin shows each state as a text badge after the row's first cell and with a text style (a deleted row is struck through and muted, a negative one is red, a disabled one is muted), so the state is never carried by colour alone.
The badge texts are list messages. Override them per list in the `messages` block of `config_list.yaml`:
| Key | Default text |
|-----|--------------|
| `rowStateDeleted` | Deleted |
| `rowStateNegative` | Blocked |
| `rowStateDisabled` | Inactive |
A list that shows soft-deleted records includes them in its scope, as `withTrashed()` does in WinterCMS: return `db.Unscoped()` (narrowed as needed) from `pact.ListExtendQuery` and `pact.FormExtendQuery`. Such a record can then be shown, updated and targeted by bulk and record actions, and it stays soft-deleted through an update. Code that writes to it, such as an action's `Run`, must use an unscoped handle too (`tx.Unscoped()`), or GORM adds its `deleted_at IS NULL` condition and the write matches nothing. Deleting it through the admin soft-deletes again, which changes nothing; a controller that wants the delete to be permanent removes the row in `pact.FormAfterDelete`.
## Scoping every list
To restrict which records an administrator sees at all, implement `pact.ListExtendQuery` on the controller. It receives the list query before search, filters and pagination are applied, so the restriction holds for every request. See [Admin controllers](admin-controllers.md) for the other hooks.

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-BrvHT7-x.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BOx4jB46.css">
<script type="module" crossorigin src="./assets/index-BgVbexs3.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BxJxH4xB.css">
</head>
<body>
<div id="app"></div>

View File

@@ -18,6 +18,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- 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.
- 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. 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.
- 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.

View File

@@ -335,6 +335,7 @@ func AdminFilterOptions() {}
// AdminList documents the record list route.
//
// @Summary List admin records
// @Description meta.row_states carries the states of the page's rows keyed by row id, each a subset of deleted, negative and disabled; it is absent when the controller reports no state.
// @Tags admin
// @Produce json
// @Security BackendBearer

View File

@@ -564,7 +564,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
return err
}
if update {
err = tx.Save(target).Error
// The row write uses the scope the load used: a soft-deleted
// record the controller's FormExtendQuery includes (Winter's
// withTrashed) is updated in place and stays soft-deleted.
err = tx.Unscoped().Save(target).Error
} else {
err = tx.Create(target).Error
}

View File

@@ -0,0 +1,28 @@
package cabana_test
import (
"context"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
var _ pact.ListRowStates = PeopleController{}
// ListRowStates marks the rows of one list page. The framework calls it once
// per page with the page's records, in page order; the result is aligned
// with records, and a nil entry means the row has no state. db is the list's
// handle, for a hook that needs one query for the whole page.
func (PeopleController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
states := make([][]pact.RowState, len(records))
for i, record := range records {
person := record.(*Person)
if person.Banned {
states[i] = append(states[i], pact.RowStateNegative)
}
if !person.Active {
states[i] = append(states[i], pact.RowStateDisabled)
}
}
return states, nil
}

View File

@@ -942,12 +942,17 @@ func (s *service) list(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
WriteData(w, http.StatusOK, result.Data, map[string]any{
meta := map[string]any{
"page": result.Meta.Page,
"per_page": result.Meta.PerPage,
"total": result.Meta.Total,
"last_page": result.Meta.LastPage,
})
}
// Row states (D-12) are sent only when a row of the page has one.
if len(result.Meta.RowStates) > 0 {
meta["row_states"] = result.Meta.RowStates
}
WriteData(w, http.StatusOK, result.Data, meta)
})
}

View File

@@ -42,7 +42,7 @@ const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","reco
// defaultListMessagesJSON is a compiled list's messages block when the YAML
// declares none: every key is a framework default phrase key (D-13).
const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"}}`
const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"},"rowStateDeleted":{"other":"backend::lang.messages.list.row_state_deleted"},"rowStateNegative":{"other":"backend::lang.messages.list.row_state_negative"},"rowStateDisabled":{"other":"backend::lang.messages.list.row_state_disabled"}}`
func TestListSchemaCompile(t *testing.T) {
columns := readListFixture(t, "testdata/list/all_columns.yaml")

View File

@@ -36,6 +36,10 @@ type listMessageKeys struct {
DeleteSelected string `yaml:"deleteSelected"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
// The row-state badge labels (D-12).
RowStateDeleted string `yaml:"rowStateDeleted"`
RowStateNegative string `yaml:"rowStateNegative"`
RowStateDisabled string `yaml:"rowStateDisabled"`
}
// ListMessages is a list schema's copy, every key resolved (same field order
@@ -51,6 +55,10 @@ type ListMessages struct {
DeleteSelected MessageForms `json:"deleteSelected"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
// The row-state badge labels (D-12).
RowStateDeleted MessageForms `json:"rowStateDeleted"`
RowStateNegative MessageForms `json:"rowStateNegative"`
RowStateDisabled MessageForms `json:"rowStateDisabled"`
}
// formMessageKeys is the config_form.yaml messages block: phrase keys.
@@ -143,6 +151,10 @@ var (
DeleteSelected: "backend::lang.list.delete_selected",
DeleteConfirm: "backend::lang.messages.list.delete_confirm",
Deleted: "backend::lang.messages.list.deleted",
RowStateDeleted: "backend::lang.messages.list.row_state_deleted",
RowStateNegative: "backend::lang.messages.list.row_state_negative",
RowStateDisabled: "backend::lang.messages.list.row_state_disabled",
}
formMessageDefaults = formMessageKeys{
Create: "backend::lang.messages.form.create",

View File

@@ -11,6 +11,9 @@ import (
"strings"
"testing"
"testing/fstest"
"time"
"gorm.io/gorm"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
@@ -454,3 +457,199 @@ func TestFormSchemaRecordActionsBoot(t *testing.T) {
}
})
}
// rosterList fetches the people list and returns its rows and row states.
func rosterList(t *testing.T, env *rosterEnv, query string) (cabana.ListEnvelope[[]cabana.AdminRecord], string) {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+query, "", "bearer")
var body cabana.ListEnvelope[[]cabana.AdminRecord]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("list body %s: %v", rec.Body.String(), err)
}
return body, rec.Body.String()
}
func rosterDeleted() gorm.DeletedAt {
return gorm.DeletedAt{Time: time.Now().UTC(), Valid: true}
}
// TestRowStateSmoke checks the row-state batch hook through the assembled
// router on PostgreSQL (D-12; T-12.1-07): one hook call per page, the fixed
// set in the fixed order, unknown values dropped, and no key without states.
func TestRowStateSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true})
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
gone := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone", Active: true, Banned: true, DeletedAt: rosterDeleted()})
all := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "All", Banned: true, DeletedAt: rosterDeleted()})
odd := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Active: true})
rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed"})
key := func(id uint) string { return fmt.Sprint(id) }
t.Run("one call per page, fixed set and order", func(t *testing.T) {
env.spy.takeStates()
body, raw := rosterList(t, env, "")
if len(body.Data) != 5 {
t.Fatalf("rows = %d, want the five acme people including the soft-deleted: %s", len(body.Data), raw)
}
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{5}) {
t.Fatalf("ListRowStates calls = %v, want one call with the page's five records", calls)
}
want := map[string][]string{
key(idle): {"disabled"},
key(gone): {"deleted", "negative"},
key(all): {"deleted", "negative", "disabled"},
}
if !reflect.DeepEqual(body.Meta.RowStates, want) {
t.Fatalf("row_states = %v, want %v", body.Meta.RowStates, want)
}
// A row without a state, and one whose only value is unknown, are
// left out; the unknown value is never sent.
if _, ok := body.Meta.RowStates[key(plain)]; ok {
t.Fatalf("a row without states is listed: %v", body.Meta.RowStates)
}
if _, ok := body.Meta.RowStates[key(odd)]; ok || strings.Contains(raw, "starred") {
t.Fatalf("an unknown state was sent: %s", raw)
}
// States are not row data: a column key can never collide with them.
for _, row := range body.Data {
if _, ok := row["row_states"]; ok {
t.Fatalf("row carries row_states: %v", row)
}
}
})
t.Run("each page gets its own call", func(t *testing.T) {
env.spy.takeStates()
body, _ := rosterList(t, env, "?search=Idle")
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{1}) {
t.Fatalf("ListRowStates calls = %v", calls)
}
if !reflect.DeepEqual(body.Meta.RowStates, map[string][]string{key(idle): {"disabled"}}) {
t.Fatalf("row_states = %v", body.Meta.RowStates)
}
})
t.Run("no key when no row has a state", func(t *testing.T) {
_, raw := rosterList(t, env, "?search=Plain")
if strings.Contains(raw, "row_states") {
t.Fatalf("row_states sent for a page without states: %s", raw)
}
env.spy.takeStates()
// An empty page does not call the hook.
_, raw = rosterList(t, env, "?search=nobody-matches-this")
if calls := env.spy.takeStates(); len(calls) != 0 || strings.Contains(raw, "row_states") {
t.Fatalf("empty page: calls=%v body=%s", calls, raw)
}
})
t.Run("a controller without the hook sends no row_states", func(t *testing.T) {
demo, demoDB := newActEnv(t)
actInsert(t, demoDB, "plain", "acme")
rec := demo.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets", "", "bearer")
if strings.Contains(rec.Body.String(), "row_states") || !strings.Contains(rec.Body.String(), `"total":1`) {
t.Fatalf("list = %s", rec.Body.String())
}
})
t.Run("the list schema carries the badge labels", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
for _, want := range []string{`"rowStateDeleted":{"other":"Deleted"}`, `"rowStateNegative":{"other":"Blocked"}`, `"rowStateDisabled":{"other":"Not active"}`} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("list schema lacks %s: %s", want, rec.Body.String())
}
}
})
}
// TestSoftDeletedRecordSmoke checks D-13's framework side: a soft-deleted
// record that the controller's list and form scopes include can be shown,
// updated, targeted by bulk and record actions and permanently deleted
// through the admin API.
func TestSoftDeletedRecordSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
trashed := func(name string) uint {
return rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name, DeletedAt: rosterDeleted()})
}
exists := func(id uint) bool {
var n int64
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("id = ?", id).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n == 1
}
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
t.Run("show and update keep it soft-deleted", func(t *testing.T) {
id := trashed("Trashed")
rec := env.expect(t, http.StatusOK, http.MethodGet, path(id), "", "bearer")
if !strings.Contains(rec.Body.String(), `"name":"Trashed"`) {
t.Fatalf("show = %s", rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodPut, path(id), `{"name":"Renamed","email":"renamed@example.test"}`, "bearer")
if !strings.Contains(rec.Body.String(), `"name":"Renamed"`) {
t.Fatalf("update = %s", rec.Body.String())
}
person := rosterLoad(t, gdb, id)
if person.Name != "Renamed" || person.Email != "renamed@example.test" || !person.DeletedAt.Valid {
t.Fatalf("stored person = %+v", person)
}
var rows int64
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("name = ?", "Renamed").Count(&rows).Error; err != nil || rows != 1 {
t.Fatalf("rows named Renamed = %d err=%v, want the one updated row", rows, err)
}
})
t.Run("bulk and record actions reach it", func(t *testing.T) {
id := trashed("Dormant")
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/activate", fmt.Sprintf(`{"ids":[%d]}`, id), "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 1 {
t.Fatalf("bulk result = %+v", result)
}
if person := rosterLoad(t, gdb, id); !person.Active || !person.DeletedAt.Valid {
t.Fatalf("after the bulk action: %+v", person)
}
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Barred", Active: true, Banned: true, DeletedAt: rosterDeleted()})
if got := rosterOffered(t, env, banned, "bearer"); !reflect.DeepEqual(got, []string{"reinstate"}) {
t.Fatalf("offered = %v", got)
}
env.expect(t, http.StatusOK, http.MethodPost, path(banned)+"/actions/reinstate", `{}`, "bearer")
if person := rosterLoad(t, gdb, banned); person.Banned || !person.DeletedAt.Valid {
t.Fatalf("after the record action: %+v", person)
}
})
t.Run("the form delete removes it for good", func(t *testing.T) {
id := trashed("Purged")
rec := env.expect(t, http.StatusOK, http.MethodDelete, path(id), "", "bearer")
if !strings.Contains(rec.Body.String(), `"deleted":1`) || exists(id) {
t.Fatalf("delete = %s, row still exists: %v", rec.Body.String(), exists(id))
}
// A live person is removed for good as well: the hook is the
// controller's rule.
live := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Live"})
env.expect(t, http.StatusOK, http.MethodDelete, path(live), "", "bearer")
if exists(live) {
t.Fatal("the live person still exists")
}
})
t.Run("bulk delete removes it for good", func(t *testing.T) {
first, second := trashed("First"), rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"})
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", fmt.Sprintf(`{"ids":[%d,%d]}`, first, second), "bearer")
if !strings.Contains(rec.Body.String(), `"deleted":2`) || exists(first) || exists(second) {
t.Fatalf("bulk delete = %s", rec.Body.String())
}
})
t.Run("a controller that hides soft-deleted rows behaves as before", func(t *testing.T) {
// Outside the acme scope nothing changes either: a soft-deleted
// person of another tenant stays invisible.
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", DeletedAt: rosterDeleted()})
env.expect(t, http.StatusNotFound, http.MethodGet, path(foreign), "", "bearer")
env.expect(t, http.StatusNotFound, http.MethodPut, path(foreign), `{"name":"x"}`, "bearer")
if !exists(foreign) || rosterLoad(t, gdb, foreign).Name != "Zed" {
t.Fatal("an out-of-scope soft-deleted person was changed")
}
})
}

View File

@@ -49,6 +49,22 @@ type rosterSpy struct {
mu sync.Mutex
bulk []pact.AdminBulkActionInput
record []pact.AdminRecordActionInput
// states counts ListRowStates calls and keeps the size of each page.
states []int
}
func (s *rosterSpy) recordStates(n int) {
s.mu.Lock()
defer s.mu.Unlock()
s.states = append(s.states, n)
}
func (s *rosterSpy) takeStates() []int {
s.mu.Lock()
defer s.mu.Unlock()
out := s.states
s.states = nil
return out
}
func (s *rosterSpy) recordOne(in pact.AdminRecordActionInput) {
@@ -125,12 +141,52 @@ func (rosterController) RequiredPermissions() []string { return []string{"acme.r
func (rosterController) NewRecord() any { return &rosterPerson{} }
// ListExtendQuery and FormExtendQuery scope every lookup to the acme tenant,
// so a person of another tenant is out of scope.
// so a person of another tenant is out of scope. Both include soft-deleted
// people, as a WinterCMS controller with withTrashed does.
func (rosterController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("tenant = ?", "acme")
return db.Unscoped().Where("tenant = ?", "acme")
}
func (rosterController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("tenant = ?", "acme")
return db.Unscoped().Where("tenant = ?", "acme")
}
// ListRowStates marks a page of people: deleted when soft-deleted, negative
// when banned, disabled when not active. It answers out of order and with a
// duplicate and, for a person named Odd, a value outside the fixed set, so
// the framework's reduction is visible.
func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
c.spy.recordStates(len(records))
if _, inTx := cabana.TxFromContext(ctx); inTx || db == nil {
return nil, fmt.Errorf("a list hook gets the list handle, not a transaction")
}
out := make([][]pact.RowState, len(records))
for i, record := range records {
person := record.(*rosterPerson)
if !person.Active {
out[i] = append(out[i], pact.RowStateDisabled, pact.RowStateDisabled)
}
if person.Banned {
out[i] = append(out[i], pact.RowStateNegative)
}
if person.DeletedAt.Valid {
out[i] = append(out[i], pact.RowStateDeleted)
}
if person.Name == "Odd" {
out[i] = append(out[i], pact.RowState("starred"))
}
}
return out, nil
}
// FormAfterDelete removes the person for good inside the delete's
// transaction: the list keeps soft-deleted people, so deleting one there is
// permanent.
func (rosterController) FormAfterDelete(ctx context.Context, model any) error {
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return fmt.Errorf("no transaction on the context")
}
return tx.Unscoped().Delete(model).Error
}
// AdminBulkActions: activate needs acme.roster.manage and sets active on the
@@ -152,7 +208,8 @@ func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
if person.Active {
continue
}
if err := tx.Model(person).Update("active", true).Error; err != nil {
// Unscoped: the list scope includes soft-deleted people.
if err := tx.Unscoped().Model(person).Update("active", true).Error; err != nil {
return pact.AdminBulkActionResult{}, err
}
changed++
@@ -194,7 +251,7 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
if !ok {
return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context")
}
if err := tx.Model(in.Record).Update("active", true).Error; err != nil {
if err := tx.Unscoped().Model(in.Record).Update("active", true).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil
@@ -210,7 +267,7 @@ func (c rosterController) AdminRecordActions() []pact.AdminRecordAction {
if !ok {
return pact.AdminRecordActionResult{}, fmt.Errorf("no transaction on the context")
}
if err := tx.Model(in.Record).Update("banned", false).Error; err != nil {
if err := tx.Unscoped().Model(in.Record).Update("banned", false).Error; err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{}, nil

View File

@@ -5,8 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"slices"
"strconv"
"strings"
"time"
@@ -28,12 +30,16 @@ type ListInput struct {
Filters map[string]string
}
// ListMeta is the D-11 pagination block.
// ListMeta is the D-11 pagination block. RowStates are the states of the
// page's rows keyed by row id (a decimal string), each a subset of deleted,
// negative and disabled in that order; rows without a state are left out, and
// the key is absent when the controller reports no state at all.
type ListMeta struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
LastPage int `json:"last_page"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
LastPage int `json:"last_page"`
RowStates map[string][]string `json:"row_states,omitempty"`
}
// ListResult is one page of projected rows.
@@ -139,21 +145,82 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li
}
values := slice.Elem()
data := make([]map[string]any, 0, values.Len())
records := make([]any, 0, values.Len())
for i := 0; i < values.Len(); i++ {
data = append(data, projectRow(values.Index(i).Addr().Interface(), cc.Controller, cc.List.Columns))
record := values.Index(i).Addr().Interface()
records = append(records, record)
data = append(data, projectRow(record, cc.Controller, cc.List.Columns))
}
states, err := listRowStates(ctx, db, cc, records)
if err != nil {
return nil, err
}
paged := lagoon.Paginate(data, page, per, total)
return &ListResult{
Data: paged.Data,
Meta: ListMeta{
Page: paged.Meta.CurrentPage,
PerPage: paged.Meta.PerPage,
Total: paged.Meta.Total,
LastPage: paged.Meta.LastPage,
Page: paged.Meta.CurrentPage,
PerPage: paged.Meta.PerPage,
Total: paged.Meta.Total,
LastPage: paged.Meta.LastPage,
RowStates: states,
},
}, nil
}
// rowStateOrder is the fixed set of row states and the order they are sent in.
var rowStateOrder = []pact.RowState{pact.RowStateDeleted, pact.RowStateNegative, pact.RowStateDisabled}
// listRowStates asks a controller implementing pact.ListRowStates for the
// states of one page of records, in one call (D-12). Each row keeps only the
// known states, once each, in the fixed order; an unknown value is dropped
// and logged, never sent. The result is keyed by primary key and nil when no
// row has a state. A hook error or a result that is not index-aligned with
// records fails the list.
func listRowStates(ctx context.Context, db *gorm.DB, cc *CompiledController, records []any) (map[string][]string, error) {
hook, ok := cc.Controller.(pact.ListRowStates)
if !ok || hook == nil || len(records) == 0 {
return nil, nil
}
result, err := hook.ListRowStates(ctx, db.WithContext(ctx), records)
if err != nil {
slog.Error("cabana: list row states failed", "controller", controllerID(cc), "error", err)
return nil, err
}
if len(result) != len(records) {
slog.Error("cabana: list row states are not aligned with the page", "controller", controllerID(cc), "records", len(records), "states", len(result))
return nil, fmt.Errorf("cabana: controller %s returned %d row states for %d records", controllerID(cc), len(result), len(records))
}
var out map[string][]string
for i, states := range result {
if len(states) == 0 {
continue
}
has := map[pact.RowState]bool{}
for _, state := range states {
if !slices.Contains(rowStateOrder, state) {
slog.Warn("cabana: unknown list row state dropped", "controller", controllerID(cc), "state", string(state))
continue
}
has[state] = true
}
kept := make([]string, 0, len(has))
for _, state := range rowStateOrder {
if has[state] {
kept = append(kept, string(state))
}
}
if len(kept) == 0 {
continue
}
if out == nil {
out = map[string][]string{}
}
out[uitoa(pkUint(records[i]))] = kept
}
return out, nil
}
func normalizePage(schema *ListSchema, in ListInput) (int, int, error) {
page := 1
if in.Page != "" {

View File

@@ -11,3 +11,4 @@ toolbar:
bulkActions: [activate, archive]
messages:
create: acme.roster::lang.people.create
rowStateDisabled: acme.roster::lang.people.state_inactive

View File

@@ -11,3 +11,4 @@ people:
activated: The person was activated.
reinstate: Reinstate
reinstate_confirm: Lift the ban on this person?
state_inactive: Not active

View File

@@ -11,3 +11,4 @@ people:
activated: Osoba została aktywowana.
reinstate: Przywróć
reinstate_confirm: Zdjąć blokadę z tej osoby?
state_inactive: Nieaktywna

View File

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

View File

@@ -420,6 +420,30 @@ type ListExtendQuery interface {
ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
}
// RowState is one state an admin list row may carry. The set is fixed; the
// admin shows each state as a text badge and a row text style.
type RowState string
// The row states the admin understands.
const (
// RowStateDeleted marks a soft-deleted record.
RowStateDeleted RowState = "deleted"
// RowStateNegative marks a record in a blocked or otherwise negative state.
RowStateNegative RowState = "negative"
// RowStateDisabled marks an inactive record.
RowStateDisabled RowState = "disabled"
)
// ListRowStates optionally marks the rows of an admin list with states. The
// framework calls it once per list page with the page's records, in page
// order, and the list's database handle: a list does not run in a
// transaction, so cabana.TxFromContext reports none here. The result is
// index-aligned with records; a row may carry several states and a nil entry
// means none. Values outside the three RowState constants are dropped.
type ListRowStates interface {
ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]RowState, error)
}
// ListRelationColumnMapper maps a source-schema relation column onto the
// physical column exposed by the related Go model. It supports legacy admin
// schemas whose public field names no longer match the database schema.

View File

@@ -247,3 +247,15 @@ func TestRelationHookInterfaces(t *testing.T) {
}
}
}
// TestRowStateValues pins the wire values of the fixed row-state set: the
// admin SPA and plugin CSS match on these exact strings.
func TestRowStateValues(t *testing.T) {
got := []RowState{RowStateDeleted, RowStateNegative, RowStateDisabled}
want := []string{"deleted", "negative", "disabled"}
for i, state := range got {
if string(state) != want[i] {
t.Fatalf("row state %d = %q, want %q", i, state, want[i])
}
}
}

View File

@@ -173,6 +173,9 @@ messages:
deleted:
one: "Deleted :count record"
other: "Deleted :count records"
row_state_deleted: Deleted
row_state_negative: Blocked
row_state_disabled: Inactive
form:
create: New record
update: Edit record

View File

@@ -189,6 +189,9 @@ messages:
few: "Usunięto :count rekordy"
many: "Usunięto :count rekordów"
other: "Usunięto :count rekordu"
row_state_deleted: Usunięty
row_state_negative: Zablokowany
row_state_disabled: Nieaktywny
form:
create: Nowy rekord
update: Edycja rekordu