diff --git a/.changeset/25994-objectgrid-helpers-split.md b/.changeset/25994-objectgrid-helpers-split.md new file mode 100644 index 00000000000..ac43043e80e --- /dev/null +++ b/.changeset/25994-objectgrid-helpers-split.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-grid': patch +--- + +refactor(plugin-grid): `ObjectGrid`'s module-level helpers move into sibling modules + +Clause-②: no +Metadata-protocol: none + +The query helpers (the held filter resolution, the record-source ladder, the find-params signature), the column helpers and column emit types, the record-link cell, the English fallback translations, the display defaults (row height, page sizes, selection default) and the row-version helpers move verbatim out of `ObjectGrid.tsx` into six modules beside it. No logic, name or rendered output changes. `ObjectGrid.tsx` re-exports every name it exported before, and the package entry is unchanged. + +What to do: nothing. diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index 8e53b237242..be3fa2493dc 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":38,"sha256":"f6f7b1cbd89a1536a6e33febf4db131b8d526bd7281be460c401dccb8e97876a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":44,"sha256":"afbc05964d76c725b0d533f1bd28b1515591d0748eca468158f64fc3bb7f7a80","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/packages/plugin-grid/src/ObjectGrid.tsx b/ui/packages/plugin-grid/src/ObjectGrid.tsx index aafaa39eb4a..af84f5662ee 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, ViewData, 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, resolveRecordSourceConfig, 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, 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