diff --git a/.changeset/12741-bridge-view-all.md b/.changeset/12741-bridge-view-all.md new file mode 100644 index 0000000000..97ea9d118d --- /dev/null +++ b/.changeset/12741-bridge-view-all.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': minor +--- + +On a record page, a related list's "View all" opens the child object's list (objectui#12741). The destination is the ADR-0055 bare data surface, `/apps/APP/OBJECT/data?filter[FIELD]=PARENT`, which is the route a dashboard drill's "Open in list" already uses. It is not anchored to a saved view, so it shows every record related to this parent. + +`RelatedRecordActionsBridge` offers the link only where that route can say "this parent's children". It withholds it for a list with no relationship or no parent value, and for a `multiple: true` relationship field, because the route's URL filter has no membership operator. It also withholds it for a relationship field the viewer cannot read, because the list page drops that condition and would open the whole table. + +Clause-② line, the same as the pull request's: +Clause-②: yes +No export of this package changes: `recordListPath` lives beside `useOpenRecordList` and is not exported from the package entry. The Clause-② change is `@object-ui/react`'s new `RelatedRecordHandlers.onViewAll`, which this bridge supplies. diff --git a/.changeset/12741-related-handlers-view-all.md b/.changeset/12741-related-handlers-view-all.md new file mode 100644 index 0000000000..038168b454 --- /dev/null +++ b/.changeset/12741-related-handlers-view-all.md @@ -0,0 +1,9 @@ +--- +'@object-ui/react': minor +--- + +`RelatedRecordHandlers` gains an optional `onViewAll` (objectui#12741). A host returns it from `resolve` to open the child object's full list page, scoped to the parent on the relationship it was handed. `record:related_list` calls it from its "View all" link. A host that omits it draws no link, as for every other handler on this interface. + +Clause-② line, the same as the pull request's: +Clause-②: yes +One optional member is added to the exported `RelatedRecordHandlers` interface. Nothing else on the package entry changes. diff --git a/.changeset/12741-related-list-view-all.md b/.changeset/12741-related-list-view-all.md new file mode 100644 index 0000000000..79f9f5fea2 --- /dev/null +++ b/.changeset/12741-related-list-view-all.md @@ -0,0 +1,19 @@ +--- +'@object-ui/plugin-detail': minor +--- + +`record:related_list` now draws the "View all" link its `showViewAll` key declares (objectui#12741, ENFORCE, the objectui half of objectstack-ai/objectstack#23941). + +The contract declares `showViewAll: z.boolean().default(true)`, "Show 'View All' link to see all related records". Nothing read the key, and no host passed `RelatedList` an `onViewAll`, so the default drew nothing. The renderer reads the key now: + +- **Absent or `true`**: the link draws when the host offers a destination (the new `RelatedRecordHandlers.onViewAll`) and the list has records. Clicking it opens the related object's records, filtered to this parent on the relationship the list already uses. +- **`false`**: no link. +- **An empty list, or no destination from the host**: no link. + +`RelatedList` used to draw its "View all" link only when the records ran past one page. It now draws whenever it is handed `onViewAll` and has records, because the spec states no truncation condition. This renderer is the only caller that passes `onViewAll`. + +⚠️ Behaviour change: on a console record page, every related list with records now shows "View all", unless it declares `showViewAll: false`. + +Clause-② line, the same as the pull request's: +Clause-②: yes +`@object-ui/react`'s exported `RelatedRecordHandlers` gains one optional member, `onViewAll`. No input, key or accept set of this package changes. diff --git a/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx b/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx index 2e3063a76a..8a19df866f 100644 --- a/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx +++ b/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx @@ -30,6 +30,9 @@ * parent-linked. * - 删 → `dataSource.delete(child, id)` (RelatedList shows the confirm * dialog and refreshes afterwards) + * - 查看全部 → the child object's list page scoped to this parent + * (`onViewAll`, the related list's "View all" link, + * objectui#12741) * - 子对象 action → the child object's `list_item` actions, and its * `record_related` ones when a parent record is in scope, * executed against the clicked row through the page's shared @@ -81,11 +84,12 @@ import { type RelatedRecordHandlers, type RelatedRowActionDef, } from '@object-ui/react'; -import { evalRowPredicate, resolveAffordance, type ActionDef, type RowCrudPredicates } from '@object-ui/core'; +import { evalRowPredicate, isMultiValueRelationship, resolveAffordance, type ActionDef, type RowCrudPredicates } from '@object-ui/core'; 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'; /** * Notify open related lists for `objectName` to refetch. @@ -490,6 +494,36 @@ export function RelatedRecordActionsBridge({ onView: (id) => openRecord(objectName, id), }; + /** + * "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. + * + * Offered only where that route can say "this parent's children". Its + * `filter[FIELD]=VALUE` arm is EQUALITY, 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. + * A withheld handler draws no link: no link is better than a wrong list. + */ + if ( + relationshipField && + parentId != null && + parentId !== '' && + !isMultiValueRelationship(childDef.fields, relationshipField) && + perms.checkField(objectName, relationshipField, 'read') + ) { + const viewAllPath = recordListPath(appName, objectName, { [relationshipField]: parentId }); + handlers.onViewAll = () => navigate(viewAllPath); + } + if (aff.create) { // [#4646] Greyed, not gone: `onCreate` is still supplied so the list // keeps offering "+ New" as a visible-but-inert affordance. The @@ -551,7 +585,7 @@ export function RelatedRecordActionsBridge({ // the #4646 create predicates: a parent record that changes (a save, a // status transition) must re-resolve "+ New" for every related list under // it, or the toolbar keeps answering for the record's previous state. - [objects, base, dataSource, localizeActionTexts, runRowAction, openChildForm, recordHref, openRecord, perms, parentRecord, parentObjectFields, predicateScope, hasParentRecord], + [objects, base, appName, navigate, dataSource, localizeActionTexts, runRowAction, openChildForm, recordHref, openRecord, perms, parentRecord, parentObjectFields, predicateScope, hasParentRecord], ); return ( diff --git a/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.createPredicates.test.tsx b/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.createPredicates.test.tsx index 9d23dbfa5f..cd460db3dd 100644 --- a/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.createPredicates.test.tsx +++ b/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.createPredicates.test.tsx @@ -39,6 +39,8 @@ vi.mock('@object-ui/permissions', async (importOriginal) => { usePermissions: () => ({ getObjectApiOperations: () => undefined, can: (_name: string, action: string) => principal[action] ?? true, + // Field-level read, open: the bridge reads it for "View all" (objectui#12741). + checkField: () => true, }), }; }); diff --git a/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.effectiveOps.test.tsx b/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.effectiveOps.test.tsx index 6ad93cd52a..06e06f58c0 100644 --- a/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.effectiveOps.test.tsx +++ b/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.effectiveOps.test.tsx @@ -39,6 +39,8 @@ vi.mock('@object-ui/permissions', async (importOriginal) => { usePermissions: () => ({ getObjectApiOperations: (name: string) => effectiveOps[name], can: (_name: string, action: string) => principal[action] ?? true, + // Field-level read, open: the bridge reads it for "View all" (objectui#12741). + checkField: () => true, }), }; }); diff --git a/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.viewAll-12741.test.tsx b/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.viewAll-12741.test.tsx new file mode 100644 index 0000000000..701799a325 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/RelatedRecordActionsBridge.viewAll-12741.test.tsx @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12741 — a related list's "View all" opens the child object's list + * page, scoped to this parent on the list's own relationship (ENFORCE, the + * objectui half of objectstack-ai/objectstack#23941). + * + * The spec declares `record:related_list.showViewAll` as + * `z.boolean().default(true)`, "Show 'View All' link to see all related + * records". The destination is the console's ADR-0055 bare data surface, + * `/apps/:app/:object/data?filter[FIELD]=VALUE`, which is the route + * `useOpenRecordList` already drills to. It is not anchored to a saved view, + * so it shows every related record. + * + * Every case mounts what a record page mounts: the real + * `RelatedRecordActionsBridge` under a router, around the `{ type, properties }` + * node through the real `SchemaRenderer`, `@object-ui/plugin-detail`'s + * registration, the real `RelatedList` and the real data table. 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 { MetadataCtx, RecordContextProvider, SchemaRenderer, type MetadataContextValue } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +// Registers `record:related_list` — the block a record page draws a related +// list with. 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 = 'demo'; +const PARENT_ROUTE = `/apps/${APP}/project/record/P-1`; + +const childOf = (projectField: Record) => ({ + name: 'task', + label: 'Task', + fields: { name: { type: 'text', label: 'Name' }, project: projectField }, +}); +const SINGLE = childOf({ type: 'lookup', reference: 'project', label: 'Project' }); +const MULTI = childOf({ type: 'lookup', reference: 'project', label: 'Projects', multiple: true }); + +/** Two rows, under the spec's default `limit` of 5: every row fits on the list. */ +const ROWS = [ + { id: 't1', name: 'Write spec', project: 'P-1' }, + { id: 't2', name: 'Review spec', project: 'P-1' }, +]; + +const metadataFor = (child: ReturnType): MetadataContextValue => ({ + apps: [], + objects: [child], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: (async () => child) as unknown as MetadataContextValue['getItem'], + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}); + +/** The router's own location: where a click took us. */ +function RouterLocation() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +/** The role-based provider, letting `viewer` read `task` with the named fields denied. */ +const denying = (fields: string[]) => (node: React.ReactElement) => ( + ({ field, read: false })) } }, + }, + ] as never + } + > + {node} + +); + +function mount({ + child = SINGLE, + properties = {}, + wrap = (node: React.ReactElement) => node, +}: { + child?: ReturnType; + properties?: Record; + wrap?: (node: React.ReactElement) => React.ReactElement; +} = {}) { + const dataSource = { + find: vi.fn(async () => ROWS), + getObjectSchema: vi.fn(async () => child), + }; + const doc = { + type: 'record:related_list', + properties: { objectName: 'task', relationshipField: 'project', columns: ['name'], ...properties }, + }; + return render( + + + {wrap( + + + + + , + )} + + + , + ); +} + +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('Write spec'); + await screen.findByText('Review spec'); +}; + +describe('objectui#12741 — "View all" opens this parent’s children on the list route', () => { + it('with `showViewAll` absent (the spec default) the link draws even when every row fits, and opens the scoped list', async () => { + mount(); + await waitForRows(); + const link = viewAll(); + expect(link).toBeInTheDocument(); + expect(location()).toBe(PARENT_ROUTE); + fireEvent.click(link!); + const [path, search] = location().split('?'); + // The bare data surface — not the object route, which stacks the default + // view's own filter on top and could hide related records. + expect(path).toBe(`/apps/${APP}/task/data`); + // Read back the way `ObjectDataPage` reads it: exactly one condition, the + // list's relationship, equal to this parent. + expect(parseUrlFilterTriples(new URLSearchParams(search))).toEqual([['project', '=', 'P-1']]); + }); + + it('`showViewAll: false` draws no link', async () => { + mount({ properties: { showViewAll: false } }); + await waitForRows(); + expect(viewAll()).toBeNull(); + }); + + it('a `multiple: true` relationship draws no link: the list route’s filter has no membership operator', async () => { + mount({ child: MULTI }); + await waitForRows(); + expect(viewAll()).toBeNull(); + }); + + it('a relationship field the viewer cannot read draws no link: the list route would drop the condition', async () => { + mount({ wrap: denying(['project']) }); + await waitForRows(); + expect(viewAll()).toBeNull(); + }); + + it('CONTROL: under the same provider, denying a field the relationship does not use, the link still draws', async () => { + mount({ wrap: denying(['name_secret']) }); + await waitForRows(); + expect(viewAll()).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/useOpenRecordList.ts b/packages/app-shell/src/views/useOpenRecordList.ts index 05afe839ee..9858729f76 100644 --- a/packages/app-shell/src/views/useOpenRecordList.ts +++ b/packages/app-shell/src/views/useOpenRecordList.ts @@ -12,6 +12,33 @@ import { resolveFilterPlaceholders } from '@object-ui/core'; import { useFilterScope } from '@object-ui/react'; import { serializeDrillFilterParams } from './drillUrlFilters.js'; +/** + * The router path of an object's ADR-0055 bare data surface, scoped by + * `filter` (`/apps/:appName/:object/data?filter[...]`). The one place that + * route shape is spelled: {@link useOpenRecordList} navigates to it, and so + * does a related list's "View all" (`RelatedRecordActionsBridge`, + * objectui#12741). + * + * Pure: `filter` is written as handed. A caller holding AUTHORED values, which + * may carry placeholders, resolves them first, as `useOpenRecordList` does. + */ +export function recordListPath( + appName: string | undefined, + objectName: string, + filter?: Record, +): string { + // A date-bucket drill carries an ObjectQL range operator object + // (`{ $gte, $lt }`); the shared serializer emits it as `filter[field][gte|lt]` + // (never "[object Object]"). Equality dims stay `filter[field]=value` (objectstack-ai/objectstack#1752). + const qs = serializeDrillFilterParams(filter).toString(); + const base = appName ? `/apps/${appName}` : ''; + // ADR-0055 bare data surface (`/:object/data`): "the URL is the view" — no + // saved-view filter is baked in, so the scope is exactly these conditions. + // (The object route stacks URL filters ON TOP of the default view's own + // filter, which can silently over-narrow the list.) + return `${base}/${objectName}/data${qs ? `?${qs}` : ''}`; +} + /** * `useOpenRecordList` — the console's implementation of the drill "escape * hatch" (`DrillNavigationContext.openRecordList`). @@ -76,16 +103,7 @@ export function useOpenRecordList(): (objectName: string, filter?: Record = ({ )} - {/* Footer "View all" link — only when records are truncated (more than displayed) */} - {onViewAll && !isEmpty && showPagination && ( + {/* Footer "View all" link — whenever the host hands a destination and + there are records (objectui#12741). The spec's `showViewAll` is + "Show 'View All' link to see all related records", with no + truncation condition: the full list page is worth opening even when + every row fits here (sort, filter, export, a shareable URL). An + empty list has nothing to see in full. */} + {onViewAll && !isEmpty && (