diff --git a/.changeset/26896-grouped-list-phone-cards.md b/.changeset/26896-grouped-list-phone-cards.md new file mode 100644 index 00000000000..e98411123f3 --- /dev/null +++ b/.changeset/26896-grouped-list-phone-cards.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-grid': patch +--- + +fix(plugin-grid): a grouped list on a phone draws its group headers and cards instead of the desktop table + +Clause-②: no +Metadata-protocol: none + +Below the 768px card breakpoint, `ObjectGrid` turned a plain list into one card per row but kept a grouped list (`grouping`) on its desktop table, so on a phone the titles were cut mid-word and every other column sat behind the pinned Actions column. A grouped list now takes the plain list's card layout: each group's header (its toggle, label and count), then that group's rows as the same cards, then, on a server-grouped list, the group's own pager. Collapsing a group hides its cards, and the grouped pager still pages whole groups. At a desktop width the grouped table is unchanged, and so is the plain list's card view, which now draws through the same card renderer. + +What to do: nothing. diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index a155b96b4ae..7f56125d527 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -3004,7 +3004,12 @@ export const ObjectGrid: React.FC = ({ // Mobile card-view: below the 768px app breakpoint (matches useIsMobile / // Tailwind md: / the responsive page+grid layout), render stacked cards // instead of a side-scrolling wide table. - if (useCardView && data.length > 0 && !isGrouped) { + // + // objectstack#26896 — one renderer draws a record's card for both card + // views: the plain list below, and a grouped list on a phone (the grouped + // `gridContent`), which draws each group's band followed by its rows as + // these same cards rather than keeping the desktop table. + const buildMobileCardRenderer = () => { const displayColumns = generateColumns().filter((c) => c.accessorKey !== '_actions'); // Build a lookup of column metadata for smart rendering @@ -3120,6 +3125,191 @@ export const ObjectGrid: React.FC = ({ return { label: /^[a-z0-9]+(_[a-z0-9]+)+$/.test(str) ? humanizeLabel(str) : str }; }; + return (row: any, idx: number) => { + // Collect secondary fields (skip the title column) + const secondaryCols = bodyColumns.slice(0, 4); + const amountCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'amount'); + const stageCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'stage'); + const dateCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'date'); + const percentCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'percent'); + const otherCols = secondaryCols.filter( + (c: any) => c !== amountCol && c !== stageCol && !dateCols.includes(c) && !percentCols.includes(c) + ); + + // Determine left border accent color from stage value + const stageValue = stageCol ? String(row[stageCol.accessorKey] ?? '') : ''; + const leftBorderClass = stageValue ? stageBorderLeft(stageValue) : ''; + const cardClassName = [ + 'border rounded-lg p-2.5 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation', + leftBorderClass ? `border-l-[3px] ${leftBorderClass}` : '', + ].filter(Boolean).join(' '); + + return ( +
navigation.handleClick(row)} + > + {/* Title row - Name as bold prominent title */} + {titleKey !== undefined && ( +
+ {isMaskedCardColumn(titleKey) + ? (titleCol + ? titleCol.cell?.(row[titleKey], row) + // A masked name field no column shows has no `cell` + // to draw its mask, so the mask is drawn here. + : ) + : (coerceToSafeValue(row[titleKey]) ?? '—')} + {/* objectui#13330 — the host's node beside the title. */} + {rowAdornment ? {rowAdornment(row)} : null} +
+ )} + + {/* Amount + Stage row - side by side for compact display */} + {(amountCol || stageCol) && ( +
+ {amountCol && ( + + {/* objectui#10423 — the currency resolves from the FIELD + def, as the desktop cell's does (objectui#10354); + `amountCol` is a column draft and carries neither + `currency` nor `currencyConfig`. */} + {typeof row[amountCol.accessorKey] === 'number' + ? formatCompactCurrency(row[amountCol.accessorKey], resolveFieldCurrency(objectSchema?.fields?.[amountCol.accessorKey], tenantCurrency), displayLocale) + : (coerceToSafeValue(row[amountCol.accessorKey]) ?? '—')} + + )} + {stageCol && row[stageCol.accessorKey] && (() => { + const rawValue = row[stageCol.accessorKey]; + const optMeta = resolveOptionMeta(stageCol.accessorKey, rawValue); + // Explicit option color wins, resolved EXACTLY as the + // desktop cell resolves it (`SelectCellRenderer` in + // `@object-ui/fields`): a declared hex renders as + // declared via `getBadgeHexAppearance` (objectui#5141, + // adopted here by objectui#5183), a family name keeps + // going through `getBadgeColorClasses`. The `style` is + // load-bearing — the hex ships as CSS custom properties + // that the returned className reads, so dropping it + // yields a badge referencing undefined variables. + // With no declared colour at all we keep the + // pipeline-stage heuristic keyed on the raw value, + // which stays stable across locales. + const hexBadge = getBadgeHexAppearance(optMeta.color); + const badgeClasses = hexBadge + ? hexBadge.className + : optMeta.color + ? getBadgeColorClasses(optMeta.color, rawValue) + : stageBadgeColor(String(rawValue)); + return ( + + {optMeta.label} + + ); + })()} +
+ )} + + {/* Date + Percent combined row for density */} + {(dateCols.length > 0 || percentCols.length > 0) && ( +
+ {dateCols[0] && ( + + {row[dateCols[0].accessorKey] + ? formatDate(row[dateCols[0].accessorKey], 'short', { locale: displayLocale }) + : '—'} + + )} + {percentCols[0] && row[percentCols[0].accessorKey] != null && (() => { + // objectui#11254 — the width is the FIELD's, read through + // `resolveFieldScale` (ruling A′ on + // objectstack-ai/objectstack#19628) off the field def the + // amount cell above reads its currency from, so a percent + // declaring `scale: 2` reads two decimals here as it does + // in the desktop cell. This cell handed `formatPercent` an + // `undefined` width, so the function's own default decided. + // + // Only a `percent` field's width is a percent width. The + // column reached this slot by its NAME (`classify`), and a + // `number` named like a rate counts its `scale` in the + // stored value's decimals, not in the percentage points + // printed here, so its scale is not read. + // + // objectui#11475 — nor is a non-percent field printed as a + // percent at all. A percent face reads the field's declared + // STORAGE (`percentCellScale`, the spec's `percentScaleOf`), + // and a field that is not a `percent` declares none: the + // spec's words are "a plain `number` carries no percent + // semantics". Choosing a percent face by the column's NAME + // was a consumer-side guess (`lead_score` and `tax_rate`, + // both `Field.number` in the showcase, printed with a + // percent sign), so + // such a field prints through its own cell, as the desktop + // row prints it, with no percent affix. + const percentDef = objectSchema?.fields?.[percentCols[0].accessorKey]; + if (percentDef?.type !== 'percent') { + const { Renderer: SlotRenderer } = resolveGridCellRendering(percentDef); + return ( + + + + ); + } + const width = resolveFieldScale({ type: percentDef.type, scale: percentDef.scale }); + return ( + + {/* objectui#4553: the mobile card's percent cell takes + the same `displayLocale` its date sibling above + already does (objectui#4272). */} + {formatPercent( + Number(row[percentCols[0].accessorKey]), + percentCellScale(percentDef), + width, + displayLocale, + )} + + ); + })()} +
+ )} + + {/* Additional date fields beyond the first */} + {dateCols.slice(1).map((col: any) => ( +
+ {col.header} + + {row[col.accessorKey] ? formatDate(row[col.accessorKey], 'short', { locale: displayLocale }) : '—'} + +
+ ))} + + {/* Other fields - hide empty values on mobile */} + {otherCols.map((col: any) => { + const val = row[col.accessorKey]; + if (val == null || val === '') return null; + return ( +
+ {col.header} + + {col.cell ? col.cell(val, row) : String(coerceToSafeValue(val) ?? '')} + +
+ ); + })} +
+ ); + }; + }; + + if (useCardView && data.length > 0 && !isGrouped) { + const renderMobileCard = buildMobileCardRenderer(); + // objectui#13134 — the card list scrolls in a box of its own, as the // table does in its own body. Its host bounds it (`ListView`'s view area // is `overflow-hidden`), so without this box a list taller than the @@ -3141,186 +3331,7 @@ export const ObjectGrid: React.FC = ({ > {gridDescription}
- {data.map((row, idx) => { - // Collect secondary fields (skip the title column) - const secondaryCols = bodyColumns.slice(0, 4); - const amountCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'amount'); - const stageCol = secondaryCols.find((c: any) => classify(c.accessorKey) === 'stage'); - const dateCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'date'); - const percentCols = secondaryCols.filter((c: any) => classify(c.accessorKey) === 'percent'); - const otherCols = secondaryCols.filter( - (c: any) => c !== amountCol && c !== stageCol && !dateCols.includes(c) && !percentCols.includes(c) - ); - - // Determine left border accent color from stage value - const stageValue = stageCol ? String(row[stageCol.accessorKey] ?? '') : ''; - const leftBorderClass = stageValue ? stageBorderLeft(stageValue) : ''; - const cardClassName = [ - 'border rounded-lg p-2.5 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation', - leftBorderClass ? `border-l-[3px] ${leftBorderClass}` : '', - ].filter(Boolean).join(' '); - - return ( -
navigation.handleClick(row)} - > - {/* Title row - Name as bold prominent title */} - {titleKey !== undefined && ( -
- {isMaskedCardColumn(titleKey) - ? (titleCol - ? titleCol.cell?.(row[titleKey], row) - // A masked name field no column shows has no `cell` - // to draw its mask, so the mask is drawn here. - : ) - : (coerceToSafeValue(row[titleKey]) ?? '—')} - {/* objectui#13330 — the host's node beside the title. */} - {rowAdornment ? {rowAdornment(row)} : null} -
- )} - - {/* Amount + Stage row - side by side for compact display */} - {(amountCol || stageCol) && ( -
- {amountCol && ( - - {/* objectui#10423 — the currency resolves from the FIELD - def, as the desktop cell's does (objectui#10354); - `amountCol` is a column draft and carries neither - `currency` nor `currencyConfig`. */} - {typeof row[amountCol.accessorKey] === 'number' - ? formatCompactCurrency(row[amountCol.accessorKey], resolveFieldCurrency(objectSchema?.fields?.[amountCol.accessorKey], tenantCurrency), displayLocale) - : (coerceToSafeValue(row[amountCol.accessorKey]) ?? '—')} - - )} - {stageCol && row[stageCol.accessorKey] && (() => { - const rawValue = row[stageCol.accessorKey]; - const optMeta = resolveOptionMeta(stageCol.accessorKey, rawValue); - // Explicit option color wins, resolved EXACTLY as the - // desktop cell resolves it (`SelectCellRenderer` in - // `@object-ui/fields`): a declared hex renders as - // declared via `getBadgeHexAppearance` (objectui#5141, - // adopted here by objectui#5183), a family name keeps - // going through `getBadgeColorClasses`. The `style` is - // load-bearing — the hex ships as CSS custom properties - // that the returned className reads, so dropping it - // yields a badge referencing undefined variables. - // With no declared colour at all we keep the - // pipeline-stage heuristic keyed on the raw value, - // which stays stable across locales. - const hexBadge = getBadgeHexAppearance(optMeta.color); - const badgeClasses = hexBadge - ? hexBadge.className - : optMeta.color - ? getBadgeColorClasses(optMeta.color, rawValue) - : stageBadgeColor(String(rawValue)); - return ( - - {optMeta.label} - - ); - })()} -
- )} - - {/* Date + Percent combined row for density */} - {(dateCols.length > 0 || percentCols.length > 0) && ( -
- {dateCols[0] && ( - - {row[dateCols[0].accessorKey] - ? formatDate(row[dateCols[0].accessorKey], 'short', { locale: displayLocale }) - : '—'} - - )} - {percentCols[0] && row[percentCols[0].accessorKey] != null && (() => { - // objectui#11254 — the width is the FIELD's, read through - // `resolveFieldScale` (ruling A′ on - // objectstack-ai/objectstack#19628) off the field def the - // amount cell above reads its currency from, so a percent - // declaring `scale: 2` reads two decimals here as it does - // in the desktop cell. This cell handed `formatPercent` an - // `undefined` width, so the function's own default decided. - // - // Only a `percent` field's width is a percent width. The - // column reached this slot by its NAME (`classify`), and a - // `number` named like a rate counts its `scale` in the - // stored value's decimals, not in the percentage points - // printed here, so its scale is not read. - // - // objectui#11475 — nor is a non-percent field printed as a - // percent at all. A percent face reads the field's declared - // STORAGE (`percentCellScale`, the spec's `percentScaleOf`), - // and a field that is not a `percent` declares none: the - // spec's words are "a plain `number` carries no percent - // semantics". Choosing a percent face by the column's NAME - // was a consumer-side guess (`lead_score` and `tax_rate`, - // both `Field.number` in the showcase, printed with a - // percent sign), so - // such a field prints through its own cell, as the desktop - // row prints it, with no percent affix. - const percentDef = objectSchema?.fields?.[percentCols[0].accessorKey]; - if (percentDef?.type !== 'percent') { - const { Renderer: SlotRenderer } = resolveGridCellRendering(percentDef); - return ( - - - - ); - } - const width = resolveFieldScale({ type: percentDef.type, scale: percentDef.scale }); - return ( - - {/* objectui#4553: the mobile card's percent cell takes - the same `displayLocale` its date sibling above - already does (objectui#4272). */} - {formatPercent( - Number(row[percentCols[0].accessorKey]), - percentCellScale(percentDef), - width, - displayLocale, - )} - - ); - })()} -
- )} - - {/* Additional date fields beyond the first */} - {dateCols.slice(1).map((col: any) => ( -
- {col.header} - - {row[col.accessorKey] ? formatDate(row[col.accessorKey], 'short', { locale: displayLocale }) : '—'} - -
- ))} - - {/* Other fields - hide empty values on mobile */} - {otherCols.map((col: any) => { - const val = row[col.accessorKey]; - if (val == null || val === '') return null; - return ( -
- {col.header} - - {col.cell ? col.cell(val, row) : String(coerceToSafeValue(val) ?? '')} - -
- ); - })} -
- ); - })} + {data.map(renderMobileCard)}
{navigation.isOverlay && ( @@ -3661,11 +3672,40 @@ export const ObjectGrid: React.FC = ({ ) : null; - // Both branches fill the remaining height (flex-1 + min-h-0) so the + // Every branch fills the remaining height (flex-1 + min-h-0) so the // BulkActionBar rendered *after* gridContent stays inside the flex column // and remains visible; otherwise an h-full table pushes the bar past the // bottom of an overflow-hidden ancestor and clips it. - const gridContent = isGrouped ? ( + // + // objectstack#26896 — on a phone (`useCardView`, the plain list's card + // breakpoint) a grouped list draws the same bands in the same order, each + // followed by its rows as the plain list's cards and by its footer (a + // server-grouped group's pager), in one vertical scroller like the plain + // card list's. A band keeps its toggle and count, `groupedPager` still + // pages whole groups, and the desktop table's columns never reach a phone. + const gridContent = isGrouped && useCardView ? ( +
+
+ {(() => { + const renderMobileCard = buildMobileCardRenderer(); + return groupBandsOf(pagedGroups).map((group) => ( +
+ {group.band} + {group.rows.length > 0 && ( +
{group.rows.map(renderMobileCard)}
+ )} + {group.footer} +
+ )); + })()} +
+ {groupedPager} +
+ ) : isGrouped ? (
{/* One scroller for the one grouped table (objectui#12620): it holds both scrollbars, and the table's sticky header sticks to its top diff --git a/ui/packages/plugin-grid/src/__tests__/groupedMobileCards-26896.test.tsx b/ui/packages/plugin-grid/src/__tests__/groupedMobileCards-26896.test.tsx new file mode 100644 index 00000000000..08f5fec68f8 --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/groupedMobileCards-26896.test.tsx @@ -0,0 +1,202 @@ +/** + * 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#26896 — below the 768px card breakpoint, a GROUPED list takes + * the plain list's card layout: each group's band, then that group's rows as + * cards, with no table. + * + * Measured at 390x844 (HotCRM Knowledge, grouped by category): the grouped + * grid kept its desktop table, so titles were cut mid-word and every other + * column sat behind the pinned Actions column, while the plain Cases list on + * the same console turned into cards. + * + * Pinned on the real ObjectGrid, on both grouped paths (client-grouped rows in + * memory and server-grouped pages, objectui#7189): + * - one band per group, each followed by one card per row of that group, + * and no `table` at all; + * - collapsing a band removes its cards and keeps its band and count. + * Control: the same grouped view at a desktop width still draws the one + * grouped table (objectui#12620) and no card. + */ +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { ActionProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; + +beforeAll(() => { + registerAllFields(); +}); + +const ORIGINAL_INNER_WIDTH = window.innerWidth; + +function setWidth(px: number) { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: px }); +} + +afterEach(() => { + cleanup(); + setWidth(ORIGINAL_INNER_WIDTH); +}); + +const OBJECT = 'crm_knowledge_article'; + +const ARTICLES = [ + { id: '1', title: 'Install the agent', category: 'Getting Started', status: 'published' }, + { id: '2', title: 'Rotate an API key', category: 'How-To', status: 'published' }, + { id: '3', title: 'Webhook retries', category: 'Troubleshooting', status: 'draft' }, + { id: '4', title: 'REST pagination', category: 'API & Integrations', status: 'published' }, + { id: '5', title: 'First login', category: 'Getting Started', status: 'draft' }, +]; + +const COLUMNS = [ + { field: 'title', label: 'Title' }, + { field: 'category', label: 'Category' }, + { field: 'status', label: 'Status' }, +]; + +const cardBands = () => [...document.querySelectorAll('[data-testid="grouped-grid-card-band"]')] as HTMLElement[]; +const bandOf = (label: string) => + cardBands().find((band) => band.querySelector('.group-label')?.textContent === label)!; +/** The titles of the cards drawn under one group's band, in order. */ +const cardTitlesIn = (label: string) => + [...bandOf(label).querySelectorAll('[data-testid="grid-card-title"]')].map((el) => el.textContent); + +/** The phone shape: N bands, each card under the band of its own group, no table. */ +function expectGroupedCards(groupCount: number) { + expect(document.querySelector('[data-testid="grouped-grid-card-scroller"]')).not.toBeNull(); + expect(cardBands()).toHaveLength(groupCount); + expect(document.querySelectorAll('table')).toHaveLength(0); + expect(document.querySelector('[data-testid="grouped-grid-scroller"]')).toBeNull(); +} + +const renderClientGrouped = () => + render( + + + , + ); + +describe('a client-grouped list on a phone draws bands and cards (objectstack#26896)', () => { + it('one band per group, the group\'s rows as cards under it, and no table', async () => { + setWidth(390); + renderClientGrouped(); + await waitFor(() => expect(cardBands()).toHaveLength(4)); + expectGroupedCards(4); + + expect(cardTitlesIn('Getting Started')).toEqual(['Install the agent', 'First login']); + expect(cardTitlesIn('How-To')).toEqual(['Rotate an API key']); + expect(cardTitlesIn('Troubleshooting')).toEqual(['Webhook retries']); + expect(cardTitlesIn('API & Integrations')).toEqual(['REST pagination']); + // One card per row in all, each under its own band. + expect(document.querySelectorAll('[data-testid="grid-card-title"]')).toHaveLength(ARTICLES.length); + // The band keeps its count. + expect(bandOf('Getting Started').querySelector('.group-count')?.textContent).toBe('2'); + }); + + it('collapsing a band removes its cards and keeps its band', async () => { + setWidth(390); + renderClientGrouped(); + await waitFor(() => expect(cardBands()).toHaveLength(4)); + + fireEvent.click(bandOf('Getting Started').querySelector('button')!); + + await waitFor(() => expect(cardTitlesIn('Getting Started')).toHaveLength(0)); + expect(bandOf('Getting Started').querySelector('button')).toHaveAttribute('aria-expanded', 'false'); + expectGroupedCards(4); + expect(cardTitlesIn('How-To')).toEqual(['Rotate an API key']); + expect(document.body.textContent).not.toContain('Install the agent'); + + fireEvent.click(bandOf('Getting Started').querySelector('button')!); + await waitFor(() => expect(cardTitlesIn('Getting Started')).toHaveLength(2)); + }); + + it('CONTROL: at a desktop width the same view still draws the one grouped table', async () => { + setWidth(1280); + renderClientGrouped(); + await waitFor(() => + expect(document.querySelectorAll('tr[data-testid="data-table-group-band"]')).toHaveLength(4), + ); + expect(document.querySelectorAll('table')).toHaveLength(1); + expect(document.querySelector('[data-testid="grouped-grid-scroller"]')).not.toBeNull(); + expect(document.querySelector('[data-testid="grouped-grid-card-scroller"]')).toBeNull(); + expect(document.querySelectorAll('[data-testid="grid-card-title"]')).toHaveLength(0); + }); +}); + +describe('a server-grouped list on a phone draws each group\'s page as cards (objectstack#26896, objectui#7189)', () => { + const makeServerDataSource = () => ({ + queryGroupHeaders: vi.fn(async () => { + const counts = new Map(); + for (const row of ARTICLES) counts.set(row.category, (counts.get(row.category) ?? 0) + 1); + return [...counts.entries()].map(([category, count]) => ({ category, count })); + }), + find: vi.fn(async (_object: string, params: Record) => { + const wanted = params.$filter?.$and?.[0]?.category?.$eq; + const rows = ARTICLES.filter((row) => wanted === undefined || row.category === wanted); + const skip = params.$skip ?? 0; + const top = params.$top ?? rows.length; + return { data: rows.slice(skip, skip + top), total: rows.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: OBJECT, + fields: { + id: { type: 'text', label: 'Id' }, + title: { type: 'text', label: 'Title' }, + category: { type: 'text', label: 'Category' }, + status: { type: 'text', label: 'Status' }, + }, + })), + }); + + it('bands from the header query, each group\'s server page as cards under its band', async () => { + setWidth(390); + const ds = makeServerDataSource(); + render( + + + , + ); + + await waitFor(() => expect(cardBands()).toHaveLength(4)); + // It really is the server path: the header query answered the groups. + expect(ds.queryGroupHeaders).toHaveBeenCalled(); + await waitFor(() => expect(cardTitlesIn('Getting Started')).toEqual(['Install the agent', 'First login'])); + expectGroupedCards(4); + expect(cardTitlesIn('How-To')).toEqual(['Rotate an API key']); + + fireEvent.click(bandOf('How-To').querySelector('button')!); + await waitFor(() => expect(cardTitlesIn('How-To')).toHaveLength(0)); + expectGroupedCards(4); + expect(cardTitlesIn('Getting Started')).toHaveLength(2); + }); +}); diff --git a/ui/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx b/ui/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx index cd9e740e095..24689e2085a 100644 --- a/ui/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx +++ b/ui/packages/plugin-grid/src/__tests__/groupingNeedsHeaderQuery-10881.test.tsx @@ -37,8 +37,10 @@ * `rowCount` at all, both group as whole rows. * * Also here, re-homed from the retired `groupedPartialDisclosure-7189` file: - * grouping never reaches the mobile card view (`useCardView` requires - * `!isGrouped`), with the ungrouped mobile render as its lit control. + * a grouped grid on a phone keeps its groups. Since objectstack#26896 it draws + * them as the mobile card view does (each band, then its rows as cards, no + * table) rather than as the desktop table, with the ungrouped mobile render as + * its lit control. */ import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; import { render, cleanup, screen, act } from '@testing-library/react'; @@ -267,18 +269,20 @@ describe('a masked-only grouping key shows loading, never an error it withdraws }); }); -describe('grouping never reaches the mobile card view (re-homed from groupedPartialDisclosure-7189)', () => { +describe('a grouped grid on a phone keeps its groups (re-homed from groupedPartialDisclosure-7189; objectstack#26896)', () => { const originalWidth = window.innerWidth; const setWidth = (value: number) => Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value }); afterEach(() => setWidth(originalWidth)); - it('a grouped grid under 768px draws its groups, not cards', async () => { + it('a grouped grid under 768px draws its groups, each followed by its rows as cards, with no table', async () => { setWidth(390); renderGrid(makeFindOnlyDataSource(), { grouping: GROUPING, data: { provider: 'value', items: ROWS } }); await vi.waitFor(() => expect(groupRows()).toHaveLength(5)); - expect(document.querySelector('table')).not.toBeNull(); + expect(document.querySelectorAll('[data-testid="grouped-grid-card-band"]')).toHaveLength(5); + expect(document.querySelectorAll('[data-testid="grid-card-title"]').length).toBeGreaterThan(0); + expect(document.querySelector('table')).toBeNull(); }); // CONTROL — the same rows ungrouped at the same width ARE the card view.