From 1789d66524c3ecafb7bd626b06d6c4346bcd10fd Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 9 Sep 2026 13:47:48 +0100 Subject: [PATCH 01/19] wip admin table updates --- .../modules/editable-table/editable-table.ts | 34 ++- resources/js/modules/editable-table/types.ts | 17 +- resources/js/modules/forms/AdminTableNode.vue | 286 ++++++++++++++++++ resources/js/modules/forms/register.ts | 2 + resources/js/pages/Form.test.ts | 14 + resources/js/pages/Form.vue | 91 +++--- src/Form/FormNodeTypes.php | 2 + src/Form/Nodes/Table.php | 252 +++++++++++++++ 8 files changed, 659 insertions(+), 39 deletions(-) create mode 100644 resources/js/modules/forms/AdminTableNode.vue create mode 100644 src/Form/Nodes/Table.php diff --git a/resources/js/modules/editable-table/editable-table.ts b/resources/js/modules/editable-table/editable-table.ts index 6ab63b770eb..f5790ef31c0 100644 --- a/resources/js/modules/editable-table/editable-table.ts +++ b/resources/js/modules/editable-table/editable-table.ts @@ -1,5 +1,6 @@ import {Base} from '@craftcms/garnish'; import type CraftCombobox from '@craftcms/ui/components/combobox/combobox'; +import type {ComboboxItem} from '@craftcms/ui/components/combobox/combobox'; import type CraftTextExpander from '@craftcms/ui/components/text-expander/text-expander'; import '@craftcms/ui/components/text-expander/text-expander'; import {editableTableData, editableTableRowData} from './support'; @@ -7,6 +8,7 @@ import type { EditableTableColumn, EditableTableColumns, EditableTableOption, + EditableTableOptionGroup, EditableTableOptions, EditableTableRow, EditableTableValue, @@ -24,6 +26,12 @@ declare const $: any; const noop = (): void => {}; +function isOptionGroup( + option: EditableTableOption | EditableTableOptionGroup +): option is EditableTableOptionGroup { + return Array.isArray((option as EditableTableOptionGroup).options); +} + function defaultOptionValue( options: EditableTableOptions | EditableTableOption[] | undefined ): EditableTableValue | null { @@ -724,10 +732,28 @@ export class EditableTable extends Base { combobox.name = name; combobox.label = col.heading ?? colId; combobox.options = Array.isArray(col.options) - ? col.options.map((option) => ({ - label: option.label ?? String(option.value ?? ''), - value: String(option.value ?? ''), - })) + ? col.options.map( + (option): ComboboxItem => + // An -style entry (e.g. SelectOptions::getTemplateSuggestions()) + // nests its real options one level deeper — craft-combobox has native + // optgroup support (ComboboxOptGroup), so pass the grouping through rather + // than flattening it away. + isOptionGroup(option) + ? { + type: 'optgroup', + label: option.label ?? '', + options: option.options.map((groupedOption) => ({ + label: + groupedOption.label ?? + String(groupedOption.value ?? ''), + value: String(groupedOption.value ?? ''), + })), + } + : { + label: option.label ?? String(option.value ?? ''), + value: String(option.value ?? ''), + } + ) : []; combobox.modelValue = String(value ?? ''); combobox.showAllOnEmpty = true; diff --git a/resources/js/modules/editable-table/types.ts b/resources/js/modules/editable-table/types.ts index 90d0543669c..709e5dcf691 100644 --- a/resources/js/modules/editable-table/types.ts +++ b/resources/js/modules/editable-table/types.ts @@ -16,7 +16,10 @@ export interface EditableTableColumn { rows?: number; code?: boolean; value?: string | number; - options?: EditableTableOptions | EditableTableOption[]; + options?: + | EditableTableOptions + | EditableTableOption[] + | EditableTableOptionGroup[]; textExpanderTriggers?: TextExpanderTriggers; /** Checkbox: only one in the column may be checked at a time. */ radioMode?: boolean; @@ -51,6 +54,17 @@ export interface EditableTableOptions { [key: string]: EditableTableOption; } +/** + * An ``-style options entry, e.g. what + * `CraftCms\Cms\Cp\SelectOptions::getTemplateSuggestions()` returns — a leaf + * option's own shape is {@link EditableTableOption}, not this. + */ +export interface EditableTableOptionGroup { + label?: string; + type?: 'optgroup'; + options: EditableTableOption[]; +} + type EditableTableColumnValue = | string | number @@ -59,6 +73,7 @@ type EditableTableColumnValue = | string[] | EditableTableOptions | EditableTableOption[] + | EditableTableOptionGroup[] | TextExpanderTriggers; /** Map of column ID → column definition. */ diff --git a/resources/js/modules/forms/AdminTableNode.vue b/resources/js/modules/forms/AdminTableNode.vue new file mode 100644 index 00000000000..4ff5f20a613 --- /dev/null +++ b/resources/js/modules/forms/AdminTableNode.vue @@ -0,0 +1,286 @@ + + + diff --git a/resources/js/modules/forms/register.ts b/resources/js/modules/forms/register.ts index f54de84bafe..7c033a43911 100644 --- a/resources/js/modules/forms/register.ts +++ b/resources/js/modules/forms/register.ts @@ -36,6 +36,7 @@ import HiddenFieldNode from './HiddenFieldNode.vue'; import LineBreakNode from './LineBreakNode.vue'; import PermissionTreeControl from './PermissionTreeControl.vue'; import SeparatorNode from './SeparatorNode.vue'; +import AdminTableNode from './AdminTableNode.vue'; import './content-block-input'; export function registerFormComponents( @@ -73,6 +74,7 @@ export function registerFormComponents( components.register('craft:permission-tree', PermissionTreeControl); components.register('craft:markdown', MarkdownControl); components.register('craft:table', TableControl); + components.register('craft:admin-table', AdminTableNode); components.register('craft:link', LinkControl); components.register('craft:address', AddressControl); components.register('craft:icon-picker', IconPickerControl); diff --git a/resources/js/pages/Form.test.ts b/resources/js/pages/Form.test.ts index 523e4c1ccfe..20fb10812bb 100644 --- a/resources/js/pages/Form.test.ts +++ b/resources/js/pages/Form.test.ts @@ -158,6 +158,20 @@ it('submits complete current values after a partial mutation', async () => { ); }); +it('renders as a plain element with no save flow when there is nothing to submit to', async () => { + app = createApp(FormPage, {form: payload}); + app.mount(container); + await nextTick(); + + const layoutCall = state.layout.mock.calls[0]; + if (!layoutCall) throw new Error('Expected the layout registration.'); + expect(layoutCall[0].onSave).toBeUndefined(); + + // No `submit` means nothing to post to, so there's no `
` at all — + // just its contents, in a plain wrapper. + expect(container.querySelector('form')).toBeNull(); +}); + it('passes screen layout options through to the app layout', () => { app = createApp(FormPage, { form: payload, diff --git a/resources/js/pages/Form.vue b/resources/js/pages/Form.vue index f1c0dd8f157..083c7d5d991 100644 --- a/resources/js/pages/Form.vue +++ b/resources/js/pages/Form.vue @@ -20,7 +20,15 @@ const props = defineProps<{ form: FormPayload; - submit: UrlMethodPair; + /** + * Omit for a screen with nothing to save as a whole — a listing, say, + * that may still hold ordinary Field controls (row-selection checkboxes + * and the like) driven by the same value tracking below. Those just need + * their own action button reading `currentValues()`/`setValue()` off + * this component's exposed API, rather than a single generic save. When + * omitted, no `` element is rendered at all — see the template. + */ + submit?: UrlMethodPair; elevatedFields?: string[] | '*'; refreshUrl?: string; defaultFormActions?: UseAppLayoutOptions['defaultFormActions']; @@ -35,40 +43,49 @@ const elevatedFields = props.elevatedFields; const {advanceBaseline, errors, onMutation, renderer} = useInertiaFormRenderer(inertiaForm, () => props.form); - const {save} = useSettingsSave(inertiaForm, () => props.submit, { - transform: () => renderer.value?.currentValues() ?? props.form.values, - onSuccess: () => { - elevatedBaseline.value = structuredClone( - toRaw(renderer.value?.currentValues() ?? props.form.values) - ); - advanceBaseline(); - }, - passwordConfirmation: elevatedFields - ? { - required: () => { - const values = renderer.value?.currentValues() ?? props.form.values; - const fields = - elevatedFields === '*' - ? [ - ...new Set([ - ...Object.keys(elevatedBaseline.value), - ...Object.keys(values), - ]), - ] - : elevatedFields; - return fields.some( - (field) => - normalize(values[field]) !== - normalize(elevatedBaseline.value[field]) - ); - }, - } - : undefined, - }); + // Only wire up a save flow (and its cmd/ctrl + s shortcut) when there's + // somewhere to submit to — otherwise there's nothing for `save()` to post. + const save = props.submit + ? useSettingsSave(inertiaForm, () => props.submit!, { + transform: () => renderer.value?.currentValues() ?? props.form.values, + onSuccess: () => { + elevatedBaseline.value = structuredClone( + toRaw(renderer.value?.currentValues() ?? props.form.values) + ); + advanceBaseline(); + }, + passwordConfirmation: elevatedFields + ? { + required: () => { + const values = + renderer.value?.currentValues() ?? props.form.values; + const fields = + elevatedFields === '*' + ? [ + ...new Set([ + ...Object.keys(elevatedBaseline.value), + ...Object.keys(values), + ]), + ] + : elevatedFields; + + return fields.some( + (field) => + normalize(values[field]) !== + normalize(elevatedBaseline.value[field]) + ); + }, + } + : undefined, + }).save + : undefined; + // `PageScreen` shows the Save button purely on `form` being truthy (`v-if="form"`) — + // it doesn't look at `onSave`/`submit`. Passing `inertiaForm` unconditionally would + // show a Save button with nothing to save on a node-only screen (a listing, say). useAppLayout({ - form: inertiaForm, + form: props.submit ? inertiaForm : null, defaultFormActions: props.defaultFormActions, onSave: save, }); @@ -108,7 +125,13 @@ diff --git a/src/Form/FormNodeTypes.php b/src/Form/FormNodeTypes.php index 88df9ef588b..ffed47ca608 100644 --- a/src/Form/FormNodeTypes.php +++ b/src/Form/FormNodeTypes.php @@ -17,6 +17,7 @@ use CraftCms\Cms\Form\Nodes\Missing; use CraftCms\Cms\Form\Nodes\Separator; use CraftCms\Cms\Form\Nodes\Tab; +use CraftCms\Cms\Form\Nodes\Table; use CraftCms\Cms\Form\Nodes\TemplateContent; use Illuminate\Container\Attributes\Singleton; @@ -42,6 +43,7 @@ class FormNodeTypes extends TypeRegistry Missing::class, Separator::class, Tab::class, + Table::class, TemplateContent::class, ]; } diff --git a/src/Form/Nodes/Table.php b/src/Form/Nodes/Table.php new file mode 100644 index 00000000000..6f6e9d0ab67 --- /dev/null +++ b/src/Form/Nodes/Table.php @@ -0,0 +1,252 @@ + */ + private array $columns = []; + + /** @var list> */ + private array $rows = []; + + private ?string $emptyMessage = null; + + private ?string $createLabel = null; + + private ?string $createUrl = null; + + private ?string $reorderUrl = null; + + private ?string $deleteUrl = null; + + private ?string $deleteConfirmMessage = null; + + public function __construct(private readonly string $uid) {} + + public static function make(string $uid): self + { + return new self($uid); + } + + /** @param list $columns */ + public function columns(array $columns): static + { + $this->columns = $columns; + + return $this; + } + + /** + * @param list> $rows Each row is keyed by column `key`, plus an `id` + * entry identifying the row — required when {@see reorderable()} or {@see deletable()} are + * used. A cell value may be a plain scalar; an array shaped `['label' => string, 'url' => + * ?string]` to render as a link (or plain text when `url` is null); a list of such arrays + * to render several links in one cell; `['label' => string, 'items' => list]` to render a dropdown menu of links; `['icon' => string, 'label' => + * ?string]` to render a single icon (`label` becomes its accessible name, and is what the + * non-JS {@see renderHtml()} fallback shows in place of the icon); or `['html' => string]` + * for markup none of the above can express (a styled `` value, a compound badge). The + * `html` shape is run through the same sanitizer {@see TemplateContent} uses (blocking + * `form`, dropping `button`/`input`/`optgroup`/`option`/`select`/`textarea`) as a + * defense-in-depth backstop — but sanitizing isn't encoding: the caller is still responsible + * for {@see Html::encode()}-ing any user-entered value it interpolates into the string before + * it ever reaches here, exactly as for `TemplateContent`. Prefer one of the structured shapes + * above when it fits; `html` exists for what doesn't. A row may set `_deletable => false` to + * suppress its own delete action even when the table as a whole is {@see deletable()} (e.g. a + * "primary" row that can't be removed). + */ + public function rows(array $rows): static + { + $this->rows = array_map( + fn(array $row) => array_map(self::sanitizeCell(...), $row), + $rows, + ); + + return $this; + } + + private static function sanitizeCell(mixed $value): mixed + { + if (!is_array($value) || !array_key_exists('html', $value)) { + return $value; + } + + $config = app(HtmlSanitizerManager::class)->defaultConfig() + ->blockElement('form'); + + foreach (['button', 'input', 'optgroup', 'option', 'select', 'textarea'] as $element) { + $config = $config->dropElement($element); + } + + return ['html' => new HtmlSanitizer($config)->sanitize($value['html'])]; + } + + public function emptyMessage(?string $emptyMessage): static + { + $this->emptyMessage = $emptyMessage; + + return $this; + } + + public function createAction(?string $label, ?string $url): static + { + $this->createLabel = $label; + $this->createUrl = $url; + + return $this; + } + + /** Enables drag-to-reorder; the new order posts to `$url` as `{ids: list}`. */ + public function reorderable(string $url): static + { + $this->reorderUrl = $url; + + return $this; + } + + /** + * Adds a per-row delete action, posting `{id: }` to `$url`. Individual rows can + * opt out via `_deletable => false` in {@see rows()}. + */ + public function deletable(string $url, ?string $confirmMessage = null): static + { + $this->deleteUrl = $url; + $this->deleteConfirmMessage = $confirmMessage; + + return $this; + } + + public static function renderHtml(NodePayload $node, FormPayload $payload, FormHtmlRenderer $renderer): string + { + $columns = $node->props['columns']; + $rows = $node->props['rows']; + + $createAction = $node->props['createUrl'] !== null && $node->props['createLabel'] !== null + ? Html::a(Html::encode($node->props['createLabel']), $node->props['createUrl'], [ + 'class' => ['btn', 'submit', 'add', 'icon'], + ]) + : ''; + + if (empty($rows)) { + $table = Html::tag('p', Html::encode($node->props['emptyMessage'] ?? ''), [ + 'class' => ['zilch'], + ]); + } else { + $renderLink = fn(array $link): string => $link['url'] !== null + ? Html::a(Html::encode($link['label']), $link['url']) + : Html::encode($link['label']); + + // Reordering and deleting are inherently interactive (drag handles, confirmation + // dialogs, CSRF-protected requests) with no sensible plain-HTML equivalent, so this + // fallback renders a menu's links inline but otherwise omits those two affordances — + // consistent with the rest of the CP treating this renderer as JS-less read access, + // not a full replacement for the Vue control. + $renderCell = function(array $column, array $row) use ($renderLink): string { + $value = $row[$column['key']] ?? ''; + + if (is_array($value) && array_key_exists('items', $value)) { + return implode(', ', array_map($renderLink, $value['items'])); + } + + if (is_array($value) && array_key_exists('icon', $value)) { + return Html::encode($value['label'] ?? ''); + } + + if (is_array($value) && array_key_exists('html', $value)) { + // Already sanitized in rows() — not re-encoded, this is meant to be markup. + return $value['html']; + } + + if (is_array($value) && array_is_list($value)) { + return implode(', ', array_map($renderLink, $value)); + } + + if (is_array($value)) { + return $renderLink($value); + } + + return Html::encode((string) $value); + }; + + $head = Html::tag('tr', implode('', array_map( + fn(array $column) => Html::tag('th', Html::encode($column['label'])), + $columns, + ))); + + $body = implode('', array_map( + fn(array $row) => Html::tag('tr', implode('', array_map( + fn(array $column) => Html::tag('td', $renderCell($column, $row)), + $columns, + ))), + $rows, + )); + + $table = Html::tag('table', Html::tag('thead', $head).Html::tag('tbody', $body), [ + 'class' => ['data', 'fullwidth'], + ]); + } + + return Html::tag('div', $createAction.$table, [ + 'class' => ['grid', 'gap-2'], + 'data-form-node' => $node->uid, + ]); + } + + public function component(): string + { + return 'craft:admin-table'; + } + + public function uid(): ?string + { + return $this->uid; + } + + public function props(): array + { + return [ + 'columns' => $this->columns, + 'rows' => $this->rows, + 'emptyMessage' => $this->emptyMessage, + 'createLabel' => $this->createLabel, + 'createUrl' => $this->createUrl, + 'reorderUrl' => $this->reorderUrl, + 'deleteUrl' => $this->deleteUrl, + 'deleteConfirmMessage' => $this->deleteConfirmMessage, + ]; + } + + public function getControl(): ?Control + { + return null; + } + + public function children(): array + { + return []; + } +} From e2b129d20fbb7121a5c13fb77941a00ef196d429 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 9 Sep 2026 15:00:20 +0100 Subject: [PATCH 02/19] fix card designer element type --- .../js/modules/field-layout-designer/card-view-designer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/js/modules/field-layout-designer/card-view-designer.ts b/resources/js/modules/field-layout-designer/card-view-designer.ts index d4b9ad2e08c..2f165a7dd8a 100644 --- a/resources/js/modules/field-layout-designer/card-view-designer.ts +++ b/resources/js/modules/field-layout-designer/card-view-designer.ts @@ -135,6 +135,10 @@ export class CardViewDesigner extends Base { data: { fieldLayoutConfig: { ...this.designer.config, + // Not part of `this.designer.config` (that lives at + // `designer.settings.elementType` instead), but `CardDesigner::previewHtml()` + // needs a `type` on the layout config to instantiate a sample element. + type: this.designer.settings!.elementType, generatedFields: document .querySelector('craft-generated-fields-table') From a1a302bdd15cc2c4e358e2cddf4f2cdff4d4a037 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 15 Sep 2026 17:27:53 +0100 Subject: [PATCH 03/19] table and control compatibility changes --- resources/js/modules/forms/AdminTableNode.vue | 43 +++++++-- .../js/modules/forms/ComboboxControl.vue | 1 - resources/js/pages/Form.vue | 11 +++ src/Form/Nodes/Table.php | 87 +++++++++++-------- 4 files changed, 96 insertions(+), 46 deletions(-) diff --git a/resources/js/modules/forms/AdminTableNode.vue b/resources/js/modules/forms/AdminTableNode.vue index 4ff5f20a613..4c24ce3df3c 100644 --- a/resources/js/modules/forms/AdminTableNode.vue +++ b/resources/js/modules/forms/AdminTableNode.vue @@ -55,14 +55,16 @@ * renders on its own) live in the trailing actions column — a menu is * ordinary column data, not merged into it. * - * `html` is trusted, not sanitized here: the PHP `Table::rows()` that - * produced it already ran it through the same HTML sanitizer - * `TemplateContent` uses (see its Node docs), the same trust boundary - * `TemplateContentNode.vue` relies on for its own `v-html`. Sanitizing - * doesn't substitute for encoding, though — whichever server-side column - * builds one of these still has to `Html::encode()` any user-entered value - * before it goes into the string. Prefer a structured shape above when it - * fits; reach for `html` only when it doesn't. + * `html` is trusted completely, not sanitized here or on the PHP side: the + * PHP `Table::rows()` that produced it renders it unsanitized on purpose, + * so a cell can host a real working custom element (a copy-to-clipboard + * control, say) rather than only static display markup — unlike + * `TemplateContentNode.vue`'s `v-html`, which still relies on + * `TemplateContent`'s own sanitizer. That leaves whichever server-side + * column builds one of these entirely responsible for its safety — + * `Html::encode()`-ing any user-entered value before it goes into the + * string, exactly as if writing directly to the page. Prefer a structured + * shape above when it fits; reach for `html` only when it doesn't. */ type TableCellValue = | string @@ -89,6 +91,7 @@ emptyMessage: string | null; createLabel: string | null; createUrl: string | null; + createMenuItems: Array<{label: string; url: string}> | null; reorderUrl: string | null; deleteUrl: string | null; deleteConfirmMessage: string | null; @@ -108,6 +111,15 @@ const columnHelper = createCraftColumnHelper(); + const createMenuActions = computed( + () => + props.node.props.createMenuItems?.map((item) => ({ + type: 'link', + href: item.url, + label: item.label, + })) ?? [] + ); + function isMenu( value: TableLink | TableLink[] | TableMenu | TableIcon | TableHtml ): value is TableMenu { @@ -270,6 +282,21 @@ >{{ node.props.createLabel }} + + + + + (); const emit = defineEmits<{ (event: 'change', change: FormChange, values: FormPayload['values']): void; @@ -153,4 +161,7 @@ + + + diff --git a/src/Form/Nodes/Table.php b/src/Form/Nodes/Table.php index 6f6e9d0ab67..417de25b642 100644 --- a/src/Form/Nodes/Table.php +++ b/src/Form/Nodes/Table.php @@ -10,9 +10,7 @@ use CraftCms\Cms\Form\FormPayload; use CraftCms\Cms\Form\NodePayload; use CraftCms\Cms\Support\Html; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager; use Illuminate\Support\Traits\Conditionable; -use Symfony\Component\HtmlSanitizer\HtmlSanitizer; /** * A listing of rows (e.g. product types, gateways) rendered as a Form Node, backed by the @@ -39,6 +37,9 @@ class Table implements Node private ?string $createUrl = null; + /** @var list|null */ + private ?array $createMenuItems = null; + private ?string $reorderUrl = null; private ?string $deleteUrl = null; @@ -69,42 +70,25 @@ public function columns(array $columns): static * string, url: ?string}>]` to render a dropdown menu of links; `['icon' => string, 'label' => * ?string]` to render a single icon (`label` becomes its accessible name, and is what the * non-JS {@see renderHtml()} fallback shows in place of the icon); or `['html' => string]` - * for markup none of the above can express (a styled `` value, a compound badge). The - * `html` shape is run through the same sanitizer {@see TemplateContent} uses (blocking - * `form`, dropping `button`/`input`/`optgroup`/`option`/`select`/`textarea`) as a - * defense-in-depth backstop — but sanitizing isn't encoding: the caller is still responsible - * for {@see Html::encode()}-ing any user-entered value it interpolates into the string before - * it ever reaches here, exactly as for `TemplateContent`. Prefer one of the structured shapes - * above when it fits; `html` exists for what doesn't. A row may set `_deletable => false` to - * suppress its own delete action even when the table as a whole is {@see deletable()} (e.g. a - * "primary" row that can't be removed). + * for markup none of the above can express (a styled `` value, a compound badge, a + * working custom element like `` that needs its own real light-DOM + * ``). Unlike {@see TemplateContent}, `html` here is rendered completely unsanitized + * — some index tables need cells that are more than static display (a real copy-to-clipboard + * control, for instance), which a sanitizer that drops `input`/`button`/etc. as defense in + * depth would break. That means the caller owns this trust boundary entirely: run untrusted + * values through {@see Html::encode()} (or a sanitizer, if the value is itself meant to carry + * markup) before interpolating them into the string, exactly as if writing directly to the + * page. Prefer one of the structured shapes above when it fits; `html` exists for what + * doesn't. A row may set `_deletable => false` to suppress its own delete action even when + * the table as a whole is {@see deletable()} (e.g. a "primary" row that can't be removed). */ public function rows(array $rows): static { - $this->rows = array_map( - fn(array $row) => array_map(self::sanitizeCell(...), $row), - $rows, - ); + $this->rows = $rows; return $this; } - private static function sanitizeCell(mixed $value): mixed - { - if (!is_array($value) || !array_key_exists('html', $value)) { - return $value; - } - - $config = app(HtmlSanitizerManager::class)->defaultConfig() - ->blockElement('form'); - - foreach (['button', 'input', 'optgroup', 'option', 'select', 'textarea'] as $element) { - $config = $config->dropElement($element); - } - - return ['html' => new HtmlSanitizer($config)->sanitize($value['html'])]; - } - public function emptyMessage(?string $emptyMessage): static { $this->emptyMessage = $emptyMessage; @@ -116,6 +100,23 @@ public function createAction(?string $label, ?string $url): static { $this->createLabel = $label; $this->createUrl = $url; + $this->createMenuItems = null; + + return $this; + } + + /** + * Like {@see createAction()}, but for when there's more than one place a new row could come + * from (e.g. one per store) — renders as a single button that opens a menu of links instead + * of linking straight to `$url`. + * + * @param list $items + */ + public function createActionMenu(string $label, array $items): static + { + $this->createLabel = $label; + $this->createUrl = null; + $this->createMenuItems = $items; return $this; } @@ -145,11 +146,21 @@ public static function renderHtml(NodePayload $node, FormPayload $payload, FormH $columns = $node->props['columns']; $rows = $node->props['rows']; - $createAction = $node->props['createUrl'] !== null && $node->props['createLabel'] !== null - ? Html::a(Html::encode($node->props['createLabel']), $node->props['createUrl'], [ - 'class' => ['btn', 'submit', 'add', 'icon'], - ]) - : ''; + $createAction = match (true) { + $node->props['createUrl'] !== null && $node->props['createLabel'] !== null => Html::a( + Html::encode($node->props['createLabel']), + $node->props['createUrl'], + ['class' => ['btn', 'submit', 'add', 'icon']], + ), + // No sensible JS-less dropdown-button equivalent — same reasoning renderCell()'s + // `items` shape gives for menu cells — so this renders the label followed by its + // items as plain inline links instead. + !empty($node->props['createMenuItems']) => Html::encode($node->props['createLabel'] ?? '').': '.implode(', ', array_map( + fn(array $item) => Html::a(Html::encode($item['label']), $item['url']), + $node->props['createMenuItems'], + )), + default => '', + }; if (empty($rows)) { $table = Html::tag('p', Html::encode($node->props['emptyMessage'] ?? ''), [ @@ -177,7 +188,8 @@ public static function renderHtml(NodePayload $node, FormPayload $payload, FormH } if (is_array($value) && array_key_exists('html', $value)) { - // Already sanitized in rows() — not re-encoded, this is meant to be markup. + // Not re-encoded — this is meant to be markup, and rows() no longer + // sanitizes it (see its docblock); the caller owns that trust boundary. return $value['html']; } @@ -234,6 +246,7 @@ public function props(): array 'emptyMessage' => $this->emptyMessage, 'createLabel' => $this->createLabel, 'createUrl' => $this->createUrl, + 'createMenuItems' => $this->createMenuItems, 'reorderUrl' => $this->reorderUrl, 'deleteUrl' => $this->deleteUrl, 'deleteConfirmMessage' => $this->deleteConfirmMessage, From 58b15317144a06ce90ca6284125ddef1c91c0b83 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 15 Sep 2026 18:04:40 +0100 Subject: [PATCH 04/19] php stuff for select colour component --- .../components/select-color/select-color.ts | 49 ++++++- .../js/modules/forms/ColorSelectControl.vue | 67 +++++++++ resources/js/modules/forms/register.ts | 2 + src/Cp/Components/SelectColor.php | 129 ++++++++++++++++++ src/Form/Controls/ColorSelect.php | 102 ++++++++++++++ src/Form/FormControlTypes.php | 2 + 6 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 resources/js/modules/forms/ColorSelectControl.vue create mode 100644 src/Cp/Components/SelectColor.php create mode 100644 src/Form/Controls/ColorSelect.php diff --git a/packages/craftcms-ui/src/components/select-color/select-color.ts b/packages/craftcms-ui/src/components/select-color/select-color.ts index 6f761915719..9d98bae3e8a 100644 --- a/packages/craftcms-ui/src/components/select-color/select-color.ts +++ b/packages/craftcms-ui/src/components/select-color/select-color.ts @@ -1,6 +1,7 @@ import {html, LitElement} from 'lit'; import {property} from 'lit/decorators.js'; -import {colors} from '@src/constants/colors'; +import type {Validator} from '@lion/ui/form-core.js'; +import {colors as paletteColors} from '@src/constants/colors'; import {t} from '@src/utilities/translate'; import styles from './select-color.styles.js'; import '../select-rich/select-rich.js'; @@ -15,7 +16,8 @@ function titleCase(value: string): string { /** * @summary A color picker built on top of the rich select. Renders one option - * per color from `constants/colors`, with an optional "transparent" option. + * per color from `constants/colors` (or {@link CraftSelectColor.colors}, when + * narrowed), with an optional "transparent" option. * * @since 1.0 */ @@ -41,11 +43,44 @@ export default class CraftSelectColor extends LitElement { modelValue: string | null = null; /** - * When enabled, a "Transparent" option is prepended to the list of colors. + * When enabled, a blank option (labelled {@link blankLabel}) is prepended + * to the list of colors. */ @property({type: Boolean, reflect: true, attribute: 'allow-transparent'}) allowTransparent = false; + /** + * Overrides the blank option's label (default "Transparent"). Use this + * when the value being picked isn't a background/opacity concept — e.g. + * "No color" for a category swatch. + */ + @property({attribute: 'blank-label'}) + blankLabel: string | null = null; + + /** + * The colors offered, in order. Defaults to every color in the shared + * palette ({@link paletteColors}) — pass a subset to restrict the choices + * to whatever set a particular caller's values are actually drawn from. + */ + @property({type: Array}) + colors: string[] = [...paletteColors]; + + /** Forwarded to the underlying rich select. */ + @property({type: Boolean, reflect: true}) + disabled = false; + + /** Forwarded to the underlying rich select. */ + @property({type: Boolean, reflect: true, attribute: 'readonly'}) + readOnly = false; + + /** Forwarded to the underlying rich select. */ + @property({type: Boolean, reflect: true}) + required = false; + + /** Forwarded to the underlying rich select. */ + @property({attribute: false}) + validators: Validator[] = []; + /** * Renders a color swatch for the given color value. The special `__blank__` * value (the "Transparent" option) reuses the checkerboard treatment so it @@ -137,12 +172,16 @@ export default class CraftSelectColor extends LitElement { label=${this.label} name=${this.name} .modelValue=${this.modelValue} + .disabled=${this.disabled} + .readOnly=${this.readOnly} + .required=${this.required} + .validators=${this.validators} @model-value-changed=${this._handleModelValueChanged} > ${this.allowTransparent - ? this._optionTemplate('__blank__', t('Transparent')) + ? this._optionTemplate('__blank__', this.blankLabel ?? t('Transparent')) : ''} - ${colors.map((color) => + ${this.colors.map((color) => this._optionTemplate(color, t(titleCase(color))) )} diff --git a/resources/js/modules/forms/ColorSelectControl.vue b/resources/js/modules/forms/ColorSelectControl.vue new file mode 100644 index 00000000000..ea96ba869c5 --- /dev/null +++ b/resources/js/modules/forms/ColorSelectControl.vue @@ -0,0 +1,67 @@ + + + diff --git a/resources/js/modules/forms/register.ts b/resources/js/modules/forms/register.ts index 665bc4fae98..df016d34965 100644 --- a/resources/js/modules/forms/register.ts +++ b/resources/js/modules/forms/register.ts @@ -7,6 +7,7 @@ import FieldNode from './FieldNode.vue'; import ChoiceControl from './ChoiceControl.vue'; import ConditionBuilderControl from './ConditionBuilderControl.vue'; import ColorControl from './ColorControl.vue'; +import ColorSelectControl from './ColorSelectControl.vue'; import ComboboxControl from './ComboboxControl.vue'; import FormRenderer from './FormRenderer.vue'; import GroupNode from './GroupNode.vue'; @@ -76,6 +77,7 @@ export function registerFormComponents( components.register('craft:date-time', DateTimeControl); components.register('craft:time', TextControl); components.register('craft:color', ColorControl); + components.register('craft:color-select', ColorSelectControl); components.register('craft:money', MoneyControl); components.register('craft:permission-tree', PermissionTreeControl); components.register('craft:markdown', MarkdownControl); diff --git a/src/Cp/Components/SelectColor.php b/src/Cp/Components/SelectColor.php new file mode 100644 index 00000000000..51d79b5013b --- /dev/null +++ b/src/Cp/Components/SelectColor.php @@ -0,0 +1,129 @@ +` web component. */ +class SelectColor extends ViewComponent +{ + use HasDisabled; + use HasId; + + protected ?string $name = null; + + protected ?string $value = null; + + protected ?string $label = null; + + protected bool $allowTransparent = false; + + protected ?string $blankLabel = null; + + /** @var list|null */ + protected ?array $colors = null; + + protected bool $required = false; + + protected bool $readOnly = false; + + protected ?string $describedBy = null; + + protected function tagName(): string + { + return 'craft-select-color'; + } + + public function name(?string $name): static + { + $this->name = $name; + + return $this; + } + + public function value(?string $value): static + { + $this->value = $value; + + return $this; + } + + public function label(?string $label): static + { + $this->label = $label; + + return $this; + } + + /** Prepends a blank option, labelled "Transparent" unless {@see blankLabel()} overrides it. */ + public function allowTransparent(bool $allowTransparent = true): static + { + $this->allowTransparent = $allowTransparent; + + return $this; + } + + /** Overrides the blank option's label (default "Transparent"). */ + public function blankLabel(?string $blankLabel): static + { + $this->blankLabel = $blankLabel; + + return $this; + } + + /** + * Restricts the offered colors. `null` (the default) offers every color + * in the shared palette. + * + * @param list|null $colors + */ + public function colors(?array $colors): static + { + $this->colors = $colors; + + return $this; + } + + public function required(bool $required = true): static + { + $this->required = $required; + + return $this; + } + + public function readOnly(bool $readOnly = true): static + { + $this->readOnly = $readOnly; + + return $this; + } + + public function describedBy(?string $describedBy): static + { + $this->describedBy = $describedBy; + + return $this; + } + + #[\Override] + protected function hostAttributes(): array + { + return [ + 'id' => $this->getId(), + 'name' => $this->name, + 'model-value' => $this->value, + 'label' => $this->label, + 'allow-transparent' => $this->allowTransparent, + 'blank-label' => $this->blankLabel, + 'colors' => $this->colors !== null ? Json::encode($this->colors) : null, + 'required' => $this->required, + 'readonly' => $this->readOnly, + 'disabled' => $this->isDisabled(), + 'aria' => ['describedby' => $this->describedBy], + ]; + } +} diff --git a/src/Form/Controls/ColorSelect.php b/src/Form/Controls/ColorSelect.php new file mode 100644 index 00000000000..0dc3cbf7f77 --- /dev/null +++ b/src/Form/Controls/ColorSelect.php @@ -0,0 +1,102 @@ +|null */ + private ?array $colors = null; + + public static function renderHtml(ControlPayload $control, mixed $value, array $attributes, FormHtmlRenderer $renderer): string + { + $allowTransparent = (bool) ($control->props['allowTransparent'] ?? false); + + // The underlying uses this sentinel internally for its blank + // option's choiceValue; our own canonical "no color" value stays null/empty, matching + // every other select-like control in this Form system, so it's translated here rather + // than leaking out to the server. + $componentValue = match (true) { + $value !== null && $value !== '' => (string) $value, + $allowTransparent => '__blank__', + default => null, + }; + + return SelectColorComponent::make() + ->id($attributes['id']) + ->name($attributes['name']) + ->value($componentValue) + ->allowTransparent($allowTransparent) + ->blankLabel($control->props['blankLabel'] ?? null) + ->colors($control->props['colors'] ?? null) + ->disabled($attributes['disabled']) + ->readOnly($attributes['readonly']) + ->required($attributes['required']) + ->describedBy($attributes['aria']['describedby'] ?? null) + ->toHtml(); + } + + public function component(): string + { + return 'craft:color-select'; + } + + /** Prepends a blank option, labelled "Transparent" unless {@see blankLabel()} overrides it. */ + public function allowTransparent(bool $allowTransparent = true): static + { + $this->allowTransparent = $allowTransparent; + + return $this; + } + + /** Overrides the blank option's label (default "Transparent"). */ + public function blankLabel(?string $blankLabel): static + { + $this->blankLabel = $blankLabel; + + return $this; + } + + /** + * Restricts the offered colors. Omit to offer every color in the shared + * palette. + * + * @param list|null $colors + */ + public function colors(?array $colors): static + { + $this->colors = $colors; + + return $this; + } + + #[\Override] + public function props(mixed $value = null): array + { + return Arr::whereNotNull([ + 'allowTransparent' => $this->allowTransparent ?: null, + 'blankLabel' => $this->blankLabel, + 'colors' => $this->colors, + ]); + } +} diff --git a/src/Form/FormControlTypes.php b/src/Form/FormControlTypes.php index 3da41ae0b2f..aa5013812d1 100644 --- a/src/Form/FormControlTypes.php +++ b/src/Form/FormControlTypes.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Form\Controls\Checkbox; use CraftCms\Cms\Form\Controls\Choice; use CraftCms\Cms\Form\Controls\Color; +use CraftCms\Cms\Form\Controls\ColorSelect; use CraftCms\Cms\Form\Controls\Combobox; use CraftCms\Cms\Form\Controls\ConditionBuilder; use CraftCms\Cms\Form\Controls\ContentBlock; @@ -56,6 +57,7 @@ class FormControlTypes extends TypeRegistry Choice::class, ConditionBuilder::class, Color::class, + ColorSelect::class, Combobox::class, ContentBlock::class, Date::class, From bd88505a4062c27a919f2945c6744164685407ff Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 16 Sep 2026 08:35:39 +0100 Subject: [PATCH 05/19] Missing suffix for text control --- resources/js/modules/forms/TextControl.vue | 4 ++++ src/Form/Controls/Text.php | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/resources/js/modules/forms/TextControl.vue b/resources/js/modules/forms/TextControl.vue index a380b3d7760..256fab2b6b6 100644 --- a/resources/js/modules/forms/TextControl.vue +++ b/resources/js/modules/forms/TextControl.vue @@ -25,6 +25,7 @@ size?: number; dir?: string; monospace?: boolean; + suffix?: string; textExpanderTriggers?: TextExpanderTriggers; }; @@ -95,6 +96,9 @@ @model-value-changed="onModelValueChanged" > + {{ + control.props.suffix + }} inputSize($control->props['size'] ?? null) ->orientation($control->props['dir'] ?? null) ->monospace((bool) ($control->props['monospace'] ?? false)) + ->suffix($control->props['suffix'] ?? null) ->disabled($attributes['disabled'] || ($attributes['readonly'] && ($control->props['inputType'] ?? 'text') === 'range')) ->readOnly($attributes['readonly']) ->describedBy($attributes['aria']['describedby'] ?? null) @@ -178,6 +181,14 @@ public function monospace(bool $monospace = true): static return $this; } + /** A display-only unit label rendered after the input (e.g. a percent or currency symbol). */ + public function suffix(?string $suffix): static + { + $this->suffix = $suffix; + + return $this; + } + #[\Override] public function props(mixed $value = null): array { @@ -196,6 +207,7 @@ public function props(mixed $value = null): array 'size' => $this->size, 'dir' => $this->dir, 'monospace' => $this->monospace ?: null, + 'suffix' => $this->suffix, ...$this->textExpanderProps(), ]); } From 96b63f3d535ac4b97704549abe8f18b531cf9c2f Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 16 Sep 2026 09:50:59 +0100 Subject: [PATCH 06/19] added new combocreate control --- .../modules/forms/ComboboxCreateControl.vue | 72 +++++++++ resources/js/modules/forms/combobox-create.ts | 148 ++++++++++++++++++ resources/js/modules/forms/register.ts | 2 + src/Form/Controls/ComboboxCreate.php | 101 ++++++++++++ src/Form/FormControlTypes.php | 2 + 5 files changed, 325 insertions(+) create mode 100644 resources/js/modules/forms/ComboboxCreateControl.vue create mode 100644 resources/js/modules/forms/combobox-create.ts create mode 100644 src/Form/Controls/ComboboxCreate.php diff --git a/resources/js/modules/forms/ComboboxCreateControl.vue b/resources/js/modules/forms/ComboboxCreateControl.vue new file mode 100644 index 00000000000..624e805f369 --- /dev/null +++ b/resources/js/modules/forms/ComboboxCreateControl.vue @@ -0,0 +1,72 @@ + + + diff --git a/resources/js/modules/forms/combobox-create.ts b/resources/js/modules/forms/combobox-create.ts new file mode 100644 index 00000000000..a734db9833d --- /dev/null +++ b/resources/js/modules/forms/combobox-create.ts @@ -0,0 +1,148 @@ +import type { + ComboboxItem, + ComboboxOption, +} from '@craftcms/ui/components/combobox/combobox'; +import CraftCombobox from '@craftcms/ui/components/combobox/combobox'; +import {property} from 'lit/decorators.js'; +import {openSlideout} from '@/common/slideouts'; + +/** + * Inserts `option` directly before the trigger option (`value === createValue`) in a flat + * option list. Optgroups aren't supported — every consumer of this control so far (Tax Zone, + * Tax Category, …) offers a flat list, so grouping isn't worth the added complexity here; add + * it if a future caller actually needs it. + */ +function insertBeforeCreateOption( + options: ComboboxItem[], + createValue: string, + option: ComboboxOption +): ComboboxItem[] { + const index = options.findIndex( + (item) => item.type !== 'optgroup' && item.value === createValue + ); + + return index === -1 + ? [...options, option] + : [...options.slice(0, index), option, ...options.slice(index)]; +} + +/** + * @summary A `craft-combobox` whose "create a new one" option opens the target resource's own + * create screen in a slideout instead of selecting a value directly, then appends the result as + * the new selection once saved. See {@link CraftCms\Cms\Form\Controls\ComboboxCreate} (PHP) for + * the full contract. + * + * @since 1.0 + */ +export default class CraftComboboxCreate extends CraftCombobox { + @property({attribute: 'create-url'}) createUrl = ''; + + /** The option value that opens the create slideout instead of being selected directly. */ + @property({attribute: 'create-value'}) createValue = '__add__'; + + /** The key the created record is nested under in the save response. */ + @property({attribute: 'result-key'}) resultKey = ''; + + /** The created record's field used as the new option's label. */ + @property({attribute: 'label-field'}) labelField = 'name'; + + /** The created record's field used as the new option's value. */ + @property({attribute: 'value-field'}) valueField = 'id'; + + private creating = false; + private listener?: AbortController; + + override connectedCallback(): void { + super.connectedCallback(); + this.listener?.abort(); + this.listener = new AbortController(); + this.addEventListener('model-value-changed', this.onModelValueChanged, { + signal: this.listener.signal, + }); + } + + override disconnectedCallback(): void { + this.listener?.abort(); + super.disconnectedCallback(); + } + + private onModelValueChanged = (event: Event): void => { + if ( + (event as CustomEvent).detail?.initialize || + this.modelValue !== this.createValue || + this.creating + ) { + return; + } + + event.stopImmediatePropagation(); + + if (!this.createUrl) { + throw new Error('Combobox create URL is required.'); + } + + this.creating = true; + + // Reset back to nothing selected while the slideout is open, rather than leaving the + // trigger option itself "selected". + queueMicrotask(() => { + this.modelValue = ''; + this._inputNode.value = ''; + this._notifyModelValueChanged(); + }); + + void openSlideout(this.createUrl, { + opener: this, + onSaved: ({data}) => { + const record = (data as Record | undefined)?.[ + this.resultKey + ] as Record | undefined; + + if (!record) { + throw new Error( + `Combobox create response is missing "${this.resultKey}".` + ); + } + + const option = { + label: String(record[this.labelField] ?? ''), + value: String(record[this.valueField] ?? ''), + } satisfies ComboboxOption; + + this.options = insertBeforeCreateOption( + this.options, + this.createValue, + option + ); + + void this.updateComplete.then(() => { + const selectedOption = Array.from( + this._listboxNode.querySelectorAll('craft-option') + ).find((item) => String(item.choiceValue) === String(option.value)); + + if (!selectedOption) { + throw new Error('Created option was not rendered.'); + } + + this.modelValue = option.value; + this._setTextboxValue( + this._getTextboxValueFromOption(selectedOption) + ); + this._notifyModelValueChanged(); + }); + }, + }).finally(() => { + this.creating = false; + }); + }; +} + +if (!customElements.get('craft-combobox-create')) { + customElements.define('craft-combobox-create', CraftComboboxCreate); +} + +declare global { + interface HTMLElementTagNameMap { + 'craft-combobox-create': CraftComboboxCreate; + } +} diff --git a/resources/js/modules/forms/register.ts b/resources/js/modules/forms/register.ts index df016d34965..02fb99c83c2 100644 --- a/resources/js/modules/forms/register.ts +++ b/resources/js/modules/forms/register.ts @@ -9,6 +9,7 @@ import ConditionBuilderControl from './ConditionBuilderControl.vue'; import ColorControl from './ColorControl.vue'; import ColorSelectControl from './ColorSelectControl.vue'; import ComboboxControl from './ComboboxControl.vue'; +import ComboboxCreateControl from './ComboboxCreateControl.vue'; import FormRenderer from './FormRenderer.vue'; import GroupNode from './GroupNode.vue'; import LightswitchControl from './LightswitchControl.vue'; @@ -66,6 +67,7 @@ export function registerFormComponents( components.register('craft:hidden', HiddenControl); components.register('craft:text', TextControl); components.register('craft:combobox', ComboboxControl); + components.register('craft:combobox-create', ComboboxCreateControl); components.register('craft:textarea', TextareaControl); components.register('craft:lightswitch', LightswitchControl); components.register('craft:checkbox', CheckboxControl); diff --git a/src/Form/Controls/ComboboxCreate.php b/src/Form/Controls/ComboboxCreate.php new file mode 100644 index 00000000000..c60adb6978f --- /dev/null +++ b/src/Form/Controls/ComboboxCreate.php @@ -0,0 +1,101 @@ +createUrl = $createUrl; + + return $this; + } + + /** The option value that opens the create slideout instead of being selected directly. */ + public function createValue(string $createValue): static + { + $this->createValue = $createValue; + + return $this; + } + + /** + * The key the created record is nested under in the save response — the same `$modelName` + * the create screen's save action passes to `RespondsWithFlash::asModelSuccess()`. + */ + public function resultKey(string $resultKey): static + { + $this->resultKey = $resultKey; + + return $this; + } + + /** The created record's field to use as the new option's label. Defaults to `name`. */ + public function labelField(string $labelField): static + { + $this->labelField = $labelField; + + return $this; + } + + /** The created record's field to use as the new option's value. Defaults to `id`. */ + public function valueField(string $valueField): static + { + $this->valueField = $valueField; + + return $this; + } + + #[\Override] + public function props(mixed $value = null): array + { + return [ + ...parent::props($value), + ...Arr::whereNotNull([ + 'createUrl' => $this->createUrl, + 'createValue' => $this->createValue, + 'resultKey' => $this->resultKey ?: null, + 'labelField' => $this->labelField, + 'valueField' => $this->valueField, + ]), + ]; + } +} diff --git a/src/Form/FormControlTypes.php b/src/Form/FormControlTypes.php index aa5013812d1..01e6480956d 100644 --- a/src/Form/FormControlTypes.php +++ b/src/Form/FormControlTypes.php @@ -13,6 +13,7 @@ use CraftCms\Cms\Form\Controls\Color; use CraftCms\Cms\Form\Controls\ColorSelect; use CraftCms\Cms\Form\Controls\Combobox; +use CraftCms\Cms\Form\Controls\ComboboxCreate; use CraftCms\Cms\Form\Controls\ConditionBuilder; use CraftCms\Cms\Form\Controls\ContentBlock; use CraftCms\Cms\Form\Controls\Date; @@ -59,6 +60,7 @@ class FormControlTypes extends TypeRegistry Color::class, ColorSelect::class, Combobox::class, + ComboboxCreate::class, ContentBlock::class, Date::class, DateTime::class, From f204747333873cc77c2a90a1aa1b92227ff4e3f0 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 16 Sep 2026 13:20:02 +0100 Subject: [PATCH 07/19] fix change event bug --- resources/js/modules/forms/ConditionBuilderControl.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/resources/js/modules/forms/ConditionBuilderControl.vue b/resources/js/modules/forms/ConditionBuilderControl.vue index dec2592b67d..86fbcdd45d9 100644 --- a/resources/js/modules/forms/ConditionBuilderControl.vue +++ b/resources/js/modules/forms/ConditionBuilderControl.vue @@ -17,6 +17,14 @@ const emit = defineEmits<{ (event: 'update:value', value: ConditionConfig, kind: 'discrete'): void; + /** + * Never emitted — declared only to stop FieldNode's blanket `@change` + * listener from falling through onto `ConditionBuilder`'s own `change` + * (its current value, already handled below via `update:value`), which + * would otherwise hand `recordChange()` a `ConditionConfig` in place of a + * `FormChange` and crash on its missing `path`. + */ + (event: 'change'): void; }>(); const errors = computed(() => From be18ea97e305ed60e1ca705ab731dd411aba7c74 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 17 Sep 2026 11:27:13 +0100 Subject: [PATCH 08/19] Add bulk actions to table, allow money field in table and allow group component to start expanded --- resources/js/common/types/index.ts | 1 + .../modules/editable-table/editable-table.ts | 49 ++++ resources/js/modules/editable-table/types.ts | 14 +- resources/js/modules/forms/AdminTableNode.vue | 230 +++++++++++++++++- resources/js/modules/forms/GroupNode.vue | 3 + .../templates/_includes/forms/money.twig | 22 +- src/Form/Nodes/Group.php | 15 ++ src/Form/Nodes/Table.php | 62 ++++- 8 files changed, 386 insertions(+), 10 deletions(-) diff --git a/resources/js/common/types/index.ts b/resources/js/common/types/index.ts index bd1d1621084..68279f0de21 100644 --- a/resources/js/common/types/index.ts +++ b/resources/js/common/types/index.ts @@ -191,6 +191,7 @@ export type EditableTableCellType = | 'autosuggest' | 'template' | 'number' + | 'money' | 'singleline' | 'multiline' | 'heading' diff --git a/resources/js/modules/editable-table/editable-table.ts b/resources/js/modules/editable-table/editable-table.ts index f5790ef31c0..e6e3ed12578 100644 --- a/resources/js/modules/editable-table/editable-table.ts +++ b/resources/js/modules/editable-table/editable-table.ts @@ -3,6 +3,7 @@ import type CraftCombobox from '@craftcms/ui/components/combobox/combobox'; import type {ComboboxItem} from '@craftcms/ui/components/combobox/combobox'; import type CraftTextExpander from '@craftcms/ui/components/text-expander/text-expander'; import '@craftcms/ui/components/text-expander/text-expander'; +import CraftInputMoney from '@craftcms/ui/components/input-money/input-money'; import {editableTableData, editableTableRowData} from './support'; import type { EditableTableColumn, @@ -689,6 +690,54 @@ export class EditableTable extends Base { .appendTo($cell); break; + case 'money': { + // A stored value is `{value, locale}` — the same shape the + // standalone `craft:money` Form Control posts and reads (see + // `MoneyControl.vue`) — but a brand-new row's `value` is often + // just `''` (from `defaultValues`), so only unwrap the shape when + // it's actually there. + const moneyValue = + value instanceof Object && !Array.isArray(value) + ? ((value as Record).value ?? null) + : (value ?? null); + const moneyLocale = + (value instanceof Object && !Array.isArray(value) + ? (value as Record).locale + : undefined) ?? + col.locale ?? + 'en-US'; + const money = document.createElement( + 'craft-input-money' + ) as CraftInputMoney; + money.name = `${name}[value]`; + money.modelValue = + moneyValue === null ? '' : String(moneyValue); + money.currency = col.currency ?? 'USD'; + money.locale = String(moneyLocale); + if (col.decimals !== undefined) money.decimals = col.decimals; + if (col.decimalSeparator !== undefined) { + money.decimalSeparator = col.decimalSeparator; + } + if (col.groupSeparator !== undefined) { + money.groupSeparator = col.groupSeparator; + } + if (col.showCurrency !== undefined) { + money.showCurrency = col.showCurrency; + } + if (col.clearable !== undefined) money.clearable = col.clearable; + $cell.append(money); + // Posted alongside `${name}[value]`, exactly as `InputMoney::renderSlots()` + // does for the standalone control — `cellValue()`'s generic + // multi-entry fallback reassembles the two back into one + // `{value, locale}` cell value, no special-casing needed there. + $('', { + type: 'hidden', + name: `${name}[locale]`, + value: String(moneyLocale), + }).appendTo($cell); + break; + } + case 'time': Craft.ui .createTimeInput({ diff --git a/resources/js/modules/editable-table/types.ts b/resources/js/modules/editable-table/types.ts index 709e5dcf691..b6a600e3465 100644 --- a/resources/js/modules/editable-table/types.ts +++ b/resources/js/modules/editable-table/types.ts @@ -27,8 +27,20 @@ export interface EditableTableColumn { toggle?: string[]; /** Auto-populate this column's value (a handle) from another column. */ autopopulate?: string; - /** Number column: locale used for formatting/parsing. */ + /** Number/money column: locale used for formatting/parsing. */ locale?: string; + /** Money column: ISO currency code (e.g. `USD`). Defaults to `USD`. */ + currency?: string; + /** Money column: fraction digits to allow. Defaults to the currency's own. */ + decimals?: number; + /** Money column: overrides the locale's own decimal separator. */ + decimalSeparator?: string; + /** Money column: overrides the locale's own thousands separator. */ + groupSeparator?: string; + /** Money column: shows the currency code/symbol prefix. Defaults to `true`. */ + showCurrency?: boolean; + /** Money column: shows a clear button once there's a value. Defaults to `true`. */ + clearable?: boolean; [key: string]: EditableTableColumnValue; } diff --git a/resources/js/modules/forms/AdminTableNode.vue b/resources/js/modules/forms/AdminTableNode.vue index 4c24ce3df3c..269c42f65fb 100644 --- a/resources/js/modules/forms/AdminTableNode.vue +++ b/resources/js/modules/forms/AdminTableNode.vue @@ -4,12 +4,14 @@ import { type ColumnDef, getCoreRowModel, + type RowSelectionState, useVueTable, } from '@tanstack/vue-table'; import {computed, h, ref, watch} from 'vue'; import ActionMenu from '@/common/components/ActionMenu.vue'; import CpLink from '@/common/components/CpLink.vue'; - import type {ActionItemLink} from '@/common/types'; + import Text from '@/common/components/Text.vue'; + import type {ActionItemButton, ActionItemLink} from '@/common/types'; import Empty from '@/common/components/Empty.vue'; import LayoutSlot from '@/common/components/LayoutSlot.vue'; import AdminTable from '@/modules/admin-table/components/AdminTable.vue'; @@ -41,6 +43,34 @@ html: string; } + /** + * A single bulk-footer action — posts `{ids: , ...params}` to `url`. + * `allowMultiple: false` (default `true`) disables it — as a standalone button, or as a + * menu item — whenever more than one row is selected, for an action that only makes sense + * against one row at a time (its endpoint has no obligation to reflect that itself; nothing + * stops a request with several ids reaching it some other way, so this is a UI nicety, not + * a substitute for that endpoint enforcing the same rule server-side). + */ + interface BulkActionSingle { + label: string; + url: string; + params?: Record; + allowMultiple?: boolean; + } + + /** + * A dropdown of {@link BulkActionSingle}s, shown as one button in the bulk footer. `label` + * is optional — omit it (alongside an `icon`) for an icon-only invoker, matching legacy's + * own unlabeled gear-icon settings menu for a single, infrequently-needed item. + */ + interface BulkActionMenu { + label?: string; + icon?: string; + items: BulkActionSingle[]; + } + + type BulkAction = BulkActionSingle | BulkActionMenu; + /** * A scalar renders as plain text; `{label, url}` renders as a link (or plain * text when `url` is null); a list of those renders several links in one @@ -95,6 +125,8 @@ reorderUrl: string | null; deleteUrl: string | null; deleteConfirmMessage: string | null; + bulkDeletable: boolean; + bulkActions: BulkAction[]; }>; }>(); @@ -138,6 +170,10 @@ return !Array.isArray(value) && 'html' in value; } + function isBulkActionMenu(action: BulkAction): action is BulkActionMenu { + return 'items' in action; + } + function renderLink(link: TableLink) { return link.url ? h(CpLink, {href: link.url, inertia: false}, () => link.label) @@ -216,6 +252,19 @@ return cols; }); + // Keyed by row id (see `getRowId`) rather than row index, so a selection + // survives the optimistic row-removal a delete does. Gated on `bulkDeletable` + // (see `Table::deletable()`) or a non-empty `bulkActions` — either turns + // selection on; a plain `deleteUrl` alone (no `bulk: true`) wouldn't know + // what to do with the `ids` array a bulk request posts. + const rowSelection = ref({}); + + const hasBulkFooter = computed( + () => + (!!props.node.props.deleteUrl && props.node.props.bulkDeletable) || + props.node.props.bulkActions.length > 0 + ); + const table = useVueTable({ get data() { return rows.value; @@ -223,10 +272,29 @@ get columns() { return columns.value; }, + state: { + get rowSelection() { + return rowSelection.value; + }, + }, + getRowId: (row) => String(row.id), + // A row opted out of the single-row delete action (`_deletable: false`) is + // just as ineligible for any bulk one — there's no separate "excluded from + // bulk actions but not delete" flag, so this gate serves both. + enableRowSelection: (row) => + hasBulkFooter.value && row.original._deletable !== false, + onRowSelectionChange: (updater) => { + rowSelection.value = + updater instanceof Function ? updater(rowSelection.value) : updater; + }, enableSorting: false, getCoreRowModel: getCoreRowModel(), }); + const selectedIds = computed(() => + table.getSelectedRowModel().rows.map((row) => row.original.id!) + ); + function onReorder(startIndex: number, finishIndex: number): void { const reordered = [...rows.value]; const [moved] = reordered.splice(startIndex, 1); @@ -259,6 +327,65 @@ refreshForm(); } + /** + * Deletes every currently-selected row in one request. Posts a real `ids` + * array (not the JSON-encoded string `onReorder` posts) — this shares the + * same backend action as a single-row {@link deleteRow}, and the legacy + * `Craft.VueAdminTable` widget's own bulk delete posted `ids` as a plain + * array the same way. + */ + async function deleteSelected(): Promise { + const ids = selectedIds.value; + + if (!ids.length) return; + + const message = props.node.props.deleteConfirmMessage ?? t('Are you sure?'); + + if (!confirm(message)) { + return; + } + + await actionClient.post(props.node.props.deleteUrl!, {ids}); + rows.value = rows.value.filter((r) => !ids.includes(r.id!)); + table.resetRowSelection(); + refreshForm(); + } + + /** + * Runs one bulk action (a {@link BulkActionSingle}, whether it stands alone + * or was picked from a {@link BulkActionMenu}) against every selected row. + * Unlike delete, there's no confirmation step here — none of these actions + * are inherently destructive the way delete is, so `Table::bulkActions()` + * doesn't carry a per-action confirm message. + */ + async function performBulkAction(action: BulkActionSingle): Promise { + const ids = selectedIds.value; + + if (!ids.length) return; + // Belt-and-braces alongside the disabled button/menu item below — the + // control shouldn't be reachable in this state, but this is what actually + // stops the request if it somehow is. + if (action.allowMultiple === false && ids.length > 1) return; + + await actionClient.post(action.url, {ids, ...action.params}); + table.resetRowSelection(); + refreshForm(); + } + + function bulkActionDisabled(action: BulkActionSingle): boolean { + return action.allowMultiple === false && selectedIds.value.length > 1; + } + + /** Adapts a menu action's items to `ActionMenu`'s item shape. */ + function bulkActionMenuItems(menu: BulkActionMenu): ActionItemButton[] { + return menu.items.map((item) => ({ + type: 'button', + label: item.label, + disabled: bulkActionDisabled(item), + onClick: () => performBulkAction(item), + })); + } + // This Node's data is set once, from the page's own initial render — not read from a // live-fetching endpoint the way an element index table is — so a mutation only updates // this component's own local `rows`. Other props derived from the same server-side state @@ -302,12 +429,113 @@ + + + + + diff --git a/resources/js/modules/forms/GroupNode.vue b/resources/js/modules/forms/GroupNode.vue index daa65751e61..cd08f889ad3 100644 --- a/resources/js/modules/forms/GroupNode.vue +++ b/resources/js/modules/forms/GroupNode.vue @@ -13,6 +13,8 @@ type GroupNodeProps = { label?: string | null; collapsible?: boolean; + /** Starts a collapsible group open. Meaningless (and never sent) otherwise. */ + expanded?: boolean; /** Renders the group as one field rather than a section — see `Nodes\Group`. */ asField?: boolean; instructions?: string | null; @@ -99,6 +101,7 @@ v-else :is="node.props.collapsible ? 'craft-disclosure' : 'fieldset'" :label="node.props.collapsible ? node.props.label : undefined" + :opened="node.props.collapsible ? node.props.expanded || undefined : undefined" :class="{ [`width-${node.props.width}`]: Boolean(node.props.width), hidden: Boolean(node.props.hidden), diff --git a/resources/templates/_includes/forms/money.twig b/resources/templates/_includes/forms/money.twig index d450ddef06d..207f592c2ee 100644 --- a/resources/templates/_includes/forms/money.twig +++ b/resources/templates/_includes/forms/money.twig @@ -24,15 +24,33 @@ decimals: decimals, }|merge(jsSettings ?? {}) %} -{{ hiddenInput("#{name}[locale]", formattingLocale) }} - +{# + Everything renders inside one root element on purpose: the surrounding `craft-field` + assigns its `input` slot by setting the `slot` attribute directly on this template's first + (and, for a correct render, only) top-level tag (see `ViewComponent::slotted()`) rather than + wrapping it — a wrapper would break `craft-input-money`'s own styling assumptions elsewhere. + The hidden locale input used to be its own sibling ahead of this div; with two top-level + elements, the slot attribute landed on the hidden input instead of the visible one, leaving + the actual money input (and everything else in here) unslotted and invisible. +#}
+ {{ hiddenInput("#{name}[locale]", formattingLocale) }} {% if currencyLabel and showCurrency ?? true %}
{{ currencyLabel }}
{% endif %} + {# + `name` must be passed explicitly here, not just folded into `inputAttributes` above: + `text.twig`'s underlying `FormFields::textFromConfig()` sets the actual rendered + `` name from the config's own top-level `name` key via a dedicated `->name()` + call, separate from (and taking priority over) whatever `inputAttributes['name']` + holds. Without this, `{% include %}`'s implicit scope inheritance (no `only`) leaks + this template's own ambient `name` (e.g. `baseRate`) through untouched, so the + rendered input posts as a plain scalar instead of `{name}[value]`. + #} {% include '_includes/forms/text' with { + name: inputAttributes.name, inputAttributes: inputAttributes, } %} {% if showClear %} diff --git a/src/Form/Nodes/Group.php b/src/Form/Nodes/Group.php index 0c6d9db55a2..847fb5df461 100644 --- a/src/Form/Nodes/Group.php +++ b/src/Form/Nodes/Group.php @@ -49,6 +49,8 @@ class Group extends Container private bool $collapsible = false; + private bool $expanded = false; + private bool $asField = false; private ?string $instructions = null; @@ -113,6 +115,18 @@ public function collapsible(bool $collapsible = true): static return $this; } + /** + * Starts a {@see self::collapsible()} group open rather than collapsed — e.g. an + * "Advanced" section whose fields already hold a value worth surfacing right away. + * Ignored (and never sent to the client) when the group isn't collapsible at all. + */ + public function expanded(bool $expanded = true): static + { + $this->expanded = $expanded; + + return $this; + } + /** * Renders the group as one field rather than a section — see the class * docblock. Takes precedence over {@see self::collapsible()}. @@ -184,6 +198,7 @@ public function props(): array return [ 'label' => $this->label, ...($this->collapsible && ! $this->asField ? ['collapsible' => true] : []), + ...($this->collapsible && ! $this->asField && $this->expanded ? ['expanded' => true] : []), ...($this->asField ? ['asField' => true] : []), ...Arr::whereNotNull([ 'instructions' => $this->instructions, diff --git a/src/Form/Nodes/Table.php b/src/Form/Nodes/Table.php index 417de25b642..059fb71908b 100644 --- a/src/Form/Nodes/Table.php +++ b/src/Form/Nodes/Table.php @@ -46,6 +46,11 @@ class Table implements Node private ?string $deleteConfirmMessage = null; + private bool $bulkDeletable = false; + + /** @var list> */ + private array $bulkActions = []; + public function __construct(private readonly string $uid) {} public static function make(string $uid): self @@ -132,11 +137,54 @@ public function reorderable(string $url): static /** * Adds a per-row delete action, posting `{id: }` to `$url`. Individual rows can * opt out via `_deletable => false` in {@see rows()}. + * + * `$bulk` additionally renders a row-selection checkbox column and a "N selected" bar with + * its own bulk delete button, posting `{ids: }` to the same `$url` — set it only + * when that action genuinely handles an `ids` array alongside a single `id` (mirroring the + * legacy dual `id`/`ids` contract some of these actions still carry). Leave it `false` + * (the default) for an action that only understands `id`; turning bulk selection on for + * one of those doesn't add a client-side capability so much as start sending it requests + * it will reject. */ - public function deletable(string $url, ?string $confirmMessage = null): static + public function deletable(string $url, ?string $confirmMessage = null, bool $bulk = false): static { $this->deleteUrl = $url; $this->deleteConfirmMessage = $confirmMessage; + $this->bulkDeletable = $bulk; + + return $this; + } + + /** + * Adds bulk action buttons to the selection footer (shown once at least one row is + * selected, alongside "Clear selection" and — if the table is {@see deletable()} with + * `bulk: true` — a trailing "Delete" button). Every action posts `{ids: , ...params}` to its own `url`; there's no client-side notion of what the action + * does beyond that; the endpoint owns applying it and returning a normal flash response. + * + * Each entry in `$actions` is either: + * - a single action: `['label' => string, 'url' => string, 'params'? => array, 'allowMultiple'? => bool]` — `params` is merged into the posted body + * alongside `ids`; `allowMultiple` (default `true`) disables the button whenever more + * than one row is selected, for an action that only makes sense against one row at a + * time (a UI nicety only — the endpoint still gets whatever `ids` a request carries, + * and is responsible for enforcing that itself if it matters). + * - a dropdown menu of single actions in the same shape: `['label'? => string, 'icon'? => + * string, 'items' => list, allowMultiple?: bool}>]`. Omit `label` (pairing it with an `icon`) for an + * icon-only invoker — the button shows just that icon, no visible text — matching + * legacy's own unlabeled gear-icon menu for a single, infrequently-needed item (e.g. + * shipping categories' "Set Default Category"). + * + * Reaches for a row-selection checkbox column the same way `deletable(..., bulk: true)` + * does — either one turns selection on; a table with both just contributes its own + * button(s) to the same footer. + * + * @param list> $actions + */ + public function bulkActions(array $actions): static + { + $this->bulkActions = $actions; return $this; } @@ -171,11 +219,11 @@ public static function renderHtml(NodePayload $node, FormPayload $payload, FormH ? Html::a(Html::encode($link['label']), $link['url']) : Html::encode($link['label']); - // Reordering and deleting are inherently interactive (drag handles, confirmation - // dialogs, CSRF-protected requests) with no sensible plain-HTML equivalent, so this - // fallback renders a menu's links inline but otherwise omits those two affordances — - // consistent with the rest of the CP treating this renderer as JS-less read access, - // not a full replacement for the Vue control. + // Reordering, deleting, and bulk actions are inherently interactive (drag handles, + // confirmation dialogs, row selection, CSRF-protected requests) with no sensible + // plain-HTML equivalent, so this fallback renders a menu's links inline but + // otherwise omits those affordances — consistent with the rest of the CP treating + // this renderer as JS-less read access, not a full replacement for the Vue control. $renderCell = function(array $column, array $row) use ($renderLink): string { $value = $row[$column['key']] ?? ''; @@ -250,6 +298,8 @@ public function props(): array 'reorderUrl' => $this->reorderUrl, 'deleteUrl' => $this->deleteUrl, 'deleteConfirmMessage' => $this->deleteConfirmMessage, + 'bulkDeletable' => $this->bulkDeletable, + 'bulkActions' => $this->bulkActions, ]; } From 4d48c6984f8c5400b561c9a159cc8cd8357a3ecb Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 17 Sep 2026 14:01:56 +0100 Subject: [PATCH 09/19] Allow hidden tables rows and reactivity --- .../src/components/callout/callout.styles.ts | 13 +++++++ .../src/components/callout/callout.ts | 7 ++++ resources/js/common/types/index.ts | 29 +++++++++++++++ .../modules/editable-table/editable-table.ts | 7 ++++ resources/js/modules/editable-table/types.ts | 6 ++++ resources/js/modules/forms/CalloutNode.vue | 6 +++- resources/js/modules/forms/FieldNode.vue | 10 +++++- resources/js/modules/forms/TableControl.vue | 9 ++++- .../settings/composables/useSettingsSave.ts | 8 +++-- resources/js/pages/Form.vue | 36 ++++++++++++++++++- .../_includes/forms/editableTable.twig | 2 +- src/Form/Controls/Table.php | 26 ++++++++++++++ src/Form/Nodes/Callout.php | 15 ++++++++ src/Form/Nodes/Field.php | 7 ++++ 14 files changed, 174 insertions(+), 7 deletions(-) diff --git a/packages/craftcms-ui/src/components/callout/callout.styles.ts b/packages/craftcms-ui/src/components/callout/callout.styles.ts index 6789b5961e7..717bb6005ab 100644 --- a/packages/craftcms-ui/src/components/callout/callout.styles.ts +++ b/packages/craftcms-ui/src/components/callout/callout.styles.ts @@ -28,6 +28,19 @@ export default css` align-items: start; padding: var(--_callout-padding-block) var(--_callout-padding-inline); border: 1px solid transparent; + /* + Spans the full width of a surrounding grid. A + block-level Form Node can rely on that grid's own \`craft-field-group > + *\` default (or a \`width-*\` class) for this, but \`:host\` here is + \`display: contents\` — grid placement given to the *host* has no box to + apply to, so the grid falls back to auto-placing this shadow-rendered + box on its own, one column wide, unless it claims its own span here. + Every current Callout usage is full-width (no narrower \`width-*\` + variant exists yet), so this is unconditional rather than keyed off a + host class. Harmless outside a grid parent — \`grid-column\` is simply + inert there. + */ + grid-column: 1 / -1; } .callout--hide-icon { diff --git a/packages/craftcms-ui/src/components/callout/callout.ts b/packages/craftcms-ui/src/components/callout/callout.ts index 3312734e943..6d72467a676 100644 --- a/packages/craftcms-ui/src/components/callout/callout.ts +++ b/packages/craftcms-ui/src/components/callout/callout.ts @@ -13,6 +13,13 @@ import {styleMap} from 'lit/directives/style-map.js'; * @summary A boxed message: an optional icon, an optional title, body content, * and an optional trailing action. * + * The host is `display: contents` (see `callout.styles`) so it never adds an + * unstyled wrapper box of its own — but that means a surrounding CSS Grid + * (e.g. ``'s `width-*` classes) can't size the *host*: with + * no box of its own, the host's own grid placement is inert, and the grid + * instead auto-places whatever the shadow root renders one column at a time. + * `.callout` spans the full grid itself to compensate — see its own rule. + * * @attr size - `small` steps the box down to `--c-text-sm` and tightens the gap * between the icon and the text. Defaults to `auto`, which leaves the callout * at the surrounding text size. Note this is type only — the padding below is diff --git a/resources/js/common/types/index.ts b/resources/js/common/types/index.ts index 68279f0de21..a39ee40816e 100644 --- a/resources/js/common/types/index.ts +++ b/resources/js/common/types/index.ts @@ -1,5 +1,6 @@ import type {ActionFeedback, BaseAction, VariantKey} from '@craftcms/ui'; import type {ComboboxOptionData} from '@craftcms/ui/components/combobox/combobox'; +import type {UrlMethodPair} from '@inertiajs/core'; import type {Component} from 'vue'; import type {FormValues} from '@/modules/forms/types'; @@ -123,10 +124,38 @@ export type ActionItem = export type ActionItems = Array; +/** + * A server-described alternate form action — the `Form` page's counterpart to + * `\CraftCms\Cms\Http\Responses\CpScreenResponse::addAltAction()` ("Save as a + * new X", "Delete", …). Unlike a plain {@link ActionItemButton}, this carries + * no client-side `onClick` (it's plain JSON from the server) — `Form.vue` + * translates each one into a real action that resubmits the form's *current* + * in-progress values to `action` (default POST to the screen's own submit + * method) instead of firing an isolated request. + */ +export interface FormAltAction { + label: string; + /** Renders the action as destructive (e.g. a "Delete"). */ + destructive?: boolean; + /** Defaults to the screen's own submit action when omitted. */ + action?: string; + /** Extra values merged into the posted payload alongside the form's own. */ + params?: FormValues; + /** A confirmation message shown (native `confirm()`) before submitting. */ + confirm?: string; +} + export interface FormSaveOptions { redirect?: boolean; data?: FormValues; preserveState?: boolean; + /** + * Submits to a different destination than the screen's own default + * `submit` target — e.g. an alternate form action posting the same + * in-progress values elsewhere ("Save as a new X", "Delete"). Omit to use + * the screen's own action. + */ + action?: UrlMethodPair; } export interface EntryType { diff --git a/resources/js/modules/editable-table/editable-table.ts b/resources/js/modules/editable-table/editable-table.ts index e6e3ed12578..5058f19aa50 100644 --- a/resources/js/modules/editable-table/editable-table.ts +++ b/resources/js/modules/editable-table/editable-table.ts @@ -588,8 +588,15 @@ export class EditableTable extends Base { ): any { void staticRows; + // `_hidden` is a reserved row key, not a declared column — same principle + // `HasVisibility` documents for whole Field/Group nodes: the row's own cells stay + // real inputs (still posting, still holding whatever was typed) rather than being + // omitted, so the caller can toggle it back without losing anything. Both the + // `hidden` attribute and the class are set, matching that same convention, since + // some hosts override the UA `[hidden]` rule. const $tr = $('', { 'data-id': rowId, + ...(values._hidden ? {hidden: true, class: 'hidden'} : {}), }); for (const colId in columns) { diff --git a/resources/js/modules/editable-table/types.ts b/resources/js/modules/editable-table/types.ts index b6a600e3465..d2fcc820032 100644 --- a/resources/js/modules/editable-table/types.ts +++ b/resources/js/modules/editable-table/types.ts @@ -52,6 +52,12 @@ export type EditableTableValue = | EditableTableValue[] | EditableTableRow; +/** + * `_hidden` is a reserved key (not a declared column, so it never renders as a + * cell): hides the row (`hidden` attribute + class) without removing it — its + * cells stay real inputs, still posting whatever they hold, so a caller can + * toggle it back off without losing anything already typed. + */ export interface EditableTableRow { [key: string]: EditableTableValue; } diff --git a/resources/js/modules/forms/CalloutNode.vue b/resources/js/modules/forms/CalloutNode.vue index 7f8a07679f5..e8140dbb7e7 100644 --- a/resources/js/modules/forms/CalloutNode.vue +++ b/resources/js/modules/forms/CalloutNode.vue @@ -9,6 +9,7 @@ html: string; variant: string; appearance?: string; + padding?: string | number; icon?: string; dismissible: boolean; width: number; @@ -35,7 +36,10 @@ :class="`width-${node.props.width}`" :data-form-node="node.uid" :variant="node.props.variant" - v-bind="node.props.appearance ? {appearance: node.props.appearance} : {}" + v-bind="{ + ...(node.props.appearance ? {appearance: node.props.appearance} : {}), + ...(node.props.padding !== undefined ? {padding: node.props.padding} : {}), + }" :icon="node.props.icon" :data-dismissible="node.props.dismissible || undefined" > diff --git a/resources/js/modules/forms/FieldNode.vue b/resources/js/modules/forms/FieldNode.vue index 5f809aefdea..40ff0c4f03a 100644 --- a/resources/js/modules/forms/FieldNode.vue +++ b/resources/js/modules/forms/FieldNode.vue @@ -36,6 +36,7 @@ /** Visually hides the label, keeping it available to screen readers. */ labelSrOnly?: boolean; instructions?: string | null; + instructionsHtml?: string; required?: boolean; instructionsPosition?: 'before' | 'after'; tip?: string; @@ -188,7 +189,9 @@
+ >; }; type TableRow = EditableTableRow; @@ -88,11 +89,17 @@ const name = inputName(props.control.path); bodyElement.replaceChildren(); rowEntries(rows).forEach(([rowId, row]) => { + // `hiddenRows` is a control *prop*, not part of the row's own value — see + // `Table::hiddenRows()` for why: props are freshly reapplied on every reactive + // refresh, unlike row values (only ever merged in where missing). + const rowWithVisibility = props.control.props.hiddenRows?.includes(rowId) + ? {...row, _hidden: true} + : row; EditableTable.createRow( rowId, props.control.props.columns, name, - row, + rowWithVisibility, props.editable && props.control.props.allowReorder, props.editable && props.control.props.allowDelete, !props.editable diff --git a/resources/js/modules/settings/composables/useSettingsSave.ts b/resources/js/modules/settings/composables/useSettingsSave.ts index 86ad137fd3a..4e6519224bb 100644 --- a/resources/js/modules/settings/composables/useSettingsSave.ts +++ b/resources/js/modules/settings/composables/useSettingsSave.ts @@ -87,6 +87,10 @@ export function useSettingsSave( // Callers can opt out of state preservation — e.g. "save as new", which // navigates to a different record and needs the form to re-initialize. preserveState = true, + // An alternate form action ("Save as a new X", "Delete") posting the + // same in-progress values to a different destination than this screen's + // own default `submit` target. + action: actionOverride, }: FormSaveOptions = {}) { options.onBeforeSave?.(); @@ -109,7 +113,7 @@ export function useSettingsSave( * usual 422 — `asJsonFailure()` picks it. */ async function submitInSlideout(retried = false): Promise { - const route = action(); + const route = actionOverride ?? action(); const routeIsString = Object(route).constructor === String; form.clearErrors(); @@ -226,7 +230,7 @@ export function useSettingsSave( return payload; }) - .submit(action(), { + .submit(actionOverride ?? action(), { ...submitOptions, onHttpException: (response) => { if (!passwordConfirmation || response.status !== 423 || retried) { diff --git a/resources/js/pages/Form.vue b/resources/js/pages/Form.vue index c22bb0cdd4e..d25813023bd 100644 --- a/resources/js/pages/Form.vue +++ b/resources/js/pages/Form.vue @@ -2,7 +2,7 @@ import {actionClient} from '@craftcms/ui'; import type {UrlMethodPair} from '@inertiajs/core'; import {useForm} from '@inertiajs/vue3'; - import {shallowRef, toRaw} from 'vue'; + import {computed, shallowRef, toRaw} from 'vue'; import { useAppLayout, type UseAppLayoutOptions, @@ -10,6 +10,7 @@ import DynamicHtmlRenderer from '@/common/components/DynamicHtmlRenderer.vue'; import LayoutSlot from '@/common/components/LayoutSlot.vue'; import FormRenderer from '@/modules/forms/FormRenderer.vue'; + import type {ActionItem, FormAltAction} from '@/common/types'; import type { FormChange, FormChangeKind, @@ -34,6 +35,12 @@ elevatedFields?: string[] | '*'; refreshUrl?: string; defaultFormActions?: UseAppLayoutOptions['defaultFormActions']; + /** + * Server-described alternate form actions ("Save as a new X", "Delete") — + * see {@link FormAltAction}. Each resubmits the form's current values via + * this same component's own `save()`, just aimed at a different action. + */ + formActions?: FormAltAction[]; /** * Server-rendered read-only metadata (e.g. Created at/Updated at) for the details * column — the same {@see \CraftCms\Cms\Cp\Html\ContentHtml::metadataHtml()} markup @@ -89,12 +96,39 @@ }).save : undefined; + // Each server-described alt action becomes a real `ActionItemButton`, whose + // `onClick` reuses this component's own `save()` — the only place that has + // both the in-progress values (`renderer.value?.currentValues()`, via + // `save`'s own `transform` above) and the confirm/redirect machinery + // already built for the primary Save button. This is the piece `formActions` + // itself can't carry: it's plain JSON from the server, not a closure. + const translatedFormActions = computed( + () => + props.formActions?.map((altAction) => ({ + label: altAction.label, + variant: altAction.destructive ? 'danger' : undefined, + onClick: () => { + if (altAction.confirm && !window.confirm(altAction.confirm)) { + return; + } + + save?.({ + action: altAction.action + ? {url: altAction.action, method: 'post'} + : undefined, + data: altAction.params, + }); + }, + })) ?? [] + ); + // `PageScreen` shows the Save button purely on `form` being truthy (`v-if="form"`) — // it doesn't look at `onSave`/`submit`. Passing `inertiaForm` unconditionally would // show a Save button with nothing to save on a node-only screen (a listing, say). useAppLayout({ form: props.submit ? inertiaForm : null, defaultFormActions: props.defaultFormActions, + formActions: translatedFormActions.value, onSave: save, }); diff --git a/resources/templates/_includes/forms/editableTable.twig b/resources/templates/_includes/forms/editableTable.twig index 5c3a4aa68f7..31cae5e511c 100644 --- a/resources/templates/_includes/forms/editableTable.twig +++ b/resources/templates/_includes/forms/editableTable.twig @@ -113,7 +113,7 @@ {% set rowNumber = loop.index %} {% set rowName = 'Row {index}'|t('app', {index: rowNumber}) %} {% set actionBtnLabel = "#{rowName} #{'Actions'|t('app')}" %} - + {% for colId, col in cols %} {% set cell = row[colId] is defined ? row[colId] : (defaultValues[colId] ?? null) %} {% set value = cell.value is defined ? cell.value : cell %} diff --git a/src/Form/Controls/Table.php b/src/Form/Controls/Table.php index c6bea10285a..6d3bc9ab266 100644 --- a/src/Form/Controls/Table.php +++ b/src/Form/Controls/Table.php @@ -31,6 +31,9 @@ class Table extends Control private bool $keyed = false; + /** @var list */ + private array $hiddenRows = []; + /** @var array> */ private array $errors = []; @@ -51,6 +54,7 @@ public static function renderHtml(ControlPayload $control, mixed $value, array $ 'minRows' => $control->props['minRows'] ?? null, 'maxRows' => $control->props['maxRows'] ?? null, 'static' => $attributes['name'] === null, + 'hiddenRows' => $control->props['hiddenRows'] ?? [], 'errors' => $control->props['errors'] ?? [], ]); } @@ -110,6 +114,27 @@ public function keyed(bool $keyed = true): static return $this; } + /** + * Hides the given rows (by their row key — a shipping category id, say) without + * removing them: their cells stay real inputs, still posting whatever they hold, so a + * caller can stop hiding a row later without losing anything already typed in it — + * the same principle {@see \CraftCms\Cms\Form\Nodes\Concerns\HasVisibility} documents + * for whole Field/Group nodes. + * + * Deliberately a Control *prop* rather than part of each row's own value: props are + * always freshly reapplied on a reactive refresh, whereas row-level values are only + * ever merged in where missing (so an already-known row can't be updated this way + * without touching real submitted data). + * + * @param list $rowIds + */ + public function hiddenRows(array $rowIds): static + { + $this->hiddenRows = $rowIds; + + return $this; + } + /** @param array> $errors */ public function errors(array $errors): static { @@ -136,6 +161,7 @@ public function props(mixed $value = null): array 'minRows' => $this->minRows, 'maxRows' => $this->maxRows, 'keyed' => $this->keyed, + 'hiddenRows' => $this->hiddenRows ?: null, 'errors' => $this->errors ?: null, ]); } diff --git a/src/Form/Nodes/Callout.php b/src/Form/Nodes/Callout.php index 5b2f012cd18..9fb6a405474 100644 --- a/src/Form/Nodes/Callout.php +++ b/src/Form/Nodes/Callout.php @@ -23,6 +23,8 @@ class Callout implements Node private ?string $appearance = null; + private string|int|null $padding = null; + private ?string $icon = null; private bool $dismissible = false; @@ -40,6 +42,7 @@ public static function renderHtml(NodePayload $node, FormPayload $payload, FormH ->variant($node->props['variant']) ->appearance($node->props['appearance'] ?? null) ->icon($node->props['icon'] ?? null) + ->padding($node->props['padding'] ?? null) ->content(new HtmlString($node->props['html'])) ->attributes([ 'class' => ["width-{$node->props['width']}"], @@ -68,6 +71,17 @@ public function appearance(?string $appearance): static return $this; } + /** + * Spacing applied to the callout box — see `craft-callout`'s own `padding` attribute + * (`sm`/`md`/`lg`/`xl`, `0`/`none`, a unitless pixel number, or any CSS length). + */ + public function padding(string|int|null $padding): static + { + $this->padding = $padding; + + return $this; + } + public function icon(?string $icon): static { $this->icon = $icon; @@ -108,6 +122,7 @@ public function props(): array 'appearance' => $this->appearance, 'icon' => $this->icon, ], fn (?string $value): bool => $value !== null), + ...($this->padding !== null ? ['padding' => $this->padding] : []), 'dismissible' => $this->dismissible, 'width' => $this->width, ]; diff --git a/src/Form/Nodes/Field.php b/src/Form/Nodes/Field.php index 6a9ac707da5..7b917d77ea0 100644 --- a/src/Form/Nodes/Field.php +++ b/src/Form/Nodes/Field.php @@ -125,6 +125,12 @@ public function labelSrOnly(bool $labelSrOnly = true): static return $this; } + /** + * Supports the same markdown as `tip()`/`warning()` — including a raw + * inline tag like `` (preserved by the same + * {@see self::noticeHtml()} pass those use), unlike a plain-text field + * whose instructions never go through markdown parsing at all. + */ public function instructions(?string $instructions): static { $this->instructions = $instructions; @@ -237,6 +243,7 @@ public function props(): array ...Arr::whereNotNull([ 'labelSrOnly' => $this->labelSrOnly ?: null, 'instructionsPosition' => $this->instructionsPosition !== 'before' ? $this->instructionsPosition : null, + 'instructionsHtml' => $this->noticeHtml($this->instructions), 'tip' => $this->tip, 'tipHtml' => $this->noticeHtml($this->tip), 'warning' => $this->warning, From 97d3c8100aaafb6efc3acb40773586419db25973 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 17 Sep 2026 22:23:49 +0100 Subject: [PATCH 10/19] Update create combo to allow multi select --- .../src/components/combobox/combobox.ts | 10 ++++++- .../modules/forms/ComboboxCreateControl.vue | 27 +++++++++++++++--- resources/js/modules/forms/combobox-create.ts | 28 +++++++++++++++++-- src/Form/Controls/Combobox.php | 26 +++++++++++++++-- 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/packages/craftcms-ui/src/components/combobox/combobox.ts b/packages/craftcms-ui/src/components/combobox/combobox.ts index e56f33910bb..8ec17fc8ca3 100644 --- a/packages/craftcms-ui/src/components/combobox/combobox.ts +++ b/packages/craftcms-ui/src/components/combobox/combobox.ts @@ -641,7 +641,15 @@ export default class CraftCombobox extends LionCombobox { } } - private changeValues(values: string[]) { + /** + * Commits a new set of selected values for a multi-select combobox — resets the free-text + * query, closes the listbox, re-renders options, syncs the hidden native inputs used for real + * form submission, and announces the change. `protected` (not `private`) so a subclass that + * needs to inject a value outside the normal option-click/keyboard paths (e.g. + * {@link CraftComboboxCreate} appending a freshly-created record's option) can reuse this + * exact path instead of re-deriving its side effects, which would drift out of sync over time. + */ + protected changeValues(values: string[]) { if (this.inactive) { return; } diff --git a/resources/js/modules/forms/ComboboxCreateControl.vue b/resources/js/modules/forms/ComboboxCreateControl.vue index 624e805f369..87920a4d704 100644 --- a/resources/js/modules/forms/ComboboxCreateControl.vue +++ b/resources/js/modules/forms/ComboboxCreateControl.vue @@ -7,6 +7,7 @@ type ComboboxCreateControlProps = { options: ComboboxItem[]; + multiple?: boolean; createUrl?: string; createValue?: string; resultKey?: string; @@ -30,7 +31,7 @@ required: boolean; }>(); const emit = defineEmits<{ - (event: 'update:value', value: string, kind: 'discrete'): void; + (event: 'update:value', value: string | string[], kind: 'discrete'): void; }>(); function onModelValueChanged(event: Event): void { @@ -39,9 +40,20 @@ } const target = event.target as CraftComboboxCreate; + const value = target.modelValue; - if (target.modelValue !== target.createValue) { - emit('update:value', String(target.modelValue ?? ''), 'discrete'); + // Skip the transient state where the trigger option itself is (still) selected — the + // control resets that back out on its own right after opening the create slideout. + const triggered = Array.isArray(value) + ? value.includes(target.createValue) + : value === target.createValue; + + if (!triggered) { + emit( + 'update:value', + Array.isArray(value) ? value.map(String) : String(value ?? ''), + 'discrete' + ); } } @@ -49,7 +61,14 @@