From a5154e696942199b1db7964439c4d69a699eea68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:06:37 +0000 Subject: [PATCH 1/4] refactor(plugin-grid): move ObjectGrid's module-level helpers into sibling modules (slice 1) Move-only: the query helpers, column helpers and emit types, the link cell, the fallback translations, the display defaults and the row-version helpers leave ObjectGrid.tsx verbatim for six sibling modules. ObjectGrid.tsx imports them back and re-exports every name it exported before. Claude-Session: https://claude.ai/code/session_01Bdrd2yH4eqnz2QobtN3haE Co-authored-by: Claude --- ui/packages/plugin-grid/src/ObjectGrid.tsx | 983 +----------------- .../plugin-grid/src/objectGridColumns.ts | 304 ++++++ .../plugin-grid/src/objectGridDefaults.ts | 207 ++++ .../plugin-grid/src/objectGridLinkCell.tsx | 197 ++++ .../plugin-grid/src/objectGridQuery.ts | 150 +++ .../plugin-grid/src/objectGridRowVersion.ts | 65 ++ .../plugin-grid/src/objectGridTranslations.ts | 93 ++ 7 files changed, 1029 insertions(+), 970 deletions(-) create mode 100644 ui/packages/plugin-grid/src/objectGridColumns.ts create mode 100644 ui/packages/plugin-grid/src/objectGridDefaults.ts create mode 100644 ui/packages/plugin-grid/src/objectGridLinkCell.tsx create mode 100644 ui/packages/plugin-grid/src/objectGridQuery.ts create mode 100644 ui/packages/plugin-grid/src/objectGridRowVersion.ts create mode 100644 ui/packages/plugin-grid/src/objectGridTranslations.ts diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index aafaa39eb4a..3a2a15c7e08 100644 --- a/ui/packages/plugin-grid/src/ObjectGrid.tsx +++ b/ui/packages/plugin-grid/src/ObjectGrid.tsx @@ -22,12 +22,10 @@ */ import React, { useEffect, useState, useCallback, useMemo } from 'react'; -import type { ObjectGridSchema, DataSource, DependsOnInput, ListColumn, TableColumn, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat, RecordNavigateAction } from '@object-ui/types'; +import type { ObjectGridSchema, DataSource, DependsOnInput, ListColumn, TableSortItem, DataTableSchema, ListViewExportFormat, RecordNavigateAction } from '@object-ui/types'; import { isSystemManagedField, normalizeTableColumnType } from '@object-ui/types'; -import type { I18nLabel } from '@objectstack/spec/ui'; import { parseFilterAST, resolveFieldScale, type FilterCondition } from '@objectstack/spec/data'; -import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useCapabilityGate, useRelatedRecordActions, RelatedRecordActionsProvider, useDataInvalidation, notifyDataChanged, useFilterScope, useMetadata, isSameAuthoredFilter } from '@object-ui/react'; -import { createSafeTranslation } from '@object-ui/i18n'; +import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useCapabilityGate, useDataInvalidation, notifyDataChanged, useFilterScope, 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 @@ -35,9 +33,8 @@ import { createSafeTranslation } from '@object-ui/i18n'; // 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, resolveRecordPointer } from '@object-ui/fields'; -import { RecordPointerCell, type RecordPointerSource } from './RecordPointerCell'; -import { useLocalization, useDisplayLocale, resolveFieldCurrency, pickLocalized } from '@object-ui/i18n'; +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 { 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 // `resolveI18nLabel`: it resolves the INLINE per-locale map @@ -48,7 +45,7 @@ import { useLocalization, useDisplayLocale, resolveFieldCurrency, pickLocalized // `string | I18nLabel` form `BaseSchema` has carried since objectui#4580: the // two reads below put the label in STRING positions, so a map-valued label used // to reach them as an object and the compiler could not say so. -import { resolveI18nLabel as resolveInlineI18nLabel, PaginationConfigSchema } from '@objectstack/spec/ui'; +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { stateMachineNextValues, isFieldInlineEditable } from './inline-edit-options'; import { Badge, Button, NavigationOverlay, EmptyValue, @@ -61,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, ROW_HEIGHT_TO_DENSITY_MODE, resolveRecordSourceConfig, resolveRecordSourceObjectName, resolveFilterPlaceholders, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, toDisplayDate, type FilterTokenScope } 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, toFilterNodeSafely, filterRefusalSubject, FilterOperatorError, convertSortToQueryParams, normalizeSortEntries, type QuerySortEntry, resolveRecordSourceObjectName, partitionRowsByPredicate, buildCategoryOrder, buildCategoryRank, toDisplayDate } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { RECORD_OVERLAY_DEFAULT_WIDTH, @@ -90,80 +87,13 @@ import { BulkActionBar } from './components/BulkActionBar'; import { BulkActionDialog } from './components/BulkActionDialog'; import type { BulkResult } from './hooks/useBulkExecutor'; import type { BulkActionDef } from '@object-ui/types'; - -/** - * The node's own authored filter: the canonical `filter`. Its deprecated alias - * `defaultFilters` is not read: `@objectstack/spec` 18 retired it as a - * tombstone that refuses by name (objectstack#11509), and the alias read went - * with it (objectui#12030). - */ -interface AuthoredGridFilters { - filter: ObjectGridSchema['filter']; -} - -/** One resolution, remembered with the inputs it was computed from. */ -interface HeldGridFilters { - authored: AuthoredGridFilters; - currentUserId: FilterTokenScope['currentUserId']; - currentOrgId: FilterTokenScope['currentOrgId']; - recordId: FilterTokenScope['recordId']; - onUnresolved: FilterTokenScope['onUnresolved']; - resolved: AuthoredGridFilters; -} - -function resolveGridFilters(authored: AuthoredGridFilters, scope: FilterTokenScope): HeldGridFilters { - return { - authored, - currentUserId: scope.currentUserId, - currentOrgId: scope.currentOrgId, - recordId: scope.recordId, - onUnresolved: scope.onUnresolved, - resolved: resolveFilterPlaceholders(authored, scope), - }; -} - -/** - * objectui#10607 — resolve the node's own filter ONCE, through - * `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the session - * scope the host provides (`useFilterScope`). Before this, a directly authored - * `object-grid` with `filter: [['owner', '=', '{current_user_id}']]` sent the - * literal token on `$filter`. `object-view` already resolves the filters it - * hands this grid (objectui#10506); resolving that value again changes - * nothing, because a resolved id no longer matches the whole-token pattern. - * - * ⛔ Not a second resolver, and no fallback: a token the scope cannot resolve - * is whatever `resolveFilterPlaceholders` makes of it (left intact, with one - * warning naming it). - * - * The result is HELD against its inputs, the shape `plugin-view`'s - * `useResolvedFilterSegments` uses. The load effect keys on the filter this - * grid lowers, so a resolved copy minted on every render would refetch on - * every render; and a date macro such as `{now}` resolves to a new value at - * every call, so comparing OUTPUTS cannot stop that. The key is the authored - * filters, compared by structure (a host that rebuilds an equal filter inline - * must not re-query), plus each scope member read one by one (`recordId` - * among them, objectui#7297), never the scope object's identity (AGENTS.md - * #10). The held pair lives in state, - * so the value handed out is always the one React committed. - */ -function useResolvedGridFilters(authored: AuthoredGridFilters, scope: FilterTokenScope): AuthoredGridFilters { - const [held, setHeld] = useState(() => resolveGridFilters(authored, scope)); - if ( - held.currentUserId !== scope.currentUserId - || held.currentOrgId !== scope.currentOrgId - || held.recordId !== scope.recordId - || held.onUnresolved !== scope.onUnresolved - || !isSameAuthoredFilter(held.authored, authored) - ) { - // React's documented "information from previous renders" shape: a set - // during render re-renders this component at once, before any child sees - // the discarded pass, and the re-render finds the inputs equal. - const next = resolveGridFilters(authored, scope); - setHeld(next); - return next.resolved; - } - return held.resolved; -} +import { findParamsSignature, getDataConfig, useResolvedGridFilters } 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, describeNonPositivePageSize, resolvePageSize, resolveRowHeightMode } from './objectGridDefaults'; +import { type BatchSaveOutcome, OCC_TOKEN_FIELD, type RowConflictChoice, type WrittenRowVersion, rowVersionOf, rowVersionToSend } from './objectGridRowVersion'; +export type { RetiredListColumnKey, ObjectGridColumnHolds, ObjectGridColumnDraft, ObjectGridColumn } from './objectGridColumns'; /** * A declared `sort` → the `"field order"` join string THIS block sends as @@ -242,310 +172,6 @@ export function parseSchemaSort(sort: unknown): TableSortItem[] { return items; } -/** - * The class list both faces of {@link LinkCell} wear — the anchor is a visual - * no-op against the span it upgrades, so a list does not change appearance - * depending on whether its host publishes record URLs. - */ -/** - * The field types whose grid cell draws chips: the ones `@object-ui/fields` - * maps to `SelectCellRenderer` (a badge per selected option). Their column - * carries `chips`, so `data-table` floors it at its widest drawn chip and a - * single-word value is never ellipsized (objectui#12708). - */ -const CHIP_FIELD_TYPES: ReadonlySet = new Set(['select', 'status', 'multiselect', 'radio', 'checkboxes', 'tags']); - -function isChipFieldType(type: unknown): boolean { - return typeof type === 'string' && CHIP_FIELD_TYPES.has(type); -} - -const LINK_CELL_CLASS = - 'text-primary font-medium underline-offset-4 hover:underline cursor-pointer truncate block max-w-full'; - -/** - * The same list with the one-line clamp swapped for wrapping — what a cell in a - * column carrying an authored `wrap: true` wears (objectui#6650). - * - * ⭐ DERIVED from {@link LINK_CELL_CLASS} rather than written out, so the two - * cannot drift: every future edit to the link cell's appearance lands in both - * faces of both variants, and the no-wrap variant stays byte-identical to what - * `ObjectGrid.linkCellAnchor.test.tsx` pins as full markup. - * - * ⚠️ Why this exists at all — the outer read in `data-table.tsx` is NOT enough - * on its own. `data-table` puts the wrap/truncate decision on the cell BODY it - * owns, but a link column's content is this component, and its own `truncate` - * clamps the text back to one line INSIDE a body that is willing to wrap. So - * `wrap: true` on the record-link column — column one of almost every grid — - * would have rendered as silently nothing: the very failure mode this card - * exists to end, reproduced one element deeper. Measured, not reasoned: the - * end-to-end pin in `ObjectGrid.columnWrapForward.test.tsx` failed on exactly - * this, reading back `…cursor-pointer truncate block max-w-full` from a column - * authored `wrap: true`. - */ -const LINK_CELL_CLASS_WRAP = LINK_CELL_CLASS.replace('truncate', 'whitespace-normal break-words'); - -/** - * A row's primary key — the value the host's record-URL builder addresses. - * Mirrors what `useNavigationOverlay.handleClick` reads off the same row, so - * the anchor's href and the click path can never point at different records. - */ -function rowRecordId(row: unknown): string | number | undefined { - const rec = row as Record | null | undefined; - const id = rec?.id ?? rec?._id; - return typeof id === 'string' || typeof id === 'number' ? id : undefined; -} - -// Clickable text cell that can safely contain other interactive content -// (e.g., EmailCellRenderer's copy button). Using