feat(12.1-01): declared bulk actions on admin lists
- pact.HasAdminBulkActions with AdminBulkAction, its input and result
- config_list.yaml bulkActions, compiled fail-loud, needs showCheckboxes
- POST .../{controller}/bulk/{action}: ids resolved and locked through the
list scope in one transaction; partial selection is 409
- list schema offers declared actions per principal, with confirm text
- admin SPA bulk actions menu with confirm, busy state and failure toasts
- acme.roster fixture, tracer test, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
@@ -176,6 +176,9 @@
|
|||||||
},
|
},
|
||||||
"cabana.BulkAction": {
|
"cabana.BulkAction": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"confirm": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -188,6 +191,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.BulkActionResult": {
|
||||||
|
"properties": {
|
||||||
|
"affected": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"affected",
|
||||||
|
"message"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.BulkResult": {
|
"cabana.BulkResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
@@ -367,6 +385,21 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"cabana.Envelope-cabana_BulkActionResult": {
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/cabana.BulkActionResult"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/cabana.SuccessMeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"data",
|
||||||
|
"meta"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"cabana.Envelope-cabana_BulkResult": {
|
"cabana.Envelope-cabana_BulkResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -2727,6 +2760,131 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/{vendor}/{plugin}/{controller}/bulk/{action}": {
|
||||||
|
"post": {
|
||||||
|
"description": "Runs a bulk action the controller registers and the list's bulkActions declares. The ids are resolved and row-locked through the controller's list scope in one transaction before the action runs: a selection that matches no scoped row answers affected 0 without running the action, and a partial selection is a 409 that changes nothing.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Vendor",
|
||||||
|
"in": "path",
|
||||||
|
"name": "vendor",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Plugin",
|
||||||
|
"in": "path",
|
||||||
|
"name": "plugin",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Controller",
|
||||||
|
"in": "path",
|
||||||
|
"name": "controller",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Bulk action name",
|
||||||
|
"in": "path",
|
||||||
|
"name": "action",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.AdminIDsRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Record ids",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.Envelope-cabana_BulkActionResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Not Found"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Conflict"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Unprocessable Entity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BackendBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Run a declared bulk action",
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.",
|
"description": "Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.",
|
||||||
|
|||||||
107
admin/src/api/schema.d.ts
vendored
107
admin/src/api/schema.d.ts
vendored
@@ -862,6 +862,104 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/{vendor}/{plugin}/{controller}/bulk/{action}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Run a declared bulk action
|
||||||
|
* @description Runs a bulk action the controller registers and the list's bulkActions declares. The ids are resolved and row-locked through the controller's list scope in one transaction before the action runs: a selection that matches no scoped row answers affected 0 without running the action, and a partial selection is a 409 that changes nothing.
|
||||||
|
*/
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Vendor */
|
||||||
|
vendor: string;
|
||||||
|
/** @description Plugin */
|
||||||
|
plugin: string;
|
||||||
|
/** @description Controller */
|
||||||
|
controller: string;
|
||||||
|
/** @description Bulk action name */
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** @description Record ids */
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.AdminIDsRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.Envelope-cabana_BulkActionResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unprocessable Entity */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
"/{vendor}/{plugin}/{controller}/fields/{field}/options": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4046,9 +4144,14 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
"cabana.BulkAction": {
|
"cabana.BulkAction": {
|
||||||
|
confirm?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
"cabana.BulkActionResult": {
|
||||||
|
affected: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
"cabana.BulkResult": {
|
"cabana.BulkResult": {
|
||||||
deleted: number;
|
deleted: number;
|
||||||
};
|
};
|
||||||
@@ -4092,6 +4195,10 @@ export interface components {
|
|||||||
data: components["schemas"]["cabana.AdminRecord"];
|
data: components["schemas"]["cabana.AdminRecord"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
};
|
};
|
||||||
|
"cabana.Envelope-cabana_BulkActionResult": {
|
||||||
|
data: components["schemas"]["cabana.BulkActionResult"];
|
||||||
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
};
|
||||||
"cabana.Envelope-cabana_BulkResult": {
|
"cabana.Envelope-cabana_BulkResult": {
|
||||||
data: components["schemas"]["cabana.BulkResult"];
|
data: components["schemas"]["cabana.BulkResult"];
|
||||||
meta: components["schemas"]["cabana.SuccessMeta"];
|
meta: components["schemas"]["cabana.SuccessMeta"];
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export type FilterOption = Schemas['cabana.FilterOption']
|
|||||||
export type RecordMeta = Schemas['cabana.RecordMeta']
|
export type RecordMeta = Schemas['cabana.RecordMeta']
|
||||||
export type RecordEnvelope = Schemas['cabana.RecordEnvelope']
|
export type RecordEnvelope = Schemas['cabana.RecordEnvelope']
|
||||||
export type BulkResult = Schemas['cabana.BulkResult']
|
export type BulkResult = Schemas['cabana.BulkResult']
|
||||||
|
/** A checkbox action of a list: the built-in delete or a declared bulk action. */
|
||||||
|
export type BulkAction = Schemas['cabana.BulkAction']
|
||||||
|
/** A declared bulk action's answer: the toast message and the affected count. */
|
||||||
|
export type BulkActionResult = Schemas['cabana.BulkActionResult']
|
||||||
/** Body of a widget action: the record id (absent on create) and fill values. */
|
/** Body of a widget action: the record id (absent on create) and fill values. */
|
||||||
export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
||||||
/** A widget or toolbar action result: the toast message and fill values. */
|
/** A widget or toolbar action result: the toast message and fill values. */
|
||||||
|
|||||||
60
admin/src/components/list/BulkActionsMenu.vue
Normal file
60
admin/src/components/list/BulkActionsMenu.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from 'reka-ui'
|
||||||
|
import { ChevronDown } from '@lucide/vue'
|
||||||
|
import type { BulkAction } from '../../api/types'
|
||||||
|
import { t } from '../../app/i18n'
|
||||||
|
import Button from '../ui/Button.vue'
|
||||||
|
|
||||||
|
// Bulk actions menu (UI-SPEC S1, D-09): the declared bulk actions the server
|
||||||
|
// already filtered to what the admin may run, in declared order. The built-in
|
||||||
|
// delete is not listed here; it stays the toolbar button. Labels come from
|
||||||
|
// the server and are rendered as text. The trigger keeps its label while
|
||||||
|
// disabled (nothing selected, or an action running).
|
||||||
|
defineProps<{
|
||||||
|
actions: BulkAction[]
|
||||||
|
disabled: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ select: [name: string] }>()
|
||||||
|
|
||||||
|
const trigger = ref<InstanceType<typeof Button> | null>(null)
|
||||||
|
|
||||||
|
/** Moves focus back to the trigger after the confirmation closes. */
|
||||||
|
function focus(): void {
|
||||||
|
const el = trigger.value?.$el
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DropdownMenuRoot :modal="false">
|
||||||
|
<DropdownMenuTrigger as-child :disabled="disabled">
|
||||||
|
<Button ref="trigger" variant="outline" size="md" data-action="bulk-actions" :disabled="disabled">
|
||||||
|
{{ t('backend::lang.list.bulk_actions') }}
|
||||||
|
<ChevronDown :size="16" class="text-muted" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-bulk-menu
|
||||||
|
align="end"
|
||||||
|
:side-offset="8"
|
||||||
|
class="z-50 flex w-[240px] max-w-[320px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.name"
|
||||||
|
:data-bulk-action="action.name"
|
||||||
|
class="flex min-h-10 cursor-pointer items-center rounded-control px-3 py-2 outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover"
|
||||||
|
@select="emit('select', action.name)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuRoot>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Check, Search, Trash2 } from '@lucide/vue'
|
import { Check, Search, Trash2 } from '@lucide/vue'
|
||||||
import type { ToolbarAction } from '../../api/types'
|
import type { BulkAction, ToolbarAction } from '../../api/types'
|
||||||
import { t } from '../../app/i18n'
|
import { t } from '../../app/i18n'
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
|
import BulkActionsMenu from './BulkActionsMenu.vue'
|
||||||
|
|
||||||
// List toolbar (design screen 3, D-14): the search input and the toolbar
|
// List toolbar (design screen 3, D-14): the search input and the toolbar
|
||||||
// buttons in declared order. Delete is a disabled outline button without a
|
// buttons in declared order. Delete is a disabled outline button without a
|
||||||
@@ -11,6 +12,9 @@ import Button from '../ui/Button.vue'
|
|||||||
// A registered plugin action (D-12) is an outline button labelled from
|
// A registered plugin action (D-12) is an outline button labelled from
|
||||||
// `actions`, the list the server already filtered to what the admin may run;
|
// `actions`, the list the server already filtered to what the admin may run;
|
||||||
// it ignores the selection and is disabled and busy while its POST runs.
|
// it ignores the selection and is disabled and busy while its POST runs.
|
||||||
|
// Declared bulk actions (D-09) sit in one menu directly after the selection
|
||||||
|
// pill; the menu is not rendered without a permitted action, and its trigger
|
||||||
|
// is disabled while nothing is selected or a bulk action runs.
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
showSearch: boolean
|
showSearch: boolean
|
||||||
@@ -22,12 +26,23 @@ const props = withDefaults(
|
|||||||
deleteLabel: string
|
deleteLabel: string
|
||||||
actions?: ToolbarAction[]
|
actions?: ToolbarAction[]
|
||||||
busyAction?: string | null
|
busyAction?: string | null
|
||||||
|
bulkActions?: BulkAction[]
|
||||||
|
bulkBusy?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ actions: () => [], busyAction: null },
|
{ actions: () => [], busyAction: null, bulkActions: () => [], bulkBusy: false },
|
||||||
)
|
)
|
||||||
const emit = defineEmits<{ 'update:search': [value: string]; delete: []; action: [name: string] }>()
|
const emit = defineEmits<{ 'update:search': [value: string]; delete: []; action: [name: string]; bulk: [name: string] }>()
|
||||||
|
|
||||||
const labels = computed(() => new Map(props.actions.map((action) => [action.name, action.label])))
|
const labels = computed(() => new Map(props.actions.map((action) => [action.name, action.label])))
|
||||||
|
|
||||||
|
const bulkMenu = ref<InstanceType<typeof BulkActionsMenu> | null>(null)
|
||||||
|
|
||||||
|
/** Moves focus to the bulk menu trigger (after its confirmation closes). */
|
||||||
|
function focusBulk(): void {
|
||||||
|
bulkMenu.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focusBulk })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -45,7 +60,7 @@ const labels = computed(() => new Map(props.actions.map((action) => [action.name
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<span v-else />
|
<span v-else />
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex flex-wrap items-center justify-end gap-2.5">
|
||||||
<span
|
<span
|
||||||
v-if="selectedCount > 0"
|
v-if="selectedCount > 0"
|
||||||
data-selected-pill
|
data-selected-pill
|
||||||
@@ -53,6 +68,13 @@ const labels = computed(() => new Map(props.actions.map((action) => [action.name
|
|||||||
>
|
>
|
||||||
<Check :size="14" aria-hidden="true" />{{ selectedLabel }}
|
<Check :size="14" aria-hidden="true" />{{ selectedLabel }}
|
||||||
</span>
|
</span>
|
||||||
|
<BulkActionsMenu
|
||||||
|
v-if="bulkActions.length > 0"
|
||||||
|
ref="bulkMenu"
|
||||||
|
:actions="bulkActions"
|
||||||
|
:disabled="selectedCount === 0 || bulkBusy"
|
||||||
|
@select="(name: string) => emit('bulk', name)"
|
||||||
|
/>
|
||||||
<template v-for="button in buttons" :key="button">
|
<template v-for="button in buttons" :key="button">
|
||||||
<Button
|
<Button
|
||||||
v-if="button === 'delete'"
|
v-if="button === 'delete'"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Plus, SearchX } from '@lucide/vue'
|
import { Plus, SearchX } from '@lucide/vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { AdminRecord, ListMeta, ListQuery, ListSchema } from '../api/types'
|
import type { AdminRecord, BulkActionResult, ListMeta, ListQuery, ListSchema } from '../api/types'
|
||||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||||
import { message, t } from '../app/i18n'
|
import { message, t, tc } from '../app/i18n'
|
||||||
import { listQueryKey, parseListQuery, toListQuery, type RowId } from '../app/listQuery'
|
import { listQueryKey, parseListQuery, toListQuery, type RowId } from '../app/listQuery'
|
||||||
import { loadControllerAssets } from '../app/pluginAssets'
|
import { loadControllerAssets } from '../app/pluginAssets'
|
||||||
import { mapWinterUrl } from '../app/winterUrl'
|
import { mapWinterUrl } from '../app/winterUrl'
|
||||||
@@ -78,6 +78,11 @@ const toolbarButtons = computed(() =>
|
|||||||
buttons.value.filter((button) => button === 'delete' || toolbarActions.value.some((action) => action.name === button)),
|
buttons.value.filter((button) => button === 'delete' || toolbarActions.value.some((action) => action.name === button)),
|
||||||
)
|
)
|
||||||
const busyAction = ref<string | null>(null)
|
const busyAction = ref<string | null>(null)
|
||||||
|
// 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'))
|
||||||
|
const bulkBusy = ref(false)
|
||||||
|
const toolbar = ref<InstanceType<typeof ListToolbar> | null>(null)
|
||||||
|
|
||||||
const perPageOptions = computed(() => {
|
const perPageOptions = computed(() => {
|
||||||
const options = schema.value?.perPageOptions ?? []
|
const options = schema.value?.perPageOptions ?? []
|
||||||
@@ -227,6 +232,72 @@ async function onDelete(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a declared bulk action on the selected rows (UI-SPEC S1). The confirm
|
||||||
|
* dialog stays open and busy while the POST runs; the outcome is handled
|
||||||
|
* after it closes and focus is back on the menu trigger.
|
||||||
|
*/
|
||||||
|
async function onBulkAction(name: string): Promise<void> {
|
||||||
|
const action = bulkActions.value.find((item) => item.name === name)
|
||||||
|
const ids = selected.value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
|
||||||
|
if (!action || ids.length === 0 || bulkBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const label = action.label ?? name
|
||||||
|
let status = 0
|
||||||
|
let done: BulkActionResult | null = null
|
||||||
|
let failure = ''
|
||||||
|
const ok = await confirm.ask(
|
||||||
|
{
|
||||||
|
message: action.confirm || tc('backend::lang.list.bulk_confirm', ids.length, { action: label }),
|
||||||
|
confirmLabel: label,
|
||||||
|
danger: false,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
bulkBusy.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.POST('/{vendor}/{plugin}/{controller}/bulk/{action}', {
|
||||||
|
params: { path: { ...path, action: name } },
|
||||||
|
body: { ids },
|
||||||
|
})
|
||||||
|
status = result.response.status
|
||||||
|
done = result.data?.data ?? null
|
||||||
|
failure = result.error?.error.message ?? ''
|
||||||
|
} catch {
|
||||||
|
status = 0
|
||||||
|
} finally {
|
||||||
|
bulkBusy.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// Reka restores focus on its own timer when the dialog closes; the trigger
|
||||||
|
// takes it afterwards, while the selection still keeps it enabled.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
toolbar.value?.focusBulk()
|
||||||
|
if (!ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = done as BulkActionResult | null
|
||||||
|
if (result) {
|
||||||
|
showToast(result.message || tc('backend::lang.list.bulk_done', result.affected))
|
||||||
|
selected.value = []
|
||||||
|
await loadList()
|
||||||
|
partialReload.value += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === 409) {
|
||||||
|
showToast(t('backend::lang.list.bulk_stale'), 'danger')
|
||||||
|
selected.value = []
|
||||||
|
await loadList()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
showToast(failure || t('backend::lang.list.action_forbidden'), 'danger')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showToast(failure || t('backend::lang.extension.action_failed'), 'danger')
|
||||||
|
}
|
||||||
|
|
||||||
/** Runs a registered toolbar action (D-12): no confirmation, body {}. */
|
/** Runs a registered toolbar action (D-12): no confirmation, body {}. */
|
||||||
async function onAction(name: string): Promise<void> {
|
async function onAction(name: string): Promise<void> {
|
||||||
if (busyAction.value !== null) {
|
if (busyAction.value !== null) {
|
||||||
@@ -285,6 +356,7 @@ async function onAction(name: string): Promise<void> {
|
|||||||
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
|
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
|
||||||
<ListToolbar
|
<ListToolbar
|
||||||
v-if="schema"
|
v-if="schema"
|
||||||
|
ref="toolbar"
|
||||||
:show-search="schema.showSearch"
|
:show-search="schema.showSearch"
|
||||||
:search="searchText"
|
:search="searchText"
|
||||||
:search-prompt="message(messages?.searchPrompt)"
|
:search-prompt="message(messages?.searchPrompt)"
|
||||||
@@ -294,9 +366,12 @@ async function onAction(name: string): Promise<void> {
|
|||||||
:delete-label="message(messages?.deleteSelected, selected.length)"
|
:delete-label="message(messages?.deleteSelected, selected.length)"
|
||||||
:actions="toolbarActions"
|
:actions="toolbarActions"
|
||||||
:busy-action="busyAction"
|
:busy-action="busyAction"
|
||||||
|
:bulk-actions="bulkActions"
|
||||||
|
:bulk-busy="bulkBusy"
|
||||||
@update:search="onSearch"
|
@update:search="onSearch"
|
||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
@action="onAction"
|
@action="onAction"
|
||||||
|
@bulk="onBulkAction"
|
||||||
/>
|
/>
|
||||||
<FilterBar
|
<FilterBar
|
||||||
v-if="schema && schema.filters.length > 0"
|
v-if="schema && schema.filters.length > 0"
|
||||||
@@ -351,6 +426,7 @@ async function onAction(name: string): Promise<void> {
|
|||||||
:message="confirm.request.value?.message ?? ''"
|
:message="confirm.request.value?.message ?? ''"
|
||||||
:confirm-label="confirm.request.value?.confirmLabel"
|
:confirm-label="confirm.request.value?.confirmLabel"
|
||||||
:danger="confirm.request.value?.danger"
|
:danger="confirm.request.value?.danger"
|
||||||
|
:busy="confirm.busy.value"
|
||||||
@confirm="confirm.confirm"
|
@confirm="confirm.confirm"
|
||||||
@cancel="confirm.cancel"
|
@cancel="confirm.cancel"
|
||||||
/>
|
/>
|
||||||
|
|||||||
41
admin/tests/fixtures/roster.list-schema.json
vendored
Normal file
41
admin/tests/fixtures/roster.list-schema.json
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"title": "People",
|
||||||
|
"recordsPerPage": 20,
|
||||||
|
"perPageOptions": [],
|
||||||
|
"showSearch": true,
|
||||||
|
"showSetup": false,
|
||||||
|
"showCheckboxes": true,
|
||||||
|
"showSorting": true,
|
||||||
|
"searchTerm": "search",
|
||||||
|
"recordUrl": "acme/roster/people/update/:id",
|
||||||
|
"toolbarButtons": ["create", "delete"],
|
||||||
|
"toolbarActions": [],
|
||||||
|
"assets": { "scripts": [], "styles": [] },
|
||||||
|
"columns": [
|
||||||
|
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
||||||
|
{ "key": "email", "label": "Email", "searchable": true, "sortable": true }
|
||||||
|
],
|
||||||
|
"filters": [],
|
||||||
|
"rowActions": [],
|
||||||
|
"bulkActions": [
|
||||||
|
{ "name": "delete", "label": "Delete selected" },
|
||||||
|
{ "name": "activate", "label": "Activate", "confirm": "Activate the selected people?" },
|
||||||
|
{ "name": "archive", "label": "Archive" }
|
||||||
|
],
|
||||||
|
"messages": {
|
||||||
|
"create": { "other": "New person" },
|
||||||
|
"deleteConfirm": { "other": "Delete the selected (:count)?" },
|
||||||
|
"deleteSelected": { "other": "Delete selected" },
|
||||||
|
"deleted": { "one": "Deleted :count record", "other": "Deleted :count records" },
|
||||||
|
"empty": { "other": "There are no records in this view." },
|
||||||
|
"emptySearch": { "other": "Nothing found" },
|
||||||
|
"emptySearchHint": { "other": "No record matches “:term”. Try another search." },
|
||||||
|
"recordCount": { "one": ":count record", "other": ":count records" },
|
||||||
|
"searchPrompt": { "other": "Search…" },
|
||||||
|
"selected": { "other": ":count selected" }
|
||||||
|
},
|
||||||
|
"meta": { "locale": "en" }
|
||||||
|
},
|
||||||
|
"meta": { "locale": "en" }
|
||||||
|
}
|
||||||
8
admin/tests/fixtures/roster.list.json
vendored
Normal file
8
admin/tests/fixtures/roster.list.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" },
|
||||||
|
{ "id": 2, "name": "Bob Stone", "email": "bob@example.test" },
|
||||||
|
{ "id": 3, "name": "Cy Young", "email": "cy@example.test" }
|
||||||
|
],
|
||||||
|
"meta": { "page": 1, "per_page": 20, "total": 3, "last_page": 1 }
|
||||||
|
}
|
||||||
6
admin/tests/fixtures/typed.ts
vendored
6
admin/tests/fixtures/typed.ts
vendored
@@ -17,6 +17,8 @@ import formSchemaJson from './widgets.form-schema.json'
|
|||||||
import listJson from './widgets.list.json'
|
import listJson from './widgets.list.json'
|
||||||
import listSchemaJson from './widgets.list-schema.json'
|
import listSchemaJson from './widgets.list-schema.json'
|
||||||
import optionsJson from './widgets.options.json'
|
import optionsJson from './widgets.options.json'
|
||||||
|
import rosterListJson from './roster.list.json'
|
||||||
|
import rosterListSchemaJson from './roster.list-schema.json'
|
||||||
import recordJson from './widgets.record.json'
|
import recordJson from './widgets.record.json'
|
||||||
import candidatesJson from './widgets.relation-candidates.json'
|
import candidatesJson from './widgets.relation-candidates.json'
|
||||||
import linkedJson from './widgets.relation-linked.json'
|
import linkedJson from './widgets.relation-linked.json'
|
||||||
@@ -54,6 +56,10 @@ export const extensionListSchemaFixture: S['cabana.Envelope-cabana_ListSchema']
|
|||||||
*/
|
*/
|
||||||
export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelope-cabana_PartialView']
|
export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelope-cabana_PartialView']
|
||||||
export const listFixture: Rows = listJson
|
export const listFixture: Rows = listJson
|
||||||
|
/** A people list with declared bulk actions (Phase 12.1). */
|
||||||
|
export const rosterListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = rosterListSchemaJson
|
||||||
|
/** The people of the roster list (Phase 12.1). */
|
||||||
|
export const rosterListFixture: Rows = rosterListJson
|
||||||
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
|
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
|
||||||
export const optionsFixture: {
|
export const optionsFixture: {
|
||||||
maker: S['cabana.Envelope-array_cabana_FilterOption']
|
maker: S['cabana.Envelope-array_cabana_FilterOption']
|
||||||
|
|||||||
317
admin/tests/smoke/actions.smoke.test.ts
Normal file
317
admin/tests/smoke/actions.smoke.test.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
|
||||||
|
// (UI-SPEC S1, D-09). Fixtures are neutral acme.roster.* data; no application
|
||||||
|
// names appear in framework tests.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { setBundle } from '../../src/app/i18n'
|
||||||
|
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture } from '../fixtures/typed'
|
||||||
|
import { API, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||||
|
|
||||||
|
const LIST = `${API}/acme/roster/people`
|
||||||
|
|
||||||
|
// The framework strings these screens use, as GET /lang serves them in en.
|
||||||
|
const strings = {
|
||||||
|
'backend::lang.list.bulk_actions': { other: 'Bulk actions' },
|
||||||
|
'backend::lang.list.bulk_confirm': { other: 'Run “:action” on the selected (:count)?' },
|
||||||
|
'backend::lang.list.bulk_done': {
|
||||||
|
one: 'Action completed for :count record.',
|
||||||
|
other: 'Action completed for :count records.',
|
||||||
|
},
|
||||||
|
'backend::lang.list.bulk_stale': {
|
||||||
|
other: 'Some of the selected records are no longer available. The list has been refreshed; select the records again.',
|
||||||
|
},
|
||||||
|
'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' },
|
||||||
|
'backend::lang.extension.action_failed': { other: 'The action could not be completed. Please try again.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||||
|
return {
|
||||||
|
[`GET ${LIST}/schema/list`]: { body: rosterListSchemaFixture },
|
||||||
|
[`GET ${LIST}`]: { body: rosterListFixture },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaWith(bulkActions: (typeof rosterListSchemaFixture)['data']['bulkActions']) {
|
||||||
|
const schema = clone(rosterListSchemaFixture)
|
||||||
|
schema.data.bulkActions = bulkActions
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = (status: number, code: string, message: string): Reply => ({
|
||||||
|
status,
|
||||||
|
body: { error: { code, message, details: {} } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const trigger = (wrapper: VueWrapper) => wrapper.find('[data-action="bulk-actions"]')
|
||||||
|
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||||
|
const pill = (wrapper: VueWrapper) => wrapper.find('[data-selected-pill]')
|
||||||
|
const listGets = (calls: Request[]) => requestsTo(calls, 'GET', LIST).length
|
||||||
|
|
||||||
|
async function selectRows(wrapper: VueWrapper, count: number): Promise<void> {
|
||||||
|
const boxes = wrapper.findAll('td[data-select] [role="checkbox"]')
|
||||||
|
for (const box of boxes.slice(0, count)) {
|
||||||
|
await box.trigger('click')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the menu and chooses one action; the confirmation is then open. */
|
||||||
|
async function choose(wrapper: VueWrapper, name: string): Promise<void> {
|
||||||
|
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||||
|
await flushPromises()
|
||||||
|
const item = document.body.querySelector<HTMLElement>(`[data-bulk-menu] [data-bulk-action="${name}"]`)
|
||||||
|
expect(item).not.toBeNull()
|
||||||
|
item!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function press(action: 'confirm' | 'cancel'): Promise<void> {
|
||||||
|
dialog()!.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets the dialog close and the outcome (toast, reload) settle. */
|
||||||
|
async function settle(): Promise<void> {
|
||||||
|
await wait(10)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetState()
|
||||||
|
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
describe('bulk actions menu (UI-SPEC S1, D-09)', () => {
|
||||||
|
it('keeps a labelled, disabled trigger until rows are selected', async () => {
|
||||||
|
const { wrapper } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||||
|
expect(trigger(wrapper).text()).toBe('Bulk actions')
|
||||||
|
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
|
||||||
|
expect(trigger(wrapper).find('svg').exists()).toBe(true)
|
||||||
|
// The menu sits directly after the selection pill and before delete.
|
||||||
|
await selectRows(wrapper, 1)
|
||||||
|
expect(trigger(wrapper).attributes('disabled')).toBeUndefined()
|
||||||
|
const order = wrapper.findAll('[data-selected-pill], [data-action="bulk-actions"], [data-action="delete"]')
|
||||||
|
expect(order.map((node) => node.attributes('data-action') ?? 'pill')).toEqual(['pill', 'bulk-actions', 'delete'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists the declared actions in order, without delete, icons or separators', async () => {
|
||||||
|
const { wrapper } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||||
|
await selectRows(wrapper, 2)
|
||||||
|
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||||
|
await flushPromises()
|
||||||
|
const menu = document.body.querySelector('[data-bulk-menu]')!
|
||||||
|
expect(menu.getAttribute('role')).toBe('menu')
|
||||||
|
const items = Array.from(menu.querySelectorAll<HTMLElement>('[data-bulk-action]'))
|
||||||
|
expect(items.map((item) => item.dataset.bulkAction)).toEqual(['activate', 'archive'])
|
||||||
|
expect(items.map((item) => item.textContent?.trim())).toEqual(['Activate', 'Archive'])
|
||||||
|
expect(items.every((item) => item.classList.contains('min-h-10') && !item.classList.contains('truncate'))).toBe(true)
|
||||||
|
expect(menu.querySelector('svg, [role="separator"], [role="group"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms with the action text, posts the selected ids and clears the selection', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({ [`POST ${LIST}/bulk/activate`]: { body: { data: { message: '', affected: 2 }, meta: {} } } }),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
const gets = listGets(calls)
|
||||||
|
await selectRows(wrapper, 2)
|
||||||
|
await choose(wrapper, 'activate')
|
||||||
|
|
||||||
|
expect(dialog()!.textContent).toContain('Activate the selected people?')
|
||||||
|
const confirm = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
|
||||||
|
expect(confirm.textContent?.trim()).toBe('Activate')
|
||||||
|
expect(confirm.classList.contains('bg-primary')).toBe(true)
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
const [post] = requestsTo(calls, 'POST', `${LIST}/bulk/activate`)
|
||||||
|
expect(new URL(post!.url).pathname.endsWith('/bulk/activate')).toBe(true)
|
||||||
|
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||||
|
expect(await post!.json()).toEqual({ ids: [1, 2] })
|
||||||
|
expect(wrapper.find('[role="status"]').text()).toContain('Action completed for 2 records.')
|
||||||
|
expect(pill(wrapper).exists()).toBe(false)
|
||||||
|
expect(listGets(calls)).toBe(gets + 1)
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the default confirm text and shows the server message and the singular count', async () => {
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({
|
||||||
|
[`POST ${LIST}/bulk/archive`]: { body: { data: { message: 'The selected people were archived.', affected: 2 }, meta: {} } },
|
||||||
|
[`POST ${LIST}/bulk/activate`]: { body: { data: { message: '', affected: 1 }, meta: {} } },
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
await selectRows(wrapper, 2)
|
||||||
|
await choose(wrapper, 'archive')
|
||||||
|
expect(dialog()!.textContent).toContain('Run “Archive” on the selected (2)?')
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.find('[role="status"]').text()).toContain('The selected people were archived.')
|
||||||
|
|
||||||
|
// The server's affected count may be lower than the selection.
|
||||||
|
await selectRows(wrapper, 2)
|
||||||
|
await choose(wrapper, 'activate')
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.text()).toContain('Action completed for 1 record.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the dialog open and busy and the trigger disabled while the request runs', async () => {
|
||||||
|
let release: ((reply: Reply) => void) | undefined
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({
|
||||||
|
[`POST ${LIST}/bulk/activate`]: () =>
|
||||||
|
new Promise<Reply>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
await selectRows(wrapper, 1)
|
||||||
|
await choose(wrapper, 'activate')
|
||||||
|
await press('confirm')
|
||||||
|
|
||||||
|
const confirm = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
|
||||||
|
const cancel = dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!
|
||||||
|
expect(confirm.disabled).toBe(true)
|
||||||
|
expect(cancel.disabled).toBe(true)
|
||||||
|
expect(confirm.getAttribute('aria-busy')).toBe('true')
|
||||||
|
expect(confirm.querySelector('svg')).not.toBeNull()
|
||||||
|
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
|
||||||
|
expect(trigger(wrapper).text()).toBe('Bulk actions')
|
||||||
|
|
||||||
|
release?.({ body: { data: { message: '', affected: 1 }, meta: {} } })
|
||||||
|
await flushPromises()
|
||||||
|
await settle()
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends nothing on cancel and returns focus to the trigger', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||||
|
await selectRows(wrapper, 1)
|
||||||
|
await choose(wrapper, 'activate')
|
||||||
|
await press('cancel')
|
||||||
|
await settle()
|
||||||
|
expect(requestsTo(calls, 'POST', `${LIST}/bulk/activate`)).toHaveLength(0)
|
||||||
|
expect(pill(wrapper).exists()).toBe(true)
|
||||||
|
expect(document.activeElement).toBe(trigger(wrapper).element)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns focus to the trigger after a confirmed run', async () => {
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({ [`POST ${LIST}/bulk/activate`]: error(500, 'error', 'The roster is locked.') }),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
await selectRows(wrapper, 1)
|
||||||
|
await choose(wrapper, 'activate')
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
expect(document.activeElement).toBe(trigger(wrapper).element)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts a 409, clears the selection and reloads the list', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({ [`POST ${LIST}/bulk/activate`]: error(409, 'conflict', 'Conflict') }),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
const gets = listGets(calls)
|
||||||
|
await selectRows(wrapper, 2)
|
||||||
|
await choose(wrapper, 'activate')
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Some of the selected records are no longer available.')
|
||||||
|
expect(pill(wrapper).exists()).toBe(false)
|
||||||
|
expect(listGets(calls)).toBe(gets + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts a 403 with the server message or the fallback and keeps the selection', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({
|
||||||
|
[`POST ${LIST}/bulk/activate`]: error(403, 'forbidden', ''),
|
||||||
|
[`POST ${LIST}/bulk/archive`]: error(403, 'forbidden', 'Only a manager may archive people.'),
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
const gets = listGets(calls)
|
||||||
|
await selectRows(wrapper, 2)
|
||||||
|
await choose(wrapper, 'activate')
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.find('[data-tone="danger"]').text()).toContain('You do not have permission to run this action.')
|
||||||
|
expect(pill(wrapper).exists()).toBe(true)
|
||||||
|
|
||||||
|
await choose(wrapper, 'archive')
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.text()).toContain('Only a manager may archive people.')
|
||||||
|
expect(pill(wrapper).exists()).toBe(true)
|
||||||
|
expect(listGets(calls)).toBe(gets)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts any other failure, keeps the selection and reloads nothing', async () => {
|
||||||
|
const { wrapper, calls } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({
|
||||||
|
[`POST ${LIST}/bulk/activate`]: error(500, 'error', ''),
|
||||||
|
[`POST ${LIST}/bulk/archive`]: error(422, 'validation_failed', 'One of the people cannot be archived.'),
|
||||||
|
}),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
const gets = listGets(calls)
|
||||||
|
await selectRows(wrapper, 2)
|
||||||
|
await choose(wrapper, 'activate')
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.find('[data-tone="danger"]').text()).toContain('The action could not be completed. Please try again.')
|
||||||
|
|
||||||
|
await choose(wrapper, 'archive')
|
||||||
|
await press('confirm')
|
||||||
|
await settle()
|
||||||
|
expect(wrapper.text()).toContain('One of the people cannot be archived.')
|
||||||
|
expect(pill(wrapper).exists()).toBe(true)
|
||||||
|
expect(listGets(calls)).toBe(gets)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no menu when the admin may run no declared action', async () => {
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith([{ name: 'delete', label: 'Delete selected' }]) } }),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
expect(trigger(wrapper).exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a label with markup characters as text', async () => {
|
||||||
|
const label = '<b onclick="x()">Activate</b> & a label long enough to wrap inside the menu'
|
||||||
|
const { wrapper } = await mountApp(
|
||||||
|
'/acme/roster/people',
|
||||||
|
routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith([{ name: 'activate', label }]) } }),
|
||||||
|
{ attach: true },
|
||||||
|
)
|
||||||
|
await selectRows(wrapper, 1)
|
||||||
|
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||||
|
await flushPromises()
|
||||||
|
const menuItem = document.body.querySelector<HTMLElement>('[data-bulk-action="activate"]')!
|
||||||
|
expect(menuItem.querySelector('b')).toBeNull()
|
||||||
|
expect(menuItem.textContent?.trim()).toBe(label)
|
||||||
|
menuItem.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(dialog()!.querySelector('b')).toBeNull()
|
||||||
|
expect(dialog()!.textContent).toContain(`Run “${label}” on the selected (1)?`)
|
||||||
|
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe(label)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -224,3 +224,124 @@ Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` ra
|
|||||||
## Toolbar actions
|
## Toolbar actions
|
||||||
|
|
||||||
`toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. The declarations are enforced by the server, not only shown by the SPA. `POST /{controller}` needs a `config_form.yaml` and `create` in `toolbar.buttons`; `PUT` and `DELETE /{controller}/{id}` need a form (the form screen carries the delete button, as in WinterCMS); `POST /{controller}/bulk-delete` needs `delete` in `toolbar.buttons`, which in turn needs `showCheckboxes: true`. A write the controller does not declare answers 403 `forbidden`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points.
|
`toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. The declarations are enforced by the server, not only shown by the SPA. `POST /{controller}` needs a `config_form.yaml` and `create` in `toolbar.buttons`; `PUT` and `DELETE /{controller}/{id}` need a form (the form screen carries the delete button, as in WinterCMS); `POST /{controller}/bulk-delete` needs `delete` in `toolbar.buttons`, which in turn needs `showCheckboxes: true`. A write the controller does not declare answers 403 `forbidden`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points.
|
||||||
|
|
||||||
|
## Bulk actions
|
||||||
|
|
||||||
|
A bulk action runs on the rows an administrator selected in the list, as the `onBulkAction` handlers of a WinterCMS list toolbar do. The controller registers its bulk actions through `pact.HasAdminBulkActions`, and `bulkActions` in `config_list.yaml` lists the ones the list offers, in menu order:
|
||||||
|
|
||||||
|
```go src=modules/cabana/example_actions_test.go
|
||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Person is the model behind the acme.roster people controller.
|
||||||
|
type Person struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Email string `gorm:"column:email"`
|
||||||
|
Active bool `gorm:"column:active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Person) TableName() string { return "acme_roster_people" }
|
||||||
|
|
||||||
|
// Fillable lists the columns the admin form may write.
|
||||||
|
func (Person) Fillable() []string { return []string{"name", "email"} }
|
||||||
|
|
||||||
|
// PeopleController is an admin controller whose list offers bulk actions.
|
||||||
|
type PeopleController struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminController = PeopleController{}
|
||||||
|
_ pact.AdminRecordSource = PeopleController{}
|
||||||
|
_ pact.HasAdminBulkActions = PeopleController{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (PeopleController) ID() string { return "acme.roster.people" }
|
||||||
|
func (PeopleController) ModelName() string { return "Person" }
|
||||||
|
func (PeopleController) ConfigDir() string { return "controllers/people" }
|
||||||
|
func (PeopleController) NewRecord() any { return &Person{} }
|
||||||
|
|
||||||
|
func (PeopleController) RequiredPermissions() []string {
|
||||||
|
return []string{"acme.roster.access"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBulkActions registers the actions config_list.yaml offers under
|
||||||
|
// bulkActions. Run receives the selected records, already loaded and locked
|
||||||
|
// through the list scope, and writes through the request's transaction.
|
||||||
|
func (PeopleController) AdminBulkActions() []pact.AdminBulkAction {
|
||||||
|
return []pact.AdminBulkAction{{
|
||||||
|
Name: "activate",
|
||||||
|
Label: "acme.roster::lang.people.activate",
|
||||||
|
Confirm: "acme.roster::lang.people.activate_confirm",
|
||||||
|
Permissions: []string{"acme.roster.manage"},
|
||||||
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AdminBulkActionResult{}, errors.New("no transaction")
|
||||||
|
}
|
||||||
|
changed := 0
|
||||||
|
for _, record := range in.Records {
|
||||||
|
person := record.(*Person)
|
||||||
|
if person.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Model(person).Update("active", true).Error; err != nil {
|
||||||
|
return pact.AdminBulkActionResult{}, err
|
||||||
|
}
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
return pact.AdminBulkActionResult{Affected: changed}, nil
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
Name: "archive",
|
||||||
|
Label: "acme.roster::lang.people.archive",
|
||||||
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AdminBulkActionResult{}, errors.New("no transaction")
|
||||||
|
}
|
||||||
|
for _, record := range in.Records {
|
||||||
|
if err := tx.Delete(record).Error; err != nil {
|
||||||
|
return pact.AdminBulkActionResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pact.AdminBulkActionResult{
|
||||||
|
Message: "acme.roster::lang.people.archived",
|
||||||
|
Affected: len(in.Records),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml src=modules/cabana/testdata/roster/controllers/people/config_list.yaml
|
||||||
|
list: ~/plugins/acme/roster/models/person/columns.yaml
|
||||||
|
modelClass: Person
|
||||||
|
title: acme.roster::lang.people.title
|
||||||
|
recordUrl: acme/roster/people/update/:id
|
||||||
|
recordsPerPage: 20
|
||||||
|
showCheckboxes: true
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete]
|
||||||
|
search:
|
||||||
|
prompt: backend::lang.list.search_prompt
|
||||||
|
bulkActions: [activate, archive]
|
||||||
|
messages:
|
||||||
|
create: acme.roster::lang.people.create
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- The ids are resolved and row-locked through the controller's `pact.ListExtendQuery` scope inside one transaction. `Run` receives the loaded records in `pact.AdminBulkActionInput`, never the ids, so an id outside the scope cannot reach plugin code.
|
||||||
|
- A selection that matches no row in the scope answers `affected: 0` without calling `Run`. A selection of which only a part is in the scope answers 409 `conflict` and changes nothing.
|
||||||
|
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 and any other error the opaque 500.
|
||||||
|
- The action's `Permissions` are checked on top of the controller's. An administrator who may not run an action does not get it in the list schema, and posting it answers 403.
|
||||||
|
- `pact.AdminBulkActionResult` carries an optional `Message` (a phrase key or text) and `Affected`, the number of records the action changed. The answer is a `cabana.BulkActionResult`.
|
||||||
|
|
||||||
|
Bulk actions have their own namespace next to the toolbar and widget actions: a name is unique among the controller's bulk actions, and `create` and `delete` stay reserved. `bulkActions` needs `showCheckboxes: true`. A name the controller does not register, a duplicate, or an action without a label stops the start-up. The built-in bulk delete is not a declared action and keeps its own route and toolbar button.
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ The SPA signs in through the admin API and keeps the token in the HttpOnly cooki
|
|||||||
|
|
||||||
Each record form makes a session key when it opens: 32 random bytes, base64url encoded. The form sends it in the `X-Session-Key` header with every file upload, file list and file removal, and with the final create or update save. Uploads and removals are deferred: the server holds them against the key and the admin, and the save that carries the same key commits them in its transaction. Until then the form counts as unsaved, so leaving it asks first, and a new record's files go to record id `0`. The form will not save while an upload is still in flight. Uploads use `XMLHttpRequest` for progress events and carry the same `X-Requested-With` header and cookie as every other call, plus an `X-Upload-Id` so a retry returns the already stored file. Files of a protected relation are fetched through the admin API with the key and shown from object URLs. The key travels only in headers, never in a URL. See [File uploads](forms.md#file-uploads) for the `fileupload` field.
|
Each record form makes a session key when it opens: 32 random bytes, base64url encoded. The form sends it in the `X-Session-Key` header with every file upload, file list and file removal, and with the final create or update save. Uploads and removals are deferred: the server holds them against the key and the admin, and the save that carries the same key commits them in its transaction. Until then the form counts as unsaved, so leaving it asks first, and a new record's files go to record id `0`. The form will not save while an upload is still in flight. Uploads use `XMLHttpRequest` for progress events and carry the same `X-Requested-With` header and cookie as every other call, plus an `X-Upload-Id` so a retry returns the already stored file. Files of a protected relation are fetched through the admin API with the key and shown from object URLs. The key travels only in headers, never in a URL. See [File uploads](forms.md#file-uploads) for the `fileupload` field.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
## Types from OpenAPI
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ toolbar:
|
|||||||
| `toolbar` | `buttons` (the built-in `create` and `delete` and registered actions) and `search.prompt`. |
|
| `toolbar` | `buttons` (the built-in `create` and `delete` and registered actions) and `search.prompt`. |
|
||||||
| `filter` | The filter file, relative to the controller's directory. |
|
| `filter` | The filter file, relative to the controller's directory. |
|
||||||
| `headerPartial` | A server-rendered strip above the list; see [Partials and widgets](partials-and-widgets.md). |
|
| `headerPartial` | A server-rendered strip above the list; see [Partials and widgets](partials-and-widgets.md). |
|
||||||
|
| `bulkActions` | The bulk actions the list offers for the selected rows: a list of names the controller registers through `pact.HasAdminBulkActions`. Needs `showCheckboxes: true`. |
|
||||||
|
|
||||||
|
A declared bulk action runs on the selected rows in one transaction, after cabana resolved the posted ids through the list's own scope. A selection of which only a part is still in that scope (a row was deleted meanwhile, or lies outside `pact.ListExtendQuery`) answers 409 and changes nothing; the admin then reloads the list and asks for a new selection. See [Bulk actions](admin-controllers.md#bulk-actions).
|
||||||
|
|
||||||
## columns.yaml
|
## columns.yaml
|
||||||
|
|
||||||
|
|||||||
1
modules/boardwalk/dist/assets/index-BOx4jB46.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-BOx4jB46.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-BrvHT7-x.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-BrvHT7-x.js
vendored
Normal file
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
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-C1hSuckg.js"></script>
|
<script type="module" crossorigin src="./assets/index-BrvHT7-x.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-Cr97p7-8.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BOx4jB46.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
|
|||||||
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
|
||||||
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
|
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
@@ -42,6 +43,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
|||||||
| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record (needs a form and `create` in `toolbar.buttons`). |
|
| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record (needs a form and `create` in `toolbar.buttons`). |
|
||||||
| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record (update and delete need a form). |
|
| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record (update and delete need a form). |
|
||||||
| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction; needs `delete` in `toolbar.buttons`. |
|
| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction; needs `delete` in `toolbar.buttons`. |
|
||||||
|
| POST `/{vendor}/{plugin}/{controller}/bulk/{action}` | Run a declared bulk action on `{ids}` in one transaction; answers `{message, affected}`, 409 for a partial selection. |
|
||||||
| POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. |
|
| POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. |
|
||||||
| POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. |
|
| POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. |
|
||||||
| GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
|
| GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
|
||||||
@@ -166,7 +168,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
|||||||
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
|
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
|
||||||
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
|
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
|
||||||
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. |
|
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. |
|
||||||
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options. |
|
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options; `cabana.CRUDService.BulkAction` runs a declared bulk action on a scoped, locked id set. |
|
||||||
|
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
|
||||||
|
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |
|
||||||
|
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
|
||||||
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
|
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
|
||||||
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
|
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
|
||||||
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
|
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.allowAction(w, r, action) {
|
if !s.allowAction(w, r, action.Permissions) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := decodeActionRequest(r)
|
in, err := decodeActionRequest(r)
|
||||||
@@ -82,7 +82,7 @@ func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.allowAction(w, r, action) {
|
if !s.allowAction(w, r, action.Permissions) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in, err := decodeActionRequest(r)
|
in, err := decodeActionRequest(r)
|
||||||
@@ -115,11 +115,68 @@ func toolbarActionOf(cc *CompiledController, name string) (pact.AdminAction, boo
|
|||||||
return action, ok
|
return action, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowAction applies the action's own permissions on top of the controller's
|
// bulkAction serves POST .../{controller}/bulk/{action} (D-09): a registered
|
||||||
// (already checked by protect). A denial is logged and answered 403.
|
// bulk action the list's bulkActions declares. The posted ids are resolved
|
||||||
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, action pact.AdminAction) bool {
|
// through the controller's list scope inside the action's transaction, so the
|
||||||
|
// plugin receives loaded records and never an id.
|
||||||
|
func (s *service) bulkAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
name := r.PathValue("action")
|
||||||
|
action, ok := bulkActionOf(cc, name)
|
||||||
|
if !ok {
|
||||||
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.allowAction(w, r, action.Permissions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, err := s.decodeCappedBulk(w, r)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc, err := s.crud()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := svc.BulkAction(r.Context(), cc, name, in)
|
||||||
|
if err != nil {
|
||||||
|
writeCRUDError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var adminID uint
|
||||||
|
if principal, _ := bouncer.User(r.Context()); principal != nil {
|
||||||
|
adminID = principal.ID
|
||||||
|
}
|
||||||
|
slog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "affected", result.Affected)
|
||||||
|
WriteData(w, http.StatusOK, result, nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// bulkActionOf returns the registered bulk action a list declares under name
|
||||||
|
// in bulkActions; the built-in delete is not a declared action.
|
||||||
|
func bulkActionOf(cc *CompiledController, name string) (pact.AdminBulkAction, bool) {
|
||||||
|
if cc == nil || cc.List == nil || builtinToolbarActions[name] {
|
||||||
|
return pact.AdminBulkAction{}, false
|
||||||
|
}
|
||||||
|
declared := false
|
||||||
|
for _, entry := range cc.List.BulkActions {
|
||||||
|
declared = declared || entry.Name == name
|
||||||
|
}
|
||||||
|
if !declared {
|
||||||
|
return pact.AdminBulkAction{}, false
|
||||||
|
}
|
||||||
|
action, ok := cc.BulkActions[name]
|
||||||
|
return action, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowAction applies an action's own permissions on top of the controller's
|
||||||
|
// (already checked by protect). Every action kind shares it: a denial is
|
||||||
|
// logged and answered 403.
|
||||||
|
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permissions []string) bool {
|
||||||
principal, _ := bouncer.User(r.Context())
|
principal, _ := bouncer.User(r.Context())
|
||||||
if Allows(principal, action.Permissions) {
|
if Allows(principal, permissions) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
var adminID uint
|
var adminID uint
|
||||||
|
|||||||
@@ -396,6 +396,28 @@ func AdminCreate() {}
|
|||||||
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
||||||
func AdminBulkDelete() {}
|
func AdminBulkDelete() {}
|
||||||
|
|
||||||
|
// AdminBulkAction documents the declared bulk action route.
|
||||||
|
//
|
||||||
|
// @Summary Run a declared bulk action
|
||||||
|
// @Description Runs a bulk action the controller registers and the list's bulkActions declares. The ids are resolved and row-locked through the controller's list scope in one transaction before the action runs: a selection that matches no scoped row answers affected 0 without running the action, and a partial selection is a 409 that changes nothing.
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BackendBearer
|
||||||
|
// @Param vendor path string true "Vendor"
|
||||||
|
// @Param plugin path string true "Plugin"
|
||||||
|
// @Param controller path string true "Controller"
|
||||||
|
// @Param action path string true "Bulk action name"
|
||||||
|
// @Param body body AdminIDsRequest true "Record ids"
|
||||||
|
// @Success 200 {object} Envelope[BulkActionResult]
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 403 {object} ErrorEnvelope
|
||||||
|
// @Failure 404 {object} ErrorEnvelope
|
||||||
|
// @Failure 409 {object} ErrorEnvelope
|
||||||
|
// @Failure 422 {object} ErrorEnvelope
|
||||||
|
// @Router /{vendor}/{plugin}/{controller}/bulk/{action} [post]
|
||||||
|
func AdminBulkAction() {}
|
||||||
|
|
||||||
// AdminActionRequest is the body of a widget or toolbar action. record_id is
|
// AdminActionRequest is the body of a widget or toolbar action. record_id is
|
||||||
// the record a widget on the update form belongs to (absent on create and
|
// the record a widget on the update form belongs to (absent on create and
|
||||||
// always absent for a toolbar action); values is the widget's snapshot of its
|
// always absent for a toolbar action); values is the widget's snapshot of its
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ type CompiledController struct {
|
|||||||
// the single namespace that toolbar.buttons names and widget action: keys
|
// the single namespace that toolbar.buttons names and widget action: keys
|
||||||
// resolve through. create and delete are reserved built-in names.
|
// resolve through. create and delete are reserved built-in names.
|
||||||
Actions map[string]pact.AdminAction
|
Actions map[string]pact.AdminAction
|
||||||
|
// BulkActions are the controller's pact.HasAdminBulkActions entries keyed
|
||||||
|
// by name. They have their own namespace next to Actions; create and
|
||||||
|
// delete are reserved there too.
|
||||||
|
BulkActions map[string]pact.AdminBulkAction
|
||||||
|
|
||||||
// scripts and styles are the controller's declared plugin JS and CSS,
|
// scripts and styles are the controller's declared plugin JS and CSS,
|
||||||
// in declared order.
|
// in declared order.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
"gocloud.dev/blob"
|
"gocloud.dev/blob"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -47,6 +49,14 @@ type BulkResult struct {
|
|||||||
Deleted int `json:"deleted"`
|
Deleted int `json:"deleted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkActionResult is the answer of a declared bulk action: the localized
|
||||||
|
// message for the toast (empty when the action sets none) and the number of
|
||||||
|
// records the action reports as changed.
|
||||||
|
type BulkActionResult struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Affected int `json:"affected"`
|
||||||
|
}
|
||||||
|
|
||||||
// ValidationError is a D-10 validation_failed failure.
|
// ValidationError is a D-10 validation_failed failure.
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
Details map[string]any
|
Details map[string]any
|
||||||
@@ -246,6 +256,79 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkAction runs the declared bulk action name on a normalized id set in one
|
||||||
|
// transaction. The ids are resolved and row-locked through the controller's
|
||||||
|
// ListExtendQuery scope first, and the action receives the loaded records,
|
||||||
|
// never the ids. An empty selection is validation_failed. A selection that
|
||||||
|
// matches no scoped row is a successful no-op with Affected 0 and the action
|
||||||
|
// does not run. A mixed present/absent selection conflicts and rolls back. A
|
||||||
|
// name the list does not declare, or the controller does not register, is not
|
||||||
|
// found.
|
||||||
|
func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, name string, in BulkDeleteInput) (BulkActionResult, error) {
|
||||||
|
if s.DB == nil {
|
||||||
|
return BulkActionResult{}, errors.New("cabana: database is not configured")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
action, ok := bulkActionOf(cc, name)
|
||||||
|
if !ok {
|
||||||
|
return BulkActionResult{}, recordNotFound{}
|
||||||
|
}
|
||||||
|
ids, err := normalizeIDs(in.IDs)
|
||||||
|
if err != nil {
|
||||||
|
return BulkActionResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := newWritableModel(cc); err != nil {
|
||||||
|
return BulkActionResult{}, err
|
||||||
|
}
|
||||||
|
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
|
||||||
|
var result BulkActionResult
|
||||||
|
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
ctx = withTx(ctx, tx)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return lifecycleFailure(cc, err)
|
||||||
|
}
|
||||||
|
proto, err := newWritableModel(cc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := lockScoped(ctx, tx, cc, proto, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(rows) != len(ids) {
|
||||||
|
return partialSelection{}
|
||||||
|
}
|
||||||
|
out, err := action.Run(ctx, pact.AdminBulkActionInput{Records: rows})
|
||||||
|
if err != nil {
|
||||||
|
return actionFailure(cc, "bulk action", name, err)
|
||||||
|
}
|
||||||
|
result = BulkActionResult{Message: translateKey(ctx, s.tr, out.Message), Affected: out.Affected}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BulkActionResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionFailure classifies the error of a bulk or record action's Run like a
|
||||||
|
// lifecycle hook's: the errors a plugin may answer with pass through, anything
|
||||||
|
// else is logged with the controller and action and becomes the opaque
|
||||||
|
// lifecycle error (500, no error text).
|
||||||
|
func actionFailure(cc *CompiledController, kind, name string, err error) error {
|
||||||
|
out := lifecycleFailure(cc, err)
|
||||||
|
var life *lifecycleError
|
||||||
|
if errors.As(out, &life) && !errors.As(err, &life) {
|
||||||
|
slog.Error("cabana: admin "+kind+" failed", "controller", controllerID(cc), "action", name, "error", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Show loads one scoped record. Missing and out-of-scope ids are identical.
|
// Show loads one scoped record. Missing and out-of-scope ids are identical.
|
||||||
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
|
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
|
||||||
res, err := s.ShowRecord(ctx, cc, id)
|
res, err := s.ShowRecord(ctx, cc, id)
|
||||||
|
|||||||
88
modules/cabana/example_actions_test.go
Normal file
88
modules/cabana/example_actions_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Person is the model behind the acme.roster people controller.
|
||||||
|
type Person struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Email string `gorm:"column:email"`
|
||||||
|
Active bool `gorm:"column:active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Person) TableName() string { return "acme_roster_people" }
|
||||||
|
|
||||||
|
// Fillable lists the columns the admin form may write.
|
||||||
|
func (Person) Fillable() []string { return []string{"name", "email"} }
|
||||||
|
|
||||||
|
// PeopleController is an admin controller whose list offers bulk actions.
|
||||||
|
type PeopleController struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ pact.AdminController = PeopleController{}
|
||||||
|
_ pact.AdminRecordSource = PeopleController{}
|
||||||
|
_ pact.HasAdminBulkActions = PeopleController{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (PeopleController) ID() string { return "acme.roster.people" }
|
||||||
|
func (PeopleController) ModelName() string { return "Person" }
|
||||||
|
func (PeopleController) ConfigDir() string { return "controllers/people" }
|
||||||
|
func (PeopleController) NewRecord() any { return &Person{} }
|
||||||
|
|
||||||
|
func (PeopleController) RequiredPermissions() []string {
|
||||||
|
return []string{"acme.roster.access"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBulkActions registers the actions config_list.yaml offers under
|
||||||
|
// bulkActions. Run receives the selected records, already loaded and locked
|
||||||
|
// through the list scope, and writes through the request's transaction.
|
||||||
|
func (PeopleController) AdminBulkActions() []pact.AdminBulkAction {
|
||||||
|
return []pact.AdminBulkAction{{
|
||||||
|
Name: "activate",
|
||||||
|
Label: "acme.roster::lang.people.activate",
|
||||||
|
Confirm: "acme.roster::lang.people.activate_confirm",
|
||||||
|
Permissions: []string{"acme.roster.manage"},
|
||||||
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AdminBulkActionResult{}, errors.New("no transaction")
|
||||||
|
}
|
||||||
|
changed := 0
|
||||||
|
for _, record := range in.Records {
|
||||||
|
person := record.(*Person)
|
||||||
|
if person.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Model(person).Update("active", true).Error; err != nil {
|
||||||
|
return pact.AdminBulkActionResult{}, err
|
||||||
|
}
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
return pact.AdminBulkActionResult{Affected: changed}, nil
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
Name: "archive",
|
||||||
|
Label: "acme.roster::lang.people.archive",
|
||||||
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AdminBulkActionResult{}, errors.New("no transaction")
|
||||||
|
}
|
||||||
|
for _, record := range in.Records {
|
||||||
|
if err := tx.Delete(record).Error; err != nil {
|
||||||
|
return pact.AdminBulkActionResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pact.AdminBulkActionResult{
|
||||||
|
Message: "acme.roster::lang.people.archived",
|
||||||
|
Affected: len(in.Records),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
|
|||||||
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
||||||
}
|
}
|
||||||
cc.Actions = actions
|
cc.Actions = actions
|
||||||
|
bulkActions, err := compileBulkActions(cc.Controller)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
||||||
|
}
|
||||||
|
cc.BulkActions = bulkActions
|
||||||
if err := compileClientAssets(pluginID, cc, fsys); err != nil {
|
if err := compileClientAssets(pluginID, cc, fsys); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -276,3 +281,30 @@ func compileActions(ctl pact.AdminController) (map[string]pact.AdminAction, erro
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compileBulkActions collects a controller's registered bulk actions. They
|
||||||
|
// have their own namespace next to the toolbar and widget actions: a name is
|
||||||
|
// unique among the bulk actions, and create and delete stay reserved.
|
||||||
|
func compileBulkActions(ctl pact.AdminController) (map[string]pact.AdminBulkAction, error) {
|
||||||
|
out := map[string]pact.AdminBulkAction{}
|
||||||
|
src, ok := ctl.(pact.HasAdminBulkActions)
|
||||||
|
if !ok || src == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for _, action := range src.AdminBulkActions() {
|
||||||
|
if !identifier(action.Name) {
|
||||||
|
return nil, fmt.Errorf("bulk action name %q is not an identifier", action.Name)
|
||||||
|
}
|
||||||
|
if builtinToolbarActions[action.Name] {
|
||||||
|
return nil, fmt.Errorf("bulk action %s uses a reserved built-in name (create, delete)", action.Name)
|
||||||
|
}
|
||||||
|
if _, dup := out[action.Name]; dup {
|
||||||
|
return nil, fmt.Errorf("duplicate bulk action %s", action.Name)
|
||||||
|
}
|
||||||
|
if action.Run == nil {
|
||||||
|
return nil, fmt.Errorf("bulk action %s has no Run function", action.Name)
|
||||||
|
}
|
||||||
|
out[action.Name] = action
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -255,6 +255,11 @@ func (s *service) mount(r pact.Router) {
|
|||||||
constrainController(g)
|
constrainController(g)
|
||||||
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
|
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
|
||||||
constrainController(g)
|
constrainController(g)
|
||||||
|
// Declared bulk actions (D-09): ids are resolved through the list
|
||||||
|
// scope before plugin code runs.
|
||||||
|
g.Post("/{vendor}/{plugin}/{controller}/bulk/{action}", requireAjax(s.bulkAction))
|
||||||
|
constrainController(g)
|
||||||
|
g.Where("action", "[A-Za-z_][A-Za-z0-9_]*")
|
||||||
// Runtime extension actions (Phase 10.1): cabana owns these routes, so
|
// Runtime extension actions (Phase 10.1): cabana owns these routes, so
|
||||||
// CSRF, auth and record scoping never depend on plugin code.
|
// CSRF, auth and record scoping never depend on plugin code.
|
||||||
g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
|
g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
|
||||||
@@ -719,6 +724,20 @@ func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
view.ToolbarActions = allowed
|
view.ToolbarActions = allowed
|
||||||
|
// The built-in delete stays; a declared bulk action is offered only
|
||||||
|
// to an admin who may run it. A new slice: the cached schema is
|
||||||
|
// never mutated.
|
||||||
|
bulk := make([]BulkAction, 0, len(view.BulkActions))
|
||||||
|
for _, entry := range view.BulkActions {
|
||||||
|
if builtinToolbarActions[entry.Name] {
|
||||||
|
bulk = append(bulk, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if registered, ok := cc.BulkActions[entry.Name]; ok && Allows(principal, registered.Permissions) {
|
||||||
|
bulk = append(bulk, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.BulkActions = bulk
|
||||||
view.Assets = s.controllerAssets(cc)
|
view.Assets = s.controllerAssets(cc)
|
||||||
meta := map[string]any{}
|
meta := map[string]any{}
|
||||||
if view.Meta != nil {
|
if view.Meta != nil {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type listDocument struct {
|
|||||||
Filter string `yaml:"filter"`
|
Filter string `yaml:"filter"`
|
||||||
Messages *listMessageKeys `yaml:"messages"`
|
Messages *listMessageKeys `yaml:"messages"`
|
||||||
HeaderPartial string `yaml:"headerPartial"`
|
HeaderPartial string `yaml:"headerPartial"`
|
||||||
|
BulkActions bulkActionNames `yaml:"bulkActions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type listSortDocument struct {
|
type listSortDocument struct {
|
||||||
@@ -153,6 +154,11 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
|
|||||||
if doc.ShowCheckboxes {
|
if doc.ShowCheckboxes {
|
||||||
bulk = append(bulk, BulkAction{Name: "delete", Label: "backend::lang.list.delete_selected"})
|
bulk = append(bulk, BulkAction{Name: "delete", Label: "backend::lang.list.delete_selected"})
|
||||||
}
|
}
|
||||||
|
declared, err := compileBulkActionNames(ctl, doc.BulkActions.items, doc.ShowCheckboxes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||||
|
}
|
||||||
|
bulk = append(bulk, declared...)
|
||||||
filters := []ListFilter{}
|
filters := []ListFilter{}
|
||||||
if strings.TrimSpace(doc.Filter) != "" {
|
if strings.TrimSpace(doc.Filter) != "" {
|
||||||
filters, err = compileFilters(pluginID, ctl, fsys, dir, doc.Filter)
|
filters, err = compileFilters(pluginID, ctl, fsys, dir, doc.Filter)
|
||||||
@@ -362,6 +368,81 @@ func compileToolbarButtons(ctl pact.AdminController, toolbar *listToolbar, showC
|
|||||||
return out, custom, nil
|
return out, custom, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bulkActionNames is the declarative bulkActions list (D-09): the names of
|
||||||
|
// bulk actions the controller registers through pact.HasAdminBulkActions, in
|
||||||
|
// menu order. Decode has no controller, so membership is resolved in
|
||||||
|
// compileBulkActionNames.
|
||||||
|
type bulkActionNames struct {
|
||||||
|
items []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bulkActionNames) UnmarshalYAML(node ast.Node) error {
|
||||||
|
items, err := actionNameList(node, "bulkActions", "bulkActions must be a list of bulk action names the controller registers")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.items = items
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionNameList decodes a YAML sequence of action names for key: duplicates
|
||||||
|
// are refused, and anything but a sequence is refused with scalarHint.
|
||||||
|
func actionNameList(node ast.Node, key, scalarHint string) ([]string, error) {
|
||||||
|
node = unwrapNode(node)
|
||||||
|
switch n := node.(type) {
|
||||||
|
case nil, *ast.NullNode:
|
||||||
|
return nil, nil
|
||||||
|
case *ast.SequenceNode:
|
||||||
|
values := sequenceValues(n)
|
||||||
|
items := make([]string, 0, len(values))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, item := range values {
|
||||||
|
name, err := nodeString(unwrapNode(item))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s entries must be action names", key)
|
||||||
|
}
|
||||||
|
if _, dup := seen[name]; dup {
|
||||||
|
return nil, fmt.Errorf("%s: duplicate action %s", key, name)
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
items = append(items, name)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%s", scalarHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileBulkActionNames resolves every bulkActions name against the bulk
|
||||||
|
// actions the controller registers and returns the schema entries in declared
|
||||||
|
// order, with their source label and confirm keys.
|
||||||
|
func compileBulkActionNames(ctl pact.AdminController, names []string, showCheckboxes bool) ([]BulkAction, error) {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !showCheckboxes {
|
||||||
|
return nil, fmt.Errorf("bulkActions needs showCheckboxes: true")
|
||||||
|
}
|
||||||
|
registered := map[string]pact.AdminBulkAction{}
|
||||||
|
if src, ok := ctl.(pact.HasAdminBulkActions); ok && src != nil {
|
||||||
|
for _, action := range src.AdminBulkActions() {
|
||||||
|
registered[action.Name] = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]BulkAction, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
action, ok := registered[name]
|
||||||
|
if !ok || builtinToolbarActions[name] {
|
||||||
|
return nil, fmt.Errorf("bulkActions: unsupported action %s (want a bulk action the controller registers)", name)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(action.Label) == "" {
|
||||||
|
return nil, fmt.Errorf("bulkActions: action %s needs a label", name)
|
||||||
|
}
|
||||||
|
out = append(out, BulkAction{Name: name, Label: action.Label, Confirm: action.Confirm})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
|
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
|
||||||
if doc == nil {
|
if doc == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -555,6 +636,7 @@ func localizeBulkActions(ctx context.Context, tr *phrasebook.Translator, actions
|
|||||||
out := make([]BulkAction, len(actions))
|
out := make([]BulkAction, len(actions))
|
||||||
for i, action := range actions {
|
for i, action := range actions {
|
||||||
action.Label = translateKey(ctx, tr, action.Label)
|
action.Label = translateKey(ctx, tr, action.Label)
|
||||||
|
action.Confirm = translateKey(ctx, tr, action.Confirm)
|
||||||
out[i] = action
|
out[i] = action
|
||||||
}
|
}
|
||||||
if out == nil {
|
if out == nil {
|
||||||
|
|||||||
@@ -279,6 +279,14 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
|
|||||||
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
|
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
|
||||||
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
|
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
|
||||||
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
|
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
|
||||||
|
{"POST /{vendor}/{plugin}/{controller}/bulk/{action}", 200, "cabana.Envelope-cabana_BulkActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
|
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk/touch", map[string]any{"ids": []uint{e.gadgetID}}, true)
|
||||||
|
var body cabana.Envelope[cabana.BulkActionResult]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Affected != 1 || body.Data.Message != "Touched" {
|
||||||
|
t.Fatalf("bulk action body = %s (%v)", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}, into[cabana.Envelope[cabana.BulkActionResult]](), nil},
|
||||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
|
||||||
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
|
||||||
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
|
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
|
||||||
@@ -790,6 +798,16 @@ func (c conformController) AdminActions() []pact.AdminAction {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminBulkActions registers the bulk action the list's bulkActions declares.
|
||||||
|
func (conformController) AdminBulkActions() []pact.AdminBulkAction {
|
||||||
|
return []pact.AdminBulkAction{{
|
||||||
|
Name: "touch", Label: "Touch", Permissions: []string{"acme.conform.access"},
|
||||||
|
Run: func(_ context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
||||||
|
return pact.AdminBulkActionResult{Message: "Touched", Affected: len(in.Records)}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
|
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
|
||||||
|
|
||||||
// PartialData supplies curated view models, never the gadget model itself.
|
// PartialData supplies curated view models, never the gadget model itself.
|
||||||
@@ -824,6 +842,7 @@ toolbar:
|
|||||||
buttons: [create, delete, recount]
|
buttons: [create, delete, recount]
|
||||||
search:
|
search:
|
||||||
prompt: backend::lang.list.search_prompt
|
prompt: backend::lang.list.search_prompt
|
||||||
|
bulkActions: [touch]
|
||||||
`),
|
`),
|
||||||
// A plain custom element: a light-DOM button that asks the admin SPA
|
// A plain custom element: a light-DOM button that asks the admin SPA
|
||||||
// to run the field's action. It makes no network call and never
|
// to run the field's action. It makes no network call and never
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
"POST /auth/refresh",
|
"POST /auth/refresh",
|
||||||
"POST /{vendor}/{plugin}/{controller}",
|
"POST /{vendor}/{plugin}/{controller}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
|
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
|
||||||
|
"POST /{vendor}/{plugin}/{controller}/bulk/{action}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
|
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
||||||
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
|
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
|
||||||
@@ -114,7 +115,7 @@ func TestPhase10Coverage(t *testing.T) {
|
|||||||
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
|
||||||
}
|
}
|
||||||
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
||||||
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 23 besides login):\n%s", strings.Join(unsafe, "\n"))
|
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 24 besides login):\n%s", strings.Join(unsafe, "\n"))
|
||||||
}
|
}
|
||||||
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
||||||
// nested pattern serving field options, filter options and relation lists.
|
// nested pattern serving field options, filter options and relation lists.
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ func TestPhase10CSRF(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// refresh, logout, settings put, create, bulk-delete, widget action,
|
// refresh, logout, settings put, create, bulk-delete, bulk action,
|
||||||
// toolbar action, update, delete, link, unlink, file upload, file
|
// widget action, toolbar action, update, delete, link, unlink, file
|
||||||
// reorder, file caption, file remove, relation child create, update
|
// upload, file reorder, file caption, file remove, relation child
|
||||||
// and delete, pivot update, child file upload, reorder, caption and
|
// create, update and delete, pivot update, child file upload, reorder,
|
||||||
// remove
|
// caption and remove
|
||||||
if unsafe != 23 {
|
if unsafe != 24 {
|
||||||
t.Fatalf("walked %d state-changing routes, want 23: %v", unsafe, router.order)
|
t.Fatalf("walked %d state-changing routes, want 24: %v", unsafe, router.order)
|
||||||
}
|
}
|
||||||
|
|
||||||
loginHandler := router.handlers[login]
|
loginHandler := router.handlers[login]
|
||||||
|
|||||||
251
modules/cabana/phase121_actions_test.go
Normal file
251
modules/cabana/phase121_actions_test.go
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rosterPeople = "/acme/roster/people"
|
||||||
|
|
||||||
|
// rosterBulkNames returns the bulk action names the list schema offers auth.
|
||||||
|
func rosterBulkNames(t *testing.T, env *rosterEnv, auth string) []string {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", auth)
|
||||||
|
var list cabana.Envelope[cabana.ListSchema]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatalf("list schema: %v\n%s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(list.Data.BulkActions))
|
||||||
|
for _, action := range list.Data.BulkActions {
|
||||||
|
names = append(names, action.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func rosterBulkResult(t *testing.T, rec *httptest.ResponseRecorder) cabana.BulkActionResult {
|
||||||
|
t.Helper()
|
||||||
|
var body cabana.Envelope[cabana.BulkActionResult]
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("bulk action body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBulkActionTracer drives a declared bulk action through the assembled
|
||||||
|
// router on PostgreSQL (D-09; T-12.1-01, T-12.1-02, T-12.1-03, T-12.1-05):
|
||||||
|
// the per-principal list schema, the scoped and locked id resolution, the
|
||||||
|
// loaded records the plugin receives, the action permission and the CSRF
|
||||||
|
// header.
|
||||||
|
func TestBulkActionTracer(t *testing.T) {
|
||||||
|
env, gdb := newRosterEnv(t)
|
||||||
|
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
|
||||||
|
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", Email: "bob@example.test"})
|
||||||
|
cy := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy", Email: "cy@example.test", Active: true})
|
||||||
|
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Email: "zed@example.test"})
|
||||||
|
const activate = rosterPeople + "/bulk/activate"
|
||||||
|
ids := func(list ...uint) string {
|
||||||
|
raw, _ := json.Marshal(map[string]any{"ids": list})
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("list schema is per principal", func(t *testing.T) {
|
||||||
|
if got := rosterBulkNames(t, env, "bearer"); !reflect.DeepEqual(got, []string{"delete", "activate", "archive"}) {
|
||||||
|
t.Fatalf("full admin bulkActions = %v", got)
|
||||||
|
}
|
||||||
|
if got := rosterBulkNames(t, env, "limited"); !reflect.DeepEqual(got, []string{"delete", "archive"}) {
|
||||||
|
t.Fatalf("limited admin bulkActions = %v", got)
|
||||||
|
}
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
|
||||||
|
if !strings.Contains(rec.Body.String(), `"label":"Activate"`) || !strings.Contains(rec.Body.String(), `"confirm":"Activate the selected people?"`) {
|
||||||
|
t.Fatalf("label and confirm are not localized: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("runs on loaded records in the list scope", func(t *testing.T) {
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(bob, ada, bob), "bearer")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Affected != 2 || result.Message != "" {
|
||||||
|
t.Fatalf("result = %+v", result)
|
||||||
|
}
|
||||||
|
if !rosterLoad(t, gdb, ada).Active || !rosterLoad(t, gdb, bob).Active {
|
||||||
|
t.Fatal("the selected people were not activated")
|
||||||
|
}
|
||||||
|
calls := env.spy.takeBulk()
|
||||||
|
if len(calls) != 1 || len(calls[0].Records) != 2 {
|
||||||
|
t.Fatalf("calls = %+v", calls)
|
||||||
|
}
|
||||||
|
// The plugin gets loaded, locked records ordered by primary key and
|
||||||
|
// no id list: AdminBulkActionInput has no other field.
|
||||||
|
first, ok := calls[0].Records[0].(*rosterPerson)
|
||||||
|
second, ok2 := calls[0].Records[1].(*rosterPerson)
|
||||||
|
if !ok || !ok2 || first.ID != ada || second.ID != bob || first.Name != "Ada" || first.Tenant != "acme" {
|
||||||
|
t.Fatalf("records = %+v %+v", calls[0].Records[0], calls[0].Records[1])
|
||||||
|
}
|
||||||
|
if n := reflect.TypeOf(calls[0]).NumField(); n != 1 {
|
||||||
|
t.Fatalf("AdminBulkActionInput has %d fields, want only Records", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("affected may be lower than the selection", func(t *testing.T) {
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(ada, cy), "bearer")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Affected != 0 {
|
||||||
|
t.Fatalf("result = %+v", result)
|
||||||
|
}
|
||||||
|
env.spy.takeBulk()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("server message is localized", func(t *testing.T) {
|
||||||
|
spare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Spare"})
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/archive", ids(spare), "limited")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Affected != 1 || result.Message != "The selected people were archived." {
|
||||||
|
t.Fatalf("result = %+v", result)
|
||||||
|
}
|
||||||
|
if !rosterLoad(t, gdb, spare).DeletedAt.Valid {
|
||||||
|
t.Fatal("archive did not soft-delete the person")
|
||||||
|
}
|
||||||
|
env.spy.takeBulk()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a partial selection is a 409 and changes nothing", func(t *testing.T) {
|
||||||
|
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Fresh"})
|
||||||
|
for _, other := range []uint{foreign, 999999} {
|
||||||
|
rec := env.expect(t, http.StatusConflict, http.MethodPost, activate, ids(fresh, other), "bearer")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "conflict")
|
||||||
|
}
|
||||||
|
if rosterLoad(t, gdb, fresh).Active || rosterLoad(t, gdb, foreign).Active {
|
||||||
|
t.Fatal("a refused selection changed a row")
|
||||||
|
}
|
||||||
|
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
||||||
|
t.Fatalf("action ran for a partial selection: %+v", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a selection outside the scope is a no-op", func(t *testing.T) {
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(foreign, 999999), "bearer")
|
||||||
|
if result := rosterBulkResult(t, rec); result.Affected != 0 {
|
||||||
|
t.Fatalf("result = %+v", result)
|
||||||
|
}
|
||||||
|
if rosterLoad(t, gdb, foreign).Active {
|
||||||
|
t.Fatal("an out-of-scope row was activated")
|
||||||
|
}
|
||||||
|
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
||||||
|
t.Fatalf("action ran for out-of-scope ids: %+v", calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("body", func(t *testing.T) {
|
||||||
|
for _, body := range []string{`{"ids":[]}`, `{}`, `{"ids":["nope"]}`, `{`} {
|
||||||
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, activate, body, "bearer")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("undeclared and reserved names are 404", func(t *testing.T) {
|
||||||
|
for _, name := range []string{"missing", "delete", "create", "hidden"} {
|
||||||
|
env.expect(t, http.StatusNotFound, http.MethodPost, rosterPeople+"/bulk/"+name, ids(ada), "bearer")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/roster/nope/bulk/activate", ids(ada), "bearer")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("action permission on top of the controller's", func(t *testing.T) {
|
||||||
|
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Denied"})
|
||||||
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, activate, ids(fresh), "limited")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||||
|
if rosterLoad(t, gdb, fresh).Active {
|
||||||
|
t.Fatal("a denied admin changed a row")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cookie POSTs need X-Requested-With", func(t *testing.T) {
|
||||||
|
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cookie"})
|
||||||
|
rec := env.expect(t, http.StatusForbidden, http.MethodPost, activate, ids(fresh), "cookie-only")
|
||||||
|
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||||
|
if rosterLoad(t, gdb, fresh).Active {
|
||||||
|
t.Fatal("a request without the CSRF header changed a row")
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, activate, ids(fresh), "cookie")
|
||||||
|
if !rosterLoad(t, gdb, fresh).Active {
|
||||||
|
t.Fatal("the cookie request with the header did not run")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bulk delete is unchanged", func(t *testing.T) {
|
||||||
|
spare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone"})
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", ids(spare), "bearer")
|
||||||
|
if !strings.Contains(rec.Body.String(), `"deleted":1`) {
|
||||||
|
t.Fatalf("bulk delete = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterListFS is the roster fixture tree with config_list.yaml replaced.
|
||||||
|
func rosterListFS(t *testing.T, list string) fstest.MapFS {
|
||||||
|
t.Helper()
|
||||||
|
columns, err := os.ReadFile(filepath.Join(rosterDir, "models/person/columns.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return fstest.MapFS{
|
||||||
|
"controllers/people/config_list.yaml": &fstest.MapFile{Data: []byte(list)},
|
||||||
|
"models/person/columns.yaml": &fstest.MapFile{Data: columns},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListSchemaBulkActionsBoot checks the fail-loud compile of the
|
||||||
|
// bulkActions key (D-09): every mistake names the plugin, controller and file.
|
||||||
|
func TestListSchemaBulkActionsBoot(t *testing.T) {
|
||||||
|
const head = "list: ~/plugins/acme/roster/models/person/columns.yaml\nmodelClass: Person\n"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, yaml, want string
|
||||||
|
}{
|
||||||
|
{"unregistered", head + "showCheckboxes: true\nbulkActions: [activate, promote]\n", "bulkActions: unsupported action promote (want a bulk action the controller registers)"},
|
||||||
|
{"reserved", head + "showCheckboxes: true\nbulkActions: [delete]\n", "bulkActions: unsupported action delete (want a bulk action the controller registers)"},
|
||||||
|
{"duplicate", head + "showCheckboxes: true\nbulkActions: [activate, activate]\n", "bulkActions: duplicate action activate"},
|
||||||
|
{"no checkboxes", head + "bulkActions: [activate]\n", "bulkActions needs showCheckboxes: true"},
|
||||||
|
{"scalar", head + "showCheckboxes: true\nbulkActions: activate\n", "bulkActions must be a list of bulk action names the controller registers"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, tc.yaml))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the list compiled")
|
||||||
|
}
|
||||||
|
for _, part := range []string{tc.want, "acme.roster", "acme.roster.people", "controllers/people/config_list.yaml"} {
|
||||||
|
if !strings.Contains(err.Error(), part) {
|
||||||
|
t.Fatalf("error %q does not name %q", err, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("declared order after the built-in delete", func(t *testing.T) {
|
||||||
|
list, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, head+"showCheckboxes: true\nbulkActions: [archive, activate]\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := fmt.Sprint(list.BulkActions)
|
||||||
|
want := fmt.Sprint([]cabana.BulkAction{
|
||||||
|
{Name: "delete", Label: "backend::lang.list.delete_selected"},
|
||||||
|
{Name: "archive", Label: "acme.roster::lang.people.archive"},
|
||||||
|
{Name: "activate", Label: "acme.roster::lang.people.activate", Confirm: "acme.roster::lang.people.activate_confirm"},
|
||||||
|
})
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("bulkActions = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a list without bulkActions is unchanged", func(t *testing.T) {
|
||||||
|
list, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, head+"showCheckboxes: true\n"))
|
||||||
|
if err != nil || len(list.BulkActions) != 1 || list.BulkActions[0].Name != "delete" {
|
||||||
|
t.Fatalf("bulkActions = %+v err=%v", list.BulkActions, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
246
modules/cabana/phase121_fixture_test.go
Normal file
246
modules/cabana/phase121_fixture_test.go
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
package cabana_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rosterDir is the neutral acme.roster fixture plugin tree of the Phase 12.1
|
||||||
|
// framework features: declared bulk actions, record actions, row state and
|
||||||
|
// the forbidden error.
|
||||||
|
const rosterDir = "testdata/roster"
|
||||||
|
|
||||||
|
// rosterPerson is the fixture model: a person of one tenant who can be
|
||||||
|
// active, banned and soft-deleted.
|
||||||
|
type rosterPerson struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Tenant string `gorm:"column:tenant"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Email string `gorm:"column:email"`
|
||||||
|
Active bool `gorm:"column:active"`
|
||||||
|
Banned bool `gorm:"column:banned"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rosterPerson) TableName() string { return "roster_people" }
|
||||||
|
func (rosterPerson) Fillable() []string { return []string{"name", "email"} }
|
||||||
|
func (rosterPerson) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||||
|
|
||||||
|
// rosterSpy records what each registered action's Run receives.
|
||||||
|
type rosterSpy struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
bulk []pact.AdminBulkActionInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rosterSpy) recordBulk(in pact.AdminBulkActionInput) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.bulk = append(s.bulk, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := s.bulk
|
||||||
|
s.bulk = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type rosterPlugin struct{ spy *rosterSpy }
|
||||||
|
|
||||||
|
func (rosterPlugin) ID() string { return "acme.roster" }
|
||||||
|
func (rosterPlugin) Requires() []string { return nil }
|
||||||
|
func (rosterPlugin) Register(*backpack.App) error { return nil }
|
||||||
|
func (rosterPlugin) Boot(*backpack.App) error { return nil }
|
||||||
|
func (p rosterPlugin) AdminControllers() []pact.AdminController {
|
||||||
|
return []pact.AdminController{rosterController{spy: p.spy}}
|
||||||
|
}
|
||||||
|
func (rosterPlugin) Permissions() []pact.Permission {
|
||||||
|
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
|
||||||
|
}
|
||||||
|
func (rosterPlugin) AdminFS() fs.FS { return os.DirFS(rosterDir) }
|
||||||
|
|
||||||
|
// LangFS serves only the fixture's lang/ tree.
|
||||||
|
func (rosterPlugin) LangFS() fs.FS {
|
||||||
|
out := fstest.MapFS{}
|
||||||
|
for _, name := range []string{"lang/en/lang.yaml", "lang/pl/lang.yaml"} {
|
||||||
|
data, err := os.ReadFile(filepath.Join(rosterDir, name))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
out[name] = &fstest.MapFile{Data: data}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type rosterController struct{ spy *rosterSpy }
|
||||||
|
|
||||||
|
func (rosterController) ID() string { return "acme.roster.people" }
|
||||||
|
func (rosterController) ModelName() string { return "Person" }
|
||||||
|
func (rosterController) ConfigDir() string { return "controllers/people" }
|
||||||
|
func (rosterController) RequiredPermissions() []string { return []string{"acme.roster.access"} }
|
||||||
|
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.
|
||||||
|
func (rosterController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Where("tenant = ?", "acme")
|
||||||
|
}
|
||||||
|
func (rosterController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Where("tenant = ?", "acme")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBulkActions: activate needs acme.roster.manage and sets active on the
|
||||||
|
// rows that are not active yet, reporting how many it changed; archive needs
|
||||||
|
// only the controller permission and soft-deletes the rows.
|
||||||
|
func (c rosterController) AdminBulkActions() []pact.AdminBulkAction {
|
||||||
|
return []pact.AdminBulkAction{{
|
||||||
|
Name: "activate", Label: "acme.roster::lang.people.activate", Confirm: "acme.roster::lang.people.activate_confirm",
|
||||||
|
Permissions: []string{"acme.roster.manage"},
|
||||||
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
||||||
|
c.spy.recordBulk(in)
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
||||||
|
}
|
||||||
|
changed := 0
|
||||||
|
for _, record := range in.Records {
|
||||||
|
person := record.(*rosterPerson)
|
||||||
|
if person.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Model(person).Update("active", true).Error; err != nil {
|
||||||
|
return pact.AdminBulkActionResult{}, err
|
||||||
|
}
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
return pact.AdminBulkActionResult{Affected: changed}, nil
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
Name: "archive", Label: "acme.roster::lang.people.archive",
|
||||||
|
Permissions: []string{"acme.roster.access"},
|
||||||
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
||||||
|
c.spy.recordBulk(in)
|
||||||
|
tx, ok := cabana.TxFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
|
||||||
|
}
|
||||||
|
for _, record := range in.Records {
|
||||||
|
if err := tx.Delete(record).Error; err != nil {
|
||||||
|
return pact.AdminBulkActionResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pact.AdminBulkActionResult{Message: "acme.roster::lang.people.archived", Affected: len(in.Records)}, nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterEnv is the assembled admin API over the roster fixture. The embedded
|
||||||
|
// actEnv supplies call and expect with the four auth modes: bearer (developer
|
||||||
|
// token), limited (acme.roster.access only), cookie and cookie-only.
|
||||||
|
type rosterEnv struct {
|
||||||
|
*actEnv
|
||||||
|
spy *rosterSpy
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
gdb := adminGorm(t)
|
||||||
|
models := []any{&rosterPerson{}}
|
||||||
|
if err := gdb.Migrator().DropTable(models...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.AutoMigrate(models...); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stamp := fmt.Sprintf("r%d", time.Now().UnixNano())
|
||||||
|
login := "roster-" + stamp
|
||||||
|
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
||||||
|
var roleID uint
|
||||||
|
if err := gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Roster limited "+stamp, "roster-limited-"+stamp, `{"acme.roster.access":1}`).Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||||
|
t.Fatalf("limited role: id=%d err=%v", roleID, err)
|
||||||
|
}
|
||||||
|
limitedLogin := "roster-limited-" + stamp
|
||||||
|
limited := insertAdmin(t, gdb, limitedLogin, limitedLogin+"@example.test", adminTestPassword, true, false)
|
||||||
|
if err := gdb.Exec(`UPDATE backend_users SET role_id = ? WHERE id = ?`, roleID, limited.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-roster\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
|
||||||
|
if err := cfg.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spy := &rosterSpy{}
|
||||||
|
plugins := []party.Plugin{rosterPlugin{spy: spy}}
|
||||||
|
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h, err := surf.Assemble(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := &rosterEnv{actEnv: &actEnv{h: h}, spy: spy}
|
||||||
|
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
env.token = accessToken(t, rec.Body.Bytes())
|
||||||
|
env.cookie = &http.Cookie{Name: cabana.AdminCookieName, Value: env.token}
|
||||||
|
rec = postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": limitedLogin, "password": adminTestPassword})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("limited login status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
env.limited = accessToken(t, rec.Body.Bytes())
|
||||||
|
return env, gdb
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterInsert stores one person and returns its id.
|
||||||
|
func rosterInsert(t *testing.T, gdb *gorm.DB, person rosterPerson) uint {
|
||||||
|
t.Helper()
|
||||||
|
if err := gdb.Create(&person).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return person.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// rosterLoad reads one person, soft-deleted or not.
|
||||||
|
func rosterLoad(t *testing.T, gdb *gorm.DB, id uint) rosterPerson {
|
||||||
|
t.Helper()
|
||||||
|
var person rosterPerson
|
||||||
|
if err := gdb.Unscoped().First(&person, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return person
|
||||||
|
}
|
||||||
@@ -227,6 +227,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for name, action := range controller.BulkActions {
|
||||||
|
if err := reg.validatePermissions("bulk action "+id+"."+name, action.Permissions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, item := range reg.navigation {
|
for _, item := range reg.navigation {
|
||||||
if err := reg.validateNavigation(item); err != nil {
|
if err := reg.validateNavigation(item); err != nil {
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ type RowAction struct {
|
|||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BulkAction is a checkbox action. It carries no SQL.
|
// BulkAction is a checkbox action. It carries no SQL. The built-in delete is
|
||||||
|
// always listed when the list shows checkboxes; a declared action
|
||||||
|
// (config_list.yaml bulkActions) is per-principal: a response lists it only
|
||||||
|
// when the requesting admin may run it. Confirm is the action's own
|
||||||
|
// confirmation question, empty when the admin should show its default text.
|
||||||
type BulkAction struct {
|
type BulkAction struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
|
Confirm string `json:"confirm,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSchema is the boot-compiled list contract for one controller.
|
// ListSchema is the boot-compiled list contract for one controller.
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ var phase09Routes = []adminRoute{
|
|||||||
{key: "GET /{vendor}/{plugin}/{controller}"},
|
{key: "GET /{vendor}/{plugin}/{controller}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}"},
|
{key: "POST /{vendor}/{plugin}/{controller}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
|
||||||
|
{key: "POST /{vendor}/{plugin}/{controller}/bulk/{action}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
|
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
|
||||||
{key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"},
|
{key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"},
|
||||||
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
|
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
|
||||||
@@ -290,6 +291,7 @@ func phase09ProtectedCalls() []phase09Call {
|
|||||||
{"list", (*service).list},
|
{"list", (*service).list},
|
||||||
{"create", (*service).create},
|
{"create", (*service).create},
|
||||||
{"bulk-delete", (*service).bulkDelete},
|
{"bulk-delete", (*service).bulkDelete},
|
||||||
|
{"bulk-action", (*service).bulkAction},
|
||||||
{"widget-action", (*service).widgetAction},
|
{"widget-action", (*service).widgetAction},
|
||||||
{"toolbar-action", (*service).toolbarAction},
|
{"toolbar-action", (*service).toolbarAction},
|
||||||
{"partial", (*service).partial},
|
{"partial", (*service).partial},
|
||||||
|
|||||||
10
modules/cabana/testdata/roster/controllers/people/config_form.yaml
vendored
Normal file
10
modules/cabana/testdata/roster/controllers/people/config_form.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: acme.roster::lang.people.form
|
||||||
|
form: ~/plugins/acme/roster/models/person/fields.yaml
|
||||||
|
modelClass: Person
|
||||||
|
defaultRedirect: acme/roster/people
|
||||||
|
create:
|
||||||
|
redirect: acme/roster/people/update/:id
|
||||||
|
redirectClose: acme/roster/people
|
||||||
|
update:
|
||||||
|
redirect: acme/roster/people
|
||||||
|
redirectClose: acme/roster/people
|
||||||
13
modules/cabana/testdata/roster/controllers/people/config_list.yaml
vendored
Normal file
13
modules/cabana/testdata/roster/controllers/people/config_list.yaml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
list: ~/plugins/acme/roster/models/person/columns.yaml
|
||||||
|
modelClass: Person
|
||||||
|
title: acme.roster::lang.people.title
|
||||||
|
recordUrl: acme/roster/people/update/:id
|
||||||
|
recordsPerPage: 20
|
||||||
|
showCheckboxes: true
|
||||||
|
toolbar:
|
||||||
|
buttons: [create, delete]
|
||||||
|
search:
|
||||||
|
prompt: backend::lang.list.search_prompt
|
||||||
|
bulkActions: [activate, archive]
|
||||||
|
messages:
|
||||||
|
create: acme.roster::lang.people.create
|
||||||
10
modules/cabana/testdata/roster/lang/en/lang.yaml
vendored
Normal file
10
modules/cabana/testdata/roster/lang/en/lang.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
people:
|
||||||
|
title: People
|
||||||
|
form: Person
|
||||||
|
create: New person
|
||||||
|
name: Name
|
||||||
|
email: Email
|
||||||
|
activate: Activate
|
||||||
|
activate_confirm: Activate the selected people?
|
||||||
|
archive: Archive
|
||||||
|
archived: The selected people were archived.
|
||||||
10
modules/cabana/testdata/roster/lang/pl/lang.yaml
vendored
Normal file
10
modules/cabana/testdata/roster/lang/pl/lang.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
people:
|
||||||
|
title: Osoby
|
||||||
|
form: Osoba
|
||||||
|
create: Nowa osoba
|
||||||
|
name: Imię i nazwisko
|
||||||
|
email: E-mail
|
||||||
|
activate: Aktywuj
|
||||||
|
activate_confirm: Aktywować zaznaczone osoby?
|
||||||
|
archive: Archiwizuj
|
||||||
|
archived: Zaznaczone osoby zostały zarchiwizowane.
|
||||||
7
modules/cabana/testdata/roster/models/person/columns.yaml
vendored
Normal file
7
modules/cabana/testdata/roster/models/person/columns.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
columns:
|
||||||
|
name:
|
||||||
|
label: acme.roster::lang.people.name
|
||||||
|
searchable: true
|
||||||
|
email:
|
||||||
|
label: acme.roster::lang.people.email
|
||||||
|
searchable: true
|
||||||
9
modules/cabana/testdata/roster/models/person/fields.yaml
vendored
Normal file
9
modules/cabana/testdata/roster/models/person/fields.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fields:
|
||||||
|
name:
|
||||||
|
label: acme.roster::lang.people.name
|
||||||
|
type: text
|
||||||
|
span: left
|
||||||
|
email:
|
||||||
|
label: acme.roster::lang.people.email
|
||||||
|
type: text
|
||||||
|
span: right
|
||||||
@@ -14,7 +14,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
|
|||||||
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
|
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
|
||||||
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
|
- 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 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), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
- 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), 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`), 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 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.
|
- 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.
|
||||||
@@ -109,6 +109,10 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
|||||||
| `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, and the fill snapshot. |
|
| `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, and the fill snapshot. |
|
||||||
| `pact.AdminActionResult` | What an action returns: a message for the toast and the fill write-back values. |
|
| `pact.AdminActionResult` | What an action returns: a message for the toast and the fill write-back values. |
|
||||||
| `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. |
|
| `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. |
|
||||||
|
| `pact.AdminBulkAction` | One named bulk action: name, label, optional confirm text, extra permissions and the Go `Run` function. |
|
||||||
|
| `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.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
|
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
|
||||||
| `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. |
|
| `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.RelationBeforeLink` | Optional controller hook that checks or fills pivot columns before a relation link is written. |
|
||||||
|
|||||||
@@ -255,6 +255,50 @@ type HasAdminActions interface {
|
|||||||
AdminActions() []AdminAction
|
AdminActions() []AdminAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminBulkAction is one controller action an administrator runs on the rows
|
||||||
|
// selected in a list (config_list.yaml bulkActions). The admin framework owns
|
||||||
|
// the HTTP route, the CSRF check, authentication, the resolution of the posted
|
||||||
|
// ids and the transaction; Run only carries the business logic and reads the
|
||||||
|
// write transaction with cabana.TxFromContext. Name is an identifier unique
|
||||||
|
// among the controller's bulk actions; create and delete are reserved, and a
|
||||||
|
// record action may reuse a bulk action's name. Label and Confirm are phrase
|
||||||
|
// keys or literal text: Label is the menu item, Confirm the confirmation
|
||||||
|
// question, and an empty Confirm means the framework's default confirm text.
|
||||||
|
// Permissions are checked in addition to the controller's
|
||||||
|
// RequiredPermissions.
|
||||||
|
type AdminBulkAction struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Confirm string
|
||||||
|
Permissions []string
|
||||||
|
Run func(ctx context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error) `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBulkActionInput is what the framework hands an AdminBulkAction.
|
||||||
|
// Records are pointers to the controller's model, loaded and row-locked
|
||||||
|
// through the controller's ListExtendQuery scope and ordered by primary key.
|
||||||
|
// A bulk action never receives ids, so an id list can never become an
|
||||||
|
// unscoped lookup.
|
||||||
|
type AdminBulkActionInput struct {
|
||||||
|
Records []any
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBulkActionResult is a bulk action's answer. Message is a phrase key or
|
||||||
|
// text, localized by the framework and shown as a toast; when it is empty the
|
||||||
|
// admin shows its default text with Affected. Affected is the number of
|
||||||
|
// records the action changed, which may be lower than the selection when the
|
||||||
|
// action skips rows.
|
||||||
|
type AdminBulkActionResult struct {
|
||||||
|
Message string
|
||||||
|
Affected int
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAdminBulkActions is implemented by an admin controller that registers
|
||||||
|
// named bulk actions for its config_list.yaml bulkActions list.
|
||||||
|
type HasAdminBulkActions interface {
|
||||||
|
AdminBulkActions() []AdminBulkAction
|
||||||
|
}
|
||||||
|
|
||||||
// AdminPartialData supplies the view model a controller partial template
|
// AdminPartialData supplies the view model a controller partial template
|
||||||
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
|
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
|
||||||
// is the partial name; record is the scoped record for a form partial on an
|
// is the partial name; record is the scoped record for a form partial on an
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ list:
|
|||||||
filter_from: From
|
filter_from: From
|
||||||
filter_to: To
|
filter_to: To
|
||||||
pagination: Pagination
|
pagination: Pagination
|
||||||
|
bulk_actions: Bulk actions
|
||||||
|
bulk_confirm: "Run “:action” on the selected (:count)?"
|
||||||
|
bulk_done:
|
||||||
|
one: "Action completed for :count record."
|
||||||
|
other: "Action completed for :count records."
|
||||||
|
bulk_stale: Some of the selected records are no longer available. The list has been refreshed; select the records again.
|
||||||
|
action_forbidden: You do not have permission to run this action.
|
||||||
form:
|
form:
|
||||||
save: Save
|
save: Save
|
||||||
save_and_close: Save and close
|
save_and_close: Save and close
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ list:
|
|||||||
filter_from: Od
|
filter_from: Od
|
||||||
filter_to: Do
|
filter_to: Do
|
||||||
pagination: Stronicowanie
|
pagination: Stronicowanie
|
||||||
|
bulk_actions: Działania masowe
|
||||||
|
bulk_confirm: "Wykonać „:action” na zaznaczonych (:count)?"
|
||||||
|
bulk_done:
|
||||||
|
one: "Wykonano akcję dla :count rekordu."
|
||||||
|
few: "Wykonano akcję dla :count rekordów."
|
||||||
|
many: "Wykonano akcję dla :count rekordów."
|
||||||
|
other: "Wykonano akcję dla :count rekordów."
|
||||||
|
bulk_stale: Część zaznaczonych rekordów nie jest już dostępna. Lista została odświeżona; zaznacz rekordy ponownie.
|
||||||
|
action_forbidden: Nie masz uprawnień do wykonania tej akcji.
|
||||||
form:
|
form:
|
||||||
save: Zapisz
|
save: Zapisz
|
||||||
save_and_close: Zapisz i zamknij
|
save_and_close: Zapisz i zamknij
|
||||||
|
|||||||
Reference in New Issue
Block a user