diff --git a/CHANGELOG.md b/CHANGELOG.md index 52770761a1b..30966f0d682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added support for `money` columns and grouped options in legacy editable tables and Form tables, including a configurable Money cell type for Table fields. ([#19870](https://github.com/craftcms/cms/pull/19870)) - Updated control panel navigation to refresh on each server navigation, including items added or removed dynamically by plugins. - Reduced database queries when building control panel navigation. - Made several minor UI refinements. ([#19839](https://github.com/craftcms/cms/pull/19839)) diff --git a/resources/js/common/types/index.ts b/resources/js/common/types/index.ts index 98a05920994..efb2e7d4f4a 100644 --- a/resources/js/common/types/index.ts +++ b/resources/js/common/types/index.ts @@ -321,6 +321,7 @@ export type EditableTableCellType = | 'template' | 'number' | 'singleline' + | 'money' | 'multiline' | 'heading' | 'html' diff --git a/resources/js/modules/editable-table/editable-table.test.ts b/resources/js/modules/editable-table/editable-table.test.ts index e8f454df3a6..fe5f5297628 100644 --- a/resources/js/modules/editable-table/editable-table.test.ts +++ b/resources/js/modules/editable-table/editable-table.test.ts @@ -1,6 +1,7 @@ import $ from 'jquery'; import {afterEach, expect, it, vi} from 'vite-plus/test'; import {EditableTable, Row} from './editable-table'; +import type {EditableTableRow} from './types'; afterEach(() => { vi.useRealTimers(); @@ -77,39 +78,133 @@ it('initializes text cells without the legacy NiceText behavior', () => { instance.destroy(); }); -it('renders autosuggest cells as comboboxes', async () => { - vi.stubGlobal('$', $); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: false})); - vi.stubGlobal('Craft', { - hasMousePointerEvents: () => true, - inArray: (value: T, values: T[]) => values.includes(value), - }); +it.each([ + { + kind: 'flat', + options: [{label: 'Environment', value: '$SYSTEM_EMAIL'}], + expectedOptions: [{label: 'Environment', value: '$SYSTEM_EMAIL'}], + }, + { + kind: 'grouped', + options: [ + { + label: 'Environment', + options: [{label: 'System email', value: '$SYSTEM_EMAIL'}, {value: 0}], + }, + {label: 'Literal address', value: 'admin@example.com'}, + ], + expectedOptions: [ + { + type: 'optgroup', + label: 'Environment', + options: [ + {label: 'System email', value: '$SYSTEM_EMAIL'}, + {label: '0', value: '0'}, + ], + }, + {label: 'Literal address', value: 'admin@example.com'}, + ], + }, +])( + 'renders autosuggest cells with $kind combobox options', + async ({options, expectedOptions}) => { + vi.stubGlobal('$', $); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: false})); + vi.stubGlobal('Craft', { + hasMousePointerEvents: () => true, + inArray: (value: T, values: T[]) => values.includes(value), + }); - const row = EditableTable.createRow( - 'site-uid', - { - fromEmail: { - type: 'autosuggest', - heading: 'System Email Address', - options: [{label: 'Environment', value: '$SYSTEM_EMAIL'}], + const row = EditableTable.createRow( + 'site-uid', + { + fromEmail: { + type: 'autosuggest', + heading: 'System Email Address', + options, + }, }, - }, - 'siteOverrides', - {fromEmail: '$SYSTEM_EMAIL'} - ); - document.body.append(row[0]); - const combobox = row[0]?.querySelector('craft-combobox'); - if (!combobox) throw new Error('Expected the autosuggest combobox.'); - await combobox.updateComplete; - - expect(combobox.name).toBe('siteOverrides[site-uid][fromEmail]'); - expect(combobox.label).toBe('System Email Address'); - expect(combobox.modelValue).toBe('$SYSTEM_EMAIL'); - expect(combobox.options).toEqual([ - {label: 'Environment', value: '$SYSTEM_EMAIL'}, - ]); - expect(combobox.showAllOnEmpty).toBe(true); -}); + 'siteOverrides', + {fromEmail: '$SYSTEM_EMAIL'} + ); + document.body.append(row[0]); + const combobox = row[0]?.querySelector('craft-combobox'); + if (!combobox) throw new Error('Expected the autosuggest combobox.'); + await combobox.updateComplete; + + expect(combobox.name).toBe('siteOverrides[site-uid][fromEmail]'); + expect(combobox.label).toBe('System Email Address'); + expect(combobox.options).toEqual(expectedOptions); + expect(combobox.modelValue).toBe('$SYSTEM_EMAIL'); + expect(combobox.showAllOnEmpty).toBe(true); + } +); + +it.each<{ + kind: string; + values: EditableTableRow; + expectedValue: string; + expectedLocale: string; +}>([ + { + kind: 'saved', + values: {amount: {value: 12.5, locale: 'fr-BE'}}, + expectedValue: '12.5', + expectedLocale: 'fr-BE', + }, + { + kind: 'new', + values: {amount: ''}, + expectedValue: '', + expectedLocale: 'nl-BE', + }, +])( + 'renders a $kind money cell with its amount and submission locale', + async ({values, expectedValue, expectedLocale}) => { + vi.stubGlobal('$', $); + vi.stubGlobal('Craft', { + inArray: (value: unknown, values: unknown[]) => values.includes(value), + }); + + const row = EditableTable.createRow( + '0', + { + amount: { + type: 'money', + heading: 'Amount', + currency: 'EUR', + locale: 'nl-BE', + decimals: 3, + decimalSeparator: ',', + groupSeparator: '.', + showCurrency: false, + clearable: false, + }, + }, + 'prices', + values + ); + document.body.append(row[0]); + const money = document.querySelector('craft-input-money'); + if (!money) throw new Error('Expected the money input.'); + await money.updateComplete; + + expect(money.name).toBe('prices[0][amount][value]'); + expect(money.modelValue).toBe(expectedValue); + expect(money.currency).toBe('EUR'); + expect(money.locale).toBe(expectedLocale); + expect(money.decimals).toBe(3); + expect(money.decimalSeparator).toBe(','); + expect(money.groupSeparator).toBe('.'); + expect(money.showCurrency).toBe(false); + expect(money.clearable).toBe(false); + expect(money.label).toBe('Amount'); + expect(money.hasAttribute('label-sr-only')).toBe(true); + const locale = row[0].querySelector('input[type="hidden"]'); + expect(locale?.name).toBe('prices[0][amount][locale]'); + expect(locale?.value).toBe(expectedLocale); + } +); it.each(['autosuggest', 'template', 'singleline', 'multiline'])( 'renders %s cells with accessible text expanders when configured', diff --git a/resources/js/modules/editable-table/editable-table.ts b/resources/js/modules/editable-table/editable-table.ts index 29035d64e16..16e5a995a80 100644 --- a/resources/js/modules/editable-table/editable-table.ts +++ b/resources/js/modules/editable-table/editable-table.ts @@ -1,7 +1,9 @@ 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 CraftInputMoney from '@craftcms/ui/components/input-money/input-money'; import '@craftcms/ui/components/field/field'; import '@craftcms/ui/components/field-group/field-group'; import '@craftcms/ui/components/select/select'; @@ -10,6 +12,7 @@ import type { EditableTableColumn, EditableTableColumns, EditableTableOption, + EditableTableOptionGroup, EditableTableOptions, EditableTableRow, EditableTableValue, @@ -27,6 +30,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 { @@ -686,6 +695,47 @@ export class EditableTable extends Base { .appendTo($cell); break; + case 'money': { + // New rows may start with an empty string rather than {value, locale}. + const moneyValue = + value instanceof Object && !Array.isArray(value) + ? ((value as {value?: string | number | null}).value ?? null) + : ((value as string | number | null) ?? null); + const moneyLocale = + (value instanceof Object && !Array.isArray(value) + ? (value as {locale?: string}).locale + : undefined) ?? + col.locale ?? + 'en-US'; + const money = document.createElement( + 'craft-input-money' + ) as CraftInputMoney; + money.name = `${name}[value]`; + money.label = col.heading ?? colId; + money.setAttribute('label-sr-only', ''); + 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); + $('', { + type: 'hidden', + name: `${name}[locale]`, + value: String(moneyLocale), + }).appendTo($cell); + break; + } + case 'time': Craft.ui .createTimeInput({ @@ -729,10 +779,27 @@ 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 => + isOptionGroup(option) + ? { + type: 'optgroup', + label: option.label ?? '', + options: option.options.map((groupedOption) => { + const value = String( + (groupedOption.value as + | string + | number + | undefined) ?? '' + ); + return {label: groupedOption.label ?? value, 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 778b8818f56..c876b2f9cf8 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[]; prefixSelect?: EditableTablePrefixSelect; textExpanderTriggers?: TextExpanderTriggers; /** Checkbox: only one in the column may be checked at a time. */ @@ -25,8 +28,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; } @@ -58,6 +73,12 @@ export interface EditableTableOptions { [key: string]: EditableTableOption; } +export interface EditableTableOptionGroup { + label?: string; + type?: 'optgroup'; + options: EditableTableOption[]; +} + type EditableTableColumnValue = | string | number @@ -66,6 +87,7 @@ type EditableTableColumnValue = | string[] | EditableTableOptions | EditableTableOption[] + | EditableTableOptionGroup[] | EditableTablePrefixSelect | TextExpanderTriggers; diff --git a/src/Field/TableCellTypes.php b/src/Field/TableCellTypes.php index 7d04cb460b6..70a2a71fba4 100644 --- a/src/Field/TableCellTypes.php +++ b/src/Field/TableCellTypes.php @@ -15,6 +15,7 @@ use CraftCms\Cms\Field\TableCells\Heading; use CraftCms\Cms\Field\TableCells\Lightswitch; use CraftCms\Cms\Field\TableCells\MissingTableCell; +use CraftCms\Cms\Field\TableCells\Money; use CraftCms\Cms\Field\TableCells\Multiline; use CraftCms\Cms\Field\TableCells\Number; use CraftCms\Cms\Field\TableCells\Select; @@ -41,6 +42,7 @@ class TableCellTypes extends TypeRegistry 'email' => Email::class, 'heading' => Heading::class, 'lightswitch' => Lightswitch::class, + 'money' => Money::class, 'multiline' => Multiline::class, 'number' => Number::class, 'singleline' => Singleline::class, diff --git a/src/Field/TableCells/Money.php b/src/Field/TableCells/Money.php new file mode 100644 index 00000000000..e4159a81b05 --- /dev/null +++ b/src/Field/TableCells/Money.php @@ -0,0 +1,97 @@ +path) + ->currency($this->currency) + ->showCurrency($this->showCurrency); + } + + /** @return array{value: string|null, locale: string} */ + protected function controlValue(TableCellContext $context): array + { + $locale = I18N::getFormattingLocale(); + $value = $this->serializeValue($context->value); + + return [ + 'value' => $value === null || $value === '' ? null : str_replace('.', $locale->getNumberSymbol(Locale::SYMBOL_DECIMAL_SEPARATOR), (string) $value), + 'locale' => $locale->id, + ]; + } + + public function normalizeValue(mixed $value, bool $fromRequest = false): mixed + { + if (is_array($value)) { + $value = I18N::normalizeNumber($value['value'] ?? null, $value['locale'] ?? null); + } + + return $value === '' ? null : parent::normalizeValue($value, $fromRequest); + } + + public function getValueRules(): array + { + return ['nullable', 'numeric']; + } + + public function getRules(): array + { + return [ + ...parent::getRules(), + 'currency' => ['required', Rule::in(array_column(self::currencyOptions(), 'value'))], + 'showCurrency' => ['boolean'], + ]; + } + + public function settingsForm(FormContext $context = new FormContext): ?Form + { + return Form::make([ + Field::make(t('Currency'))->required() + ->control(Choice::make('currency')->options(self::currencyOptions())->value($this->currency)), + Field::make(t('Show Currency')) + ->control(Lightswitch::make('showCurrency')->value($this->showCurrency)), + ]); + } + + /** @return list */ + private static function currencyOptions(): array + { + $options = []; + foreach (new ISOCurrencies as $currency) { + $options[] = ['label' => $currency->getCode(), 'value' => $currency->getCode()]; + } + + return $options; + } +} diff --git a/src/Field/TableCells/Select.php b/src/Field/TableCells/Select.php index 8d59f02ee3a..49887a14c86 100644 --- a/src/Field/TableCells/Select.php +++ b/src/Field/TableCells/Select.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Form\Controls\Choice; use CraftCms\Cms\Form\Controls\Control; use CraftCms\Cms\Form\Controls\Table; +use CraftCms\Cms\Form\Controls\TableColumn; use CraftCms\Cms\Form\Form; use CraftCms\Cms\Form\FormContext; use CraftCms\Cms\Form\Nodes\Field; @@ -18,7 +19,7 @@ */ class Select extends TableCell { - /** @var list */ + /** @var list}> */ public array $options = []; public static function displayName(): string @@ -31,16 +32,18 @@ protected function createControl(TableCellContext $context): Control $options = array_map(fn (array $option): array => [ ...$option, 'label' => t($option['label'], category: 'site', locale: $context->locale), - ], $this->options); + ...(isset($option['group']) ? ['group' => t($option['group'], category: 'site', locale: $context->locale)] : []), + ], TableColumn::options($this->options)); return Choice::make($context->path)->options($options)->withoutPlaceholder(); } protected function controlValue(TableCellContext $context): mixed { - $default = array_find($this->options, fn (array $option): bool => ! empty($option['default'])); + $options = TableColumn::options($this->options); + $default = array_find($options, fn (array $option): bool => ! empty($option['default'])); - return $context->value ?? $default['value'] ?? $this->options[0]['value'] ?? null; + return $context->value ?? $default['value'] ?? $options[0]['value'] ?? null; } public function settingsForm(FormContext $context = new FormContext): ?Form @@ -50,9 +53,10 @@ public function settingsForm(FormContext $context = new FormContext): ?Form ->columns([ 'label' => ['heading' => t('Label'), 'type' => 'singleline'], 'value' => ['heading' => t('Value'), 'type' => 'singleline'], + 'group' => ['heading' => t('Group'), 'type' => 'singleline'], 'default' => ['heading' => t('Default'), 'type' => 'checkbox', 'radioMode' => true], ]) - ->allowAdd()->allowDelete()->allowReorder()->value($this->options)), + ->allowAdd()->allowDelete()->allowReorder()->value(TableColumn::options($this->options))), ]); } } diff --git a/src/Form/Controls/TableColumn.php b/src/Form/Controls/TableColumn.php index f68d4f4de3d..6337cbe4f57 100644 --- a/src/Form/Controls/TableColumn.php +++ b/src/Form/Controls/TableColumn.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Form\Contracts\Control as ControlContract; use CraftCms\Cms\Form\Enums\ChoicePresentation; use CraftCms\Cms\Form\Enums\ControlMode; +use CraftCms\Cms\Support\Facades\I18N; use InvalidArgumentException; /** @@ -33,6 +34,10 @@ public static function control(string $key, array $config): ControlContract 'heading' => Text::make([$key])->mode(ControlMode::ReadOnly), 'multiline' => Textarea::make([$key])->rows($config['rows'] ?? 1)->monospace($config['code'] ?? false), 'number' => Number::make([$key])->min($config['min'] ?? null)->max($config['max'] ?? null)->step($config['step'] ?? null), + 'money' => Money::make([$key]) + ->currency($config['currency'] ?? 'USD') + ->locale($config['locale'] ?? I18N::getFormattingLocale()->id) + ->showCurrency($config['showCurrency'] ?? true), 'date' => Date::make([$key]), 'time' => Time::make([$key]), 'color' => Color::make([$key]), @@ -43,7 +48,7 @@ public static function control(string $key, array $config): ControlContract 'icon' => IconPicker::make([$key]), 'autosuggest', 'template' => isset($config['textExpanderTriggers']) ? Text::make([$key]) - : Combobox::make([$key])->options($config['options'] ?? self::options($config['suggestions'] ?? []))->showAllOnEmpty(), + : Combobox::make([$key])->options(self::comboboxOptions($config['options'] ?? self::options($config['suggestions'] ?? [])))->showAllOnEmpty(), 'hidden' => Hidden::make([$key]), default => throw new InvalidArgumentException("Unknown Table column type [{$type}] at [{$key}]."), }; @@ -65,18 +70,43 @@ public static function control(string $key, array $config): ControlContract /** * @param array $options - * @return list + * @return list */ public static function options(array $options): array { $normalized = []; foreach ($options as $key => $option) { + if (is_array($option) && is_array($option['options'] ?? null)) { + foreach (self::options($option['options']) as $groupedOption) { + $normalized[] = [...$groupedOption, 'group' => (string) ($option['label'] ?? '')]; + } + + continue; + } + $normalized[] = is_array($option) - ? ['label' => (string) ($option['label'] ?? $option['value'] ?? $key), 'value' => $option['value'] ?? $key] + ? [ + ...$option, + 'label' => (string) ($option['label'] ?? $option['value'] ?? $key), + 'value' => $option['value'] ?? $key, + ...(isset($option['group']) ? ['group' => (string) $option['group']] : []), + ...(isset($option['default']) ? ['default' => (bool) $option['default']] : []), + ] : ['label' => (string) $option, 'value' => $key]; } return $normalized; } + + /** + * @param list> $options + * @return list> + */ + private static function comboboxOptions(array $options): array + { + return array_map(fn (array $option): array => is_array($option['options'] ?? null) + ? ['type' => 'optgroup', 'label' => $option['label'] ?? '', 'options' => self::options($option['options'])] + : $option, $options); + } } diff --git a/tests/Unit/Field/TableTest.php b/tests/Unit/Field/TableTest.php index e0d6245d9e6..7da85b67280 100644 --- a/tests/Unit/Field/TableTest.php +++ b/tests/Unit/Field/TableTest.php @@ -18,6 +18,7 @@ use CraftCms\Cms\Form\FormResolver; use CraftCms\Cms\Form\Nodes\Field as FormField; use CraftCms\Cms\Gql\Types\TableRow; +use CraftCms\Cms\Support\Facades\I18N; use GraphQL\Type\Definition\Type; use Symfony\Component\DomCrawler\Crawler; @@ -71,6 +72,10 @@ 'type' => 'select', 'options' => [['label' => 'Draft', 'value' => 'draft'], ['label' => 'Published', 'value' => 'published', 'default' => true]], ], null, 'published'], + 'grouped select default' => [[ + 'type' => 'select', + 'options' => [['label' => 'Editorial', 'options' => [['label' => 'Draft', 'value' => 'draft'], ['label' => 'Review', 'value' => 'review', 'default' => true]]]], + ], null, 'review'], 'first select option' => [[ 'type' => 'select', 'options' => [['label' => 'Draft', 'value' => 'draft'], ['label' => 'Published', 'value' => 'published']], @@ -107,8 +112,59 @@ 'invalid colour' => ['color', '#gggggg', false], 'empty colour' => ['color', '', true], 'null colour' => ['color', null, true], + 'valid money' => ['money', ['value' => '12,50', 'locale' => 'nl-BE'], true], + 'invalid money' => ['money', ['value' => 'invalid', 'locale' => 'nl-BE'], false], + 'empty money' => ['money', ['value' => '', 'locale' => 'nl-BE'], true], +]); + +it('round-trips money cells through localized row forms and scalar storage', function (?string $amount, ?string $expected) { + I18N::withLocale('en-US', 'nl-BE', function () use ($amount, $expected): void { + $field = new Table([ + 'name' => 'Prices', + 'handle' => 'prices', + 'columns' => ['amount' => [ + 'heading' => 'Amount', 'handle' => 'price', 'type' => 'money', + 'currency' => 'EUR', 'showCurrency' => false, + ]], + ]); + expect($field->validate())->toBeTrue(); + + $normalized = $field->normalizeValueFromRequest([['amount' => ['value' => $amount, 'locale' => 'nl-BE']]], null); + $stored = $field->serializeValueForDb($normalized, new Entry); + $payload = app(FormResolver::class)->resolve(Form::make([ + FormField::make('Prices', $field->formControl(new FieldContext('prices', value: $field->normalizeValue($stored, null)))), + ]), new FormContext); + $cell = $payload->nodes[0]->control->forms[0]->nodes[0]->control; + + expect($stored)->toBe([['amount' => $expected]]) + ->and($payload->values['prices'][0]['amount'])->toBe(['value' => $amount === '' ? null : $amount, 'locale' => 'nl-BE']) + ->and($cell->component)->toBe('craft:money') + ->and($cell->path)->toBe(['prices', '0', 'amount']) + ->and($cell->props)->toMatchArray(['currency' => 'EUR', 'locale' => 'nl-BE', 'showCurrency' => false]); + + $settings = app(FormResolver::class)->resolve($field->settingsForm(), new FormContext); + $types = array_column($settings->nodes[0]->control->props['cellTypes'], 'value'); + expect($types)->toContain('money') + ->and(TableRow::prepareRowFieldDefinition($field->columns)['price']->name)->toBe('Number'); + }); +})->with([ + 'amount' => ['12,50', '12.50'], + 'zero' => ['0', '0'], + 'cleared' => ['', null], + 'new' => [null, null], ]); +it('rejects unavailable currencies in money column settings', function () { + $field = new Table([ + 'name' => 'Prices', + 'handle' => 'prices', + 'columns' => ['amount' => ['heading' => 'Amount', 'handle' => 'price', 'type' => 'money', 'currency' => 'XYZ']], + ]); + + expect($field->validate())->toBeFalse() + ->and($field->errors()->has('columns.amount.currency'))->toBeTrue(); +}); + it('keeps column handles scalar while rendering validation errors by cell', function () { $field = new Table([ 'name' => 'Details', diff --git a/tests/Unit/Form/TableControlTest.php b/tests/Unit/Form/TableControlTest.php index 33a3e369622..ed59e4f4373 100644 --- a/tests/Unit/Form/TableControlTest.php +++ b/tests/Unit/Form/TableControlTest.php @@ -8,8 +8,10 @@ use CraftCms\Cms\Form\Enums\ControlMode; use CraftCms\Cms\Form\Form; use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\FormHtmlRenderer; use CraftCms\Cms\Form\FormResolver; use CraftCms\Cms\Form\Nodes\Field; +use Symfony\Component\DomCrawler\Crawler; it('resolves table cells at concrete paths while keeping row templates outside values and errors', function () { $form = Form::make([ @@ -35,6 +37,62 @@ ]); }); +it('resolves money columns in reusable tables with their row input paths', function () { + $payload = app(FormResolver::class)->resolve(Form::make([ + Field::make('Prices', Table::make('prices')->columns([ + 'amount' => ['heading' => 'Amount', 'type' => 'money', 'currency' => 'EUR', 'locale' => 'nl-BE', 'showCurrency' => false], + ])->value([['amount' => '']])->allowAdd()), + ]), new FormContext(namespace: 'settings')); + $cell = $payload->nodes[0]->control->forms[0]->nodes[0]->control; + + expect($cell->component)->toBe('craft:money') + ->and($cell->path)->toBe(['settings', 'prices', '0', 'amount']) + ->and($cell->props)->toMatchArray(['currency' => 'EUR', 'locale' => 'nl-BE', 'showCurrency' => false]) + ->and($payload->nodes[0]->control->props['rowTemplate']['nodes'][0]['control']['component'])->toBe('craft:money'); +}); + +it('preserves nested and flat groups in reusable table select columns', function () { + $payload = app(FormResolver::class)->resolve(Form::make([ + Field::make('Rows', Table::make('rows')->columns([ + 'status' => ['heading' => 'Status', 'type' => 'select', 'options' => [ + ['label' => 'Editorial', 'options' => [['label' => 'Draft', 'value' => 'draft'], ['label' => 'Review', 'value' => 'review']]], + ['label' => 'Published', 'value' => 'published', 'group' => 'Public'], + ['label' => 'Archived', 'value' => 'archived'], + ]], + ])->value([['status' => 'review']])), + ]), new FormContext); + $cell = $payload->nodes[0]->control->forms[0]->nodes[0]->control; + $crawler = new Crawler(app(FormHtmlRenderer::class)->renderControl($cell, $payload->values, 'status', false, false)); + + expect($cell->props['options'])->toEqual([ + ['label' => 'Draft', 'value' => 'draft', 'group' => 'Editorial'], + ['label' => 'Review', 'value' => 'review', 'group' => 'Editorial'], + ['label' => 'Published', 'value' => 'published', 'group' => 'Public'], + ['label' => 'Archived', 'value' => 'archived'], + ]) + ->and($crawler->filter('optgroup[label="Editorial"] option[selected]')->attr('value'))->toBe('review') + ->and($crawler->filter('optgroup[label="Public"] option')->attr('value'))->toBe('published'); +}); + +it('resolves implicit option groups for reusable table combobox columns', function () { + $payload = app(FormResolver::class)->resolve(Form::make([ + Field::make('Rows', Table::make('rows')->columns([ + 'address' => ['heading' => 'Address', 'type' => 'autosuggest', 'options' => [ + ['label' => 'Environment', 'options' => [['label' => 'System email', 'value' => '$SYSTEM_EMAIL'], ['value' => 0]]], + ['label' => 'Literal address', 'value' => 'admin@example.com'], + ]], + ])->value([['address' => '$SYSTEM_EMAIL']])), + ]), new FormContext); + + expect($payload->nodes[0]->control->forms[0]->nodes[0]->control->props['options'])->toEqual([ + ['type' => 'optgroup', 'label' => 'Environment', 'options' => [ + ['label' => 'System email', 'value' => '$SYSTEM_EMAIL'], + ['label' => '0', 'value' => '0'], + ]], + ['label' => 'Literal address', 'value' => 'admin@example.com'], + ]); +}); + it('applies table modes to concrete cells and reusable row templates', function (ControlMode $mode) { $form = Form::make([ Field::make('Rows', Table::make('rows')->columns([