diff --git a/.changeset/27604-record-hover-card.md b/.changeset/27604-record-hover-card.md new file mode 100644 index 00000000000..6e8e9e790bc --- /dev/null +++ b/.changeset/27604-record-hover-card.md @@ -0,0 +1,18 @@ +--- +'@object-ui/fields': patch +'@object-ui/plugin-detail': patch +--- + +feat(fields): every record reference shows a hover card with the target's declared highlights, without navigating + +Clause-②: yes (widening) +Metadata-protocol: none + +Hovering a record reference for 400 ms, or pressing Space on it, shows a floating card: the target object's icon and label, the record's name as a link that opens it the way a click on the reference does, and the object's declared `highlightFields` minus the name field, at most six, drawn by the same cell renderers a list uses (option labels, currency, dates, lookups). Esc, moving the pointer away or blurring the reference hides it, and one card is open at a time. Focus alone does not show it. + +- **Where.** Every lookup and master-detail value that links to its record (`LookupCellRenderer`): a relation column in a list, in a related list's table and in its enlarged view, and a lookup in the record's details. On the record page's related column, the name in a compact related row shows the card too, and its title opens the row as the name does. +- **What it reads.** Nothing until a card is shown; then one `findOne` for the record, with `$expand` naming only the reference fields the card draws. The answer is kept per record and read again after the data bus says the record changed, so a list of 200 rows reads nothing on render. +- **Metadata only.** Only the object's declared `highlightFields` are drawn: a name the object does not define, the name field and a field the viewer may not read are left out. An object that declares none shows the record's name and the object label. A record the viewer cannot read shows the reference's name and the object label. +- **Layout.** The card is portalled to the document body, so the row, cell or chip under it keeps its layout. + +`@object-ui/fields` exports `RecordHoverCard` (with `RecordHoverCardProps` and `RecordHoverCardObjectDef`) for a host that draws a reference of its own: wrap the element that names the record and pass the target's `objectName`, `recordId` and the object definition the host already holds. diff --git a/ui/packages/fields/src/RecordHoverCard.tsx b/ui/packages/fields/src/RecordHoverCard.tsx new file mode 100644 index 00000000000..e9b07459a93 --- /dev/null +++ b/ui/packages/fields/src/RecordHoverCard.tsx @@ -0,0 +1,518 @@ +/** + * 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. + */ + +/** + * RecordHoverCard — a glance at a referenced record without leaving the page + * (objectstack#27604). + * + * Wrap any element that names a record — a lookup value's link, the name in a + * compact related row — and hovering it for {@link OPEN_DELAY_MS} shows a + * floating card: the target object's icon and label, the record's name (a + * link that opens it the way the reference itself does), and the object's + * declared `highlightFields` (ADR-0085), drawn by the same cell renderers a + * list draws them with. Keyboard: Space on the focused reference shows the + * card, Esc hides it. Focus alone does not: tabbing through a list of + * references must not pop a card per row. + * + * Invariants: + * + * - **Stateless and floating.** The card is portalled to `document.body`, so + * the host keeps its layout: nothing expands, nothing pushes the panels + * below. One card is open at a time. + * - **One read per shown card, none per reference.** Nothing is read until a + * card is shown. The read is one `findOne` (with `$expand` naming only the + * reference fields the card draws), cached per record in this module and + * dropped when the data bus says the record changed. + * - **Metadata only.** The fields are the DECLARED `highlightFields`, minus + * the name field, those the object does not define and those the viewer may + * not read, at most {@link HIGHLIGHTS_MAX}. Nothing is derived: an object + * that declares none shows its name and its object label only. + * - **No card inside a card.** References drawn inside the card are plain + * links ({@link InsideRecordHoverCard}). + * - **Inert without a source.** No data source with `findOne` in the + * renderer context, no object name or no record id: the child renders + * exactly as it would without this wrapper. + */ + +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { HoverCard, HoverCardContent, HoverCardTrigger, LazyIcon, Skeleton } from '@object-ui/components'; +import { + buildExpandFields, + getRecordDisplayName, + humanizeLabel, + isEmptyValue, + resolveNameField, + withoutDeniedFields, +} from '@object-ui/core'; +import { pickLocalized } from '@object-ui/i18n'; +import type { DataSource, FieldMetadata } from '@object-ui/types'; +import { usePermissions } from '@object-ui/permissions'; +import { + SchemaRendererContext, + dataChangeMatches, + subscribeDataChanges, + useDataInvalidation, + useObjectLabel, + useObjectTranslation, + useRelatedRecordActions, +} from '@object-ui/react'; +import { getCellRenderer, pickRecordDisplayName, resolveCellRendererType } from './index.js'; + +/** How long a pointer rests on a reference before its card shows. */ +const OPEN_DELAY_MS = 400; +/** How long the card stays after the pointer leaves (time to reach it). */ +const CLOSE_DELAY_MS = 200; +/** The record header's six (`RECORD_HIGHLIGHTS_MAX` in `@object-ui/plugin-detail`). */ +const HIGHLIGHTS_MAX = 6; +/** Records whose read the module keeps; the oldest goes first. */ +const READS_MAX = 200; + +/** The target object's definition, as the host already holds it. */ +export interface RecordHoverCardObjectDef { + name?: string; + label?: unknown; + icon?: unknown; + fields?: Record | null | undefined> | null; + highlightFields?: unknown; + [key: string]: unknown; +} + +export interface RecordHoverCardProps { + /** The referenced record's object. */ + objectName?: string; + /** The referenced record's id. */ + recordId?: string | number | null; + /** + * The target object's definition the host holds (its `fields`, + * `highlightFields`, `icon`, `label`). Absent: the card shows the record's + * name and the object name, no highlights. + */ + objectSchema?: RecordHoverCardObjectDef | null; + /** The name the reference shows, used until the record is read. */ + label?: string; + /** + * Where the record is read from: the source the host reads its own rows + * with. Absent: the renderer context's data source. + */ + dataSource?: Pick | null; + /** + * Open the record the way a click on the reference does. Absent: the host's + * `openRecord` (`RelatedRecordActionsContext`), the lookup link's own path. + */ + onOpen?: (event: React.MouseEvent) => void; + /** The reference: one element that takes a ref and pointer / key handlers. */ + children: React.ReactElement; +} + +/** The one read the card makes. */ +type HoverCardSource = Pick; + +/** `true` inside a card: references drawn there get no card of their own. */ +const InsideRecordHoverCard = React.createContext(false); + +/** Closes the card that is open now, so showing another hides it. */ +let closeOpenCard: (() => void) | null = null; + +// ── The read cache ───────────────────────────────────────────────────────── + +type HoverRead = + | { state: 'pending'; objectName: string; recordId: string; promise: Promise } + | { state: 'readable'; objectName: string; recordId: string; record: Record } + | { state: 'unreadable'; objectName: string; recordId: string }; + +const reads = new Map(); +let unsubscribeReads: (() => void) | null = null; + +const readKey = (objectName: string, recordId: string): string => `${objectName}\u0000${recordId}`; + +function storeRead(key: string, entry: HoverRead): void { + reads.delete(key); + reads.set(key, entry); + while (reads.size > READS_MAX) { + const oldest = reads.keys().next().value; + if (oldest === undefined) break; + reads.delete(oldest); + } +} + +/** A record the data bus says changed is read again the next time it shows. */ +function watchDataChanges(): void { + if (unsubscribeReads) return; + unsubscribeReads = subscribeDataChanges((change) => { + for (const [key, entry] of Array.from(reads)) { + if (entry.state !== 'pending' && dataChangeMatches(change, entry.objectName, entry.recordId)) reads.delete(key); + } + }); +} + +function startRead(source: HoverCardSource, objectName: string, recordId: string, expand: string[]): void { + watchDataChanges(); + const key = readKey(objectName, recordId); + // The executor runs synchronously, so a source that throws lands where a + // rejection does. + const promise = new Promise((resolve) => { + resolve( + expand.length > 0 + ? source.findOne(objectName, recordId, { $expand: expand }) + : source.findOne(objectName, recordId), + ); + }).then( + (record) => { + storeRead( + key, + record && typeof record === 'object' + ? { state: 'readable', objectName, recordId, record: record as Record } + : { state: 'unreadable', objectName, recordId }, + ); + }, + () => storeRead(key, { state: 'unreadable', objectName, recordId }), + ); + storeRead(key, { state: 'pending', objectName, recordId, promise }); +} + +/** + * The card's read of one record: from the cache when it holds the record, + * else one `findOne` shared by every card that shows the same record while it + * is out. A change on the data bus reads it again, keeping what the card shows + * until the answer lands. + */ +function useHoverCardRead( + source: HoverCardSource, + objectName: string, + recordId: string, + expand: string[], +): HoverRead | undefined { + const key = readKey(objectName, recordId); + const changed = useDataInvalidation(objectName, recordId); + // The last answer this card drew, shown while a re-read is out. + const [kept, setKept] = React.useState<{ key: string; entry: HoverRead } | null>(null); + // The data the read sends, as a string, so the effect keys on it. + const expandKey = expand.join(','); + React.useEffect(() => { + let alive = true; + if (!reads.has(key)) startRead(source, objectName, recordId, expandKey ? expandKey.split(',') : []); + const entry = reads.get(key); + if (entry?.state === 'pending') { + void entry.promise.then(() => { + const settled = reads.get(key); + if (alive && settled && settled.state !== 'pending') setKept({ key, entry: settled }); + }); + } + return () => { + alive = false; + }; + }, [key, source, objectName, recordId, expandKey, changed]); + const entry = reads.get(key); + if (entry && entry.state !== 'pending') return entry; + return kept?.key === key ? kept.entry : entry; +} + +// ── What the card shows ──────────────────────────────────────────────────── + +/** + * The declared `highlightFields` the card draws: strings naming a field the + * object defines, the name field and the fields the viewer may not read left + * out before the cap, so neither wastes a slot. + */ +function declaredHighlights( + def: RecordHoverCardObjectDef | null | undefined, + objectName: string, + perms: ReturnType, +): string[] { + if (!def || !Array.isArray(def.highlightFields)) return []; + const fields = def.fields ?? {}; + const nameField = resolveNameField(def); + return (def.highlightFields as unknown[]) + .filter( + (name): name is string => + typeof name === 'string' && + name !== '' && + name !== nameField && + fields[name] != null && + (!perms?.isLoaded || perms.checkField(objectName, name, 'read')), + ) + .slice(0, HIGHLIGHTS_MAX); +} + +const stopPropagation = (event: React.SyntheticEvent) => event.stopPropagation(); + +/** + * The record's name as a link: the host's record URL, so a modifier click + * opens a tab, and a plain click handed to `open` (the reference's own path). + */ +function CardTitle({ + objectName, + recordId, + onOpen, + onDone, + children, +}: { + objectName: string; + recordId: string; + onOpen?: (event: React.MouseEvent) => void; + onDone: () => void; + children: React.ReactNode; +}): React.ReactElement { + const host = useRelatedRecordActions(); + const href = host?.recordHref ? host.recordHref(objectName, recordId) : null; + const open = + onOpen ?? (host?.openRecord ? () => host.openRecord!(objectName, recordId) : undefined); + const className = + 'block max-w-full truncate rounded-sm text-left text-sm font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'; + if (href) { + return ( + { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return; + if (!open) return; + event.preventDefault(); + onDone(); + open(event); + }} + > + {children} + + ); + } + if (open) { + return ( + + ); + } + return ( + + {children} + + ); +} + +function RecordHoverCardBody({ + source, + objectName, + recordId, + objectSchema, + label, + onOpen, + onDone, +}: { + source: HoverCardSource; + objectName: string; + recordId: string; + objectSchema?: RecordHoverCardObjectDef | null; + label?: string; + onOpen?: (event: React.MouseEvent) => void; + onDone: () => void; +}): React.ReactElement { + const perms = usePermissions(); + const { language } = useObjectTranslation(); + const { objectLabel, fieldLabel, translateOptions } = useObjectLabel(); + const highlights = declaredHighlights(objectSchema, objectName, perms); + const expandable = objectSchema ? buildExpandFields(objectSchema.fields, highlights) : []; + const expand = perms?.isLoaded + ? expandable.filter((field) => perms.checkField(objectName, field, 'read')) + : expandable; + const read = useHoverCardRead(source, objectName, recordId, expand); + // Not asked yet is the first render's answer: the read starts in an effect. + const pending = !read || read.state === 'pending'; + const record = read?.state === 'readable' ? withoutDeniedFields(read.record, perms, objectName) : null; + + const name = + (record && (objectSchema ? getRecordDisplayName(objectSchema, record) : pickRecordDisplayName(record))) || + label || + recordId; + const typeLabel = objectLabel({ + name: objectName, + label: pickLocalized(objectSchema?.label, language) || humanizeLabel(objectName), + }); + + const rows = record + ? highlights + .filter((field) => !isEmptyValue(record[field])) + .map((field) => { + const def = objectSchema!.fields![field]!; + const caption = fieldLabel(objectName, field, pickLocalized(def.label, language) || humanizeLabel(field)); + const meta: Record = { ...def, name: field, label: caption }; + if (Array.isArray(def.options)) { + meta.options = translateOptions(objectName, field, def.options as Parameters[2]); + } + const Cell = getCellRenderer(resolveCellRendererType(meta as { type?: string; format?: string })); + return ( + +
{caption}
+
+ +
+
+ ); + }) + : []; + + return ( +
+
+ + +
+ + {name} + +
+ {typeLabel} +
+
+
+ {pending && highlights.length > 0 ? ( +
+ {highlights.map((field) => ( + + ))} +
+ ) : rows.length > 0 ? ( +
{rows}
+ ) : null} +
+ ); +} + +/** + * A hover card over one record reference. See the module comment for what it + * shows and the rules it keeps. + */ +export function RecordHoverCard({ + objectName, + recordId, + objectSchema, + label, + dataSource, + onOpen, + children, +}: RecordHoverCardProps): React.ReactElement { + const nested = React.useContext(InsideRecordHoverCard); + const fromContext = React.useContext(SchemaRendererContext)?.dataSource; + const source: HoverCardSource | null | undefined = dataSource === undefined ? fromContext : dataSource; + const [open, setOpen] = React.useState(false); + // Radix opens a hover card on focus as well as on hover; only a pointer + // resting on the reference (or Space) may open this one. + const pointerInside = React.useRef(false); + const [close] = React.useState(() => () => setOpen(false)); + React.useEffect( + () => () => { + if (closeOpenCard === close) closeOpenCard = null; + }, + [close], + ); + + if ( + nested || + !objectName || + recordId == null || + recordId === '' || + !source || + typeof source.findOne !== 'function' + ) { + return children; + } + + const show = () => { + if (closeOpenCard && closeOpenCard !== close) closeOpenCard(); + closeOpenCard = close; + setOpen(true); + }; + const hide = () => { + if (closeOpenCard === close) closeOpenCard = null; + setOpen(false); + }; + + return ( + { + if (!next) hide(); + else if (pointerInside.current) show(); + }} + openDelay={OPEN_DELAY_MS} + closeDelay={CLOSE_DELAY_MS} + > + { + if (event.pointerType !== 'touch') pointerInside.current = true; + }} + onPointerLeave={() => { + pointerInside.current = false; + }} + onKeyDown={(event) => { + if (event.key !== ' ' || event.target !== event.currentTarget) return; + // Space shows the card instead of scrolling the page or pressing a + // button reference; Enter still follows the reference. + event.preventDefault(); + event.stopPropagation(); + show(); + }} + onKeyUp={(event) => { + if (event.key === ' ' && event.target === event.currentTarget) event.preventDefault(); + }} + > + {children} + + {open && + typeof document !== 'undefined' && + createPortal( + + + + + , + document.body, + )} + + ); +} diff --git a/ui/packages/fields/src/__tests__/RecordHoverCard-27604.test.tsx b/ui/packages/fields/src/__tests__/RecordHoverCard-27604.test.tsx new file mode 100644 index 00000000000..ffb5c9886b7 --- /dev/null +++ b/ui/packages/fields/src/__tests__/RecordHoverCard-27604.test.tsx @@ -0,0 +1,346 @@ +/** + * 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#27604 — every record reference shows a hover card: the target's + * icon, name and object label, and its DECLARED `highlightFields` minus the + * name (at most six), drawn by the target's own cell renderers. Pinned through + * `LookupCellRenderer`, the one renderer a relation cell in a list, the + * enlarged tab's table and a lookup chip in the details column all draw with. + */ +import { describe, it, expect, vi } from 'vitest'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { LookupCellRenderer } from '../index'; +import { RelatedRecordActionsProvider, SchemaRendererProvider } from '@object-ui/react'; + +const LEAD = { + name: 'lead', + label: 'Lead', + icon: 'user-plus', + nameField: 'name', + fields: { + name: { type: 'text', label: 'Name' }, + company: { type: 'text', label: 'Company' }, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'new', label: 'New' }, + { value: 'working', label: 'Working' }, + ], + }, + rating: { type: 'select', label: 'Rating', options: [{ value: 'hot', label: 'Hot' }] }, + email: { type: 'email', label: 'Email' }, + annual_revenue: { type: 'currency', label: 'Annual revenue', currency: 'USD' }, + phone: { type: 'phone', label: 'Phone' }, + lead_source: { type: 'text', label: 'Lead source' }, + }, + // Eight entries, the name first: the card drops the name and stops at six. + highlightFields: ['name', 'company', 'status', 'rating', 'email', 'annual_revenue', 'phone', 'lead_source'], +}; + +const CONTACT = { + name: 'contact', + label: 'Contact', + icon: 'contact', + nameField: 'name', + // No status field anywhere on the object. + fields: { + name: { type: 'text', label: 'Name' }, + title: { type: 'text', label: 'Title' }, + email: { type: 'email', label: 'Email' }, + phone: { type: 'phone', label: 'Phone' }, + account: { type: 'lookup', label: 'Account', reference: 'account' }, + }, + highlightFields: ['account', 'title', 'email', 'phone'], +}; + +const PRODUCT = { + name: 'product', + label: 'Product', + icon: 'package', + nameField: 'name', + fields: { + name: { type: 'text', label: 'Name' }, + product_code: { type: 'text', label: 'Product code' }, + family: { type: 'select', label: 'Family', options: [{ value: 'hardware', label: 'Hardware' }] }, + price: { type: 'currency', label: 'Price', currency: 'USD' }, + }, + highlightFields: ['product_code', 'family', 'price'], +}; + +/** Declares no `highlightFields`: nothing may be invented for it. */ +const NOTE = { + name: 'note', + label: 'Note', + nameField: 'name', + fields: { name: { type: 'text', label: 'Name' }, body: { type: 'textarea', label: 'Body' }, status: { type: 'text' } }, +}; + +const ACCOUNT = { name: 'account', label: 'Account', nameField: 'name', fields: { name: { type: 'text' } } }; + +const DEFS: Record = { lead: LEAD, contact: CONTACT, product: PRODUCT, note: NOTE, account: ACCOUNT }; + +function recordOf(objectName: string, id: string): Record | null { + if (objectName === 'lead') { + return { + id, + name: `Lead ${id}`, + company: 'Analytical Engines', + status: 'working', + rating: 'hot', + email: 'ada@engines.example', + annual_revenue: 1200000, + phone: '+1-555-0100', + lead_source: 'Web', + }; + } + if (objectName === 'contact') { + return { id, name: 'Grace Hopper', title: 'Rear Admiral', email: 'grace@navy.example', phone: '+1-555-0199', account: { id: 'acc-1', name: 'Navy' } }; + } + if (objectName === 'product') return { id, name: 'Widget', product_code: 'W-100', family: 'hardware', price: 1200 }; + if (objectName === 'note') return { id, name: 'Kickoff notes', body: 'Long body', status: 'draft' }; + return null; +} + +function makeSource(overrides: Partial> = {}) { + return { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async (objectName: string, id: string) => recordOf(objectName, id)), + getObjectSchema: vi.fn(async (objectName: string) => DEFS[objectName]), + ...overrides, + }; +} + +function makeHost() { + return { + resolve: () => ({}), + recordHref: (objectName: string, recordId: string | number) => `/apps/crm/${objectName}/record/${recordId}`, + openRecord: vi.fn(), + } as any; +} + +function renderRefs( + refs: Array<{ objectName: string; id: string; name: string }>, + source = makeSource(), + host = makeHost(), + onRowClick = vi.fn(), +) { + const utils = render( + + +
+ {refs.map((ref) => ( +
+ +
+ ))} +
+
+
, + ); + return { ...utils, source, host, onRowClick }; +} + +const linkOf = (id: string) => document.querySelector(`[data-row="${id}"] a`) as HTMLAnchorElement; +const cards = () => Array.from(document.querySelectorAll('[data-record-hover-card]')); +const card = () => document.querySelector('[data-record-hover-card]') as HTMLElement | null; +const shownFields = () => + Array.from(document.querySelectorAll('[data-record-hover-card-field]')).map((el) => el.getAttribute('data-record-hover-card-field')); + +function hover(el: Element) { + fireEvent.pointerOver(el, { pointerType: 'mouse' }); +} +function unhover(el: Element) { + fireEvent.pointerOut(el, { pointerType: 'mouse' }); +} + +/** + * Let the cells' definition reads settle before the hover. The definition + * cache is module-wide, so a later test may find its object already read. + */ +async function settled() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function openCardOver(id: string) { + hover(linkOf(id)); + await waitFor(() => expect(card()).not.toBeNull(), { timeout: 2000 }); + await waitFor(() => expect(card()).not.toHaveAttribute('aria-busy')); +} + +describe('RecordHoverCard over a lookup reference (objectstack#27604)', () => { + it('a lead: the declared highlights minus the name, six at most, in the target\'s own renderers', async () => { + renderRefs([{ objectName: 'lead', id: 'lead-a', name: 'Lead lead-a' }]); + await settled(); + await openCardOver('lead-a'); + + expect(shownFields()).toEqual(['company', 'status', 'rating', 'email', 'annual_revenue', 'phone']); + const c = card()!; + expect(c.querySelector('[data-record-hover-card-title]')).toHaveTextContent('Lead lead-a'); + expect(c.querySelector('[data-record-hover-card-object-label]')).toHaveTextContent('Lead'); + // The select draws its option label, the currency its formatted amount. + expect(c.querySelector('[data-record-hover-card-field="status"]')).toHaveTextContent('Working'); + expect(c.querySelector('[data-record-hover-card-field="annual_revenue"]')?.textContent).toMatch(/1,200,000/); + expect(c).toHaveTextContent('Annual revenue'); + }); + + it('a contact (no status field): exactly the declared four, nothing invented, one read with $expand', async () => { + const { source } = renderRefs([{ objectName: 'contact', id: 'contact-a', name: 'Grace Hopper' }]); + await settled(); + await openCardOver('contact-a'); + + expect(shownFields()).toEqual(['account', 'title', 'email', 'phone']); + expect(card()).not.toHaveTextContent('Status'); + expect(source.findOne).toHaveBeenCalledTimes(1); + expect(source.findOne).toHaveBeenCalledWith('contact', 'contact-a', { $expand: ['account'] }); + // A reference inside the card is a plain link: it has no card of its own. + const inner = card()!.querySelector('[data-record-hover-card-field="account"] a') as HTMLAnchorElement; + expect(inner).toHaveTextContent('Navy'); + hover(inner); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 600)); + }); + expect(cards()).toHaveLength(1); + }); + + it('a product: its declared highlights in order', async () => { + renderRefs([{ objectName: 'product', id: 'product-a', name: 'Widget' }]); + await settled(); + await openCardOver('product-a'); + + expect(shownFields()).toEqual(['product_code', 'family', 'price']); + expect(card()!.querySelector('[data-record-hover-card-field="family"]')).toHaveTextContent('Hardware'); + expect(card()!.querySelector('[data-record-hover-card-field="price"]')?.textContent).toMatch(/1,200/); + }); + + it('an object that declares no highlightFields: the name and the object label only', async () => { + renderRefs([{ objectName: 'note', id: 'note-a', name: 'Kickoff notes' }]); + await settled(); + await openCardOver('note-a'); + + expect(card()!.querySelector('[data-record-hover-card-title]')).toHaveTextContent('Kickoff notes'); + expect(card()!.querySelector('[data-record-hover-card-object-label]')).toHaveTextContent('Note'); + expect(shownFields()).toEqual([]); + expect(card()!.querySelector('dl')).toBeNull(); + }); + + it('an unreadable record: the reference\'s name and the object label, nothing else', async () => { + const source = makeSource({ findOne: vi.fn(async () => Promise.reject(new Error('404'))) }); + renderRefs([{ objectName: 'lead', id: 'lead-gone', name: 'Old lead' }], source); + await settled(); + await openCardOver('lead-gone'); + + expect(card()!.querySelector('[data-record-hover-card-title]')).toHaveTextContent('Old lead'); + expect(card()!.querySelector('[data-record-hover-card-object-label]')).toHaveTextContent('Lead'); + expect(shownFields()).toEqual([]); + }); + + it('one request per shown card, none on row render, cached per record', async () => { + const refs = Array.from({ length: 200 }, (_, i) => ({ objectName: 'lead', id: `lead-r${i}`, name: `Lead ${i}` })); + const { source } = renderRefs(refs); + await settled(); + // 200 rows drawn: not one record read. + expect(source.findOne).not.toHaveBeenCalled(); + + await openCardOver('lead-r5'); + expect(source.findOne).toHaveBeenCalledTimes(1); + + unhover(linkOf('lead-r5')); + await waitFor(() => expect(card()).toBeNull()); + await openCardOver('lead-r5'); + // The same record again: answered from the cache. + expect(source.findOne).toHaveBeenCalledTimes(1); + + unhover(linkOf('lead-r5')); + await waitFor(() => expect(card()).toBeNull()); + await openCardOver('lead-r6'); + expect(source.findOne).toHaveBeenCalledTimes(2); + }); + + it('shows after the hover delay, not at once', async () => { + renderRefs([{ objectName: 'lead', id: 'lead-delay', name: 'Lead delay' }]); + await settled(); + hover(linkOf('lead-delay')); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + expect(card()).toBeNull(); + await waitFor(() => expect(card()).not.toBeNull(), { timeout: 2000 }); + }); + + it('keyboard: focus alone shows nothing, Space shows the card, Esc hides it', async () => { + renderRefs([{ objectName: 'lead', id: 'lead-kb', name: 'Lead kb' }]); + await settled(); + const link = linkOf('lead-kb'); + act(() => link.focus()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 600)); + }); + expect(card()).toBeNull(); + + const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + act(() => { + link.dispatchEvent(space); + }); + // Space shows the card instead of scrolling the page. + expect(space.defaultPrevented).toBe(true); + await waitFor(() => expect(card()).not.toBeNull()); + + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); + await waitFor(() => expect(card()).toBeNull()); + }); + + it('one card at a time', async () => { + renderRefs([ + { objectName: 'lead', id: 'lead-one', name: 'Lead one' }, + { objectName: 'product', id: 'product-two', name: 'Widget two' }, + ]); + await settled(); + fireEvent.keyDown(linkOf('lead-one'), { key: ' ' }); + await waitFor(() => expect(cards()).toHaveLength(1)); + fireEvent.keyDown(linkOf('product-two'), { key: ' ' }); + await waitFor(() => expect(card()).toHaveAttribute('data-record-hover-card-object', 'product')); + expect(cards()).toHaveLength(1); + }); + + it('the card never changes the host\'s layout: it floats outside the host\'s DOM', async () => { + renderRefs([{ objectName: 'lead', id: 'lead-layout', name: 'Lead layout' }]); + await settled(); + const hostEl = document.querySelector('[data-testid="host"]') as HTMLElement; + // Radix marks the trigger with attributes while the card is open; the + // host's elements, classes and text stay as they were. + const markup = () => hostEl.innerHTML.replace(/ data-(state|radix-[a-z-]+)="[^"]*"/g, ''); + const before = markup(); + + await openCardOver('lead-layout'); + + expect(hostEl.contains(card())).toBe(false); + expect(document.body.contains(card())).toBe(true); + expect(markup()).toBe(before); + }); + + it('the title opens the record the way the reference does, and nothing under the card hears the click', async () => { + const { host, onRowClick } = renderRefs([{ objectName: 'lead', id: 'lead-open', name: 'Lead open' }]); + await settled(); + await openCardOver('lead-open'); + + const title = card()!.querySelector('[data-record-hover-card-title]') as HTMLAnchorElement; + expect(title).toHaveAttribute('href', '/apps/crm/lead/record/lead-open'); + fireEvent.click(title); + + expect(host.openRecord).toHaveBeenCalledWith('lead', 'lead-open'); + expect(onRowClick).not.toHaveBeenCalled(); + await waitFor(() => expect(card()).toBeNull()); + }); +}); diff --git a/ui/packages/fields/src/__tests__/RecordHoverCard.fieldAccess-27604.test.tsx b/ui/packages/fields/src/__tests__/RecordHoverCard.fieldAccess-27604.test.tsx new file mode 100644 index 00000000000..821dec91ed7 --- /dev/null +++ b/ui/packages/fields/src/__tests__/RecordHoverCard.fieldAccess-27604.test.tsx @@ -0,0 +1,70 @@ +/** + * 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#27604 — the hover card asks the viewer's field policy the record + * page asks: a declared highlight the viewer may not read is not drawn, does + * not take one of the six slots, and is not named in the read's `$expand`. + */ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + const policy = { + isLoaded: true, + checkField: (objectName: string, field: string) => !(objectName === 'contact' && field === 'account'), + }; + return { ...actual, usePermissions: () => policy }; +}); + +import { LookupCellRenderer } from '../index'; +import { RelatedRecordActionsProvider, SchemaRendererProvider } from '@object-ui/react'; + +const CONTACT = { + name: 'contact', + label: 'Contact', + nameField: 'name', + fields: { + name: { type: 'text', label: 'Name' }, + title: { type: 'text', label: 'Title' }, + account: { type: 'lookup', label: 'Account', reference: 'account' }, + }, + highlightFields: ['account', 'title'], +}; + +describe('RecordHoverCard and the viewer\'s field policy (objectstack#27604)', () => { + it('leaves a field the viewer may not read out of the card and out of $expand', async () => { + const source = { + find: vi.fn(), + findOne: vi.fn(async (_o: string, id: string) => ({ id, name: 'Grace Hopper', title: 'Admiral', account: 'acc-1' })), + getObjectSchema: vi.fn(async () => CONTACT), + }; + const host = { + resolve: () => ({}), + recordHref: (o: string, id: string | number) => `/r/${o}/${id}`, + openRecord: vi.fn(), + } as any; + render( + + + + + , + ); + await waitFor(() => expect(source.getObjectSchema).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + fireEvent.pointerOver(document.querySelector('a')!, { pointerType: 'mouse' }); + await waitFor(() => expect(document.querySelector('[data-record-hover-card-field="title"]')).not.toBeNull(), { timeout: 2000 }); + + expect(document.querySelector('[data-record-hover-card-field="account"]')).toBeNull(); + expect(source.findOne).toHaveBeenCalledWith('contact', 'contact-x'); + }); +}); diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 1d683cfc478..64339673a51 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -32,6 +32,7 @@ import { formatAddress, type AddressValue } from './widgets/address-format.js'; import { formatNumberFieldValue, formatCurrency, formatPercentPoints, percentCellScale } from './widgets/number-format.js'; import { useFieldTranslation } from './widgets/useFieldTranslation.js'; import { useBooleanValueLabel } from './widgets/booleanValueLabel.js'; +import { RecordHoverCard, type RecordHoverCardObjectDef } from './RecordHoverCard.js'; // Module-level cache so multiple renderers fetching the same lookup ID // only trigger one network call. Keyed by `${objectName}:${id}`. It holds the @@ -2538,11 +2539,17 @@ function referencedRecordId(item: unknown): string | number | undefined { function ReferencedRecordLink({ objectName, recordId, + objectSchema, + label, className, children, }: { objectName?: string; recordId?: string | number; + /** The referenced object's definition, for the link's hover card. */ + objectSchema?: RecordHoverCardObjectDef | null; + /** The name the link shows, which its card shows until the record is read. */ + label?: string; className?: string; children: React.ReactNode; }): React.ReactElement { @@ -2555,26 +2562,30 @@ function ReferencedRecordLink({ if (!href) return <>{children}; + // Hovering the link (or Space on it) shows the referenced record's card + // (objectstack#27604): the same reference, a glance instead of a visit. return ( - { - // The rows this cell sits in carry their own click handlers — the - // detail row copies the field value, a related-list row opens ITS own - // record. Following the reference must not also fire those. - e.stopPropagation(); - // Modifier and non-primary clicks belong to the browser (new tab, new - // window) — that is the point of rendering a real href. - if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; - // No SPA handler: let the anchor navigate on its own. - if (!host?.openRecord) return; - e.preventDefault(); - host.openRecord(objectName as string, recordId as string | number); - }} - > - {children} - + + { + // The rows this cell sits in carry their own click handlers — the + // detail row copies the field value, a related-list row opens ITS own + // record. Following the reference must not also fire those. + e.stopPropagation(); + // Modifier and non-primary clicks belong to the browser (new tab, new + // window) — that is the point of rendering a real href. + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; + // No SPA handler: let the anchor navigate on its own. + if (!host?.openRecord) return; + e.preventDefault(); + host.openRecord(objectName as string, recordId as string | number); + }} + > + {children} + + ); } @@ -2692,7 +2703,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R } catch { /* not JSON — fall through to normal resolution */ } if (parsedDisplay) { return ( - + ); @@ -2708,7 +2719,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R resolveLookupRecordName(obj, refSchema, displayField, perms, referenceTo) || String(obj.id || obj._id || ''); if (display) { return ( - + ); @@ -2806,6 +2817,8 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R key={idx} objectName={referenceTo} recordId={referencedRecordId(item)} + objectSchema={refSchema} + label={unresolved ? undefined : label} className="text-inherit" > , refSchema, displayField, perms, referenceTo) || String((value as any).id || (value as any)._id || '[Object]'); return ( - + ); @@ -2860,7 +2873,12 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R // (objectui#13335): the label, no id, and no link to a page that would 404. if (restricted) return ; return ( - + {unresolved ? ( ) : ( @@ -4767,6 +4785,10 @@ export type { * this package's own `resolveActionParam`. See the module. */ export { fieldHelpText } from './fieldHelpText.js'; +// The hover card every record reference shows (objectstack#27604): lookup +// links here, and the hosts that draw a reference of their own (the record +// page's compact related rows). +export { RecordHoverCard, type RecordHoverCardProps, type RecordHoverCardObjectDef } from './RecordHoverCard.js'; /** Cache so each widget type creates one lazy component (and one chunk request). */ const lazyFieldWidgets = new Map>(); diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 6f2f777146a..fe4d8188749 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -47,7 +47,7 @@ import { import type { LucideIcon } from 'lucide-react'; import type { DataSource, FieldMetadata } from '@object-ui/types'; import type { ViewFilterRule } from '@objectstack/spec/ui'; -import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer } from '@object-ui/fields'; +import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer, RecordHoverCard } from '@object-ui/fields'; import { buildExpandFields, collectPredicateFieldRefs, @@ -2909,17 +2909,29 @@ export const RelatedList: React.FC = ({ className="flex h-8 items-center justify-between gap-2 border-t border-border px-3 text-[12.5px]" data-related-card-row="" > - {handleRowClick ? ( - - ) : ( - {name} - )} + {/* objectstack#27604 — hovering the name (or Space on it) + shows the record's card; its title opens the record the + way this name does. */} + handleRowClick(row, e) : undefined} + > + {handleRowClick ? ( + + ) : ( + {name} + )} + {chipOf(row)} ); diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.compactHoverCard-27604.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.compactHoverCard-27604.test.tsx new file mode 100644 index 00000000000..e143eed22c7 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.compactHoverCard-27604.test.tsx @@ -0,0 +1,126 @@ +/** + * 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#27604 — the name in a compact related row (the record page's + * related column) shows the row record's hover card: hover or Space, its + * declared highlights, one read when shown and none when the rows draw, and + * its title opens the row exactly as a click on the name does. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { RelatedList } from '../RelatedList'; +import { RelatedListCompactContext } from '../relatedListCompact'; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +const TASK = { + name: 'task', + label: 'Task', + icon: 'check-square', + nameField: 'subject', + fields: { + subject: { type: 'text', label: 'Subject' }, + status: { + type: 'select', + label: 'Status', + options: [ + { value: 'open', label: 'Open' }, + { value: 'done', label: 'Done' }, + ], + }, + priority: { type: 'select', label: 'Priority', options: [{ value: 'high', label: 'High' }] }, + notes: { type: 'textarea', label: 'Notes' }, + }, + highlightFields: ['subject', 'status', 'priority'], +}; +const rows = [ + { id: 'task-1', subject: 'Call Ada', status: 'open' }, + { id: 'task-2', subject: 'Send deck', status: 'done' }, +]; + +function renderCompact() { + const source = { + find: vi.fn(async () => rows), + findOne: vi.fn(async (_o: string, id: string) => ({ ...rows.find((r) => r.id === id), priority: 'high', notes: 'Bring the deck' })), + getObjectSchema: vi.fn(async () => TASK), + }; + const onRowClick = vi.fn(); + render( + + + , + ); + return { source, onRowClick }; +} + +const nameOf = (text: string) => + Array.from(document.querySelectorAll('[data-related-card-row] button')).find((b) => b.textContent === text) as HTMLButtonElement; +const card = () => document.querySelector('[data-record-hover-card]') as HTMLElement | null; +const shownFields = () => + Array.from(document.querySelectorAll('[data-record-hover-card-field]')).map((el) => el.getAttribute('data-record-hover-card-field')); + +describe('compact related row — the record hover card (objectstack#27604)', () => { + it('hovering the name shows the row record\'s declared highlights; drawing the rows read none', async () => { + const { source } = renderCompact(); + await waitFor(() => expect(nameOf('Call Ada')).toBeTruthy()); + const row = nameOf('Call Ada').closest('[data-related-card-row]')!; + const before = row.innerHTML.replace(/ data-(state|radix-[a-z-]+)="[^"]*"/g, ''); + expect(source.findOne).not.toHaveBeenCalled(); + + fireEvent.pointerOver(nameOf('Call Ada'), { pointerType: 'mouse' }); + await waitFor(() => expect(card()).not.toBeNull(), { timeout: 2000 }); + await waitFor(() => expect(shownFields()).toEqual(['status', 'priority'])); + + expect(card()!.querySelector('[data-record-hover-card-title]')).toHaveTextContent('Call Ada'); + expect(card()!.querySelector('[data-record-hover-card-object-label]')).toHaveTextContent('Task'); + expect(source.findOne).toHaveBeenCalledTimes(1); + // The row keeps its layout: the card floats outside it. + expect(row.contains(card())).toBe(false); + expect(row.innerHTML.replace(/ data-(state|radix-[a-z-]+)="[^"]*"/g, '')).toBe(before); + }); + + it('Space on the focused name shows the card without opening the row; Esc hides it', async () => { + const { onRowClick } = renderCompact(); + await waitFor(() => expect(nameOf('Send deck')).toBeTruthy()); + const name = nameOf('Send deck'); + act(() => name.focus()); + fireEvent.keyDown(name, { key: ' ' }); + fireEvent.keyUp(name, { key: ' ' }); + await waitFor(() => expect(card()).not.toBeNull()); + expect(onRowClick).not.toHaveBeenCalled(); + + fireEvent.keyDown(name, { key: 'Escape' }); + await waitFor(() => expect(card()).toBeNull()); + }); + + it('the card title opens the row the way a click on the name does', async () => { + const { onRowClick } = renderCompact(); + await waitFor(() => expect(nameOf('Call Ada')).toBeTruthy()); + fireEvent.keyDown(nameOf('Call Ada'), { key: ' ' }); + await waitFor(() => expect(card()?.querySelector('[data-record-hover-card-title]')).toBeTruthy()); + + fireEvent.click(card()!.querySelector('[data-record-hover-card-title]')!); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick.mock.calls[0][0]).toMatchObject({ id: 'task-1' }); + await waitFor(() => expect(card()).toBeNull()); + }); +});