From 39923d863408b6a1d9fd0a3092436946f2874a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 07:55:25 +0000 Subject: [PATCH 1/2] refactor(plugin-grid): move ObjectGrid's grouping and column summaries into sibling hooks (slice 6) The grouping (group value formatter, date group faces, server group headers and rows, grouped data) moves verbatim into useObjectGridGrouping, with temporalGroupHeading and its face type. The fan-out query, resolved match total and column summaries move into useObjectGridSummaries. Each hook is called where its block stood, so hook order is unchanged. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Bdrd2yH4eqnz2QobtN3haE --- ...994-objectgrid-grouping-summaries-hooks.md | 12 + ui/packages/plugin-grid/src/ObjectGrid.tsx | 433 +----------------- .../plugin-grid/src/objectGridGrouping.ts | 389 ++++++++++++++++ .../plugin-grid/src/objectGridSummaries.ts | 131 ++++++ 4 files changed, 554 insertions(+), 411 deletions(-) create mode 100644 .changeset/25994-objectgrid-grouping-summaries-hooks.md create mode 100644 ui/packages/plugin-grid/src/objectGridGrouping.ts create mode 100644 ui/packages/plugin-grid/src/objectGridSummaries.ts diff --git a/.changeset/25994-objectgrid-grouping-summaries-hooks.md b/.changeset/25994-objectgrid-grouping-summaries-hooks.md new file mode 100644 index 00000000000..5d74ea6a326 --- /dev/null +++ b/.changeset/25994-objectgrid-grouping-summaries-hooks.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-grid': patch +--- + +refactor(plugin-grid): `ObjectGrid`'s grouping and column summaries move into two sibling hooks + +Clause-②: no +Metadata-protocol: none + +Two adjacent blocks of the `ObjectGrid` component move verbatim out of `ObjectGrid.tsx`. The grouping (the group value formatter, the date-family group faces, the server group headers and row pages, and the grouped data) goes to `useObjectGridGrouping` (`objectGridGrouping.ts`), together with `temporalGroupHeading`, which draws a date group's heading. The cross-page fan-out query, the resolved match total and the column summaries go to `useObjectGridSummaries` (`objectGridSummaries.ts`). `ObjectGrid` calls each hook where its block stood, so its hook order, its queries and its rendered output do not change. No name is renamed, and the package entry is unchanged. + +What to do: nothing. diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index 2ffa5d6d0f9..d2c8fe6649d 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -24,7 +24,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectGridSchema, DataSource, ListColumn, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat, RecordNavigateAction } from '@object-ui/types'; import { isSystemManagedField, normalizeTableColumnType } from '@object-ui/types'; -import { parseFilterAST, resolveFieldScale, type FilterCondition } from '@objectstack/spec/data'; +import { resolveFieldScale } from '@objectstack/spec/data'; import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useCapabilityGate, notifyDataChanged, useMetadata } from '@object-ui/react'; // objectui#8920 — the grid reaches a cell renderer through THIS module and // nowhere else. `getCellRenderer` / `resolveCellRendererType` are deliberately @@ -33,7 +33,7 @@ import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeF // what stops a seventh site picking a convention of its own. import { resolveGridCellRendering, gridCellRendererForFixedKey, linkCellRenderer, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution'; import { isMaskedGridColumn, isWithheldGridColumn } from './maskedColumn'; -import { formatCurrency, formatCompactCurrency, formatDate, formatDateTime, formatRelativeDate, formatPercent, percentCellScale, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue, MaskedCellRenderer, withKnownObjects, RICH_TEXT_FIELD_TYPES } from '@object-ui/fields'; +import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, percentCellScale, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue, MaskedCellRenderer, withKnownObjects, RICH_TEXT_FIELD_TYPES } from '@object-ui/fields'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -58,7 +58,7 @@ import { toast, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, isDeadlineSettled, leadWithNameField, declaredNameField, buildExportFileName, columnIdentity, resolveAffordance, resolveFieldAffordance, type SchemaLike, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, filterRefusalSubject, FilterOperatorError, resolveRecordSourceConfig, resolveRecordSourceObjectName, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, toDisplayDate } from '@object-ui/core'; +import { resolveConditionalFormatting, isDeadlineSettled, leadWithNameField, declaredNameField, buildExportFileName, columnIdentity, resolveAffordance, resolveFieldAffordance, type SchemaLike, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, filterRefusalSubject, FilterOperatorError, resolveRecordSourceConfig, resolveRecordSourceObjectName, partitionRowsByPredicate } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -70,11 +70,7 @@ import { } from '@object-ui/plugin-detail'; import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock, Loader2 } from 'lucide-react'; import { useRowColor } from './useRowColor'; -import { useGroupedDataInOptionOrder, usableGroupingFields, type GroupOptionRanks, type ServerGroupSource } from './useGroupedData'; -import { groupSearchOf, useServerGroupHeaders, useServerGroupRows, type ServerGroupLeaf } from './useServerGrouping'; import { GroupRow } from './GroupRow'; -import { useScopedColumnSummary } from './useColumnSummary'; -import { useWholeSetSummary } from './useWholeSetSummary'; import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './rowCrudAffordances'; import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts'; import { resolveLegacyRowActions } from './resolveLegacyRowActions'; @@ -98,6 +94,8 @@ import { useObjectGridDiagnostics } from './objectGridDiagnostics'; import { useObjectGridQueryInputs } from './objectGridQueryInputs'; import { useObjectGridGroupingDecisions } from './objectGridGroupingDecisions'; import { useObjectGridRowLoad } from './objectGridRowLoad'; +import { temporalGroupHeading, useObjectGridGrouping } from './objectGridGrouping'; +import { useObjectGridSummaries } from './objectGridSummaries'; export type { RetiredListColumnKey, ObjectGridColumnHolds, ObjectGridColumnDraft, ObjectGridColumn } from './objectGridColumns'; export type { ObjectGridColumnState, ObjectGridFooterSummary, ObjectGridExternalPaginationProps } from './objectGridProps'; @@ -588,61 +586,6 @@ export type ObjectGridDataTableSchemaHolds = { export type ObjectGridDataTableSchema = DeclaredDataTableSchema & ObjectGridDataTableSchemaHolds; - -/** - * [objectui#13234] The face a date-family CELL paints, for one grouping field: - * the renderer key from the cells' one shared resolve - * (`resolveGridCellRendering`), and the object field's `format`, which is the - * `format` the cell's field metadata carries. - */ -interface TemporalGroupFace { - rendererType: 'date' | 'datetime'; - format?: string; -} - -/** - * [objectui#13234] A `date` / `datetime` group's key, drawn as the grid's cell - * draws the same value. `undefined` for a key the cell has no face for (empty - * or unparsable), so the band keeps the group's own label: "(empty)" for an - * empty key, the stored text otherwise. - * - * The formatting CALLS are the cells' own, with the cells' arguments: - * `DateCellRenderer` in a grid column (`inGridColumn`, objectstack#25884) - * paints `formatDate(value, format || undefined, …)`, the absolute default - * face when no `format` is declared; - * `DateTimeCellRenderer` paints `formatRelativeDate` for `'relative'`, the - * compact face for no format and for `'short'`, and `formatDateTime` with the - * authored word otherwise (the compact face's string is the cell's two halves - * joined by the one space the cell draws between them). No date is formatted - * here. The face SELECTION is the one part those renderers do not export, and - * it is restated here only: `groupDateHeading-13234.test.tsx` holds every - * heading to the cell under it for every face, so a move on either side turns - * it red. - * - * A heading names a DAY, not one row's deadline, so it reads the face's - * neutral phrase: `dueLike`'s "Overdue" wording and red are a per-row verdict - * (`settledWhen` is judged per row), and the rows of one group can judge it - * differently. - */ -function temporalGroupHeading( - face: TemporalGroupFace, - key: unknown, - locale: string | undefined, -): string | undefined { - if (typeof key !== 'string' && typeof key !== 'number') return undefined; - // The cells' floor: every falsy scalar is empty, the numeric epoch included. - if (!key) return undefined; - // The cells' validity guard, the shared parse step. - if (isNaN(toDisplayDate(key).getTime())) return undefined; - if (face.rendererType === 'date') return formatDate(key, face.format || undefined, { locale }); - // A `datetime` cell renders an INSTANT, from the engine's parse. - const instant = new Date(key); - const style = !face.format || face.format === 'short' ? 'compact' : face.format; - return style === 'relative' - ? formatRelativeDate(instant, { locale }) - : formatDateTime(instant, { style, locale }); -} - export const ObjectGrid: React.FC = ({ schema, dataSource, @@ -1340,358 +1283,26 @@ export const ObjectGrid: React.FC = ({ return Object.keys(style).length > 0 ? (style as React.CSSProperties) : undefined; }, [schema.conditionalFormatting, predicateScope, objectSchema]); - // --- Grouping support --- - // Build a per-field value formatter so group headers display the human - // readable label for select/boolean fields rather than the raw value - // (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true"). - // A select value's label is the OBJECT FIELD's option label (objectui#11544). - const groupValueFormatter = React.useMemo(() => { - // [objectui#7217] ONE normalized entry list, shared with the - // `useGroupedDataInOptionOrder` call below. Reading `grouping.fields` raw here threw - // `TypeError: Cannot read properties of null (reading 'field')` on a null - // hole — the whole grid gone, during render, before any projection was - // built. `usableGroupingFields` admits exactly the entries - // `collectGroupingFieldRefs` harvests into the projection, so the grid can - // never group by an entry the query never asked for. - const groupingFields = usableGroupingFields(schema.grouping?.fields); - if (!groupingFields.length) return undefined; - - // Per-field { value -> label } lookup, plus a per-field type so we can - // handle booleans / dates / users without dedicated option lists. - const lookup = new Map }>(); - - for (const gf of groupingFields) { - const fieldName = gf.field; - const objectDefField = objectFields?.[fieldName]; - // The authored column for this field may respell its TYPE — `type` is a - // declared `ListColumn` member, and it is how a `"true"` / `"false"` text - // value reads Yes / No. It never supplies the value LABELS - // (objectui#11544): `options` is not a `ListColumn` member, and the - // strict `ListColumnSchema` refuses it at publish, so the labels come - // from the object field's `options` only. The lookup is typed as - // `ListColumn` so a read of an undeclared column member does not compile. - const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; - const colOverride = cols.find( - (c): c is ListColumn => typeof c === 'object' && c !== null && c.field === fieldName, - ); - - const type = colOverride?.type || objectDefField?.type; - const rawOptions = objectDefField?.options; - - const optionsMap = new Map(); - if (Array.isArray(rawOptions) && rawOptions.length > 0) { - const translated = schema.objectName - ? translateOptions(schema.objectName, fieldName, rawOptions) - : rawOptions; - for (const opt of translated) { - if (opt && opt.value !== undefined && opt.value !== null) { - const label = (opt as any).label; - optionsMap.set(String(opt.value), label != null ? String(label) : String(opt.value)); - } - } - } - - lookup.set(fieldName, { - type: type || undefined, - options: optionsMap.size > 0 ? optionsMap : undefined, - }); - } - - return (field: string, value: any): string | undefined => { - const meta = lookup.get(field); - if (!meta) return undefined; - // Select / multi-select: resolve from options map first. - if (meta.options) { - const label = meta.options.get(String(value)); - if (label !== undefined) return label; - } - // Boolean fields: render as Yes/No. We use the toolbar i18n bundle so - // grids without an objectName still produce a readable label — the same - // `grid.yes`/`grid.no` keys the boolean cell renderer and the bulk-action - // dialog use, passed as `defaultValue` (a bare string second argument is - // read as an options object, so the fallback never applied). - if (meta.type === 'boolean' || typeof value === 'boolean') { - if (value === true || value === 'true') return t('grid.yes', { defaultValue: 'Yes' }); - if (value === false || value === 'false') return t('grid.no', { defaultValue: 'No' }); - } - return undefined; - }; - }, [schema.grouping, schema.columns, schema.objectName, objectFields, translateOptions, t]); - - // [objectui#13234] Per grouping field whose cell paints a date-family face, - // that face; `temporalGroupHeading` draws the field's group headings through - // it. The type is the one `groupValueFormatter` reads (the authored column's - // `type` over the object field's), taken through the cells' one shared - // resolve, so a heading is drawn as a date exactly when the cell under it is. - // - // ⚠️ Drawn at the band (`groupBandsOf`), NOT returned from - // `groupValueFormatter`: the grouping hook ORDERS groups by the label that - // formatter returns, and a stored day sorts in calendar order only as the - // stored string ("Aug 1" < "Oct 1" < "Sep 1" alphabetically). The group keeps - // the stored key as its label, so its order and count are what they were; - // only the text in its band changes. - const temporalGroupFaces = React.useMemo(() => { - const faces = new Map(); - const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; - for (const gf of usableGroupingFields(schema.grouping?.fields)) { - const objectDefField = objectFields?.[gf.field]; - const colOverride = cols.find( - (c): c is ListColumn => typeof c === 'object' && c !== null && c.field === gf.field, - ); - const { rendererType } = resolveGridCellRendering({ - type: colOverride?.type || objectDefField?.type, - format: objectDefField?.format, - widget: objectDefField?.widget, - }); - if (rendererType === 'date' || rendererType === 'datetime') { - faces.set(gf.field, { rendererType, format: objectDefField?.format || undefined }); - } - } - return faces; - }, [schema.grouping, schema.columns, objectFields]); - - const groupingForRender = groupingRefusesNothing || !schema.grouping - ? schema.grouping - : { ...schema.grouping, fields: unmaskedGroupingFields }; - // [objectui#7189] The entries the SERVER groups by: the render grouping less - // any key this principal may not read (see `groupingKeyReadable`). Keyed on - // the names, so a host rebuilding `grouping` every render re-asks nothing. - // [objectstack#25786] A level's `orderBy` rides along — the header query - // compiles its `min` / `max` column from it — under the same read gate: an - // `orderBy` this principal may not read is dropped, and the level orders by - // its own key, since the header row would carry that field's value and the - // group order would disclose it. - const serverGroupingKey = serverGroupedFetch - ? JSON.stringify( - usableGroupingFields(groupingForRender?.fields) - .filter((gf) => groupingKeyReadable(gf.field)) - .map((gf) => [ - gf.field, - gf.order ?? null, - gf.collapsed ?? null, - gf.orderBy && groupingKeyReadable(gf.orderBy) ? gf.orderBy : null, - ]), - ) - : '[]'; - const serverGroupingFields = React.useMemo( - () => (JSON.parse(serverGroupingKey) as Array<[string, 'asc' | 'desc' | null, boolean | null, string | null]>) - .map(([field, order, collapsed, orderBy]) => ({ - field, - ...(order ? { order } : {}), - ...(orderBy !== null ? { orderBy } : {}), - ...(collapsed !== null ? { collapsed } : {}), - })), - [serverGroupingKey], - ); - // The view's composed filter as ONE `FilterCondition` — what both compiled - // queries take, so the header numbers and the rows they head answer the - // same question. Lowered from the AST the flat fetch would have sent; a - // filter that does not lower is refused, never dropped (dropping it would - // group the unfiltered object). - const groupRowQueryKey = JSON.stringify(groupRowQuery); - const groupWhere = React.useMemo((): { where?: FilterCondition; error?: Error } => { - const query = JSON.parse(groupRowQueryKey) as Record | null; - const ast = query?.$filter; - if (ast === undefined || ast === null) return {}; - try { - const where = parseFilterAST(ast); - if (where === undefined) { - return { error: new Error(`[ObjectGrid] The view filter could not be lowered for server-side grouping: ${JSON.stringify(ast)}`) }; - } - return { where }; - } catch (err) { - return { error: err instanceof Error ? err : new Error(String(err)) }; - } - }, [groupRowQueryKey]); - const groupReloadKey = `${refreshKey}:${invalidationNonce}`; - const groupHeaders = useServerGroupHeaders({ - enabled: serverGroupedFetch && !objectTypesPending && groupRowQuery !== null && !groupWhere.error, - dataSource, - objectName, - fields: serverGroupingFields, - where: groupWhere.where, - // [objectui#11021] The search term, off the same resolved query every - // group's row page is asked with: one pair on both queries. - ...groupSearchOf(groupRowQuery), - aggregations: schema.aggregations, - objectFields, - reloadKey: groupReloadKey, + // The grouping: the group value formatter and date faces, the server group + // headers and rows, and the grouped data, in hook order (`./objectGridGrouping`). + const { + temporalGroupFaces, groupWhere, groupHeaders, groups, isGrouped, toggleGroup, groupRows, + } = useObjectGridGrouping({ + schema, dataSource, objectName, objectFields, objectTypesPending, data, setData, + setTotalMatching, t, translateOptions, groupedPage, setGroupedPage, groupedPageSize, refreshKey, + serverPageSize, groupRowQuery, groupingKeyReadable, groupingRefusesNothing, + unmaskedGroupingFields, serverGroupedFetch, invalidationNonce, }); - // Keyed on the hook's STATE values (a stable identity React promises), so - // the group tree is rebuilt when the server answers, not on every render. - const groupHeaderRows = groupHeaders.headers; - const groupKeyLabels = groupHeaders.keyLabels; - const serverGroupSource: ServerGroupSource | undefined = React.useMemo( - () => (serverGroupedFetch ? { headers: groupHeaderRows ?? [], keyLabels: groupKeyLabels } : undefined), - [serverGroupedFetch, groupHeaderRows, groupKeyLabels], - ); - // [objectui#11809] Each grouping field's DECLARED option order: the object - // field's `options` read through core's `buildCategoryOrder` / - // `buildCategoryRank`, the same reading the dashboard's category axis and - // the funnel use. A select field's groups then follow the order the author - // declared (Backlog → To Do → In Progress …), as the Kanban lanes do, rather - // than the order of their labels; empty and undeclared values sit last. A - // field with no options keeps label order. Keyed on the order itself (a - // string), not on `schema.grouping`, which a host rebuilds every render. - const groupOptionOrderKey = JSON.stringify( - usableGroupingFields(schema.grouping?.fields).flatMap((gf) => { - const order = buildCategoryOrder(objectFields?.[gf.field]?.options); - return order ? [[gf.field, order]] : []; - }), - ); - const groupOptionRanks = React.useMemo((): GroupOptionRanks | undefined => { - const ranks = new Map>(); - for (const [field, order] of JSON.parse(groupOptionOrderKey) as Array<[string, string[]]>) { - const rank = buildCategoryRank(order); - if (rank) ranks.set(field, rank); - } - return ranks.size > 0 ? ranks : undefined; - }, [groupOptionOrderKey]); - - const { groups, isGrouped, toggleGroup } = useGroupedDataInOptionOrder( - serverGroupedFetch - ? (schema.grouping ? { ...schema.grouping, fields: serverGroupingFields } : undefined) - : groupingForRender, - data, - schema.aggregations, - groupValueFormatter, - serverGroupSource, - groupOptionRanks, - ); - - // Reset grouped pagination to page 1 whenever the grouping config, page size - // or the underlying data changes (e.g. switching grouping field, reload). - const groupingKey = React.useMemo( - () => JSON.stringify(schema.grouping ?? null), - [schema.grouping], - ); - React.useEffect(() => { - setGroupedPage(1); - }, [groupingKey, groupedPageSize, refreshKey]); - - // [objectui#7189] The open LEAF groups on the current page of groups — the - // only ones whose rows are on screen, and so the only ones whose rows are - // asked for. A collapsed group costs no row query at all. - const serverGroupLeaves: ServerGroupLeaf[] = []; - if (serverGroupedFetch) { - const groupPages = Math.max(1, Math.ceil(groups.length / groupedPageSize)); - const onPage = Math.min(groupedPage, groupPages); - const collectOpenLeaves = (group: typeof groups[number]) => { - if (group.collapsed) return; - if (group.subgroups.length === 0) serverGroupLeaves.push({ key: group.key, keyValues: group.keyValues }); - else group.subgroups.forEach(collectOpenLeaves); - }; - groups.slice((onPage - 1) * groupedPageSize, onPage * groupedPageSize).forEach(collectOpenLeaves); - } - const groupRows = useServerGroupRows({ - enabled: serverGroupedFetch && !!groupHeaders.headers, - dataSource, - objectName, - fields: serverGroupingFields, - where: groupWhere.where, - baseParams: groupRowQuery, - pageSize: serverPageSize, - leaves: serverGroupLeaves, - reloadKey: groupReloadKey, - }); - // The rows in hand are the open groups' pages: what everything else that - // reads `data` (column inference, the client export, selection) sees. - const serverLeafKeys = JSON.stringify(serverGroupLeaves.map((leaf) => leaf.key)); - useEffect(() => { - if (!serverGroupedFetch) return; - const keys = JSON.parse(serverLeafKeys) as string[]; - setData(keys.flatMap((key) => groupRows.pages[key]?.rows ?? [])); - }, [serverGroupedFetch, serverLeafKeys, groupRows.pages]); - // …and the match total is the QUERY's: the top-level group counts summed. - const serverGroupTotal = serverGroupedFetch && groupHeaders.headers - ? (groupHeaders.headers[0] ?? []).reduce((sum, row) => sum + (Number(row.count) || 0), 0) - : undefined; - useEffect(() => { - if (serverGroupTotal !== undefined) setTotalMatching(serverGroupTotal); - }, [serverGroupTotal]); - - // The query the cross-page fan-out would replay — from whichever side owns the - // fetch (objectui#4501). ONE value, read by the fan-out AND by the affordance - // gate below, so the offer and the thing it promises can never disagree — - // and by the footer, whose whole-set figures are this same query's - // (objectui#12081 item 3). Declared here, above the footer's hook, rather - // than beside the fan-out: hooks run before the component's early returns. - // - // `hasInlineData` is exactly the data loader's own guard (`if (hasInlineData) - // return`), which makes it the precise test for "this grid did not issue the - // query behind the rows on screen". In that case `lastFindParamsRef` is not - // merely empty, it is WRONG — either never written, or left over from an - // earlier own-fetch — so the host's `findParams` is the only admissible - // source, and there is deliberately no fallback to the ref and no `?? {}` - // default: replaying `{}` is what asked the server for the whole object and - // fed up to 5000 unmatched records to bulk delete. - const bulkFanoutParams: Record | null = hasInlineData - ? (hostFindParams ?? null) - : (lastFindParamsRef.current ?? null); - - // The real match total, from whichever side owns the fetch — ONE derived - // value with ONE answer (objectui#4464). The pager has always read it this - // way; the cross-page "Select all N matching" affordance read the raw - // `totalMatching` STATE instead, whose only writer is this component's own - // data loader. Under a host that fetches the rows itself (ListView passing - // `manualPagination` + `rowCount`, i.e. the console) that loader never runs, - // so the state stayed `undefined` and `BulkActionBar`'s gate was permanently - // false while the pager, two lines away, showed the correct page count off - // the very number the bar needed. - // - // Every consumer reads this const — the pager's `rowCount`, both - // `BulkActionBar` sites, and the footer's whole-set read, which checks the - // count it is answered against this one (objectui#12081 item 3). Do NOT - // re-spell the conditional at a second consumption site: two copies of the - // fallback is exactly how one of them gets missed again (this defect, and - // #4138 before it). - // - // This answers HOW MANY match, and `canOfferSelectAllMatching` (objectui#4501) - // independently answers WHETHER the escalation may be offered at all. The two - // compose and neither substitutes for the other: the bar sites read this total - // only *through* that floor, so a host with a real `rowCount` but no - // `findParams` still gets no offer — a number to display is not a query to - // replay. The floor also subsumes the `singleSelection` suppression that used - // to be spelled at these sites (`!singleSelection` is its first conjunct). - const resolvedTotalMatching = externalManualPagination ? hostRowCount : totalMatching; - - // --- Column summary support --- - const summaryColumns = React.useMemo(() => { - const cols = normalizeColumns(schema.columns); - if (cols && cols.length > 0 && typeof cols[0] === 'object') { - return cols as ListColumn[]; - } - return undefined; - }, [schema.columns]); - // [objectui#12081 item 3] Whether the rows the footer reads ARE the set it - // is drawn beside. Under server paging they are one page of it: a host - // (`ListView`) hands this grid one window with the set's `rowCount`, or this - // grid's own loader asked for one page of `fetchWindow` rows. Rows handed in - // without a declared page are the set, and so is a first page that holds - // every match. Only the flat table draws the footer, so a grouped grid asks - // nothing. - const footerRowsAreTheSet = externalManualPagination - ? (hostRowCount as number) <= data.length - : hasInlineData || (serverPage === 1 && (typeof totalMatching === 'number' - ? totalMatching <= data.length - : data.length < fetchWindow)); - const wholeSetSummary = useWholeSetSummary({ - enabled: !footerRowsAreTheSet && !isGrouped && !serverGroupedFetch && data.length > 0, - dataSource, - objectName, - columns: summaryColumns, - findParams: bulkFanoutParams, - count: resolvedTotalMatching, - reloadKey: String(refreshKey), + // The fan-out query, the resolved match total and the column summaries, in + // hook order (`./objectGridSummaries`). + const { + bulkFanoutParams, resolvedTotalMatching, summaries, hasSummary, + } = useObjectGridSummaries({ + schema, dataSource, objectName, objectSchema, data, hasInlineData, externalManualPagination, + hostFindParams, hostRowCount, lastFindParamsRef, totalMatching, refreshKey, serverPage, + fetchWindow, serverGroupedFetch, isGrouped, }); - const { summaries, hasSummary } = useScopedColumnSummary( - summaryColumns, - data, - objectSchema?.fields, - footerRowsAreTheSet ? 'rows' : (wholeSetSummary.status === 'answered' ? 'whole' : 'page'), - wholeSetSummary.row, - ); // The authored-input diagnostics (`columns`, `bulkActionDefs`, page size), // three `console.warn` effects, run here in hook order (`./objectGridDiagnostics`). diff --git a/ui/packages/plugin-grid/src/objectGridGrouping.ts b/ui/packages/plugin-grid/src/objectGridGrouping.ts new file mode 100644 index 00000000000..7d5d3f11def --- /dev/null +++ b/ui/packages/plugin-grid/src/objectGridGrouping.ts @@ -0,0 +1,389 @@ +/** + * The grouping of `ObjectGrid` (`./ObjectGrid.tsx`): the date-family group + * faces and their headings, the group value formatter, the server group headers + * and row pages, and the grouped data the table draws, moved out of the + * component verbatim (objectstack#25994, slice 6). Every input is passed in; + * the component reads the returned values. + */ + +import React, { useEffect } from 'react'; +import type { ObjectGridSchema, DataSource, ListColumn } from '@object-ui/types'; +import { parseFilterAST, type FilterCondition } from '@objectstack/spec/data'; +import type { useSafeFieldLabel } from '@object-ui/react'; +import { formatDate, formatDateTime, formatRelativeDate } from '@object-ui/fields'; +import { buildCategoryOrder, buildCategoryRank, toDisplayDate } from '@object-ui/core'; +import { resolveGridCellRendering, type GridCellFieldLike } from './cellRendererResolution'; +import { useGroupedDataInOptionOrder, usableGroupingFields, type GroupOptionRanks, type ServerGroupSource } from './useGroupedData'; +import { groupSearchOf, useServerGroupHeaders, useServerGroupRows, type ServerGroupLeaf } from './useServerGrouping'; +import { normalizeColumns } from './objectGridColumns'; +import type { useGridTranslation } from './objectGridTranslations'; +import type { useObjectGridQueryInputs } from './objectGridQueryInputs'; +import type { useObjectGridGroupingDecisions } from './objectGridGroupingDecisions'; +import type { useObjectGridRowLoad } from './objectGridRowLoad'; + +type QueryInputs = ReturnType; +type GroupingDecisions = ReturnType; +type RowLoad = ReturnType; +/** What the grouping reads off an object field: its cell face and its options. */ +type ObjectFieldLike = GridCellFieldLike & { + type?: string; + options?: Parameters['translateOptions']>[2]; +}; + +/** + * [objectui#13234] The face a date-family CELL paints, for one grouping field: + * the renderer key from the cells' one shared resolve + * (`resolveGridCellRendering`), and the object field's `format`, which is the + * `format` the cell's field metadata carries. + */ +interface TemporalGroupFace { + rendererType: 'date' | 'datetime'; + format?: string; +} + +/** + * [objectui#13234] A `date` / `datetime` group's key, drawn as the grid's cell + * draws the same value. `undefined` for a key the cell has no face for (empty + * or unparsable), so the band keeps the group's own label: "(empty)" for an + * empty key, the stored text otherwise. + * + * The formatting CALLS are the cells' own, with the cells' arguments: + * `DateCellRenderer` in a grid column (`inGridColumn`, objectstack#25884) + * paints `formatDate(value, format || undefined, …)`, the absolute default + * face when no `format` is declared; + * `DateTimeCellRenderer` paints `formatRelativeDate` for `'relative'`, the + * compact face for no format and for `'short'`, and `formatDateTime` with the + * authored word otherwise (the compact face's string is the cell's two halves + * joined by the one space the cell draws between them). No date is formatted + * here. The face SELECTION is the one part those renderers do not export, and + * it is restated here only: `groupDateHeading-13234.test.tsx` holds every + * heading to the cell under it for every face, so a move on either side turns + * it red. + * + * A heading names a DAY, not one row's deadline, so it reads the face's + * neutral phrase: `dueLike`'s "Overdue" wording and red are a per-row verdict + * (`settledWhen` is judged per row), and the rows of one group can judge it + * differently. + */ +export function temporalGroupHeading( + face: TemporalGroupFace, + key: unknown, + locale: string | undefined, +): string | undefined { + if (typeof key !== 'string' && typeof key !== 'number') return undefined; + // The cells' floor: every falsy scalar is empty, the numeric epoch included. + if (!key) return undefined; + // The cells' validity guard, the shared parse step. + if (isNaN(toDisplayDate(key).getTime())) return undefined; + if (face.rendererType === 'date') return formatDate(key, face.format || undefined, { locale }); + // A `datetime` cell renders an INSTANT, from the engine's parse. + const instant = new Date(key); + const style = !face.format || face.format === 'short' ? 'compact' : face.format; + return style === 'relative' + ? formatRelativeDate(instant, { locale }) + : formatDateTime(instant, { style, locale }); +} + +export function useObjectGridGrouping({ + schema, dataSource, objectName, objectFields, objectTypesPending, data, setData, setTotalMatching, + t, translateOptions, groupedPage, setGroupedPage, groupedPageSize, refreshKey, serverPageSize, + groupRowQuery, groupingKeyReadable, groupingRefusesNothing, unmaskedGroupingFields, + serverGroupedFetch, invalidationNonce, +}: { + schema: ObjectGridSchema; + dataSource: DataSource | undefined; + objectName: string | undefined; + objectFields: Readonly> | undefined; + objectTypesPending: boolean; + data: unknown[]; + setData: React.Dispatch>; + setTotalMatching: React.Dispatch>; + t: ReturnType['t']; + translateOptions: ReturnType['translateOptions']; + groupedPage: number; + setGroupedPage: React.Dispatch>; + groupedPageSize: number; + refreshKey: number; + serverPageSize: QueryInputs['serverPageSize']; + groupRowQuery: GroupingDecisions['groupRowQuery']; + groupingKeyReadable: GroupingDecisions['groupingKeyReadable']; + groupingRefusesNothing: GroupingDecisions['groupingRefusesNothing']; + unmaskedGroupingFields: GroupingDecisions['unmaskedGroupingFields']; + serverGroupedFetch: GroupingDecisions['serverGroupedFetch']; + invalidationNonce: RowLoad['invalidationNonce']; +}) { + // --- Grouping support --- + // Build a per-field value formatter so group headers display the human + // readable label for select/boolean fields rather than the raw value + // (e.g. "In Progress" instead of "in_progress", "Yes" instead of "true"). + // A select value's label is the OBJECT FIELD's option label (objectui#11544). + const groupValueFormatter = React.useMemo(() => { + // [objectui#7217] ONE normalized entry list, shared with the + // `useGroupedDataInOptionOrder` call below. Reading `grouping.fields` raw here threw + // `TypeError: Cannot read properties of null (reading 'field')` on a null + // hole — the whole grid gone, during render, before any projection was + // built. `usableGroupingFields` admits exactly the entries + // `collectGroupingFieldRefs` harvests into the projection, so the grid can + // never group by an entry the query never asked for. + const groupingFields = usableGroupingFields(schema.grouping?.fields); + if (!groupingFields.length) return undefined; + + // Per-field { value -> label } lookup, plus a per-field type so we can + // handle booleans / dates / users without dedicated option lists. + const lookup = new Map }>(); + + for (const gf of groupingFields) { + const fieldName = gf.field; + const objectDefField = objectFields?.[fieldName]; + // The authored column for this field may respell its TYPE — `type` is a + // declared `ListColumn` member, and it is how a `"true"` / `"false"` text + // value reads Yes / No. It never supplies the value LABELS + // (objectui#11544): `options` is not a `ListColumn` member, and the + // strict `ListColumnSchema` refuses it at publish, so the labels come + // from the object field's `options` only. The lookup is typed as + // `ListColumn` so a read of an undeclared column member does not compile. + const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; + const colOverride = cols.find( + (c): c is ListColumn => typeof c === 'object' && c !== null && c.field === fieldName, + ); + + const type = colOverride?.type || objectDefField?.type; + const rawOptions = objectDefField?.options; + + const optionsMap = new Map(); + if (Array.isArray(rawOptions) && rawOptions.length > 0) { + const translated = schema.objectName + ? translateOptions(schema.objectName, fieldName, rawOptions) + : rawOptions; + for (const opt of translated) { + if (opt && opt.value !== undefined && opt.value !== null) { + const label = (opt as any).label; + optionsMap.set(String(opt.value), label != null ? String(label) : String(opt.value)); + } + } + } + + lookup.set(fieldName, { + type: type || undefined, + options: optionsMap.size > 0 ? optionsMap : undefined, + }); + } + + return (field: string, value: any): string | undefined => { + const meta = lookup.get(field); + if (!meta) return undefined; + // Select / multi-select: resolve from options map first. + if (meta.options) { + const label = meta.options.get(String(value)); + if (label !== undefined) return label; + } + // Boolean fields: render as Yes/No. We use the toolbar i18n bundle so + // grids without an objectName still produce a readable label — the same + // `grid.yes`/`grid.no` keys the boolean cell renderer and the bulk-action + // dialog use, passed as `defaultValue` (a bare string second argument is + // read as an options object, so the fallback never applied). + if (meta.type === 'boolean' || typeof value === 'boolean') { + if (value === true || value === 'true') return t('grid.yes', { defaultValue: 'Yes' }); + if (value === false || value === 'false') return t('grid.no', { defaultValue: 'No' }); + } + return undefined; + }; + }, [schema.grouping, schema.columns, schema.objectName, objectFields, translateOptions, t]); + + // [objectui#13234] Per grouping field whose cell paints a date-family face, + // that face; `temporalGroupHeading` draws the field's group headings through + // it. The type is the one `groupValueFormatter` reads (the authored column's + // `type` over the object field's), taken through the cells' one shared + // resolve, so a heading is drawn as a date exactly when the cell under it is. + // + // ⚠️ Drawn at the band (`groupBandsOf`), NOT returned from + // `groupValueFormatter`: the grouping hook ORDERS groups by the label that + // formatter returns, and a stored day sorts in calendar order only as the + // stored string ("Aug 1" < "Oct 1" < "Sep 1" alphabetically). The group keeps + // the stored key as its label, so its order and count are what they were; + // only the text in its band changes. + const temporalGroupFaces = React.useMemo(() => { + const faces = new Map(); + const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; + for (const gf of usableGroupingFields(schema.grouping?.fields)) { + const objectDefField = objectFields?.[gf.field]; + const colOverride = cols.find( + (c): c is ListColumn => typeof c === 'object' && c !== null && c.field === gf.field, + ); + const { rendererType } = resolveGridCellRendering({ + type: colOverride?.type || objectDefField?.type, + format: objectDefField?.format, + widget: objectDefField?.widget, + }); + if (rendererType === 'date' || rendererType === 'datetime') { + faces.set(gf.field, { rendererType, format: objectDefField?.format || undefined }); + } + } + return faces; + }, [schema.grouping, schema.columns, objectFields]); + + const groupingForRender = groupingRefusesNothing || !schema.grouping + ? schema.grouping + : { ...schema.grouping, fields: unmaskedGroupingFields }; + // [objectui#7189] The entries the SERVER groups by: the render grouping less + // any key this principal may not read (see `groupingKeyReadable`). Keyed on + // the names, so a host rebuilding `grouping` every render re-asks nothing. + // [objectstack#25786] A level's `orderBy` rides along — the header query + // compiles its `min` / `max` column from it — under the same read gate: an + // `orderBy` this principal may not read is dropped, and the level orders by + // its own key, since the header row would carry that field's value and the + // group order would disclose it. + const serverGroupingKey = serverGroupedFetch + ? JSON.stringify( + usableGroupingFields(groupingForRender?.fields) + .filter((gf) => groupingKeyReadable(gf.field)) + .map((gf) => [ + gf.field, + gf.order ?? null, + gf.collapsed ?? null, + gf.orderBy && groupingKeyReadable(gf.orderBy) ? gf.orderBy : null, + ]), + ) + : '[]'; + const serverGroupingFields = React.useMemo( + () => (JSON.parse(serverGroupingKey) as Array<[string, 'asc' | 'desc' | null, boolean | null, string | null]>) + .map(([field, order, collapsed, orderBy]) => ({ + field, + ...(order ? { order } : {}), + ...(orderBy !== null ? { orderBy } : {}), + ...(collapsed !== null ? { collapsed } : {}), + })), + [serverGroupingKey], + ); + // The view's composed filter as ONE `FilterCondition` — what both compiled + // queries take, so the header numbers and the rows they head answer the + // same question. Lowered from the AST the flat fetch would have sent; a + // filter that does not lower is refused, never dropped (dropping it would + // group the unfiltered object). + const groupRowQueryKey = JSON.stringify(groupRowQuery); + const groupWhere = React.useMemo((): { where?: FilterCondition; error?: Error } => { + const query = JSON.parse(groupRowQueryKey) as Record | null; + const ast = query?.$filter; + if (ast === undefined || ast === null) return {}; + try { + const where = parseFilterAST(ast); + if (where === undefined) { + return { error: new Error(`[ObjectGrid] The view filter could not be lowered for server-side grouping: ${JSON.stringify(ast)}`) }; + } + return { where }; + } catch (err) { + return { error: err instanceof Error ? err : new Error(String(err)) }; + } + }, [groupRowQueryKey]); + const groupReloadKey = `${refreshKey}:${invalidationNonce}`; + const groupHeaders = useServerGroupHeaders({ + enabled: serverGroupedFetch && !objectTypesPending && groupRowQuery !== null && !groupWhere.error, + dataSource, + objectName, + fields: serverGroupingFields, + where: groupWhere.where, + // [objectui#11021] The search term, off the same resolved query every + // group's row page is asked with: one pair on both queries. + ...groupSearchOf(groupRowQuery), + aggregations: schema.aggregations, + objectFields, + reloadKey: groupReloadKey, + }); + // Keyed on the hook's STATE values (a stable identity React promises), so + // the group tree is rebuilt when the server answers, not on every render. + const groupHeaderRows = groupHeaders.headers; + const groupKeyLabels = groupHeaders.keyLabels; + const serverGroupSource: ServerGroupSource | undefined = React.useMemo( + () => (serverGroupedFetch ? { headers: groupHeaderRows ?? [], keyLabels: groupKeyLabels } : undefined), + [serverGroupedFetch, groupHeaderRows, groupKeyLabels], + ); + + // [objectui#11809] Each grouping field's DECLARED option order: the object + // field's `options` read through core's `buildCategoryOrder` / + // `buildCategoryRank`, the same reading the dashboard's category axis and + // the funnel use. A select field's groups then follow the order the author + // declared (Backlog → To Do → In Progress …), as the Kanban lanes do, rather + // than the order of their labels; empty and undeclared values sit last. A + // field with no options keeps label order. Keyed on the order itself (a + // string), not on `schema.grouping`, which a host rebuilds every render. + const groupOptionOrderKey = JSON.stringify( + usableGroupingFields(schema.grouping?.fields).flatMap((gf) => { + const order = buildCategoryOrder(objectFields?.[gf.field]?.options); + return order ? [[gf.field, order]] : []; + }), + ); + const groupOptionRanks = React.useMemo((): GroupOptionRanks | undefined => { + const ranks = new Map>(); + for (const [field, order] of JSON.parse(groupOptionOrderKey) as Array<[string, string[]]>) { + const rank = buildCategoryRank(order); + if (rank) ranks.set(field, rank); + } + return ranks.size > 0 ? ranks : undefined; + }, [groupOptionOrderKey]); + + const { groups, isGrouped, toggleGroup } = useGroupedDataInOptionOrder( + serverGroupedFetch + ? (schema.grouping ? { ...schema.grouping, fields: serverGroupingFields } : undefined) + : groupingForRender, + data, + schema.aggregations, + groupValueFormatter, + serverGroupSource, + groupOptionRanks, + ); + + // Reset grouped pagination to page 1 whenever the grouping config, page size + // or the underlying data changes (e.g. switching grouping field, reload). + const groupingKey = React.useMemo( + () => JSON.stringify(schema.grouping ?? null), + [schema.grouping], + ); + React.useEffect(() => { + setGroupedPage(1); + }, [groupingKey, groupedPageSize, refreshKey]); + + // [objectui#7189] The open LEAF groups on the current page of groups — the + // only ones whose rows are on screen, and so the only ones whose rows are + // asked for. A collapsed group costs no row query at all. + const serverGroupLeaves: ServerGroupLeaf[] = []; + if (serverGroupedFetch) { + const groupPages = Math.max(1, Math.ceil(groups.length / groupedPageSize)); + const onPage = Math.min(groupedPage, groupPages); + const collectOpenLeaves = (group: typeof groups[number]) => { + if (group.collapsed) return; + if (group.subgroups.length === 0) serverGroupLeaves.push({ key: group.key, keyValues: group.keyValues }); + else group.subgroups.forEach(collectOpenLeaves); + }; + groups.slice((onPage - 1) * groupedPageSize, onPage * groupedPageSize).forEach(collectOpenLeaves); + } + const groupRows = useServerGroupRows({ + enabled: serverGroupedFetch && !!groupHeaders.headers, + dataSource, + objectName, + fields: serverGroupingFields, + where: groupWhere.where, + baseParams: groupRowQuery, + pageSize: serverPageSize, + leaves: serverGroupLeaves, + reloadKey: groupReloadKey, + }); + // The rows in hand are the open groups' pages: what everything else that + // reads `data` (column inference, the client export, selection) sees. + const serverLeafKeys = JSON.stringify(serverGroupLeaves.map((leaf) => leaf.key)); + useEffect(() => { + if (!serverGroupedFetch) return; + const keys = JSON.parse(serverLeafKeys) as string[]; + setData(keys.flatMap((key) => groupRows.pages[key]?.rows ?? [])); + }, [serverGroupedFetch, serverLeafKeys, groupRows.pages]); + // …and the match total is the QUERY's: the top-level group counts summed. + const serverGroupTotal = serverGroupedFetch && groupHeaders.headers + ? (groupHeaders.headers[0] ?? []).reduce((sum, row) => sum + (Number(row.count) || 0), 0) + : undefined; + useEffect(() => { + if (serverGroupTotal !== undefined) setTotalMatching(serverGroupTotal); + }, [serverGroupTotal]); + + return { + temporalGroupFaces, groupWhere, groupHeaders, groups, isGrouped, toggleGroup, groupRows, + }; +} diff --git a/ui/packages/plugin-grid/src/objectGridSummaries.ts b/ui/packages/plugin-grid/src/objectGridSummaries.ts new file mode 100644 index 00000000000..d300e82182a --- /dev/null +++ b/ui/packages/plugin-grid/src/objectGridSummaries.ts @@ -0,0 +1,131 @@ +/** + * The column summaries of `ObjectGrid` (`./ObjectGrid.tsx`), with the two + * values they are answered against: the query the cross-page fan-out replays + * and the resolved match total. Moved out of the component verbatim + * (objectstack#25994, slice 6). Every input is passed in; the component reads + * the returned values. + */ + +import React from 'react'; +import type { ObjectGridSchema, DataSource, ListColumn } from '@object-ui/types'; +import { useScopedColumnSummary } from './useColumnSummary'; +import { useWholeSetSummary } from './useWholeSetSummary'; +import { normalizeColumns } from './objectGridColumns'; +import type { ObjectGridExternalPaginationProps } from './objectGridProps'; +import type { useObjectGridQueryInputs } from './objectGridQueryInputs'; +import type { useObjectGridGroupingDecisions } from './objectGridGroupingDecisions'; +import type { useObjectGridGrouping } from './objectGridGrouping'; + +type QueryInputs = ReturnType; +type GroupingDecisions = ReturnType; +type Grouping = ReturnType; + + +export function useObjectGridSummaries({ + schema, dataSource, objectName, objectSchema, data, hasInlineData, externalManualPagination, + hostFindParams, hostRowCount, lastFindParamsRef, totalMatching, refreshKey, serverPage, + fetchWindow, serverGroupedFetch, isGrouped, +}: { + schema: ObjectGridSchema; + dataSource: DataSource | undefined; + objectName: string | undefined; + objectSchema: { fields?: Parameters[2] } | null; + data: Parameters[1]; + hasInlineData: boolean; + externalManualPagination: boolean; + hostFindParams: ObjectGridExternalPaginationProps['findParams']; + hostRowCount: ObjectGridExternalPaginationProps['rowCount']; + lastFindParamsRef: React.RefObject | null>; + totalMatching: number | undefined; + refreshKey: number; + serverPage: QueryInputs['serverPage']; + fetchWindow: GroupingDecisions['fetchWindow']; + serverGroupedFetch: GroupingDecisions['serverGroupedFetch']; + isGrouped: Grouping['isGrouped']; +}) { + // The query the cross-page fan-out would replay — from whichever side owns the + // fetch (objectui#4501). ONE value, read by the fan-out AND by the affordance + // gate below, so the offer and the thing it promises can never disagree — + // and by the footer, whose whole-set figures are this same query's + // (objectui#12081 item 3). Declared here, above the footer's hook, rather + // than beside the fan-out: hooks run before the component's early returns. + // + // `hasInlineData` is exactly the data loader's own guard (`if (hasInlineData) + // return`), which makes it the precise test for "this grid did not issue the + // query behind the rows on screen". In that case `lastFindParamsRef` is not + // merely empty, it is WRONG — either never written, or left over from an + // earlier own-fetch — so the host's `findParams` is the only admissible + // source, and there is deliberately no fallback to the ref and no `?? {}` + // default: replaying `{}` is what asked the server for the whole object and + // fed up to 5000 unmatched records to bulk delete. + const bulkFanoutParams: Record | null = hasInlineData + ? (hostFindParams ?? null) + : (lastFindParamsRef.current ?? null); + + // The real match total, from whichever side owns the fetch — ONE derived + // value with ONE answer (objectui#4464). The pager has always read it this + // way; the cross-page "Select all N matching" affordance read the raw + // `totalMatching` STATE instead, whose only writer is this component's own + // data loader. Under a host that fetches the rows itself (ListView passing + // `manualPagination` + `rowCount`, i.e. the console) that loader never runs, + // so the state stayed `undefined` and `BulkActionBar`'s gate was permanently + // false while the pager, two lines away, showed the correct page count off + // the very number the bar needed. + // + // Every consumer reads this const — the pager's `rowCount`, both + // `BulkActionBar` sites, and the footer's whole-set read, which checks the + // count it is answered against this one (objectui#12081 item 3). Do NOT + // re-spell the conditional at a second consumption site: two copies of the + // fallback is exactly how one of them gets missed again (this defect, and + // #4138 before it). + // + // This answers HOW MANY match, and `canOfferSelectAllMatching` (objectui#4501) + // independently answers WHETHER the escalation may be offered at all. The two + // compose and neither substitutes for the other: the bar sites read this total + // only *through* that floor, so a host with a real `rowCount` but no + // `findParams` still gets no offer — a number to display is not a query to + // replay. The floor also subsumes the `singleSelection` suppression that used + // to be spelled at these sites (`!singleSelection` is its first conjunct). + const resolvedTotalMatching = externalManualPagination ? hostRowCount : totalMatching; + + // --- Column summary support --- + const summaryColumns = React.useMemo(() => { + const cols = normalizeColumns(schema.columns); + if (cols && cols.length > 0 && typeof cols[0] === 'object') { + return cols as ListColumn[]; + } + return undefined; + }, [schema.columns]); + // [objectui#12081 item 3] Whether the rows the footer reads ARE the set it + // is drawn beside. Under server paging they are one page of it: a host + // (`ListView`) hands this grid one window with the set's `rowCount`, or this + // grid's own loader asked for one page of `fetchWindow` rows. Rows handed in + // without a declared page are the set, and so is a first page that holds + // every match. Only the flat table draws the footer, so a grouped grid asks + // nothing. + const footerRowsAreTheSet = externalManualPagination + ? (hostRowCount as number) <= data.length + : hasInlineData || (serverPage === 1 && (typeof totalMatching === 'number' + ? totalMatching <= data.length + : data.length < fetchWindow)); + const wholeSetSummary = useWholeSetSummary({ + enabled: !footerRowsAreTheSet && !isGrouped && !serverGroupedFetch && data.length > 0, + dataSource, + objectName, + columns: summaryColumns, + findParams: bulkFanoutParams, + count: resolvedTotalMatching, + reloadKey: String(refreshKey), + }); + const { summaries, hasSummary } = useScopedColumnSummary( + summaryColumns, + data, + objectSchema?.fields, + footerRowsAreTheSet ? 'rows' : (wholeSetSummary.status === 'answered' ? 'whole' : 'page'), + wholeSetSummary.row, + ); + + return { + bulkFanoutParams, resolvedTotalMatching, summaries, hasSummary, + }; +} From 678369999fd5cc4e6772bee37ab92618b8c47497 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:49:40 +0000 Subject: [PATCH 2/2] fix(plugin-grid): rename the grouping hook's local field type off a spec-owned name (slice 6) check:spec-symbols refuses a local `ObjectFieldLike` because @objectstack/spec/system exports that name. The local type is GroupingFieldDef now; nothing else changes. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Bdrd2yH4eqnz2QobtN3haE --- ui/packages/plugin-grid/src/objectGridGrouping.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/packages/plugin-grid/src/objectGridGrouping.ts b/ui/packages/plugin-grid/src/objectGridGrouping.ts index 7d5d3f11def..54c14621972 100644 --- a/ui/packages/plugin-grid/src/objectGridGrouping.ts +++ b/ui/packages/plugin-grid/src/objectGridGrouping.ts @@ -25,7 +25,7 @@ type QueryInputs = ReturnType; type GroupingDecisions = ReturnType; type RowLoad = ReturnType; /** What the grouping reads off an object field: its cell face and its options. */ -type ObjectFieldLike = GridCellFieldLike & { +type GroupingFieldDef = GridCellFieldLike & { type?: string; options?: Parameters['translateOptions']>[2]; }; @@ -93,7 +93,7 @@ export function useObjectGridGrouping({ schema: ObjectGridSchema; dataSource: DataSource | undefined; objectName: string | undefined; - objectFields: Readonly> | undefined; + objectFields: Readonly> | undefined; objectTypesPending: boolean; data: unknown[]; setData: React.Dispatch>;