From cd46215290961416ae2a23ddca0435be65490a5e Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:13:06 +0800 Subject: [PATCH 1/3] feat(app-shell,plugin-list,i18n): the Display menu's Open records section writes the reader's drawer | split preference; the name cell's hover arrow opens the record as the full page (WIP) Claude-Session: https://claude.ai/code/session_cdb7a717-def7-4e71-9988-f089fbaa18ff Co-authored-by: Claude --- .../src/utils/recordOpenPreference.ts | 89 +++++++++++++- .../app-shell/src/views/ObjectView.tsx | 30 ++++- .../src/views/objectViewListRenderer.tsx | 18 ++- .../src/views/objectViewOpenAsPage.tsx | 111 +++++++++++++++++ ui/packages/i18n/src/locales/ar.ts | 6 + ui/packages/i18n/src/locales/de.ts | 6 + ui/packages/i18n/src/locales/en.ts | 6 + ui/packages/i18n/src/locales/es.ts | 6 + ui/packages/i18n/src/locales/fr.ts | 6 + ui/packages/i18n/src/locales/ja.ts | 6 + ui/packages/i18n/src/locales/ko.ts | 6 + ui/packages/i18n/src/locales/pt.ts | 6 + ui/packages/i18n/src/locales/ru.ts | 6 + ui/packages/i18n/src/locales/zh.ts | 6 + ui/packages/plugin-list/src/ListView.tsx | 21 +++- .../src/components/ViewSettingsPopover.tsx | 113 +++++++++++++++++- 16 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx diff --git a/ui/packages/app-shell/src/utils/recordOpenPreference.ts b/ui/packages/app-shell/src/utils/recordOpenPreference.ts index cd1d82ba499..e608b742d22 100644 --- a/ui/packages/app-shell/src/utils/recordOpenPreference.ts +++ b/ui/packages/app-shell/src/utils/recordOpenPreference.ts @@ -6,6 +6,8 @@ * LICENSE file in the root directory of this source tree. */ +import { useCallback, useSyncExternalStore } from 'react'; + /** * How the console's object page opens a record a reader clicks, when the * object opens its records in the drawer — a READER's own preference, not @@ -19,9 +21,11 @@ * lives here instead: `drawer` draws the record in the drawer over the list, * `split` draws the same record page beside the list. * - * This is the READ side. The Display-menu control that writes it is - * objectstack#26681; until it lands nothing in the console writes the key, so - * every reader gets the default. + * Both sides live here: {@link readRecordOpenPreference} and + * {@link writeRecordOpenPreference}, and {@link useRecordOpenPreference}, the + * live pair the object page reads — the Display menu's 「打开记录」 control + * writes through it and the next row click opens the new way, with no reload + * (objectstack#26681). * * Read per browser: the value lives in `localStorage` under * {@link RECORD_OPEN_PREFERENCE_KEY}, across objects. An absent value, an @@ -42,7 +46,10 @@ export const DEFAULT_RECORD_OPEN_PREFERENCE: RecordOpenPreference = 'drawer'; /** The storage surface this module reads — `localStorage`, or a test double. */ export type RecordOpenPreferenceStorage = Pick; -function defaultStorage(): RecordOpenPreferenceStorage | null { +/** The storage surface this module writes — `localStorage`, or a test double. */ +export type RecordOpenPreferenceWriteStorage = Pick; + +function defaultStorage(): Storage | null { if (typeof window === 'undefined') return null; try { return window.localStorage; @@ -69,3 +76,77 @@ export function readRecordOpenPreference( return DEFAULT_RECORD_OPEN_PREFERENCE; } } + +/** + * The latest choice made on this page, ahead of storage: a browser whose + * storage refuses the write (private mode, a full quota, a blocked origin) + * would otherwise have the reader pick 分屏 and keep reading 抽屉 back. + * `undefined` until a write; another tab's write clears it (see `subscribe`), + * so storage answers again. + */ +let pageChoice: RecordOpenPreference | undefined; + +/** The live {@link useRecordOpenPreference} callers on this page. */ +const listeners = new Set<() => void>(); + +/** + * Keep the reader's choice. `storage` defaults to the browser's + * `localStorage`; `null` says there is none. With no storage, or one that + * refuses the write, nothing is kept and nothing throws: the choice holds on + * this page, through {@link useRecordOpenPreference}, and the next page load + * reads the default. + * + * Every live {@link useRecordOpenPreference} on this page hears the write; + * another tab of the same origin hears it through the browser's `storage` + * event. + */ +export function writeRecordOpenPreference( + next: RecordOpenPreference, + storage: RecordOpenPreferenceWriteStorage | null = defaultStorage(), +): void { + pageChoice = next; + if (storage) { + try { + storage.setItem(RECORD_OPEN_PREFERENCE_KEY, next); + } catch { + // Not kept past this page — see above. + } + } + for (const listener of Array.from(listeners)) listener(); +} + +function subscribe(onChange: () => void): () => void { + listeners.add(onChange); + const onStorage = (event: StorageEvent) => { + // `key === null` is a `clear()` in another tab. + if (event.key === RECORD_OPEN_PREFERENCE_KEY || event.key === null) { + pageChoice = undefined; + onChange(); + } + }; + if (typeof window !== 'undefined') window.addEventListener('storage', onStorage); + return () => { + listeners.delete(onChange); + if (typeof window !== 'undefined') window.removeEventListener('storage', onStorage); + }; +} + +function snapshot(): RecordOpenPreference { + return pageChoice ?? readRecordOpenPreference(); +} + +function serverSnapshot(): RecordOpenPreference { + return DEFAULT_RECORD_OPEN_PREFERENCE; +} + +/** + * The reader's record-open preference as live state, and the setter the + * Display menu writes it with (objectstack#26681). A write here, in another + * component of this page, or in another tab re-renders every caller, so the + * object page decides its split face from the value the reader just chose. + */ +export function useRecordOpenPreference(): [RecordOpenPreference, (next: RecordOpenPreference) => void] { + const preference = useSyncExternalStore(subscribe, snapshot, serverSnapshot); + const setPreference = useCallback((next: RecordOpenPreference) => writeRecordOpenPreference(next), []); + return [preference, setPreference]; +} diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index fe1d0643ebe..43072da53b2 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -44,7 +44,8 @@ import { IdentityImportOptions, IdentityImportResultExtra, identityImportFields import { ManagedUsersInviteLine } from './managedEnvironmentUsers.js'; import { importTargetFields } from './importTargetFields.js'; import { resolveViewId } from '../utils/resolveViewId.js'; -import { readRecordOpenPreference } from '../utils/recordOpenPreference.js'; +import { useRecordOpenPreference } from '../utils/recordOpenPreference.js'; +import type { ListViewProps } from '@object-ui/plugin-list'; import { defaultListViewId, viewRowId } from '../utils/viewIdentity.js'; import { useObjectActions } from '../hooks/useObjectActions.js'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; @@ -714,11 +715,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // objectstack#25992 slice 6: the record navigation overlay (drawer state in the // URL, row click, expand to page, record links) moved to `./objectViewRecordNavigation.ts`. // objectstack#26677 — the side-by-side pane is a READER's preference, not a - // `navigation.mode` (`split` left the spec). Read once per mount; the - // Display-menu control that writes it is objectstack#26681. It applies only - // to a record that opens in the drawer: an object that declares `page` - // (or `new_window` / `none`) keeps its declaration. - const [recordOpenPreference] = useState(readRecordOpenPreference); + // `navigation.mode` (`split` left the spec). Live state: the Display menu's + // Open records section writes it (objectstack#26681) and the next row click + // opens the new way. It applies only to a record that opens in the drawer: + // an object that declares `page` (or `new_window` / `none`) keeps its + // declaration. + const [recordOpenPreference, setRecordOpenPreference] = useRecordOpenPreference(); const { listRecordActions, navOverlay, handleDrawerClose, handleRowClick, handleExpandDrawer } = useObjectViewRecordNavigation({ activeView, objectDef, @@ -738,6 +740,20 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co // The split face draws the drawer's own record beside the list: same URL // state (`?recordId=`), same close, a different shell. const opensBesideList = recordOpenPreference === 'split' && navOverlay.mode === 'drawer'; + // objectstack#26681 — the Display menu's Open records section: the mode this + // object and view resolved to, and the reader's preference with its setter. + const recordOpen = useMemo>( + () => ({ resolvedMode: navOverlay.mode, preference: recordOpenPreference, setPreference: setRecordOpenPreference }), + [navOverlay.mode, recordOpenPreference, setRecordOpenPreference], + ); + // objectstack#26681 — the name cell's ↗, "open as full page", for a row that + // opens the drawer (or the pane beside the list). A `page` object's row + // already opens the page; a `new_window` / `none` one keeps its declaration. + const openAsPageLabel = t('console.objectView.openAsPage', { defaultValue: 'Open as full page' }); + const openAsPage = useMemo( + () => (navOverlay.mode === 'drawer' ? { objectName: objectDef.name, label: openAsPageLabel } : null), + [navOverlay.mode, objectDef.name, openAsPageLabel], + ); // objectstack#25992 slice 8: the `filter[...]` URL equality filters and the `uf_*` // user-filter selections moved to `./objectViewListUrlState.ts`. @@ -800,6 +816,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co actions, handleRowClick, navOverlay, + recordOpen, + openAsPage, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx index 1a7ce855419..c349de6e092 100644 --- a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx +++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx @@ -15,7 +15,7 @@ import type { useObjectListPreferences, ListViewPreference } from '../context/Li const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })), ); -import { ListView } from '@object-ui/plugin-list'; +import { ListView, type ListViewProps } from '@object-ui/plugin-list'; import type { ListViewSchema, TreeViewConfig } from '@object-ui/types'; import type { useAuth } from '@object-ui/auth'; import type { useActionTextLocalizer } from '@object-ui/react'; @@ -37,6 +37,7 @@ import { isSameOptionsValue, } from './objectViewOptions.js'; import { keepInlineEditModeForTheSession } from './objectViewChrome.js'; +import { composeRowAdornment } from './objectViewOpenAsPage.js'; import type { useObjectViewIdentity } from './objectViewIdentity.js'; import type { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; @@ -70,6 +71,8 @@ export function useObjectViewListRenderer({ actions, handleRowClick, navOverlay, + recordOpen, + openAsPage, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, @@ -101,6 +104,10 @@ export function useObjectViewListRenderer({ actions: ReturnType; handleRowClick: ReturnType['handleRowClick']; navOverlay: ReturnType['navOverlay']; + /** objectstack#26681 — the Display menu's Open records section. */ + recordOpen: NonNullable; + /** objectstack#26681 — the name cell's ↗ (`./objectViewOpenAsPage.tsx`), or `null` for none. */ + openAsPage: { objectName: string; label: string } | null; urlFilters: ReturnType['urlFilters']; initialUfSelections: ReturnType['initialUfSelections']; handleUserFilterSelectionsChange: ReturnType['handleUserFilterSelectionsChange']; @@ -905,11 +912,16 @@ export function useObjectViewListRenderer({ // objectui#13330 — on a managed environment, the Users list // marks a user who holds nothing beyond the baseline. A host // prop the grid draws beside the row's title. - rowAdornment={managedUsers ? managedUserMarker : undefined} + // + // objectstack#26681 — and, for a row that opens the drawer, the + // ↗ that opens its record as the full page (after the marker). + rowAdornment={composeRowAdornment(managedUsers ? managedUserMarker : undefined, openAsPage)} + // objectstack#26681 — the Display menu's Open records section. + recordOpen={recordOpen} dataSource={ds} /> ); - }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker]); + }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar, viewTabRow, managedUsers, managedUserMarker, recordOpen, openAsPage]); return { renderListView }; } diff --git a/ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx b/ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx new file mode 100644 index 00000000000..2f0b9a43d2a --- /dev/null +++ b/ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx @@ -0,0 +1,111 @@ +/** + * 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. + */ + +/** + * The name cell's ↗ on the Console object list (objectstack#26681): open this + * row's record as the full page, whatever the reader's drawer | split + * preference, without changing it. + * + * A row of an object that opens its records in the drawer opens the drawer + * (or the pane beside the list, by the reader's preference); the record page + * itself is one ↗ away. It is drawn beside the row's title through the grid's + * host channel (`rowAdornment`), and only for a drawer-mode object: a row of a + * `page` object already opens the page, and a `none` list opens nothing. + * + * **The URL is not built here.** The list page publishes its record builder on + * `RelatedRecordActionsContext` (`listRecordActionsValue`): `recordHref` is the + * anchor's href and `openRecord` the SPA navigation a plain left click takes — + * the record route, with the list as the record's origin, the destination the + * drawer's own "open as full page" reaches. A host that cannot route to the + * record answers `null`, and nothing is drawn. + * + * Hidden by OPACITY until the row is hovered or holds focus, as the row's ⋮ + * menu is (`RowActionMenu`), so it keeps its Tab stop and its name; a coarse + * pointer, which has no hover, always shows it. + */ + +import * as React from 'react'; +import { ArrowUpRight } from 'lucide-react'; +import { useRelatedRecordActions } from '@object-ui/react'; + +const OPEN_AS_PAGE_CLASS = + 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground ' + + 'opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100 ' + + 'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:opacity-100'; + +export function OpenAsPageLink({ + objectName, + recordId, + label, +}: { + objectName: string; + recordId: string | number; + /** The tooltip and accessible name: 「整页打开」. */ + label: string; +}) { + const host = useRelatedRecordActions(); + const href = host?.recordHref?.(objectName, recordId) ?? null; + if (href === null || !host?.openRecord) return null; + const openRecord = host.openRecord; + return ( + { + // The row under it opens the drawer on a click: following this + // link must never also do that. + e.stopPropagation(); + // A modifier or non-primary click belongs to the browser (a new + // tab of the same record page), as on the name's own link. + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; + e.preventDefault(); + openRecord(objectName, recordId); + }} + onKeyDown={(e) => { + // Enter is the anchor's own activation (the browser turns it into + // the click above). The grid cell around it reads Enter as "edit + // this cell" in inline-edit mode and would cancel that click, so + // the key stops here. + if (e.key === 'Enter') e.stopPropagation(); + }} + > + + ); +} + +/** A row's primary key, as the grid's own link cell reads it (`rowRecordId`). */ +function rowId(row: Record): string | number | null { + const id = row?.id ?? row?._id; + return (typeof id === 'string' && id !== '') || typeof id === 'number' ? id : null; +} + +/** + * The row adornment the list hands the grid: the ↗ for a drawer-mode object, + * after any adornment the page already draws (the managed-environment Users + * marker). `undefined` when there is nothing to draw on any row, so the grid + * keeps its title cells exactly as they were. + */ +export function composeRowAdornment( + own: ((row: Record) => React.ReactNode) | undefined, + openAsPage: { objectName: string; label: string } | null, +): ((row: Record) => React.ReactNode) | undefined { + if (!openAsPage) return own; + return (row) => { + const id = rowId(row); + const link = id === null + ? null + : ; + const before = own ? own(row) : null; + if (before === null || before === undefined || before === false) return link; + return <>{before}{link}; + }; +} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 8734575d302..78c176f8c4a 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -935,6 +935,11 @@ const ar = { layout: "التخطيط", rowNumbers: "أرقام الصفوف", resetToViewDefault: "إعادة التعيين إلى الإعدادات الافتراضية للعرض", + recordOpen: "فتح السجلات", + recordOpenDrawer: "درج جانبي", + recordOpenSplit: "عرض مقسوم", + recordOpenNote: "تفضيلك الشخصي، وليس جزءًا من تعريف الكائن. كلاهما يتوسّع إلى صفحة كاملة.", + recordOpenDeclaredPage: "هذا الكائن مُعرَّف ليُفتح كصفحة كاملة", addGroup: "إضافة حقل مجموعة", collapsedByDefault: "مطوي افتراضياً", removeGroup: "إزالة", @@ -2349,6 +2354,7 @@ const ar = { resetViewTitle: "إعادة هذا العرض إلى الفرز وعوامل التصفية وعرض الأعمدة الخاصة به. تتغير إعداداتك فقط.", cannotDeleteMetaView: "هذا عرض مدمج معرَّف في الكود — لا يمكن حذفه.", expandToPage: "فتح كصفحة كاملة", + openAsPage: "فتح كصفحة كاملة", objectNotFound: "الكائن غير موجود", objectNotFoundDescription: "الكائن \"{{objectName}}\" غير موجود في الإعدادات الحالية.", objectNotFoundHint: "تحقق من إعدادات التنقل في تطبيقك أو اختر كائنًا آخر من الشريط الجانبي.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 1c8c60aa99c..b3ce0ae5a3d 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -830,6 +830,11 @@ const de = { layout: "Layout", rowNumbers: "Zeilennummern", resetToViewDefault: "Auf Standard der Ansicht zurücksetzen", + recordOpen: "Datensätze öffnen", + recordOpenDrawer: "Seitenleiste", + recordOpenSplit: "Geteilt", + recordOpenNote: "Ihre persönliche Einstellung, nicht Teil der Objektdeklaration. Beide lassen sich zur ganzen Seite erweitern.", + recordOpenDeclaredPage: "Dieses Objekt ist so deklariert, dass es als ganze Seite geöffnet wird", addGroup: "Gruppenfeld hinzufügen", collapsedByDefault: "Standardmäßig eingeklappt", removeGroup: "Entfernen", @@ -2150,6 +2155,7 @@ const de = { resetViewTitle: "Sortierung, Filter und Spaltenbreiten dieser Ansicht auf ihre eigenen Einstellungen zurücksetzen. Nur Ihre Einstellungen ändern sich.", cannotDeleteMetaView: "Diese Ansicht ist integriert und im Code definiert – sie kann nicht gelöscht werden.", expandToPage: "Als ganze Seite öffnen", + openAsPage: "Als ganze Seite öffnen", objectNotFound: "Objekt nicht gefunden", objectNotFoundDescription: "Das Objekt „{{objectName}}“ existiert in der aktuellen Konfiguration nicht.", objectNotFoundHint: "Überprüfen Sie Ihre App-Navigationseinstellungen oder wählen Sie ein anderes Objekt aus der Seitenleiste.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 5b9022a3423..af9f84d7950 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1089,6 +1089,11 @@ const en = { layout: 'Layout', rowNumbers: 'Row numbers', resetToViewDefault: 'Reset to view default', + recordOpen: 'Open records', + recordOpenDrawer: 'Drawer', + recordOpenSplit: 'Split', + recordOpenNote: 'Your own preference, not part of the object’s declaration. Both expand to the full page.', + recordOpenDeclaredPage: 'This object is declared to open as a full page', addGroup: 'Add group field', collapsedByDefault: 'Collapsed by default', removeGroup: 'Remove', @@ -2668,6 +2673,7 @@ const en = { resetViewTitle: 'Set this view back to its own sort, filters and column widths. Only yours change.', cannotDeleteMetaView: 'This view is built in and defined in code — it cannot be deleted.', expandToPage: 'Open as full page', + openAsPage: 'Open as full page', allRecords: 'All Records', new: 'New', import: 'Import', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 2777ee04b1d..2ce0bfe0e91 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -859,6 +859,11 @@ const es = { layout: "Diseño", rowNumbers: "Números de fila", resetToViewDefault: "Restablecer los valores predeterminados de la vista", + recordOpen: "Abrir registros", + recordOpenDrawer: "Panel lateral", + recordOpenSplit: "Dividido", + recordOpenNote: "Tu preferencia personal, no forma parte de la declaración del objeto. Ambos se amplían a página completa.", + recordOpenDeclaredPage: "Este objeto está declarado para abrirse como página completa", addGroup: "Agregar campo de grupo", collapsedByDefault: "Contraído por defecto", removeGroup: "Eliminar", @@ -2202,6 +2207,7 @@ const es = { resetViewTitle: "Devolver esta vista a su propia ordenación, filtros y anchos de columna. Solo cambia su configuración.", cannotDeleteMetaView: "Esta vista está integrada y definida en el código: no se puede eliminar.", expandToPage: "Abrir como página completa", + openAsPage: "Abrir como página completa", objectNotFound: "Objeto no encontrado", objectNotFoundDescription: "El objeto \"{{objectName}}\" no existe en la configuración actual.", objectNotFoundHint: "Verifique la configuración de navegación de su aplicación o seleccione un objeto diferente en la barra lateral.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 742820a60e2..80ce043e483 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -854,6 +854,11 @@ const fr = { layout: "Disposition", rowNumbers: "Numéros de ligne", resetToViewDefault: "Rétablir les valeurs par défaut de la vue", + recordOpen: "Ouvrir les enregistrements", + recordOpenDrawer: "Panneau latéral", + recordOpenSplit: "Partagé", + recordOpenNote: "Votre préférence personnelle, hors de la déclaration de l’objet. Les deux s’agrandissent en pleine page.", + recordOpenDeclaredPage: "Cet objet est déclaré pour s’ouvrir en pleine page", addGroup: "Ajouter un champ de groupe", collapsedByDefault: "Réduit par défaut", removeGroup: "Supprimer", @@ -2198,6 +2203,7 @@ const fr = { resetViewTitle: "Rétablir le tri, les filtres et les largeurs de colonnes propres à cette vue. Seuls vos réglages changent.", cannotDeleteMetaView: "Cette vue est intégrée et définie dans le code — elle ne peut pas être supprimée.", expandToPage: "Ouvrir en pleine page", + openAsPage: "Ouvrir en pleine page", objectNotFound: "Objet introuvable", objectNotFoundDescription: "L'objet « {{objectName}} » n'existe pas dans la configuration actuelle.", objectNotFoundHint: "Vérifiez les paramètres de navigation de votre application ou sélectionnez un autre objet dans la barre latérale.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index ec0c1814b4a..f9ac1f7f32e 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -830,6 +830,11 @@ const ja = { layout: "レイアウト", rowNumbers: "行番号", resetToViewDefault: "ビューの既定に戻す", + recordOpen: "レコードを開く", + recordOpenDrawer: "ドロワー", + recordOpenSplit: "分割", + recordOpenNote: "個人の設定で、オブジェクトの宣言には含まれません。どちらもフルページに展開できます。", + recordOpenDeclaredPage: "このオブジェクトはフルページで開くよう宣言されています", addGroup: "グループフィールドを追加", collapsedByDefault: "デフォルトで折りたたむ", removeGroup: "削除", @@ -2152,6 +2157,7 @@ const ja = { resetViewTitle: "このビューの並べ替え、フィルター、列幅をビュー本来の設定に戻します。変わるのはあなたの表示だけです。", cannotDeleteMetaView: "このビューはコードで定義された組み込みビューのため、削除できません。", expandToPage: "フルページで開く", + openAsPage: "フルページで開く", objectNotFound: "オブジェクトが見つかりません", objectNotFoundDescription: "オブジェクト「{{objectName}}」は現在の設定に存在しません。", objectNotFoundHint: "アプリのナビゲーション設定を確認するか、サイドバーから別のオブジェクトを選択してください。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index c3f6c2b3357..55ffb353162 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -830,6 +830,11 @@ const ko = { layout: "레이아웃", rowNumbers: "행 번호", resetToViewDefault: "보기 기본값으로 재설정", + recordOpen: "레코드 열기", + recordOpenDrawer: "서랍", + recordOpenSplit: "분할", + recordOpenNote: "개인 설정이며 객체 선언에 포함되지 않습니다. 둘 다 전체 페이지로 확장할 수 있습니다.", + recordOpenDeclaredPage: "이 객체는 전체 페이지로 열리도록 선언되어 있습니다", addGroup: "그룹 필드 추가", collapsedByDefault: "기본적으로 접힘", removeGroup: "제거", @@ -2150,6 +2155,7 @@ const ko = { resetViewTitle: "이 보기의 정렬, 필터, 열 너비를 보기 자체의 설정으로 되돌립니다. 본인의 설정만 변경됩니다.", cannotDeleteMetaView: "이 보기는 코드에 정의된 기본 제공 보기이므로 삭제할 수 없습니다.", expandToPage: "전체 페이지로 열기", + openAsPage: "전체 페이지로 열기", objectNotFound: "객체를 찾을 수 없음", objectNotFoundDescription: "객체 \"{{objectName}}\"이(가) 현재 구성에 존재하지 않습니다.", objectNotFoundHint: "앱 탐색 설정을 확인하거나 사이드바에서 다른 객체를 선택하세요.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 3ea5434ca82..709c4981bde 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -854,6 +854,11 @@ const pt = { layout: "Layout", rowNumbers: "Números de linha", resetToViewDefault: "Restaurar o padrão da exibição", + recordOpen: "Abrir registros", + recordOpenDrawer: "Painel lateral", + recordOpenSplit: "Dividido", + recordOpenNote: "Sua preferência pessoal, não faz parte da declaração do objeto. Ambos se expandem para a página inteira.", + recordOpenDeclaredPage: "Este objeto está declarado para abrir como página inteira", addGroup: "Adicionar campo de grupo", collapsedByDefault: "Recolhido por padrão", removeGroup: "Remover", @@ -2197,6 +2202,7 @@ const pt = { resetViewTitle: "Retornar esta exibição à sua própria ordenação, filtros e larguras de coluna. Apenas as suas configurações mudam.", cannotDeleteMetaView: "Esta exibição é integrada e definida no código — não é possível excluí-la.", expandToPage: "Abrir como página inteira", + openAsPage: "Abrir como página inteira", objectNotFound: "Objeto não encontrado", objectNotFoundDescription: "O objeto \"{{objectName}}\" não existe na configuração atual.", objectNotFoundHint: "Verifique as configurações de navegação do seu aplicativo ou selecione um objeto diferente na barra lateral.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 57ace4f220b..a972b5bf442 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -884,6 +884,11 @@ const ru = { layout: "Макет", rowNumbers: "Номера строк", resetToViewDefault: "Сбросить к настройкам представления", + recordOpen: "Открытие записей", + recordOpenDrawer: "Боковая панель", + recordOpenSplit: "Разделённый вид", + recordOpenNote: "Ваша личная настройка, не входит в объявление объекта. Оба варианта разворачиваются на всю страницу.", + recordOpenDeclaredPage: "Этот объект объявлен для открытия на всю страницу", addGroup: "Добавить поле группировки", collapsedByDefault: "Свёрнуто по умолчанию", removeGroup: "Удалить", @@ -2256,6 +2261,7 @@ const ru = { resetViewTitle: "Вернуть сортировку, фильтры и ширину столбцов этого представления к его собственным настройкам. Изменятся только ваши настройки.", cannotDeleteMetaView: "Это встроенное представление, определённое в коде, — удалить его нельзя.", expandToPage: "Открыть на всю страницу", + openAsPage: "Открыть на всю страницу", objectNotFound: "Объект не найден", objectNotFoundDescription: "Объект «{{objectName}}» не существует в текущей конфигурации.", objectNotFoundHint: "Проверьте настройки навигации приложения или выберите другой объект на боковой панели.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index faed640b2b9..996974c7887 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -853,6 +853,11 @@ const zh = { layout: '布局', rowNumbers: '行号', resetToViewDefault: '恢复视图默认设置', + recordOpen: '打开记录', + recordOpenDrawer: '抽屉', + recordOpenSplit: '分屏', + recordOpenNote: '个人偏好,不进对象声明;两种都能展开为整页', + recordOpenDeclaredPage: '此对象声明为整页打开', addGroup: '添加分组字段', collapsedByDefault: '默认折叠', removeGroup: '删除', @@ -2226,6 +2231,7 @@ const zh = { resetViewTitle: '将此视图的排序、筛选和列宽恢复为视图自身的设置。只影响你自己。', cannotDeleteMetaView: '该视图为内置视图,由代码定义,无法删除。', expandToPage: '以完整页面打开', + openAsPage: '整页打开', objectNotFound: '未找到对象', objectNotFoundDescription: '对象"{{objectName}}"在当前配置中不存在。', objectNotFoundHint: '请检查您的应用导航设置或从侧边栏选择其他对象。', diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 50881f57f35..a18be93b686 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -15,7 +15,7 @@ import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@obje import { ViewSwitcherDropdown } from './ViewSwitcher'; import { ListEmptyState, hasDeclaredFilter, filterNamesCurrentUser } from './ListEmptyState'; import { ListViewCompactHeader, ListViewCompactRows, ListViewCappedRows, CAPPED_LIST_DEFAULT_LIMIT } from './ListViewCompact'; -import { ViewSettingsPopover } from './components/ViewSettingsPopover'; +import { ViewSettingsPopover, type ViewSettingsRecordOpen } from './components/ViewSettingsPopover'; import { UserFilters } from './UserFilters'; import { calendarWindowCovers, @@ -466,6 +466,17 @@ export interface ListViewProps { * that draws the rows it fetched answers; other kinds never call it. */ onTotalChange?: (total: number) => void; + /** + * The Display control's Open records section (objectstack#26681): how a row + * opens its record — the mode the host resolved for this object and view, + * the reader's own drawer | split preference and its setter. See + * `ViewSettingsRecordOpen`. + * + * A HOST prop, never a schema key: the preference is the reader's, kept by + * the host, and the setter is a function value no document can carry. + * Absent ⇒ the panel draws no such section, exactly as before. + */ + recordOpen?: ViewSettingsRecordOpen; } // Helper to convert FilterBuilder group to ObjectStack AST. @@ -1091,6 +1102,12 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.layout': 'Layout', 'list.rowNumbers': 'Row numbers', 'list.resetToViewDefault': 'Reset to view default', + // The Display control's Open records section (objectstack#26681). + 'list.recordOpen': 'Open records', + 'list.recordOpenDrawer': 'Drawer', + 'list.recordOpenSplit': 'Split', + 'list.recordOpenNote': 'Your own preference, not part of the object’s declaration. Both expand to the full page.', + 'list.recordOpenDeclaredPage': 'This object is declared to open as a full page', // Heading of the record-detail overlay this view opens when a child view's // row is clicked (objectui#3426). Borrowed from the `detail.*` namespace // rather than minted as `list.recordDetail`: `NavigationOverlay` already @@ -1515,6 +1532,7 @@ export const ListView = React.forwardRef(({ capped = false, onTotalChange, viewDefaults, + recordOpen, ...props }: ListViewProps & { [key: string]: any }, ref) => { // The switcher can be enabled either by the host component (prop) or by @@ -5979,6 +5997,7 @@ export const ListView = React.forwardRef(({ }} /> ) : undefined} + recordOpen={recordOpen} showGroup={toolbarFlags.showGroup} groupingConfig={groupingConfig} setGroupingConfig={changeGrouping} diff --git a/ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx b/ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx index c0729f08440..2fba8862c98 100644 --- a/ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx +++ b/ui/packages/plugin-list/src/components/ViewSettingsPopover.tsx @@ -5,6 +5,7 @@ * the bar keeps Filter, Sort, Display and Search, and everything else sits * here, in this order — * - Layout (the visualization switcher; only when the list offers a choice) + * - Open records (how a row opens its record; only when the host passes it) * - Group, Row color, Hide fields * - Density * - Row numbers (a session-only switch, grid only) @@ -14,7 +15,8 @@ * It began as UX Sprint 2 (P1-4)'s "View settings" gear behind * `compactToolbar: true`; the wide form — one toolbar button per control — is * what a host gets with `compactToolbar: false`, and ListView.tsx keeps it. - * Content is duplicated between the two forms: change one, change both. + * Content is duplicated between the two forms: change one, change both. One + * exception: Open records is drawn here only — see `recordOpen`. * * Every control persists exactly what its wide-form twin persists; this * component writes nothing itself. The trigger's count is the number of @@ -38,6 +40,8 @@ import { SelectTrigger, SelectValue, Switch, + ToggleGroup, + ToggleGroupItem, } from '@object-ui/components'; import { SlidersHorizontal, @@ -61,6 +65,34 @@ export interface ViewSettingsDensity { cycle: () => void; } +/** How a reader prefers a drawer-mode record to open (objectstack#26681). */ +export type ViewSettingsRecordOpenPreference = 'drawer' | 'split'; + +/** + * The Open records section (objectstack#26681): how a row opens its record. + * + * The side-by-side pane is each READER's own choice, kept per browser across + * objects; it is not metadata, and an object declares only `drawer` (the + * default) or `page` (maintainer ruling 2026-10-27, objectstack#26677). So the + * section answers by the mode the host resolved for this object and view: + * + * - `drawer`: two segments, Drawer | Split, writing the reader's preference + * through `setPreference`, with a one-line note that the choice is the + * reader's own and that both expand to the full page; + * - `page`: one read-only line saying the object is declared to open as a + * full page — the declaration wins, so there is nothing to choose; + * - `new_window` / `none`: no section. The preference changes nothing there + * and the ruling gives those declarations no line of their own. + */ +export interface ViewSettingsRecordOpen { + /** The `navigation.mode` the host resolved for this object and view. */ + resolvedMode: 'drawer' | 'page' | 'new_window' | 'none'; + /** The reader's current preference; read only when `resolvedMode` is `drawer`. */ + preference: ViewSettingsRecordOpenPreference; + /** Keep the reader's choice. The host owns where it is kept. */ + setPreference: (next: ViewSettingsRecordOpenPreference) => void; +} + export interface ViewSettingsPopoverProps { t: (key: string, opts?: any) => string; allFields: ViewSettingsField[]; @@ -79,6 +111,17 @@ export interface ViewSettingsPopoverProps { */ layout?: (closeMenu: () => void) => React.ReactNode; + /** + * The Open records section (objectstack#26681), drawn after Layout. A host + * prop: the console passes it; any other host draws no section. It is not a + * setting of the view, so it neither counts in `changedCount` nor returns + * under Reset. + * + * Drawn in this panel only: no host that passes it draws the wide toolbar + * (`compactToolbar: false`), so the wide form has no twin of it. + */ + recordOpen?: ViewSettingsRecordOpen; + showGroup?: boolean; groupingConfig?: any; setGroupingConfig?: (next: any) => void; @@ -253,6 +296,71 @@ function SwitchRow({ ); } +/** + * The Open records section — see {@link ViewSettingsRecordOpen}. `null` for a + * mode it draws nothing for. + */ +function RecordOpenSection({ + t, + recordOpen, +}: { + t: ViewSettingsPopoverProps['t']; + recordOpen: ViewSettingsRecordOpen; +}) { + const { resolvedMode, preference, setPreference } = recordOpen; + if (resolvedMode !== 'drawer' && resolvedMode !== 'page') return null; + const title = t('list.recordOpen', { defaultValue: 'Open records' }); + return ( +
+
+ {title} + {resolvedMode === 'drawer' ? ( + { + if ((next === 'drawer' || next === 'split') && next !== preference) setPreference(next); + }} + aria-label={title} + className="gap-0" + data-testid="display-record-open-choice" + > + + {t('list.recordOpenDrawer', { defaultValue: 'Drawer' })} + + + {t('list.recordOpenSplit', { defaultValue: 'Split' })} + + + ) : ( + + {t('list.recordOpenDeclaredPage', { defaultValue: 'This object is declared to open as a full page' })} + + )} +
+ {resolvedMode === 'drawer' && ( +

+ {t('list.recordOpenNote', { + defaultValue: 'Your own preference, not part of the object’s declaration. Both expand to the full page.', + })} +

+ )} +
+ ); +} + /** Set on the layout slot while its segments show icons only; the class below reads it. */ const LAYOUT_FOLD_ATTR = 'data-layout-fold'; @@ -321,6 +429,7 @@ export function ViewSettingsPopover(props: ViewSettingsPopoverProps) { t, allFields, layout, + recordOpen, showGroup, groupingConfig, setGroupingConfig, @@ -396,6 +505,8 @@ export function ViewSettingsPopover(props: ViewSettingsPopoverProps) { )} + {recordOpen && } + {showGroup && setGroupingConfig && (
Date: Wed, 28 Oct 2026 08:17:05 +0800 Subject: [PATCH 2/3] test(app-shell,plugin-list): the Open records section, the live preference and the name cell's open-as-page arrow Claude-Session: https://claude.ai/code/session_cdb7a717-def7-4e71-9988-f089fbaa18ff Co-authored-by: Claude --- .../src/utils/recordOpenPreference.test.ts | 27 +++ .../src/utils/recordOpenPreference.ts | 8 +- ...bjectView.recordOpenDisplay-26681.test.tsx | 223 ++++++++++++++++++ ...wSettingsPopover.recordOpen-26681.test.tsx | 125 ++++++++++ 4 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx create mode 100644 ui/packages/plugin-list/src/__tests__/ViewSettingsPopover.recordOpen-26681.test.tsx diff --git a/ui/packages/app-shell/src/utils/recordOpenPreference.test.ts b/ui/packages/app-shell/src/utils/recordOpenPreference.test.ts index 09d0d2b163d..9841de4b437 100644 --- a/ui/packages/app-shell/src/utils/recordOpenPreference.test.ts +++ b/ui/packages/app-shell/src/utils/recordOpenPreference.test.ts @@ -20,6 +20,7 @@ import { DEFAULT_RECORD_OPEN_PREFERENCE, RECORD_OPEN_PREFERENCE_KEY, readRecordOpenPreference, + writeRecordOpenPreference, } from './recordOpenPreference'; const storageWith = (value: string | null) => ({ getItem: (key: string) => (key === RECORD_OPEN_PREFERENCE_KEY ? value : null) }); @@ -55,3 +56,29 @@ describe('readRecordOpenPreference (objectstack#26677)', () => { expect(readRecordOpenPreference()).toBe('drawer'); }); }); + +describe('writeRecordOpenPreference (objectstack#26681)', () => { + /** A storage double that keeps what it is handed. */ + const memoryStorage = () => { + const kept = new Map(); + return { + getItem: (key: string) => kept.get(key) ?? null, + setItem: (key: string, value: string) => void kept.set(key, value), + }; + }; + + it('keeps the choice under the key the read side reads', () => { + const storage = memoryStorage(); + writeRecordOpenPreference('split', storage); + expect(storage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('split'); + expect(readRecordOpenPreference(storage)).toBe('split'); + writeRecordOpenPreference('drawer', storage); + expect(readRecordOpenPreference(storage)).toBe('drawer'); + }); + + it('writes nothing and throws nothing with no storage, or with a storage that refuses the write', () => { + expect(() => writeRecordOpenPreference('split', null)).not.toThrow(); + const refusing = { setItem: () => { throw new Error('QuotaExceededError'); } }; + expect(() => writeRecordOpenPreference('split', refusing)).not.toThrow(); + }); +}); diff --git a/ui/packages/app-shell/src/utils/recordOpenPreference.ts b/ui/packages/app-shell/src/utils/recordOpenPreference.ts index e608b742d22..4d4c91b1e91 100644 --- a/ui/packages/app-shell/src/utils/recordOpenPreference.ts +++ b/ui/packages/app-shell/src/utils/recordOpenPreference.ts @@ -78,11 +78,12 @@ export function readRecordOpenPreference( } /** - * The latest choice made on this page, ahead of storage: a browser whose + * A choice storage would not keep, held for this page: a browser whose * storage refuses the write (private mode, a full quota, a blocked origin) * would otherwise have the reader pick 分屏 and keep reading 抽屉 back. - * `undefined` until a write; another tab's write clears it (see `subscribe`), - * so storage answers again. + * `undefined` while storage keeps the choice — storage is then the one answer + * — and cleared by the next kept write and by another tab's write (see + * `subscribe`). */ let pageChoice: RecordOpenPreference | undefined; @@ -108,6 +109,7 @@ export function writeRecordOpenPreference( if (storage) { try { storage.setItem(RECORD_OPEN_PREFERENCE_KEY, next); + pageChoice = undefined; } catch { // Not kept past this page — see above. } diff --git a/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx b/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx new file mode 100644 index 00000000000..5418d140107 --- /dev/null +++ b/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx @@ -0,0 +1,223 @@ +/** + * 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. + */ + +/** + * objectstack#26681 — the console object page's Display menu writes the + * reader's record-open preference, and the name cell's ↗ opens the record as + * the full page. + * + * End to end through `ObjectView`, the host that resolves the mode and keeps + * the preference (`utils/recordOpenPreference.ts`): + * + * - a drawer object: the Open records section draws Drawer | Split; choosing + * Split keeps `split` under `os:record-open-mode`, and the next row click + * draws the record BESIDE the list (the split face: the list's record-count + * footer and the pane's close control) — no reload, no remount; + * - a view that declares `page`: the read-only line, no segments, and no ↗ + * (its rows already open the page); + * - the ↗ beside a drawer row's title is a real anchor to the record route, + * and a plain click on it lands there, leaving the preference as it was. + * + * The record body is not what this file reads, so `RecordDetailView` is a + * null sink. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities — `ListView` names `perms` in its fetch dependencies. + 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 }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +// The record body: not what this file reads. +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => ({ save: vi.fn(async () => ({})), get: vi.fn(async () => null) }), +})); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; +import { RECORD_OPEN_PREFERENCE_KEY } from '../utils/recordOpenPreference'; + +const OBJECT_NAME = 'duly_task'; +const VIEW_ID = `${OBJECT_NAME}.all`; +const ROWS = [{ id: 'r1', name: 'Alpha' }]; + +const objectsWith = (navigation?: Record) => [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + listViews: { + [VIEW_ID]: { + name: VIEW_ID, + label: 'Everything', + type: 'grid', + columns: ['name'], + ...(navigation ? { navigation } : {}), + }, + }, + }, +]; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.localStorage.removeItem(RECORD_OPEN_PREFERENCE_KEY); + window.history.replaceState(null, '', '/'); +}); + +/** Mount the object list, its one row drawn. */ +async function openList(objects: ReturnType) { + const dataSource = { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: vi.fn(async (name: string) => objects.find((o) => o.name === name) ?? null), + } as never; + // The list's own record href is built from the DOCUMENT's path + // (`listRecordDetailUrl`), which a `MemoryRouter` does not move. + window.history.replaceState(null, '', `/apps/demo/${OBJECT_NAME}/view/${VIEW_ID}`); + render( + + + + + {}} />} + /> + record page} + /> + + + + , + ); + await screen.findByTestId('primary-field-link', undefined, { timeout: 5000 }); +} + +function openDisplay() { + fireEvent.click(screen.getByTestId('toolbar-display-trigger')); +} + +describe('objectstack#26681 — Open records in the Display menu', () => { + it('a drawer object: choosing Split keeps the preference, and the next row click opens the record beside the list', async () => { + await openList(objectsWith()); + openDisplay(); + const drawerSegment = await screen.findByRole('radio', { name: 'Drawer' }); + expect(drawerSegment.getAttribute('aria-checked')).toBe('true'); + fireEvent.click(screen.getByRole('radio', { name: 'Split' })); + + expect(window.localStorage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('split'); + // The section reads the live value back: no reload, no remount. + await waitFor(() => + expect(screen.getByRole('radio', { name: 'Split' }).getAttribute('aria-checked')).toBe('true'), + ); + + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); + fireEvent.click(screen.getByTestId('primary-field-link')); + expect(await screen.findByTestId('record-count-footer', undefined, { timeout: 5000 })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Close panel' })).toBeTruthy(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('a drawer object with no choice made: the row opens the drawer', async () => { + await openList(objectsWith()); + fireEvent.click(screen.getByTestId('primary-field-link')); + expect(await screen.findByRole('dialog', undefined, { timeout: 5000 })).toBeTruthy(); + expect(screen.queryByTestId('record-count-footer')).toBeNull(); + }); + + it('a view that declares `page`: the read-only line, no segments, and no ↗', async () => { + window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'split'); + await openList(objectsWith({ mode: 'page' })); + expect(screen.queryByTestId('record-open-as-page')).toBeNull(); + openDisplay(); + expect(await screen.findByTestId('display-record-open-declared')).toBeTruthy(); + expect(within(screen.getByTestId('display-record-open')).queryByRole('radio')).toBeNull(); + }); + + it('the ↗ beside a drawer row is an anchor to the record route, and a click lands there without changing the preference', async () => { + await openList(objectsWith()); + const arrow = await screen.findByTestId('record-open-as-page'); + expect(arrow.tagName).toBe('A'); + expect(arrow.getAttribute('href')).toBe(`/apps/demo/${OBJECT_NAME}/record/r1`); + expect(arrow.getAttribute('aria-label')).toBe('Open as full page'); + expect(arrow.getAttribute('title')).toBe('Open as full page'); + + fireEvent.click(arrow); + expect(await screen.findByTestId('record-page-route')).toBeTruthy(); + // Neither the drawer nor the pane opened on the way, and nothing was kept. + expect(screen.queryByRole('dialog')).toBeNull(); + expect(window.localStorage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-list/src/__tests__/ViewSettingsPopover.recordOpen-26681.test.tsx b/ui/packages/plugin-list/src/__tests__/ViewSettingsPopover.recordOpen-26681.test.tsx new file mode 100644 index 00000000000..4d6cb654dd0 --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/ViewSettingsPopover.recordOpen-26681.test.tsx @@ -0,0 +1,125 @@ +/** + * 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. + */ + +/** + * objectstack#26681 — the Display menu's Open records section. + * + * The side-by-side pane is each reader's own choice (maintainer ruling + * 2026-10-27, objectstack#26677), so the section answers by the mode the host + * resolved for the object and view: + * + * - `drawer`: two segments, Drawer | Split, each writing the reader's + * preference through the host's setter, with the note that the choice is + * the reader's own; + * - `page`: one read-only line — the declaration wins, nothing to choose; + * - `new_window` / `none`, or no `recordOpen` at all: no section. + * + * The note and the read-only line are read against the packs, not spelled + * here: what is pinned is which line is drawn, not its wording. Rendered in + * zh-CN as well, the language the ruling was given in. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import { en, zh as zhPack } from '@object-ui/i18n/locales'; +import { ViewSettingsPopover, type ViewSettingsRecordOpen } from '../components/ViewSettingsPopover'; + +afterEach(cleanup); + +function Harness({ recordOpen }: { recordOpen?: ViewSettingsRecordOpen }) { + const { t } = useObjectTranslation(); + return ( + string} + allFields={[{ name: 'name', label: 'Name' }]} + recordOpen={recordOpen} + onRefresh={() => {}} + /> + ); +} + +function openDisplay(recordOpen: ViewSettingsRecordOpen | undefined, language = 'en') { + render( + + + , + ); + fireEvent.click(screen.getByTestId('toolbar-display-trigger')); +} + +const drawer = (preference: 'drawer' | 'split', setPreference = vi.fn()): ViewSettingsRecordOpen => ({ + resolvedMode: 'drawer', + preference, + setPreference, +}); + +describe('ViewSettingsPopover — Open records (objectstack#26681)', () => { + it('a drawer object: the two segments, the reader\'s current choice pressed, and the note', async () => { + openDisplay(drawer('drawer')); + const section = await screen.findByTestId('display-record-open'); + expect(section.textContent).toContain('Open records'); + const group = screen.getByRole('radiogroup', { name: 'Open records' }); + expect(group).toBeTruthy(); + expect(screen.getByRole('radio', { name: 'Drawer' }).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByRole('radio', { name: 'Split' }).getAttribute('aria-checked')).toBe('false'); + expect(screen.getByTestId('display-record-open-note').textContent).toBe(en.list.recordOpenNote); + expect(screen.queryByTestId('display-record-open-declared')).toBeNull(); + }); + + it('choosing Split hands `split` to the host; pressing the pressed segment writes nothing', async () => { + const setPreference = vi.fn(); + openDisplay(drawer('drawer', setPreference)); + fireEvent.click(await screen.findByRole('radio', { name: 'Drawer' })); + expect(setPreference).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('radio', { name: 'Split' })); + expect(setPreference).toHaveBeenCalledTimes(1); + expect(setPreference).toHaveBeenCalledWith('split'); + }); + + it('a reader who prefers the split pane sees Split pressed, and can go back to Drawer', async () => { + const setPreference = vi.fn(); + openDisplay(drawer('split', setPreference)); + expect((await screen.findByRole('radio', { name: 'Split' })).getAttribute('aria-checked')).toBe('true'); + fireEvent.click(screen.getByRole('radio', { name: 'Drawer' })); + expect(setPreference).toHaveBeenCalledWith('drawer'); + }); + + it('a `page` object: the read-only line, no segments, no note', async () => { + openDisplay({ resolvedMode: 'page', preference: 'split', setPreference: vi.fn() }); + expect((await screen.findByTestId('display-record-open-declared')).textContent).toBe( + en.list.recordOpenDeclaredPage, + ); + expect(screen.queryByRole('radio')).toBeNull(); + expect(screen.queryByTestId('display-record-open-note')).toBeNull(); + }); + + it.each(['new_window', 'none'] as const)('a `%s` object: no section', async (resolvedMode) => { + openDisplay({ resolvedMode, preference: 'drawer', setPreference: vi.fn() }); + await screen.findByTestId('view-settings-content'); + expect(screen.queryByTestId('display-record-open')).toBeNull(); + }); + + it('a host that passes no `recordOpen`: no section', async () => { + openDisplay(undefined); + await screen.findByTestId('view-settings-content'); + expect(screen.queryByTestId('display-record-open')).toBeNull(); + }); + + it('zh-CN: the section reads the zh pack', async () => { + const zh = zhPack.list; + openDisplay(drawer('drawer'), 'zh'); + // The zh pack loads on demand: wait for its words, not for the section. + expect(await screen.findByRole('radio', { name: zh.recordOpenDrawer })).toBeTruthy(); + expect(screen.getByRole('radio', { name: zh.recordOpenSplit })).toBeTruthy(); + expect(screen.getByTestId('display-record-open').textContent).toContain(zh.recordOpen); + expect(screen.getByTestId('display-record-open-note').textContent).toBe(zh.recordOpenNote); + cleanup(); + openDisplay({ resolvedMode: 'page', preference: 'drawer', setPreference: vi.fn() }, 'zh'); + expect((await screen.findByText(zh.recordOpenDeclaredPage)).getAttribute('data-testid')).toBe('display-record-open-declared'); + }); +}); From 4b3209b5389c6e6d2408d87bea10ea4bb7e0893d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:34:18 +0800 Subject: [PATCH 3/3] feat(plugin-grid,plugin-list,app-shell): the open-as-page arrow sits beside the row's record link, through a recordLinkAdornment host channel The title column rowAdornment picks is not where the record opens from: on HotCRM contacts it is the avatar column. The arrow now goes after the first authored link (or the auto-linked primary field) in the link cell itself. Claude-Session: https://claude.ai/code/session_cdb7a717-def7-4e71-9988-f089fbaa18ff Co-authored-by: Claude --- .../26681-display-record-open-preference.md | 14 +++ ...bjectView.recordOpenDisplay-26681.test.tsx | 6 +- .../src/views/objectViewListRenderer.tsx | 17 ++- .../src/views/objectViewOpenAsPage.tsx | 35 +----- ui/packages/plugin-grid/src/ObjectGrid.tsx | 20 ++- ...ectGrid.recordLinkAdornment-26681.test.tsx | 118 ++++++++++++++++++ .../src/objectGridGenerateColumns.tsx | 19 ++- .../plugin-grid/src/objectGridLinkCell.tsx | 28 +++++ ui/packages/plugin-list/src/ListView.tsx | 17 +++ 9 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 .changeset/26681-display-record-open-preference.md create mode 100644 ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordLinkAdornment-26681.test.tsx diff --git a/.changeset/26681-display-record-open-preference.md b/.changeset/26681-display-record-open-preference.md new file mode 100644 index 00000000000..a849baae508 --- /dev/null +++ b/.changeset/26681-display-record-open-preference.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-list': patch +'@object-ui/plugin-grid': patch +'@object-ui/i18n': patch +--- + +feat(plugin-list,plugin-grid,i18n): the Display menu's Open records section — the reader's drawer | split preference, or the object's declared full page — and a host node beside each row's record link (#26681) + +Clause-②: no +Metadata-protocol: none + +- **`@object-ui/plugin-list`**: `ListView` takes a `recordOpen` host prop and hands it to the Display menu (`ViewSettingsPopover`), which draws an Open records section after Layout. For a drawer-mode object it offers two segments, Drawer | Split, writing the reader's own preference through the host's `setPreference`, with a note that the choice is the reader's and that both expand to the full page. For an object or view that declares `navigation.mode: 'page'` it draws one read-only line instead; for `new_window` and `none`, nothing. A host that passes no `recordOpen` draws no section, so every other host is unchanged. It is a host prop, not a view key: no metadata is added, and the section neither counts toward the Display trigger's changed-settings count nor returns under "Reset to view default". +- **`@object-ui/plugin-list`** and **`@object-ui/plugin-grid`**: `ListView` and `ObjectGrid` take a `recordLinkAdornment` host prop — a node drawn beside each row's record link, after the anchor and never inside it: beside the first column the view links (`link: true`), else beside the auto-linked primary field. `rowAdornment` is unchanged: it sits beside the row's title, which need not be where the record opens from. The console passes an "open as full page" arrow through it for a row that opens the drawer. Absent, the link cell is exactly the anchor it was. +- **`@object-ui/i18n`**: the section's five `list.*` strings (`recordOpen`, `recordOpenDrawer`, `recordOpenSplit`, `recordOpenNote`, `recordOpenDeclaredPage`) and `console.objectView.openAsPage`, the tooltip of the console list's open-as-full-page arrow, in all ten packs. diff --git a/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx b/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx index 5418d140107..6f397a431c2 100644 --- a/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.recordOpenDisplay-26681.test.tsx @@ -20,7 +20,7 @@ * footer and the pane's close control) — no reload, no remount; * - a view that declares `page`: the read-only line, no segments, and no ↗ * (its rows already open the page); - * - the ↗ beside a drawer row's title is a real anchor to the record route, + * - the ↗ beside a drawer row's record link is a real anchor to the record route, * and a plain click on it lands there, leaving the preference as it was. * * The record body is not what this file reads, so `RecordDetailView` is a @@ -213,6 +213,10 @@ describe('objectstack#26681 — Open records in the Display menu', () => { expect(arrow.getAttribute('href')).toBe(`/apps/demo/${OBJECT_NAME}/record/r1`); expect(arrow.getAttribute('aria-label')).toBe('Open as full page'); expect(arrow.getAttribute('title')).toBe('Open as full page'); + // Beside the row's record link, never inside it. + const pair = arrow.closest('[data-testid="link-cell-trailing"]') as HTMLElement; + expect(pair.querySelector('[data-testid="primary-field-link"]')).not.toBeNull(); + expect(arrow.parentElement?.closest('a')).toBeNull(); fireEvent.click(arrow); expect(await screen.findByTestId('record-page-route')).toBeTruthy(); diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx index c349de6e092..784a14c9544 100644 --- a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx +++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx @@ -37,7 +37,7 @@ import { isSameOptionsValue, } from './objectViewOptions.js'; import { keepInlineEditModeForTheSession } from './objectViewChrome.js'; -import { composeRowAdornment } from './objectViewOpenAsPage.js'; +import { OpenAsPageLink } from './objectViewOpenAsPage.js'; import type { useObjectViewIdentity } from './objectViewIdentity.js'; import type { useObjectViewRecordNavigation } from './objectViewRecordNavigation.js'; import type { useObjectViewToolbarAffordances } from './objectViewToolbarAffordances.js'; @@ -912,10 +912,17 @@ export function useObjectViewListRenderer({ // objectui#13330 — on a managed environment, the Users list // marks a user who holds nothing beyond the baseline. A host // prop the grid draws beside the row's title. - // - // objectstack#26681 — and, for a row that opens the drawer, the - // ↗ that opens its record as the full page (after the marker). - rowAdornment={composeRowAdornment(managedUsers ? managedUserMarker : undefined, openAsPage)} + rowAdornment={managedUsers ? managedUserMarker : undefined} + // objectstack#26681 — for a row that opens the drawer, the ↗ + // beside its record link that opens the record as the full page. + recordLinkAdornment={openAsPage + ? (row: Record) => { + const id = row?.id ?? row?._id; + return (typeof id === 'string' && id !== '') || typeof id === 'number' + ? + : null; + } + : undefined} // objectstack#26681 — the Display menu's Open records section. recordOpen={recordOpen} dataSource={ds} diff --git a/ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx b/ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx index 2f0b9a43d2a..18f45dfa1b1 100644 --- a/ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx +++ b/ui/packages/app-shell/src/views/objectViewOpenAsPage.tsx @@ -13,9 +13,10 @@ * * A row of an object that opens its records in the drawer opens the drawer * (or the pane beside the list, by the reader's preference); the record page - * itself is one ↗ away. It is drawn beside the row's title through the grid's - * host channel (`rowAdornment`), and only for a drawer-mode object: a row of a - * `page` object already opens the page, and a `none` list opens nothing. + * itself is one ↗ away. It is drawn after the row's record link — the name the + * row opens from — through the grid's host channel (`recordLinkAdornment`), + * and only for a drawer-mode object: a row of a `page` object already opens + * the page, and a `none` list opens nothing. * * **The URL is not built here.** The list page publishes its record builder on * `RelatedRecordActionsContext` (`listRecordActionsValue`): `recordHref` is the @@ -29,7 +30,6 @@ * pointer, which has no hover, always shows it. */ -import * as React from 'react'; import { ArrowUpRight } from 'lucide-react'; import { useRelatedRecordActions } from '@object-ui/react'; @@ -82,30 +82,3 @@ export function OpenAsPageLink({ ); } -/** A row's primary key, as the grid's own link cell reads it (`rowRecordId`). */ -function rowId(row: Record): string | number | null { - const id = row?.id ?? row?._id; - return (typeof id === 'string' && id !== '') || typeof id === 'number' ? id : null; -} - -/** - * The row adornment the list hands the grid: the ↗ for a drawer-mode object, - * after any adornment the page already draws (the managed-environment Users - * marker). `undefined` when there is nothing to draw on any row, so the grid - * keeps its title cells exactly as they were. - */ -export function composeRowAdornment( - own: ((row: Record) => React.ReactNode) | undefined, - openAsPage: { objectName: string; label: string } | null, -): ((row: Record) => React.ReactNode) | undefined { - if (!openAsPage) return own; - return (row) => { - const id = rowId(row); - const link = id === null - ? null - : ; - const before = own ? own(row) : null; - if (before === null || before === undefined || before === false) return link; - return <>{before}{link}; - }; -} diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index a155b96b4ae..833bb9e689b 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -276,6 +276,23 @@ export interface ObjectGridComponentProps extends ObjectGridExternalPaginationPr * composes, nothing an author writes, and no node key reaches it. */ rowAdornment?: (record: any) => React.ReactNode; + /** + * [objectstack#26681] A host-drawn node beside ONE row's record LINK — the + * link cell the row's record opens from (the primary field, or a column + * authored `link: true`) — after the link and never inside it, in the grid + * and in the card view's title alike. Called with the row record; `null` / + * `undefined` draws nothing, and so does an absent prop, so a caller that + * passes nothing renders exactly what it rendered before this prop existed. + * + * Not {@link rowAdornment}: that one sits beside the row's TITLE, the + * declared name field's column when shown, which need not be the column + * that opens the record. The console's object list passes an "open as full + * page" arrow for a row that opens the drawer, so it belongs at the link. + * + * A RUNTIME prop, never authored metadata: it is a React node a host + * composes, nothing an author writes, and no node key reaches it. + */ + recordLinkAdornment?: (record: any) => React.ReactNode; /** * TWO parameters since objectui#9357, and the second is not decoration: this * prop reaches `useNavigationOverlay` as its `onRowClick`, and `handleClick` @@ -596,6 +613,7 @@ export const ObjectGrid: React.FC = ({ onDelete, rowOperations, rowAdornment, + recordLinkAdornment, onBulkDelete, onRowSelect, onRowClick, @@ -1217,7 +1235,7 @@ export const ObjectGrid: React.FC = ({ const { generateColumns } = useObjectGridGenerateColumns({ schema, dataSource, data, dataConfig, hasInlineData, objectName, objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, perms, navigation, executeAction, predicateScope, - resolveFieldLabel, translateOptions, uiLanguage, + resolveFieldLabel, translateOptions, uiLanguage, recordLinkAdornment, }); // Formats this grid can actually deliver (objectui#2942): the server stream diff --git a/ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordLinkAdornment-26681.test.tsx b/ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordLinkAdornment-26681.test.tsx new file mode 100644 index 00000000000..b19e4cda265 --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordLinkAdornment-26681.test.tsx @@ -0,0 +1,118 @@ +/** + * 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. + */ + +/** + * objectstack#26681 — `ObjectGrid`'s `recordLinkAdornment`: a host node beside + * each row's record LINK, after the anchor and never inside it. + * + * The console's object list hands its "open as full page" arrow through it, so + * the arrow sits at the name the row opens from — not at the row's title + * column (`rowAdornment`), which on an object whose declared name field no + * column shows is the first column, an avatar or a status. + * + * - the node is drawn once per row, beside that row's link, as the anchor's + * SIBLING (an anchor inside an anchor is invalid markup and splits the + * click); + * - a click on it never reaches the row's own click; + * - an absent prop leaves the link cell exactly as it was: the anchor is + * the cell's whole content, with no wrapper. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, RelatedRecordActionsProvider } from '@object-ui/react'; + +registerAllFields(); + +const rows = [ + { id: 'r1', status: 'open', name: 'Alice' }, + { id: 'r2', status: 'won', name: 'Bob' }, +]; + +const host = { + resolve: () => ({}), + recordHref: (objectName: string, recordId: string | number) => `/apps/demo/${objectName}/record/${recordId}`, + openRecord: vi.fn(), +} as any; + +function renderGrid(props: { recordLinkAdornment?: (row: any) => React.ReactNode; onRowClick?: (row: any) => void }) { + const schema: any = { + type: 'object-grid' as const, + objectName: 'test_object', + // The authored link is the SECOND column. The first is auto-linked as + // the primary field too, so each row draws TWO links; the node goes only + // beside the one the author linked. + columns: [ + { field: 'status', label: 'Status' }, + { field: 'name', label: 'Name', link: true }, + ], + data: { provider: 'value', items: rows }, + }; + return render( + + + + + , + ); +} + +describe('ObjectGrid recordLinkAdornment (objectstack#26681)', () => { + it('draws the host node beside each row\'s record link, as the anchor\'s sibling', async () => { + renderGrid({ recordLinkAdornment: (row) => }); + await waitFor(() => expect(screen.getAllByTestId('link-cell')).toHaveLength(2)); + + for (const id of ['r1', 'r2']) { + const node = screen.getByTestId(`beside-${id}`); + // Never inside the anchor. + expect(node.closest('a')).toBeNull(); + // Beside THIS row's link, in the same cell. + const pair = node.closest('[data-testid="link-cell-trailing"]') as HTMLElement; + expect(pair).not.toBeNull(); + const link = pair.querySelector('a[data-testid="link-cell"]'); + expect(link).toHaveAttribute('href', `/apps/demo/test_object/record/${id}`); + expect(link).toHaveTextContent(id === 'r1' ? 'Alice' : 'Bob'); + } + // One per row: never beside the first column's auto-link as well. + expect(screen.getAllByTestId('primary-field-link')).toHaveLength(2); + expect(document.querySelectorAll('[data-testid="link-cell-trailing"]')).toHaveLength(2); + for (const auto of screen.getAllByTestId('primary-field-link')) { + expect(auto.closest('[data-testid="link-cell-trailing"]')).toBeNull(); + } + }); + + it('a host that returns null for a row draws nothing beside that row', async () => { + renderGrid({ recordLinkAdornment: (row) => (row.id === 'r1' ? ↗ : null) }); + await waitFor(() => expect(screen.getAllByTestId('link-cell')).toHaveLength(2)); + expect(screen.getAllByTestId('beside')).toHaveLength(1); + expect(document.querySelectorAll('[data-testid="link-cell-trailing"]')).toHaveLength(1); + }); + + it('with no prop, the link cell is the anchor alone — no wrapper', async () => { + renderGrid({}); + await waitFor(() => expect(screen.getAllByTestId('link-cell')).toHaveLength(2)); + expect(document.querySelector('[data-testid="link-cell-trailing"]')).toBeNull(); + }); + + it('a click on the node does not reach the row', async () => { + const onRowClick = vi.fn(); + const onArrow = vi.fn(); + renderGrid({ + onRowClick, + recordLinkAdornment: (row) => ( + + ), + }); + fireEvent.click(await screen.findByTestId('beside-r1')); + expect(onArrow).toHaveBeenCalledWith('r1'); + expect(onRowClick).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx b/ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx index 5adf811cd19..1d99b819ef4 100644 --- a/ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx +++ b/ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx @@ -35,7 +35,7 @@ type QueryInputs = ReturnType; export function useObjectGridGenerateColumns({ schema, dataSource, data, dataConfig, hasInlineData, objectName, objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, perms, navigation, executeAction, predicateScope, - resolveFieldLabel, translateOptions, uiLanguage, + resolveFieldLabel, translateOptions, uiLanguage, recordLinkAdornment, }: { schema: ObjectGridSchema; dataSource: DataSource | undefined; @@ -55,6 +55,8 @@ export function useObjectGridGenerateColumns({ resolveFieldLabel: ReturnType['fieldLabel']; translateOptions: ReturnType['translateOptions']; uiLanguage: ReturnType['language']; + /** objectstack#26681 — the host's node beside a row's record link (`ObjectGrid`'s prop). */ + recordLinkAdornment?: (record: any) => React.ReactNode; }) { const generateColumns = useCallback((): ObjectGridColumnDraft[] => { // objectui#11815 — the host's verdict on a declared deadline's @@ -255,7 +257,7 @@ export function useObjectGridGenerateColumns({ return (cols as ListColumn[]) .filter((col) => resolvesToDataColumn(col)) .filter((col) => passesFieldGate(col)) - .map((col, colIndex) => { + .map((col, colIndex, shown) => { // The column's own `label` is the spec's "Display label override" // (`ListColumnSchema`), so when the author wrote one it IS the // header, and the field-label lookup is not consulted @@ -380,6 +382,13 @@ export function useObjectGridGenerateColumns({ // Auto-link primary field (first column) to record detail (Airtable-style) const isPrimaryField = colIndex === 0 && !col.link && !col.action; const isLinked = col.link || isPrimaryField; + // objectstack#26681 — the host's `recordLinkAdornment` goes beside + // ONE link per row: the first column the author linked + // (`link: true`), else the auto-linked primary field. A view that + // links its name column keeps the node off the first column's + // auto-link (an avatar, a status). + const firstAuthoredLink = shown.findIndex((c) => !!c.link); + const adornsLink = firstAuthoredLink === -1 ? isPrimaryField : colIndex === firstAuthoredLink; // The face the value takes INSIDE the link: never an anchor of its // own (objectui#11817, `linkCellRenderer`). const LinkContentRenderer = linkCellRenderer(inferredType, CellRenderer); @@ -400,6 +409,7 @@ export function useObjectGridGenerateColumns({ objectName={schema.objectName} recordId={rowRecordId(row)} wrap={col.wrap} + trailing={adornsLink ? recordLinkAdornment?.(row) : undefined} > {displayContent} @@ -418,6 +428,7 @@ export function useObjectGridGenerateColumns({ objectName={schema.objectName} recordId={rowRecordId(row)} wrap={col.wrap} + trailing={adornsLink ? recordLinkAdornment?.(row) : undefined} > {displayContent} @@ -637,6 +648,7 @@ export function useObjectGridGenerateColumns({ onActivate={() => navigation.handleClick(row)} objectName={schema.objectName} recordId={rowRecordId(row)} + trailing={recordLinkAdornment?.(row)} > {displayContent} @@ -649,6 +661,7 @@ export function useObjectGridGenerateColumns({ onActivate={() => navigation.handleClick(row)} objectName={schema.objectName} recordId={rowRecordId(row)} + trailing={recordLinkAdornment?.(row)} > {value != null && value !== '' ? String(value) : } @@ -933,7 +946,7 @@ export function useObjectGridGenerateColumns({ }); return generatedColumns; - }, [objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms, uiLanguage, dataSource, predicateScope]); + }, [objectSchema, objectFields, objectTypesPending, schemaFields, schemaColumns, dataConfig, hasInlineData, objectName, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms, uiLanguage, dataSource, predicateScope, recordLinkAdornment]); return { generateColumns }; } diff --git a/ui/packages/plugin-grid/src/objectGridLinkCell.tsx b/ui/packages/plugin-grid/src/objectGridLinkCell.tsx index d7cdeabbe01..c5895458a74 100644 --- a/ui/packages/plugin-grid/src/objectGridLinkCell.tsx +++ b/ui/packages/plugin-grid/src/objectGridLinkCell.tsx @@ -93,6 +93,34 @@ export const LinkCell: React.FC<{ * `data-table` does to the body around it. Absent / `false` ⇒ unchanged. */ wrap?: boolean; + /** + * A host-drawn node after the link, never inside it (objectstack#26681): + * `ObjectGrid`'s `recordLinkAdornment` for this row. The console's object + * list hands its "open as full page" arrow here. Absent (or `null` / + * `false`) ⇒ the cell is exactly the link, as before. + */ + trailing?: React.ReactNode; + children: React.ReactNode; +}> = ({ trailing, ...link }) => { + const own = ; + if (trailing === null || trailing === undefined || trailing === false) return own; + // The link keeps its own one-line clamp: its box may shrink (`min-w-0`), + // the trailing node never does. + return ( + + {own} + {trailing} + + ); +}; + +/** {@link LinkCell}'s link itself: the anchor, or the span a host with no record URL gets. */ +const RecordLink: React.FC<{ + testId: string; + onActivate: () => void; + objectName?: string; + recordId?: string | number | null; + wrap?: boolean; children: React.ReactNode; }> = ({ testId, onActivate, objectName, recordId, wrap, children }) => { const host = useRelatedRecordActions(); diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index a18be93b686..046f58fbebb 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -413,6 +413,17 @@ export interface ListViewProps { * Absent ⇒ every row is exactly what it was. */ rowAdornment?: (record: Record) => React.ReactNode; + /** + * A host-drawn node beside each row's record LINK in the grid + * (objectstack#26681), handed to `ObjectGrid`'s `recordLinkAdornment`: after + * the link cell the row's record opens from, never inside it. Called with + * the row record; `null` draws nothing. The console's object list passes an + * "open as full page" arrow for a row that opens the drawer. + * + * A HOST prop, never a schema key, for the reason `rowAdornment` is one. + * Only the grid is handed it. Absent ⇒ every row is exactly what it was. + */ + recordLinkAdornment?: (record: Record) => React.ReactNode; /** * The VIEW'S OWN hidden fields and row height, for a host that lays the * user's own preference over them in `schema` (objectui#12949). The Display @@ -1528,6 +1539,7 @@ export const ListView = React.forwardRef(({ toolbarLeading, defaultRowNumbers = true, rowAdornment, + recordLinkAdornment, compact = false, capped = false, onTotalChange, @@ -6475,6 +6487,11 @@ export const ListView = React.forwardRef(({ // the grid's host channel (a React prop, never a node key). ? { rowAdornment } : {})} + {...(viewComponentSchema.type === 'object-grid' && recordLinkAdornment + // objectstack#26681 — the host's node beside each row's record + // link, on the grid's host channel (a React prop, never a node key). + ? { recordLinkAdornment } + : {})} // [objectui#12081] A windowed calendar keeps drawing while the // next window loads (the refresh bar above says so), and shows its // own loading state only when it has nothing to draw.