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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/11860-grouping-url.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .changeset/11860-list-url-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
171 changes: 166 additions & 5 deletions packages/app-shell/src/views/ObjectView.listUrlState-11860.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`;
Expand All @@ -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<any[]> | undefined;
/** The rows `find` answers with. A list with none shows its empty state and mounts no grid. */
let rows: Array<Record<string, unknown>> = [];

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 () => ({})),
Expand Down Expand Up @@ -191,11 +212,12 @@ async function mountHost(entries: string[]): Promise<Host> {
}

/** Build a query string the way a copied link carries one. */
function link(path: string, state: { filter?: unknown; search?: string; sort?: unknown; extra?: Record<string, string> }) {
function link(path: string, state: { filter?: unknown; search?: string; sort?: unknown; group?: unknown; extra?: Record<string, string> }) {
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()}`;
}

Expand All @@ -209,6 +231,8 @@ beforeEach(() => {
cleanup();
localStorage.clear();
listQueries = [];
groupQueries = [];
rows = [];
failWith = undefined;
savedViews = undefined;
perms.isLoaded = false;
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ const ABSENCES: Record<string, Absence> = {
// 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)." },

Expand Down
26 changes: 20 additions & 6 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading