From cb4b6798de896b859d0637cb14e8adf00d595d9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:46:33 +0000 Subject: [PATCH 1/5] fix(app-shell): a related list's View all carries the list's own filter, or is not offered Claude-Session: https://claude.ai/code/session_01HYEgis8rPZK6Dr3G5Jas4q Co-authored-by: Claude --- .../src/views/RelatedRecordActionsBridge.tsx | 41 +-- ...ActionsBridge.viewAllFilter-25888.test.tsx | 257 ++++++++++++++++++ .../exactRecordListFilter-25888.test.ts | 102 +++++++ .../app-shell/src/views/useOpenRecordList.ts | 115 +++++++- .../src/renderers/record-related-list.tsx | 10 +- .../context/RelatedRecordActionsContext.tsx | 27 +- 6 files changed, 529 insertions(+), 23 deletions(-) create mode 100644 ui/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.viewAllFilter-25888.test.tsx create mode 100644 ui/packages/app-shell/src/views/__tests__/exactRecordListFilter-25888.test.ts diff --git a/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx b/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx index b78734b322b..39902d7a1e8 100644 --- a/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx +++ b/ui/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx @@ -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'; /** @@ -479,7 +479,7 @@ export function RelatedRecordActionsBridge({ const value = useMemo( () => ({ - 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 @@ -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); } diff --git a/ui/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.viewAllFilter-25888.test.tsx b/ui/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.viewAllFilter-25888.test.tsx new file mode 100644 index 00000000000..497017fecb4 --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.viewAllFilter-25888.test.tsx @@ -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 {`${location.pathname}${location.search}`}; +} + +/** The role-based provider, letting `viewer` read the child with the named fields denied. */ +const denying = (fields: string[]) => (node: React.ReactElement) => ( + ({ field, read: false })) } }, + }, + ] as never + } + > + {node} + +); + +function mount({ + properties = {}, + wrap = (node: React.ReactElement) => node, +}: { + properties?: Record; + 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( + + + {wrap( + + + + + , + )} + + + , + ); + 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) => {node}, + }); + 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'], + ]), + ); + }); +}); diff --git a/ui/packages/app-shell/src/views/__tests__/exactRecordListFilter-25888.test.ts b/ui/packages/app-shell/src/views/__tests__/exactRecordListFilter-25888.test.ts new file mode 100644 index 00000000000..c7ad6efb049 --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/exactRecordListFilter-25888.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#25888 — `exactRecordListFilter`, the write-side gate that lets a + * related list's "View all" carry the list's own filter: every condition the + * list route can spell is written, and anything it cannot spell refuses the + * whole destination (`null`) rather than opening a wider list. + * + * The oracle is the route's READ side (`parseUrlFilterTriples`, what + * `ObjectDataPage` parses), so these pins hold the round trip, not a spelling. + */ + +import { describe, it, expect } from 'vitest'; +import { exactRecordListFilter, recordListPath } from '../useOpenRecordList'; +import { parseUrlFilterTriples } from '../drillUrlFilters'; + +const readAll = () => true; +const PARENT = { record_id: 'C-1' }; + +/** The triples the list route reads back from what the function wrote. */ +function roundTrip(filter: Record | null) { + expect(filter).not.toBeNull(); + const search = recordListPath('app', 'obj', filter!).split('?')[1] ?? ''; + return parseUrlFilterTriples(new URLSearchParams(search)); +} + +describe('exactRecordListFilter — the AND of the sources, written exactly or not at all', () => { + it('writes the parent relationship and an authored rule array as one conjunction', () => { + const filter = exactRecordListFilter( + [PARENT, [{ field: 'object_name', operator: 'equals', value: 'contract' }]], + readAll, + ); + expect(roundTrip(filter)).toEqual([ + ['record_id', '=', 'C-1'], + ['object_name', '=', 'contract'], + ]); + }); + + it('an absent or empty list filter adds nothing', () => { + expect(roundTrip(exactRecordListFilter([PARENT, undefined], readAll))).toEqual([['record_id', '=', 'C-1']]); + expect(roundTrip(exactRecordListFilter([PARENT, []], readAll))).toEqual([['record_id', '=', 'C-1']]); + }); + + it('writes every spelling the route has: equality, the four bounds, is-null both ways, is-empty', () => { + const filter = exactRecordListFilter( + [ + PARENT, + { + amount: { $gte: 10, $lt: 100 }, + score: { $gt: 1, $lte: 9 }, + closed_at: { $null: true }, + owner: { $exists: true }, + notes: { $empty: false }, + stage: { $eq: 'won' }, + }, + ], + readAll, + ); + expect(roundTrip(filter)).toEqual([ + ['record_id', '=', 'C-1'], + ['amount', '>=', '10'], + ['amount', '<', '100'], + ['score', '>', '1'], + ['score', '<=', '9'], + ['closed_at', 'is_null', true], + ['owner', 'is_not_null', true], + ['notes', 'is_not_empty', true], + ['stage', '=', 'won'], + ]); + }); + + it('a repeated condition that agrees is written once', () => { + const filter = exactRecordListFilter([PARENT, [['record_id', '=', 'C-1']]], readAll); + expect(roundTrip(filter)).toEqual([['record_id', '=', 'C-1']]); + }); + + it.each([ + ['an `or`', ['or', ['status', '=', 'a'], ['status', '=', 'b']]], + ['a set operator', [['status', 'in', ['a', 'b']]]], + ['a negation', [['status', '!=', 'a']]], + ['a text operator', [['name', 'contains', 'x']]], + ['a boolean comparand', [['done', '=', true]]], + ['an empty-string comparand', [['status', '=', '']]], + ['a field-reference comparand', { amount: { $eq: { $field: 'budget' } } }], + ['a second, different comparand for the relationship', [['record_id', '=', 'C-2']]], + ['two different comparands in one bound', { $and: [{ amount: { $gte: 1 } }, { amount: { $gte: 2 } }] }], + ])('refuses %s: the route cannot say it, and dropping it would widen the list', (_label, scope) => { + expect(exactRecordListFilter([PARENT, scope], readAll)).toBeNull(); + }); + + it('refuses a condition on a field `canRead` denies — the relationship included', () => { + const scope = [{ field: 'object_name', operator: 'equals', value: 'contract' }]; + expect(exactRecordListFilter([PARENT, scope], (f) => f !== 'object_name')).toBeNull(); + expect(exactRecordListFilter([PARENT, scope], (f) => f !== 'record_id')).toBeNull(); + // CONTROL: denying a field neither source names changes nothing. + expect(exactRecordListFilter([PARENT, scope], (f) => f !== 'owner')).not.toBeNull(); + }); + + it('refuses a filter the lowering itself refuses, instead of reading it as "no filter"', () => { + expect(exactRecordListFilter([PARENT, [{ field: 'name', operator: 'no_such_op', value: 'x' }]], readAll)).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/useOpenRecordList.ts b/ui/packages/app-shell/src/views/useOpenRecordList.ts index 9858729f765..a76d747c4ad 100644 --- a/ui/packages/app-shell/src/views/useOpenRecordList.ts +++ b/ui/packages/app-shell/src/views/useOpenRecordList.ts @@ -8,9 +8,10 @@ import { useCallback } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { resolveFilterPlaceholders } from '@object-ui/core'; +import { resolveFilterPlaceholders, toFilterNodeSafely } from '@object-ui/core'; import { useFilterScope } from '@object-ui/react'; -import { serializeDrillFilterParams } from './drillUrlFilters.js'; +import { parseFilterAST } from '@objectstack/spec/data'; +import { parseUrlFilterTriples, RANGE_OP_PARAM, serializeDrillFilterParams } from './drillUrlFilters.js'; /** * The router path of an object's ADR-0055 bare data surface, scoped by @@ -39,6 +40,116 @@ export function recordListPath( return `${base}/${objectName}/data${qs ? `?${qs}` : ''}`; } +/** + * The AND of `sources` as a filter {@link recordListPath} writes EXACTLY, or + * `null` when the list route cannot say it (objectstack#25888). + * + * A related list's "View all" opens the rows the list showed: its parent + * relationship AND its own `filter`. Dropping a condition widens the + * destination, and a widened read is not always more rows: a read gate that + * admits a reader only on a read pinning two fields (the approvals request + * gate pins `object_name` + `record_id`) answers the read that lost one pin + * with a different, smaller slice. So every condition is written, or no + * destination is offered at all. ⛔ Never a subset: the AND of fewer + * conditions is a wider list than the one the user clicked through. + * + * Each source is anything the list itself lowers (a `ViewFilterRule[]`, an + * ObjectQL AST node, the object dialect), with its placeholders already + * resolved: this function does not read a scope. What it writes is the + * route's grammar and nothing else: + * + * - equality to a non-empty string or a finite number, `filter[FIELD]=VALUE`; + * - the four range bounds `$gte` / `$lte` / `$gt` / `$lt`, same comparands; + * - the is-null pair (`$null`, or `$exists` inverted) and `$empty`, each + * with a boolean; + * - conjunction, which the route spells by writing several params. + * + * Everything else answers `null`: `$or` and every other combinator, `$in`, + * `$ne`, the text operators, a boolean, `null` or `Date` comparand (the route + * carries text, and those round-trips are not the list's own comparison), an + * empty string (the read side skips an empty param), two different comparands + * in one param (one key holds one value), a filter the lowering refuses, and a + * condition on a field `canRead` denies (`ObjectDataPage` drops a condition on + * an unreadable field and would open the list without it). + * + * Every condition is also proved by its own round trip before it is accepted: + * written alone, it must read back through `parseUrlFilterTriples` (what + * `ObjectDataPage` parses) as exactly one triple. + */ +export function exactRecordListFilter( + sources: readonly unknown[], + canRead: (field: string) => boolean, +): Record | null { + const conditions: Record[] = []; + /** Param name → the value written there, so a second, different comparand is caught. */ + const written = new Map(); + + const isComparand = (v: unknown): v is string | number => + (typeof v === 'string' && v !== '') || (typeof v === 'number' && Number.isFinite(v)); + + const add = (field: string, condition: Record): boolean => { + if (!canRead(field)) return false; + const params = serializeDrillFilterParams(condition); + const entries = Array.from(params.entries()); + if (entries.length !== 1 || parseUrlFilterTriples(params).length !== 1) return false; + const [name, value] = entries[0]; + const prior = written.get(name); + if (prior !== undefined) return prior === value; + written.set(name, value); + conditions.push(condition); + return true; + }; + + const collect = (node: unknown): boolean => { + if (!isPlainObject(node)) return false; + for (const [key, value] of Object.entries(node)) { + if (key === '$and') { + if (!Array.isArray(value) || !value.every(collect)) return false; + continue; + } + if (key.startsWith('$')) return false; + if (isComparand(value)) { + if (!add(key, { [key]: value })) return false; + continue; + } + if (!isPlainObject(value)) return false; + for (const [op, operand] of Object.entries(value)) { + let ok: boolean; + if (op === '$eq' && isComparand(operand)) ok = add(key, { [key]: operand }); + else if (Object.prototype.hasOwnProperty.call(RANGE_OP_PARAM, op) && isComparand(operand)) ok = add(key, { [key]: { [op]: operand } }); + else if ((op === '$null' || op === '$empty') && typeof operand === 'boolean') ok = add(key, { [key]: { [op]: operand } }); + else if (op === '$exists' && typeof operand === 'boolean') ok = add(key, { [key]: { $null: !operand } }); + else ok = false; + if (!ok) return false; + } + } + return true; + }; + + for (const source of sources) { + const lowered = toFilterNodeSafely(source); + if (!lowered.ok) return null; + if (lowered.node === undefined) continue; + let condition: unknown; + try { + condition = parseFilterAST(lowered.node); + } catch (error) { + // The spec's comparand doors refuse with the `INVALID_FILTER` envelope; + // anything else is a defect, not a statement about this filter. + if ((error as { code?: unknown } | null)?.code !== 'INVALID_FILTER') throw error; + return null; + } + if (condition !== undefined && !collect(condition)) return null; + } + return { $and: conditions }; +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + /** * `useOpenRecordList` — the console's implementation of the drill "escape * hatch" (`DrillNavigationContext.openRecordList`). diff --git a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx index ad2cd164c6a..8409a155536 100644 --- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -16,8 +16,10 @@ import React from 'react'; import { ElementDataSourceGate, useActionTextLocalizer, + useFilterScope, useMetadataItem, useRecordContext, + useResolvedFilter, useSafeFieldLabel, useRelatedRecordActions, type ElementDataSourceMapping, @@ -263,6 +265,11 @@ const RecordRelatedListBody: React.FC = ({ : ((ctx?.data as any)?.[relationshipValueField] ?? null); const relatedActions = useRelatedRecordActions(); + // The list's own filter, resolved the way `RelatedList` resolves it for its + // query (same hook, same scope, at the same place in the tree), so the host + // scopes "View all" by the value the rows were read with (objectstack#25888). + const filterScope = useFilterScope(); + const resolvedListFilter = useResolvedFilter(schema.filter, filterScope); const handlers = React.useMemo( () => // The `objectName &&` gate is the objectui#8649 erasure repair surfacing a @@ -285,9 +292,10 @@ const RecordRelatedListBody: React.FC = ({ objectName, relationshipField: schema.relationshipField, parentId: parentLinkValue, + filter: resolvedListFilter, }) ?? null) : null, - [relatedActions, objectName, schema.relationshipField, parentLinkValue], + [relatedActions, objectName, schema.relationshipField, parentLinkValue, resolvedListFilter], ); /** diff --git a/ui/packages/react/src/context/RelatedRecordActionsContext.tsx b/ui/packages/react/src/context/RelatedRecordActionsContext.tsx index 3c4aacb3890..1990465b4a9 100644 --- a/ui/packages/react/src/context/RelatedRecordActionsContext.tsx +++ b/ui/packages/react/src/context/RelatedRecordActionsContext.tsx @@ -38,6 +38,8 @@ */ import React, { createContext, useContext } from 'react'; +import type { FilterNode } from '@object-ui/core'; +import type { ViewFilterRule } from '@objectstack/spec/ui'; /** A child-object action surfaced in a related list's row menu. */ export interface RelatedRowActionDef { @@ -110,15 +112,19 @@ export interface RelatedRecordHandlers { onToolbarAction?: (action: RelatedRowActionDef) => void | Promise; /** * Open the child object's full list page, scoped to THIS parent on the - * relationship `resolve` was handed (objectui#12741). The related list's - * "View all" link calls it, unless the block declares `showViewAll: false`. + * relationship `resolve` was handed (objectui#12741) AND by the list's own + * `filter` (objectstack#25888): the rows the list showed, every one of them + * and no others. The related list's "View all" link calls it, unless the + * block declares `showViewAll: false`. * * The host builds the destination because only the host knows the console's * routes, as it does for {@link onView}. It omits the handler when no route * it has can show that scoped list, and the link is then not drawn. One case * is a multi-value relationship: the list route's URL filter has no - * membership operator. Another is a relationship field the viewer cannot - * read: the list route drops that condition and would show every row. + * membership operator. Another is a field the viewer cannot read, the + * relationship or one the filter names: the list route drops that condition + * and would show more rows. A third is a filter condition the route cannot + * spell (an `or`, a set or text operator): the route would drop it too. */ onViewAll?: () => void; } @@ -150,6 +156,19 @@ export interface ResolveRelatedRecordActionsInput { relationshipField?: string; /** Primary-key value of the parent record (used to pre-link new children). */ parentId?: string | number | null; + /** + * The list's OWN scope filter (`record:related_list.filter`), which the list + * ANDs with the parent relationship for the rows it shows (objectstack#25888). + * Handed with its placeholders (`{current_user_id}`, `{record_id}`, the date + * macros) already resolved against the list's own filter scope, so it is + * the value the list queried with. + * + * Read by a host that builds a destination for "these rows" + * ({@link RelatedRecordHandlers.onViewAll}): it scopes that destination by + * this filter too, or offers none. Absent means the list has no filter of + * its own; it does not mean "unknown". + */ + filter?: ViewFilterRule[] | FilterNode; } export interface RelatedRecordActionsValue { From aee4223a582a6c05de080013f95aa0b0b28fc873 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:47:20 +0000 Subject: [PATCH 2/5] chore(changeset): related list View all carries the list's filter Claude-Session: https://claude.ai/code/session_01HYEgis8rPZK6Dr3G5Jas4q Co-authored-by: Claude --- .changeset/25888-related-list-view-all-filter.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/25888-related-list-view-all-filter.md diff --git a/.changeset/25888-related-list-view-all-filter.md b/.changeset/25888-related-list-view-all-filter.md new file mode 100644 index 00000000000..f2ed5a74b9a --- /dev/null +++ b/.changeset/25888-related-list-view-all-filter.md @@ -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. From 5eaa137971ee9ef9813bccf47263a6b6d5dd9a05 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:18:39 +0000 Subject: [PATCH 3/5] chore(sdui): re-record the registry source hash after the related-list renderer change Claude-Session: https://claude.ai/code/session_01HYEgis8rPZK6Dr3G5Jas4q Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1555759c7e3..d77a870c17a 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "ad9e0875b83f339e03c528fc3be774592615dfdc5ee5fe9becc4376ddb5f0d52" + "registrySourcesSha256": "e00e2bba66ab79a3cbb1348198eaee2544facde127d269152de722faa8663f91" } } From a281cecb2aaacf3b9fb15b5047ffee1341ea1b44 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:06:02 +0000 Subject: [PATCH 4/5] chore(sdui): re-record the registry source hash on the merged tree Claude-Session: https://claude.ai/code/session_01HYEgis8rPZK6Dr3G5Jas4q Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..326dfb9ddc3 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "e03fff49f04e8c45261abfdd50469179893c260aa7f6d3cf3edc1db8f5d11cb8" } } From 3c970bb0c973e0fed38acdac0cd6da4d30220fda Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:28:32 +0000 Subject: [PATCH 5/5] chore(sdui): re-record plugin-detail and react source digests on the merged tree Claude-Session: https://claude.ai/code/session_01HYEgis8rPZK6Dr3G5Jas4q Co-authored-by: Claude --- scripts/sdui-manifest.sources/plugin-detail.json | 2 +- scripts/sdui-manifest.sources/react.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/sdui-manifest.sources/plugin-detail.json b/scripts/sdui-manifest.sources/plugin-detail.json index 94e9aa537aa..053011d42f8 100644 --- a/scripts/sdui-manifest.sources/plugin-detail.json +++ b/scripts/sdui-manifest.sources/plugin-detail.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e99ff0993d322375559215401be4cb26cc3179096ff2d15739470dc0cc7e5fcc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-detail","sources":54,"sha256":"e22491dc392ede00c47cdea1d193d7c03c9c01751e65ee851adb17f2038b7ad2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/react.json b/scripts/sdui-manifest.sources/react.json index 3a0b892f9ae..19cca11088e 100644 --- a/scripts/sdui-manifest.sources/react.json +++ b/scripts/sdui-manifest.sources/react.json @@ -1 +1 @@ -{"package":"ui/packages/react","sources":70,"sha256":"b4e342408561b4e02a5ec126437e0ca184dd96da8cc407d71f079288f882a34d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/react","sources":70,"sha256":"e3ea09e281e39a1d3f30d87b199d3756e88d7ffb48f91a11e777b2806a8bddff","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}