diff --git a/.changeset/11860-grouping-url.md b/.changeset/11860-grouping-url.md new file mode 100644 index 0000000000..568cf8bd63 --- /dev/null +++ b/.changeset/11860-grouping-url.md @@ -0,0 +1,18 @@ +--- +'@object-ui/plugin-list': minor +'@object-ui/app-shell': patch +--- + +A console object list's toolbar grouping is now in the URL, beside its Filter panel conditions, search term and sort, so a grouped list can be shared as a link or bookmarked (objectui#11860). + +`ListView` (`@object-ui/plugin-list`) has a new optional prop, `onGroupingChange`. It fires when the user changes the grouping in either grouping editor: the toolbar's Group panel (adding, changing or removing a level, or Clear) and the compact toolbar's View settings popover. The value is the `@objectstack/spec` `GroupingConfig` (`{ fields: [{ field, order, collapsed }] }`), the shape `schema.grouping` takes, or `undefined` when the grouping is cleared. It does not fire when the list re-reads a changed `schema.grouping` from its host. A host that does not pass it behaves as before. + +The console object page (`@object-ui/app-shell`) writes that value into a fourth `uf_` query parameter, `uf__group`, as JSON, and opens a list grouped by it: + +- **A URL that carries a grouping opens the list grouped that way**, over the grouping the view declares. A URL without one opens the view's declared grouping. +- **Malformed or out-of-date groupings are dropped.** This applies to a value that is not valid JSON, a grouping or level the spec's `GroupingConfigSchema` rejects, and a field the object no longer has or the user may not read. The list still opens, and the dropped entry is removed from the address bar. +- Each change replaces the current history entry. Clearing the grouping removes the parameter; the spec has no empty grouping, so a link to a view that declares a grouping cannot carry "no grouping". Switching to another view opens it on its own grouping. Opening or closing the Group panel leaves the URL unchanged. + +The grouping is written to the URL only: it is not stored on the view or in the per-browser filter memory. + +Also fixed on the console object page: a link's `uf__sort` now sorts the list on a view that declares a sort of its own. Before, the view's declared sort overrode it, so the link's sort showed in the address bar while the list stayed in the view's order. diff --git a/.changeset/11860-list-url-state.md b/.changeset/11860-list-url-state.md index dc63d76682..178afa9f12 100644 --- a/.changeset/11860-list-url-state.md +++ b/.changeset/11860-list-url-state.md @@ -18,6 +18,6 @@ How a list opens: Each change replaces the current history entry rather than adding one, so Back leaves the list in one step. Switching to another view opens it without the previous view's parameters. When a URL carries a sort, the list's "reset to default" returns to that sort. -Panels and dialogs are not written to the URL; opening or closing the Filter panel or the search box leaves it unchanged. Toolbar grouping is not included yet. +Panels and dialogs are not written to the URL; opening or closing the Filter panel or the search box leaves it unchanged. Nothing is added to the package entry: no export, prop, type member or language-pack key. The per-browser filter memory is unchanged. diff --git a/packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx b/packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx index 7ce8f9dd6a..b1a28db0c0 100644 --- a/packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx +++ b/packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx @@ -12,8 +12,8 @@ * The maintainer's contract for the list surface: views, filters, sort and * grouping go into the URL; transient panels and dialogs do not. This pins the * console object page's half of it for the Filter panel's conditions, the - * search term and the sort (grouping has no change notification on `ListView` - * to write it from — reported on the card, not done here): + * search term, the sort and the toolbar grouping (`ListView` reports a user's + * grouping change through `onGroupingChange`): * * - a URL that carries list state opens that list, and wins over the * per-user cache WHOLE — a shared link opens the same list for everyone; @@ -86,9 +86,13 @@ vi.mock('./MetadataInspector', () => ({ vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); import { ObjectView } from './ObjectView'; +// The grid registers `object-grid` on import. A grouped grid asks the server +// for its groups itself, and that header query is where the grouping the list +// opened with is read (objectui#11860's grouping pins below). +import '@object-ui/plugin-grid'; import { ExpressionProvider } from '../providers/ExpressionProvider'; import { buildListFilterKey } from './listFilterStorage'; -import { LIST_FILTER_PARAM, LIST_SEARCH_PARAM, LIST_SORT_PARAM } from './userFilterUrlState'; +import { LIST_FILTER_PARAM, LIST_GROUP_PARAM, LIST_SEARCH_PARAM, LIST_SORT_PARAM } from './userFilterUrlState'; const OBJ = 'url_task'; const VIEW = `/apps/demo/${OBJ}/view`; @@ -107,23 +111,40 @@ const OBJECTS = [ listViews: { all: { label: 'All', type: 'grid', columns: ['name', 'priority', 'status', 'due_date'] }, board: { label: 'Board', type: 'kanban', columns: ['name', 'priority'], kanban: { groupByField: 'status' } }, + grouped: { + label: 'By status', + type: 'grid', + columns: ['name', 'priority', 'status'], + grouping: { fields: [{ field: 'status', order: 'asc', collapsed: false }] }, + }, + sorted: { label: 'By name', type: 'grid', columns: ['name', 'priority', 'due_date'], sort: [{ field: 'name', order: 'asc' }] }, }, }, ]; /** The list queries `ListView` issued, newest last. The record-count probe (`$top: 0`) is excluded. */ let listQueries: any[] = []; +/** The group header queries a grouped grid issued, newest last (objectui#11860's grouping). */ +let groupQueries: Array<{ groupBy?: string[] }> = []; let failWith: unknown = undefined; let savedViews: Promise | undefined; +/** The rows `find` answers with. A list with none shows its empty state and mounts no grid. */ +let rows: Array> = []; function makeDataSource() { const ds: any = { find: vi.fn(async (_object: string, params: any) => { if (params?.$top !== 0) listQueries.push(params); if (failWith) throw failWith; - return { data: [], total: 0 }; + return { data: rows, total: rows.length }; }), findOne: vi.fn(async () => null), + // A grouped grid asks the server for its groups (objectui#10881); recorded + // so a test can read which fields the list is grouped by. + queryGroupHeaders: vi.fn(async (_object: string, query: { groupBy?: string[] }) => { + groupQueries.push(query); + return []; + }), create: vi.fn(async () => ({})), update: vi.fn(async () => ({})), delete: vi.fn(async () => ({})), @@ -191,11 +212,12 @@ async function mountHost(entries: string[]): Promise { } /** Build a query string the way a copied link carries one. */ -function link(path: string, state: { filter?: unknown; search?: string; sort?: unknown; extra?: Record }) { +function link(path: string, state: { filter?: unknown; search?: string; sort?: unknown; group?: unknown; extra?: Record }) { const params = new URLSearchParams(state.extra); if (state.filter !== undefined) params.set(LIST_FILTER_PARAM, typeof state.filter === 'string' ? state.filter : JSON.stringify(state.filter)); if (state.search !== undefined) params.set(LIST_SEARCH_PARAM, state.search); if (state.sort !== undefined) params.set(LIST_SORT_PARAM, typeof state.sort === 'string' ? state.sort : JSON.stringify(state.sort)); + if (state.group !== undefined) params.set(LIST_GROUP_PARAM, typeof state.group === 'string' ? state.group : JSON.stringify(state.group)); return `${path}?${params.toString()}`; } @@ -209,6 +231,8 @@ beforeEach(() => { cleanup(); localStorage.clear(); listQueries = []; + groupQueries = []; + rows = []; failWith = undefined; savedViews = undefined; perms.isLoaded = false; @@ -436,3 +460,140 @@ describe('a user the server refuses (objectui#11860)', () => { expect({ kind: linked.getAttribute('data-error-kind'), text: linked.textContent }).toEqual(bareRefusal); }); }); + +/** + * The fields the list is grouped by, outermost first: the deepest group header + * query of the newest batch (one query per depth, each naming the levels down + * to it). `undefined` when the list asked for no groups. + */ +const groupedBy = (): string[] | undefined => { + const query = groupQueries[groupQueries.length - 1]; + return query ? [...(query.groupBy ?? [])] : undefined; +}; +const groupParamOf = (host: Host) => { + const raw = new URLSearchParams(host.search()).get(LIST_GROUP_PARAM); + return raw === null ? null : JSON.parse(raw); +}; +const BY_PRIORITY = { fields: [{ field: 'priority', order: 'desc', collapsed: false }] }; + +async function openGroupPanel() { + fireEvent.click(screen.getByRole('button', { name: /^group/i })); + await settle(); +} + +describe('the toolbar grouping is in the URL too (objectui#11860)', () => { + // One row, so the list draws its grid; a grouped grid then asks for its groups. + beforeEach(() => { + rows = [{ id: 'r1', name: 'One', priority: 'urgent', status: 'open', due_date: '2026-11-01' }]; + }); + + it('a link groups the list it opens, over the grouping its view declares', async () => { + const host = await mountHost([link(`${VIEW}/grouped`, { group: BY_PRIORITY })]); + expect(groupedBy()).toEqual(['priority']); + // The link's param stays as it came. + expect(groupParamOf(host)).toEqual(BY_PRIORITY); + }); + + it('without one, the view\'s declared grouping stands and the address bar carries none', async () => { + const host = await mountHost([`${VIEW}/grouped`]); + expect(groupedBy()).toEqual(['status']); + expect(groupParamOf(host)).toBeNull(); + }); + + it('a grouping change is written with replace, a cleared one leaves the URL, and Back leaves the list', async () => { + const host = await mountHost(['/elsewhere', `${VIEW}/all`]); + expect(groupedBy()).toBeUndefined(); + await openGroupPanel(); + fireEvent.click(screen.getByTestId('grouping-add')); + await settle(); + const written = groupParamOf(host); + expect(written?.fields).toHaveLength(1); + expect(written.fields[0]).toMatchObject({ order: 'asc', collapsed: false }); + // The list is grouped by what the URL now says. + expect(groupedBy()).toEqual([written.fields[0].field]); + + fireEvent.click(screen.getByTestId('clear-grouping')); + await settle(); + expect(groupParamOf(host)).toBeNull(); + + expect(host.navigations().length).toBeGreaterThan(0); + expect(host.navigations().every((type) => type === 'REPLACE')).toBe(true); + await host.go(-1); + expect(host.pathname()).toBe('/elsewhere'); + }); + + it('the grouping a user set on a link survives a reload of that link', async () => { + const host = await mountHost([`${VIEW}/all`]); + await openGroupPanel(); + fireEvent.click(screen.getByTestId('grouping-add')); + await settle(); + const url = `${host.pathname()}${host.search()}`; + const field = groupParamOf(host).fields[0].field; + cleanup(); + groupQueries = []; + await mountHost([url]); + expect(groupedBy()).toEqual([field]); + }); + + it('CONTROL: opening and closing the Group panel leaves the URL unchanged', async () => { + const host = await mountHost([link(`${VIEW}/grouped`, { group: BY_PRIORITY })]); + const before = host.search(); + const navigationsBefore = host.navigations().length; + await openGroupPanel(); + expect(screen.getByTestId('group-field-list')).toBeDefined(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); + await settle(); + expect(host.search()).toBe(before); + expect(host.navigations().length).toBe(navigationsBefore); + }); + + it('a malformed or stale grouping is dropped: the view\'s own grouping stands and the address bar loses it', async () => { + for (const group of ['{not json', { fields: [{ field: 'ghost' }] }, { fields: [{ field: 'priority', order: 'up' }] }]) { + cleanup(); + groupQueries = []; + const host = await mountHost([link(`${VIEW}/grouped`, { group, extra: { keep: '1' } })]); + expect(groupedBy()).toEqual(['status']); + expect(groupParamOf(host)).toBeNull(); + expect(new URLSearchParams(host.search()).get('keep')).toBe('1'); + } + }); + + it('a level naming a field this user cannot read is dropped once permissions are loaded', async () => { + perms.isLoaded = true; + perms.checkField = (_object, field) => field !== 'priority'; + const host = await mountHost([ + link(`${VIEW}/all`, { + group: { fields: [{ field: 'priority', order: 'asc', collapsed: false }, { field: 'status', order: 'asc', collapsed: false }] }, + }), + ]); + expect(groupedBy()).toEqual(['status']); + expect(groupParamOf(host)).toEqual({ fields: [{ field: 'status', order: 'asc', collapsed: false }] }); + }); + + it('another view starts clean, on its own declared grouping; Back returns to the link\'s', async () => { + const host = await mountHost([link(`${VIEW}/all`, { group: BY_PRIORITY })]); + expect(groupedBy()).toEqual(['priority']); + await host.go(`${VIEW}/grouped`); + expect(groupedBy()).toEqual(['status']); + expect(groupParamOf(host)).toBeNull(); + await host.go(-1); + expect(groupedBy()).toEqual(['priority']); + expect(groupParamOf(host)).toEqual(BY_PRIORITY); + }); +}); + +describe('a link\'s sort wins over the sort its view declares (objectui#11860)', () => { + it('the link\'s sort reaches the query, not only the address bar', async () => { + const host = await mountHost([link(`${VIEW}/sorted`, { sort: [{ field: 'due_date', order: 'desc' }] })]); + expect(lastQuery().$orderby).toEqual([{ field: 'due_date', order: 'desc' }]); + expect(JSON.parse(new URLSearchParams(host.search()).get(LIST_SORT_PARAM)!)).toEqual([ + { field: 'due_date', order: 'desc' }, + ]); + }); + + it('without one, the view\'s declared sort stands', async () => { + const host = await mountHost([`${VIEW}/sorted`]); + expect(lastQuery().$orderby).toEqual([{ field: 'name', order: 'asc' }]); + expect(new URLSearchParams(host.search()).has(LIST_SORT_PARAM)).toBe(false); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index 6cdcb1ab68..35d6e23fbc 100644 --- a/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -412,7 +412,7 @@ const ABSENCES: Record = { // second, competing source of the same value. columns: { kind: 'relayed-upstream', upstreamReads: ['activeView', 'currentNamedViewConfig'], reason: "The view's column set is composed upstream (`currentNamedViewConfig?.columns || activeView?.columns || …`, objectui#5269) and arrives through `...listSchema`." }, viewType: { kind: 'relayed-upstream', upstreamReads: ['currentViewType'], reason: "The view KIND is resolved upstream into `currentViewType` (it drives which branch runs there) and handed down; the relay must not re-decide it." }, - grouping: { kind: 'relayed-upstream', upstreamReads: ['activeView'], reason: 'Composed upstream as `grouping: activeView?.grouping` and read by ListView from the spread.' }, + grouping: { kind: 'relayed-upstream', upstreamReads: ['activeView'], reason: 'Composed upstream as `grouping: activeView?.grouping` and read by ListView from the spread. The relay writes the key only to lay a grouping the URL carries over that value (objectui#11860); it reads no `viewDef`.' }, compactToolbar: { kind: 'relayed-upstream', upstreamReads: ['activeView'], reason: 'Composed upstream from the active view; no second rung needed.' }, showDescription: { kind: 'relayed-upstream', upstreamReads: ['activeView'], reason: "Legacy bare flag, composed upstream AND folded on top of the view's `appearance` by this relay's `appearance` rung (ADR-0047)." }, diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 64a2c36120..0bf8e6f193 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -2398,10 +2398,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co /** * objectui#11860 — the list's toolbar state in the URL: the Filter panel's - * conditions, the search term and the sort, under the `uf_` family - * `userFilterUrlState` owns (its header names the params and their shapes). - * Panels and dialogs stay out of it; grouping is not here, because the - * list reports no grouping change to its host. + * conditions, the search term, the sort and the grouping, under the `uf_` + * family `userFilterUrlState` owns (its header names the params and their + * shapes). Panels and dialogs stay out of it. * * The SEED — what the list opens with — is decided once per list identity * (object + view, the same identity `renderListView` keys the list on; @@ -2413,7 +2412,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co * same list for everyone who may read it. A piece the URL lacks is * absent, not filled from storage. * 2. Otherwise the per-user cache (`listFilterStorage`, unchanged) for the - * Filter panel and the search; the view's own sort. + * Filter panel and the search; the view's own sort and grouping. * * Then the seed is written back into the URL (replace, never a new history * entry), so the address bar shows the list on screen: a restored filter @@ -2465,6 +2464,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co filters: seed.state.filters ?? null, search: seed.state.search ?? null, sort: seed.state.sort ?? null, + grouping: seed.state.grouping ?? null, }); // Only a mirror of the CACHE is this page's own write. A link's params, // even rewritten, stay the link's: the identity can still move to the @@ -3261,6 +3261,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // returns to the sort the link opened with, the way it returns to // a stored one. sort: listSeed?.sort ?? (viewDef as any).sort ?? listSchema.sort, + // objectui#11860 — and a grouping the URL carries is laid over the + // view's, which the caller composed into `listSchema` (the census + // declares `grouping` relayed upstream; this reads no `viewDef`). + // `ListView` seeds its toolbar grouping from this value. + grouping: listSeed?.grouping ?? listSchema.grouping, // The ONE place this view's effective filter is computed (#2890). // It used to be computed twice — once here as `filter` for the child // views, once further down as `filters` for ListView — with the two @@ -3485,7 +3490,13 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co aria: viewDef.aria ?? listSchema.aria, // (the legacy `filters` twin of the `filter` above lived here until // #2890 — see the note at its single remaining computation) - ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}), + // + // objectui#11860 — and a second `sort` write lived here, the view's + // own sort spread in AFTER the `sort` rung above. Redundant with + // that rung while it read the view alone; once the rung put a + // URL-carried sort first, this spread overrode it on every view + // that declares a sort, so a link's sort reached the address bar + // and never the query. The rung is the one `sort` write. // objectui#10380 — for each kind the stored row's legacy `options` // bag carries, the view's own top-level block goes out at the top // level, as `InterfaceListPage` sends it. `ListView` then lays it @@ -3653,6 +3664,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co persistViewPatch(viewDef.id, viewDef, { sort }); writeListUrlState({ sort }); }} + // objectui#11860 — the URL only: nothing else stores the + // toolbar grouping, and the view's own stays as authored. + onGroupingChange={(grouping) => writeListUrlState({ grouping: grouping ?? null })} onFilterChange={(filter: any) => { // SESSION state only (objectui#4155) — localStorage keeps // the BUILDER's group verbatim, read back into diff --git a/packages/app-shell/src/views/userFilterUrlState.listState-11860.test.ts b/packages/app-shell/src/views/userFilterUrlState.listState-11860.test.ts index b5eeb67dd9..8453783634 100644 --- a/packages/app-shell/src/views/userFilterUrlState.listState-11860.test.ts +++ b/packages/app-shell/src/views/userFilterUrlState.listState-11860.test.ts @@ -8,8 +8,8 @@ /** * objectui#11860 — the list toolbar's state in the `uf_` URL family: the - * Filter panel's conditions (`uf__filter`), the search term (`uf__search`) and - * the sort (`uf__sort`). + * Filter panel's conditions (`uf__filter`), the search term (`uf__search`), the + * sort (`uf__sort`) and the grouping (`uf__group`). * * Pinned here, without mounting anything: the round trip of every piece through * a real `URLSearchParams` string, the spec shapes the values carry, and the @@ -20,10 +20,11 @@ */ import { describe, it, expect } from 'vitest'; -import { ViewFilterRuleSchema } from '@objectstack/spec/ui'; -import type { FilterGroup, SortItem } from '@object-ui/components'; +import { GroupingConfigSchema, ViewFilterRuleSchema } from '@objectstack/spec/ui'; +import type { FilterGroup, GroupingConfigValue, SortItem } from '@object-ui/components'; import { LIST_FILTER_PARAM, + LIST_GROUP_PARAM, LIST_SEARCH_PARAM, LIST_SORT_PARAM, applyListStateParams, @@ -227,6 +228,72 @@ describe('the sort and the search term (objectui#11860)', () => { }); }); +describe('the toolbar grouping round-trips through `uf__group` (objectui#11860)', () => { + it('what the grouping editor emits is the spec `GroupingConfig`, and comes back unchanged', () => { + // The editor's own value type, as `GroupingEditor`'s `onChange` hands it. + const emitted: GroupingConfigValue = { + fields: [ + { field: 'status', order: 'desc', collapsed: true }, + { field: 'priority', order: 'asc', collapsed: false }, + ], + }; + // The spec parses it as it is: no key added, none refused. + expect(GroupingConfigSchema.parse(emitted)).toEqual(emitted); + const params = applyListStateParams(new URLSearchParams(), { grouping: emitted }); + expect(JSON.parse(params.get(LIST_GROUP_PARAM)!)).toEqual(emitted); + expect(parseListStateParams(viaLink(params), acceptKnown).grouping).toEqual(emitted); + }); + + it('a level that leaves out `order` and `collapsed` reads back with the spec defaults', () => { + const params = new URLSearchParams({ [LIST_GROUP_PARAM]: JSON.stringify({ fields: [{ field: 'status' }] }) }); + expect(parseListStateParams(params, acceptKnown).grouping).toEqual({ + fields: [{ field: 'status', order: 'asc', collapsed: false }], + }); + }); + + it('a level the spec refuses, or naming a field not accepted, is dropped; the others keep their order', () => { + const raw = JSON.stringify({ + fields: [ + { field: 'ghost', order: 'asc' }, + { field: 'priority', order: 'sideways' }, + { field: 'status', order: 'desc', collapsed: true }, + { field: 'name', order: 'asc', extra: 1 }, + { field: 'due_date' }, + ], + }); + expect(parseListStateParams(new URLSearchParams({ [LIST_GROUP_PARAM]: raw }), acceptKnown).grouping).toEqual({ + fields: [ + { field: 'status', order: 'desc', collapsed: true }, + { field: 'due_date', order: 'asc', collapsed: false }, + ], + }); + }); + + it('a value that is not JSON, not the spec envelope, or left with no level carries no grouping', () => { + const read = (raw: string) => + parseListStateParams(new URLSearchParams({ [LIST_GROUP_PARAM]: raw }), acceptKnown).grouping; + expect(read('{not json')).toBeUndefined(); + expect(read('status')).toBeUndefined(); + expect(read(JSON.stringify([{ field: 'status' }]))).toBeUndefined(); + expect(read(JSON.stringify({ fields: [] }))).toBeUndefined(); + expect(read(JSON.stringify({ fields: [{ field: 'ghost' }] }))).toBeUndefined(); + // `GroupingConfigSchema` declares `fields` alone: another key drops the whole value. + expect(read(JSON.stringify({ fields: [{ field: 'status' }], groupBy: 'priority' }))).toBeUndefined(); + }); + + it('a cleared grouping deletes the param, and an absent piece leaves it alone', () => { + const start = applyListStateParams(new URLSearchParams({ recordId: 'r1' }), { + grouping: { fields: [{ field: 'status' }] }, + }); + expect(applyListStateParams(start, { search: 'x' }).get(LIST_GROUP_PARAM)).toBe(start.get(LIST_GROUP_PARAM)); + for (const cleared of [null, undefined, { fields: [] }]) { + const next = applyListStateParams(start, { grouping: cleared as never }); + expect(next.has(LIST_GROUP_PARAM)).toBe(false); + expect(next.get('recordId')).toBe('r1'); + } + }); +}); + describe('the list-state keys are not quick-filter selections (objectui#11860)', () => { it('`parseUserFilterParams` hands `UserFilters` its fields and `_tab`, never a list-state key', () => { let params = applyUserFilterParams(new URLSearchParams(), { status: ['open'], _tab: ['mine'] }); @@ -234,7 +301,9 @@ describe('the list-state keys are not quick-filter selections (objectui#11860)', search: 'x', sort: [{ field: 'name', order: 'asc' }], filters: { logic: 'and', conditions: [{ id: 'a', field: 'priority', operator: 'equals', value: 'urgent' }] }, + grouping: { fields: [{ field: 'status', order: 'asc', collapsed: false }] }, }); + expect(params.has(LIST_GROUP_PARAM)).toBe(true); expect(parseUserFilterParams(viaLink(params))).toEqual({ status: ['open'], _tab: ['mine'] }); }); diff --git a/packages/app-shell/src/views/userFilterUrlState.ts b/packages/app-shell/src/views/userFilterUrlState.ts index 7893acd865..b0ddf4943d 100644 --- a/packages/app-shell/src/views/userFilterUrlState.ts +++ b/packages/app-shell/src/views/userFilterUrlState.ts @@ -16,7 +16,7 @@ * into the URL makes filter selections survive a reload and makes filtered * lists shareable as links — Airtable Interfaces parity. * - * objectui#11860 extends the same family, under three more reserved keys, to + * objectui#11860 extends the same family, under four more reserved keys, to * the rest of the list toolbar's state — the maintainer's contract for the list * surface: views, filters, sort and grouping go into the URL, transient panels * and dialogs do not. Each value is a spec shape serialized as JSON, never a @@ -27,6 +27,7 @@ * | `uf__filter` | the Filter panel's conditions | `{ logic, conditions }`, each condition one spec `ViewFilterRule` | * | `uf__search` | the search term | the term itself | * | `uf__sort` | the sort | the spec `ListViewSchema.sort` array | + * | `uf__group` | the toolbar grouping | the spec `GroupingConfig` | * * - `uf__filter` is the FilterBuilder group (`FilterGroup`, `@object-ui/types` * — `logic` is its own key) with every row folded to the spec's @@ -41,14 +42,16 @@ * row comes back expanded into an OR of equalities). * - Reading is strict and quiet: a value that does not parse, a condition the * spec's `ViewFilterRuleSchema` refuses, a sort entry `ListViewSchema.sort` - * refuses, and an entry naming a field the caller does not accept are each - * DROPPED, and the list still opens. Nothing here throws. - * - Grouping is not here: the list reports no grouping change to its host, so - * there is nothing to write it from (objectui#11860, reported on the card). + * refuses, a grouping level or envelope `GroupingConfigSchema` refuses, and + * an entry naming a field the caller does not accept are each DROPPED, and + * the list still opens. Nothing here throws. + * - `uf__group` has no empty form: the spec's `GroupingConfig` requires at + * least one level, so a cleared grouping deletes the param, and a list + * opened without it shows the grouping its view declares. */ -import { ListViewSchema, ViewFilterRuleSchema } from '@objectstack/spec/ui'; -import type { ViewFilterRule } from '@objectstack/spec/ui'; +import { GroupingConfigSchema, ListViewSchema, ViewFilterRuleSchema } from '@objectstack/spec/ui'; +import type { GroupingConfig, ViewFilterRule } from '@objectstack/spec/ui'; import type { FilterGroup } from '@object-ui/components'; import { toFilterGroup } from '@object-ui/plugin-view'; import { foldFilterGroupToSpecRules } from './viewFilterFold.js'; @@ -61,6 +64,8 @@ export const LIST_FILTER_PARAM = `${PREFIX}_filter`; export const LIST_SEARCH_PARAM = `${PREFIX}_search`; /** The list's sort (objectui#11860). */ export const LIST_SORT_PARAM = `${PREFIX}_sort`; +/** The list's toolbar grouping (objectui#11860). */ +export const LIST_GROUP_PARAM = `${PREFIX}_group`; /** * The reserved keys that are list state, not quick-filter selections. Kept out @@ -71,6 +76,7 @@ const LIST_STATE_PARAMS: ReadonlySet = new Set([ LIST_FILTER_PARAM, LIST_SEARCH_PARAM, LIST_SORT_PARAM, + LIST_GROUP_PARAM, ]); /** Read `uf_*` params into the UserFilters `initialSelections` shape. */ @@ -139,6 +145,8 @@ export interface ListUrlState { search?: string; /** The sort, in the spec's `ListViewSchema.sort` shape. */ sort?: ListSortRule[]; + /** The grouping, in the spec's `GroupingConfig` shape — ready for `schema.grouping`. */ + grouping?: GroupingConfig; } /** @@ -149,6 +157,7 @@ export interface ListUrlState { export type ListUrlFieldGate = (field: string) => boolean; const SORT_ENTRY_SCHEMA = ListViewSchema.shape.sort.unwrap().element; +const GROUPING_LEVEL_SCHEMA = GroupingConfigSchema.shape.fields.element; function parseJson(raw: string | null): unknown { if (!raw) return undefined; @@ -192,6 +201,26 @@ function parseSortParam(raw: string | null, acceptField: ListUrlFieldGate): List return parsed.length === 0 || sort.length > 0 ? sort : undefined; } +function parseGroupingParam(raw: string | null, acceptField: ListUrlFieldGate): GroupingConfig | undefined { + const parsed = parseJson(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + const { fields } = parsed as { fields?: unknown }; + if (!Array.isArray(fields)) return undefined; + // Level by level, as the sort is read entry by entry: a level the spec + // refuses, or one naming a field the caller does not accept, is dropped and + // the levels around it keep their order. + const levels: GroupingConfig['fields'] = []; + for (const entry of fields) { + const level = GROUPING_LEVEL_SCHEMA.safeParse(entry); + if (level.success && acceptField(level.data.field)) levels.push(level.data); + } + if (levels.length === 0) return undefined; + // The envelope is the spec's too: a key `GroupingConfigSchema` does not + // declare beside `fields` drops the whole value. + const config = GroupingConfigSchema.safeParse({ ...parsed, fields: levels }); + return config.success ? config.data : undefined; +} + /** * Read the list state a URL carries. Each piece is `undefined` when the URL * does not carry it, or when nothing in it survives the checks above. @@ -207,6 +236,8 @@ export function parseListStateParams( if (search) state.search = search; const sort = parseSortParam(searchParams.get(LIST_SORT_PARAM), acceptField); if (sort) state.sort = sort; + const grouping = parseGroupingParam(searchParams.get(LIST_GROUP_PARAM), acceptField); + if (grouping) state.grouping = grouping; return state; } @@ -215,6 +246,7 @@ export interface ListStatePatch { filters?: FilterGroup | null; search?: string | null; sort?: ReadonlyArray<{ field: string; order: 'asc' | 'desc' }> | null; + grouping?: GroupingConfig | null; } function serializeFilterGroup(group: FilterGroup | null | undefined): string | undefined { @@ -245,5 +277,14 @@ export function applyListStateParams(prev: URLSearchParams, patch: ListStatePatc patch.sort ? JSON.stringify(patch.sort.map(({ field, order }) => ({ field, order }))) : undefined, ); } + if ('grouping' in patch) { + const levels = patch.grouping?.fields ?? []; + write( + LIST_GROUP_PARAM, + levels.length > 0 + ? JSON.stringify({ fields: levels.map(({ field, order, collapsed }) => ({ field, order, collapsed })) }) + : undefined, + ); + } return next; } diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 6cca39c965..df360aae0c 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -108,7 +108,12 @@ import { ListView } from '@object-ui/plugin-list'; ``` When both are present, `grouping` wins. End users can also add or remove -grouping fields at runtime via the Group toolbar button. +grouping fields at runtime via the Group toolbar button. Every such edit, in +the toolbar's Group panel or in the compact toolbar's View settings popover +(Clear included), fires `onGroupingChange` with the spec `GroupingConfig`, or +with `undefined` when the grouping is cleared. A changed `schema.grouping` that +the list re-reads does not fire it: that value came from the host +(objectui#11860). A grouped **grid** is grouped on the server: over a data source that answers the group header query (`dataSource.queryGroupHeaders`), `ListView` hands the @@ -172,6 +177,7 @@ import { ListView } from '@object-ui/plugin-list'; onSearchChange={(search) => console.log('Search:', search)} onSortChange={(sort) => console.log('Sort:', sort)} onFilterChange={(filters) => console.log('Filters:', filters)} + onGroupingChange={(grouping) => console.log('Grouping:', grouping)} /> ``` diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index dcd8203540..6acc4893d2 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -32,6 +32,7 @@ import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLoc // on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead // (objectui#5134). import { resolveI18nLabel as resolveInlineI18nLabel, normalizeFilterOperator, PaginationConfigSchema } from '@objectstack/spec/ui'; +import type { GroupingConfig } from '@objectstack/spec/ui'; import { usePermissions } from '@object-ui/permissions'; /** @@ -289,6 +290,18 @@ export interface ListViewProps { * decides whether platform-unsortable entries are still present (#6455). */ onSortChange?: (sort: SortItem[]) => void; + /** + * Fires with the grouping after a user edit in either grouping editor: the + * toolbar's Group panel (a level added, changed or removed, or Clear) and the + * compact toolbar's settings popover. `undefined` when the grouping is + * cleared. + * + * The value is the spec's `GroupingConfig`, the shape `schema.grouping` + * takes, so a host hands it back through `schema.grouping` unchanged + * (objectui#11860). Not fired when the list re-reads a changed + * `schema.grouping` or `groupBy`: that value came from the host. + */ + onGroupingChange?: (grouping: GroupingConfig | undefined) => void; onSearchChange?: (search: string) => void; /** Called when the user toggles fields via the Hide Fields popover. */ onHiddenFieldsChange?: (hidden: string[]) => void; @@ -1153,6 +1166,7 @@ export const ListView = React.forwardRef(({ onViewChange, onFilterChange, onSortChange, + onGroupingChange, onSearchChange, onHiddenFieldsChange, onInlineEditChange, @@ -1522,6 +1536,13 @@ export const ListView = React.forwardRef(({ }, [schema.grouping, schema.groupBy, schema.groupBy2]); const [groupingConfig, setGroupingConfig] = React.useState(initialGroupingConfig); const [showGroupPopover, setShowGroupPopover] = React.useState(false); + // The one door a USER's grouping edit goes through, from both editors, so + // the host hears of it (objectui#11860). The re-sync below sets the state + // directly: a schema delta is the host's own value, not news to report. + const changeGrouping = React.useCallback((next: GroupingConfig | undefined) => { + setGroupingConfig(next); + onGroupingChange?.(next); + }, [onGroupingChange]); // Re-sync grouping when the underlying schema-driven config changes (e.g. the // user edits `groupBy` in the view designer). User-driven changes via the @@ -4749,7 +4770,7 @@ export const ListView = React.forwardRef(({

{t('list.groupBy')}

{groupingConfig && ( - )} @@ -4764,7 +4785,7 @@ export const ListView = React.forwardRef(({ collapseTitle: t('list.collapsedByDefault', { defaultValue: 'Collapsed by default' }), removeTitle: t('list.removeGroup', { defaultValue: 'Remove' }), }} - onChange={(next) => setGroupingConfig(next as any)} + onChange={changeGrouping} />
@@ -4981,7 +5002,7 @@ export const ListView = React.forwardRef(({ allFields={allFields as any} showGroup={toolbarFlags.showGroup} groupingConfig={groupingConfig} - setGroupingConfig={setGroupingConfig} + setGroupingConfig={changeGrouping} showColor={toolbarFlags.showColor} rowColorConfig={rowColorConfig} setRowColorConfig={setRowColorConfig} diff --git a/packages/plugin-list/src/__tests__/ListView.onGroupingChange-11860.test.tsx b/packages/plugin-list/src/__tests__/ListView.onGroupingChange-11860.test.tsx new file mode 100644 index 0000000000..669e36b67f --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.onGroupingChange-11860.test.tsx @@ -0,0 +1,150 @@ +/** + * 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. + */ + +/** + * objectui#11860 — `ListView` tells its host when the USER changes the + * grouping, so a host can keep the grouping in the URL the way it keeps the + * sort and the Filter panel. + * + * Every door a user has onto the grouping is pinned, plus the one door that is + * NOT the user's: + * + * - the toolbar's Group panel: a level added, changed, removed, and Clear; + * - the compact toolbar's View settings popover: a level added, and its Clear; + * - a changed `schema.grouping` re-read by the list — the HOST's own value — + * is not reported back, the way `onSortChange` does not echo a view's + * declared sort. + * + * Each reported value is the spec's `GroupingConfig`: `GroupingConfigSchema` + * parses it unchanged, so the host can hand it straight back through + * `schema.grouping`. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, act } from '@testing-library/react'; +import { GroupingConfigSchema } from '@objectstack/spec/ui'; +import type { GroupingConfig } from '@objectstack/spec/ui'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +/** A partial stub: only the members the list's mount path calls. */ +const dataSource = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +} as unknown as DataSource; + +function schemaWith(extra: Partial = {}): ListViewSchema { + return { + type: 'list-view', + objectName: 'contacts', + viewType: 'grid', + fields: ['name', 'email', 'status'], + ...extra, + } as ListViewSchema; +} + +function mount(schema: ListViewSchema, onGroupingChange: (g: GroupingConfig | undefined) => void) { + const view = (s: ListViewSchema) => ( + + + + ); + const result = render(view(schema)); + return { rerender: (next: ListViewSchema) => result.rerender(view(next)) }; +} + +/** Every value reported must be the spec shape, unchanged by a parse. */ +function expectSpecShape(value: GroupingConfig | undefined) { + expect(value).toBeDefined(); + const parsed = GroupingConfigSchema.safeParse(value); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual(value); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('ListView reports a user grouping change to its host (objectui#11860)', () => { + it('the toolbar Group panel: adding, changing and removing a level each report the spec `GroupingConfig`', async () => { + const onGroupingChange = vi.fn(); + mount(schemaWith(), onGroupingChange); + fireEvent.click(screen.getByRole('button', { name: /group/i })); + await vi.waitFor(() => expect(screen.getByTestId('group-field-list')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('grouping-add')); + expect(onGroupingChange).toHaveBeenCalledTimes(1); + const added = onGroupingChange.mock.calls[0][0] as GroupingConfig; + expectSpecShape(added); + expect(added.fields).toHaveLength(1); + + fireEvent.click(screen.getByTestId('grouping-order-0')); + expect(onGroupingChange).toHaveBeenCalledTimes(2); + const flipped = onGroupingChange.mock.calls[1][0] as GroupingConfig; + expectSpecShape(flipped); + expect(flipped.fields[0]).toEqual({ ...added.fields[0], order: 'desc' }); + + fireEvent.click(screen.getByTestId('grouping-remove-0')); + expect(onGroupingChange).toHaveBeenCalledTimes(3); + // The editor's own empty value: no level left is no grouping. + expect(onGroupingChange.mock.calls[2][0]).toBeUndefined(); + }); + + it('the toolbar Group panel: Clear reports `undefined`', async () => { + const onGroupingChange = vi.fn(); + mount(schemaWith({ grouping: { fields: [{ field: 'status', order: 'asc', collapsed: false }] } }), onGroupingChange); + fireEvent.click(screen.getByRole('button', { name: /group/i })); + await vi.waitFor(() => expect(screen.getByTestId('clear-grouping')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('clear-grouping')); + expect(onGroupingChange).toHaveBeenCalledTimes(1); + expect(onGroupingChange).toHaveBeenCalledWith(undefined); + }); + + it('the compact View settings popover: adding a level and its Clear report the same way', async () => { + const onGroupingChange = vi.fn(); + mount(schemaWith({ compactToolbar: true }), onGroupingChange); + // The compact toolbar draws no Group button of its own. + expect(screen.queryByTestId('group-field-list')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('view-settings-trigger')); + await vi.waitFor(() => expect(screen.getByTestId('view-settings-content')).toBeInTheDocument()); + + const content = screen.getByTestId('view-settings-content'); + fireEvent.click(content.querySelector('[data-testid="grouping-add"]')!); + expect(onGroupingChange).toHaveBeenCalledTimes(1); + expectSpecShape(onGroupingChange.mock.calls[0][0]); + + const clear = Array.from(content.querySelectorAll('button')).find((b) => b.textContent === 'Clear'); + expect(clear).toBeDefined(); + fireEvent.click(clear!); + expect(onGroupingChange).toHaveBeenCalledTimes(2); + expect(onGroupingChange.mock.calls[1][0]).toBeUndefined(); + }); + + it('a changed `schema.grouping` is re-read and NOT reported: it is the host\'s own value', async () => { + const onGroupingChange = vi.fn(); + const { rerender } = mount( + schemaWith({ grouping: { fields: [{ field: 'status', order: 'asc', collapsed: false }] } }), + onGroupingChange, + ); + await act(async () => { + rerender(schemaWith({ grouping: { fields: [{ field: 'email', order: 'desc', collapsed: false }] } })); + }); + // The list did take the host's new grouping (the badge counts its levels)… + fireEvent.click(screen.getByRole('button', { name: /group/i })); + await vi.waitFor(() => expect(screen.getByTestId('grouping-order-0')).toBeInTheDocument()); + expect(screen.getByTestId('grouping-order-0').getAttribute('title')).toBe('Descending'); + // …and told the host nothing about it. + expect(onGroupingChange).not.toHaveBeenCalled(); + }); +});