Skip to content
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
21 changes: 21 additions & 0 deletions .changeset/lookup-cell-chip-cap.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
---
'@object-ui/fields': patch
---

A multi-value lookup cell no longer grows its row without bound: `LookupCellRenderer`
now shows at most 3 chips and collapses the rest into one muted `+N` chip, the same
cap `UserCellRenderer` has always applied to its avatar stack in the very same file.

Previously the array branch rendered EVERY referenced record as its own chip inside a
`flex-wrap` container. In a grid column that wraps to one chip per line, so a cell
referencing a large set — a production 排班计划 row referencing 60+ work objects — grew
a single row to several screens of height and blew the page layout apart. The same
uncapped rendering reached every surface that resolves through `getCellRenderer('lookup')`:
grid, related lists, gallery, kanban, report and dashboard tables, and the record detail
sections.

The collapsed names stay reachable: the `+N` chip's `title` lists the display names of
the hidden references (resolved through the same option/label/record-name path as the
visible chips), and the record's own detail view remains the place to see the full set.
The first 3 chips keep their per-record links (#4336) and their resolution order —
nothing changes for cells with 3 or fewer references.
76 changes: 76 additions & 0 deletions packages/fields/src/__tests__/lookupCellOverflow.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
/**
* 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.
*/

/**
* A multi-value lookup cell used to render EVERY referenced record as its own
* chip. With a large reference set (the reported production cell held 60+
* work objects) the chips wrapped line after line and a single grid row grew
* to several screens of height, blowing the page layout apart.
*
* The cell now caps the chips at MAX_LOOKUP_CELL_CHIPS (3) and collapses the
* rest into one "+N" chip — the same pattern UserCellRenderer has always used
* for its avatar stack. The hidden display names stay reachable through the
* overflow chip's `title`.
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { LookupCellRenderer } from '../index';

const FIELD = { type: 'lookup', reference_to: 'mtc_work_object' } as any;

const manyRecords = (n: number) =>
Array.from({ length: n }, (_, i) => ({ id: `obj-${i + 1}`, name: `ZTLW-A.${i + 1}` }));

describe('LookupCellRenderer — multi-value overflow cap', () => {
it('renders at most 3 chips plus a "+N" overflow chip', () => {
render(<LookupCellRenderer value={manyRecords(60)} field={FIELD} />);

expect(screen.getByText('ZTLW-A.1')).toBeInTheDocument();
expect(screen.getByText('ZTLW-A.2')).toBeInTheDocument();
expect(screen.getByText('ZTLW-A.3')).toBeInTheDocument();
// The 4th and later references are collapsed, not rendered as chips.
expect(screen.queryByText('ZTLW-A.4')).toBeNull();
expect(screen.getByText('+57')).toBeInTheDocument();
});

it('keeps the hidden display names reachable on the overflow chip title', () => {
render(<LookupCellRenderer value={manyRecords(5)} field={FIELD} />);

const overflow = screen.getByText('+2');
expect(overflow).toHaveAttribute('title', 'ZTLW-A.4, ZTLW-A.5');
});

it('renders no overflow chip when the set fits the cap', () => {
render(<LookupCellRenderer value={manyRecords(3)} field={FIELD} />);

expect(screen.getByText('ZTLW-A.3')).toBeInTheDocument();
expect(screen.queryByText(/^\+\d+$/)).toBeNull();
});

it('caps primitive-id arrays the same way', () => {
const options = Array.from({ length: 10 }, (_, i) => ({
value: `id-${i + 1}`,
label: `Label ${i + 1}`,
}));
render(
<LookupCellRenderer
value={options.map((o) => o.value)}
field={{ ...FIELD, options }}
/>,
);

expect(screen.getByText('Label 3')).toBeInTheDocument();
expect(screen.queryByText('Label 4')).toBeNull();
const overflow = screen.getByText('+7');
expect(overflow.getAttribute('title')).toContain('Label 4');
expect(overflow.getAttribute('title')).toContain('Label 10');
});
});
50 changes: 40 additions & 10 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1903,6 +1903,15 @@ function ReferencedRecordLink({
);
}

/**
* How many chips a multi-value lookup cell shows before collapsing the rest
* into a single "+N" chip. Mirrors UserCellRenderer's avatar cap (3): enough
* to identify the cell's content, few enough that the cell cannot grow its
* row unboundedly (objectui — a 60-reference cell blew a grid row up to
* several screens of height).
*/
const MAX_LOOKUP_CELL_CHIPS = 3;

/**
* Lookup/Master-Detail field cell renderer.
*
Expand DownExpand Up@@ -2033,18 +2042,31 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
};

if (Array.isArray(value)) {
const itemDisplay = (item: unknown): { label: string; muted: boolean } => {
if (item != null && typeof item === 'object') {
return {
label:
resolveLookupRecordName(item as Record<string, unknown>, refSchema, displayField) ||
String((item as any).id || (item as any)._id || '[Object]'),
muted: false,
};
}
const r = resolveLabel(item);
return { label: r.text, muted: r.muted };
};

// Cap the chips the same way UserCellRenderer caps its avatars: a
// multi-value lookup can reference dozens of records (a 60-reference cell
// has been seen in the wild), and one chip per reference lets a single
// cell stretch its row to several screens. The hidden names stay
// reachable — the overflow chip's `title` lists them, and the record
// itself shows the full set.
const visible = value.slice(0, MAX_LOOKUP_CELL_CHIPS);
const overflow = value.slice(MAX_LOOKUP_CELL_CHIPS);
return (
<div className="flex flex-wrap gap-1">
{value.map((item, idx) => {
let label: string;
let muted = false;
if (item != null && typeof item === 'object') {
label = resolveLookupRecordName(item as Record<string, unknown>, refSchema, displayField) || String((item as any).id || (item as any)._id || '[Object]');
} else {
const r = resolveLabel(item);
label = r.text;
muted = r.muted;
}
{visible.map((item, idx) => {
const { label, muted } = itemDisplay(item);
// Each chip is one referenced record, so each links on its own —
// there is no single destination a multi-value cell could point at.
return (
Expand All@@ -2067,6 +2089,14 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
</ReferencedRecordLink>
);
})}
{overflow.length > 0 && (
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted/40 text-muted-foreground"
title={overflow.map((item) => itemDisplay(item).label).join(', ')}
>
+{overflow.length}
</span>
)}
</div>
);
}
Expand Down
Loading