From 49c49b3f4dcd6926011cb216d9351f8c54903875 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:17:47 +0000 Subject: [PATCH 1/4] feat(plugin-list, plugin-chatbot): the list view and the chatbot pick with the shared Select (objectui#11865, slice p) ListView's "Color by field" and its rows-per-page fallback selector, and ChatbotEnhanced's model picker, were browser-native select elements. Each is now the shared Select through a module-private picker: items carry option indexes, so the "None" option (value '') needs no empty SelectItem, and a value outside the options shows as itself. Each pick writes what the native control wrote. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .../plugin-chatbot/src/ChatbotEnhanced.tsx | 76 ++++++++++-- packages/plugin-list/src/ListView.tsx | 111 ++++++++++++++---- 2 files changed, 148 insertions(+), 39 deletions(-) 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-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) => ( - - ))} - + /> )} From 16f8fa0c5ef5c5107fbd59809646a10720401f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:21:38 +0000 Subject: [PATCH 2/4] test(plugin-list, plugin-chatbot): pin the three shared pickers and move objectui#11984's readers (objectui#11865, slice p) New pins for ListView's "Color by field" and rows-per-page selector and for ChatbotEnhanced's model picker: each is the primitive, writes what the native control wrote (literals read on base), shows an outside value as itself, and opens and selects from the keyboard. objectui#11984's field-read pin read ListView's own "Color by field" through native-select readers. The wide Row color position now reads the listbox, and the stored-config rows read the trigger and the listbox; a new row pins that a rule on an unreadable field outside the columns never becomes the picker's outside item. The existing page-size and model-picker tests read the shared Select. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- ...ChatbotEnhanced.modelPicker-11865.test.tsx | 155 +++++++++ .../src/__tests__/ChatbotEnhanced.test.tsx | 9 +- .../ListView.fieldListRead-11984.test.tsx | 43 ++- .../ListView.sharedSelect-11865.test.tsx | 295 ++++++++++++++++++ .../src/__tests__/ListView.test.tsx | 28 +- 5 files changed, 504 insertions(+), 26 deletions(-) create mode 100644 packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.modelPicker-11865.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.sharedSelect-11865.test.tsx 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..562993c7d9 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.modelPicker-11865.test.tsx @@ -0,0 +1,155 @@ +/** + * 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 + * read-only row (green there by design: it keeps a behaviour). 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/__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..32cc4e6606 --- /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: Record) => { + 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