From c99f17640d7f5e474fd66345cbc9569c5fb20981 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:19:00 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(plugin-list):=20unfinished=20filter=20r?= =?UTF-8?q?ows=20are=20not=20filters,=20date=20sorts=20read=20Oldest=20?= =?UTF-8?q?=E2=86=92=20Newest,=20one=20sort=20row=20per=20field,=20and=20a?= =?UTF-8?q?n=20empty=20list=20says=20how=20to=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A filter-panel row with no value is not counted on the badge, does not make the list "No matching records", and is not handed to the host's onFilterChange (so it is not stored as the user's list state). - SortBuilder reads an optional field type: date/datetime rows name the direction "Oldest → Newest"; a field another row holds is unavailable, "Add sort" seeds the first free field and disables when none is left. - The search-emptied empty state offers "Clear search". - The empty state offers Create whenever the host gives a create path and the user may create; an explicit addRecord.enabled: false still withholds it, and object-view hands no create path when Create is turned off. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVW2q9rsHS1vCrQ8Q9 --- .../27520-list-toolbar-filter-sort-empty.md | 17 ++ ...r-date-direction-duplicates-27520.test.tsx | 141 ++++++++++++ .../components/src/custom/sort-builder.tsx | 54 ++++- ui/packages/i18n/src/locales/ar.ts | 3 + ui/packages/i18n/src/locales/de.ts | 3 + ui/packages/i18n/src/locales/en.ts | 3 + ui/packages/i18n/src/locales/es.ts | 3 + ui/packages/i18n/src/locales/fr.ts | 3 + ui/packages/i18n/src/locales/ja.ts | 3 + ui/packages/i18n/src/locales/ko.ts | 3 + ui/packages/i18n/src/locales/pt.ts | 3 + ui/packages/i18n/src/locales/ru.ts | 3 + ui/packages/i18n/src/locales/zh.ts | 3 + .../plugin-list/src/ListEmptyState.tsx | 31 ++- ui/packages/plugin-list/src/ListView.tsx | 73 +++++- .../ListView.toolbarRows-27520.test.tsx | 208 ++++++++++++++++++ ui/packages/plugin-view/src/ObjectView.tsx | 6 +- 17 files changed, 539 insertions(+), 21 deletions(-) create mode 100644 .changeset/27520-list-toolbar-filter-sort-empty.md create mode 100644 ui/packages/components/src/__tests__/sort-builder-date-direction-duplicates-27520.test.tsx create mode 100644 ui/packages/plugin-list/src/__tests__/ListView.toolbarRows-27520.test.tsx diff --git a/.changeset/27520-list-toolbar-filter-sort-empty.md b/.changeset/27520-list-toolbar-filter-sort-empty.md new file mode 100644 index 00000000000..025d977c5af --- /dev/null +++ b/.changeset/27520-list-toolbar-filter-sort-empty.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/components': patch +'@object-ui/plugin-view': patch +'@object-ui/i18n': patch +--- + +fix(plugin-list): a filter row with no value is not a filter, a date sort reads Oldest → Newest, a sort keeps one row per field, and an empty list says how to start + +Clause-②: yes (widening: `@object-ui/i18n` gains `list.clearSearch`, `sortBuilder.oldestFirst` and `sortBuilder.newestFirst` in all ten locales, and `SortBuilder`'s `fields` entries take an optional `type`; no accept set moves) +Metadata-protocol: none + +- **Unfinished filter rows.** A filter-panel row with no value yet is no longer counted on the Filter badge, no longer turns the list into "No matching records", and is no longer handed to the host's `onFilterChange`, so the console does not store it as the user's list state and it does not come back after a reload. The panel still shows the row so it can be finished. +- **Date sort direction.** The sort picker names a `date` or `datetime` field's direction "Oldest → Newest" / "Newest → Oldest" instead of "A → Z" / "Z → A". The list hands the picker each field's type. +- **One sort row per field.** A field another sort row already orders by is unavailable in the other rows, "Add sort" starts on the first field no row holds, and it is disabled once every field has a row. +- **Clear search.** The "No matching records" a search produced offers "Clear search". The clear button inside the search box is now named "Clear search" too. +- **Create on an empty list.** An empty list offers Create whenever the host gives a create path and the user may create, without the view having to declare an inline `addRecord` block. An author's `addRecord: { enabled: false }` or `userActions.addRecordForm: false` still withholds it, and the `object-view` renderer no longer hands its list a create path when the author turned Create off (`showCreate: false` or `operations.create: false`). diff --git a/ui/packages/components/src/__tests__/sort-builder-date-direction-duplicates-27520.test.tsx b/ui/packages/components/src/__tests__/sort-builder-date-direction-duplicates-27520.test.tsx new file mode 100644 index 00000000000..44c34091c9d --- /dev/null +++ b/ui/packages/components/src/__tests__/sort-builder-date-direction-duplicates-27520.test.tsx @@ -0,0 +1,141 @@ +/** + * 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. + */ + +/** + * objectstack#27520 row 7, the sort half: `SortBuilder` names a date's + * direction as time, and keeps one row per field. + * + * Observed in the RC walkthrough on the Tasks list toolbar: the direction of a + * datetime sort read "A → Z", and the same field could be added to the sort + * twice. The second key on a field the first already orders by changes + * nothing, so a duplicate row is a control that does nothing. + * + * Every case reads the real `SortBuilder` and its real Radix `Select`, and + * keeps its control beside it: a text field still reads "A → Z", and a field + * no other row holds is still an ordinary option. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { cleanup, render, screen, fireEvent, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SortBuilder, type SortBuilderProps, type SortItem } from '../custom/sort-builder'; + +type Fields = NonNullable; +type Row = Pick; + +const FIELDS: Fields = [ + { value: 'title', label: 'Title', type: 'text' }, + { value: 'due_date', label: 'Due Date', type: 'date' }, + { value: 'created_at', label: 'Created At', type: 'datetime' }, +]; + +/** Holds the value the way a host does, so a change is rendered back. */ +function Harness({ fields, initial, onChange }: { fields: Fields; initial: Row[]; onChange: (rows: Row[]) => void }) { + const [value, setValue] = React.useState(() => + initial.map((row, i) => ({ id: `r${i}`, ...row })), + ); + return ( + { + onChange(next.map(({ field, order }) => ({ field, order }))); + setValue(next); + }} + /> + ); +} + +function mount(initial: Row[], fields: Fields = FIELDS) { + const onChange = vi.fn(); + render(); + return onChange; +} + +/** Each row is `[field select, direction select]`. */ +const fieldTriggers = () => screen.getAllByRole('combobox').filter((_, i) => i % 2 === 0); +const directionTriggers = () => screen.getAllByRole('combobox').filter((_, i) => i % 2 === 1); + +/** Open a dropdown and return what it offers, closing it again. */ +function optionsOf(trigger: HTMLElement): Array<{ label: string; disabled: boolean }> { + fireEvent.click(trigger); + const listbox = screen.getByRole('listbox'); + const options = within(listbox).getAllByRole('option').map((o) => ({ + label: o.textContent?.trim() ?? '', + disabled: o.getAttribute('aria-disabled') === 'true', + })); + fireEvent.keyDown(listbox, { key: 'Escape' }); + return options; +} + +const asc = (field: string): Row => ({ field, order: 'asc' }); + +afterEach(() => { + cleanup(); +}); + +describe('SortBuilder names a date sort as time (objectstack#27520 row 7)', () => { + it('a date row reads "Oldest → Newest" / "Newest → Oldest"; a datetime row too', () => { + mount([asc('due_date'), { field: 'created_at', order: 'desc' }]); + const [dateDir, datetimeDir] = directionTriggers(); + expect(dateDir).toHaveTextContent('Oldest → Newest'); + expect(datetimeDir).toHaveTextContent('Newest → Oldest'); + expect(optionsOf(dateDir).map((o) => o.label)).toEqual(['Oldest → Newest', 'Newest → Oldest']); + }); + + it('CONTROL: a text row keeps "A → Z" / "Z → A"', () => { + mount([asc('title')]); + expect(directionTriggers()[0]).toHaveTextContent('A → Z'); + expect(optionsOf(directionTriggers()[0]).map((o) => o.label)).toEqual(['A → Z', 'Z → A']); + }); + + it('the label follows the row when its field changes from text to date', () => { + mount([asc('title')]); + fireEvent.click(fieldTriggers()[0]); + fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: 'Due Date' })); + expect(directionTriggers()[0]).toHaveTextContent('Oldest → Newest'); + }); + + it('CONTROL: a field entry with no type keeps "A → Z"', () => { + mount([asc('due_date')], FIELDS.map(({ value, label }) => ({ value, label }))); + expect(directionTriggers()[0]).toHaveTextContent('A → Z'); + }); +}); + +describe('SortBuilder keeps one row per field (objectstack#27520 row 7)', () => { + it('a field another row sorts by is unavailable in this row; the row\'s own field and the free ones are not', () => { + mount([asc('title'), asc('due_date')]); + expect(optionsOf(fieldTriggers()[1])).toEqual([ + { label: 'Title', disabled: true }, + { label: 'Due Date', disabled: false }, + { label: 'Created At', disabled: false }, + ]); + // CONTROL: the first row sees the second row's field the same way. + expect(optionsOf(fieldTriggers()[0])).toEqual([ + { label: 'Title', disabled: false }, + { label: 'Due Date', disabled: true }, + { label: 'Created At', disabled: false }, + ]); + }); + + it('"Add sort" seeds the first field no row holds, not the first field', () => { + const onChange = mount([asc('title')]); + fireEvent.click(screen.getByRole('button', { name: /add sort/i })); + expect(onChange).toHaveBeenLastCalledWith([asc('title'), asc('due_date')]); + }); + + it('"Add sort" is disabled once every field has a row', () => { + mount([asc('title'), asc('due_date'), asc('created_at')]); + expect(screen.getByRole('button', { name: /add sort/i })).toBeDisabled(); + }); + + it('CONTROL: with a field still free, "Add sort" stays enabled', () => { + mount([asc('title'), asc('due_date')]); + expect(screen.getByRole('button', { name: /add sort/i })).toBeEnabled(); + }); +}); diff --git a/ui/packages/components/src/custom/sort-builder.tsx b/ui/packages/components/src/custom/sort-builder.tsx index 4d602d5beba..86c34e2fab9 100644 --- a/ui/packages/components/src/custom/sort-builder.tsx +++ b/ui/packages/components/src/custom/sort-builder.tsx @@ -22,12 +22,17 @@ const useSafeSortTranslation = createSafeTranslation( 'sortBuilder.selectField': 'Select field', 'sortBuilder.ascending': 'A → Z', 'sortBuilder.descending': 'Z → A', + 'sortBuilder.oldestFirst': 'Oldest → Newest', + 'sortBuilder.newestFirst': 'Newest → Oldest', 'sortBuilder.addSort': 'Add sort', 'sortBuilder.removeSort': 'Remove sort', }, 'sortBuilder.sortBy', ) +/** Field types whose order is time: their direction reads "Oldest → Newest". */ +const DATE_SORT_TYPES = new Set(['date', 'datetime']) + /** * One row of the SortBuilder UI: the spec's `SortItem` (`{ field, order }`) * plus the React key this list needs. @@ -56,6 +61,12 @@ export interface SortBuilderProps { * disabled, and is itself disabled when every entry is. */ disabled?: boolean + /** + * The field's type (objectstack#27520). A `date` or `datetime` row names + * its direction as time ("Oldest → Newest") instead of "A → Z". Absent, + * the row reads "A → Z" as before. + */ + type?: string }>; value?: SortItem[]; onChange?: (value: SortItem[]) => void; @@ -97,9 +108,27 @@ export function SortBuilder({ onChange?.(newItems); }; + // One row per field (objectstack#27520): a second key on a field the sort + // already orders by changes nothing, so a field another row holds is not + // offered to this one. A row's own field stays choosable in its dropdown. + const heldBy = new Map(); + for (const item of items) if (item.field && !heldBy.has(item.field)) heldBy.set(item.field, item.id); + const takenElsewhere = (field: string, rowId: string) => { + const holder = heldBy.get(field); + return holder !== undefined && holder !== rowId; + }; + // A new row starts on the first field the user may choose: a disabled - // entry is only there for the row that already names it. - const firstChoosable = fields.find((f) => !f.disabled); + // entry is only there for the row that already names it, and a field a row + // already sorts by is not offered again. + const firstChoosable = fields.find((f) => !f.disabled && !heldBy.has(f.value)); + + // A date's direction is time, not the alphabet (objectstack#27520). + const typeOf = new Map(fields.map((f) => [f.value, f.type])); + const directionLabels = (field: string) => + DATE_SORT_TYPES.has(typeOf.get(field) ?? '') + ? { asc: t('sortBuilder.oldestFirst'), desc: t('sortBuilder.newestFirst') } + : { asc: t('sortBuilder.ascending'), desc: t('sortBuilder.descending') }; const addItem = () => { const newItem: SortItem = { @@ -121,7 +150,9 @@ export function SortBuilder({ return (
- {items.map((item, index) => ( + {items.map((item, index) => { + const direction = directionLabels(item.field); + return (
{index === 0 ? t('sortBuilder.sortBy') : t('sortBuilder.thenBy')} @@ -136,12 +167,18 @@ export function SortBuilder({ {fields.map(f => ( - {f.label} + + {f.label} + ))}
-
+
@@ -165,7 +202,8 @@ export function SortBuilder({
- ))} + ); + })}
) : undefined; + const clearSearchAction = onClearSearch ? ( + + ) : undefined; + // One action draws bare, as before; two or more (or the authored link, + // which always sat in this row) share one wrapping row. + const grouped = !!link || (!!clearSearchAction && !!addRecordAction); return ( + {clearSearchAction} {addRecordAction} - + {link && }
- ) : addRecordAction} + ) : (clearSearchAction ?? addRecordAction)} /> ); } diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 576ae421a2e..d02e87e50dd 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -893,6 +893,36 @@ export function objectHasOwnershipQueue(objectDef: unknown): boolean { const WITHHELD_FILTER_GROUP: FilterBuilderRowGroup = { id: 'root', logic: 'and', conditions: [] }; const WITHHELD_USER_FILTER_CONDITIONS: unknown[] = []; +/** + * Whether a filter-panel row is FINISHED: it applies to the query. The same + * test `convertFilterGroupToAST` keeps a row by, so the badge, the stored list + * state and the query never disagree about what is filtered. + */ +function isAppliedFilterRow(row: { operator: string; value?: unknown }): boolean { + return isValuelessFilterOperator(row.operator) || isFilterValueComplete(row.operator, row.value as never); +} + +/** + * The filter-panel group with its unfinished rows left out (objectstack#27520 + * row 7): a row with no value yet is not a filter. It is not counted on the + * Filter badge, it does not make the list a "no matching records" list, and it + * is not what the host stores as the user's list state, so it does not come + * back after a reload. The panel itself keeps the whole group, so the user can + * still finish the row. + * + * A record-mode amount's currency companion goes with its amount row: kept + * while the amount row is kept, never left behind as a filter of its own. + * + * Returns `group` itself when every row is finished, so an emit with nothing + * to drop hands the host the identity it had before. + */ +function appliedFilterGroup(group: FilterBuilderRowGroup): FilterBuilderRowGroup { + const rows = group.conditions ?? []; + const finished = rows.filter(isAppliedFilterRow); + const kept = finished.filter((c) => !isCurrencyCompanion(c, group) || isCurrencyCompanion(c, { ...group, conditions: finished })); + return kept.length === rows.length ? group : { ...group, conditions: kept }; +} + export function convertFilterGroupToAST(group: FilterBuilderRowGroup): any[] { if (!group || !group.conditions || group.conditions.length === 0) return []; @@ -1026,6 +1056,7 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.firstRunMessage': 'Create your first record to get started.', 'list.noMatches': 'No matching records', 'list.noMatchesMessage': 'No records match your current filters or search. Try adjusting or clearing them.', + 'list.clearSearch': 'Clear search', // Emptied by the VIEW's own declared filter, with no user filter or search // applied (objectui#11687): the "your filters" wording above would name // filters the user never set. @@ -1804,6 +1835,9 @@ export const ListView = React.forwardRef(({ // slots. `position: 'both'` renders both buttons — it used to collapse // to `top` through a binary ternary (#2941). showAddRecord: addRecordEnabled, + // objectstack#27520 row 17 — the empty-state Create, read apart from + // the inline add-record slots: withheld only by an explicit opt-out. + emptyStateAddRecord: schema.addRecord?.enabled !== false && ua?.addRecordForm !== false, showAddRecordTop: addRecordEnabled && addRecordPlacement.top, showAddRecordBottom: addRecordEnabled && addRecordPlacement.bottom, }; @@ -3834,6 +3868,9 @@ export const ListView = React.forwardRef(({ onSearchChange?.(value); }, [onSearchChange]); + // objectstack#27520 row 17 — see `toolbarFlags.emptyStateAddRecord`. + const emptyStateOffersCreate = toolbarFlags.emptyStateAddRecord && !!props.onAddRecord; + // --- NavigationConfig support --- // No assertion, deliberately. `schema.navigation` is the spec-derived // `ListViewSchema['navigation']` and the hook's `NavigationConfig` is now the @@ -4730,10 +4767,12 @@ export const ListView = React.forwardRef(({ // otherwise `editable` keeps the pre-verdict answer for the session. }, [currentView, schema, authoredFilter, currentSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, hostRoutesRowClicks, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm, gridOwnsGroupedFetch, calendarWindowed, handleCalendarVisibleRange, kanbanLanesPaged]); - const hasFilters = currentFilters.conditions && currentFilters.conditions.length > 0; // The count the toolbar shows: a record-mode amount's code condition is - // part of its amount row (objectstack#27188), not a filter of its own. - const shownFilterCount = (currentFilters.conditions ?? []).filter((c) => !isCurrencyCompanion(c, currentFilters)).length; + // part of its amount row (objectstack#27188), not a filter of its own, and a + // row with no value yet is not a filter at all (objectstack#27520). + const appliedPanelFilters = React.useMemo(() => appliedFilterGroup(currentFilters), [currentFilters]); + const shownFilterCount = (appliedPanelFilters.conditions ?? []).filter((c) => !isCurrencyCompanion(c, appliedPanelFilters)).length; + const hasFilters = shownFilterCount > 0; /** * Every field this view can name, before any per-builder rule narrows it. @@ -4989,7 +5028,7 @@ export const ListView = React.forwardRef(({ // URL sort; the picker never offers one), so the list IS sorted by the // stored ID: said in one line under the rows, only while it is true. let picked = false; - const fields: Array<{ value: string; label: string; disabled?: boolean }> = []; + const fields: Array<{ value: string; label: string; disabled?: boolean; type?: string }> = []; for (const field of candidateFields) { const readable = canReadField(perms, schema.objectName, field.value); if (!readable && !inUse.has(field.value)) continue; @@ -4997,8 +5036,10 @@ export const ListView = React.forwardRef(({ const platformSortable = platformSortability ? isPlatformSortableField(platformSortability, field.value) : !UNMATERIALIZED_FIELD_TYPES.has(field.type); + // `type` lets the picker name a date's direction as time + // (objectstack#27520): "Oldest → Newest", not "A → Z". if (readable && !relational && platformSortable) { - fields.push({ value: field.value, label: field.label }); + fields.push({ value: field.value, label: field.label, type: field.type }); continue; } if (inUse.has(field.value)) { @@ -5831,8 +5872,10 @@ export const ListView = React.forwardRef(({ value={currentFilters} extraOperators={LIST_VIEW_EXTRA_OPERATORS} onChange={(newFilters) => { + // The panel keeps every row; the host stores only the + // filter that applies (objectstack#27520). setCurrentFilters(newFilters); - if (onFilterChange) onFilterChange(newFilters); + if (onFilterChange) onFilterChange(appliedFilterGroup(newFilters)); }} /> @@ -6356,6 +6399,7 @@ export const ListView = React.forwardRef(({ variant="ghost" size="sm" className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20" + aria-label={t('list.clearSearch')} onClick={() => handleSearchChange('')} > @@ -6539,9 +6583,22 @@ export const ListView = React.forwardRef(({ !!(searchTerm && searchTerm.trim()) || (Array.isArray(appliedUserFilterConditions) && appliedUserFilterConditions.length > 0) || appliedQueueSittingConditions.length > 0 || - (Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0) + // A panel row with no value yet is not the user's query + // (objectstack#27520): the list is not "no matches" for it. + (Array.isArray(appliedFilters?.conditions) && appliedFilterGroup(appliedFilters).conditions.length > 0) } - onAddRecord={toolbarFlags.showAddRecord ? () => props.onAddRecord?.() : undefined} + // objectstack#27520 row 17 — the empty list offers Create whenever + // the host hands a create path (the console hands its header New's + // create only when that New is offered, objectui#13089) and the + // user may create (`mayCreate`). The view no longer has to author + // an inline `addRecord` block for an empty list to say how to + // start; an author's explicit `addRecord: { enabled: false }`, or + // `userActions.addRecordForm: false`, still withholds it. + onAddRecord={emptyStateOffersCreate ? () => props.onAddRecord?.() : undefined} + // objectstack#27520 row 7 — the search persists across + // navigation, so a user can come back to a list their own search + // emptied: the empty state offers the way out. + onClearSearch={searchTerm && searchTerm.trim() ? () => handleSearchChange('') : undefined} t={t} displayLocale={displayLocale} /> diff --git a/ui/packages/plugin-list/src/__tests__/ListView.toolbarRows-27520.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.toolbarRows-27520.test.tsx new file mode 100644 index 00000000000..9754a6aedf9 --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/ListView.toolbarRows-27520.test.tsx @@ -0,0 +1,208 @@ +/** + * 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. + */ + +/** + * objectstack#27520 rows 7 and 17 — the list toolbar and the empty list, as + * the RC walkthrough found them on the Tasks list. + * + * - Row 7, filters: a filter row with no value yet is not a filter. It is + * not counted on the Filter badge, it does not make the list "No matching + * records", and it is not handed to the host's `onFilterChange` (which the + * console stores as the user's list state, so it came back after a reload). + * The panel itself still shows the row, so the user can finish it. + * - Row 7, sort: a date field's direction reads "Oldest → Newest". The list + * hands each field's type to the picker. + * - Row 7, search: the "No matching records" a search produced offers + * "Clear search". The search persists across navigation, so the user can + * come back to it. + * - Row 17: an empty list offers Create when the host gives a create path + * and the user may create, without the view having to author an inline + * `addRecord` block. An `addRecord: { enabled: false }` the author wrote + * still withholds it. + * + * Every case mounts the real `ListView` with the real `FilterBuilder` and + * `SortBuilder`; each negative sits beside a control on the same rig. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +const OBJECT = 'task_27520'; + +const DEFINITION = { + name: OBJECT, + label: 'Task', + fields: { + title: { type: 'text', label: 'Title' }, + due_date: { type: 'date', label: 'Due Date' }, + priority: { type: 'number', label: 'Priority' }, + }, +}; + +function emptyDataSource() { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => structuredClone(DEFINITION)), + }; +} + +type Props = Partial>; + +function mount(node: Record = {}, props: Props = {}) { + const ds = emptyDataSource(); + const schema = { + type: 'list-view', + objectName: OBJECT, + viewType: 'grid', + columns: [{ field: 'title' }, { field: 'due_date' }, { field: 'priority' }], + ...node, + } as unknown as ListViewSchema; + const utils = render( + + + , + ); + return { ...utils, ds }; +} + +async function emptyState(container: HTMLElement): Promise { + await waitFor(() => expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull()); + return container.querySelector('[data-testid="empty-state"]') as HTMLElement; +} + +const COMPLETE = { id: 'c1', field: 'title', operator: 'contains', value: 'launch' }; +const NO_VALUE = { id: 'c2', field: 'priority', operator: 'equals', value: '' }; +const group = (...conditions: Array>) => + ({ id: 'root', logic: 'and', conditions }) as React.ComponentProps['initialFilters']; + +const filterTrigger = () => screen.getByTestId('toolbar-filter-trigger'); + +afterEach(() => { + cleanup(); +}); + +describe('row 7: a filter row with no value is not a filter', () => { + it('the badge counts the finished rows only', async () => { + mount({}, { initialFilters: group(COMPLETE, NO_VALUE) }); + await waitFor(() => expect(filterTrigger()).toHaveAccessibleName('Filter, 1 active')); + expect(filterTrigger().textContent).toBe('1'); + }); + + it('a group of unfinished rows only draws no badge and keeps the first-run copy', async () => { + const { container } = mount({}, { initialFilters: group(NO_VALUE), onAddRecord: () => {} }); + const panel = await emptyState(container); + expect(filterTrigger()).toHaveAccessibleName('Filter'); + expect(filterTrigger().textContent).toBe(''); + expect(panel.textContent).toMatch(/Nothing here yet/); + expect(panel.textContent).not.toMatch(/No matching records/); + }); + + it('CONTROL: two finished rows count two', async () => { + mount({}, { initialFilters: group(COMPLETE, { ...NO_VALUE, value: 3 }) }); + await waitFor(() => expect(filterTrigger()).toHaveAccessibleName('Filter, 2 active')); + }); + + it('"Add filter" draws the new row in the panel but hands the host only the finished rows', async () => { + const onFilterChange = vi.fn(); + mount({}, { initialFilters: group(COMPLETE), onFilterChange }); + fireEvent.click(filterTrigger()); + const panel = (await screen.findByText('Filter Records')).closest('[role="dialog"]') as HTMLElement; + // The object definition is what lists the fields; wait for it. + await waitFor(() => expect(within(panel).getByRole('button', { name: /add filter/i })).toBeEnabled()); + const rowsBefore = within(panel).getAllByRole('button', { name: /remove condition/i }).length; + + fireEvent.click(within(panel).getByRole('button', { name: /add filter/i })); + + // The panel holds the unfinished row, so the user can finish it… + await waitFor(() => + expect(within(panel).getAllByRole('button', { name: /remove condition/i })).toHaveLength(rowsBefore + 1), + ); + // …but what the host stores is the filter that applies. + expect(onFilterChange).toHaveBeenCalled(); + const stored = onFilterChange.mock.calls.at(-1)![0]; + expect(stored.conditions).toEqual([COMPLETE]); + // And the badge did not move. + expect(filterTrigger()).toHaveAccessibleName('Filter, 1 active'); + }); +}); + +describe('row 7: a date sort reads as time', () => { + async function openSort() { + fireEvent.click(await screen.findByRole('button', { name: /^sort/i })); + const panel = (await screen.findByText('Sort Records')).closest('[role="dialog"]') as HTMLElement; + return panel; + } + const directionOf = (panel: HTMLElement) => within(panel).getAllByRole('combobox')[1]; + + it('a sort on a date field reads "Oldest → Newest"', async () => { + mount({ sort: [{ field: 'due_date', order: 'asc' }] }); + const panel = await openSort(); + await waitFor(() => expect(directionOf(panel)).toHaveTextContent('Oldest → Newest')); + }); + + it('CONTROL: a sort on a text field reads "A → Z"', async () => { + mount({ sort: [{ field: 'title', order: 'asc' }] }); + const panel = await openSort(); + await waitFor(() => expect(within(panel).getAllByRole('combobox')[0]).toHaveTextContent('Title')); + expect(directionOf(panel)).toHaveTextContent('A → Z'); + }); +}); + +describe('row 7: a search emptied list offers "Clear search"', () => { + it('clicking it clears the search, tells the host, and the list is no longer "no matches"', async () => { + const onSearchChange = vi.fn(); + const { container } = mount({}, { initialSearchTerm: 'zzz', onSearchChange }); + const panel = await emptyState(container); + expect(panel.textContent).toMatch(/No matching records/); + fireEvent.click(within(panel).getByRole('button', { name: 'Clear search' })); + expect(onSearchChange).toHaveBeenLastCalledWith(''); + await waitFor(() => + expect(container.querySelector('[data-testid="empty-state"]')?.textContent).toMatch(/Nothing here yet/), + ); + expect(screen.queryByTestId('search-active-keyword')).toBeNull(); + }); + + it('CONTROL: a list emptied by the filter panel alone offers no "Clear search"', async () => { + const { container } = mount({}, { initialFilters: group(COMPLETE) }); + const panel = await emptyState(container); + expect(panel.textContent).toMatch(/No matching records/); + expect(within(panel).queryByRole('button', { name: 'Clear search' })).toBeNull(); + }); +}); + +describe('row 17: an empty list offers Create when the user may create', () => { + it('a view with no inline addRecord block still offers the host\'s create', async () => { + const onAddRecord = vi.fn(); + const { container } = mount({}, { onAddRecord }); + const panel = await emptyState(container); + const add = panel.querySelector('[data-testid="empty-state-add-record"]') as HTMLElement | null; + expect(add).not.toBeNull(); + fireEvent.click(add!); + expect(onAddRecord).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: no create path from the host, no Create', async () => { + const { container } = mount(); + const panel = await emptyState(container); + expect(panel.querySelector('[data-testid="empty-state-add-record"]')).toBeNull(); + }); + + it('an author\'s addRecord: { enabled: false } still withholds it', async () => { + const { container } = mount({ addRecord: { enabled: false } }, { onAddRecord: () => {} }); + const panel = await emptyState(container); + expect(panel.querySelector('[data-testid="empty-state-add-record"]')).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index e0402b58217..f24a097ea76 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -3192,7 +3192,11 @@ export const ObjectView: React.FC = ({ onRowClick: handleRowClick, className: 'h-full', refreshKey, - onAddRecord: handleCreate, + // objectstack#27520 row 17 — the list's empty state offers Create + // whenever it is handed a create path, so it is handed one only where + // the author left Create on (the same two switches as the New button + // below; the create grant is the list's own `mayCreate` read). + onAddRecord: schema.showCreate !== false && operations.create !== false ? handleCreate : undefined, }); } // #endregion object-view HOST-COMPOSITION SURFACE (objectui#5097) From 3f4401e8610c3a83020cf246a4ebd301a6109ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:46:01 +0000 Subject: [PATCH 2/3] fix(plugin-list): keep the declared empty-state Create, adapt sort pins to one row per field - A view that declares addRecord.enabled keeps its empty-state Create with or without a host handler, as before; a host create path now adds it. - The two removable-only controls read each field from its own row and expect "Add sort" to start on the first free field. - object-view's renderListView branch is host-only, and Studio folds its New into the list through it with showCreate: false, so its create path stays as it was. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVW2q9rsHS1vCrQ8Q9 --- .changeset/27520-list-toolbar-filter-sort-empty.md | 5 ++--- ui/packages/plugin-list/src/ListView.tsx | 6 ++++-- .../ListView.sortRemovableOnly-11943.test.tsx | 12 +++++++++--- ui/packages/plugin-view/src/ObjectView.tsx | 6 +----- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.changeset/27520-list-toolbar-filter-sort-empty.md b/.changeset/27520-list-toolbar-filter-sort-empty.md index 025d977c5af..4880b6048dc 100644 --- a/.changeset/27520-list-toolbar-filter-sort-empty.md +++ b/.changeset/27520-list-toolbar-filter-sort-empty.md @@ -1,7 +1,6 @@ --- '@object-ui/plugin-list': patch '@object-ui/components': patch -'@object-ui/plugin-view': patch '@object-ui/i18n': patch --- @@ -13,5 +12,5 @@ Metadata-protocol: none - **Unfinished filter rows.** A filter-panel row with no value yet is no longer counted on the Filter badge, no longer turns the list into "No matching records", and is no longer handed to the host's `onFilterChange`, so the console does not store it as the user's list state and it does not come back after a reload. The panel still shows the row so it can be finished. - **Date sort direction.** The sort picker names a `date` or `datetime` field's direction "Oldest → Newest" / "Newest → Oldest" instead of "A → Z" / "Z → A". The list hands the picker each field's type. - **One sort row per field.** A field another sort row already orders by is unavailable in the other rows, "Add sort" starts on the first field no row holds, and it is disabled once every field has a row. -- **Clear search.** The "No matching records" a search produced offers "Clear search". The clear button inside the search box is now named "Clear search" too. -- **Create on an empty list.** An empty list offers Create whenever the host gives a create path and the user may create, without the view having to declare an inline `addRecord` block. An author's `addRecord: { enabled: false }` or `userActions.addRecordForm: false` still withholds it, and the `object-view` renderer no longer hands its list a create path when the author turned Create off (`showCreate: false` or `operations.create: false`). +- **Clear search.** The "No matching records" a search produced offers "Clear search". +- **Create on an empty list.** An empty list offers Create whenever the host gives a create path and the user may create, without the view having to declare an inline `addRecord` block. An author's `addRecord: { enabled: false }` or `userActions.addRecordForm: false` still withholds it, and a view that declares the inline block keeps its Create as before. diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index d02e87e50dd..e045f2ee2b9 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -3869,7 +3869,10 @@ export const ListView = React.forwardRef(({ }, [onSearchChange]); // objectstack#27520 row 17 — see `toolbarFlags.emptyStateAddRecord`. - const emptyStateOffersCreate = toolbarFlags.emptyStateAddRecord && !!props.onAddRecord; + // A view that declares the inline block keeps its empty-state Create as + // before; a host-supplied create path now offers it too. + const emptyStateOffersCreate = + toolbarFlags.showAddRecord || (toolbarFlags.emptyStateAddRecord && !!props.onAddRecord); // --- NavigationConfig support --- // No assertion, deliberately. `schema.navigation` is the spec-derived @@ -6399,7 +6402,6 @@ export const ListView = React.forwardRef(({ variant="ghost" size="sm" className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20" - aria-label={t('list.clearSearch')} onClick={() => handleSearchChange('')} > diff --git a/ui/packages/plugin-list/src/__tests__/ListView.sortRemovableOnly-11943.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.sortRemovableOnly-11943.test.tsx index 67d4927c291..785c37caf33 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.sortRemovableOnly-11943.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.sortRemovableOnly-11943.test.tsx @@ -221,14 +221,20 @@ describe('a field the Sort picker keeps only for the current sort is removable o ]); fireEvent.click(screen.getByRole('button', { name: /add sort/i })); await waitFor(() => expect(sortRowTriggers()).toHaveLength(2)); - // With nothing flagged, "Add sort" seeds the first entry, as it always has. - expect(sortRowTriggers()[1]).toHaveTextContent('Title'); + // With nothing flagged, "Add sort" seeds the first entry no row holds: + // `Title` is the first row's, so the new row starts on `Priority` + // (objectstack#27520, one row per field). + expect(sortRowTriggers()[1]).toHaveTextContent('Priority'); }); it('control: with full read and no served projection, the unreadable and refused fields are ordinary options', async () => { mount({ first: 'secret_note', sort: ['secret_note', 'remote_status'], perms: FULL_READ, projection: false }); + // Each read from the row that sorts by it: in the OTHER row a field is + // unavailable because a row already holds it (objectstack#27520), which + // is not the removable-only flag this control is about. const options = await openSort(); expect(options.find((o) => o.label === 'Secret Note')).toEqual({ label: 'Secret Note', disabled: false }); - expect(options.find((o) => o.label === 'Remote Status')).toEqual({ label: 'Remote Status', disabled: false }); + const secondRow = await optionsOf(sortRowTriggers()[1]); + expect(secondRow.find((o) => o.label === 'Remote Status')).toEqual({ label: 'Remote Status', disabled: false }); }); }); diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index f24a097ea76..e0402b58217 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -3192,11 +3192,7 @@ export const ObjectView: React.FC = ({ onRowClick: handleRowClick, className: 'h-full', refreshKey, - // objectstack#27520 row 17 — the list's empty state offers Create - // whenever it is handed a create path, so it is handed one only where - // the author left Create on (the same two switches as the New button - // below; the create grant is the list's own `mayCreate` read). - onAddRecord: schema.showCreate !== false && operations.create !== false ? handleCreate : undefined, + onAddRecord: handleCreate, }); } // #endregion object-view HOST-COMPOSITION SURFACE (objectui#5097) From 81af33952cdf668f304243a59e13aa8306a33644 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 13:52:55 +0000 Subject: [PATCH 3/3] test(plugin-list): type the toolbar-rows fixtures as FilterBuilderRow Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L6bhgVW2q9rsHS1vCrQ8Q9 --- .../src/__tests__/ListView.toolbarRows-27520.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/packages/plugin-list/src/__tests__/ListView.toolbarRows-27520.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.toolbarRows-27520.test.tsx index 9754a6aedf9..a00b1de448f 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.toolbarRows-27520.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.toolbarRows-27520.test.tsx @@ -33,6 +33,7 @@ import { cleanup, render, screen, fireEvent, waitFor, within } from '@testing-li import '@testing-library/jest-dom'; import React from 'react'; import type { DataSource, ListViewSchema } from '@object-ui/types'; +import type { FilterBuilderRow } from '@object-ui/components'; import { SchemaRendererProvider } from '@object-ui/react'; import { ListView } from '../ListView'; @@ -83,10 +84,11 @@ async function emptyState(container: HTMLElement): Promise { return container.querySelector('[data-testid="empty-state"]') as HTMLElement; } -const COMPLETE = { id: 'c1', field: 'title', operator: 'contains', value: 'launch' }; -const NO_VALUE = { id: 'c2', field: 'priority', operator: 'equals', value: '' }; -const group = (...conditions: Array>) => - ({ id: 'root', logic: 'and', conditions }) as React.ComponentProps['initialFilters']; +type Row = FilterBuilderRow; +const COMPLETE: Row = { id: 'c1', field: 'title', operator: 'contains', value: 'launch' }; +const NO_VALUE: Row = { id: 'c2', field: 'priority', operator: 'equals', value: '' }; +const group = (...conditions: Row[]): React.ComponentProps['initialFilters'] => + ({ id: 'root', logic: 'and', conditions }); const filterTrigger = () => screen.getByTestId('toolbar-filter-trigger');