Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions resources/js/common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
* labels stay aligned whichever one is current.
*/
selected?: boolean;
variant?: VariantKey | string;

Check warning on line 130 in resources/js/common/types/index.ts

View workflow job for this annotation

GitHub Actions / Build + Check

typescript(no-redundant-type-constituents)

"\"Danger\"" | "\"Info\"" | "\"Neutral\"" | "\"Success\"" | "\"Warning\"" is overridden by string in this union type.
icon?: string;
/**
* A rendered SVG to use in place of a named icon, for the things that bring
Expand Down Expand Up @@ -185,7 +185,7 @@
* labels stay aligned whichever one is current.
*/
selected?: boolean;
variant?: VariantKey | string;

Check warning on line 188 in resources/js/common/types/index.ts

View workflow job for this annotation

GitHub Actions / Build + Check

typescript(no-redundant-type-constituents)

"\"Danger\"" | "\"Info\"" | "\"Neutral\"" | "\"Success\"" | "\"Warning\"" is overridden by string in this union type.
hidden?: boolean;
disabled?: boolean;
onClick?: (event: Event) => void;
Expand Down Expand Up @@ -321,6 +321,7 @@
| 'template'
| 'number'
| 'singleline'
| 'money'
| 'multiline'
| 'heading'
| 'html'
Expand Down
157 changes: 126 additions & 31 deletions resources/js/modules/editable-table/editable-table.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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: <T>(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: <T>(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',
Expand Down
75 changes: 71 additions & 4 deletions resources/js/modules/editable-table/editable-table.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +12,7 @@ import type {
EditableTableColumn,
EditableTableColumns,
EditableTableOption,
EditableTableOptionGroup,
EditableTableOptions,
EditableTableRow,
EditableTableValue,
Expand All @@ -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 {
Expand Down Expand Up @@ -686,6 +695,47 @@ export class EditableTable extends Base<EditableTableSettings> {
.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);
$('<input/>', {
type: 'hidden',
name: `${name}[locale]`,
value: String(moneyLocale),
}).appendTo($cell);
break;
}

case 'time':
Craft.ui
.createTimeInput({
Expand Down Expand Up @@ -729,10 +779,27 @@ export class EditableTable extends Base<EditableTableSettings> {
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;
Expand Down
26 changes: 24 additions & 2 deletions resources/js/modules/editable-table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
}

Expand Down Expand Up @@ -58,6 +73,12 @@ export interface EditableTableOptions {
[key: string]: EditableTableOption;
}

export interface EditableTableOptionGroup {
label?: string;
type?: 'optgroup';
options: EditableTableOption[];
}

type EditableTableColumnValue =
| string
| number
Expand All @@ -66,6 +87,7 @@ type EditableTableColumnValue =
| string[]
| EditableTableOptions
| EditableTableOption[]
| EditableTableOptionGroup[]
| EditableTablePrefixSelect
| TextExpanderTriggers;

Expand Down
2 changes: 2 additions & 0 deletions src/Field/TableCellTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Loading
Loading