|
| 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