diff --git a/.changeset/lookup-cell-chip-cap.md b/.changeset/lookup-cell-chip-cap.md new file mode 100644 index 0000000000..4f1c77677a --- /dev/null +++ b/.changeset/lookup-cell-chip-cap.md @@ -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. diff --git a/packages/fields/src/__tests__/lookupCellOverflow.test.tsx b/packages/fields/src/__tests__/lookupCellOverflow.test.tsx new file mode 100644 index 0000000000..a3716a1f47 --- /dev/null +++ b/packages/fields/src/__tests__/lookupCellOverflow.test.tsx @@ -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(); + + 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(); + + 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(); + + 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( + 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'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 102c727433..57a82bd65f 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -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. * @@ -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, 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 (
- {value.map((item, idx) => { - let label: string; - let muted = false; - if (item != null && typeof item === 'object') { - label = resolveLookupRecordName(item as Record, 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 ( @@ -2067,6 +2089,14 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R ); })} + {overflow.length > 0 && ( + itemDisplay(item).label).join(', ')} + > + +{overflow.length} + + )}
); }