Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/13377-lookup-cell-tooltip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/types': minor
'@object-ui/components': patch
'@object-ui/plugin-grid': patch
---

Grids: hovering a `lookup` or `master_detail` cell no longer shows the referenced record's raw id (objectui#13377).

The table's cell wrapper set its `title` tooltip to the cell's raw value, and for a reference column that value is the related record's id. So hovering beside the drawn name, or beside "Restricted record" for a record the viewer cannot read, still showed the id.

- **`@object-ui/types`:** `TableColumn` gains an optional `foreignKey` boolean: the column's raw value is the id of a referenced record. The zod mirror (`TableColumnSchema`) keeps it, and the static `table` column refuses it by name, like `masked` and `chips`.
- **`@object-ui/components`:** a `data-table` column with `foreignKey: true` draws no `title` tooltip. Nothing else changes for it: copy, CSV export, inline edit, search and sort behave as before.
- **`@object-ui/plugin-grid`:** `ObjectGrid` sets `foreignKey` on every `lookup`, `master_detail`, `tree` and `user` column, read from the column's own type or the object's field type. Every other column keeps its tooltip.
1 change: 1 addition & 0 deletions content/docs/components/complex/data-table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface TableColumn {
sortable?: boolean; // Enable sorting for this column
masked?: boolean; // Withhold the raw value (see Masked columns)
chips?: boolean; // Cells draw chips: never narrower than the widest one (see Chip columns)
foreignKey?: boolean; // Raw value is a referenced record's id: the cell draws no title tooltip
}

interface DataTableSchema {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* 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.
*/

/**
* `TableColumn.foreignKey` — the cell wrapper draws no `title` tooltip on a
* column whose raw value is a referenced record's id (objectui#13377).
*
* The producer (`ObjectGrid`) draws the record's name through `cell` and sets
* the flag; the wrapper's tooltip was `String(cellValue)`, so hovering beside
* the drawn name showed the id. Pinned three ways in one file: the flagged
* column has no tooltip; an ordinary column in the same table keeps its own;
* and the SAME column with the flag absent (or `false`) carries the id, which
* proves the flag is what withholds it.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { TableColumnSchema } from '@object-ui/types/zod';
import '../data-table';

const RAW_ID = 'CASE-ID-13377';
const DRAWN = 'Printer jam';
const CONTROL = 'KA-0025';
const ROWS = [{ id: '1', title: CONTROL, source_case: RAW_ID }];

afterEach(() => {
vi.restoreAllMocks();
cleanup();
});

/** `foreignKey` undefined ⇒ the key is ABSENT from the column, not `false`. */
function renderTable(foreignKey: boolean | undefined) {
const DataTable = ComponentRegistry.get('data-table') as any;
if (!DataTable) throw new Error('data-table not registered');
return render(
<DataTable
schema={{
type: 'data-table',
data: ROWS,
columns: [
{ header: 'Title', accessorKey: 'title' },
// The producer's shape: it draws the record's name through `cell`.
{
header: 'Source Case',
accessorKey: 'source_case',
cell: () => DRAWN,
...(foreignKey === undefined ? {} : { foreignKey }),
},
],
pagination: false,
searchable: false,
}}
/>,
);
}

/** The body cell under the header `label`. */
function cellUnder(label: string): HTMLElement {
const headers = Array.from(document.querySelectorAll('thead th'));
const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label);
expect(index, `CONTROL: a "${label}" header rendered`).toBeGreaterThanOrEqual(0);
const cell = document.querySelector('tbody tr')?.children[index] as HTMLElement | undefined;
expect(cell?.tagName, `CONTROL: the "${label}" body cell exists`).toBe('TD');
return cell!;
}

describe('data-table — `foreignKey: true` draws no tooltip carrying the raw id (objectui#13377)', () => {
it('the foreign-key cell has no `title`; the ordinary cell keeps its value as its tooltip', () => {
renderTable(true);
expect(
cellUnder('Title').querySelector('[title]')?.getAttribute('title'),
'CONTROL: an ordinary cell keeps its tooltip',
).toBe(CONTROL);
const ref = cellUnder('Source Case');
expect(ref.textContent, 'CONTROL: the producer drew the name').toContain(DRAWN);
expect(ref.querySelector('[title]'), 'the foreign-key cell carries no tooltip').toBeNull();
expect(document.body.innerHTML, 'the raw id is nowhere in the DOM').not.toContain(RAW_ID);
});

it('withholds only the tooltip: Ctrl+C still copies the raw value (unlike `masked`)', () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
renderTable(true);
fireEvent.keyDown(cellUnder('Source Case'), { key: 'c', ctrlKey: true });
expect(writeText.mock.calls.map((call) => call[0])).toEqual([RAW_ID]);
});
});

describe('data-table — the flag ABSENT keeps the tooltip (objectui#13377 control)', () => {
it('absent: the cell carries the raw value as its tooltip, as before', () => {
renderTable(undefined);
expect(cellUnder('Source Case').querySelector('[title]')?.getAttribute('title')).toBe(RAW_ID);
});

it('`foreignKey: false` is the same as absent', () => {
renderTable(false);
expect(cellUnder('Source Case').querySelector('[title]')?.getAttribute('title')).toBe(RAW_ID);
});
});

describe('`foreignKey` survives the zod mirror (objectui#13377)', () => {
it('a parsed column keeps the flag instead of stripping it', () => {
const parsed = TableColumnSchema.parse({ header: 'Source Case', accessorKey: 'source_case', foreignKey: true });
expect(parsed.foreignKey).toBe(true);
expect(TableColumnSchema.safeParse({ header: 'A', accessorKey: 'a', foreignKey: 'yes' }).success).toBe(false);
});
});
7 changes: 5 additions & 2 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3920,8 +3920,11 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
}
// No tooltip on a MASKED column (objectui#10583):
// the title carried the raw value, so a hover
// showed what the cell's mask hides.
title={!isFit && !col.masked && cellValue != null && typeof cellValue !== 'object' ? String(cellValue) : undefined}
// showed what the cell's mask hides. None on a
// FOREIGN-KEY column either (objectui#13377): its
// raw value is the referenced record's id, which
// the drawn name (or "Restricted record") withholds.
title={!isFit && !col.masked && !col.foreignKey && cellValue != null && typeof cellValue !== 'object' ? String(cellValue) : undefined}
// The cell half of the drawn-size read the
// auto width is estimated from (objectui#11682).
data-auto-width-key={autoWidthKey(col)}
Expand Down
10 changes: 9 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5045,9 +5045,17 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
// column from a text one. It floors a chip column at its widest drawn
// chip. Written only when true, so every other column is unchanged.
const chips = !masked && isChipFieldType(producerType ?? objectFields?.[col.accessorKey]?.type);
// objectui#13377 — THE FOREIGN-KEY FLAG, for the same reason again: the
// fold drops `lookup` / `master_detail` / `tree` / `user`, so `data-table`
// could not tell that the raw value its cell `title` shows is the
// referenced record's id. Read from the same type as the chip flag (the
// authored one first), the one the cell is drawn by: a view that authors
// `type: 'text'` over a lookup draws the id as text, and keeps the
// tooltip. A masked column already carries none. Written only when true.
const foreignKey = !masked && isExpandableFieldType({ type: producerType ?? objectFields?.[col.accessorKey]?.type });
const rest = masked
? { ...draft, masked: true }
: chips ? { ...draft, chips: true } : draft;
: { ...draft, ...(chips ? { chips: true } : {}), ...(foreignKey ? { foreignKey: true } : {}) };
if (producerType == null) return rest;
const normalized = normalizeTableColumnType(producerType);
if (normalized === undefined) return rest;
Expand Down
146 changes: 146 additions & 0 deletions packages/plugin-grid/src/__tests__/lookupCellTooltip-13377.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#13377 — a reference cell's hover tooltip carried the raw id.
*
* `data-table`'s cell wrapper set `title` to `String(cellValue)`, and for a
* `lookup` / `master_detail` column that value is the referenced record's id.
* So hovering beside the drawn name, or beside "Restricted record"
* (objectui#13335), showed the id the cell's text withholds. `ObjectGrid` now
* stamps `foreignKey` on such a column and the table draws no tooltip for it.
*
* Driven through the real `ObjectGrid` with a readable and a restricted
* reference in both a lookup and a master_detail column. The control is the
* plain text column of the same grid, which keeps its value as its tooltip,
* and a view column that authors `type: 'text'` over the lookup field: it draws
* the id as its text, so the flag (read from the authored type first, like
* `chips`) is not set and its tooltip stays.
*/

import React from 'react';
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ObjectGrid } from '../ObjectGrid';
import { registerAllFields } from '@object-ui/fields';
import { ActionProvider, SchemaRendererProvider } from '@object-ui/react';

registerAllFields();

const OBJECT = 'kb_article_13377';
const REFERENCED = 'kb_case_13377';
const RESTRICTED_ID = 'CASE-RESTRICTED-13377';
const READABLE = { id: 'CASE-READABLE-13377', name: 'Printer jam' };
const ROWS = [
{ id: 'a1', title: 'KA-0025', source_case: RESTRICTED_ID, parent_case: READABLE.id },
{ id: 'a2', title: 'KA-0026', source_case: READABLE.id, parent_case: RESTRICTED_ID },
];

beforeAll(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn() as any;
}
});

/** The body cells under the header `label`, one per row. */
function cellsUnder(container: HTMLElement, label: string): HTMLElement[] {
const headers = Array.from(container.querySelectorAll('thead th'));
const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label);
expect(index, `CONTROL: a "${label}" header rendered`).toBeGreaterThanOrEqual(0);
const cells = Array.from(container.querySelectorAll('tbody tr')).map((tr) => tr.children[index] as HTMLElement);
expect(cells, `CONTROL: one "${label}" cell per row`).toHaveLength(ROWS.length);
return cells;
}

/** Every `title` attribute inside a cell. */
const titlesIn = (cell: HTMLElement) =>
Array.from(cell.querySelectorAll('[title]')).map((el) => el.getAttribute('title') ?? '');

async function renderGrid(extraColumns: Array<Record<string, unknown>> = []) {
const ds = {
find: vi.fn(async () => ({ data: ROWS, total: ROWS.length, hasMore: false, pageSize: 50 })),
findOne: vi.fn(async (objectName: string, id: string) =>
objectName === REFERENCED && id === READABLE.id ? READABLE : null,
),
getObjectSchema: async (name: string) =>
name === REFERENCED
? { name, fields: { id: { type: 'text' }, name: { type: 'text' } } }
: {
name,
fields: {
id: { type: 'text' },
title: { type: 'text', label: 'Title' },
source_case: { type: 'lookup', label: 'Source Case', reference: REFERENCED },
parent_case: { type: 'master_detail', label: 'Parent Case', reference: REFERENCED },
},
},
} as any;
const schema: any = {
type: 'object-grid',
objectName: OBJECT,
data: ROWS,
pagination: { pageSize: 50 },
columns: [
{ field: 'title', label: 'Title' },
{ field: 'source_case', label: 'Source Case', type: 'lookup' },
// No authored type: the flag comes from the object's declared type.
{ field: 'parent_case', label: 'Parent Case' },
...extraColumns,
],
};
const utils = render(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid schema={schema} dataSource={ds} />
</SchemaRendererProvider>
</ActionProvider>,
);
await waitFor(() => expect(screen.getAllByText('Printer jam')).toHaveLength(2), { timeout: 4000 });
await waitFor(() => expect(screen.getAllByText('Restricted record')).toHaveLength(2), { timeout: 4000 });
return utils;
}

describe('objectui#13377 — ObjectGrid: a reference cell draws no tooltip carrying the raw id', () => {
it('a lookup and a master_detail cell, readable and restricted, carry no `title` with the id', async () => {
const { container } = await renderGrid();
for (const label of ['Source Case', 'Parent Case']) {
for (const cell of cellsUnder(container, label)) {
const titles = titlesIn(cell);
expect(titles.some((t) => t.includes(RESTRICTED_ID)), `${label}: no tooltip shows the restricted id`).toBe(false);
expect(titles.some((t) => t.includes(READABLE.id)), `${label}: no tooltip shows the readable id`).toBe(false);
// The wrapper `data-table` owns, specifically: it carries no tooltip at all.
const wrapper = cell.querySelector('[data-auto-width-key]');
expect(wrapper, `CONTROL: ${label} has the table's cell wrapper`).not.toBeNull();
expect(wrapper!.hasAttribute('title'), `${label}: the wrapper has no title`).toBe(false);
}
}
// The ids are nowhere in the grid's DOM, text or attribute.
expect(container.innerHTML).not.toContain(RESTRICTED_ID);
expect(container.innerHTML).not.toContain(READABLE.id);
});

it('CONTROL — the plain text column of the same grid keeps its value as its tooltip', async () => {
const { container } = await renderGrid();
const [first, second] = cellsUnder(container, 'Title');
expect(first!.querySelector('[data-auto-width-key]')?.getAttribute('title')).toBe('KA-0025');
expect(second!.querySelector('[data-auto-width-key]')?.getAttribute('title')).toBe('KA-0026');
});

it('CONTROL — a view column authoring `type: \'text\'` over the lookup draws the id as text and keeps its tooltip', async () => {
const { container } = await renderGrid([{ field: 'source_case', label: 'As Text', type: 'text' }]);
const [first, second] = cellsUnder(container, 'As Text');
expect(first!.textContent, 'CONTROL: the authored type draws the raw id').toBe(RESTRICTED_ID);
expect(first!.querySelector('[data-auto-width-key]')?.getAttribute('title')).toBe(RESTRICTED_ID);
expect(second!.querySelector('[data-auto-width-key]')?.getAttribute('title')).toBe(READABLE.id);
// The lookup-typed column over the same field still carries none.
for (const cell of cellsUnder(container, 'Source Case')) {
expect(cell.querySelector('[data-auto-width-key]')!.hasAttribute('title')).toBe(false);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ describe('objectui#13335 — ObjectGrid: an unreadable lookup cell reads "Restri
const marks = container.querySelectorAll('[data-slot="restricted-reference"]');
expect(marks).toHaveLength(1);
// What the lookup renderer draws carries no id and no link. (The table's
// own cell wrapper still puts the raw value in its `title`, as it does for
// every primitive cell; that wrapper is `data-table`'s, not this renderer's.)
// own cell wrapper, `data-table`'s and not this renderer's, put the raw
// value in its `title` too, until objectui#13377: that half is pinned in
// `lookupCellTooltip-13377.test.tsx`.)
expect(marks[0]!.outerHTML).not.toContain(RESTRICTED_ID);
expect(marks[0]!.closest('td')!.querySelector('a')).toBeNull();
expect(container.querySelectorAll('[data-slot="unresolved-reference"]')).toHaveLength(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const RETIRED_COLUMN_KEYS: Record<string, unknown> = {
wrap: true,
masked: true,
chips: true,
foreignKey: true,
};

/** Every key the rich `TableColumn` interface declares. `satisfies` keeps the
Expand Down Expand Up @@ -152,6 +153,7 @@ const RICH_COLUMN_KEYS = [
'wrap',
'masked',
'chips',
'foreignKey',
] as const satisfies readonly (keyof TableColumn)[];
type _RichKeyListExhaustive = Expect<Equal<(typeof RICH_COLUMN_KEYS)[number], keyof TableColumn>>;

Expand Down Expand Up @@ -328,7 +330,7 @@ describe('the tombstone refusal reaches the author with its remediation text (ob
expect(StaticTableColumnSchema.safeParse(LIVE_COLUMN).success).toBe(true);
expect(TableZod.safeParse(STATIC_TABLE).success).toBe(true);

for (const key of ['headerIcon', 'fitContent', 'format', 'options', 'currency', 'wrap', 'masked', 'chips'] as const) {
for (const key of ['headerIcon', 'fitContent', 'format', 'options', 'currency', 'wrap', 'masked', 'chips', 'foreignKey'] as const) {
const result = StaticTableColumnSchema.safeParse({
header: 'Amount',
accessorKey: 'amount',
Expand Down
24 changes: 24 additions & 0 deletions packages/types/src/data-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,22 @@ export interface TableColumn {
* renderer is opaque to it (objectui#12708).
*/
chips?: boolean;
/**
* The column's raw value is a FOREIGN KEY: the id of a referenced record
* (a `lookup`, `master_detail`, `tree` or `user` field), while its `cell`
* renderer draws that record's name (for a lookup the viewer cannot read,
* "Restricted record"). The cell carries no `title` tooltip, which would show
* the raw id the drawn cell withholds. Absent or `false`, the tooltip is as
* before.
*
* Unlike {@link TableColumn.masked}, it withholds nothing else: copy, CSV
* export, inline edit, search and sort are unchanged.
*
* Written by `ObjectGrid` from the column's field type, because the table
* cannot tell a reference from text: `type` has no reference member
* (objectui#13377).
*/
foreignKey?: boolean;
}

/**
Expand Down Expand Up @@ -907,6 +923,14 @@ export interface StaticTableColumn {
* @deprecated Not part of the static `table` renderer's contract.
*/
chips?: never;
/**
* NOT on the static `table` surface (objectui#13377) — declared on the rich
* {@link TableColumn} only, where `data-table`'s cell tooltip reads it. The
* static renderer draws no `title` tooltip for it to withhold.
* Use `data-table` for the interactive set.
* @deprecated Not part of the static `table` renderer's contract.
*/
foreignKey?: never;
/**
* RETIRED from the static `table` surface (objectui#5474, ADR-0049) — the
* static renderer never read it; a right-aligned column authored here was
Expand Down
Loading
Loading