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
14 changes: 14 additions & 0 deletions .changeset/25994-objectgrid-props-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-grid': patch
---

refactor(plugin-grid): `ObjectGrid`'s host-facing props types and its footer-summary reporter move into sibling modules

Clause-②: no
Metadata-protocol: none

`ObjectGridColumnState`, `ObjectGridFooterSummary` and `ObjectGridExternalPaginationProps` move verbatim out of `ObjectGrid.tsx` into `objectGridProps.ts`, and the renderless `FooterSummaryReport` moves into `objectGridFooterSummaryReport.tsx`. No logic, name or rendered output changes. `ObjectGrid.tsx` re-exports the three types, so every name it exported before is still importable from the same path, and the package entry is unchanged.

Correction to the slice 1 note: the record-source ladder (`getDataConfig`) stayed in `ObjectGrid.tsx`; it moved in neither slice.

What to do: nothing.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-grid.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-grid","sources":44,"sha256":"afbc05964d76c725b0d533f1bd28b1515591d0748eca468158f64fc3bb7f7a80","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-grid","sources":46,"sha256":"2c2577a854216b798b363a8751d8924e3e9205404c0eb592b5f3d53197cd3bc0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
150 changes: 3 additions & 147 deletions ui/packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ 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';
import { type ObjectGridColumnState, type ObjectGridExternalPaginationProps, type ObjectGridFooterSummary } from './objectGridProps';
import { FooterSummaryReport } from './objectGridFooterSummaryReport';
export type { RetiredListColumnKey, ObjectGridColumnHolds, ObjectGridColumnDraft, ObjectGridColumn } from './objectGridColumns';
export type { ObjectGridColumnState, ObjectGridFooterSummary, ObjectGridExternalPaginationProps } from './objectGridProps';

/**
* A declared `sort` → the `"field order"` join string THIS block sends as
Expand Down Expand Up @@ -172,127 +175,6 @@ export function parseSchemaSort(sort: unknown): TableSortItem[] {
return items;
}

/**
* The column layout ObjectGrid persists and reports back — the merged result of
* a resize and a reorder, not either event on its own. Named so the state hook
* below and the `onColumnStateChange` prop cannot drift apart.
*/
export interface ObjectGridColumnState {
order?: string[];
widths?: Record<string, number>;
}

/**
* One column summary as the grid's footer would draw it — `header: label`,
* e.g. `Annual Revenue` and `Sum: 109,800,000.00` — handed to a host through
* {@link ObjectGridComponentProps.onColumnSummaryChange} (objectui#12528).
*/
export interface ObjectGridFooterSummary {
/** The column's accessor key. */
key: string;
/** The column's header text. */
header: string;
/** The formatted summary, scope included (`Sum: 109,800,000.00`). */
label: string;
}

/**
* The HOST-DRIVEN ("external") mode of ObjectGrid — framework#2212.
*
* The ordinary authoring surface hands ObjectGrid a `schema` and a `dataSource`
* and lets it fetch, page, sort and search for itself. In this mode a host
* (ListView, a designer preview, an app screen with its own toolbar) has
* already fetched one window of a larger collection and drives the controls
* itself: it passes the window as `data` plus the real match total and the
* page/sort/search state, and ObjectGrid forwards them straight to its
* DataTable instead of client-slicing the window it was handed.
*
* Kept as its own named interface rather than flattened into `ObjectGridComponentProps`
* (#4277 裁决 B, 2026-08-11): the two are different classes of contract, and a
* dozen more members merged into the authoring surface would erase that
* boundary. Until this existed, every member below was read out of `...rest`
* through an `as any` cast and was declared nowhere at all.
*
* DERIVATION (#4277 裁决 §3, the anti-drift pin): this vocabulary is already
* declared once, on `DataTableSchema` — which is exactly where ObjectGrid
* forwards it — so the members that have a counterpart there are TYPE-DERIVED
* from that declaration rather than hand-copied into a second enumeration. Two
* hand-written copies of one vocabulary is how the next drift happens. The
* `Partial<...>` wrapper is deliberate and is the only shape change: the whole
* mode is opt-in, and `DataTableSchema['data']` is required because a table
* always has rows, while a grid that was given no `data` fetches its own.
*
* `search` is read outside this mode too (objectui#11021): a host that passes
* it owns the search term even where the grid fetches for itself, and the
* grid's own queries carry it. `ListView` hands its toolbar term to a grid
* that groups on the server that way, with no rows.
*/
export interface ObjectGridExternalPaginationProps
extends Partial<
Pick<
DataTableSchema,
// The host's already-fetched window. Highest-priority data source: it
// wins over `schema.data` / `schema.bind` when present.
| 'data'
// Turns off client slicing. With `rowCount` + `onPageChange` it is what
// makes the mode active at all (see `externalManualPagination` below).
| 'manualPagination'
| 'rowCount'
| 'page'
| 'pageSize'
| 'onPageChange'
| 'onPageSizeChange'
| 'sort'
| 'onSortChange'
| 'search'
| 'onSearchChange'
>
> {
/**
* Grid-only: `DataTableSchema` has no counterpart to derive from.
*
* The table vocabulary reports column changes as separate per-event
* callbacks — `onColumnResize(columnKey, width)` and
* `onColumnReorder(newOrder)` — whereas this reports the MERGED, persisted
* `{ order, widths }` layout after ObjectGrid has folded either event into
* the state it also writes to `localStorage`, so a host can save one blob
* (the console stores it on the signed-in user's own list state). Deriving
* it from either table
* callback would misstate both the payload and when it fires.
*/
onColumnStateChange?: (state: ObjectGridColumnState) => void;

/**
* Grid-only: `DataTableSchema` has no counterpart to derive from — a table is
* handed rows, it never issues a query.
*
* The params the host passed to `dataSource.find()` for the window it is
* handing down in `data` — the SAME shape this grid's own loader stores in
* `lastFindParamsRef` (`$filter` / `$orderby` / `$select` / `$search` /
* `$searchFields` / `$expand` / `$top` / `$skip`), so both paths feed one
* reader. `$top`/`$skip` may be present and are ignored: the fan-out windows
* the replay itself.
*
* Why a prop rather than a fallback inside the grid (objectui#4501): the
* cross-page "select all N matching" escalation re-issues the view's query to
* collect the whole match set, and the query has to come from whichever side
* owns the fetch. Under external pagination that is the host, and the grid's
* own ref is either empty or stale — replaying it asked the server for the
* WHOLE OBJECT (no `$filter`) and handed up to 5000 unmatched records to a
* destructive executor. A grid-side `?? {}` default is what produced that,
* which is why the missing case is not defaulted but REFUSED: with no params
* for the current data path the escalation is not offered at all (see
* `bulkFanoutParams` in the component body).
*
* A changed value is also the host's query-change signal: it resets the
* escalation, mirroring the `setSelectAllMatching(false)` the internal loader
* runs next to its own `lastFindParamsRef` write. Compared by CONTENT, so a
* host re-render that rebuilds an equal object does not drop the user's
* escalation.
*/
findParams?: Record<string, unknown> | null;
}

/**
* Props of the `ObjectGrid` React component.
*
Expand Down Expand Up @@ -735,32 +617,6 @@ export type ObjectGridDataTableSchemaHolds = {
export type ObjectGridDataTableSchema =
DeclaredDataTableSchema & ObjectGridDataTableSchemaHolds;

/**
* [objectui#12528] Reports the grid's column summaries to a host that draws
* them in its own footer row (`onColumnSummaryChange`). Renders nothing.
*
* Keyed on the summaries' TEXT, never on the array's identity (AGENTS.md #10):
* every grid render builds a new array, and an equal one must send nothing.
* Unmounting — whatever stops the grid drawing the row — reports `null`.
*/
function FooterSummaryReport({
summaries,
onChange,
}: {
summaries: ObjectGridFooterSummary[];
onChange: (summaries: readonly ObjectGridFooterSummary[] | null) => void;
}) {
const key = JSON.stringify(summaries);
const onChangeRef = React.useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
useEffect(() => {
onChangeRef.current(JSON.parse(key) as ObjectGridFooterSummary[]);
}, [key]);
useEffect(() => () => onChangeRef.current(null), []);
return null;
}

/**
* [objectui#13234] The face a date-family CELL paints, for one grouping field:
Expand Down
35 changes: 35 additions & 0 deletions ui/packages/plugin-grid/src/objectGridFooterSummaryReport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* `FooterSummaryReport`, the renderless reporter `ObjectGrid` (`./ObjectGrid.tsx`)
* mounts to hand its column summaries to a host, moved out of that file
* verbatim (objectstack#25994, slice 2).
*/

import React, { useEffect } from 'react';
import type { ObjectGridFooterSummary } from './objectGridProps';

/**
* [objectui#12528] Reports the grid's column summaries to a host that draws
* them in its own footer row (`onColumnSummaryChange`). Renders nothing.
*
* Keyed on the summaries' TEXT, never on the array's identity (AGENTS.md #10):
* every grid render builds a new array, and an equal one must send nothing.
* Unmounting — whatever stops the grid drawing the row — reports `null`.
*/
export function FooterSummaryReport({
summaries,
onChange,
}: {
summaries: ObjectGridFooterSummary[];
onChange: (summaries: readonly ObjectGridFooterSummary[] | null) => void;
}) {
const key = JSON.stringify(summaries);
const onChangeRef = React.useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
useEffect(() => {
onChangeRef.current(JSON.parse(key) as ObjectGridFooterSummary[]);
}, [key]);
useEffect(() => () => onChangeRef.current(null), []);
return null;
}
130 changes: 130 additions & 0 deletions ui/packages/plugin-grid/src/objectGridProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* The host-facing props types of `ObjectGrid` (`./ObjectGrid.tsx`) that
* `ObjectGridComponentProps` builds on: the persisted column layout, one footer
* summary, and the host-driven pagination props, moved out of that file
* verbatim (objectstack#25994, slice 2). `ObjectGrid.tsx` re-exports every
* name it exported before.
*/

import type { DataTableSchema } from '@object-ui/types';

/**
* The column layout ObjectGrid persists and reports back — the merged result of
* a resize and a reorder, not either event on its own. Named so the state hook
* below and the `onColumnStateChange` prop cannot drift apart.
*/
export interface ObjectGridColumnState {
order?: string[];
widths?: Record<string, number>;
}

/**
* One column summary as the grid's footer would draw it — `header: label`,
* e.g. `Annual Revenue` and `Sum: 109,800,000.00` — handed to a host through
* {@link ObjectGridComponentProps.onColumnSummaryChange} (objectui#12528).
*/
export interface ObjectGridFooterSummary {
/** The column's accessor key. */
key: string;
/** The column's header text. */
header: string;
/** The formatted summary, scope included (`Sum: 109,800,000.00`). */
label: string;
}

/**
* The HOST-DRIVEN ("external") mode of ObjectGrid — framework#2212.
*
* The ordinary authoring surface hands ObjectGrid a `schema` and a `dataSource`
* and lets it fetch, page, sort and search for itself. In this mode a host
* (ListView, a designer preview, an app screen with its own toolbar) has
* already fetched one window of a larger collection and drives the controls
* itself: it passes the window as `data` plus the real match total and the
* page/sort/search state, and ObjectGrid forwards them straight to its
* DataTable instead of client-slicing the window it was handed.
*
* Kept as its own named interface rather than flattened into `ObjectGridComponentProps`
* (#4277 裁决 B, 2026-08-11): the two are different classes of contract, and a
* dozen more members merged into the authoring surface would erase that
* boundary. Until this existed, every member below was read out of `...rest`
* through an `as any` cast and was declared nowhere at all.
*
* DERIVATION (#4277 裁决 §3, the anti-drift pin): this vocabulary is already
* declared once, on `DataTableSchema` — which is exactly where ObjectGrid
* forwards it — so the members that have a counterpart there are TYPE-DERIVED
* from that declaration rather than hand-copied into a second enumeration. Two
* hand-written copies of one vocabulary is how the next drift happens. The
* `Partial<...>` wrapper is deliberate and is the only shape change: the whole
* mode is opt-in, and `DataTableSchema['data']` is required because a table
* always has rows, while a grid that was given no `data` fetches its own.
*
* `search` is read outside this mode too (objectui#11021): a host that passes
* it owns the search term even where the grid fetches for itself, and the
* grid's own queries carry it. `ListView` hands its toolbar term to a grid
* that groups on the server that way, with no rows.
*/
export interface ObjectGridExternalPaginationProps
extends Partial<
Pick<
DataTableSchema,
// The host's already-fetched window. Highest-priority data source: it
// wins over `schema.data` / `schema.bind` when present.
| 'data'
// Turns off client slicing. With `rowCount` + `onPageChange` it is what
// makes the mode active at all (see `externalManualPagination` below).
| 'manualPagination'
| 'rowCount'
| 'page'
| 'pageSize'
| 'onPageChange'
| 'onPageSizeChange'
| 'sort'
| 'onSortChange'
| 'search'
| 'onSearchChange'
>
> {
/**
* Grid-only: `DataTableSchema` has no counterpart to derive from.
*
* The table vocabulary reports column changes as separate per-event
* callbacks — `onColumnResize(columnKey, width)` and
* `onColumnReorder(newOrder)` — whereas this reports the MERGED, persisted
* `{ order, widths }` layout after ObjectGrid has folded either event into
* the state it also writes to `localStorage`, so a host can save one blob
* (the console stores it on the signed-in user's own list state). Deriving
* it from either table
* callback would misstate both the payload and when it fires.
*/
onColumnStateChange?: (state: ObjectGridColumnState) => void;

/**
* Grid-only: `DataTableSchema` has no counterpart to derive from — a table is
* handed rows, it never issues a query.
*
* The params the host passed to `dataSource.find()` for the window it is
* handing down in `data` — the SAME shape this grid's own loader stores in
* `lastFindParamsRef` (`$filter` / `$orderby` / `$select` / `$search` /
* `$searchFields` / `$expand` / `$top` / `$skip`), so both paths feed one
* reader. `$top`/`$skip` may be present and are ignored: the fan-out windows
* the replay itself.
*
* Why a prop rather than a fallback inside the grid (objectui#4501): the
* cross-page "select all N matching" escalation re-issues the view's query to
* collect the whole match set, and the query has to come from whichever side
* owns the fetch. Under external pagination that is the host, and the grid's
* own ref is either empty or stale — replaying it asked the server for the
* WHOLE OBJECT (no `$filter`) and handed up to 5000 unmatched records to a
* destructive executor. A grid-side `?? {}` default is what produced that,
* which is why the missing case is not defaulted but REFUSED: with no params
* for the current data path the escalation is not offered at all (see
* `bulkFanoutParams` in the component body).
*
* A changed value is also the host's query-change signal: it resets the
* escalation, mirroring the `setSelectAllMatching(false)` the internal loader
* runs next to its own `lastFindParamsRef` write. Compared by CONTENT, so a
* host re-render that rebuilds an equal object does not drop the user's
* escalation.
*/
findParams?: Record<string, unknown> | null;
}
Loading