Repository navigation
Expand file tree
/
Copy pathObjectGrid.tsx
More file actions
3962 lines (3814 loc) · 209 KB
/
Copy pathObjectGrid.tsx
File metadata and controls
3962 lines (3814 loc) · 209 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* ObjectGrid Component
*
* A specialized grid component built on top of data-table.
* Auto-generates columns from ObjectQL schema with type-aware rendering.
* Implements the grid view type from @objectstack/spec view.zod ListView schema.
*
* Features:
* - Traditional table/grid with CRUD operations
* - Search, filters, pagination
* - Column resizing, sorting
* - Row selection
* - Inline editing support
*/
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import type { ObjectGridSchema, DataSource, ViewData, TableSortItem, DataTableSchema, ListViewExportFormat, RecordNavigateAction } from '@object-ui/types';
import { isSystemManagedField } from '@object-ui/types';
import { resolveFieldScale } from '@objectstack/spec/data';
import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useCapabilityGate, notifyDataChanged, 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
// what dropped a `format`-hinted column's renderer, and one shared owner is
// what stops a seventh site picking a convention of its own.
import { resolveGridCellRendering } from './cellRendererResolution';
import { isMaskedGridColumn } from './maskedColumn';
import { formatCurrency, formatCompactCurrency, formatDate, 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
// (`{ en: …, 'fr-FR': … }`) that `I18nLabel` carries. It does NOT accept
// objectui's keyed `{ key, defaultValue, params }` ref — that vocabulary lives
// on the FLAT `schema.ariaLabel` and is resolved by `SchemaRenderer` instead.
// Needed here since objectui#9092 restored `ObjectGridSchema.label` to the
// `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 } from '@objectstack/spec/ui';
import { stateMachineNextValues } from './inline-edit-options';
import {
Badge, Button, NavigationOverlay, EmptyValue,
legacyRecordDrawerWidthKey, recordOverlayWidthStorageKey, useOverlayAnchor,
Popover, PopoverContent, PopoverTrigger,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
RefreshIndicator,
DataEmptyState, EmptyStateLink, resolveIcon,
HorizontalOverflowBox,
toast,
} from '@object-ui/components';
import { resolveConditionalFormatting, declaredNameField, buildExportFileName, resolveAffordance, type SchemaLike, isExpandableFieldType, isUnmaterializedFieldType, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, filterRefusalSubject, FilterOperatorError, resolveRecordSourceConfig, resolveRecordSourceObjectName, partitionRowsByPredicate } from '@object-ui/core';
import { usePermissions } from '@object-ui/permissions';
import {
RECORD_OVERLAY_DEFAULT_WIDTH,
RecordDetailPanel,
ConcurrentUpdateDialog,
isConcurrentUpdateError,
useDetailTranslation,
type ConcurrentUpdateConflict,
} from '@object-ui/plugin-detail';
import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Download, Rows2, Rows3, Rows4, AlignJustify, Loader2 } from 'lucide-react';
import { useRowColor } from './useRowColor';
import { GroupRow } from './GroupRow';
import { resolveRowCrudAffordances } from './rowCrudAffordances';
import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts';
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
import { resolveBulkActions, resolvePromotedBulkParams } from './resolveBulkActions';
import { partitionBulkRows } from './bulkEligibility';
import { formatActionLabel } from './components/RowActionMenu';
import { BulkActionBar } from './components/BulkActionBar';
import { BulkActionDialog } from './components/BulkActionDialog';
import type { BulkResult } from './hooks/useBulkExecutor';
import type { BulkActionDef } from '@object-ui/types';
import { type ObjectGridColumnDraft } from './objectGridColumns';
import { 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 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';
import { useObjectGridGenerateColumns } from './objectGridGenerateColumns';
import { resolveObjectGridPersistedColumns } from './objectGridPersistedColumns';
import { resolveObjectGridActionsColumn } from './objectGridActionsColumn';
export type { RetiredListColumnKey, ObjectGridColumnHolds, ObjectGridColumnDraft, ObjectGridColumn } from './objectGridColumns';
export type { ObjectGridColumnState, ObjectGridFooterSummary, ObjectGridExternalPaginationProps } from './objectGridProps';
/**
* A view's declared `sort` → the shape the table's header indicators read.
*
* `[{ field, order }, …]` is the ONE spelling `@objectstack/spec` still
* declares for `ObjectGridPropsSchema.sort` (objectui#8221 retired the string
* clauses), and since objectui#8961 it is the only spelling this reader
* admits. The headers agree with the fetch path on it: a view that arrives
* sorted by `created_at desc` shows that arrow before anyone clicks anything —
* otherwise the first click on that column produces `asc` while the list was
* already `desc`, and the arrow tells the truth only from the second click on.
*
* ⭐ A retired string spelling (`"name desc"`, `["name desc", …]`) yields
* NOTHING here, so it lights no arrow. That is the agreement, not an omission:
* the fetch path REFUSES the same spelling and sends no `$orderby`
* (objectui#8767). Between #8767 and objectui#8961 this reader was WIDER than
* that path — it parsed the string and drew a confident arrow for an ordering
* the query did not carry, a UI element stating something untrue about the rows
* beside it, with nothing but a console line to say so.
*
* ⛔ Do not re-widen it for a stored `sys_metadata` row still carrying the old
* spelling. The author is already told, once per spelling, by PR #8758's own
* diagnostic at the fetch path — it quotes the offending value and prescribes
* the array form. A second reading here would restore exactly the arrow the
* wire cannot honour.
*
* The wire shape is NOT what moved: this block still sends its own
* `"field order"` join string (see {@link toOrderByClause}), the shared sink's
* `{field: direction}` map stays declined, and the server-side export path
* reads `schema.sort` itself rather than through this function, so it was
* already array-only and is untouched.
*
* Exported for the test that pins it against the fetch path's own reading.
*/
export function parseSchemaSort(sort: unknown): TableSortItem[] {
const entries = Array.isArray(sort) ? sort : [];
const items: TableSortItem[] = [];
for (const entry of entries) {
if (entry && typeof entry === 'object' && typeof (entry as any).field === 'string') {
const { field, order } = entry as { field: string; order?: string };
items.push({ field, order: String(order).toLowerCase() === 'desc' ? 'desc' : 'asc' });
}
}
return items;
}
/**
* Props of the `ObjectGrid` React component.
*
* Renamed off the bare `ObjectGridProps` (objectui#4650): from 17.0.0
* `@objectstack/spec/ui` owns that name, where it is the AUTHORED props
* document of the `object-grid` element — `z.input<typeof
* ObjectGridPropsSchema>`, i.e. serialisable authoring keys only (`label`,
* `fields`, `filter`, `pagination`, …). This is the RENDERER's props: a
* live `dataSource`, the host pagination handles below, and eleven callbacks,
* none of which can exist in authored metadata. Two layers under one word,
* resolved the way this repo already resolved it for `PageHeaderProps` ->
* `PageHeaderComponentProps` (app-shell) and the `Record*ComponentProps` family
* in `@object-ui/types`.
*
* The barrel keeps `ObjectGridProps` as a deprecated alias of this type, so no
* importer breaks. Tripwire: `__tests__/spec-symbol-4650.test.ts`.
*/
/**
* The subset of the grid-level `operations` block that a row may answer for
* ITSELF — the vocabulary {@link ObjectGridComponentProps.rowOperations} speaks.
*
* `update` / `delete` are the two the row kebab renders, and they are spelled
* exactly as the authored `operations` block spells them, so one word means one
* thing whether it is declared for the whole grid or resolved for one row. The
* block's other members (`create`, `export`) are deliberately absent: neither
* is a row affordance, so a per-row answer for them would have nowhere to land.
*/
export interface ObjectGridRowOperations {
update?: boolean;
delete?: boolean;
}
export interface ObjectGridComponentProps extends ObjectGridExternalPaginationProps {
schema: ObjectGridSchema;
dataSource?: DataSource;
className?: string;
/**
* [objectui#10657] The object's field catalogue (`fields` of the definition
* `getObjectSchema(objectName)` returns), handed down by a HOST that has
* already read it. `ListView` passes the definition it fetched before its
* rows; the grid answers every field-type question from it until its own
* read of the definition lands, and from its own read after that.
*
* ## Why a host hands it down
*
* A host that fetches the rows hands them down as `data`, so they paint
* before the grid's own schema read settles. Until then an untyped view
* column over a `password` / `secret` field has no type, and the grid can
* only withhold it (drawn as the mask, never as text). With the catalogue in
* hand there is no such window: the column draws, and is flagged, from its
* declared type at first paint.
*
* ## A RUNTIME prop, never authored metadata
*
* The name is the host channel decision batch #70 ruled for the object's
* field catalogue (objectui#7742): `SchemaRenderer` strips an AUTHORED
* `objectFields` on every spelling (objectui#8818), so only a host's React
* prop can reach this slot, and no view author can supply a catalogue that
* types a credential field as text.
*/
objectFields?: Readonly<Record<string, unknown>>;
/**
* [objectui#12423] Row numbers on the leading cell, as a HOST says: `ListView`
* passes its Display control's Row numbers switch here (session-only, never
* stored). Absent ⇒ on, the grid's default since it drew them. A RUNTIME
* prop: the protocol declares no row-number key on a view, and none is read
* off the node.
*/
showRowNumbers?: boolean;
/**
* [objectui#12528] A HOST that draws its own footer row takes the column
* summaries into it. When this is passed the grid draws no summary row of
* its own and reports, in column order, what that row would have drawn —
* `null` when it would draw none (no summary, a grouped grid, unmounted).
* `ListView` passes it so the summaries sit at the right of its record-count
* row: one footer row instead of two. Reported on a change of CONTENT, never
* of identity. A RUNTIME prop, never authored metadata.
*/
onColumnSummaryChange?: (summaries: readonly ObjectGridFooterSummary[] | null) => void;
/**
* [objectui#8674] Narrow ONE row's generic Edit / Delete entries — the layer
* that lets a host withhold an operation the record itself cannot accept.
*
* ## Why this exists
*
* `operations` and the `onEdit` / `onDelete` wiring are GRID-level: they say
* whether the affordance exists at all, identically for every row. A host
* whose refusal is per RECORD had nowhere to put it, so it put the refusal in
* the callback instead — `FieldDesigner`'s delete handler returned early on a
* system field, after the grid had already drawn the button. The two states
* it was distinguishing (`readOnly`, which withholds the callback, and
* `isSystem`, which swallowed the click) differed in the code and did not
* differ on screen: the author clicked a button drawn as available and got no
* dialog, no toast, no console message. The operation an affordance cannot
* perform is not offered.
*
* ## The contract
*
* Called with a row record; returns the overrides for THAT row. It is an
* INTERSECTION, like every layer around it (the ADR-0103 bucket, the object's
* `userActions`, the server's effective API operations, the principal's own
* grant and the record-level explain verdict): `false` WITHHOLDS, and nothing
* it returns can re-open what those closed. `true`, an omitted member, a
* `null` / `undefined` return, and an absent prop all leave the verdict
* exactly as the grid resolved it — so a caller that passes nothing renders
* what it rendered before this prop existed.
*
* Scope is the row's kebab. Bulk delete rides `onBulkDelete` and the object
* verdict behind the selection bar, which no per-row answer can speak for:
* one selected row's `false` must not silently drop the other rows' action.
*/
rowOperations?: (record: any) => ObjectGridRowOperations | null | undefined;
/**
* [objectui#13330] A host-drawn node beside ONE row's title: the cell of the
* object's declared name field when a column shows it, else the first shown
* column (the row actions are never it), and the title of the row's card in
* the card view. Called with the row record; `null` / `undefined` draws
* nothing, and so does an absent prop, so a caller that passes nothing
* renders exactly what it rendered before this prop existed.
*
* The console's Users list on a managed environment passes a marker for a
* user who holds nothing beyond the baseline. What the node says is the
* host's; the grid only places it, after the cell's own content and never in
* place of it, so the value, its link and its mask all stay as drawn.
*
* A RUNTIME prop, never authored metadata: it is a React node a host
* composes, nothing an author writes, and no node key reaches it.
*/
rowAdornment?: (record: any) => React.ReactNode;
/**
* TWO parameters since objectui#9357, and the second is not decoration: this
* prop reaches `useNavigationOverlay` as its `onRowClick`, and `handleClick`
* invokes it as `onRowClick(record, event)` — the modifier payload a host
* needs to implement Cmd/Ctrl/middle-click for itself. Declaring one
* parameter hid the second on the ONE line a host reads. Spelled `any` and
* not `HandleClickModifiers` for the reason objectui#9341 measured on
* `ObjectKanbanSchema.onCardClick`: that interface lives in
* `@object-ui/react`, the published twins in `@object-ui/types` may not name
* it, and a host that discovered the payload from the implementation
* annotated it `React.MouseEvent` — which a narrower declaration refuses
* contravariantly. `BaseSchema`'s own `onClick` / `onChange` / `onSubmit`
* already use this spelling for exactly this situation.
*/
onRowClick?: (record: any, event?: any) => void;
/**
* Called for page-level navigation with the record id and a
* {@link RecordNavigateAction} — a navigation-MODE token from a closed
* vocabulary: `'view'` opens the record page, `'new_window'` opens it in a
* new browser tab.
*
* [objectui#9547] The tenth callback on this interface, and the channel a
* programmatic caller should use: its nine siblings below are props here
* too, and `ObjectGridSchema.onNavigate`'s own docblock sends callers to
* this interface for it. That schema key still works — it is the
* same slot reached off the node — and when BOTH are supplied, THIS PROP
* WINS (maintainer ruling C on objectui#9547: the programmatic caller is the
* nearer party). `onRowClick` above still takes full priority over either:
* a host that owns the row click owns navigation too.
*/
onNavigate?: (recordId: string | number, action: RecordNavigateAction) => void;
onEdit?: (record: any) => void;
onDelete?: (record: any) => void;
onBulkDelete?: (records: any[]) => void;
onCellChange?: (rowIndex: number, columnKey: string, newValue: any, row: any) => void;
onRowSave?: (rowIndex: number, changes: Record<string, any>, row: any) => void | Promise<void>;
onBatchSave?: (changes: Array<{ rowIndex: number; changes: Record<string, any>; row: any }>) => void | Promise<void>;
onRowSelect?: (selectedRows: any[]) => void;
onAddRecord?: () => void;
}
/**
* Helper to get data configuration from schema.
*
* The ruled three-rung ladder itself (`data`, then `staticData`, then
* `objectName`) is `resolveRecordSourceConfig` in `@object-ui/core` — ONE
* implementation of a contract published on both faces (`77cb489b4`), which
* this file used to hand-copy (`ce2aaefe1`).
*
* What used to stay here was the head above it: the bare-array `data`
* shorthand, which lifted `data: [...]` to `{ provider: 'value', items }`.
* ⛔ IT IS GONE (objectui#8348, decision batch #83, maintainer verbatim
* 「8348 以协议为准」 — the contract decides).
*
* MEASURED on `@objectstack/spec` 17.4.0:
* `ComponentPropsMap['object-grid'].data` is the `ViewData` union, and its own
* description names the refusal — *"Static inline rows live at
* `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused —
* see migration `object-grid-data-view-data-converged`"*. This block's own
* registration publishes the same arm (`{ name: 'data', type: 'object' }` in
* `index.tsx`), and `gridDataInputContract.test.ts` has pinned that declaration
* since objectui#5090. The head was the last carrier of a spelling every one of
* those faces refuses, so `data` is honoured here on the OBJECT arm only and
* the shared rung is passed `'view-data'`.
*
* ⛔ WHAT THIS REACHES, measured per CARRIER — do NOT read it as "the array is
* gone". An authored `data` array reaches this component TWICE: as
* `schema.data`, which this function used to lift, and as the `data` PROP,
* because `SchemaRenderer` spreads every non-metadata node key and
* `index.tsx` forwards `{...rest}`. That prop is `passedData` below, and it
* lifts an array to `{ provider: 'value', items }` at HIGHER priority than this
* ladder — it is the channel a host such as `ListView` uses to hand down rows it
* already fetched, and it is indistinguishable here from an authored key.
*
* ⇒ at the ladder the array is no longer a record source; through
* `SchemaRenderer` an authored `data: [ …rows… ]` still draws, from the props
* channel. Both halves are pinned in
* `__tests__/gridBareArrayDataRefused-8348.test.tsx`, which had to correct its
* own first draft on exactly this point. Collapsing the two carriers would take
* the host path with it and is outside objectui#8348's scope — reported on the
* card, not changed in passing.
*
* The declared spelling for inline rows is
* `data: { provider: 'value', items: [...] }`, and the deprecated `staticData`
* array still works as before.
*/
function getDataConfig(schema: ObjectGridSchema): ViewData | null {
return resolveRecordSourceConfig(schema, 'view-data');
}
/**
* The relational copy set and `applyRelationalMeta` moved to
* `./relationalMetaKeys` for objectui#6875. The list there is DERIVED from a
* table classifying every key swept off the three `@object-ui/fields` consumers,
* and a gate re-derives that read set from their sources — so the copy set can
* no longer drift into being a strict subset of what those consumers read,
* which is what it had silently become. Read that file's docblock before
* adding, removing or re-spelling a key.
*
* ⚠️ What this bag actually feeds is ONE of those three: the read-only cell.
* `fieldMeta` is handed to `<CellRenderer field={…}>` and nowhere else in this
* file. The inline picker is the OTHER seam — `renderCellEditor` below looks
* the field up in `objectSchema` and spreads the whole def into the widget, so
* a picker key reaches `LookupField` whether or not it is on the copy set.
* Measured in `__tests__/lookupPickerKeys-7154.test.tsx` (objectui#7154).
*/
/**
* ⭐ THE SCHEMA SLOT THIS GRID FILLS (objectui#6459) — the receiver half of the
* seam whose producer half #6004 typed. `dataTableSchema` below was
* `const dataTableSchema: any`, so the ~46 keys assembled there were checked
* against nothing, while the `DataTableSchema` this file imports went unused as
* that object's annotation.
*
* ## Why the fix is not `: DataTableSchema` — measured before choosing this shape
*
* objectui#6459's sizing note predicted a bare annotation would be inert
* because `buildGroupedTableSchema` re-emits the value through a spread and
* excess-property checking is a freshness check. Measured here (on
* `38a123cac`), the truth is stronger and the spread is not even needed:
*
* 1. `const dataTableSchema: DataTableSchema = {`, everything else unchanged,
* PLUS an undeclared `bogusKeyForProbe6459: true` WRITTEN OUT LONGHAND in
* the (fresh) literal → `tsc --noEmit` exit 0, ZERO diagnostics.
*
* The reason was `BaseSchema`'s `[key: string]: any` index signature, which
* `DataTableSchema` inherited until objectui#8347: under an index signature
* EVERY key is a member,
* so excess-property checking never has a non-member to refuse — at a fresh
* literal, through a spread, anywhere. It is the terminal case of the rule the
* `options` tombstone above records ("a pin enforced by a key's non-membership
* silently stops enforcing the moment the key becomes a member"): with an
* index signature there is no non-membership to enforce with, ever.
*
* `RemoveIndexSignature` stripped it (since objectui#8347 it strips nothing on
* `DataTableSchema`) — DERIVED from `DataTableSchema`, never a
* hand-copied member list, so a member added there tomorrow flows in on its
* own and two enumerations of one vocabulary never exist. With the signature
* gone, the same probe goes red (TS2353 naming the key), measured at both
* annotated literals — the flat one and `buildGroupedTableSchema`'s return.
*
* ## What the instrument is, and is not (pinned in dataTableSchemaSlot-6459.test.ts)
*
* Both writers of this type are object literals sitting DIRECTLY in annotated
* positions (a `const` initializer; an annotated arrow's parenthesized return),
* so unlike `generateColumns()` — where `.map()` laundered freshness away and
* only `?: never` tombstones could bite — excess-property checking is live
* here for longhand keys, and the group literal's spread source is itself the
* checked `const`, so every key entering that spread was already refused or
* admitted at ITS literal. What this shape does NOT do is refuse an undeclared
* key riding a NON-FRESH value assigned into the slot (assignability admits
* extra keys; that is structural typing, not a bug here) — per-key `?: never`
* tombstones remain the instrument for a key RETIRED by ruling, but an open
* census cannot be tombstoned, because a tombstone needs the key's name.
*
* ## The census this annotation surfaced — CLOSED, re-derived by objectui#7196
*
* Diffing the 46 keys the flat literal writes (plus the 8 the group literal
* re-writes) against `DataTableSchema` + `BaseSchema` declared members left
* exactly TWO undeclared keys when this was written. Re-derived on 2026-09-01
* against the same two literals (still 46 and 8), it now leaves **ZERO**. Both
* halves closed:
*
* - the card's speculative list (`pagination`, `manualPagination`, `rowCount`,
* `frozenColumns`, `singleClickEdit`, `selectionResetKey`,
* `disableInnerScroll`, `borderless`) was already declared then, and is
* still declared now;
* - the two keys that survived that diff, `renderCellEditor` and
* `cellClassName`, were DECLARED by `bf97b98c8` (maintainer ruling
* 2026-08-30) on three surfaces:
* · `packages/types/src/data-display.ts` — the members themselves
* · `packages/types/src/zod/data-display.zod.ts` — the Zod mirror
* · `packages/types/src/__tests__/data-table-declared-keys-6882.test.ts`
* — an `Equal` (not `extends`) exact-shape pin
*
* ⇒ The ruling this census was filed for HAPPENED, and it went the declare way.
* `ObjectGridDataTableSchemaHolds` below is therefore redundant rather than
* load-bearing — the position `headerIcon` reached at objectui#6615 — and
* removing it is the same separate, MEASURED step objectui#6424 took there. Its
* docblock carries what #7196 measured toward that.
*
* ### What the two entries used to say, and what is true instead
*
* - `renderCellEditor` — said HELD, read "via its own `(schema as any)`
* cast", and called the `packages/types` ruling still open. All three are
* over: the key is declared, the cast went with the declaration
* (`data-table.tsx` reads `schema.renderCellEditor` directly; the line where
* the cast stood still spells it, as a quotation inside its own
* correction), and the ruling landed. BEHAVIOUR is unchanged and always
* was — a returned widget takes the cell, `null` falls through to the
* built-in text / number / date inputs.
* - `cellClassName` — said HELD, and described the key as folded "into every
* body cell's `className`". The hold is over (declared by the same `bf97b98c8`);
* the DESCRIPTION was wrong from the day it was written, which is the more
* useful half of this correction. Measured: `data-table.tsx` folds the
* SCHEMA-level key at exactly three sites and every one is a UTILITY cell —
* the selection checkbox, the row-number cell, the row-actions cell. It
* never reaches a data cell; a data cell folds `col.cellClassName`, the
* per-column twin declared on `TableColumn`. The two class slots style
* DISJOINT cells and never combine on one. So what breaks when the schema
* key is absent is NOT "density stops reaching cells": data cells keep
* their density, because `applyDensity` below puts the same class on every
* column. What breaks is the checkbox / row-number / row-actions cells
* falling out of height alignment with the data beside them, which is why
* this grid sets BOTH slots. `bf97b98c8`'s declaration is where the authoritative
* version of this now lives; this is the local copy agreeing with it.
* ⭐ GUARDED SINCE `4eb665bcf`: the cell set is MEASURED in the rendered
* DOM by `packages/components/src/renderers/complex/__tests__/`
* `data-table-cellClassName-population-6921.test.tsx` — the fence (a data
* cell does NOT fold this key) and the non-regression (the three utility
* cells DO) — and this entry's wording is pinned against that measurement
* by `cellClassNameCensusProse-6921.test.ts` beside the slot suite. The
* false sentence was copied forward for three days before anyone measured
* it; a re-wording here that drops a cell name or revives the hold goes red.
*
* ⛔ The old closing note ("do not fix either hold by declaring the key on
* `DataTableSchema` as a rider — that package is published surface with its own
* review floor") governs nothing now. It was asking for the ruling to be taken
* deliberately at that package's floor, and that is exactly how `bf97b98c8` took it.
*/
type RemoveIndexSignature<T> = {
[K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
};
/** `DataTableSchema`'s DECLARED members only — any index signature stripped (it has none since objectui#8347). */
export type DeclaredDataTableSchema = RemoveIndexSignature<DataTableSchema>;
/**
* The SCHEMA-level keys this grid holds at the seam — the schema-slot sibling
* of `ObjectGridColumnHolds`. Shapes mirror the CONSUMER's reads in
* `data-table.tsx`, not what this file happens to pass.
*
* ⚠️ "Undeclared by `DataTableSchema`" was this type's ENTRY CONDITION, and —
* exactly as `ObjectGridColumnHolds` warns about its own — it is a claim about
* ANOTHER package that can stop being true with nothing going red here. It
* stopped being true on 2026-08-30: `bf97b98c8` declared BOTH members. As of
* objectui#7196 this type holds nothing; every member is redundant with
* `DeclaredDataTableSchema`.
*
* ⛔ Kept rather than deleted, because deleting a member of an EXPORTED type is
* a change of a different kind and gets its own card — the order objectui#6615
* → #6424 took for `headerIcon`. What #7196 measured, so that card can start
* from a reading instead of a guess:
*
* - each member's shape is `Equal` (not merely assignable) to the upstream
* declared member, so removing it cannot narrow or widen the seam;
* - the seam's `cellClassName` is already reduced (`string & string` is
* `string`), while its `renderCellEditor` resolves to the SAME signature
* intersected with itself. That is inert — mutually assignable with the
* declared member, measured in both directions — but it is the one visible
* trace the redundant hold leaves, and it is what the exact-shape pin in
* `packages/types` would report if pointed at the seam type;
* - unlike `ObjectGridColumnHolds.pinned`, neither member is its own ONLY
* declaration on the emitted type, so deleting them deletes nothing from
* it. That is precisely the ⛔ contrast `ObjectGridColumnHolds` spells out,
* and this type is now on the other side of it.
*
* ⭐ GUARDED SINCE objectui#7201 — this is the one paragraph that changed.
* Until then nothing above was mechanically checked, which is how it went stale
* unseen: `dataTableSchemaSlot-6459.test.ts` pinned only that the seam ACCEPTS
* both keys, an assertion that stays green whether they are held HERE or
* declared THERE. That suite now also carries the column-level twin's shape
* (`columnHoldsExpiry-6424.test.ts`, which asserts `TableColumn` does NOT
* declare `pinned`), transplanted: a compile-time verdict per member of this
* type, recording whether `DataTableSchema` declares that key. Both members are
* pinned DECLARED today, so a revert upstream reddens; and the key set itself is
* pinned, so a member added or removed here reddens too. ⚠️ The transplant is
* NOT literal in one respect, deliberately: the probe reads
* `DeclaredDataTableSchema`, not `DataTableSchema`, because the index signature
* stripped above made the raw type answer "declared" to EVERY key — measured,
* and pinned in that suite as its own control, which is flipped since
* objectui#8347 removed the signature.
*
* ⇒ Anything in this census that a reader would otherwise have to re-measure by
* hand is still prose; what is mechanical is the ENTRY CONDITION of the two
* holds below. ⛔ Do not read the gate as covering the rest of the census — the
* 46-key derivation is still hand-maintained, and whether this repo wants a
* derivation gate over it is the question objectui#7201 left for a ruling.
*/
export type ObjectGridDataTableSchemaHolds = {
/**
* REDUNDANT since `bf97b98c8` (2026-08-30) — `DataTableSchema` declares this
* key itself now, with a shape measured `Equal` to this one. `data-table`
* calls it to render a host cell editor; returning `null` falls through to the
* built-in text / number / date inputs. objectui#7188 added `pendingRow` (the
* persisted `row` merged with the row's staged, unsaved edits) upstream, and
* this copy carries it too so the seam's intersection stays INERT — `Equal`
* in both directions, as #7196 measured — rather than becoming a second,
* narrower signature that a `pendingRow`-reading host would trip on.
*/
renderCellEditor?: (ctx: {
column: any;
row: any;
pendingRow: any;
value: any;
stage: (v: any) => void;
commit: (v?: any) => void;
cancel: () => void;
}) => React.ReactNode;
/**
* REDUNDANT since `bf97b98c8` (2026-08-30) — `DataTableSchema` declares this
* key itself now, with a shape measured `Equal` to this one. `data-table`
* folds it into the three UTILITY body cells (selection, row-number,
* row-actions) and never into a data cell, which folds
* `TableColumn.cellClassName` instead.
*/
cellClassName?: string;
};
/** What this grid is allowed to write into the `data-table` schema slot. */
export type ObjectGridDataTableSchema =
DeclaredDataTableSchema & ObjectGridDataTableSchemaHolds;
export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
schema,
dataSource,
onEdit,
onDelete,
rowOperations,
rowAdornment,
onBulkDelete,
onRowSelect,
onRowClick,
onNavigate,
onCellChange,
onRowSave,
onBatchSave,
onAddRecord,
// The host-driven mode (`ObjectGridExternalPaginationProps`). Every one of
// these was read out of `...rest` through an `as any` cast until #4277 gave
// them a declaration; they are ordinary typed props now, and `rest` is gone
// with them. Renamed on the way in only where the component already owns the
// plain name (`data` is the fetched rows, `pageSize` the schema's).
data: passedData,
manualPagination: hostManualPagination,
rowCount: hostRowCount,
page: hostPage,
pageSize: hostPageSize,
onPageChange: hostOnPageChange,
onPageSizeChange: hostOnPageSizeChange,
sort: hostSort,
onSortChange: hostOnSortChange,
search: hostSearch,
onSearchChange: hostOnSearchChange,
findParams: hostFindParams,
onColumnStateChange,
objectFields: hostObjectFields,
showRowNumbers: hostShowRowNumbers,
onColumnSummaryChange,
}) => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Tenant default currency (ADR-0053) backstops amount cells that lack a code.
const { currency: tenantCurrency } = useLocalization();
// The one date/number locale resolver: tenant regional default → active UI
// language → 'en' (objectui#4272). Read unconditionally at component level.
const displayLocale = useDisplayLocale();
const { t, language: uiLanguage } = useGridTranslation();
const { fieldLabel: resolveFieldLabel, fieldOptionLabel: resolveFieldOptionLabel, translateOptions, actionLabel: resolveActionLabel } = useSafeFieldLabel();
// The session's object metadata (objectui#12105) — what a promoted bulk
// action's `objectOverride` param resolves against, as on the record page.
// The CONTEXT VALUE is closed over, never a snapshot of its `objects`
// getter, which reads the live cache at call time.
const metadata = useMetadata();
const [objectSchema, setObjectSchema] = useState<any>(null);
const [useCardView, setUseCardView] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
// Optimistic concurrency for the default inline-edit saves (objectui#12292).
// Per record, the version this grid's last landed write returned — see
// `rowVersionToSend`. It lives as long as this grid does.
const writtenVersionsRef = React.useRef(new Map<string, WrittenRowVersion>());
// The conflict a 409 opened, on the record surfaces' own dialog, and the
// resolver of the save that is awaiting the user's choice.
const { t: tDetail } = useDetailTranslation();
const [rowConflict, setRowConflict] = useState<ConcurrentUpdateConflict | null>(null);
const rowConflictDecisionRef = React.useRef<((choice: RowConflictChoice) => void) | null>(null);
// A grid torn down mid-conflict settles it as Cancel, so the awaiting save
// never hangs.
useEffect(() => () => rowConflictDecisionRef.current?.('cancel'), []);
const [showExport, setShowExport] = useState(false);
const [exportBusy, setExportBusy] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [rowHeightMode, setRowHeightMode] = useState<RowHeightMode>(resolveRowHeightMode(schema.rowHeight));
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const [selectAllMatching, setSelectAllMatching] = useState(false);
// Bumped to tell the underlying table to drop its internal checkbox selection.
// The table owns that state, so clearing our `selectedRows` alone would leave
// the checkboxes ticked (toolbar gone, rows still visibly selected).
const [selectionResetKey, setSelectionResetKey] = useState(0);
const [totalMatching, setTotalMatching] = useState<number | undefined>(undefined);
const [activeBulkDef, setActiveBulkDef] = useState<BulkActionDef | null>(null);
const [activeBulkRows, setActiveBulkRows] = useState<any[]>([]);
// Selected records the def's `visible` excluded (#3067) — shown in the
// dialog so a run over fewer records than the user picked says so.
const [activeBulkSkipped, setActiveBulkSkipped] = useState(0);
const lastFindParamsRef = React.useRef<Record<string, unknown> | null>(null);
// Grouped view paginates whole groups (groups stay intact, never split across
// pages). Defaults to the schema page size, falling back to the display
// default the spec declares (objectui#9853) — here counted in groups.
const [groupedPage, setGroupedPage] = useState(1);
const [groupedPageSize, setGroupedPageSize] = useState<number>(
resolvePageSize(schema, DEFAULT_DISPLAY_PAGE_SIZE),
);
// The row-height sync (`./objectGridRowHeight`), run here in hook order.
useObjectGridRowHeightSync({ schema, rowHeightMode, setRowHeightMode });
// Column state persistence: the seed, the external sync and the save, in
// hook order (`./objectGridColumnState`).
const { columnState, saveColumnState } = useObjectGridColumnState({ schema, onColumnStateChange });
// Pull to refresh and the card-view breakpoint, in hook order (`./objectGridMobile`).
const { pullRef, isRefreshing, pullDistance } = useObjectGridMobile({
schema, dataSource, setRefreshKey, setUseCardView,
});
// `passedData` — data handed down directly (from ListView) — is destructured
// from props above.
// Resolve bound data if 'bind' property exists
const boundData = useDataScope(schema.bind);
// Get data configuration (supports both new and legacy formats)
const rawDataConfig = getDataConfig(schema);
// Memoize dataConfig using deep comparison to prevent infinite loops
const dataConfig = React.useMemo(() => {
// If we have passed data (highest priority), treat it as value provider
if (passedData && Array.isArray(passedData)) {
return {
provider: 'value',
items: passedData
};
}
// If we have bound data, it takes precedence as inline value
if (boundData && Array.isArray(boundData)) {
return {
provider: 'value',
items: boundData
};
}
return rawDataConfig;
}, [JSON.stringify(rawDataConfig), boundData, passedData]);
const hasInlineData = dataConfig?.provider === 'value';
// External (parent-driven) server pagination. When a host like ListView fetches
// the data itself, it passes the current window down as `data` AND hands us the
// real match total + page controls. We must forward those straight to DataTable
// instead of client-slicing the window — otherwise the footer would report
// "pages = window / pageSize" and records beyond the window stay unreachable
// (objectstack-ai/objectstack#2212). `data` is a prop, and so are these — all declared on
// `ObjectGridExternalPaginationProps` since #4277.
const externalManualPagination =
hostManualPagination === true &&
typeof hostRowCount === 'number' &&
typeof hostOnPageChange === 'function';
// Extract stable primitive/reference-stable values from schema for dependency arrays.
// This prevents infinite re-render loops when schema is a new object on each render
// (e.g. when rendered through SchemaRenderer which creates a fresh evaluatedSchema).
// The `?? schema.objectName` tail is NOT the shared rung repeated: it stands
// in for the old `'object' in dataConfig` test, i.e. this site's own coercion
// of the OFF-CONTRACT `data: { provider: 'object' }` that carries no `object`
// (`ViewDataSchema` declares it required). Kept because this name gates the
// permission verdicts below — see the note at `inlineEditable`.
const objectName = resolveRecordSourceObjectName(schema, dataConfig) ?? schema.objectName;
/**
* The field catalogue this grid answers field-type questions from
* (objectui#10657): its own object definition once that read has landed,
* the catalogue a host handed down (`objectFields`) until then.
*
* ⚠️ Only the field reads that decide how a column DRAWS and whether it is
* MASKED go through this — the column builders, the masked flag, the client
* export, the grouping, the mobile card. Everything that needs the whole
* definition (actions, `highlightFields`, the default-column policy) still
* waits for `objectSchema`, exactly as before.
*/
const objectFields: Record<string, any> | undefined =
objectSchema?.fields ?? hostObjectFields ?? undefined;
/**
* Are this grid's OBJECT field types still unknown (objectui#10657, which
* folded objectui#10706)? True while an object definition is expected (an
* object to ask about, and a data source that can answer `getObjectSchema`)
* and no catalogue is in hand: the read is in flight, or it failed, which
* the inline path swallows as non-fatal. Rows a host hands down paint inside
* that window, so every untyped column is WITHHELD until it closes, and for
* good if it never does (`isWithheldGridColumn`). With no `getObjectSchema`
* there is nothing to wait for, and the authored column types are all this
* grid will ever know.
*/
const objectTypesPending =
!!objectName && typeof dataSource?.getObjectSchema === 'function' && !objectFields;
// [objectstack#3391] Server-resolved effective API operation set for this object
// (/me/permissions `apiOperations`). The Export button and handler AND their
// gate with this — a missing set (unrestricted object / old backend / no
// provider) keeps the current behavior. The frontend consumes the effective
// set the server resolved; it never reads the raw `apiMethods`.
const perms = usePermissions();
const { getObjectApiOperations } = perms;
const effectiveApiOps = objectName ? getObjectApiOperations(objectName) : undefined;
// [#4096] The CURRENT PRINCIPAL's write verdict on this object, the same
// source the toolbar's `can(obj, 'create')` reads (`/me/permissions`
// `allowEdit` / `allowDelete`). `effectiveApiOps` above cannot stand in for
// it: that set is the object's API exposure surface and is identical for
// every account, so the row kebab and the bulk bar used to fail open for a
// read-only principal. Undefined when no object name is resolved (element
// data source), which leaves the affordance verdict untouched.
//
// [objectui#12082] Every object-level verdict this grid renders — the row
// kebab's Edit / Delete, the bulk bar's Delete, in-place editing and the
// add-record row — is a row of the affordance-to-grant map, resolved by
// `resolveAffordance` from `@object-ui/core` against this one source: the
// object's `managedBy` / `userActions`, the effective operation set and the
// caller's grant, all asked of `objectName`. With no `objectName` (an element
// or inline data source) nothing is asked and every verdict reads open.
//
// KEY COLLISION — the OBJECT's `userActions` block, and the only one these
// verdicts can consume. `userActions` names two different shapes: on a VIEW
// it is toolbar policy (`UserActionsConfigSchema` — `sort`/`search`/`filter`/
// `refresh`/`rowHeight`/`addRecordForm`/`editInline`/`buttons`, which rejects
// `edit` BY NAME), on an OBJECT it is the CRUD-predicate block `edit`/
// `delete`/`create` carrying `visibleWhen`/`disabledWhen`. Only the object
// block means anything to the map's rows — or to `listViewPredicates` at the
// `$select` read below, which carries the full measurement. So the source
// stays `objectSchema`-only and must never gain the VIEW's `userActions` as a
// fallback: that is the shadowing this grid was fixed for (maintainer ruling
// of 2026-08-20 on objectui#5240, Q3=B). Pinned by
// `__tests__/gridNonAuthorKeys.test.tsx`.
const affordanceSource = { objectSchema: objectSchema as SchemaLike | null | undefined, objectName, perms };
const rowEditVerdict = resolveAffordance('rowEdit', affordanceSource);
const rowDeleteVerdict = resolveAffordance('rowDelete', affordanceSource);
// [#5148] …and the same verdict for `create`, the third face of the shape.
// Its two siblings above have carried the principal's own grant since #4096
// while the inline add-record row below rode on the author-declared
// `operations.create` ALONE — a flag that says whether the affordance was
// WIRED, never a permission grant. One component therefore answered "may
// this user create records here?" two opposite ways on the same screen: the
// toolbar's New button hid itself for a principal with no grant, while the
// add row underneath stayed live and walked that user through a write it
// already knew the server would 403. Resolved here beside its siblings so
// the three read as one block rather than drifting apart again.
const addRowGranted = resolveAffordance('gridAddRow', affordanceSource).allowed;
// [#5143] Whether THIS principal may edit THIS object's rows in place — the
// single verdict behind every inline-edit affordance this grid renders
// (`editable`, the cell editor, and the save/cancel column that serves them).
//
// `permissionUpdate` above was resolved for the row kebab and consumed by
// nothing else, while the inline-edit props read `schema.editable` raw. That
// left one component answering "may this user write these records?" two
// opposite ways on the same rows: the kebab hid Edit for a read-only
// principal, and a declaratively-authored `object-grid` block carrying
// `editable: true` dropped that same principal straight into editable cells,
// to be stopped only by the server's 403. No data ever landed (the server
// gate is solid) — the cost was a round-trip the UI guaranteed would fail.
//
// #4647 closed the ListView door with this exact conjunction (PR #5145,
// `inlineEditOffered`); the SDUI-authored grid schema is a SECOND, independent
// door into the same state that never passes through ListView. Spelling the
// gate identically here is what keeps the two from drifting: the object's
// resolved affordance — ADR-0103 bucket ∧ `userActions.edit` ∧ the server's
// effective API operations (objectstack#3391/objectstack#3546), which is what `isObjectInlineEditable`
// names — AND the current principal's own grant (#4096).
//
// Fail-open, like every sibling gate in this file. `can()` answers `true`
// with no `PermissionProvider`, and `isObjectInlineEditable` resolves the
// default-writable bucket for a null/absent object schema, so a standalone
// embed, the Studio designer, and a pure inline-data grid with no object
// semantics at all keep today's behavior. The narrowing only ever engages
// where there IS an object to have a verdict about.
const objectInlineEditable = resolveAffordance('listInlineEdit', affordanceSource).allowed;
// [#5143] The authored request ∧ the verdict — resolved ONCE and read by all
// three inline-edit props below (`editable`, `renderCellEditor`, and the
// save/cancel `rowActions` column). Three sites re-deriving `schema.editable`
// is how they came to disagree in the first place; one name is what keeps a
// future prop from being added on the raw key again.
//
// The authored key stays the gate's left half, so this narrows and never
// widens: no verdict can turn inline editing ON for a grid that did not ask
// for it.
//
// When ListView owns this grid it has already ANDed its own copy of this
// conjunction into the `editable` it hands down (#4647 / PR #5145), so on
// that path the second application is idempotent — A ∧ B, then ∧ B again.
// The two are NOT the same predicate everywhere, though, and the difference
// runs in the safe direction: ListView reads `schema.objectName`, while
// `objectName` here is `dataConfig.object ?? schema.objectName`. A grid whose
// object identity arrives only through its data config (`{ provider:
// 'object', object: … }`, no top-level `objectName`) falls through ListView's
// `schema.objectName ? … : true` branch OPEN and is judged solely here. This
// gate is therefore the only one on that shape, not a redundant copy of
// ListView's — verified rather than assumed, since "it's already gated
// upstream" is exactly the reasoning that left this door open to begin with.
const inlineEditable = (schema.editable ?? false) && objectInlineEditable;
// When the consumer wired onEdit/onDelete callbacks but the view schema
// omits an explicit `operations` block, default to allowing those actions.
// This gives every main list a Row actions kebab out of the box without
// forcing every view JSON to declare operations: { update: true, delete: true }.
const explicitOperations = 'operations' in schema ? schema.operations : undefined;
const operations = explicitOperations ?? {
update: !!onEdit,
delete: !!onDelete,
};
// Row actions can declare 'edit' / 'delete' as canonical strings — they
// SELECT the native Edit/Delete entries inside what `operations` allows
// (objectui#9819: `operations` is the ceiling), so the dropdown surfaces
// native entries (with proper icons) and
// routes them to onEdit / onDelete instead of the generic action runner
// (which has no 'edit' handler and a parameter-shape mismatch for 'delete').
//
// [objectui#10083] Whether the list was DECLARED is its own signal, read
// before the `[]` below erases it: `rowActions` narrows the generic
// Edit/Delete to the canonical names it carries only when the view declared
// it; an absent list keeps the default (see `resolveRowCrudAffordances`).
const rowActionsDeclared = Array.isArray(schema.rowActions);
const rowActionsList: string[] = Array.isArray(schema.rowActions) ? schema.rowActions : [];
/**
* NON-AUTHOR SURFACE — `rowActionDefs` is deliberately absent from
* `GRID_QUERY_INPUTS` (`index.tsx`), by the maintainer ruling of 2026-08-19
* on objectui#5091. Both cast reads on the line below are therefore
* DELIBERATE, not missed.
*
* That ruling KNOWINGLY REVERSES one line of the 2026-08-18 ruling on the
* same card, which had sent this key INTO the manifest as the "symmetric
* partner" of the declared `bulkActionDefs`. The symmetry premise did not
* survive measurement, and the reversal was flagged and accepted on the
* record:
*
* - The PRODUCER derives it. `app-shell/src/views/ObjectView.tsx:1968`
* builds this key from `objectDef.actions` filtered by
* `locations.includes('list_item')`, then localizes each def — see its
* own docblock there. Twenty lines below it, `bulkActionDefs` is passed
* STRAIGHT THROUGH from the view author. The two keys are asymmetric by
* construction: one is computed for the grid, the other is authored.
* - The contract refuses it. `ComponentPropsMap['object-grid']`
* (`@objectstack/spec@17`) is a `strictObject` that accepts
* `bulkActionDefs` and answers `unrecognized_keys` for THIS key, so an
* author who took a published offer could not save the document.
* - `@object-ui/types`' `ObjectGridSchema` declares `bulkActionDefs` and no
* `rowActionDefs` — which is why this read is a cast in the first place.
*
* The AUTHORED way to put an action on a row is unchanged and stays declared:
* `locations: ['list_item']` on the object's own action, plus the legacy
* `rowActions` name list (`GRID_QUERY_INPUTS`, `index.tsx:190`) that the fold
* below resolves against the object. Both halves — unlisted here, rejected
* there — are pinned by `__tests__/gridNonAuthorKeys.test.tsx`, together with
* the read itself, so this exemption cannot decay into a silent drop.
*/
const rowActionDefsList: any[] = Array.isArray((schema as any).rowActionDefs) ? (schema as any).rowActionDefs : [];
const wantEditAction = rowActionsList.includes('edit');
const wantDeleteAction = rowActionsList.includes('delete');
// Legacy `rowActions` carry a bare action NAME, which the runner cannot
// execute on its own — resolve each against the object's declared actions so
// it dispatches as a real def (and so a name that duplicates an existing
// `list_item` def stops rendering a dead twin of it). See
// `resolveLegacyRowActions` for the full rationale (objectui#2960).
const { defs: resolvedRowActionDefs, unresolved: customRowActions } = resolveLegacyRowActions({
rowActions: rowActionsList.filter(a => a !== 'edit' && a !== 'delete'),
rowActionDefs: rowActionDefsList,
objectActions: (objectSchema as any)?.actions,
});
// Honor the object's resolved CRUD affordance: the ADR-0103 lifecycle bucket
// (`managedBy`), the `userActions.edit`/`delete` override — explicit `false`
// opts out of the generic row Edit/Delete (e.g. sys_environment ships a
// dedicated Rename + cascade-Delete instead, and the generic entries would
// duplicate them) — and [objectstack#3720] the server's effective API operation set, so
// the row kebab never offers an update/delete the server would reject.
// `operations` above only says whether the CONSUMER wired the affordance; it
// is not a permission grant, which is why the object verdict is ANDed here
// rather than assumed to have been applied upstream.
// [#4096] …and neither is `apiOperations`, which describes the OBJECT, not
// the caller — so the principal's own `allowEdit` / `allowDelete` is ANDed on
// top, matching the toolbar and the record header on the very same screen.
// [objectui#12082] Those object layers arrive as the map's `rowEdit` /
// `rowDelete` verdicts (`affordanceSource` above); this call adds the row
// wiring on top of them.
//
// Resolved HERE, above the error / loading early returns, rather than beside
// the row-actions column it feeds: the record-level layer below is a hook and
// may not sit behind a conditional return.
const { canEdit, canDelete, objectCanDelete, editPredicates, deletePredicates, objectDeletePredicates } = resolveRowCrudAffordances({
operationsUpdate: operations?.update,
operationsDelete: operations?.delete,
wantEditAction,
wantDeleteAction,
rowActionsDeclared,
hasOnEdit: !!onEdit,
hasOnDelete: !!onDelete,
// The object's `managedBy` / `userActions` reach these two verdicts through
// `affordanceSource.objectSchema` above — see the KEY COLLISION note there.
edit: rowEditVerdict,
delete: rowDeleteVerdict,
});
// [#4296] …and neither of those describes the ROW. `allowEdit` is the
// principal's verdict on the OBJECT; `writeScope`, the sharing model and RLS
// narrow it per record, so everything above fails OPEN for every row the
// principal does not own — the kebab offered Edit/Delete on rows the server
// answers 403 for, while the record detail header (which has ANDed the
// record-grained verdict since objectstack#3821) correctly hid both on the
// very same record. One batched explain call per (object, operation) for the
// rows on screen answers it for the whole page; rows with no answer keep the
// object-level verdict (see `useRecordCrudVerdicts` — fail open, never
// over-hide).
const pageRecordIds = useMemo(
() => Array.from(new Set(
data.map(rowRecordId).filter((id) => id != null).map((id) => String(id)),
)),
[data],
);
const recordVerdict = useRecordCrudVerdicts({
objectName,
recordIds: pageRecordIds,
update: canEdit,
delete: canDelete,
});
// The projection keys and the filter, sort, search and server-paging state,
// read here in hook order (`./objectGridQueryInputs`).