Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/25994-objectgrid-column-state-mobile-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/plugin-grid': patch
---

refactor(plugin-grid): `ObjectGrid`'s row-height sync, column state and mobile affordances move into three sibling hooks

Clause-②: no
Metadata-protocol: none

Three adjacent blocks at the top of the `ObjectGrid` component move verbatim out of `ObjectGrid.tsx`. The effect that follows a changed `schema.rowHeight` goes to `useObjectGridRowHeightSync` (`objectGridRowHeight.ts`). The column-state persistence (the storage key, the `columnState` seed, the sync of a host-supplied `columnState` and `saveColumnState`) goes to `useObjectGridColumnState` (`objectGridColumnState.ts`). Pull to refresh and the card-view breakpoint go to `useObjectGridMobile` (`objectGridMobile.ts`). `ObjectGrid` calls each hook where its block stood, so its hook order, its stored column state and its rendered output do not change. No name is renamed, and the package entry is unchanged.

What to do: nothing.
114 changes: 12 additions & 102 deletions ui/packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ import {
HorizontalOverflowBox, PINNED_RIGHT_SEAM_SHADOW_CLASSES,
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 } from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
import {
Expand Down Expand Up @@ -88,14 +87,17 @@ import { LinkCell, recordPointerFace, rowRecordId } from './objectGridLinkCell';
import { useGridTranslation } from './objectGridTranslations';
import { DEFAULT_DISPLAY_PAGE_SIZE, DEFAULT_SELECTION_TYPE, type RowHeightMode, resolvePageSize, resolveRowHeightMode } from './objectGridDefaults';
import { type BatchSaveOutcome, type RowConflictChoice, type WrittenRowVersion, rowVersionOf, rowVersionToSend } from './objectGridRowVersion';
import { type ObjectGridColumnState, type ObjectGridExternalPaginationProps, type ObjectGridFooterSummary } from './objectGridProps';
import { type ObjectGridExternalPaginationProps, type ObjectGridFooterSummary } from './objectGridProps';
import { FooterSummaryReport } from './objectGridFooterSummaryReport';
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';
import { useObjectGridRowHeightSync } from './objectGridRowHeight';
import { useObjectGridColumnState } from './objectGridColumnState';
import { useObjectGridMobile } from './objectGridMobile';
export type { RetiredListColumnKey, ObjectGridColumnHolds, ObjectGridColumnDraft, ObjectGridColumn } from './objectGridColumns';
export type { ObjectGridColumnState, ObjectGridFooterSummary, ObjectGridExternalPaginationProps } from './objectGridProps';

Expand Down Expand Up @@ -678,110 +680,18 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE),
);

// Sync internal rowHeightMode when schema.rowHeight prop changes (e.g., parent ListView density toggle).
// Routed through the same resolver as the seed above: this is the component's
// second entry point for an author-supplied `rowHeight`, and one resolver at
// every entry is what keeps the answer single (objectui#4443).
React.useEffect(() => {
if (!schema.rowHeight) return;
const next = resolveRowHeightMode(schema.rowHeight);
if (next !== rowHeightMode) {
setRowHeightMode(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schema.rowHeight]);
// The row-height sync (`./objectGridRowHeight`), run here in hook order.
useObjectGridRowHeightSync({ schema, rowHeightMode, setRowHeightMode });

// Column state persistence (order and widths)
const columnStorageKey = React.useMemo(() => {
return schema.id
? `grid-columns-${schema.objectName}-${schema.id}`
: `grid-columns-${schema.objectName}`;
}, [schema.objectName, schema.id]);
// Column state persistence: the seed, the external sync and the save, in
// hook order (`./objectGridColumnState`).
const { columnState, saveColumnState } = useObjectGridColumnState({ schema, onColumnStateChange });

/**
* NON-AUTHOR SURFACE — `columnState` is deliberately absent from
* `GRID_QUERY_INPUTS` (`index.tsx`), by the maintainer ruling of 2026-08-18
* on objectui#5091. The cast reads in this block are therefore DELIBERATELY
* unlisted, not missed.
*
* It is the USER-STATE persistence payload, not authoring surface: the host
* hands in whatever the user last saved (app-shell's `ObjectView` reads it
* off the user's own list state, then off the view def) and stores their
* drags back on that state (`persistListColumnState` there; `saveColumnState`
* below is the outbound half). Publishing it would put
* column widths and order in the designer panel and in the generated
* `sdui-intrinsics.d.ts` — i.e. bless hand-authoring a payload the product
* writes on the user's behalf.
*
* The contract says the same thing independently: `ComponentPropsMap
* ['object-grid']` (`@objectstack/spec@17`) is a `strictObject` and rejects
* `columnState` BY NAME (`unrecognized_keys`), so an author who took the
* offer could not save the document. Both halves — unlisted here, rejected
* there — are pinned by `__tests__/gridNonAuthorKeys.test.tsx`, together with
* the reads themselves, so this exemption cannot decay into a silent drop.
*/
const [columnState, setColumnState] = useState<ObjectGridColumnState>(() => {
// Priority: 1) externally provided (e.g. persisted view override),
// 2) localStorage (per-browser fallback), 3) empty.
const fromProps = (schema as any).columnState;
if (fromProps && typeof fromProps === 'object') return fromProps;
try {
const saved = localStorage.getItem(columnStorageKey);
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
// Pull to refresh and the card-view breakpoint, in hook order (`./objectGridMobile`).
const { pullRef, isRefreshing, pullDistance } = useObjectGridMobile({
schema, dataSource, setRefreshKey, setUseCardView,
});

// Sync when external columnState changes (e.g. switching views, reload pulls
// a saved override from the server). Wrapped in a stable string key to
// avoid re-renders when the parent passes a fresh-but-equal object.
// Non-author surface, same ruling as the seed above (objectui#5091): these
// two reads are the host's inbound channel, never an authored key.
const externalColumnStateKey = React.useMemo(
() => JSON.stringify((schema as any).columnState ?? null),
[(schema as any).columnState]
);
React.useEffect(() => {
const fromProps = (schema as any).columnState;
if (fromProps && typeof fromProps === 'object') {
setColumnState(fromProps);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalColumnStateKey]);

const saveColumnState = useCallback((state: typeof columnState) => {
setColumnState(state);
try {
localStorage.setItem(columnStorageKey, JSON.stringify(state));
} catch (e) {
console.warn('Failed to persist column state:', e);
}
// Notify the host so it can persist the layout (the console stores it on
// the signed-in user's own list state).
if (typeof onColumnStateChange === 'function') {
try { onColumnStateChange(state); } catch (e) { console.warn('onColumnStateChange threw:', e); }
}
}, [columnStorageKey, onColumnStateChange]);

const handlePullRefresh = useCallback(async () => {
setRefreshKey(k => k + 1);
}, []);

const { ref: pullRef, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
onRefresh: handlePullRefresh,
enabled: !!dataSource && !!schema.objectName,
});

// Activate the mobile card view below the 768px app mobile breakpoint so
// phones and tablet-portrait never need to side-scroll a wide grid.
useEffect(() => {
const checkWidth = () => setUseCardView(window.innerWidth < 768);
checkWidth();
window.addEventListener('resize', checkWidth);
return () => window.removeEventListener('resize', checkWidth);
}, []);

// `passedData` — data handed down directly (from ListView) — is destructured
// from props above.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
// packages/plugin-grid/src/__tests__ -> repo root
const repoRoot = path.resolve(here, '../../../..');
const GRID_SOURCE = path.join(repoRoot, 'packages/plugin-grid/src/ObjectGrid.tsx');
const COLUMN_STATE_SOURCE = path.join(repoRoot, 'packages/plugin-grid/src/objectGridColumnState.ts');
const TYPES_SOURCE = path.join(repoRoot, 'packages/types/src/objectql.ts');

const gridLines = readFileSync(GRID_SOURCE, 'utf8').split('\n');
Expand Down Expand Up @@ -844,7 +845,7 @@ describe('`onNavigate` — the exemption is DOCUMENTED, which is what this card
// The control for the two cases above: they assert "a comment is present",
// which would also pass if this file had quietly stopped being able to see
// comments at all. The four older exemptions are the independent witness.
const gridSource = gridLines.join('\n');
const gridSource = [gridLines.join('\n'), readFileSync(COLUMN_STATE_SOURCE, 'utf8')].join('\n');
for (const { key } of NON_AUTHOR_KEYS) {
expect(
gridSource,
Expand Down
98 changes: 98 additions & 0 deletions ui/packages/plugin-grid/src/objectGridColumnState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* The column-state persistence of `ObjectGrid` (`./ObjectGrid.tsx`): the
* storage key, the `columnState` seed with the host's inbound channel, the
* sync of an external `columnState` and the outbound `saveColumnState`, moved
* out of the component verbatim (objectstack#25994, slice 7). The
* NON-AUTHOR SURFACE comment stays directly above the seed it documents.
*/

import React, { useState, useCallback } from 'react';
import type { ObjectGridSchema } from '@object-ui/types';
import type { ObjectGridColumnState, ObjectGridExternalPaginationProps } from './objectGridProps';

/**
* `ObjectGrid`'s column order and widths: the persisted state and the save
* the column handlers call. `ObjectGrid` calls it where these lines used to
* stand, so the order of the component's hooks does not change.
*/
export function useObjectGridColumnState({
schema, onColumnStateChange,
}: {
schema: ObjectGridSchema;
onColumnStateChange: ObjectGridExternalPaginationProps['onColumnStateChange'];
}) {
// Column state persistence (order and widths)
const columnStorageKey = React.useMemo(() => {
return schema.id
? `grid-columns-${schema.objectName}-${schema.id}`
: `grid-columns-${schema.objectName}`;
}, [schema.objectName, schema.id]);

/**
* NON-AUTHOR SURFACE — `columnState` is deliberately absent from
* `GRID_QUERY_INPUTS` (`index.tsx`), by the maintainer ruling of 2026-08-18
* on objectui#5091. The cast reads in this block are therefore DELIBERATELY
* unlisted, not missed.
*
* It is the USER-STATE persistence payload, not authoring surface: the host
* hands in whatever the user last saved (app-shell's `ObjectView` reads it
* off the user's own list state, then off the view def) and stores their
* drags back on that state (`persistListColumnState` there; `saveColumnState`
* below is the outbound half). Publishing it would put
* column widths and order in the designer panel and in the generated
* `sdui-intrinsics.d.ts` — i.e. bless hand-authoring a payload the product
* writes on the user's behalf.
*
* The contract says the same thing independently: `ComponentPropsMap
* ['object-grid']` (`@objectstack/spec@17`) is a `strictObject` and rejects
* `columnState` BY NAME (`unrecognized_keys`), so an author who took the
* offer could not save the document. Both halves — unlisted here, rejected
* there — are pinned by `__tests__/gridNonAuthorKeys.test.tsx`, together with
* the reads themselves, so this exemption cannot decay into a silent drop.
*/
const [columnState, setColumnState] = useState<ObjectGridColumnState>(() => {
// Priority: 1) externally provided (e.g. persisted view override),
// 2) localStorage (per-browser fallback), 3) empty.
const fromProps = (schema as any).columnState;
if (fromProps && typeof fromProps === 'object') return fromProps;
try {
const saved = localStorage.getItem(columnStorageKey);
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
});

// Sync when external columnState changes (e.g. switching views, reload pulls
// a saved override from the server). Wrapped in a stable string key to
// avoid re-renders when the parent passes a fresh-but-equal object.
// Non-author surface, same ruling as the seed above (objectui#5091): these
// two reads are the host's inbound channel, never an authored key.
const externalColumnStateKey = React.useMemo(
() => JSON.stringify((schema as any).columnState ?? null),
[(schema as any).columnState]
);
React.useEffect(() => {
const fromProps = (schema as any).columnState;
if (fromProps && typeof fromProps === 'object') {
setColumnState(fromProps);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalColumnStateKey]);

const saveColumnState = useCallback((state: typeof columnState) => {
setColumnState(state);
try {
localStorage.setItem(columnStorageKey, JSON.stringify(state));
} catch (e) {
console.warn('Failed to persist column state:', e);
}
// Notify the host so it can persist the layout (the console stores it on
// the signed-in user's own list state).
if (typeof onColumnStateChange === 'function') {
try { onColumnStateChange(state); } catch (e) { console.warn('onColumnStateChange threw:', e); }
}
}, [columnStorageKey, onColumnStateChange]);

return { columnState, saveColumnState };
}
44 changes: 44 additions & 0 deletions ui/packages/plugin-grid/src/objectGridMobile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* The mobile affordances of `ObjectGrid` (`./ObjectGrid.tsx`): pull to
* refresh and the card-view breakpoint effect, moved out of the component
* verbatim (objectstack#25994, slice 7). The refresh key and the card-view
* flag stay component state; their setters are passed in.
*/

import React, { useEffect, useCallback } from 'react';
import type { ObjectGridSchema, DataSource } from '@object-ui/types';
import { usePullToRefresh } from '@object-ui/mobile';

/**
* Wires `ObjectGrid`'s pull to refresh and its card-view breakpoint. Returns
* what the grid's scroll container reads. `ObjectGrid` calls it where these
* lines used to stand, so the order of the component's hooks does not change.
*/
export function useObjectGridMobile({
schema, dataSource, setRefreshKey, setUseCardView,
}: {
schema: ObjectGridSchema;
dataSource: DataSource | undefined;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
setUseCardView: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const handlePullRefresh = useCallback(async () => {
setRefreshKey(k => k + 1);
}, []);

const { ref: pullRef, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
onRefresh: handlePullRefresh,
enabled: !!dataSource && !!schema.objectName,
});

// Activate the mobile card view below the 768px app mobile breakpoint so
// phones and tablet-portrait never need to side-scroll a wide grid.
useEffect(() => {
const checkWidth = () => setUseCardView(window.innerWidth < 768);
checkWidth();
window.addEventListener('resize', checkWidth);
return () => window.removeEventListener('resize', checkWidth);
}, []);

return { pullRef, isRefreshing, pullDistance };
}
36 changes: 36 additions & 0 deletions ui/packages/plugin-grid/src/objectGridRowHeight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* The row-height sync of `ObjectGrid` (`./ObjectGrid.tsx`): the effect that
* follows a changed `schema.rowHeight` into the grid's own row-height state,
* moved out of the component verbatim (objectstack#25994, slice 7). The state
* and its setter stay in the component, which passes them in.
*/

import React from 'react';
import type { ObjectGridSchema } from '@object-ui/types';
import { type RowHeightMode, resolveRowHeightMode } from './objectGridDefaults';

/**
* Runs `ObjectGrid`'s row-height sync effect. It declares no state and returns
* nothing. `ObjectGrid` calls it where the effect used to stand, so the order
* of the component's hooks does not change.
*/
export function useObjectGridRowHeightSync({
schema, rowHeightMode, setRowHeightMode,
}: {
schema: ObjectGridSchema;
rowHeightMode: RowHeightMode;
setRowHeightMode: React.Dispatch<React.SetStateAction<RowHeightMode>>;
}): void {
// Sync internal rowHeightMode when schema.rowHeight prop changes (e.g., parent ListView density toggle).
// Routed through the same resolver as the seed above: this is the component's
// second entry point for an author-supplied `rowHeight`, and one resolver at
// every entry is what keeps the answer single (objectui#4443).
React.useEffect(() => {
if (!schema.rowHeight) return;
const next = resolveRowHeightMode(schema.rowHeight);
if (next !== rowHeightMode) {
setRowHeightMode(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schema.rowHeight]);
}
Loading