Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/25888-related-list-view-all-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/react': patch
'@object-ui/plugin-detail': patch
'@object-ui/app-shell': patch
---

fix(app-shell): a related list's "View all" opens the rows the list showed, its own `filter` included, or is not offered

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

- **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".
- **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.
- **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.

What to do: nothing. A related list without a `filter` opens the same destination as before.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-detail.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e99ff0993d322375559215401be4cb26cc3179096ff2d15739470dc0cc7e5fcc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e22491dc392ede00c47cdea1d193d7c03c9c01751e65ee851adb17f2038b7ad2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/react.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/react","sources":70,"sha256":"b4e342408561b4e02a5ec126437e0ca184dd96da8cc407d71f079288f882a34d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/react","sources":70,"sha256":"e3ea09e281e39a1d3f30d87b199d3756e88d7ffb48f91a11e777b2806a8bddff","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
41 changes: 25 additions & 16 deletions ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import { actionRendersAt, type ActionLocation } from '@object-ui/types';
import { usePermissions } from '@object-ui/permissions';
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RECORD_TRAIL_PARAM, appendRecordTrail } from '../urlParams.js';
import { documentHref } from './documentHref.js';
import { recordListPath } from './useOpenRecordList.js';
import { exactRecordListFilter, recordListPath } from './useOpenRecordList.js';
import { resolveRecordFormTarget } from '../utils/recordFormNavigation.js';

/**
Expand Down Expand Up @@ -479,7 +479,7 @@ export function RelatedRecordActionsBridge({

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

/**
* "View all" (objectui#12741): the child object's list page, scoped to
* this parent on the list's own relationship. The destination is the
* ADR-0055 bare data surface (`recordListPath`, the route
* `useOpenRecordList` drills to). It is not anchored to a saved view,
* so the page shows every related record rather than the related
* records some default view also lets through.
* this parent on the list's own relationship AND by the list's own
* `filter` (objectstack#25888), so it shows the rows the list showed.
* The destination is the ADR-0055 bare data surface (`recordListPath`,
* the route `useOpenRecordList` drills to). It is not anchored to a
* saved view, so the page shows every related record rather than the
* related records some default view also lets through.
*
* Offered only where that route can say "this parent's children". Its
* `filter[FIELD]=VALUE` arm is EQUALITY, so the handler is withheld:
* Offered only where that route can say exactly that scope, so the
* handler is withheld:
* - without a relationship or a parent value: an unscoped list is
* every record of the object, not this parent's;
* - on a `multiple: true` relationship: membership has no URL
* spelling, and equality against an array of ids matches nothing
* (the reason `RelatedList`'s raw-URL fetch refuses it, objectui#7299);
* - on a relationship field the viewer cannot read: `ObjectDataPage`
* drops that condition and would open the whole table.
* - when `exactRecordListFilter` cannot write every condition: a
* field the viewer cannot read (the relationship or one the filter
* names), which `ObjectDataPage` drops, or a filter condition the
* route has no spelling for. Writing the rest would open a WIDER
* list, and a read gate that admits a reader only on a fully pinned
* read answers a wider read with a smaller slice, not more rows.
* A withheld handler draws no link: no link is better than a wrong list.
*/
if (
const viewAllFilter =
relationshipField &&
parentId != null &&
parentId !== '' &&
!isMultiValueRelationship(childDef.fields, relationshipField) &&
perms.checkField(objectName, relationshipField, 'read')
) {
const viewAllPath = recordListPath(appName, objectName, { [relationshipField]: parentId });
!isMultiValueRelationship(childDef.fields, relationshipField)
? exactRecordListFilter(
[{ [relationshipField]: parentId }, filter],
(field) => perms.checkField(objectName, field, 'read'),
)
: null;
if (viewAllFilter) {
const viewAllPath = recordListPath(appName, objectName, viewAllFilter);
handlers.onViewAll = () => navigate(viewAllPath);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#25888 — a related list's "View all" carries the list's OWN
* `filter` as well as its relationship, so the full list shows the rows the
* list showed.
*
* The defect: the destination kept the relationship (`record_id`) and dropped
* the list's `filter` (`object_name`). For most objects that only widens the
* list. For `sys_approval_request` it emptied it: the request read gate admits
* a record reader only on a read pinning BOTH fields, so a reader who saw two
* requests on the record's Approvals list saw none on "View all".
*
* Every case mounts what a record page mounts, as the objectui#12741 suite
* beside this one does: the real `RelatedRecordActionsBridge` under a router,
* around the `{ type, properties }` node through the real `SchemaRenderer`,
* `@object-ui/plugin-detail`'s registration and the real `RelatedList`. The
* oracle for the destination is the READ side of the same URL family
* (`parseUrlFilterTriples`, what `ObjectDataPage` parses), so the pin does not
* restate how a filter is spelled.
*/

import * as React from 'react';
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import '@testing-library/jest-dom';
import {
FilterScopeProvider,
MetadataCtx,
RecordContextProvider,
SchemaRenderer,
type MetadataContextValue,
} from '@object-ui/react';
import { PermissionProvider } from '@object-ui/permissions';
// Registers `record:related_list`. Module scope on purpose (AGENTS.md, test discipline).
import '@object-ui/plugin-detail';

import { RelatedRecordActionsBridge } from '../RelatedRecordActionsBridge';
import { parseUrlFilterTriples } from '../drillUrlFilters';

/** Desktop, pinned (objectui#8399): under 768px a table related list is a card gallery. */
beforeAll(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
});

afterEach(() => cleanup());

const APP = 'clm';
const PARENT_ROUTE = `/apps/${APP}/contract/record/C-1`;

/**
* The repro's shape: a polymorphic child that points at its parent by
* `object_name` + `record_id`, listed on the parent's record page by
* `relationshipField: 'record_id'` plus a `filter` pinning `object_name`.
*/
const REQUEST = {
name: 'approval_request',
label: 'Approval Request',
fields: {
name: { type: 'text', label: 'Name' },
object_name: { type: 'text', label: 'Object' },
record_id: { type: 'text', label: 'Record' },
owner: { type: 'text', label: 'Owner' },
status: { type: 'text', label: 'Status' },
},
};

const ROWS = [
{ id: 'r1', name: 'First approval', object_name: 'contract', record_id: 'C-1' },
{ id: 'r2', name: 'Second approval', object_name: 'contract', record_id: 'C-1' },
];

/** The list's own scope, authored the way the spec declares it (`ViewFilterRule[]`). */
const PINS_OBJECT = [{ field: 'object_name', operator: 'equals', value: 'contract' }];

const metadata: MetadataContextValue = {
apps: [],
objects: [REQUEST],
dashboards: [],
reports: [],
pages: [],
loading: false,
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async () => [],
getItem: (async () => REQUEST) as unknown as MetadataContextValue['getItem'],
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
};

function RouterLocation() {
const location = useLocation();
return <output data-testid="router-location">{`${location.pathname}${location.search}`}</output>;
}

/** The role-based provider, letting `viewer` read the child with the named fields denied. */
const denying = (fields: string[]) => (node: React.ReactElement) => (
<PermissionProvider
roles={[]}
userRoles={['viewer']}
permissions={
[
{
object: 'approval_request',
roles: { viewer: { actions: ['read'], fieldPermissions: fields.map((field) => ({ field, read: false })) } },
},
] as never
}
>
{node}
</PermissionProvider>
);

function mount({
properties = {},
wrap = (node: React.ReactElement) => node,
}: {
properties?: Record<string, unknown>;
wrap?: (node: React.ReactElement) => React.ReactElement;
} = {}) {
const dataSource = {
find: vi.fn(async () => ROWS),
getObjectSchema: vi.fn(async () => REQUEST),
};
const doc = {
type: 'record:related_list',
properties: {
objectName: 'approval_request',
relationshipField: 'record_id',
columns: ['name'],
...properties,
},
};
render(
<MetadataCtx.Provider value={metadata}>
<MemoryRouter initialEntries={[PARENT_ROUTE]}>
{wrap(
<RelatedRecordActionsBridge
appName={APP}
objects={[REQUEST]}
dataSource={dataSource}
parentObjectName="contract"
parentRecordId="C-1"
parentTitle="MSA"
parentRecord={{ id: 'C-1', name: 'MSA' }}
>
<RecordContextProvider objectName="contract" recordId="C-1" dataSource={dataSource as never}>
<SchemaRenderer schema={doc as never} />
</RecordContextProvider>
</RelatedRecordActionsBridge>,
)}
<RouterLocation />
</MemoryRouter>
</MetadataCtx.Provider>,
);
return dataSource;
}

const viewAll = () => screen.queryByRole('button', { name: /view all/i });
const location = () => screen.getByTestId('router-location').textContent ?? '';
/** Every row painted: each "no link" read below is taken on a list that drew its records. */
const waitForRows = async () => {
await screen.findByText('First approval');
await screen.findByText('Second approval');
};
/** Click "View all" and read the destination back the way `ObjectDataPage` reads it. */
const followViewAll = () => {
const link = viewAll();
expect(link).toBeInTheDocument();
fireEvent.click(link!);
const [path, search] = location().split('?');
expect(path).toBe(`/apps/${APP}/approval_request/data`);
return parseUrlFilterTriples(new URLSearchParams(search));
};
/** Order-free comparison: the list route ANDs its triples, so order says nothing. */
const sorted = (triples: unknown[][]) => [...triples].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));

describe('objectstack#25888 — "View all" carries the related list’s own filter', () => {
it('the repro: a list scoped by `object_name` on a `record_id` relationship opens a list scoped by BOTH', async () => {
mount({ properties: { filter: PINS_OBJECT } });
await waitForRows();
expect(sorted(followViewAll())).toEqual(
sorted([
['record_id', '=', 'C-1'],
['object_name', '=', 'contract'],
]),
);
});

it('CONTROL: with no `filter` the destination carries the relationship alone, as before', async () => {
mount();
await waitForRows();
expect(followViewAll()).toEqual([['record_id', '=', 'C-1']]);
});

it('a placeholder in the filter is written RESOLVED, against the scope the list read its rows with', async () => {
mount({
properties: { filter: [{ field: 'owner', operator: 'equals', value: '{current_user_id}' }] },
wrap: (node) => <FilterScopeProvider currentUserId="usr_7">{node}</FilterScopeProvider>,
});
await waitForRows();
expect(sorted(followViewAll())).toEqual(
sorted([
['record_id', '=', 'C-1'],
['owner', '=', 'usr_7'],
]),
);
});

it('a range and an is-null condition travel too: the route has a spelling for each', async () => {
mount({
properties: {
filter: [
['name', '>=', 'A'],
['status', 'is_null', true],
],
},
});
await waitForRows();
expect(sorted(followViewAll())).toEqual(
sorted([
['record_id', '=', 'C-1'],
['name', '>=', 'A'],
['status', 'is_null', true],
]),
);
});

it('a filter with an `or` draws no link: the route cannot say it, and dropping it would widen the list', async () => {
mount({
properties: {
filter: ['or', ['status', '=', 'pending'], ['status', '=', 'approved']],
},
});
await waitForRows();
expect(viewAll()).toBeNull();
});

it('a filter on a field the viewer cannot read draws no link: `ObjectDataPage` would drop that condition', async () => {
mount({ properties: { filter: PINS_OBJECT }, wrap: denying(['object_name']) });
await waitForRows();
expect(viewAll()).toBeNull();
});

it('CONTROL: under the same provider, denying a field the filter does not name, the link draws and carries both', async () => {
mount({ properties: { filter: PINS_OBJECT }, wrap: denying(['owner']) });
await waitForRows();
expect(sorted(followViewAll())).toEqual(
sorted([
['record_id', '=', 'C-1'],
['object_name', '=', 'contract'],
]),
);
});
});
Loading
Loading