Skip to content

Commit 4f7e041

Browse files
fix(app-shell): a related list's View all carries the list's own filter, or is not offered (#26060)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9AHezFqxq2NEBfcwsXbEqsF)_ Fixes #25888 Clause-②: yes (an optional member on the exported `ResolveRelatedRecordActionsInput` of `@object-ui/react` widens its public type surface, additive; the seat owes a TIER_DEFAULT review record before the queue; re-declared if the PR changes no exported type) Metadata-protocol: none ## What was wrong A `record:related_list` "View all" link opened the child object's list scoped by the list's relationship alone. The list's own `filter` never reached the host that builds the link: the renderer's `resolve` call handed the bridge `objectName`, `relationshipField` and `parentId` only, and the bridge built `recordListPath(appName, objectName, { [relationshipField]: parentId })`. On the card's repro (Approvals on a record page, filter `object_name`, relationship `record_id`) the destination read `filter[record_id]=ID` only, and the approvals request read gate, which admits a record reader only on a read pinning both fields, answered it with the empty participant slice: 2 rows on the tab, 0 on "View all". ## What changed - **`@object-ui/react`** — `ResolveRelatedRecordActionsInput` gains an optional `filter` (`ViewFilterRule[] | FilterNode`): the list's own scope filter, already resolved. The `onViewAll` doc now says the destination is the relationship AND that filter, and names the cases where the handler is withheld. - **`@object-ui/plugin-detail`** (`record-related-list.tsx`) — resolves `schema.filter` with `useResolvedFilter(schema.filter, useFilterScope())`, the same hook and scope `RelatedList` uses for its own query at the same place in the tree, and hands that value to `resolve`. Resolving here and not in the bridge is deliberate: the bridge is mounted above the page's `RecordContextProvider`, so its filter scope has no `recordId` and a `{record_id}` token would stay unresolved there while the tab resolved it. - **`@object-ui/app-shell`** — new `exactRecordListFilter(sources, canRead)` beside `recordListPath` in `useOpenRecordList.ts`: the AND of the sources as a filter the list route writes EXACTLY, or `null`. The bridge passes `[{ [relationshipField]: parentId }, filter]` and draws "View all" only on a non-null answer. ## The open question, settled: a filter the URL cannot express Ruling: never let the full list show rows the tab did not. Writing only the expressible subset of an AND is always a wider (or, behind a pinning read gate, a different) list, so there is no safe subset: either every condition is written or the link is withheld. `exactRecordListFilter` writes exactly the route's grammar (equality to a non-empty string or finite number, the four range bounds, `$null` / `$exists` / `$empty` with a boolean, conjunction) and answers `null` for anything else: `$or` and other combinators, `$in`, `$ne`, text operators, a boolean / `null` / `Date` comparand, an empty string (the read side skips an empty param), two different comparands in one param (including a filter that contradicts the relationship), a filter the lowering refuses, and any condition on a field the viewer cannot read (`ObjectDataPage` drops such a condition). Each accepted condition is proved by its own round trip: written alone it must read back through `parseUrlFilterTriples` as exactly one triple. Placeholders (`{current_user_id}`, `{record_id}`, the date macros) are resolved before this point, by the renderer, against the list's own scope. ## Tests - `RelatedRecordActionsBridge.viewAllFilter-25888.test.tsx` (real bridge + `SchemaRenderer` + `RelatedList`, oracle `parseUrlFilterTriples`): the repro carries both `record_id` and `object_name`; CONTROL with no `filter` carries the relationship alone; a `{current_user_id}` filter is written resolved; range + is-null travel; an `or` filter draws no link; a filter field the viewer cannot read draws no link; CONTROL denying an unrelated field still draws and carries both. - `exactRecordListFilter-25888.test.ts`: every spelling round-trips, agreeing duplicates collapse, nine inexpressible shapes refuse, `canRead` refuses for the relationship and the filter field (control: an unrelated field), a lowering refusal is `null`, never "no filter". - `RelatedRecordActionsBridge.viewAll-12741.test.tsx` (existing) stays green. ## Verification (head `a281cecb`, after merging `origin/main`) - New + existing View all suites: 3 files, 27/27 passed. Ablation: with the bridge's `filter` argument removed (`ablation-replace`, anchor 1 -> 0, restored to the HEAD blob, `git diff HEAD` empty) the 25888 bridge suite went 6 failed / 1 passed, the one survivor being the no-filter CONTROL. - Related suites across the three packages (every app-shell test file named for related records / related lists / `useOpenRecordList` / drill / View all, every plugin-detail related-list file, every react file reading `RelatedRecordActions` or `useResolvedFilter`): 73 files, 455/455 passed. - `type-check` for `@object-ui/react`, `@object-ui/plugin-detail`, `@object-ui/app-shell`: exit 0, after building the app-shell dependency closure on the merged tree. - Derived gate families (`dispatch-gates --commands`, no paths): 46 run, every one exit 0, plus `check-changeset-fixed` exit 0; `--ran` reconciles 46/46 with 0 NOT-MEASURED. - Declared narrowing: the three packages' FULL suites were not run locally (an attempt exceeded the 2-hour background ceiling and was stopped with no reading); CI's `Build & E2E UI` and `Test Core` run them. ## Acceptance notes - `scripts/sdui-manifest.record.json` is re-recorded (registry source hash only; `check-sdui-manifest` reds on any `ui/packages/**` source edit). It was outside the claim's declared file surface; it is a regenerated artifact, `node scripts/gen-sdui-manifest-node.mjs`, 106 components unchanged. - A related list whose filter holds a boolean equality (`done = true`) now draws no "View all". The URL carries text, and whether `filter[done]=true` reads back as the list's boolean comparison is unmeasured, so the fail-closed answer is taken. Widening the route's grammar (booleans, `$in`, `$or`) is a separate change to the `/data` URL contract, not this fix. --- _Generated by [Claude Code](https://claude.ai/code/session_01HYEgis8rPZK6Dr3G5Jas4q)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HYEgis8rPZK6Dr3G5Jas4q --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e05e0de commit 4f7e041

9 files changed

Lines changed: 547 additions & 25 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/react': patch
3+
'@object-ui/plugin-detail': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
fix(app-shell): a related list's "View all" opens the rows the list showed, its own `filter` included, or is not offered
8+
9+
Clause-②: yes (an optional member on the exported `ResolveRelatedRecordActionsInput` of `@object-ui/react` widens its public type surface, additive; the seat owes a TIER_DEFAULT review record before the queue; re-declared if the PR changes no exported type)
10+
Metadata-protocol: none
11+
12+
- **The defect.** A `record:related_list` "View all" link opened the child object's list scoped by the list's relationship alone and dropped the list's own `filter`. The full list was wider than the tab, and on `sys_approval_request` it was empty: the request read gate admits a record reader only on a read that pins both `object_name` and `record_id`, so a reader who saw the requests on a record's Approvals list saw none on "View all".
13+
- **The fix.** The destination now carries every condition of the list's `filter`, resolved the way the list resolved it for its own query (`{current_user_id}`, `{record_id}` and the date macros), ANDed with the relationship. When the list route has no spelling for a condition (an `or`, a set or text operator, a boolean comparand), or a condition names a field the viewer cannot read, no link is drawn: the route would drop that condition and open a wider list.
14+
- **The type.** `ResolveRelatedRecordActionsInput` gains an optional `filter`, the list's own resolved scope filter. A host implementing `resolve` reads it to scope any destination it builds for the list's rows; a host that ignores it keeps compiling.
15+
16+
What to do: nothing. A related list without a `filter` opens the same destination as before.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e99ff0993d322375559215401be4cb26cc3179096ff2d15739470dc0cc7e5fcc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e22491dc392ede00c47cdea1d193d7c03c9c01751e65ee851adb17f2038b7ad2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/react","sources":70,"sha256":"b4e342408561b4e02a5ec126437e0ca184dd96da8cc407d71f079288f882a34d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/react","sources":70,"sha256":"e3ea09e281e39a1d3f30d87b199d3756e88d7ffb48f91a11e777b2806a8bddff","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}

‎ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ import { actionRendersAt, type ActionLocation } from '@object-ui/types';
8989
import { usePermissions } from '@object-ui/permissions';
9090
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RECORD_TRAIL_PARAM, appendRecordTrail } from '../urlParams.js';
9191
import { documentHref } from './documentHref.js';
92-
import { recordListPath } from './useOpenRecordList.js';
92+
import { exactRecordListFilter, recordListPath } from './useOpenRecordList.js';
9393
import { resolveRecordFormTarget } from '../utils/recordFormNavigation.js';
9494

9595
/**
@@ -479,7 +479,7 @@ export function RelatedRecordActionsBridge({
479479

480480
const value = useMemo<RelatedRecordActionsValue>(
481481
() => ({
482-
resolve: ({ objectName, relationshipField, parentId }) => {
482+
resolve: ({ objectName, relationshipField, parentId, filter }) => {
483483
const childDef = objects.find((o: any) => o?.name === objectName);
484484
if (!childDef || !base) return {} as RelatedRecordHandlers;
485485
// [objectstack#3546] Intersect the child object's bucket affordances with the
@@ -555,31 +555,40 @@ export function RelatedRecordActionsBridge({
555555

556556
/**
557557
* "View all" (objectui#12741): the child object's list page, scoped to
558-
* this parent on the list's own relationship. The destination is the
559-
* ADR-0055 bare data surface (`recordListPath`, the route
560-
* `useOpenRecordList` drills to). It is not anchored to a saved view,
561-
* so the page shows every related record rather than the related
562-
* records some default view also lets through.
558+
* this parent on the list's own relationship AND by the list's own
559+
* `filter` (objectstack#25888), so it shows the rows the list showed.
560+
* The destination is the ADR-0055 bare data surface (`recordListPath`,
561+
* the route `useOpenRecordList` drills to). It is not anchored to a
562+
* saved view, so the page shows every related record rather than the
563+
* related records some default view also lets through.
563564
*
564-
* Offered only where that route can say "this parent's children". Its
565-
* `filter[FIELD]=VALUE` arm is EQUALITY, so the handler is withheld:
565+
* Offered only where that route can say exactly that scope, so the
566+
* handler is withheld:
566567
* - without a relationship or a parent value: an unscoped list is
567568
* every record of the object, not this parent's;
568569
* - on a `multiple: true` relationship: membership has no URL
569570
* spelling, and equality against an array of ids matches nothing
570571
* (the reason `RelatedList`'s raw-URL fetch refuses it, objectui#7299);
571-
* - on a relationship field the viewer cannot read: `ObjectDataPage`
572-
* drops that condition and would open the whole table.
572+
* - when `exactRecordListFilter` cannot write every condition: a
573+
* field the viewer cannot read (the relationship or one the filter
574+
* names), which `ObjectDataPage` drops, or a filter condition the
575+
* route has no spelling for. Writing the rest would open a WIDER
576+
* list, and a read gate that admits a reader only on a fully pinned
577+
* read answers a wider read with a smaller slice, not more rows.
573578
* A withheld handler draws no link: no link is better than a wrong list.
574579
*/
575-
if (
580+
const viewAllFilter =
576581
relationshipField &&
577582
parentId != null &&
578583
parentId !== '' &&
579-
!isMultiValueRelationship(childDef.fields, relationshipField) &&
580-
perms.checkField(objectName, relationshipField, 'read')
581-
) {
582-
const viewAllPath = recordListPath(appName, objectName, { [relationshipField]: parentId });
584+
!isMultiValueRelationship(childDef.fields, relationshipField)
585+
? exactRecordListFilter(
586+
[{ [relationshipField]: parentId }, filter],
587+
(field) => perms.checkField(objectName, field, 'read'),
588+
)
589+
: null;
590+
if (viewAllFilter) {
591+
const viewAllPath = recordListPath(appName, objectName, viewAllFilter);
583592
handlers.onViewAll = () => navigate(viewAllPath);
584593
}
585594

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#25888 — a related list's "View all" carries the list's OWN
5+
* `filter` as well as its relationship, so the full list shows the rows the
6+
* list showed.
7+
*
8+
* The defect: the destination kept the relationship (`record_id`) and dropped
9+
* the list's `filter` (`object_name`). For most objects that only widens the
10+
* list. For `sys_approval_request` it emptied it: the request read gate admits
11+
* a record reader only on a read pinning BOTH fields, so a reader who saw two
12+
* requests on the record's Approvals list saw none on "View all".
13+
*
14+
* Every case mounts what a record page mounts, as the objectui#12741 suite
15+
* beside this one does: the real `RelatedRecordActionsBridge` under a router,
16+
* around the `{ type, properties }` node through the real `SchemaRenderer`,
17+
* `@object-ui/plugin-detail`'s registration and the real `RelatedList`. The
18+
* oracle for the destination is the READ side of the same URL family
19+
* (`parseUrlFilterTriples`, what `ObjectDataPage` parses), so the pin does not
20+
* restate how a filter is spelled.
21+
*/
22+
23+
import * as React from 'react';
24+
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
25+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
26+
import { MemoryRouter, useLocation } from 'react-router-dom';
27+
import '@testing-library/jest-dom';
28+
import {
29+
FilterScopeProvider,
30+
MetadataCtx,
31+
RecordContextProvider,
32+
SchemaRenderer,
33+
type MetadataContextValue,
34+
} from '@object-ui/react';
35+
import { PermissionProvider } from '@object-ui/permissions';
36+
// Registers `record:related_list`. Module scope on purpose (AGENTS.md, test discipline).
37+
import '@object-ui/plugin-detail';
38+
39+
import { RelatedRecordActionsBridge } from '../RelatedRecordActionsBridge';
40+
import { parseUrlFilterTriples } from '../drillUrlFilters';
41+
42+
/** Desktop, pinned (objectui#8399): under 768px a table related list is a card gallery. */
43+
beforeAll(() => {
44+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
45+
});
46+
47+
afterEach(() => cleanup());
48+
49+
const APP = 'clm';
50+
const PARENT_ROUTE = `/apps/${APP}/contract/record/C-1`;
51+
52+
/**
53+
* The repro's shape: a polymorphic child that points at its parent by
54+
* `object_name` + `record_id`, listed on the parent's record page by
55+
* `relationshipField: 'record_id'` plus a `filter` pinning `object_name`.
56+
*/
57+
const REQUEST = {
58+
name: 'approval_request',
59+
label: 'Approval Request',
60+
fields: {
61+
name: { type: 'text', label: 'Name' },
62+
object_name: { type: 'text', label: 'Object' },
63+
record_id: { type: 'text', label: 'Record' },
64+
owner: { type: 'text', label: 'Owner' },
65+
status: { type: 'text', label: 'Status' },
66+
},
67+
};
68+
69+
const ROWS = [
70+
{ id: 'r1', name: 'First approval', object_name: 'contract', record_id: 'C-1' },
71+
{ id: 'r2', name: 'Second approval', object_name: 'contract', record_id: 'C-1' },
72+
];
73+
74+
/** The list's own scope, authored the way the spec declares it (`ViewFilterRule[]`). */
75+
const PINS_OBJECT = [{ field: 'object_name', operator: 'equals', value: 'contract' }];
76+
77+
const metadata: MetadataContextValue = {
78+
apps: [],
79+
objects: [REQUEST],
80+
dashboards: [],
81+
reports: [],
82+
pages: [],
83+
loading: false,
84+
error: null,
85+
refresh: async () => {},
86+
invalidate: () => {},
87+
ensureType: async () => [],
88+
getItem: (async () => REQUEST) as unknown as MetadataContextValue['getItem'],
89+
getItemsByType: () => [],
90+
getTypeStatus: () => 'ready' as const,
91+
};
92+
93+
function RouterLocation() {
94+
const location = useLocation();
95+
return <output data-testid="router-location">{`${location.pathname}${location.search}`}</output>;
96+
}
97+
98+
/** The role-based provider, letting `viewer` read the child with the named fields denied. */
99+
const denying = (fields: string[]) => (node: React.ReactElement) => (
100+
<PermissionProvider
101+
roles={[]}
102+
userRoles={['viewer']}
103+
permissions={
104+
[
105+
{
106+
object: 'approval_request',
107+
roles: { viewer: { actions: ['read'], fieldPermissions: fields.map((field) => ({ field, read: false })) } },
108+
},
109+
] as never
110+
}
111+
>
112+
{node}
113+
</PermissionProvider>
114+
);
115+
116+
function mount({
117+
properties = {},
118+
wrap = (node: React.ReactElement) => node,
119+
}: {
120+
properties?: Record<string, unknown>;
121+
wrap?: (node: React.ReactElement) => React.ReactElement;
122+
} = {}) {
123+
const dataSource = {
124+
find: vi.fn(async () => ROWS),
125+
getObjectSchema: vi.fn(async () => REQUEST),
126+
};
127+
const doc = {
128+
type: 'record:related_list',
129+
properties: {
130+
objectName: 'approval_request',
131+
relationshipField: 'record_id',
132+
columns: ['name'],
133+
...properties,
134+
},
135+
};
136+
render(
137+
<MetadataCtx.Provider value={metadata}>
138+
<MemoryRouter initialEntries={[PARENT_ROUTE]}>
139+
{wrap(
140+
<RelatedRecordActionsBridge
141+
appName={APP}
142+
objects={[REQUEST]}
143+
dataSource={dataSource}
144+
parentObjectName="contract"
145+
parentRecordId="C-1"
146+
parentTitle="MSA"
147+
parentRecord={{ id: 'C-1', name: 'MSA' }}
148+
>
149+
<RecordContextProvider objectName="contract" recordId="C-1" dataSource={dataSource as never}>
150+
<SchemaRenderer schema={doc as never} />
151+
</RecordContextProvider>
152+
</RelatedRecordActionsBridge>,
153+
)}
154+
<RouterLocation />
155+
</MemoryRouter>
156+
</MetadataCtx.Provider>,
157+
);
158+
return dataSource;
159+
}
160+
161+
const viewAll = () => screen.queryByRole('button', { name: /view all/i });
162+
const location = () => screen.getByTestId('router-location').textContent ?? '';
163+
/** Every row painted: each "no link" read below is taken on a list that drew its records. */
164+
const waitForRows = async () => {
165+
await screen.findByText('First approval');
166+
await screen.findByText('Second approval');
167+
};
168+
/** Click "View all" and read the destination back the way `ObjectDataPage` reads it. */
169+
const followViewAll = () => {
170+
const link = viewAll();
171+
expect(link).toBeInTheDocument();
172+
fireEvent.click(link!);
173+
const [path, search] = location().split('?');
174+
expect(path).toBe(`/apps/${APP}/approval_request/data`);
175+
return parseUrlFilterTriples(new URLSearchParams(search));
176+
};
177+
/** Order-free comparison: the list route ANDs its triples, so order says nothing. */
178+
const sorted = (triples: unknown[][]) => [...triples].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
179+
180+
describe('objectstack#25888 — "View all" carries the related list’s own filter', () => {
181+
it('the repro: a list scoped by `object_name` on a `record_id` relationship opens a list scoped by BOTH', async () => {
182+
mount({ properties: { filter: PINS_OBJECT } });
183+
await waitForRows();
184+
expect(sorted(followViewAll())).toEqual(
185+
sorted([
186+
['record_id', '=', 'C-1'],
187+
['object_name', '=', 'contract'],
188+
]),
189+
);
190+
});
191+
192+
it('CONTROL: with no `filter` the destination carries the relationship alone, as before', async () => {
193+
mount();
194+
await waitForRows();
195+
expect(followViewAll()).toEqual([['record_id', '=', 'C-1']]);
196+
});
197+
198+
it('a placeholder in the filter is written RESOLVED, against the scope the list read its rows with', async () => {
199+
mount({
200+
properties: { filter: [{ field: 'owner', operator: 'equals', value: '{current_user_id}' }] },
201+
wrap: (node) => <FilterScopeProvider currentUserId="usr_7">{node}</FilterScopeProvider>,
202+
});
203+
await waitForRows();
204+
expect(sorted(followViewAll())).toEqual(
205+
sorted([
206+
['record_id', '=', 'C-1'],
207+
['owner', '=', 'usr_7'],
208+
]),
209+
);
210+
});
211+
212+
it('a range and an is-null condition travel too: the route has a spelling for each', async () => {
213+
mount({
214+
properties: {
215+
filter: [
216+
['name', '>=', 'A'],
217+
['status', 'is_null', true],
218+
],
219+
},
220+
});
221+
await waitForRows();
222+
expect(sorted(followViewAll())).toEqual(
223+
sorted([
224+
['record_id', '=', 'C-1'],
225+
['name', '>=', 'A'],
226+
['status', 'is_null', true],
227+
]),
228+
);
229+
});
230+
231+
it('a filter with an `or` draws no link: the route cannot say it, and dropping it would widen the list', async () => {
232+
mount({
233+
properties: {
234+
filter: ['or', ['status', '=', 'pending'], ['status', '=', 'approved']],
235+
},
236+
});
237+
await waitForRows();
238+
expect(viewAll()).toBeNull();
239+
});
240+
241+
it('a filter on a field the viewer cannot read draws no link: `ObjectDataPage` would drop that condition', async () => {
242+
mount({ properties: { filter: PINS_OBJECT }, wrap: denying(['object_name']) });
243+
await waitForRows();
244+
expect(viewAll()).toBeNull();
245+
});
246+
247+
it('CONTROL: under the same provider, denying a field the filter does not name, the link draws and carries both', async () => {
248+
mount({ properties: { filter: PINS_OBJECT }, wrap: denying(['owner']) });
249+
await waitForRows();
250+
expect(sorted(followViewAll())).toEqual(
251+
sorted([
252+
['record_id', '=', 'C-1'],
253+
['object_name', '=', 'contract'],
254+
]),
255+
);
256+
});
257+
});

0 commit comments

Comments
 (0)