diff --git a/.changeset/25994-objectgrid-query-grouping-hooks.md b/.changeset/25994-objectgrid-query-grouping-hooks.md new file mode 100644 index 00000000000..923060dace3 --- /dev/null +++ b/.changeset/25994-objectgrid-query-grouping-hooks.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-grid': patch +--- + +refactor(plugin-grid): `ObjectGrid`'s query inputs and grouping decisions move into two sibling hooks + +Clause-②: no +Metadata-protocol: none + +Two adjacent blocks of the `ObjectGrid` component move verbatim out of `ObjectGrid.tsx`. The projection keys and the view's filter, sort, search and server-paging state go to `useObjectGridQueryInputs` (`objectGridQueryInputs.ts`). The masked grouping-key refusal, the server-grouping decision, the two grouping refusals, the fetch window and the held group row query go to `useObjectGridGroupingDecisions` (`objectGridGroupingDecisions.ts`). `ObjectGrid` calls each hook where its block stood, so its hook order, its queries, its warnings 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 cda41ab8300..f6b042383d7 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -25,7 +25,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectGridSchema, DataSource, DependsOnInput, 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 { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useCapabilityGate, useDataInvalidation, notifyDataChanged, useFilterScope, useMetadata } from '@object-ui/react'; +import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useCapabilityGate, useDataInvalidation, notifyDataChanged, useMetadata } from '@object-ui/react'; // objectui#8920 — the grid reaches a cell renderer through THIS module and // nowhere else. `getCellRenderer` / `resolveCellRendererType` are deliberately // NOT imported here: six sites spelling the resolve three different ways is @@ -58,7 +58,7 @@ import { toast, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { resolveConditionalFormatting, resolveDependsOnFields, isDeadlineSettled, leadWithNameField, declaredNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, resolveAffordance, resolveFieldAffordance, type SchemaLike, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, resolveRecordSourceConfig, resolveRecordSourceObjectName, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, toDisplayDate } from '@object-ui/core'; +import { resolveConditionalFormatting, resolveDependsOnFields, isDeadlineSettled, leadWithNameField, declaredNameField, buildExpandFields, buildExportFileName, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, resolveAffordance, resolveFieldAffordance, type SchemaLike, isProjectableField, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, resolveRecordSourceConfig, resolveRecordSourceObjectName, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, toDisplayDate } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -87,15 +87,17 @@ import { BulkActionBar } from './components/BulkActionBar'; import { BulkActionDialog } from './components/BulkActionDialog'; import type { BulkResult } from './hooks/useBulkExecutor'; import type { BulkActionDef } from '@object-ui/types'; -import { findParamsSignature, useResolvedGridFilters } from './objectGridQuery'; +import { findParamsSignature } from './objectGridQuery'; import { type ObjectGridColumn, type ObjectGridColumnDraft, isChipFieldType, normalizeColumns, resolveColumnLabel } from './objectGridColumns'; import { LinkCell, recordPointerFace, rowRecordId } from './objectGridLinkCell'; import { useGridTranslation } from './objectGridTranslations'; -import { DEFAULT_DISPLAY_PAGE_SIZE, DEFAULT_FETCH_BATCH_SIZE, DEFAULT_SELECTION_TYPE, NO_GROUPING_REFUSAL, type RowHeightMode, resolvePageSize, resolveRowHeightMode } from './objectGridDefaults'; +import { DEFAULT_DISPLAY_PAGE_SIZE, DEFAULT_SELECTION_TYPE, type RowHeightMode, resolvePageSize, resolveRowHeightMode } from './objectGridDefaults'; import { type BatchSaveOutcome, OCC_TOKEN_FIELD, type RowConflictChoice, type WrittenRowVersion, rowVersionOf, rowVersionToSend } from './objectGridRowVersion'; import { type ObjectGridColumnState, type ObjectGridExternalPaginationProps, type ObjectGridFooterSummary } from './objectGridProps'; import { FooterSummaryReport } from './objectGridFooterSummaryReport'; import { useObjectGridDiagnostics } from './objectGridDiagnostics'; +import { useObjectGridQueryInputs } from './objectGridQueryInputs'; +import { useObjectGridGroupingDecisions } from './objectGridGroupingDecisions'; export type { RetiredListColumnKey, ObjectGridColumnHolds, ObjectGridColumnDraft, ObjectGridColumn } from './objectGridColumns'; export type { ObjectGridColumnState, ObjectGridFooterSummary, ObjectGridExternalPaginationProps } from './objectGridProps'; @@ -1172,159 +1174,33 @@ export const ObjectGrid: React.FC = ({ delete: canDelete, }); - const schemaFields = schema.fields; - const schemaColumns = schema.columns; - // [objectui#12292] Whether this grid's rows must carry their concurrency - // token: the AUTHORED key, not `inlineEditable`, so the principal's write - // verdict landing does not cost a second fetch. A read-only principal is - // sent one column it does not use. - const carriesConcurrencyToken = schema.editable === true; - // [objectui#12393] The row colour's field, which the projection asks for - // (see `rowColorFields` in the load effect). A string, so the fetch effect - // can name it: a host can replace the rule on a mounted grid (`ListView` - // hands the grid its toolbar pick as `rowColor`), and a new field no - // request carried would otherwise never be fetched. The `colors` map is a - // render-time concern the projection cannot see, so changing it costs no - // round trip. - const rowColorField = schema.rowColor?.field || undefined; - // [objectui#7179] The fetch effect's dep on the grouping block, as a CONTENT - // key over the field NAMES alone. - // - // Not `schema.grouping` itself: hosts rebuild that object literal every - // render (`plugin-list` spreads a fresh `{ grouping: groupingConfig }` into - // the node it hands down), so naming it here would re-issue the query on - // every render — the identity-churn refetch storm objectui#6697 recorded for - // `expandFields`. - // - // Names ONLY, not the whole block: `order` and `collapsed` are render-time - // concerns the projection cannot see, so a user collapsing a group must not - // cost a round trip. What the query depends on is exactly this list. - const groupingProjectionKey = useMemo( - () => JSON.stringify(collectGroupingFieldRefs(schema.grouping)), - [schema.grouping], - ); - // [objectui#10689] The fetch effect's dep on the three view-level PREDICATE - // carriers its projection harvests (objectui#3501), as a CONTENT key over the - // operand NAMES alone, the shape `groupingProjectionKey` takes above. - // - // The load effect reads `conditionalFormatting`, `rowActionDefs` and - // `bulkActionDefs` through `listViewPredicates` and puts each operand into - // `$select`, but none of the three was a dependency. So a rule added to a - // mounted grid never had its operand fetched: the rows kept arriving without - // the field, and the rule, which reads it, never matched. - // - // Names ONLY: a rule's style, or an action's label, is a render-time concern - // the projection cannot see, so changing it costs no round trip. The same - // harvest over the same three inputs as the effect's; the object-level - // `actions` / `userActions` it adds come from the definition the effect reads - // for itself. The `rowActionDefs` cast is the objectui#5091 NON-AUTHOR - // SURFACE exemption stated at the effect's own read of that key. - // `__tests__/ObjectGrid.harvestInputsFetchKey-10689.test.tsx` pins each input. - const conditionalFormattingRaw = schema.conditionalFormatting as readonly unknown[] | undefined; - const rowActionDefsRaw = (schema as { rowActionDefs?: readonly unknown[] }).rowActionDefs; - const bulkActionDefsRaw = (schema as { bulkActionDefs?: readonly unknown[] }).bulkActionDefs; - const predicateProjectionKey = useMemo( - () => JSON.stringify(collectPredicateFieldRefs(listViewPredicates({ - conditionalFormatting: conditionalFormattingRaw, - rowActionDefs: rowActionDefsRaw, - bulkActionDefs: bulkActionDefsRaw, - }))), - [conditionalFormattingRaw, rowActionDefsRaw, bulkActionDefsRaw], - ); - // The view's declared filter, lowered ONCE through the repo's single filter - // sink for both consumers below (the fetch and the server-side export). - // - // The lowering is what makes the authoring surface honest rather than merely - // reachable (objectui#4041). Until this block published `filter`, the only - // thing that could arrive here was an ObjectQL AST synthesized by - // `ElementDataSourceGate`, and passing that through verbatim was right. An - // AUTHOR writes the spec's view vocabulary instead — `ViewFilterRule[]`, - // `[{ field, operator, value }]` — and that shape byte-copied onto `$filter` - // is refused on the wire: `isFilterAST` is false for an array of objects and - // the data API answers `400 INVALID_FILTER` (measured against a real backend - // in objectui#3431). Declaring the key without this hop would have traded a - // silent wrong answer for a guaranteed failure, which is not a fix. - // - // `toFilterNode` is that hop by design — the last stop before the wire, where - // a value legitimately leaves the spec's view vocabulary and becomes an AST - // (see its doc for why the fold cannot live at the producer). It passes AST - // nodes through untouched, so the `ElementDataSourceGate` path is unchanged; - // it also collects the MongoDB-style object shape, and returns `undefined` - // for an absent or empty source so we skip `$filter` rather than sending an - // empty array. `plugin-list`'s `buildEffectiveFilter` and `plugin-view`'s - // `ObjectView` already reach the wire through this same sink; this read point - // was the last consumer on the chain that did not. - // - // ⚠️ `toFilterNodeSafely`, not `toFilterNode` — objectui#9050. This read is a - // RENDER-time `useMemo`: a `FilterOperatorError` from the lowering is a - // render error, and there is no load `try` and no `classifyLoadError` above - // it. The refusal is kept as a VALUE and rendered by the malformed-filter - // branch below; collapsing it to `undefined` would mean "no filter" and run - // the grid unconstrained, the silent widening objectui#9001 closed. - // - // objectui#10607: both authored entries are read RESOLVED and held (see - // `useResolvedGridFilters`), so `{current_user_id}` goes out as the real id - // and an equal filter keeps its reference across renders. - const filterScope = useFilterScope(); - const authoredFilters = useResolvedGridFilters({ filter: schema.filter }, filterScope); - const schemaFilterSource = authoredFilters.filter; - const schemaFilterResult = useMemo(() => toFilterNodeSafely(schemaFilterSource), [schemaFilterSource]); - const schemaFilterRefusal = schemaFilterResult.ok ? undefined : schemaFilterResult.refusal; - const schemaFilter = schemaFilterResult.ok ? schemaFilterResult.node : undefined; - const schemaSort = schema.sort; - const schemaPagination = schema.pagination; - const schemaPageSize = schema.pageSize; - - // Server-side ("manual") pagination for the flat list view. The fetch window - // ($top/$skip) and the DataTable's display page size are the SAME number here - // — the records we hold ARE one page, so paging means refetching the next - // slice from the server instead of slicing an in-memory batch. This is what - // makes records beyond the first batch reachable at all - // (objectstack-ai/objectstack#2212). - // - // That makes this a DISPLAY page size, so undeclared it is the spec's display - // default, ⛔ not the fetch batch (objectui#9853). A server-grouped leaf pages - // its rows by this same size (`useServerGroupRows` below). - const [serverPage, setServerPage] = useState(1); - const [serverPageSize, setServerPageSize] = useState( - resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE), - ); - - // Column-header sort, when this grid fetches its own rows (objectui#3106). - // `null` means "nobody has clicked a header" and the view's declared - // `schema.sort` is in force; once set it REPLACES that sort for the whole - // collection, not for the window on screen. - const [headerSort, setHeaderSort] = useState(null); - // A view whose declared sort changes underneath us (view switch, saved-view - // edit) invalidates a header sort taken against the previous one. - useEffect(() => { setHeaderSort(null); }, [JSON.stringify(schema.sort ?? null)]); - - // Toolbar search term, when this grid fetches its own rows (objectui#3118). - // It goes out as `$search` on the refetch — the server decides which fields - // it matches from the object's metadata (ADR-0061), the same channel the - // ListView toolbar uses. Filtering the rows we hold instead would search the - // page on screen and call the answer "the results in this list". - const [searchTerm, setSearchTerm] = useState(''); - // A term is asked of one collection. Pointing the grid at another object - // makes it a question about rows it was never typed for. - useEffect(() => { setSearchTerm(''); }, [objectName]); - // [objectui#11021] Whose term is it? A host that passes `search` owns it — - // under host-driven paging, and also when this grid fetches for itself: - // `ListView` fetches for a flat grid, but hands a grid that groups on the - // server no rows, only its toolbar term, which this grid's own queries then - // carry (both group queries, or its own window). Absent that prop, the term - // is the one typed into this grid's box. - const hostOwnsSearch = externalManualPagination || hostSearch !== undefined; - const querySearchTerm = hostOwnsSearch ? (hostSearch ?? '') : searchTerm; - // [objectui#10689] The `$searchFields` the load effect sends, as a CONTENT - // key for its dependency list: the view's `searchableFields`, and only while - // a term goes out, since without one the query does not send them. A view - // that narrows its search to other fields re-reads once; an equal array in a - // new object does not. - const searchFieldsKey = - querySearchTerm.trim() && schema.searchableFields && schema.searchableFields.length > 0 - ? JSON.stringify(schema.searchableFields) - : ''; + // The projection keys and the filter, sort, search and server-paging state, + // read here in hook order (`./objectGridQueryInputs`). + const { + schemaFields, + schemaColumns, + carriesConcurrencyToken, + rowColorField, + groupingProjectionKey, + predicateProjectionKey, + filterScope, + schemaFilterSource, + schemaFilterRefusal, + schemaFilter, + schemaSort, + schemaPagination, + schemaPageSize, + serverPage, + setServerPage, + serverPageSize, + setServerPageSize, + headerSort, + setHeaderSort, + setSearchTerm, + hostOwnsSearch, + querySearchTerm, + searchFieldsKey, + } = useObjectGridQueryInputs({ schema, objectName, externalManualPagination, hostSearch }); // --- Inline data effect (synchronous, no fetch needed) --- useEffect(() => { @@ -1373,171 +1249,31 @@ export const ObjectGrid: React.FC = ({ return () => { cancelled = true; }; }, [hasInlineData, objectName, dataSource]); - // objectui#10583 — a MASKED field is REFUSED as a grouping key. - // Grouping by it printed the raw value as each group's label. Masking the - // label would not be enough: the buckets would still show which records - // share a credential, ordered by its raw value. So the entry is dropped (the - // other entries still group, as `usableGroupingFields` does for an unusable - // one) and the drop is reported through the grid's warning channel. The rule - // is the column flag's: `isMaskedGridColumn` over the view column's type and - // the object-declared type. - // - // objectui#10657 (which folded objectui#10706) — the same rule's third - // argument: while the object's field types are unknown (`objectTypesPending`, - // the host-fetched window, or a failed read), an entry on a field whose view - // column authors no type is WITHHELD and so refused too, since its group - // label would print whatever the field holds. The group label follows the - // cell: a withheld cell draws the mask, and a withheld key labels no group. - // Once the types are known the entry groups again, unless its field is - // masked. The refusal of a withheld entry is not warned: it is not a finding - // about the authored grouping, and a failed read reports itself. - const groupingFieldsRaw = schema.grouping?.fields; - const maskedGroupingSignature = React.useMemo(() => { - const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; - const columnTypeOf = (field: string) => - cols.find((c): c is ListColumn => typeof c === 'object' && c !== null && c.field === field)?.type; - const refused = usableGroupingFields(groupingFieldsRaw) - .map((gf) => gf.field) - .filter((field) => isMaskedGridColumn(columnTypeOf(field), objectFields?.[field]?.type, objectTypesPending)); - return JSON.stringify({ - refused, - // The ones refused on a KNOWN masked type, which is what the warning names. - warned: refused.filter((field) => isMaskedGridColumn(columnTypeOf(field), objectFields?.[field]?.type, false)), - }); - }, [groupingFieldsRaw, schema.columns, objectFields, objectTypesPending]); - // Keyed on the authored array and the signature STRING, never on a memo's - // identity (AGENTS.md #10). Read only when something was refused: the - // unmasked path below hands `useGroupedData` the authored config itself. - const unmaskedGroupingFields = React.useMemo(() => { - const { refused }: { refused: string[] } = JSON.parse(maskedGroupingSignature); - return usableGroupingFields(groupingFieldsRaw).filter((gf) => !refused.includes(gf.field)); - }, [groupingFieldsRaw, maskedGroupingSignature]); - const groupingRefusesNothing = maskedGroupingSignature === NO_GROUPING_REFUSAL; - useEffect(() => { - const { warned: masked }: { warned: string[] } = JSON.parse(maskedGroupingSignature); - if (masked.length === 0) return; - console.warn( - `[ObjectUI] ObjectGrid grouping: ${schema.objectName ?? 'object-grid'} groups by the masked ` - + `field(s) ${masked.join(', ')}. A masked field cannot be a grouping key: its group labels would ` - + 'show the raw value, and its groups would show which records share it. The entry was ignored.', - ); - }, [maskedGroupingSignature, schema.objectName]); - - // ── Server-side grouping (objectui#7189, maintainer ruling A) ───────────── - // - // Grouping on a list view is server-side: the set of groups and every number - // in a group header are properties of the QUERY, not of a fetched page, and - // the rows inside a group are paged. So a grouped grid that OWNS its fetch - // asks the server for its group headers (`dataSource.queryGroupHeaders`, - // the compiled `compileListViewGroupQuery`) and pages each open group's rows - // (`compileListViewGroupRowsQuery`) instead of fetching one window and - // bucketing it — which answered two headers (86, 14) or five page slices - // (31/31/30/7/1) for the same five units of 86/61/31/7/1, depending on - // nothing but row order, and left every row past the window unreachable. - // - // Presence of `queryGroupHeaders` is the capability. Rows handed in whole - // (`hasInlineData`) are grouped where they are, which is exact because - // nothing was withheld. A data source that cannot answer the header query - // cannot group (objectui#10881, maintainer ruling F): the grid refuses, - // naming the missing member, and asks for no rows — see - // `groupingNeedsHeaderQuery`. It used to group the page it fetched, whose - // every count was a page slice. - // - // Decided on the grouping AS CONFIGURED less the entries refused on a KNOWN - // masked type — NOT less the entries merely withheld while the object's - // types are still loading, or the first render would fetch one flat window - // it is about to throw away. - const groupingRefusedKnownMasked = React.useMemo( - () => (JSON.parse(maskedGroupingSignature) as { warned: string[] }).warned, - [maskedGroupingSignature], - ); - // A grouping field this principal may not READ is not a key the server may - // group by: a header row carries every key's raw value, so grouping by it - // would print what the column itself withholds. Same gate, same deferral - // (an unanswered policy filters nothing), as the projection's. - const groupingKeyReadable = (field: string): boolean => - !perms?.isLoaded || !objectName || perms.checkField(objectName, field, 'read'); - const serverGroupedFetch = - !hasInlineData - && !!objectName - && typeof dataSource?.queryGroupHeaders === 'function' - && usableGroupingFields(groupingFieldsRaw).some( - (gf) => !groupingRefusedKnownMasked.includes(gf.field) && groupingKeyReadable(gf.field), - ); - // [objectui#10881] The refusal: this grid would fetch its own rows and group - // them, and its data source cannot answer the group header query. There is - // no honest grouping to draw — a fetched window grouped in the browser shows - // page slices as group counts and drops every group whose rows fall past the - // window — so the grid names the missing member instead, and the load effect - // below asks for no rows (the object definition is still read: it is what - // tells a KNOWN masked grouping key, which groups nothing, from one that - // does). Rows handed in whole never reach it (`hasInlineData`). The same - // grouping entries count as for `serverGroupedFetch`, less the readability - // gate: over such a source a grouping by a key this principal may not read - // is refused too, rather than falling through to a fetched page. - const groupingNeedsHeaderQuery = - !hasInlineData - && !!objectName - && !!dataSource - && typeof dataSource.queryGroupHeaders !== 'function' - && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); - // [objectui#10881] The second refusal: rows a host handed in that the SAME - // host declares to be one page of more — the external-pagination props - // (`externalManualPagination`) with a `rowCount` above the rows it handed. - // Only rows handed in WHOLE group where they are; a declared window grouped - // here shows page-slice counts and drops every group whose rows fall past - // it (on 100 of 186 rows over five units, two groups: 86 and 14). The - // header-query sentence is the wrong one here: the host owns this fetch, - // and its data source may even declare `queryGroupHeaders`. So this names - // its own remedy — hand the rows in whole, or let the grid fetch them. - // Counted against the rows as HANDED (`dataConfig.items`), not the `data` - // state that mirrors them one render later, so whole rows never flash it. - // Same grouping entries as `groupingNeedsHeaderQuery`. A `rowCount` absent - // or not above the handed rows is whole rows, and groups as before. - const handedRows = hasInlineData ? (dataConfig as { items?: unknown } | undefined)?.items : undefined; - const groupingNeedsWholeRows = - externalManualPagination - && Array.isArray(handedRows) - && (hostRowCount as number) > handedRows.length - && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); - // What the load effect's ONE flat fetch asks for (objectui#9853, ruling - // 5824040487, structure B). Where the rows it holds ARE the page on screen, - // that is the display page size. Where this grid buckets the rows it holds - // into groups in the browser, nothing pages them, so it asks for the fetch - // BATCH instead, which no display path reads. Since objectui#7189 a grouped - // grid that fetches its own rows buckets a window only when the server - // cannot group for it and the grid has not refused: not server-grouped - // (`serverGroupedFetch` strips `$top` and pages each group), not refused - // (`groupingNeedsHeaderQuery` asks for no rows), and at least one grouping - // entry left once a KNOWN masked key is refused. The case that reaches it - // today is a grouping key this principal may not read, over a data source - // that answers the header query (see `groupingKeyReadable`). - // - // Counted the way the two predicates above count, less the keys refused on a - // KNOWN masked type and NOT less the ones merely withheld while the object's - // types load: otherwise the first fetch would go out at the page size and be - // re-issued at the batch the moment the types land. - const bucketsFetchedWindow = - !serverGroupedFetch - && !groupingNeedsHeaderQuery - && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); - const fetchWindow = bucketsFetchedWindow - ? resolvePageSize(schema, DEFAULT_FETCH_BATCH_SIZE) - : serverPageSize; - // The grid's own row query — projection, expansion, order and the view's - // filter — resolved by the load effect below exactly as the flat fetch - // resolves it, and handed to each group's row page. `null` until resolved. - const [groupRowQuery, setGroupRowQuery] = useState | null>(null); - // [objectui#11021] Leaving server grouping drops the held query. Only the - // load effect below writes it, and only while grouping on the server, so a - // grid that went flat and came back asked its first header query with the - // query it held when it last grouped — a cleared search term, or an old - // filter, sort or projection — and painted that answer before the fresh - // one. `null` holds both group queries until the query as it stands now is - // resolved. - useEffect(() => { - if (!serverGroupedFetch) setGroupRowQuery(null); - }, [serverGroupedFetch]); + // The grouping decisions: masked-key refusal, server grouping, the two + // grouping refusals and the fetch window (`./objectGridGroupingDecisions`). + const { + unmaskedGroupingFields, + groupingRefusesNothing, + groupingKeyReadable, + serverGroupedFetch, + groupingNeedsHeaderQuery, + groupingNeedsWholeRows, + fetchWindow, + groupRowQuery, + setGroupRowQuery, + } = useObjectGridGroupingDecisions({ + schema, + objectName, + objectFields, + objectTypesPending, + perms, + dataSource, + hasInlineData, + dataConfig, + externalManualPagination, + hostRowCount, + serverPageSize, + }); // objectui#10035 — the refresh input this grid had none of, so a host could // show it a write only by remounting it (AGENTS.md #8's corollary: refresh diff --git a/ui/packages/plugin-grid/src/objectGridGroupingDecisions.ts b/ui/packages/plugin-grid/src/objectGridGroupingDecisions.ts new file mode 100644 index 00000000000..9562d4ba941 --- /dev/null +++ b/ui/packages/plugin-grid/src/objectGridGroupingDecisions.ts @@ -0,0 +1,221 @@ +/** + * The grouping decisions of `ObjectGrid` (`./ObjectGrid.tsx`): the masked + * grouping-key refusal, whether the grid groups on the server, the two + * grouping refusals (no header query, rows not handed in whole), the load + * effect's fetch window and the held group row query, moved out of the + * component verbatim (objectstack#25994, slice 4). Every input is passed in; + * the component reads the returned values. + */ + +import React, { useEffect, useState } from 'react'; +import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object-ui/types'; +import type { usePermissions } from '@object-ui/permissions'; +import { isMaskedGridColumn } from './maskedColumn'; +import { usableGroupingFields } from './useGroupedData'; +import { normalizeColumns } from './objectGridColumns'; +import { DEFAULT_FETCH_BATCH_SIZE, NO_GROUPING_REFUSAL, resolvePageSize } from './objectGridDefaults'; +import type { ObjectGridExternalPaginationProps } from './objectGridProps'; + +export function useObjectGridGroupingDecisions({ + schema, + objectName, + objectFields, + objectTypesPending, + perms, + dataSource, + hasInlineData, + dataConfig, + externalManualPagination, + hostRowCount, + serverPageSize, +}: { + schema: ObjectGridSchema; + objectName: string | undefined; + objectFields: Readonly> | undefined; + objectTypesPending: boolean; + perms: ReturnType; + dataSource: DataSource | undefined; + hasInlineData: boolean; + dataConfig: ViewData | { provider: string; items: unknown[] } | null; + externalManualPagination: boolean; + hostRowCount: ObjectGridExternalPaginationProps['rowCount']; + serverPageSize: number; +}) { + // objectui#10583 — a MASKED field is REFUSED as a grouping key. + // Grouping by it printed the raw value as each group's label. Masking the + // label would not be enough: the buckets would still show which records + // share a credential, ordered by its raw value. So the entry is dropped (the + // other entries still group, as `usableGroupingFields` does for an unusable + // one) and the drop is reported through the grid's warning channel. The rule + // is the column flag's: `isMaskedGridColumn` over the view column's type and + // the object-declared type. + // + // objectui#10657 (which folded objectui#10706) — the same rule's third + // argument: while the object's field types are unknown (`objectTypesPending`, + // the host-fetched window, or a failed read), an entry on a field whose view + // column authors no type is WITHHELD and so refused too, since its group + // label would print whatever the field holds. The group label follows the + // cell: a withheld cell draws the mask, and a withheld key labels no group. + // Once the types are known the entry groups again, unless its field is + // masked. The refusal of a withheld entry is not warned: it is not a finding + // about the authored grouping, and a failed read reports itself. + const groupingFieldsRaw = schema.grouping?.fields; + const maskedGroupingSignature = React.useMemo(() => { + const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; + const columnTypeOf = (field: string) => + cols.find((c): c is ListColumn => typeof c === 'object' && c !== null && c.field === field)?.type; + const refused = usableGroupingFields(groupingFieldsRaw) + .map((gf) => gf.field) + .filter((field) => isMaskedGridColumn(columnTypeOf(field), objectFields?.[field]?.type, objectTypesPending)); + return JSON.stringify({ + refused, + // The ones refused on a KNOWN masked type, which is what the warning names. + warned: refused.filter((field) => isMaskedGridColumn(columnTypeOf(field), objectFields?.[field]?.type, false)), + }); + }, [groupingFieldsRaw, schema.columns, objectFields, objectTypesPending]); + // Keyed on the authored array and the signature STRING, never on a memo's + // identity (AGENTS.md #10). Read only when something was refused: the + // unmasked path below hands `useGroupedData` the authored config itself. + const unmaskedGroupingFields = React.useMemo(() => { + const { refused }: { refused: string[] } = JSON.parse(maskedGroupingSignature); + return usableGroupingFields(groupingFieldsRaw).filter((gf) => !refused.includes(gf.field)); + }, [groupingFieldsRaw, maskedGroupingSignature]); + const groupingRefusesNothing = maskedGroupingSignature === NO_GROUPING_REFUSAL; + useEffect(() => { + const { warned: masked }: { warned: string[] } = JSON.parse(maskedGroupingSignature); + if (masked.length === 0) return; + console.warn( + `[ObjectUI] ObjectGrid grouping: ${schema.objectName ?? 'object-grid'} groups by the masked ` + + `field(s) ${masked.join(', ')}. A masked field cannot be a grouping key: its group labels would ` + + 'show the raw value, and its groups would show which records share it. The entry was ignored.', + ); + }, [maskedGroupingSignature, schema.objectName]); + + // ── Server-side grouping (objectui#7189, maintainer ruling A) ───────────── + // + // Grouping on a list view is server-side: the set of groups and every number + // in a group header are properties of the QUERY, not of a fetched page, and + // the rows inside a group are paged. So a grouped grid that OWNS its fetch + // asks the server for its group headers (`dataSource.queryGroupHeaders`, + // the compiled `compileListViewGroupQuery`) and pages each open group's rows + // (`compileListViewGroupRowsQuery`) instead of fetching one window and + // bucketing it — which answered two headers (86, 14) or five page slices + // (31/31/30/7/1) for the same five units of 86/61/31/7/1, depending on + // nothing but row order, and left every row past the window unreachable. + // + // Presence of `queryGroupHeaders` is the capability. Rows handed in whole + // (`hasInlineData`) are grouped where they are, which is exact because + // nothing was withheld. A data source that cannot answer the header query + // cannot group (objectui#10881, maintainer ruling F): the grid refuses, + // naming the missing member, and asks for no rows — see + // `groupingNeedsHeaderQuery`. It used to group the page it fetched, whose + // every count was a page slice. + // + // Decided on the grouping AS CONFIGURED less the entries refused on a KNOWN + // masked type — NOT less the entries merely withheld while the object's + // types are still loading, or the first render would fetch one flat window + // it is about to throw away. + const groupingRefusedKnownMasked = React.useMemo( + () => (JSON.parse(maskedGroupingSignature) as { warned: string[] }).warned, + [maskedGroupingSignature], + ); + // A grouping field this principal may not READ is not a key the server may + // group by: a header row carries every key's raw value, so grouping by it + // would print what the column itself withholds. Same gate, same deferral + // (an unanswered policy filters nothing), as the projection's. + const groupingKeyReadable = (field: string): boolean => + !perms?.isLoaded || !objectName || perms.checkField(objectName, field, 'read'); + const serverGroupedFetch = + !hasInlineData + && !!objectName + && typeof dataSource?.queryGroupHeaders === 'function' + && usableGroupingFields(groupingFieldsRaw).some( + (gf) => !groupingRefusedKnownMasked.includes(gf.field) && groupingKeyReadable(gf.field), + ); + // [objectui#10881] The refusal: this grid would fetch its own rows and group + // them, and its data source cannot answer the group header query. There is + // no honest grouping to draw — a fetched window grouped in the browser shows + // page slices as group counts and drops every group whose rows fall past the + // window — so the grid names the missing member instead, and the load effect + // below asks for no rows (the object definition is still read: it is what + // tells a KNOWN masked grouping key, which groups nothing, from one that + // does). Rows handed in whole never reach it (`hasInlineData`). The same + // grouping entries count as for `serverGroupedFetch`, less the readability + // gate: over such a source a grouping by a key this principal may not read + // is refused too, rather than falling through to a fetched page. + const groupingNeedsHeaderQuery = + !hasInlineData + && !!objectName + && !!dataSource + && typeof dataSource.queryGroupHeaders !== 'function' + && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); + // [objectui#10881] The second refusal: rows a host handed in that the SAME + // host declares to be one page of more — the external-pagination props + // (`externalManualPagination`) with a `rowCount` above the rows it handed. + // Only rows handed in WHOLE group where they are; a declared window grouped + // here shows page-slice counts and drops every group whose rows fall past + // it (on 100 of 186 rows over five units, two groups: 86 and 14). The + // header-query sentence is the wrong one here: the host owns this fetch, + // and its data source may even declare `queryGroupHeaders`. So this names + // its own remedy — hand the rows in whole, or let the grid fetch them. + // Counted against the rows as HANDED (`dataConfig.items`), not the `data` + // state that mirrors them one render later, so whole rows never flash it. + // Same grouping entries as `groupingNeedsHeaderQuery`. A `rowCount` absent + // or not above the handed rows is whole rows, and groups as before. + const handedRows = hasInlineData ? (dataConfig as { items?: unknown } | undefined)?.items : undefined; + const groupingNeedsWholeRows = + externalManualPagination + && Array.isArray(handedRows) + && (hostRowCount as number) > handedRows.length + && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); + // What the load effect's ONE flat fetch asks for (objectui#9853, ruling + // 5824040487, structure B). Where the rows it holds ARE the page on screen, + // that is the display page size. Where this grid buckets the rows it holds + // into groups in the browser, nothing pages them, so it asks for the fetch + // BATCH instead, which no display path reads. Since objectui#7189 a grouped + // grid that fetches its own rows buckets a window only when the server + // cannot group for it and the grid has not refused: not server-grouped + // (`serverGroupedFetch` strips `$top` and pages each group), not refused + // (`groupingNeedsHeaderQuery` asks for no rows), and at least one grouping + // entry left once a KNOWN masked key is refused. The case that reaches it + // today is a grouping key this principal may not read, over a data source + // that answers the header query (see `groupingKeyReadable`). + // + // Counted the way the two predicates above count, less the keys refused on a + // KNOWN masked type and NOT less the ones merely withheld while the object's + // types load: otherwise the first fetch would go out at the page size and be + // re-issued at the batch the moment the types land. + const bucketsFetchedWindow = + !serverGroupedFetch + && !groupingNeedsHeaderQuery + && usableGroupingFields(groupingFieldsRaw).some((gf) => !groupingRefusedKnownMasked.includes(gf.field)); + const fetchWindow = bucketsFetchedWindow + ? resolvePageSize(schema, DEFAULT_FETCH_BATCH_SIZE) + : serverPageSize; + // The grid's own row query — projection, expansion, order and the view's + // filter — resolved by the load effect below exactly as the flat fetch + // resolves it, and handed to each group's row page. `null` until resolved. + const [groupRowQuery, setGroupRowQuery] = useState | null>(null); + // [objectui#11021] Leaving server grouping drops the held query. Only the + // load effect below writes it, and only while grouping on the server, so a + // grid that went flat and came back asked its first header query with the + // query it held when it last grouped — a cleared search term, or an old + // filter, sort or projection — and painted that answer before the fresh + // one. `null` holds both group queries until the query as it stands now is + // resolved. + useEffect(() => { + if (!serverGroupedFetch) setGroupRowQuery(null); + }, [serverGroupedFetch]); + + return { + unmaskedGroupingFields, + groupingRefusesNothing, + groupingKeyReadable, + serverGroupedFetch, + groupingNeedsHeaderQuery, + groupingNeedsWholeRows, + fetchWindow, + groupRowQuery, + setGroupRowQuery, + }; +} diff --git a/ui/packages/plugin-grid/src/objectGridQueryInputs.ts b/ui/packages/plugin-grid/src/objectGridQueryInputs.ts new file mode 100644 index 00000000000..29a7ae3bd55 --- /dev/null +++ b/ui/packages/plugin-grid/src/objectGridQueryInputs.ts @@ -0,0 +1,207 @@ +/** + * The query inputs of `ObjectGrid` (`./ObjectGrid.tsx`): the projection keys + * the load effect depends on (grouping, predicates, row colour, concurrency + * token) and the view's filter, sort, search and server-paging state, moved + * out of the component verbatim (objectstack#25994, slice 4). Every input is + * passed in; the component reads the returned values. + */ + +import { useEffect, useState, useMemo } from 'react'; +import type { ObjectGridSchema, TableSortItem } from '@object-ui/types'; +import { useFilterScope } from '@object-ui/react'; +import { collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, toFilterNodeSafely } from '@object-ui/core'; +import { useResolvedGridFilters } from './objectGridQuery'; +import { DEFAULT_DISPLAY_PAGE_SIZE, resolvePageSize } from './objectGridDefaults'; +import type { ObjectGridExternalPaginationProps } from './objectGridProps'; + +export function useObjectGridQueryInputs({ + schema, + objectName, + externalManualPagination, + hostSearch, +}: { + schema: ObjectGridSchema; + objectName: string | undefined; + externalManualPagination: boolean; + hostSearch: ObjectGridExternalPaginationProps['search']; +}) { + const schemaFields = schema.fields; + const schemaColumns = schema.columns; + // [objectui#12292] Whether this grid's rows must carry their concurrency + // token: the AUTHORED key, not `inlineEditable`, so the principal's write + // verdict landing does not cost a second fetch. A read-only principal is + // sent one column it does not use. + const carriesConcurrencyToken = schema.editable === true; + // [objectui#12393] The row colour's field, which the projection asks for + // (see `rowColorFields` in the load effect). A string, so the fetch effect + // can name it: a host can replace the rule on a mounted grid (`ListView` + // hands the grid its toolbar pick as `rowColor`), and a new field no + // request carried would otherwise never be fetched. The `colors` map is a + // render-time concern the projection cannot see, so changing it costs no + // round trip. + const rowColorField = schema.rowColor?.field || undefined; + // [objectui#7179] The fetch effect's dep on the grouping block, as a CONTENT + // key over the field NAMES alone. + // + // Not `schema.grouping` itself: hosts rebuild that object literal every + // render (`plugin-list` spreads a fresh `{ grouping: groupingConfig }` into + // the node it hands down), so naming it here would re-issue the query on + // every render — the identity-churn refetch storm objectui#6697 recorded for + // `expandFields`. + // + // Names ONLY, not the whole block: `order` and `collapsed` are render-time + // concerns the projection cannot see, so a user collapsing a group must not + // cost a round trip. What the query depends on is exactly this list. + const groupingProjectionKey = useMemo( + () => JSON.stringify(collectGroupingFieldRefs(schema.grouping)), + [schema.grouping], + ); + // [objectui#10689] The fetch effect's dep on the three view-level PREDICATE + // carriers its projection harvests (objectui#3501), as a CONTENT key over the + // operand NAMES alone, the shape `groupingProjectionKey` takes above. + // + // The load effect reads `conditionalFormatting`, `rowActionDefs` and + // `bulkActionDefs` through `listViewPredicates` and puts each operand into + // `$select`, but none of the three was a dependency. So a rule added to a + // mounted grid never had its operand fetched: the rows kept arriving without + // the field, and the rule, which reads it, never matched. + // + // Names ONLY: a rule's style, or an action's label, is a render-time concern + // the projection cannot see, so changing it costs no round trip. The same + // harvest over the same three inputs as the effect's; the object-level + // `actions` / `userActions` it adds come from the definition the effect reads + // for itself. The `rowActionDefs` cast is the objectui#5091 NON-AUTHOR + // SURFACE exemption stated at the effect's own read of that key. + // `__tests__/ObjectGrid.harvestInputsFetchKey-10689.test.tsx` pins each input. + const conditionalFormattingRaw = schema.conditionalFormatting as readonly unknown[] | undefined; + const rowActionDefsRaw = (schema as { rowActionDefs?: readonly unknown[] }).rowActionDefs; + const bulkActionDefsRaw = (schema as { bulkActionDefs?: readonly unknown[] }).bulkActionDefs; + const predicateProjectionKey = useMemo( + () => JSON.stringify(collectPredicateFieldRefs(listViewPredicates({ + conditionalFormatting: conditionalFormattingRaw, + rowActionDefs: rowActionDefsRaw, + bulkActionDefs: bulkActionDefsRaw, + }))), + [conditionalFormattingRaw, rowActionDefsRaw, bulkActionDefsRaw], + ); + // The view's declared filter, lowered ONCE through the repo's single filter + // sink for both consumers below (the fetch and the server-side export). + // + // The lowering is what makes the authoring surface honest rather than merely + // reachable (objectui#4041). Until this block published `filter`, the only + // thing that could arrive here was an ObjectQL AST synthesized by + // `ElementDataSourceGate`, and passing that through verbatim was right. An + // AUTHOR writes the spec's view vocabulary instead — `ViewFilterRule[]`, + // `[{ field, operator, value }]` — and that shape byte-copied onto `$filter` + // is refused on the wire: `isFilterAST` is false for an array of objects and + // the data API answers `400 INVALID_FILTER` (measured against a real backend + // in objectui#3431). Declaring the key without this hop would have traded a + // silent wrong answer for a guaranteed failure, which is not a fix. + // + // `toFilterNode` is that hop by design — the last stop before the wire, where + // a value legitimately leaves the spec's view vocabulary and becomes an AST + // (see its doc for why the fold cannot live at the producer). It passes AST + // nodes through untouched, so the `ElementDataSourceGate` path is unchanged; + // it also collects the MongoDB-style object shape, and returns `undefined` + // for an absent or empty source so we skip `$filter` rather than sending an + // empty array. `plugin-list`'s `buildEffectiveFilter` and `plugin-view`'s + // `ObjectView` already reach the wire through this same sink; this read point + // was the last consumer on the chain that did not. + // + // ⚠️ `toFilterNodeSafely`, not `toFilterNode` — objectui#9050. This read is a + // RENDER-time `useMemo`: a `FilterOperatorError` from the lowering is a + // render error, and there is no load `try` and no `classifyLoadError` above + // it. The refusal is kept as a VALUE and rendered by the malformed-filter + // branch below; collapsing it to `undefined` would mean "no filter" and run + // the grid unconstrained, the silent widening objectui#9001 closed. + // + // objectui#10607: both authored entries are read RESOLVED and held (see + // `useResolvedGridFilters`), so `{current_user_id}` goes out as the real id + // and an equal filter keeps its reference across renders. + const filterScope = useFilterScope(); + const authoredFilters = useResolvedGridFilters({ filter: schema.filter }, filterScope); + const schemaFilterSource = authoredFilters.filter; + const schemaFilterResult = useMemo(() => toFilterNodeSafely(schemaFilterSource), [schemaFilterSource]); + const schemaFilterRefusal = schemaFilterResult.ok ? undefined : schemaFilterResult.refusal; + const schemaFilter = schemaFilterResult.ok ? schemaFilterResult.node : undefined; + const schemaSort = schema.sort; + const schemaPagination = schema.pagination; + const schemaPageSize = schema.pageSize; + + // Server-side ("manual") pagination for the flat list view. The fetch window + // ($top/$skip) and the DataTable's display page size are the SAME number here + // — the records we hold ARE one page, so paging means refetching the next + // slice from the server instead of slicing an in-memory batch. This is what + // makes records beyond the first batch reachable at all + // (objectstack-ai/objectstack#2212). + // + // That makes this a DISPLAY page size, so undeclared it is the spec's display + // default, ⛔ not the fetch batch (objectui#9853). A server-grouped leaf pages + // its rows by this same size (`useServerGroupRows` below). + const [serverPage, setServerPage] = useState(1); + const [serverPageSize, setServerPageSize] = useState( + resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE), + ); + + // Column-header sort, when this grid fetches its own rows (objectui#3106). + // `null` means "nobody has clicked a header" and the view's declared + // `schema.sort` is in force; once set it REPLACES that sort for the whole + // collection, not for the window on screen. + const [headerSort, setHeaderSort] = useState(null); + // A view whose declared sort changes underneath us (view switch, saved-view + // edit) invalidates a header sort taken against the previous one. + useEffect(() => { setHeaderSort(null); }, [JSON.stringify(schema.sort ?? null)]); + + // Toolbar search term, when this grid fetches its own rows (objectui#3118). + // It goes out as `$search` on the refetch — the server decides which fields + // it matches from the object's metadata (ADR-0061), the same channel the + // ListView toolbar uses. Filtering the rows we hold instead would search the + // page on screen and call the answer "the results in this list". + const [searchTerm, setSearchTerm] = useState(''); + // A term is asked of one collection. Pointing the grid at another object + // makes it a question about rows it was never typed for. + useEffect(() => { setSearchTerm(''); }, [objectName]); + // [objectui#11021] Whose term is it? A host that passes `search` owns it — + // under host-driven paging, and also when this grid fetches for itself: + // `ListView` fetches for a flat grid, but hands a grid that groups on the + // server no rows, only its toolbar term, which this grid's own queries then + // carry (both group queries, or its own window). Absent that prop, the term + // is the one typed into this grid's box. + const hostOwnsSearch = externalManualPagination || hostSearch !== undefined; + const querySearchTerm = hostOwnsSearch ? (hostSearch ?? '') : searchTerm; + // [objectui#10689] The `$searchFields` the load effect sends, as a CONTENT + // key for its dependency list: the view's `searchableFields`, and only while + // a term goes out, since without one the query does not send them. A view + // that narrows its search to other fields re-reads once; an equal array in a + // new object does not. + const searchFieldsKey = + querySearchTerm.trim() && schema.searchableFields && schema.searchableFields.length > 0 + ? JSON.stringify(schema.searchableFields) + : ''; + + return { + schemaFields, + schemaColumns, + carriesConcurrencyToken, + rowColorField, + groupingProjectionKey, + predicateProjectionKey, + filterScope, + schemaFilterSource, + schemaFilterRefusal, + schemaFilter, + schemaSort, + schemaPagination, + schemaPageSize, + serverPage, + setServerPage, + serverPageSize, + setServerPageSize, + headerSort, + setHeaderSort, + setSearchTerm, + hostOwnsSearch, + querySearchTerm, + searchFieldsKey, + }; +}