diff --git a/.changeset/11865-p-listview-chatbot-shared-select.md b/.changeset/11865-p-listview-chatbot-shared-select.md new file mode 100644 index 0000000000..d260b171dd --- /dev/null +++ b/.changeset/11865-p-listview-chatbot-shared-select.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/plugin-chatbot': patch +--- + +Three more controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the list view and the chatbot): `ListView`'s "Color by field" and its "Rows per page" selector (the fallback for views without a grid pager), and `ChatbotEnhanced`'s model picker. + +The three were browser-native selects, so they looked and behaved differently from the console's other dropdowns. They now use the shared Radix `Select`: the same trigger, dropdown and keyboard behaviour. "Color by field" now matches its twin in the compact toolbar's View settings popover, which made the same change earlier. + +What they write is unchanged. Each option gives the same value as before: "None" still clears the row-colour config, a field keeps the config's colours, each page size still reaches `onPageSizeChange` and refetches at that size, and each model still reaches `onModelChange` as its id. Re-picking the current option writes nothing. The model picker keeps its accessible name, the `model` label. A row-colour rule on a field the caller may not read still shows as "None" and is never offered. + +One display change: a value none of a picker's options carries now shows as itself. The native select showed its first option instead: "None" for a row-colour field outside the list's columns, the first size for a page size in force that is not one of the options (an undeclared size, for instance), and the first model for a selected model the environment no longer offers. + +**Clause-②: no.** No published face moves: the package entries export the same names, the two components take the same props, and no i18n key is added. What moves is the three controls' own markup, described above. diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index 3d959c6d86..6cd9e6cef2 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -18,7 +18,7 @@ * - streaming markdown via streamdown (used by Message internals) */ import * as React from 'react'; -import { cn } from '@object-ui/components'; +import { cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; import { useObjectTranslation, useSafeTranslate } from '@object-ui/i18n'; import { AlertCircle, ArrowRight, Copy, Check, RefreshCw, CornerDownLeft, Bot, Eye, GitCompareArrows, Rocket, Clock3, CheckCircle2, XCircle, Loader2, ShieldCheck, TriangleAlert, ClipboardList, HelpCircle, Table2, WifiOff, Sparkles, Hourglass } from 'lucide-react'; @@ -925,6 +925,63 @@ export interface ChatbotModelOption { provider?: string; } +/** The item a selected model none of the offered models carries is shown by. */ +const OUTSIDE_MODELS = 'outside'; + +/** + * objectui#11865 — the composer's model picker, drawn with the shared `Select`, + * the control the rest of the console picks with. It used to be a + * browser-native ` { + // `undefined` for the outside item: it is the host's own model, so there is nothing to write. + const picked = models[Number(token)]; + if (picked) onPick(picked.id); + }} + > + + + + + {at === -1 && {value}} + {models.map((m, i) => ( + + {m.label ?? m.id} + {m.provider ? ` · ${m.provider}` : ''} + + ))} + + + ); +} + function formatMessageProps(role: ChatMessage['role']): MessageProps['from'] { // The vendored Message only knows user/assistant — render system as assistant // (FloatingChatbotProvider already renders system messages inline elsewhere). @@ -3557,19 +3614,12 @@ const ChatbotEnhanced = React.forwardRef( envs (the backend returns one entry) get no dropdown — the lone model is still sent via `selectedModelId`. */} {models && models.length > 1 ? ( - + onPick={(modelId) => onModelChange?.(modelId)} + /> ) : null} {/* #2458 UX#7 — the composer sends on PLAIN Enter (Shift+Enter = newline); the old `⌘` glyph implied Cmd+Enter and misled users. diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.modelPicker-11865.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.modelPicker-11865.test.tsx new file mode 100644 index 0000000000..b42e5f3b88 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.modelPicker-11865.test.tsx @@ -0,0 +1,156 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The composer's model picker picks with the shared `Select` (objectui#11865). + * + * The control was a browser-native select beside the shared Radix `Select` + * the rest of the console picks with. The card asks for one control for one + * kind of choice, surface by surface. + * + * What is pinned: + * - it IS the primitive (a Radix combobox trigger, with no visible native + * select beside it), keeps its accessible name, the `aria-label` the + * `model` label gives it, shows the model in force and lists the native + * control's options, with their text, in their order; + * - every option writes what the native control wrote: the model's `id`, + * handed to `onModelChange`, compared as JSON text over three selections + * (none, so the first model is in force; an offered model; a model none + * of the options carries). Re-picking the current model writes nothing; + * - a selected model none of the options carries is what the trigger shows; + * - the keyboard alone opens the picker and selects, inside the composer's + * form, without sending the draft; + * - the read-only transcript has no composer, so no picker, as before. + * + * DIRECTION, observed against the native control: every pin here reads the + * control as the primitive's trigger, so each is red there except the name + * row and the read-only row (green there by design: they keep a behaviour, + * the `aria-label` name and the composer-less transcript). What makes the + * write rows guards of "the conversion changed nothing the composer writes" is + * the literal each compares against: a `change` event on the pre-conversion + * native control wrote that same JSON, read once on this component with these + * fixtures. That probe's `change` event fired for the current option too, + * which a browser's native select does not do, so the re-pick rows pin the + * primitive. + */ + +import '@testing-library/jest-dom/vitest'; +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; +import { ChatbotEnhanced } from '../ChatbotEnhanced'; + +afterEach(() => cleanup()); + +const MODELS = [ + { id: 'gpt-4o-mini', label: 'GPT-4o mini', provider: 'openai' }, + { id: 'claude-3-5-sonnet', label: 'Claude 3.5', provider: 'anthropic' }, + { id: 'local-llm' }, +]; + +const SELECTIONS: Record = { + unset: undefined, + claude: 'claude-3-5-sonnet', + ghost: 'ghost-model', +}; + +function mount(selected: string | undefined, extra: Record = {}) { + const onModelChange = vi.fn(); + const onSendMessage = vi.fn(); + const view = render( + , + ); + return { onModelChange, onSendMessage, view, trigger: () => screen.getByRole('combobox', { name: 'Model' }) }; +} + +async function openPicker(trigger: HTMLElement): Promise { + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + const listbox = await screen.findByRole('listbox'); + return within(listbox).getAllByRole('option'); +} + +async function pick(trigger: HTMLElement, label: string): Promise { + const options = await openPicker(trigger); + const option = options.find((o) => o.textContent === label); + if (!option) throw new Error(`the picker lists no "${label}": ${options.map((o) => o.textContent).join(' | ')}`); + fireEvent.click(option); +} + +/** + * [selection, option label, what the native control handed `onModelChange`]. + * `null` marks the model in force: re-picking it writes nothing. + */ +const WRITES: ReadonlyArray = [ + ['unset', 'GPT-4o mini · openai', null], + ['unset', 'Claude 3.5 · anthropic', '[["claude-3-5-sonnet"]]'], + ['unset', 'local-llm', '[["local-llm"]]'], + ['claude', 'GPT-4o mini · openai', '[["gpt-4o-mini"]]'], + ['claude', 'Claude 3.5 · anthropic', null], + ['claude', 'local-llm', '[["local-llm"]]'], + ['ghost', 'GPT-4o mini · openai', '[["gpt-4o-mini"]]'], + ['ghost', 'Claude 3.5 · anthropic', '[["claude-3-5-sonnet"]]'], + ['ghost', 'local-llm', '[["local-llm"]]'], +]; + +describe('ChatbotEnhanced — the model picker is the shared Select (objectui#11865)', () => { + it('is the primitive, keeps its name, shows the model in force, and lists the native options in order', async () => { + const { trigger, view } = mount(SELECTIONS.claude); + expect(trigger().tagName).toBe('BUTTON'); + // Radix mirrors the value into a hidden native select inside a form; none is visible. + expect(view.container.querySelectorAll('select:not([aria-hidden="true"])')).toHaveLength(0); + expect(trigger().textContent).toBe('Claude 3.5 · anthropic'); + const options = await openPicker(trigger()); + expect(options.map((o) => o.textContent)).toEqual(['GPT-4o mini · openai', 'Claude 3.5 · anthropic', 'local-llm']); + }); + + it('with no selection the first model is in force', () => { + expect(mount(SELECTIONS.unset).trigger().textContent).toBe('GPT-4o mini · openai'); + }); + + it('takes its name from the `model` label', () => { + mount(SELECTIONS.unset, { labels: { model: 'Modèle' } }); + expect(screen.getByRole('combobox', { name: 'Modèle' })).toHaveAttribute('aria-label', 'Modèle'); + }); + + it.each(WRITES)('selection "%s", picking "%s" writes what the native control wrote', async (selection, label, json) => { + const { onModelChange, trigger } = mount(SELECTIONS[selection]); + await pick(trigger(), label); + expect(JSON.stringify(onModelChange.mock.calls)).toBe(json ?? '[]'); + }); + + it('a selected model none of the options carries is what the trigger shows, and re-picking it writes nothing', async () => { + const { onModelChange, trigger } = mount(SELECTIONS.ghost); + // The native control showed the first model here. + expect(trigger().textContent).toBe('ghost-model'); + const options = await openPicker(trigger()); + expect(options.map((o) => o.textContent)).toEqual(['ghost-model', 'GPT-4o mini · openai', 'Claude 3.5 · anthropic', 'local-llm']); + fireEvent.click(options[0]); + expect(onModelChange).not.toHaveBeenCalled(); + }); + + it('Enter opens the picker and Enter on a model selects it, without sending the draft', async () => { + const { onModelChange, onSendMessage, trigger } = mount(SELECTIONS.unset); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'a draft' } }); + fireEvent.keyDown(trigger(), { key: 'Enter' }); + const listbox = await screen.findByRole('listbox'); + fireEvent.keyDown(within(listbox).getByRole('option', { name: 'local-llm' }), { key: 'Enter' }); + expect(onModelChange.mock.calls).toEqual([['local-llm']]); + expect(onSendMessage).not.toHaveBeenCalled(); + }); + + it('the read-only transcript has no composer, so no picker', () => { + mount(SELECTIONS.claude, { readOnly: true }); + expect(screen.queryByRole('combobox')).toBeNull(); + }); +}); diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx index 98b94fd299..e33884c9b3 100644 --- a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx @@ -678,7 +678,7 @@ describe('ChatbotEnhanced (AI Elements composition)', () => { expect(detail).toHaveTextContent('Add an options array.'); }); - it('renders a model picker and forwards changes', () => { + it('renders a model picker and forwards changes', async () => { const onModelChange = vi.fn(); render( { onModelChange={onModelChange} /> ); - const picker = screen.getByLabelText(/Model/i) as HTMLSelectElement; - fireEvent.change(picker, { target: { value: 'claude-3-5-sonnet' } }); + // The shared `Select` (objectui#11865), still named by its aria-label. + const picker = screen.getByLabelText(/Model/i); + expect(picker).toHaveAttribute('role', 'combobox'); + fireEvent.keyDown(picker, { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: 'Claude 3.5 · anthropic' })); expect(onModelChange).toHaveBeenCalledWith('claude-3-5-sonnet'); }); diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 565dae2c04..0fadbc0a6e 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -7,7 +7,7 @@ */ import * as React from 'react'; -import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, RefreshIndicator, DataEmptyState, DataErrorState, resolveIcon } from '@object-ui/components'; +import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, RefreshIndicator, DataEmptyState, DataErrorState, resolveIcon, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components'; import type { SortItem } from '@object-ui/components'; import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Inbox, Download, Rows4, Rows3, Rows2, Share2, Printer, Plus, Trash2, CheckSquare, AlertTriangle, ShieldAlert, RotateCw, Loader2, type LucideIcon } from 'lucide-react'; import type { FilterGroup } from '@object-ui/components'; @@ -1148,6 +1148,66 @@ function canReadField( return !perms?.isLoaded || !objectName || perms.checkField(objectName, field, 'read'); } +/** The item a value none of a picker's options carries is shown by. */ +const OUTSIDE_OPTIONS = 'outside'; + +/** + * objectui#11865 — the toolbar's "Color by field" and the record-count bar's + * rows-per-page selector, drawn with the shared `Select`, the control the rest + * of the console picks with. Both used to be browser-native ` { + // `undefined` for the outside item: it is the view's own value, so there is nothing to write. + const picked = options[Number(token)]; + if (picked) onPick(picked.value); + }} + > + + + + + {at === -1 && {value}} + {options.map((o, i) => ( + + {o.label} + + ))} + + + ); +} + /** * Imperative handle exposed by ListView via React.forwardRef. * Allows parent components to trigger a data refresh programmatically. @@ -4983,24 +5043,26 @@ export const ListView = React.forwardRef(({
- + />
@@ -5673,28 +5735,25 @@ export const ListView = React.forwardRef(({ )} {/* Grid view delegates the rows-per-page selector to the DataTable's own server-driven pager (ObjectGrid passes pagination.pageSizeOptions - straight through). Rendering a second native { - const newSize = Number(e.target.value); + options={schema.pagination.pageSizeOptions.map((size: any) => ({ value: String(size), label: String(size) }))} + onPick={(size) => { + const newSize = Number(size); setDynamicPageSize(newSize); if (props.onPageSizeChange) props.onPageSizeChange(newSize); }} - > - {schema.pagination.pageSizeOptions.map((size: any) => ( - - ))} - + /> )} diff --git a/packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx b/packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx index 459e276a3e..8c47d4b9cb 100644 --- a/packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.fieldListRead-11984.test.tsx @@ -188,14 +188,10 @@ function checkboxLabels(scope: HTMLElement): string[] { .map((box) => text(box.closest('label'))); } -function selectLabels(select: HTMLElement): string[] { - return Array.from((select as HTMLSelectElement).options).map(text).filter((label) => label !== 'None'); -} - /** - * The View settings popover's Row color picker is the shared `Select` - * (objectui#11865), not a native select: read the fields its listbox offers, - * leaving out "None" as `selectLabels` does. + * Both Row color pickers, the toolbar's and the View settings popover's, are + * the shared `Select` (objectui#11865), not native selects: read the fields + * the listbox offers, leaving out "None". */ async function pickerLabels(trigger: HTMLElement): Promise { return (await comboboxOptions(trigger)).filter((label) => label !== 'None'); @@ -319,7 +315,7 @@ const POSITIONS: Position[] = [ }, { name: 'the Row color select', - read: () => inPopover(toolbarButton(/^color/i), (c) => selectLabels(within(c).getByTestId('color-field-select'))), + read: () => inPopover(toolbarButton(/^color/i), (c) => pickerLabels(within(c).getByTestId('color-field-select'))), }, { name: 'the user-filter chips, derived from the definition', @@ -477,15 +473,42 @@ describe('a stored configuration on an unreadable field is kept as stored (objec mount(RESTRICTED, { rowColor: { field: 'secret_note', colors: {} } }); await definitionLoaded(); const content = await openPopover(toolbarButton(/^color/i)); - expect(within(content).getByTestId('color-field-select')).toHaveValue(''); + const picker = within(content).getByTestId('color-field-select'); + expect(picker).toHaveTextContent(/^None$/); expect(within(content).queryByTestId('clear-row-color')).toBeNull(); + // Nor does the picker offer it, as its own field or as the rule's. + expect(await comboboxOptions(picker)).toEqual(['None', 'Title', 'Status', 'Priority']); cleanup(); // Control: with full read the select shows the rule and offers Clear. mount(FULL_READ, { rowColor: { field: 'secret_note', colors: {} } }); await definitionLoaded(); const full = await openPopover(toolbarButton(/^color/i)); - expect(within(full).getByTestId('color-field-select')).toHaveValue('secret_note'); + const fullPicker = within(full).getByTestId('color-field-select'); + expect(fullPicker).toHaveTextContent(/^Secret Note$/); expect(within(full).getByTestId('clear-row-color')).toBeInTheDocument(); + expect(await comboboxOptions(fullPicker)).toEqual(['None', ...FIELD_LABELS]); + }); + + it('a row-color rule on it outside the columns gets no item of its own: the picker never names it', async () => { + // The picker shows a field none of its options carries as an item of its + // own (objectui#11865). It is handed the OFFERED rule, so a rule on a field + // the caller may not read never reaches that item. + const columns = COLUMNS.filter((c) => c.field !== 'secret_note'); + mount(RESTRICTED, { columns, rowColor: { field: 'secret_note', colors: {} } }); + await definitionLoaded(); + const content = await openPopover(toolbarButton(/^color/i)); + const picker = within(content).getByTestId('color-field-select'); + expect(picker).toHaveTextContent(/^None$/); + expect(within(content).queryByTestId('clear-row-color')).toBeNull(); + expect(await comboboxOptions(picker)).toEqual(['None', 'Title', 'Status', 'Priority']); + cleanup(); + // Control: with full read the same rule is the picker's own outside item. + mount(FULL_READ, { columns, rowColor: { field: 'secret_note', colors: {} } }); + await definitionLoaded(); + const full = await openPopover(toolbarButton(/^color/i)); + const fullPicker = within(full).getByTestId('color-field-select'); + expect(fullPicker).toHaveTextContent(/^secret_note$/); + expect(await comboboxOptions(fullPicker)).toEqual(['secret_note', 'None', 'Title', 'Status', 'Priority', 'Secret Status']); }); it('a user-filter chip whose field a restored selection names stays until that selection is cleared', async () => { diff --git a/packages/plugin-list/src/__tests__/ListView.sharedSelect-11865.test.tsx b/packages/plugin-list/src/__tests__/ListView.sharedSelect-11865.test.tsx new file mode 100644 index 0000000000..a0aae723b7 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.sharedSelect-11865.test.tsx @@ -0,0 +1,295 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * ListView's "Color by field" and its rows-per-page fallback selector pick + * with the shared `Select` (objectui#11865). + * + * Both were browser-native selects beside the shared Radix `Select` the rest + * of the console picks with. The card asks for one control for one kind of + * choice, surface by surface. + * + * What is pinned, for each: + * - it IS the primitive (a Radix combobox trigger, still found by its test + * id), shows the value in force and lists the native control's options in + * their order; + * - every option writes what the native control wrote, compared as JSON + * text: the row-colour config the view hands its grid, over four configs + * (none, a field with colours, a field with no colours, a field none of + * the options carries); the page size handed to `onPageSizeChange` and + * fetched as `$top`, over three paginations. "None" (the option whose + * value is `''`) clears the config as before. Re-picking the current + * option writes nothing; + * - a value none of the options carries is what the trigger shows; + * - the keyboard alone opens the picker and selects. + * + * "Color by field" is the twin of `ViewSettingsPopover`'s `ColorFieldPicker` + * (its own pin is `ViewSettingsPopover.sharedSelect-11865.test.tsx`): the two + * draw an outside value and "None" the same way. That a rule on a field the + * caller may not read never reaches the outside item is pinned with the + * field-read pin, `ListView.fieldListRead-11984.test.tsx`. + * + * Neither picker has a name: neither native control had one (the colour + * label has no `htmlFor`, the "Rows per page" text is a `span`, and neither + * had an `aria-label`), so there is no name to keep. Neither surface has a + * read-only state. + * + * DIRECTION, observed against the native controls: every pin here reads the + * control as the primitive's trigger, so each is red there. What makes the + * write rows guards of "the conversion changed nothing the view writes" is + * the literal each compares against: a `change` event on the pre-conversion + * native control wrote that same JSON, read once on this component with these + * fixtures. That probe's `change` event fired for the current option too, + * which a browser's native select does not do, so the re-pick rows pin the + * primitive. + */ + +import '@testing-library/jest-dom/vitest'; +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, within, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; +import { ListView } from '../ListView'; + +afterEach(() => cleanup()); + +/** The row-colour config the view last handed its grid. */ +let gridRowColor: unknown; +ComponentRegistry.register( + 'object-grid', + (props: { schema?: { rowColor?: unknown } }) => { + gridRowColor = props.schema?.rowColor; + return
; + }, + { namespace: 'test', label: 'Grid spy', category: 'view' }, +); + +const OBJECT = 'pin_ticket'; +const FIELDS: Record> = { + title: { type: 'text', label: 'Title' }, + status: { type: 'select', label: 'Status', options: [{ label: 'Open', value: 'open' }] }, + priority: { type: 'number', label: 'Priority' }, +}; +const COLUMNS = Object.entries(FIELDS).map(([field, def]) => ({ field, label: def.label as string })); + +function makeDataSource() { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', title: 'a', name: 'Alice' }], total: 1 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: OBJECT, fields: FIELDS }), + }; +} + +function mount(schemaExtra: Record, props: Record = {}) { + const dataSource = makeDataSource(); + const schema = { type: 'list-view', objectName: OBJECT, ...schemaExtra } as unknown as ListViewSchema; + render( + + + , + ); + return dataSource; +} + +async function openPicker(trigger: HTMLElement): Promise { + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + const listbox = await screen.findByRole('listbox'); + return within(listbox).getAllByRole('option'); +} + +async function pick(trigger: HTMLElement, label: string): Promise { + const options = await openPicker(trigger); + const option = options.find((o) => o.textContent === label); + if (!option) throw new Error(`the picker lists no "${label}": ${options.map((o) => o.textContent).join(' | ')}`); + fireEvent.click(option); +} + +// --------------------------------------------------------------------------- +// "Color by field" +// --------------------------------------------------------------------------- + +type RowColor = { field: string; colors?: Record } | undefined; + +const CONFIGS: Record = { + none: undefined, + status: { field: 'status', colors: { open: 'red' } }, + priority: { field: 'priority' }, + ghost: { field: 'ghost', colors: { x: 'blue' } }, +}; + +/** Mount a grid list holding `config`, open the Color popover, return its picker. */ +async function colorPicker(config: RowColor): Promise { + mount({ viewType: 'grid', columns: COLUMNS, userActions: { rowColor: true }, ...(config ? { rowColor: config } : {}) }); + await screen.findByTestId('grid-spy'); + fireEvent.click(screen.getByRole('button', { name: /^color/i })); + return screen.findByTestId('color-field-select'); +} + +/** + * [config, option label, what the native control wrote]. `null` marks the + * current option: re-picking it writes nothing. `UNDEFINED` is the config + * cleared. + */ +const COLOR_WRITES: ReadonlyArray = [ + ['none', 'None', null], + ['none', 'Title', '{"field":"title","colors":{}}'], + ['none', 'Status', '{"field":"status","colors":{}}'], + ['none', 'Priority', '{"field":"priority","colors":{}}'], + ['status', 'None', 'UNDEFINED'], + ['status', 'Title', '{"field":"title","colors":{"open":"red"}}'], + ['status', 'Status', null], + ['status', 'Priority', '{"field":"priority","colors":{"open":"red"}}'], + ['priority', 'None', 'UNDEFINED'], + ['priority', 'Title', '{"field":"title","colors":{}}'], + ['priority', 'Status', '{"field":"status","colors":{}}'], + ['priority', 'Priority', null], + ['ghost', 'None', 'UNDEFINED'], + ['ghost', 'Title', '{"field":"title","colors":{"x":"blue"}}'], + ['ghost', 'Status', '{"field":"status","colors":{"x":"blue"}}'], + ['ghost', 'Priority', '{"field":"priority","colors":{"x":"blue"}}'], +]; + +describe('ListView — "Color by field" is the shared Select (objectui#11865)', () => { + it('is the primitive, shows the configured field, and lists the native options in order', async () => { + const trigger = await colorPicker(CONFIGS.status); + expect(trigger.tagName).toBe('BUTTON'); + expect(trigger).toHaveAttribute('role', 'combobox'); + expect(trigger.textContent).toBe('Status'); + const options = await openPicker(trigger); + expect(options.map((o) => o.textContent)).toEqual(['None', 'Title', 'Status', 'Priority']); + }); + + it.each([ + ['none', 'None'], + ['priority', 'Priority'], + ] as const)('with config "%s" the trigger shows "%s"', async (config, shown) => { + expect((await colorPicker(CONFIGS[config])).textContent).toBe(shown); + }); + + it.each(COLOR_WRITES)('config "%s", picking "%s" writes what the native control wrote', async (config, label, json) => { + const trigger = await colorPicker(CONFIGS[config]); + const before = gridRowColor; + await pick(trigger, label); + if (json === null) { + // Nothing was written: the grid still holds the very config it mounted with. + expect(gridRowColor).toBe(before); + } else { + expect(gridRowColor === undefined ? 'UNDEFINED' : JSON.stringify(gridRowColor)).toBe(json); + } + }); + + it('a field none of the options carries is what the trigger shows, and re-picking it writes nothing', async () => { + const trigger = await colorPicker(CONFIGS.ghost); + // The native control showed "None" here. + expect(trigger.textContent).toBe('ghost'); + const before = gridRowColor; + const options = await openPicker(trigger); + expect(options.map((o) => o.textContent)).toEqual(['ghost', 'None', 'Title', 'Status', 'Priority']); + fireEvent.click(options[0]); + expect(gridRowColor).toBe(before); + }); + + it('Enter opens the picker and Enter on a field selects it', async () => { + const trigger = await colorPicker(CONFIGS.none); + fireEvent.keyDown(trigger, { key: 'Enter' }); + const listbox = await screen.findByRole('listbox'); + fireEvent.keyDown(within(listbox).getByRole('option', { name: 'Status' }), { key: 'Enter' }); + expect(JSON.stringify(gridRowColor)).toBe('{"field":"status","colors":{}}'); + }); +}); + +// --------------------------------------------------------------------------- +// The rows-per-page fallback selector +// --------------------------------------------------------------------------- + +const PAGINATIONS: Record> = { + declared25: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] }, + // No declared size: an unpaged view fetches its default batch (100), which + // is not one of the options. + undeclared: { pageSizeOptions: [10, 25, 50] }, + declared7: { pageSize: 7, pageSizeOptions: [10, 25] }, +}; + +/** Mount a gallery list (no DataTable pager) with `pagination`, return its selector. */ +async function sizePicker(pagination: Record) { + const onPageSizeChange = vi.fn(); + const dataSource = mount({ viewType: 'gallery', fields: ['name'], pagination }, { onPageSizeChange }); + const trigger = await screen.findByTestId('page-size-selector'); + return { trigger, onPageSizeChange, dataSource }; +} + +const lastTop = (dataSource: ReturnType) => dataSource.find.mock.calls.at(-1)?.[1]?.$top; + +/** + * [pagination, option label, what the native control handed `onPageSizeChange`]. + * `null` marks the size in force: re-picking it writes nothing. + */ +const SIZE_WRITES: ReadonlyArray = [ + ['declared25', '10', '[[10]]'], + ['declared25', '25', null], + ['declared25', '50', '[[50]]'], + ['declared25', '100', '[[100]]'], + ['undeclared', '10', '[[10]]'], + ['undeclared', '25', '[[25]]'], + ['undeclared', '50', '[[50]]'], + ['declared7', '10', '[[10]]'], + ['declared7', '25', '[[25]]'], +]; + +describe('ListView — the rows-per-page selector is the shared Select (objectui#11865)', () => { + it('is the primitive, shows the size in force, and lists the native options in order', async () => { + const { trigger } = await sizePicker(PAGINATIONS.declared25); + expect(trigger.tagName).toBe('BUTTON'); + expect(trigger).toHaveAttribute('role', 'combobox'); + expect(trigger.textContent).toBe('25'); + const options = await openPicker(trigger); + expect(options.map((o) => o.textContent)).toEqual(['10', '25', '50', '100']); + }); + + it.each(SIZE_WRITES)('pagination "%s", picking "%s" writes what the native control wrote', async (pagination, label, json) => { + const { trigger, onPageSizeChange, dataSource } = await sizePicker(PAGINATIONS[pagination]); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + const fetches = dataSource.find.mock.calls.length; + await pick(trigger, label); + if (json === null) { + expect(onPageSizeChange).not.toHaveBeenCalled(); + expect(dataSource.find.mock.calls.length).toBe(fetches); + } else { + expect(JSON.stringify(onPageSizeChange.mock.calls)).toBe(json); + // The list refetches at the picked size, as before. + await waitFor(() => expect(lastTop(dataSource)).toBe(Number(label))); + } + }); + + it.each([ + ['undeclared', '100', ['100', '10', '25', '50']], + ['declared7', '7', ['7', '10', '25']], + ] as const)('pagination "%s": the size in force, %s, is what the trigger shows, and re-picking it writes nothing', async (pagination, inForce, listed) => { + const { trigger, onPageSizeChange, dataSource } = await sizePicker(PAGINATIONS[pagination]); + // The native control showed its first option, "10", here. + expect(trigger.textContent).toBe(inForce); + await waitFor(() => expect(lastTop(dataSource)).toBe(Number(inForce))); + const options = await openPicker(trigger); + expect(options.map((o) => o.textContent)).toEqual(listed); + fireEvent.click(options[0]); + expect(onPageSizeChange).not.toHaveBeenCalled(); + }); + + it('Enter opens the selector and Enter on a size selects it', async () => { + const { trigger, onPageSizeChange } = await sizePicker(PAGINATIONS.declared25); + fireEvent.keyDown(trigger, { key: 'Enter' }); + const listbox = await screen.findByRole('listbox'); + fireEvent.keyDown(within(listbox).getByRole('option', { name: '50' }), { key: 'Enter' }); + expect(onPageSizeChange.mock.calls).toEqual([[50]]); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListView.test.tsx b/packages/plugin-list/src/__tests__/ListView.test.tsx index d6801fd028..ba26acdd0d 100644 --- a/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; import { ListView, evaluateConditionalFormatting } from '../ListView'; import type { DataSource, ListViewSchema } from '@object-ui/types'; import { SchemaRendererProvider } from '@object-ui/react'; @@ -1883,9 +1883,10 @@ describe('ListView', () => { // NOTE: For the GRID view the rows-per-page selector now lives in the // DataTable's own server-driven pager (ObjectGrid forwards // pagination.pageSizeOptions straight through), so ListView no longer renders - // its native