Skip to content

Commit f79ec28

Browse files
refactor(plugin-grid): move ObjectGrid's column generator, column post-processing and Actions column into sibling modules (slice 8) (#26806)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8KF8i7bmUmRJW1rodBW4KnP)_ Fixes #25994 (slice 8, the last slice of the move-only split: regions R, U and V. Slices 1 to 7 have landed; this one closes the card.) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:把 `ObjectGrid.tsx` 里最后三大块代码(列生成回调 `generateColumns`、列后处理与持久化列宽列序、操作列与固定列集合)原样搬进三个同目录模块,组件在原位置调用它们;文件从 5160 行降到 3962 行(拆分前 7689 行)。 - 为什么改:维护者裁决「大文件互踩: 立重构卡把 ObjectView 和 ObjectGrid 拆成小模块。这个可以立卡」;这是第 8 片也是最后一片,合并后关闭 #25994。 - 风险与回滚:纯搬移,无逻辑、命名、渲染变化,hook 调用顺序不变;五个读源码的测试只改了读取路径。有问题直接 revert 本 PR 即可。 - 先看:`objectGridGenerateColumns.tsx` 的签名与依赖数组(与原 `useCallback` 相同)、`ObjectGrid.tsx` 里三处调用、五个测试文件的路径改动。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-grid/ --maxWorkers=2`,预期 253 个文件、2256 个测试全绿,与 main 相同。 - 需要你做的:无,席位落地;合并即关闭 #25994。 ## What changes Base `7bdff7ec` (`origin/main`). `ui/packages/plugin-grid/src/ObjectGrid.tsx` is 5160 lines there. This slice moves regions **R**, **U** and **V** of the seam table in #26364 (re-planned in #26499), the last three blocks the card names, verbatim into three new sibling modules: | # | Concern | New home | Inputs | Returns | |---|---|---|---|---| | R | `generateColumns`, the `useCallback` that turns the authored columns, or the object's fields, into column drafts (base lines 1221 to 2098) | hook `useObjectGridGenerateColumns` in `objectGridGenerateColumns.tsx` | 18: `schema`, `dataSource`, `data`, `dataConfig`, `hasInlineData`, `objectName`, `objectSchema`, `objectFields`, `objectTypesPending`, `schemaFields`, `schemaColumns`, `perms`, `navigation`, `executeAction`, `predicateScope`, `resolveFieldLabel`, `translateOptions`, `uiLanguage` | `generateColumns` | | U | Column post-processing: field-type enrichment, the fold onto the declared column type, the user's persisted widths and order (base lines 2377 to 2557) | function `resolveObjectGridPersistedColumns` in `objectGridPersistedColumns.ts` | `generateColumns`, `objectFields`, `objectName`, `perms`, `objectTypesPending`, `columnState` | `persistedColumns` | | V | The Actions column, its NON-AUTHOR SURFACE comment on `maxInlineRowActions`, and the pinned column sets (base lines 2559 to 2719) | function `resolveObjectGridActionsColumn` in `objectGridActionsColumn.tsx` | 22, from `schema` to `t` (listed in the module signature) | `columnsWithActions`, `hasActions`, `hasPinnedColumns`, `hasUserPinnedColumns`, `pinnedLeftCols`, `pinnedRightCols`, `unpinnedCols`, `rightPinnedClasses`, `rightPinnedCellClasses`, `userLeftPinnedCount` | - R is a hook and keeps its `useCallback` with the same dependency array, called where the callback stood, so the component's hook order does not change. U and V stand below the component's early returns, so they are plain functions, as on slices 5 to 7. - Inputs are typed from their source: `ReturnType` of the hook or helper that produces them (`usePermissions`, `useNavigationOverlay`, `useAction`, `usePredicateScope`, `useSafeFieldLabel`, `useGridTranslation`, `useObjectGridQueryInputs`, `useObjectGridColumnState`, `useCapabilityGate`, `useRecordCrudVerdicts`, `resolveLegacyRowActions`, `resolveRowCrudAffordances`), `ObjectGridComponentProps` for the host callbacks, and React's `Dispatch` of `SetStateAction` for `setRefreshKey`. No context. - Each module returns only the names the rest of the component reads. - Imports: the specifiers whose only readers moved leave `ObjectGrid.tsx` and appear in the module that reads them; two whole imports (`applyRelationalMeta`, `resolvesToDataColumn`) move. The `export` and `export type` re-exports at the top of `ObjectGrid.tsx` are untouched, so the package entry and every exported name are unchanged. - `ObjectGrid.tsx` goes from 5160 to 3962 lines. The diff is 2674 changed lines in 10 files. **Move-only proof.** The multiset of non-blank lines of the base `ObjectGrid.tsx` equals the new `ObjectGrid.tsx` plus the three modules, except for 10 lines, all of them edited import lines in `ObjectGrid.tsx` that lost specifiers. Every added line is a module header or docblock, an import, a signature or parameter type, a `return { … }` list, or in `ObjectGrid.tsx` one of the three new imports and three calls with their comments. **The split, slices 1 to 8.** Line counts of `ObjectGrid.tsx` on `main` after each merge: | Slice | PR | Moved | New sibling modules | Lines after | |---|---|---|---|---| | (start) | | | | 7689 | | 1 | #26323 | module-level helpers, types and constants | `objectGridColumns.ts`, `objectGridDefaults.ts`, `objectGridLinkCell.tsx`, `objectGridQuery.ts`, `objectGridRowVersion.ts`, `objectGridTranslations.ts` | 6781 | | 2 | #26354 | host-facing props types and `FooterSummaryReport` | `objectGridProps.ts` | 6637 | | 3 | #26364 | authored-input diagnostics (seam table) | `objectGridDiagnostics.ts` | 6571 | | 4 | #26499 | query inputs and grouping decisions (re-plan) | `objectGridQueryInputs.ts`, `objectGridGroupingDecisions.ts` | 6307 | | 5 | #26537 | row load | `objectGridRowLoad.ts` | 5639 | | 6 | #26574 | grouping and column summaries | `objectGridGrouping.ts`, `objectGridSummaries.ts` | 5250 | | 7 | #26773 | row-height sync, column state, mobile affordances | `objectGridRowHeight.ts`, `objectGridColumnState.ts`, `objectGridMobile.ts` | 5160 | | 8 | this PR | column generator, column post-processing, Actions column | `objectGridGenerateColumns.tsx`, `objectGridPersistedColumns.ts`, `objectGridActionsColumn.tsx` | **3962** | Final count: `ObjectGrid.tsx` is 3962 lines, down from 7689 (48% smaller), with 19 sibling modules beside it. What remains is the component itself (state, the remaining handlers, the render) and the re-exports. **Open PRs on these files.** Of the 26 open PRs, one touches `ObjectGrid.tsx`: #26804 (opened after this slice's claim), with hunks at the components import, the `useOverlayAnchor` call and the record-overlay props, none of them inside R, U or V. `git merge-tree` of this head with #26804's head merges with no conflict. No open PR touches the five test files or the three new modules. ## Why The card's ruling, verbatim: 「大文件互踩: 立重构卡把 ObjectView 和 ObjectGrid 拆成小模块。这个可以立卡」. One move-only slice per PR, per the card's ask 1 to 5; this is the last slice named in hand-off 6438136564 and dispatch order 6446731987, so the PR closes the card. ## Tests Five test edits, path-only, forced by the move. Each joins the moved file to the source it already read; no assertion, pattern or count changed. - `ui/packages/plugin-grid/src/__tests__/columnReadBoundary-6458.test.ts`: `gridSource` reads `objectGridGenerateColumns.tsx` and `ObjectGrid.tsx` joined, in that order. The `ListColumn` branch of `generateColumns()` that the scan bounds moved with R, and the order keeps the `(schema as any).hideRowHeightToggle` control after the region end. - `ui/packages/plugin-grid/src/__tests__/cellRendererResolutionBoundary-8920.test.ts`: the "ObjectGrid.tsx makes no direct resolver call" case reads `ObjectGrid.tsx` and `objectGridGenerateColumns.tsx` joined, because the call sites it guards moved with R. - `ui/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts`: the LIT CONTROL's `wholeFile` reads both files joined, because most `fieldMeta` reads moved with R. The ` renderCellEditor:` seam stays in `ObjectGrid.tsx`, so the zero it asserts is unchanged. - `ui/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx`: the control case also reads `objectGridActionsColumn.tsx`, because the NON-AUTHOR SURFACE comment on `maxInlineRowActions` moved with V. - `ui/packages/core/src/utils/__tests__/column-identity.ratchet.test.ts`: the CONVERGED entry follows `columnIdentity(` from `plugin-grid/src/ObjectGrid.tsx` to `plugin-grid/src/objectGridGenerateColumns.tsx`. `percent-storage-callers-11475` needs no edit: `formatPercent` stays in the mobile card in `ObjectGrid.tsx`, and none of the moved lines call it. I matched every string literal of every tracked test and script against the lines that left `ObjectGrid.tsx`; the other hits were generic words or behavioural tests, and all of them ran green below. ## Acceptance notes - Some parameter types are `any`, copied from the component's own declarations: `data` is an any-typed array and `objectSchema` is `any` in the component's `useState` calls, and `objectFields` is declared in the component as a record of `any` or undefined. The new parameters spell those same types, which is the 6 new `no-explicit-any` warnings. Narrowing them would go beyond a move. - eslint on the four files: 0 errors; warnings 179 before, 185 after (the 6 above). Unlike the earlier hook slices, no React lint finding is hidden this time: the `exhaustive-deps` finding on `generateColumns` (missing `navigation` and `schema.showColumnTypeIcons`) is raised in the new module exactly as before, and the other findings are the same at shifted lines. - R's module is `.tsx` and V's is `.tsx` because they hold JSX; U's is `.ts`. The claim said `objectGrid*.ts`. - Moved comments that point "below" or "above" are kept verbatim. Most remain true inside the module; `renderCellEditor` "(below)" in `objectGridPersistedColumns.ts` now lives in `ObjectGrid.tsx`. - `objectGridActionsColumn.tsx` imports `ObjectGridComponentProps` from `./ObjectGrid` as a type only, so no runtime cycle is added. ## Verification Run on head `ab6ff716`: - `pnpm --dir ui exec vitest run packages/plugin-grid/ --maxWorkers=2` under the verification lock: 253 of 253 files, 2256 of 2256 tests after the move. Before: the same command on base `7bdff7ec` (a separate worktree, deps built): 253 of 253 files, 2256 of 2256 tests. - The 109 test files outside plugin-grid that read source trees (every ui test that reads files and names plugin-grid or walks the package roots, including `column-identity.ratchet`, `percent-storage-callers-11475`, `affordanceGrantMap-12082`, `catalog-gallery-render` and `registry-inputs-spec-parity`): 109 of 109 files, 3413 of 3413 tests. - `pnpm --filter @object-ui/plugin-grid type-check` exit 0, after `turbo run build --filter='@object-ui/plugin-grid^...'`. - The Type Check UI job's 17 `check:*` steps plus `type-check:coverage`: all exit 0 (`check:spec-symbols` and `check:doc-example-readers` included; no new local type shares a spec name, and no single-return wrapper is exported). `pnpm check:changeset-fixed` exit 0. - `pnpm --dir ui run check:new-line-citations` (report-only, exit 0) reports 1 new citation: `apps/console/src/dev/DevRowActions.tsx:51` in `objectGridActionsColumn.tsx`. It is not new prose; the comment moved verbatim with V, and the gate counts it because it changed files. It is kept as it was, since rewording it would not be move-only. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 derived commands, all exit 0. `pnpm --dir ui exec vitest run scripts/` under the lock: 178 passed and 2 skipped files, 5065 passed and 2 skipped tests. Its first run hit a 15-second timeout in one `check-i18n-dead-keys` real-tree case while the gate list ran beside it; the rerun alone was green. `--ran` reconciles 35 of 35 (0 NOT-MEASURED, 0 UNRUN); the Type Check UI commands above are recorded as outside the derivation. Changeset: `.changeset/25994-objectgrid-columns-modules.md` (patch, `@object-ui/plugin-grid`). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the touched files reports 0 errors. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer quick-read section and the quoted ruling are Chinese by rule). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbXqCPeUA7ztE9YWKwP --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXqCPeUA7ztE9YWKwP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ea90c7 commit f79ec28

10 files changed

Lines changed: 1435 additions & 1239 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
refactor(plugin-grid): `ObjectGrid`'s column generator, column post-processing and Actions column move into three sibling modules
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The last three blocks of the move-only split leave `ObjectGrid.tsx` verbatim. `generateColumns`, which turns the authored columns or the object's fields into column drafts, goes to the hook `useObjectGridGenerateColumns` (`objectGridGenerateColumns.tsx`). The column post-processing (field-type enrichment, the fold onto the declared column type, and the user's persisted widths and order) goes to `resolveObjectGridPersistedColumns` (`objectGridPersistedColumns.ts`). The Actions column and the pinned column sets go to `resolveObjectGridActionsColumn` (`objectGridActionsColumn.tsx`). `ObjectGrid` calls each where its block stood, so its hook order, its columns and its rendered output do not change. No name is renamed, and the package entry is unchanged.
11+
12+
What to do: nothing.

‎ui/packages/core/src/utils/__tests__/column-identity.ratchet.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ describe('column identity dual-read ratchet (#3104)', () => {
284284
const CONVERGED = [
285285
'core/src/utils/expand-fields.ts',
286286
'plugin-list/src/ListView.tsx',
287-
'plugin-grid/src/ObjectGrid.tsx',
287+
'plugin-grid/src/objectGridGenerateColumns.tsx',
288288
'plugin-detail/src/RelatedList.tsx',
289289
'plugin-detail/src/renderers/record-details.tsx',
290290
'plugin-detail/src/renderers/record-related-list.tsx',

‎ui/packages/plugin-grid/src/ObjectGrid.tsx‎

Lines changed: 36 additions & 1234 deletions
Large diffs are not rendered by default.

‎ui/packages/plugin-grid/src/__tests__/cellRendererResolutionBoundary-8920.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
5252
const SRC = path.resolve(here, '..');
5353
const HELPER = 'cellRendererResolution.ts';
5454
const GRID = 'ObjectGrid.tsx';
55+
const GENERATE_COLUMNS = 'objectGridGenerateColumns.tsx';
5556

5657
/**
5758
* Every non-test source file under `packages/plugin-grid/src`, as repo-relative
@@ -144,7 +145,7 @@ describe('objectui#8920 — one owner for the grid\'s cell-renderer resolution',
144145
it('⭐ ObjectGrid.tsx makes no direct resolver call', () => {
145146
// The card's own subject: six sites in this one file, three conventions.
146147
expect(
147-
directCalls(read(GRID)),
148+
directCalls([GRID, GENERATE_COLUMNS].map(read).join('\n')),
148149
'ObjectGrid.tsx called a published resolver directly. That is how a ' +
149150
'`format`-hinted textual column lost its renderer in four of six ' +
150151
'sites (objectui#8920): `getCellRenderer(field.type)` type-checks and ' +

‎ui/packages/plugin-grid/src/__tests__/columnReadBoundary-6458.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,9 @@ const here = path.dirname(fileURLToPath(import.meta.url));
9595
// packages/plugin-grid/src/__tests__ -> repo root
9696
const repoRoot = path.resolve(here, '../../../..');
9797
const GRID_SOURCE = path.join(repoRoot, 'packages/plugin-grid/src/ObjectGrid.tsx');
98+
const GENERATE_COLUMNS_SOURCE = path.join(repoRoot, 'packages/plugin-grid/src/objectGridGenerateColumns.tsx');
9899

99-
const gridSource = readFileSync(GRID_SOURCE, 'utf8');
100+
const gridSource = [GENERATE_COLUMNS_SOURCE, GRID_SOURCE].map((file) => readFileSync(file, 'utf8')).join('\n');
100101

101102
/**
102103
* The bounds of the `ListColumn` branch of `generateColumns()`. The scan is

‎ui/packages/plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
639639
const repoRoot = path.resolve(here, '../../../..');
640640
const GRID_SOURCE = path.join(repoRoot, 'packages/plugin-grid/src/ObjectGrid.tsx');
641641
const COLUMN_STATE_SOURCE = path.join(repoRoot, 'packages/plugin-grid/src/objectGridColumnState.ts');
642+
const ACTIONS_COLUMN_SOURCE = path.join(repoRoot, 'packages/plugin-grid/src/objectGridActionsColumn.tsx');
642643
const TYPES_SOURCE = path.join(repoRoot, 'packages/types/src/objectql.ts');
643644

644645
const gridLines = readFileSync(GRID_SOURCE, 'utf8').split('\n');
@@ -845,7 +846,7 @@ describe('`onNavigate` — the exemption is DOCUMENTED, which is what this card
845846
// The control for the two cases above: they assert "a comment is present",
846847
// which would also pass if this file had quietly stopped being able to see
847848
// comments at all. The four older exemptions are the independent witness.
848-
const gridSource = [gridLines.join('\n'), readFileSync(COLUMN_STATE_SOURCE, 'utf8')].join('\n');
849+
const gridSource = [gridLines.join('\n'), readFileSync(COLUMN_STATE_SOURCE, 'utf8'), readFileSync(ACTIONS_COLUMN_SOURCE, 'utf8')].join('\n');
849850
for (const { key } of NON_AUTHOR_KEYS) {
850851
expect(
851852
gridSource,

‎ui/packages/plugin-grid/src/__tests__/relationalMetaCopySet.derivation.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,9 @@ describe('objectui#6875 — the copy set is derived from the consumers, not rest
338338
expect(body).toContain('FieldEditWidget');
339339
// LIT CONTROL for the zero: the identifier IS all over this file, so the
340340
// instrument can see it. Only the editor seam is free of it.
341-
const wholeFile = readFileSync(path.join(GRID_SRC, 'ObjectGrid.tsx'), 'utf8');
341+
const wholeFile = ['ObjectGrid.tsx', 'objectGridGenerateColumns.tsx']
342+
.map((file) => readFileSync(path.join(GRID_SRC, file), 'utf8'))
343+
.join('\n');
342344
expect(wholeFile.split('fieldMeta').length - 1).toBeGreaterThan(10);
343345
expect(
344346
body.split('fieldMeta').length - 1,
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* The Actions column of `ObjectGrid` (`./ObjectGrid.tsx`) and the pinned column
3+
* sets it joins, moved out of the component verbatim (objectstack#25994, slice
4+
* 8). A plain function: it runs below the component's early returns.
5+
*/
6+
7+
import React from 'react';
8+
import type { ObjectGridSchema } from '@object-ui/types';
9+
import { useAction, usePredicateScope, useCapabilityGate } from '@object-ui/react';
10+
import { PINNED_RIGHT_SEAM_SHADOW_CLASSES } from '@object-ui/components';
11+
import { resolveRowRecordCrudAffordance } from './rowCrudAffordances';
12+
import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts';
13+
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
14+
import { RowActionMenu, planRowActionMenu } from './components/RowActionMenu';
15+
import { rowRecordId } from './objectGridLinkCell';
16+
import { useGridTranslation } from './objectGridTranslations';
17+
import type { resolveObjectGridPersistedColumns } from './objectGridPersistedColumns';
18+
import type { resolveRowCrudAffordances } from './rowCrudAffordances';
19+
import type { ObjectGridComponentProps } from './ObjectGrid';
20+
21+
type RowCrudAffordances = ReturnType<typeof resolveRowCrudAffordances>;
22+
23+
/**
24+
* The persisted columns plus the Actions column when a row on screen offers an
25+
* action, split into the pinned and unpinned sets the table layout reads.
26+
* `ObjectGrid` calls it where these lines used to stand.
27+
*/
28+
export function resolveObjectGridActionsColumn({
29+
schema, data, objectSchema, persistedColumns, operations, customRowActions, resolvedRowActionDefs,
30+
wantEditAction, wantDeleteAction, mayInvokeRowAction, rowOperations, canEdit, canDelete,
31+
recordVerdict, predicateScope, onEdit, onDelete, editPredicates, deletePredicates, executeAction,
32+
setRefreshKey, t,
33+
}: {
34+
schema: ObjectGridSchema;
35+
data: any[];
36+
objectSchema: any;
37+
persistedColumns: ReturnType<typeof resolveObjectGridPersistedColumns>['persistedColumns'];
38+
operations: ObjectGridSchema['operations'] | { update: boolean; delete: boolean };
39+
customRowActions: ReturnType<typeof resolveLegacyRowActions>['unresolved'];
40+
resolvedRowActionDefs: ReturnType<typeof resolveLegacyRowActions>['defs'];
41+
wantEditAction: boolean;
42+
wantDeleteAction: boolean;
43+
mayInvokeRowAction: ReturnType<typeof useCapabilityGate>;
44+
rowOperations: ObjectGridComponentProps['rowOperations'];
45+
canEdit: RowCrudAffordances['canEdit'];
46+
canDelete: RowCrudAffordances['canDelete'];
47+
recordVerdict: ReturnType<typeof useRecordCrudVerdicts>;
48+
predicateScope: ReturnType<typeof usePredicateScope>;
49+
onEdit: ObjectGridComponentProps['onEdit'];
50+
onDelete: ObjectGridComponentProps['onDelete'];
51+
editPredicates: RowCrudAffordances['editPredicates'];
52+
deletePredicates: RowCrudAffordances['deletePredicates'];
53+
executeAction: ReturnType<typeof useAction>['execute'];
54+
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
55+
t: ReturnType<typeof useGridTranslation>['t'];
56+
}) {
57+
// `operations`, the row-action lists and the row CRUD affordance verdict are
58+
// resolved further up, next to the permission reads they AND with — this
59+
// render path sits BELOW the error / loading early returns, and [#4296] the
60+
// record-level verdict they feed is fetched by a hook, which may not sit
61+
// behind a conditional return.
62+
const hasActions = !!(operations && (operations.update || operations.delete));
63+
const hasRowActions = customRowActions.length > 0 || resolvedRowActionDefs.length > 0 || wantEditAction || wantDeleteAction;
64+
65+
// objectui#12708 — the Actions column is drawn only when a row on screen
66+
// OFFERS something: its plan, the one `RowActionMenu` renders the row's cell
67+
// from, holds an inline button or a menu item once its capability gate, its
68+
// per-record predicates and the record-level verdicts are applied. Wiring
69+
// alone (`operations`, a declared list) used to draw an empty column over
70+
// rows that offer nothing. One row is enough, so the read stops at the first.
71+
const rowActionDefsGated = (resolvedRowActionDefs as any[]).filter((d) => mayInvokeRowAction(d?.requiredPermissions));
72+
// NON-AUTHOR SURFACE — see the `maxInlineActions` prop below.
73+
const maxInlineRowActions: number = (schema as any).maxInlineRowActions ?? 1;
74+
const rowCrudVerdicts = (row: any) => {
75+
// [objectui#8674] The CALLER's answer for this one row, ANDed in below.
76+
const rowOps = rowOperations?.(row);
77+
return {
78+
canEdit: resolveRowRecordCrudAffordance(canEdit && rowOps?.update !== false, recordVerdict(rowRecordId(row), 'update')),
79+
canDelete: resolveRowRecordCrudAffordance(canDelete && rowOps?.delete !== false, recordVerdict(rowRecordId(row), 'delete')),
80+
};
81+
};
82+
const rowOffersAction = (row: any): boolean => {
83+
const plan = planRowActionMenu({
84+
row,
85+
scope: predicateScope,
86+
actionDefs: rowActionDefsGated,
87+
maxInlineActions: maxInlineRowActions,
88+
rowActions: customRowActions,
89+
...rowCrudVerdicts(row),
90+
onEdit,
91+
onDelete,
92+
editPredicates,
93+
deletePredicates,
94+
objectFields: objectSchema?.fields,
95+
});
96+
return plan.inline.length > 0 || plan.menuCount > 0;
97+
};
98+
const drawActionsColumn = (hasActions || hasRowActions) && data.some(rowOffersAction);
99+
100+
const columnsWithActions = drawActionsColumn ? [
101+
...persistedColumns,
102+
{
103+
header: t('grid.actions'),
104+
accessorKey: '_actions',
105+
// Size to the buttons it holds (never the 80px char-estimate floor) and
106+
// don't clip — otherwise multiple inline actions (e.g. Open + Upgrade)
107+
// overflow the fixed-width cell and the leftmost button gets cut off.
108+
fitContent: true,
109+
align: 'right',
110+
// Stick to the right edge so the actions stay reachable when a wide table
111+
// scrolls horizontally (otherwise the last column sits past the scroll
112+
// extent and is hidden). Excluded from the frozen-column decision below so
113+
// this auto-pin doesn't cancel the default left-freeze of the first column.
114+
pinned: 'right',
115+
cell: (_value: any, row: any) => {
116+
// [objectui#8674] The CALLER's answer for this one row, resolved
117+
// once per row and ANDed into the two verdicts below. A host whose
118+
// refusal is per record (a system field the designer may not drop)
119+
// has no other place to put it: `operations` and the `onEdit` /
120+
// `onDelete` wiring are grid-level and identical for every row, so
121+
// the refusal used to live in the callback and fire AFTER the button
122+
// had been drawn and clicked. Narrowing only — see `rowOperations`.
123+
// Resolved by `rowCrudVerdicts`, the same call the column's draw
124+
// verdict above reads (objectui#12708).
125+
const rowVerdicts = rowCrudVerdicts(row);
126+
return (
127+
<RowActionMenu
128+
row={row}
129+
rowActions={customRowActions}
130+
rowActionDefs={resolvedRowActionDefs as any[]}
131+
objectFields={objectSchema?.fields}
132+
// NON-AUTHOR SURFACE — `maxInlineRowActions` is deliberately
133+
// absent from `GRID_QUERY_INPUTS` (maintainer ruling, 2026-08-18,
134+
// objectui#5091), so the cast read behind `maxInlineRowActions` (resolved
135+
// above, beside the column's draw verdict) is deliberate, not missed. It is
136+
// a host/internal switch for the inline-button budget before the
137+
// rest fold into the "⋮" menu — an embedder's layout call, set from
138+
// code (`apps/console/src/dev/DevRowActions.tsx:51`), never from a
139+
// view document. `ComponentPropsMap['object-grid']` is a
140+
// `strictObject` and rejects the key by name, so publishing it would
141+
// advertise a key the save gate refuses. The `?? 1` default is the
142+
// published behaviour and stays the one an author sees. Pinned by
143+
// `__tests__/gridNonAuthorKeys.test.tsx`.
144+
maxInlineActions={maxInlineRowActions}
145+
// [#4296] The object verdict ANDed with THIS row's record-level one.
146+
// It rides the same per-row channel the #2614 predicates ride —
147+
// `planRowActionMenu` conjoins `canEdit`/`canDelete` with
148+
// `visibleWhen` in one expression, so the item and the "⋮" guard read
149+
// one decision (#3562) and a hidden row grows no empty trigger. An
150+
// unanswered row keeps the object verdict, i.e. today's rendering.
151+
// [objectui#8674] `rowOps` (inside `rowCrudVerdicts`) is the third narrowing in this one
152+
// expression, and an INTERSECTION like the two around it: only
153+
// `false` removes, so a caller that passes no `rowOperations` —
154+
// every caller but the field designer today — reads exactly the
155+
// verdict this line carried before the prop existed.
156+
canEdit={rowVerdicts.canEdit}
157+
canDelete={rowVerdicts.canDelete}
158+
editPredicates={editPredicates}
159+
deletePredicates={deletePredicates}
160+
onEdit={onEdit}
161+
onDelete={onDelete}
162+
onAction={(action, r) => {
163+
void executeAction({ type: action, params: { record: r } }).then(res => {
164+
// A successful row action typically mutated this record; refresh
165+
// so the grid reflects the server state (same rationale as bulk).
166+
if (res?.success) setRefreshKey(k => k + 1);
167+
});
168+
}}
169+
onActionDef={(def, r) => {
170+
// Dispatch schema-driven row action through the runner. We forward
171+
// the full action def so type/target/recordIdParam/bodyShape/etc.
172+
// route correctly, attach the row record under `_rowRecord` for the
173+
// apiHandler row-id injection, and surface raw `params` as
174+
// `actionParams` so the runner shows the param dialog when present.
175+
const { params: rawParams, ...rest } = def;
176+
const dispatch: any = { ...rest };
177+
if (Array.isArray(rawParams) && rawParams.length > 0) {
178+
dispatch.actionParams = rawParams;
179+
}
180+
dispatch.params = { _rowRecord: r };
181+
void executeAction(dispatch).then(res => {
182+
if (res?.success) setRefreshKey(k => k + 1);
183+
});
184+
}}
185+
/>
186+
);
187+
},
188+
sortable: false,
189+
},
190+
] : persistedColumns;
191+
192+
// --- Pinned column reordering ---
193+
// Reorder: pinned:'left' first, unpinned middle, pinned:'right' last
194+
const pinnedLeftCols = columnsWithActions.filter((c: any) => c.pinned === 'left');
195+
const pinnedRightCols = columnsWithActions.filter((c: any) => c.pinned === 'right');
196+
const unpinnedCols = columnsWithActions.filter((c: any) => !c.pinned);
197+
const hasPinnedColumns = pinnedLeftCols.length > 0 || pinnedRightCols.length > 0;
198+
// Both carry the shared seam shadow (objectui#12708): inside the grouped
199+
// grid's one scroller (a `seam="pinned"` overflow box) a right-pinned cell
200+
// draws it on its own left edge while columns remain to the right. Outside
201+
// such a box it stays invisible, and `data-table` places its own.
202+
const rightPinnedClasses = `sticky right-0 z-10 bg-background border-l border-border ${PINNED_RIGHT_SEAM_SHADOW_CLASSES}`;
203+
// A body cell also layers the row's `--os-row-tint` (set by `useRowColor`)
204+
// over its opaque `bg-background`, so a tinted row's colour reaches its
205+
// right-pinned cells while they stay opaque over the content scrolling
206+
// beneath them (objectui#12375). The same literal as `data-table`'s
207+
// `STICKY_ROW_TINT` for its frozen cells; an untinted row resolves it to
208+
// nothing.
209+
const rightPinnedCellClasses =
210+
`sticky right-0 z-10 bg-background bg-[image:linear-gradient(var(--os-row-tint,transparent),var(--os-row-tint,transparent))] border-l border-border ${PINNED_RIGHT_SEAM_SHADOW_CLASSES}`;
211+
// The `_actions` column is auto-pinned right (above), so it must be excluded
212+
// from the frozen-column decision — otherwise every list with row actions
213+
// would trip `hasPinnedColumns` and lose the implicit left-freeze of its
214+
// first column. Only USER-declared pins should drive freezing.
215+
const userLeftPinnedCount = pinnedLeftCols.filter((c: any) => c.accessorKey !== '_actions').length;
216+
const hasUserPinnedColumns =
217+
userLeftPinnedCount > 0 || pinnedRightCols.some((c: any) => c.accessorKey !== '_actions');
218+
219+
return {
220+
columnsWithActions, hasActions, hasPinnedColumns, hasUserPinnedColumns, pinnedLeftCols,
221+
pinnedRightCols, unpinnedCols, rightPinnedClasses, rightPinnedCellClasses, userLeftPinnedCount,
222+
};
223+
}

0 commit comments

Comments
 (0)