diff --git a/.changeset/12703-view-all-link.md b/.changeset/12703-view-all-link.md new file mode 100644 index 0000000000..d1e194e012 --- /dev/null +++ b/.changeset/12703-view-all-link.md @@ -0,0 +1,13 @@ +--- +"@object-ui/react": minor +"@object-ui/app-shell": minor +"@object-ui/plugin-list": minor +"@object-ui/i18n": minor +--- + +A `list-view` block drawn as a compact list in a narrow page region now shows a "View all" link under its rows (objectui#12703). + +- `@object-ui/react`: `RelatedRecordActionsValue` gains two optional members, `listHref(objectName, viewName?)` and `openList(objectName, viewName?)`. They address an object's list page, or one of its saved views, the way `recordHref` / `openRecord` address a record. A host that omits them is unchanged. +- `@object-ui/app-shell`: a custom page supplies them. The routes are `/apps/APP/OBJECT/view/VIEW` for a block bound to a saved view (`dataSource.view`) and `/apps/APP/OBJECT` otherwise. The block's inline filter, columns and sort are never put in the URL. +- `@object-ui/plugin-list`: the compact list draws the link only when the host answers a destination. It is a real anchor with in-app navigation on a plain click. +- `@object-ui/i18n`: one new key, `list.viewAll`, in all ten packs. diff --git a/content/docs/guide/react-pages.md b/content/docs/guide/react-pages.md index 5e0fffff6d..ba0a1bd8f2 100644 --- a/content/docs/guide/react-pages.md +++ b/content/docs/guide/react-pages.md @@ -371,8 +371,17 @@ list. The posture is measured from the region's rendered width, never authored, and no page or block key sets it. `kind:'html'` and `kind:'react'` pages have no -regions, so their list blocks always render the full list. A "View all" link from -the compact list to the object list is a planned follow-up. +regions, so their list blocks always render the full list. + +Under its rows the compact list draws a "View all" link. A block bound to a +saved view (`dataSource.view`) links to that view's page in the console +(`/apps/APP/OBJECT/view/VIEW`); any other block links to the object's list +(`/apps/APP/OBJECT`). The block's own filter, columns and sort are not carried +into the link, so a block that narrows the list inline opens the object's whole +list. To link to exactly what the block shows, bind the block to a saved view. +The console's page supplies the destination through +`RelatedRecordActionsContext`'s `listHref`; a host that does not supply it draws +no link. ## Related diff --git a/packages/app-shell/src/views/__tests__/PageView.listHref-12703.test.tsx b/packages/app-shell/src/views/__tests__/PageView.listHref-12703.test.tsx new file mode 100644 index 0000000000..85686bb9fc --- /dev/null +++ b/packages/app-shell/src/views/__tests__/PageView.listHref-12703.test.tsx @@ -0,0 +1,240 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12703, part 2 — a custom page supplies the object-list destination + * its compact list blocks link "View all" to. + * + * `PageView` publishes `pageRecordActionsValue` on `RelatedRecordActionsContext` + * (objectui#11293). It now carries `listHref` / `openList`: the app-scoped + * route of an object's list, `/apps/APP/OBJECT`, or of one of its saved views, + * `/apps/APP/OBJECT/view/VIEW`, the two routes the console mounts for them. + * As for `recordHref` (objectui#12233), the href carries the router's basename + * and the SPA open navigates by the bare route. + * + * The page, the router, the page renderer and the `list-view` block are real: + * `PageView` under a `MemoryRouter` with basename `/_console`, rendering a page + * whose narrow `aside` region holds two list blocks, one bound to a saved view + * and one with an inline filter only. Region widths come from a stand-in + * `ResizeObserver`, as in plugin-list's part-1 pin. The oracle for each href is + * react-router's own `Link` for the same route. + * + * The last block pins the builder's refusals: an object outside the app's + * metadata and a page outside an app route get no destination. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', role: 'user', image: null }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // STABLE identities: `ListView` names `perms` in its fetch dependency list. + const perms = { + check: () => ({ allowed: true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +const APP = 'crm'; +const OBJECT = 'crm_event'; + +const FIELDS = { + id: { name: 'id', type: 'text', label: 'Id' }, + subject: { name: 'subject', type: 'text', label: 'Subject' }, + status: { name: 'status', type: 'text', label: 'Status' }, +}; + +const OBJECTS = [ + { + name: OBJECT, + label: 'Event', + fields: FIELDS, + listViews: { upcoming: { name: 'upcoming', label: 'Upcoming', type: 'grid', columns: ['subject', 'status'] } }, + }, +]; + +const PAGE = { + name: 'sales_home', + label: 'Sales Home', + type: 'home', + template: 'three-column', + regions: [ + { name: 'main', components: [] }, + { + name: 'aside', + width: 'medium', + components: [ + { type: 'list-view', id: 'bound', label: 'Upcoming', dataSource: { object: OBJECT, view: 'upcoming' } }, + { + type: 'list-view', + id: 'inline', + label: 'Planned', + properties: { + data: { provider: 'object', object: OBJECT }, + columns: ['subject'], + filter: [['status', '=', 'planned']], + }, + }, + ], + }, + ], +}; + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ pages: [PAGE], objects: OBJECTS, getTypeStatus: () => 'ready' }), +})); + +vi.mock('../MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false }), +})); + +import { AdapterCtx, SchemaRendererProvider } from '@object-ui/react'; +// The real page renderer and `list-view` (module scope, AGENTS.md §测试纪律). +import '@object-ui/components'; +import '@object-ui/plugin-list'; +import { PageView } from '../PageView'; +import { pageRecordActionsValue } from '../pageRecordActions'; + +const VIEW_ROUTE = `/apps/${APP}/${OBJECT}/view/upcoming`; +const LIST_ROUTE = `/apps/${APP}/${OBJECT}`; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [{ id: 'e-1', subject: 'Renewal review', status: 'planned' }], total: 1 })), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => 1), + getObjectSchema: vi.fn(async () => OBJECTS[0]), + getObjects: vi.fn(async () => OBJECTS), + onMutation: () => () => {}, + }; +} + +/** Reports these widths for the page body and each region. */ +function stubWidths(widths: { page: number; regions: Record }) { + class WidthReportingObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + const region = target.getAttribute('data-region'); + const width = region != null + ? widths.regions[region] + : target.matches('[data-page-type] > div') ? widths.page : undefined; + if (width === undefined) return; + this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this as never); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', WidthReportingObserver); +} + +/** The router's own location, basename stripped: where a left click took us. */ +function RouterLocation() { + const location = useLocation(); + return {location.pathname}; +} + +function mount() { + stubWidths({ page: 1200, regions: { main: 856, aside: 320 } }); + const ds = makeDataSource(); + return render( + + + + + } /> + + + view + list + + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('a custom page supplies its compact lists\' "View all" destination (objectui#12703)', () => { + it('a block bound to a saved view links to that view\'s app route under the basename, and a left click routes there', async () => { + const { container } = mount(); + const bound = await waitFor(() => { + const el = container.querySelector('[data-region="aside"]'); + const links = el ? within(el as HTMLElement).queryAllByTestId('list-compact-view-all') : []; + expect(links).toHaveLength(2); + return links[0]; + }); + + const href = bound.getAttribute('href'); + expect(href).toBe(`/_console${VIEW_ROUTE}`); + expect(href).toBe(screen.getByTestId('oracle-view').getAttribute('href')); + + fireEvent.click(bound, { button: 0 }); + expect(screen.getByTestId('router-location').textContent).toBe(VIEW_ROUTE); + }); + + it('a block with an inline filter only links to the object\'s list route, with no query', async () => { + const { container } = mount(); + const inline = await waitFor(() => { + const links = within(container.querySelector('[data-region="aside"]') as HTMLElement) + .queryAllByTestId('list-compact-view-all'); + expect(links).toHaveLength(2); + return links[1]; + }); + expect(inline.getAttribute('href')).toBe(`/_console${LIST_ROUTE}`); + expect(inline.getAttribute('href')).toBe(screen.getByTestId('oracle-list').getAttribute('href')); + }); +}); + +describe('the page\'s list destination builder refuses what it cannot route (objectui#12703)', () => { + const navigate = vi.fn(); + + it('an object outside the app\'s metadata has no destination, and opening it does nothing', () => { + const host = pageRecordActionsValue(APP, OBJECTS, navigate, '/'); + expect(host.listHref?.('not_an_object')).toBeNull(); + host.openList?.('not_an_object', 'upcoming'); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('a page outside an app route has no destination', () => { + const host = pageRecordActionsValue(undefined, OBJECTS, navigate, '/'); + expect(host.listHref?.(OBJECT)).toBeNull(); + expect(host.listHref?.(OBJECT, 'upcoming')).toBeNull(); + }); + + it('a view name is one encoded path segment', () => { + const host = pageRecordActionsValue(APP, OBJECTS, navigate, '/'); + expect(host.listHref?.(OBJECT, 'a/b c')).toBe(`/apps/${APP}/${OBJECT}/view/a%2Fb%20c`); + }); +}); diff --git a/packages/app-shell/src/views/pageRecordActions.ts b/packages/app-shell/src/views/pageRecordActions.ts index bad5e192e4..8b2ea172e4 100644 --- a/packages/app-shell/src/views/pageRecordActions.ts +++ b/packages/app-shell/src/views/pageRecordActions.ts @@ -43,6 +43,11 @@ const NO_RELATED_HANDLERS: RelatedRecordHandlers = Object.freeze({}); * tab or a copied link reaches the record; `navigate` applies that basename * itself, so it is handed the bare route. The join is `documentHref`, the * record page's own. + * - **`listHref` / `openList` (objectui#12703)** answer the same way for an + * object's list: the object's list route, or one saved view's route when + * the caller names it, and `null` for an object this console cannot route + * to. They take an object and a view name only, so no caller can hand them + * a filter to encode. * * ⚠️ The same seam feeds the grid's link column and the lookup cells, so on a * page those now render as real anchors to the record they name, as they @@ -66,6 +71,18 @@ export function pageRecordActionsValue( appName && routable.has(objectName) && recordId != null && recordId !== '' ? `/apps/${appName}/${objectName}/record/${encodeURIComponent(String(recordId))}` : null; + // objectui#12703 — the object's list route, or the route of one of its saved + // views: `/apps/:app/:object` and `/apps/:app/:object/view/:view`, the shapes + // the app navigation writes for an object and a view item. A view name is + // passed as the block binding states it; `ObjectView` resolves the short and + // the qualified spelling alike. + const listPath = (objectName: string, viewName?: string): string | null => { + if (!appName || !routable.has(objectName)) return null; + const base = `/apps/${appName}/${objectName}`; + return typeof viewName === 'string' && viewName !== '' + ? `${base}/view/${encodeURIComponent(viewName)}` + : base; + }; return { resolve: () => NO_RELATED_HANDLERS, recordHref: (objectName, recordId) => { @@ -76,5 +93,13 @@ export function pageRecordActionsValue( const path = recordPath(objectName, recordId); if (path) navigate(path); }, + listHref: (objectName, viewName) => { + const path = listPath(objectName, viewName); + return path === null ? null : documentHref(routerRootHref, path); + }, + openList: (objectName, viewName) => { + const path = listPath(objectName, viewName); + if (path) navigate(path); + }, }; } diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index aa9816b483..45a79fda43 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -903,6 +903,8 @@ const ar = { recordCount_many: "{{count}} سجلًا", recordCount_other: "{{count}} سجل", addRecord: "إضافة سجل", + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: "عرض الكل", tabs: "علامات التبويب", allRecords: "جميع السجلات", search: "بحث", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d286f56f48..c5b23be450 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -788,6 +788,8 @@ const de = { recordCount_one: "{{count}} Datensatz", recordCount_other: "{{count}} Datensätze", addRecord: "Datensatz hinzufügen", + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: "Alle anzeigen", tabs: "Tabs", allRecords: "Alle Datensätze", search: "Suche", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 38cd3106ed..7bbd45dc7b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -999,6 +999,8 @@ const en = { recordCount_one: '{{count}} record', recordCount_other: '{{count}} records', addRecord: 'Add record', + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: 'View all', tabs: 'Tabs', allRecords: 'All Records', search: 'Search', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index ce2d61ecb5..4a26280912 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -817,6 +817,8 @@ const es = { recordCount_many: "{{count}} de registros", recordCount_other: "{{count}} registros", addRecord: "Agregar registro", + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: "Ver todo", tabs: "Pestañas", allRecords: "Todos los registros", search: "Buscar", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 6c1bb9181d..e99de969a6 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -813,6 +813,8 @@ const fr = { recordCount_many: "{{count}} d'enregistrements", recordCount_other: "{{count}} enregistrements", addRecord: "Ajouter un enregistrement", + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: "Tout afficher", tabs: "Onglets", allRecords: "Tous les enregistrements", search: "Rechercher", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 8e4230964b..21c61ceb44 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -788,6 +788,8 @@ const ja = { recordCount_one: "{{count}} 件のレコード", recordCount_other: "{{count}} 件のレコード", addRecord: "レコードを追加", + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: "すべて表示", tabs: "タブ", allRecords: "すべてのレコード", search: "検索", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e0b3e079d2..7ad23f9647 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -788,6 +788,8 @@ const ko = { recordCount_one: "{{count}}개 레코드", recordCount_other: "{{count}}개 레코드", addRecord: "레코드 추가", + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: "모두 보기", tabs: "탭", allRecords: "전체 레코드", search: "검색", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 4f84df6090..98b554b0ba 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -812,6 +812,8 @@ const pt = { recordCount_many: "{{count}} de registros", recordCount_other: "{{count}} registros", addRecord: "Adicionar registro", + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: "Ver tudo", tabs: "Abas", allRecords: "Todos os registros", search: "Pesquisar", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 8d6227104d..fca4befc54 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -849,6 +849,8 @@ const ru = { recordCount_many: "{{count}} записей", recordCount_other: "{{count}} записи", addRecord: "Добавить запись", + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: "Показать все", tabs: "Вкладки", allRecords: "Все записи", search: "Поиск", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index a6de0f910d..53a0bdef06 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -769,6 +769,8 @@ const zh = { recordCount_one: '{{count}} 条记录', recordCount_other: '{{count}} 条记录', addRecord: '添加记录', + // objectui#12703 — the compact list's footer link to the object's list or the block's view. + viewAll: '查看全部', tabs: '标签页', allRecords: '全部记录', search: '搜索', diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 3c061da4a8..32ab37965a 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -452,8 +452,23 @@ pager or the record-count bar. The prop applies only to a paged grid, the view whose rows `ListView` fetches a page of. A board, a calendar or a grouped grid keeps its own rendering. When `compact` is absent (wide regions, unknown widths, the object list page and -every other host), the list renders exactly as before. A "View all" link from -the compact list to the object list is not part of this release. +every other host), the list renders exactly as before. + +### "View all" + +Under its rows the compact list draws a "View all" link (`list.viewAll`) to the +rest of the list. The host builds the destination: the `list-view` block asks +the host's `listHref` / `openList` on `RelatedRecordActionsContext` +(`@object-ui/react`) for the object it reads, naming the saved view the block +binds with `dataSource.view` when it binds one. Without a bound view the link +goes to the object's own list. The block's own `filter`, `columns` and `sort` +are never handed over, so they never reach the URL; the header count stays the +answer to what the block shows. + +The link is a real anchor: a plain left click navigates in the app through +`openList`, and a modifier click (a new tab) follows the href. A host that +publishes no `listHref`, or answers `null`, gets no link. An empty list draws +no link either. ## Sorting (and why relational columns are not offered) diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index e90098a607..e9a555068f 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -14,10 +14,13 @@ import { ElementDataSourceGate, SchemaRendererContext, isConfigBag, + useRelatedRecordActions, type ElementDataSourceMapping, + type RelatedRecordActionsValue, } from '@object-ui/react'; import type { ListViewSchema } from '@object-ui/types'; import { ListView, type ListViewHandle, type ListViewProps } from './ListView'; +import { CompactListDestinationContext, type CompactListDestination } from './ListViewCompact'; /** * Which schema keys `ListView` actually reads, so the composed binding lands on @@ -130,6 +133,50 @@ function namesRecordSource(schema: ListViewSchema): boolean { return true; } +/** + * Where the compact list's "View all" link goes (objectui#12703, part 2): the + * host's list destination for the object this block reads, on the saved view + * the block binds (`dataSource.view`) when it binds one, else the object's own + * list. `null` when the host publishes no `listHref`, answers `null`, or the + * block names no object, and the compact list then draws no link. + * + * Only the object and the view name are handed over. ⛔ The block's own + * `filter`, `columns` and `sort` (inline, or the binding's additions to the + * view) are never encoded: the destination is a list a user can open on its + * own, and the block's header count stays the answer to "what this block + * shows". + * + * Plain function, not a hook: it is called inside the gate's render callback. + */ +function compactListDestination( + host: RelatedRecordActionsValue | null, + bound: ListViewSchema, +): CompactListDestination | null { + if (!host?.listHref) return null; + const objectName = boundObjectName(bound); + if (!objectName) return null; + const binding: unknown = (bound as { dataSource?: unknown }).dataSource; + const viewName = isElementDataSourceConfig(binding) && typeof binding.view === 'string' && binding.view !== '' + ? binding.view + : undefined; + const href = host.listHref(objectName, viewName); + if (!href) return null; + const openList = host.openList; + // No SPA open published: the anchor navigates on its own. + return openList ? { href, open: () => openList(objectName, viewName) } : { href }; +} + +/** The object the bound schema reads: the binding's (landed on `objectName`) or an `object` provider's. */ +function boundObjectName(schema: ListViewSchema): string | undefined { + const bound = schema.objectName; + if (typeof bound === 'string' && bound.trim().length > 0) return bound; + const data: unknown = schema.data; + if (isConfigBag(data) && data.provider === 'object' && typeof data.object === 'string' && data.object.trim().length > 0) { + return data.object; + } + return undefined; +} + /** * The "no object named" hint, in the gate's own words (the `view.noObject` * message), naming the page key this block reads: `data.object`. The gate's @@ -238,6 +285,9 @@ const ListViewBlock = elementDataSourceBlock(React.forwardRef {(schema) => namesRecordSource(schema) - ? + ? ( + + + + ) : } ); diff --git a/packages/plugin-list/src/ListViewCompact.tsx b/packages/plugin-list/src/ListViewCompact.tsx index 8b541c86a2..4c7d8777bd 100644 --- a/packages/plugin-list/src/ListViewCompact.tsx +++ b/packages/plugin-list/src/ListViewCompact.tsx @@ -20,13 +20,65 @@ * Values render through the shared cell renderers (`getCellRenderer`), so a * date, a picklist label or a related record reads the way the grid and the * gallery draw it. ⛔ No second formatter lives here. + * + * Under the rows, a "View all" link to the rest (objectui#12703 part 2): drawn + * only when the block hands a destination through + * {@link CompactListDestinationContext}, which it builds from the host's + * `listHref` / `openList`. A host without that seam draws no link. */ import * as React from 'react'; import { cn } from '@object-ui/components'; import { getRecordDisplayName, isEmptyValue, columnIdentity } from '@object-ui/core'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; -import { useSafeFieldLabel } from '@object-ui/i18n'; +import { useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n'; + +/** + * Where the compact list's "View all" link goes: a document href and the SPA + * open that reaches the same place. The page block builds it from the host's + * `listHref` / `openList` (`RelatedRecordActionsValue`) and the object and saved + * view it is bound to; `null` (the default) draws no link. + * + * A context rather than a `ListView` prop because the destination is the + * block's, not the list's: only the page block knows its binding, and the + * object list page, which never draws the compact posture, has none. + * Package-internal, like this module. + */ +export interface CompactListDestination { + href: string; + /** SPA navigation to `href`; absent, the anchor navigates on its own. */ + open?: () => void; +} + +export const CompactListDestinationContext = React.createContext(null); + +/** The compact list's footer: a real link to the rest of the list. */ +export function ListViewCompactFooter({ destination }: { destination: CompactListDestination }) { + const { t } = useObjectTranslation(); + return ( + + ); +} /** The two columns a compact row draws: the view's first two visible ones. */ export function compactRowColumns(fields: readonly unknown[]): { primary?: string; secondary?: string } { @@ -83,6 +135,7 @@ export interface ListViewCompactRowsProps { export function ListViewCompactRows({ rows, fields, objectName, objectDef, onRowClick }: ListViewCompactRowsProps) { const { fieldLabel, fieldOptionLabel } = useSafeFieldLabel(); const { primary, secondary } = compactRowColumns(fields); + const destination = React.useContext(CompactListDestinationContext); // The field as the shared cell renderers take it: the object's definition, // its label and option labels through the i18n dictionary. @@ -111,43 +164,46 @@ export function ListViewCompactRows({ rows, fields, objectName, objectDef, onRow }; return ( -
    - {rows.map((record, i) => { - const id = (record.id ?? record._id ?? i) as string | number; - // No columns declared: the record's display name, as the gallery's - // card heading reads it, and no second line. - const first = primary ? cell(record, primary) : null; - const firstContent = first ?? getRecordDisplayName(objectDef, record); - const second = secondary ? cell(record, secondary) : null; - return ( -
  • - -
  • - ); - })} -
+ {second != null && ( + + {second} + + )} + + + ); + })} + + {destination && } + ); } diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.compactViewAll-12703.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.compactViewAll-12703.test.tsx new file mode 100644 index 0000000000..1d63fd6a0d --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListViewBlock.compactViewAll-12703.test.tsx @@ -0,0 +1,240 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12703, part 2 — the compact list's "View all" link. + * + * A `list-view` block in a narrow page region draws the compact list (part 1). + * Under its rows it now draws a real link to the rest of the list, whose + * destination the HOST builds through `RelatedRecordActionsValue.listHref` / + * `openList`: the block's bound saved view when it binds `dataSource.view`, the + * object's own list otherwise. The block's own filter, columns and sort are + * never handed to the host, so they cannot reach the URL. + * + * Rendered through the REAL page renderer and the REAL `list-view` + * registration, with the stand-in `ResizeObserver` the part-1 pin beside this + * file uses for the region widths. The host is a recording stand-in: it answers + * `/apps/crm/OBJECT` or `/apps/crm/OBJECT/view/VIEW` for exactly the arguments + * it was handed, so what the link carries is what the block asked for. The + * host's own route building is pinned in app-shell + * (`PageView.listHref-12703.test.tsx`). + * + * What is pinned: + * (a) a compact block bound to a saved view: the link's href is that view's + * destination, it is named "View all", and a plain left click opens it + * through the host's SPA open (a modifier click is left to the browser); + * (b) a compact block with an inline filter only: the host is asked for the + * object's list with no view, and the href carries no query; + * (c) CONTROL — a host without `listHref`, and no host at all: no link; + * (d) CONTROL — the block in a wide region draws the full list and no link, + * with the same host. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup, within, fireEvent } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { + ActionProvider, + I18nProvider, + RelatedRecordActionsProvider, + SchemaRenderer, + SchemaRendererProvider, + type RelatedRecordActionsValue, +} from '@object-ui/react'; +// Registers the REAL page renderer and `page:card` (module scope, AGENTS.md §测试纪律). +import '@object-ui/components'; +// Registers the REAL `list-view`. +import '../index'; + +const OBJECT = 'crm_event'; + +const rows = [ + { id: 'e-1', subject: 'Renewal review', location: 'Room 4', status: 'planned' }, + { id: 'e-2', subject: 'Discovery call', location: 'Zoom', status: 'planned' }, +]; + +const objectDef = { + name: OBJECT, + label: 'Event', + fields: { + id: { name: 'id', type: 'text', label: 'Id' }, + subject: { name: 'subject', type: 'text', label: 'Subject' }, + location: { name: 'location', type: 'text', label: 'Location' }, + status: { name: 'status', type: 'text', label: 'Status' }, + }, + // The saved view case (a) binds by name. + listViews: { + upcoming: { name: 'upcoming', label: 'Upcoming', type: 'grid', columns: ['subject', 'location'] }, + }, +}; + +ComponentRegistry.register( + 'object-grid', + () =>
, + { namespace: 'test', label: 'Grid spy', category: 'view' }, +); + +const makeDataSource = () => + ({ + find: vi.fn(async () => rows), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => rows.length), + getObjectSchema: vi.fn(async () => objectDef), + getObjects: vi.fn(async () => []), + onMutation: () => () => {}, + }); + +/** A host that records what it was asked and answers the console's route shapes. */ +function makeHost() { + const listHref = vi.fn((objectName: string, viewName?: string) => + viewName ? `/apps/crm/${objectName}/view/${viewName}` : `/apps/crm/${objectName}`, + ); + const openList = vi.fn(); + const value: RelatedRecordActionsValue = { resolve: () => ({}), listHref, openList }; + return { value, listHref, openList }; +} + +/** A page with a wide `main` and a narrow `aside`, each holding this block. */ +const pageWith = (block: Record) => ({ + type: 'home', + template: 'three-column', + regions: [ + { name: 'main', components: [{ ...block, id: 'main_list' }] }, + { name: 'aside', width: 'medium', components: [{ ...block, id: 'aside_list' }] }, + ], +}); + +/** (a) bound to a saved view of the object. */ +const boundBlock = { + type: 'list-view', + label: 'Upcoming', + dataSource: { object: OBJECT, view: 'upcoming' }, +}; + +/** (b) an inline filter, columns and sort, and no saved view. */ +const inlineBlock = { + type: 'list-view', + label: 'Planned', + properties: { + data: { provider: 'object', object: OBJECT }, + columns: ['subject', 'location'], + filter: [['status', '=', 'planned']], + sort: [{ field: 'subject', order: 'asc' }], + }, +}; + +/** Installs a `ResizeObserver` that reports these widths for the page body and each region. */ +function stubWidths(widths: { page: number; regions: Record }) { + class WidthReportingObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + const region = target.getAttribute('data-region'); + const width = region != null + ? widths.regions[region] + : target.matches('[data-page-type] > div') ? widths.page : undefined; + if (width === undefined) return; + this.callback([{ target, contentRect: { width } } as unknown as ResizeObserverEntry], this as never); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', WidthReportingObserver); +} + +function renderPage(page: Record, host: RelatedRecordActionsValue | null) { + stubWidths({ page: 1200, regions: { main: 856, aside: 320 } }); + const tree = ; + // The real English pack, so the link is found by the name a user hears. + return render( + + + + {host ? {tree} : tree} + + + , + ); +} + +const regionOf = (container: HTMLElement, name: string): HTMLElement => { + const el = container.querySelector(`[data-region="${name}"]`); + if (!(el instanceof HTMLElement)) throw new Error(`no region ${name}`); + return el; +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('the compact list links to the rest through the host (objectui#12703 part 2)', () => { + it('(a) a block bound to a saved view links to that view, and a left click opens it in the SPA', async () => { + const host = makeHost(); + const { container } = renderPage(pageWith(boundBlock), host.value); + const aside = regionOf(container, 'aside'); + + await waitFor(() => expect(within(aside).getAllByTestId('list-compact-row')).toHaveLength(2)); + const link = within(aside).getByRole('link', { name: /^View all/ }); + expect(link.getAttribute('href')).toBe(`/apps/crm/${OBJECT}/view/upcoming`); + expect(host.listHref).toHaveBeenCalledWith(OBJECT, 'upcoming'); + + // A modifier click is the browser's (a new tab): no SPA open. + fireEvent.click(link, { button: 0, metaKey: true }); + expect(host.openList).not.toHaveBeenCalled(); + + // A plain left click opens the same destination in the SPA. + fireEvent.click(link, { button: 0 }); + expect(host.openList).toHaveBeenCalledTimes(1); + expect(host.openList).toHaveBeenCalledWith(OBJECT, 'upcoming'); + }); + + it('(b) a block with an inline filter only links to the object list, with no query', async () => { + const host = makeHost(); + const { container } = renderPage(pageWith(inlineBlock), host.value); + const aside = regionOf(container, 'aside'); + + await waitFor(() => expect(within(aside).getAllByTestId('list-compact-row')).toHaveLength(2)); + const href = within(aside).getByRole('link', { name: /^View all/ }).getAttribute('href'); + expect(href).toBe(`/apps/crm/${OBJECT}`); + expect(href).not.toContain('?'); + // The host was asked for the object and nothing else: no view, and no + // filter, columns or sort, which the seam has no argument for. + for (const call of host.listHref.mock.calls) { + expect(call).toEqual([OBJECT, undefined]); + } + }); + + it('(c) CONTROL: a host without `listHref`, or no host, draws no link', async () => { + const bare = renderPage(pageWith(boundBlock), { resolve: () => ({}) }); + const aside = regionOf(bare.container, 'aside'); + await waitFor(() => expect(within(aside).getAllByTestId('list-compact-row')).toHaveLength(2)); + expect(within(aside).queryByTestId('list-compact-footer')).toBeNull(); + expect(within(aside).queryByRole('link', { name: /^View all/ })).toBeNull(); + cleanup(); + + const none = renderPage(pageWith(boundBlock), null); + const aside2 = regionOf(none.container, 'aside'); + await waitFor(() => expect(within(aside2).getAllByTestId('list-compact-row')).toHaveLength(2)); + expect(within(aside2).queryByTestId('list-compact-footer')).toBeNull(); + }); + + it('(d) CONTROL: the same block in the wide region draws the full list and no link', async () => { + const host = makeHost(); + const { container } = renderPage(pageWith(boundBlock), host.value); + const main = regionOf(container, 'main'); + + await waitFor(() => expect(within(main).getByTestId('grid-spy')).toBeTruthy()); + expect(within(main).getByTestId('toolbar-filter-trigger')).toBeTruthy(); + expect(within(main).queryByTestId('list-compact-footer')).toBeNull(); + expect(within(main).queryByRole('link', { name: /^View all/ })).toBeNull(); + }); +}); diff --git a/packages/react/README.md b/packages/react/README.md index 7ed20c00d4..1b0ffd6f07 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -450,6 +450,47 @@ function Section({ collapsed, children }: { collapsed: boolean; children: ReactN } ``` +### RelatedRecordActionsProvider / useRelatedRecordActions + +The host's navigation seam for low-level renderers that have no router. The +host (the console's record, list and custom pages) publishes a +`RelatedRecordActionsValue`; renderers read it with `useRelatedRecordActions()`, +which returns `null` when no host provides one. + +- `resolve(input)` returns the CRUD and action handlers for a related list. +- `recordHref(objectName, recordId)` / `openRecord(...)` address one record's + page: a document href for a real anchor, and the SPA navigation a plain left + click uses. +- `listHref(objectName, viewName?)` / `openList(...)` address an object's list + page, or one of its saved views when `viewName` is given. The compact list a + page draws in a narrow region links "View all" through them. They take an + object and a view name only: a caller cannot hand them a filter, columns or a + sort to encode. + +Every member but `resolve` is optional. An absent member, or one that answers +`null`, means the host cannot route there, and the renderer draws no link. + +```tsx +import type { ReactNode } from 'react' +import { RelatedRecordActionsProvider } from '@object-ui/react' +import type { RelatedRecordActionsValue } from '@object-ui/react' + +declare const navigate: (to: string) => void + +const listPath = (objectName: string, viewName?: string): string => + viewName ? `/apps/crm/${objectName}/view/${encodeURIComponent(viewName)}` : `/apps/crm/${objectName}` + +const host: RelatedRecordActionsValue = { + resolve: () => ({}), + listHref: (objectName: string, viewName?: string) => listPath(objectName, viewName), + openList: (objectName: string, viewName?: string) => navigate(listPath(objectName, viewName)), +} + +export function PageHost({ children }: { children: ReactNode }) { + return {children} +} +``` + ### ComponentRegistry There is no registry hook: the registry is a process-level singleton exported diff --git a/packages/react/src/context/RelatedRecordActionsContext.tsx b/packages/react/src/context/RelatedRecordActionsContext.tsx index ae43814d39..07119a3e09 100644 --- a/packages/react/src/context/RelatedRecordActionsContext.tsx +++ b/packages/react/src/context/RelatedRecordActionsContext.tsx @@ -31,6 +31,10 @@ * it can route to, so the low-level renderers on a record page can turn a * lookup VALUE into a link to the referenced record without assembling a URL * of their own. Same provider, same builder, one route shape. + * + * Since objectui#12703 it also publishes the object-list URL builder + * ({@link RelatedRecordActionsValue.listHref} / `openList`), for a renderer + * that shows a few of an object's records and links to the rest. */ import React, { createContext, useContext } from 'react'; @@ -164,6 +168,31 @@ export interface RelatedRecordActionsValue { * in-app; a modifier click is left to the browser and the href. */ openRecord?: (objectName: string, recordId: string | number) => void; + /** + * The host's list-page URL for an object it can route to, optionally opened + * on one of the object's saved views (objectui#12703). The list-side twin of + * {@link recordHref}: a renderer that shows a few of an object's records (the + * compact list a page draws in a narrow region) presents "View all" as a real + * anchor to this destination, without assembling a URL of its own. + * + * `viewName` names a saved view of that object, as the block binding states + * it (`dataSource.view`); absent, the destination is the object's own list. + * The destination is a view or the object, never a query: a caller does not + * encode its own filter, columns or sort into it, and the host has no + * argument to receive them. + * + * Returns `null` when the host cannot route to that object (unknown object, + * no app context); consumers then draw no link. Like {@link recordHref} it is + * a document href that carries the router's basename, while {@link openList} + * navigates by the router path. + */ + listHref?: (objectName: string, viewName?: string) => string | null; + /** + * SPA-navigate to the destination {@link listHref} addresses. Consumers call + * this from a plain left click on the anchor; a modifier click is left to the + * browser and the href. + */ + openList?: (objectName: string, viewName?: string) => void; } const RelatedRecordActionsContext = createContext(null);