@oneuptime/common 10.2.20 → 10.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1234 @@
1
+ import React, {
2
+ FunctionComponent,
3
+ ReactElement,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import API from "../../Utils/API/API";
10
+ import { WORKFLOW_URL } from "../../Config";
11
+ import URL from "../../../Types/API/URL";
12
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
13
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
14
+ import { JSONObject } from "../../../Types/JSON";
15
+ import ComponentLoader from "../ComponentLoader/ComponentLoader";
16
+ import CodeEditor from "../CodeEditor/CodeEditor";
17
+ import CodeType from "../../../Types/Code/CodeType";
18
+ import Icon from "../Icon/Icon";
19
+ import IconProp from "../../../Types/Icon/IconProp";
20
+
21
+ /*
22
+ * A column descriptor mirrors the shape returned by the workflow
23
+ * /model-schema/:tableName endpoint. Kept loose (the `type` field is just a
24
+ * string for the wire format) so this UI doesn't have to import server-side
25
+ * enums.
26
+ */
27
+ export interface PickerColumn {
28
+ id: string;
29
+ title: string;
30
+ description?: string;
31
+ type: string;
32
+ isRelation: boolean;
33
+ relatedTableName?: string | undefined;
34
+ relatedColumns?: Array<PickerColumn> | undefined;
35
+ }
36
+
37
+ interface SchemaResponse {
38
+ tableName: string;
39
+ columns: Array<PickerColumn>;
40
+ }
41
+
42
+ export interface ComponentProps {
43
+ tableName: string;
44
+ initialValue?: string | JSONObject | null | undefined;
45
+ onChange: (value: string) => void;
46
+ error?: string | undefined;
47
+ placeholder?: string | undefined;
48
+ tabIndex?: number | undefined;
49
+ }
50
+
51
+ type ViewMode = "picker" | "json";
52
+
53
+ interface CompatibilityResult {
54
+ compatible: boolean;
55
+ reasons: Array<string>;
56
+ parsed?: JSONObject | null;
57
+ }
58
+
59
+ /*
60
+ * normalizeInitialValue turns the raw stored value (which may be a JSON
61
+ * string, an already-parsed object, null, or empty) into a `{ text, parsed }`
62
+ * pair. `parsed` is null when the text doesn't parse as a JSON object.
63
+ */
64
+ const normalizeInitialValue: (
65
+ value: string | JSONObject | null | undefined,
66
+ ) => { text: string; parsed: JSONObject | null } = (
67
+ value: string | JSONObject | null | undefined,
68
+ ): { text: string; parsed: JSONObject | null } => {
69
+ if (value === null || value === undefined || value === "") {
70
+ return { text: "", parsed: null };
71
+ }
72
+
73
+ if (typeof value === "string") {
74
+ try {
75
+ const parsed: unknown = JSON.parse(value);
76
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
77
+ return { text: value, parsed: parsed as JSONObject };
78
+ }
79
+ return { text: value, parsed: null };
80
+ } catch {
81
+ return { text: value, parsed: null };
82
+ }
83
+ }
84
+
85
+ // Already an object.
86
+ if (typeof value === "object" && !Array.isArray(value)) {
87
+ return {
88
+ text: JSON.stringify(value, null, 2),
89
+ parsed: value as JSONObject,
90
+ };
91
+ }
92
+
93
+ return { text: String(value), parsed: null };
94
+ };
95
+
96
+ /*
97
+ * Decide whether an existing select value can be represented by the picker.
98
+ * Returns a list of human-readable reasons when not — we surface those in a
99
+ * banner so the user understands why we're keeping their JSON as-is.
100
+ */
101
+ const classifyCompatibility: (
102
+ text: string,
103
+ parsed: JSONObject | null,
104
+ columns: Array<PickerColumn>,
105
+ ) => CompatibilityResult = (
106
+ text: string,
107
+ parsed: JSONObject | null,
108
+ columns: Array<PickerColumn>,
109
+ ): CompatibilityResult => {
110
+ // Empty value: picker can represent it (as nothing selected).
111
+ if (text.trim() === "" || parsed === null) {
112
+ if (text.trim() === "") {
113
+ return { compatible: true, reasons: [], parsed: {} };
114
+ }
115
+ return {
116
+ compatible: false,
117
+ reasons: ["The current value isn't valid JSON."],
118
+ parsed: null,
119
+ };
120
+ }
121
+
122
+ const reasons: Array<string> = [];
123
+ const columnsById: { [key: string]: PickerColumn } = {};
124
+ for (const c of columns) {
125
+ columnsById[c.id] = c;
126
+ }
127
+
128
+ for (const key of Object.keys(parsed)) {
129
+ const column: PickerColumn | undefined = columnsById[key];
130
+
131
+ if (!column) {
132
+ reasons.push(`"${key}" isn't a readable field on this model.`);
133
+ continue;
134
+ }
135
+
136
+ const value: unknown = parsed[key];
137
+
138
+ if (column.isRelation) {
139
+ if (value === true) {
140
+ /*
141
+ * The picker requires at least one sub-field for a relation. Hidden in
142
+ * the picker UI on purpose.
143
+ */
144
+ reasons.push(
145
+ `"${key}" selects the whole relation. The picker requires you to pick specific sub-fields.`,
146
+ );
147
+ continue;
148
+ }
149
+
150
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
151
+ reasons.push(`"${key}" has an unexpected value.`);
152
+ continue;
153
+ }
154
+
155
+ const subColumnsById: { [k: string]: PickerColumn } = {};
156
+ for (const sub of column.relatedColumns || []) {
157
+ subColumnsById[sub.id] = sub;
158
+ }
159
+
160
+ for (const subKey of Object.keys(value as JSONObject)) {
161
+ const subValue: unknown = (value as JSONObject)[subKey];
162
+ const subColumn: PickerColumn | undefined = subColumnsById[subKey];
163
+
164
+ if (!subColumn) {
165
+ reasons.push(
166
+ `"${key}.${subKey}" isn't a readable field on the related model.`,
167
+ );
168
+ continue;
169
+ }
170
+
171
+ if (subColumn.isRelation) {
172
+ reasons.push(
173
+ `"${key}.${subKey}" is nested more than one level deep.`,
174
+ );
175
+ continue;
176
+ }
177
+
178
+ if (typeof subValue !== "boolean") {
179
+ if (typeof subValue === "object" && subValue !== null) {
180
+ reasons.push(
181
+ `"${key}.${subKey}" is nested more than one level deep.`,
182
+ );
183
+ } else {
184
+ reasons.push(`"${key}.${subKey}" has an unexpected value.`);
185
+ }
186
+ }
187
+ }
188
+ continue;
189
+ }
190
+
191
+ // Scalar column.
192
+ if (typeof value !== "boolean") {
193
+ reasons.push(`"${key}" has an unexpected value.`);
194
+ }
195
+ }
196
+
197
+ return {
198
+ compatible: reasons.length === 0,
199
+ reasons,
200
+ parsed,
201
+ };
202
+ };
203
+
204
+ /*
205
+ * Convert the picker's checkbox state into the JSON object that goes back
206
+ * into the workflow argument. Empty selections collapse to an empty string
207
+ * (rather than "{}") so existing "required" validation continues to work.
208
+ */
209
+ const buildSelectJson: (
210
+ scalarChecks: Set<string>,
211
+ relationChecks: { [relation: string]: Set<string> },
212
+ ) => string = (
213
+ scalarChecks: Set<string>,
214
+ relationChecks: { [relation: string]: Set<string> },
215
+ ): string => {
216
+ const out: JSONObject = {};
217
+
218
+ for (const id of scalarChecks) {
219
+ out[id] = true;
220
+ }
221
+
222
+ for (const relation of Object.keys(relationChecks)) {
223
+ const set: Set<string> | undefined = relationChecks[relation];
224
+ if (!set || set.size === 0) {
225
+ continue;
226
+ }
227
+ const subObj: JSONObject = {};
228
+ for (const subId of set) {
229
+ subObj[subId] = true;
230
+ }
231
+ out[relation] = subObj;
232
+ }
233
+
234
+ if (Object.keys(out).length === 0) {
235
+ return "";
236
+ }
237
+
238
+ return JSON.stringify(out);
239
+ };
240
+
241
+ /*
242
+ * A minimal checkbox that supports an `indeterminate` visual. Used for the
243
+ * relation header where partial selection of sub-fields needs its own state.
244
+ */
245
+ interface PickerCheckboxProps {
246
+ checked: boolean;
247
+ indeterminate?: boolean | undefined;
248
+ onChange: () => void;
249
+ ariaLabel?: string | undefined;
250
+ size?: "sm" | "md" | undefined;
251
+ }
252
+
253
+ const PickerCheckbox: FunctionComponent<PickerCheckboxProps> = (
254
+ props: PickerCheckboxProps,
255
+ ): ReactElement => {
256
+ const ref: React.MutableRefObject<HTMLInputElement | null> =
257
+ useRef<HTMLInputElement | null>(null);
258
+
259
+ useEffect(() => {
260
+ if (ref.current) {
261
+ ref.current.indeterminate = Boolean(props.indeterminate);
262
+ }
263
+ }, [props.indeterminate]);
264
+
265
+ const sizeClass: string = props.size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
266
+
267
+ return (
268
+ <input
269
+ ref={ref}
270
+ type="checkbox"
271
+ checked={props.checked}
272
+ onChange={props.onChange}
273
+ onClick={(e: React.MouseEvent<HTMLInputElement>) => {
274
+ // Prevent toggling parent label/row when the click originates here.
275
+ e.stopPropagation();
276
+ }}
277
+ aria-label={props.ariaLabel}
278
+ className={`${sizeClass} rounded border-gray-300 text-indigo-600 focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 cursor-pointer`}
279
+ />
280
+ );
281
+ };
282
+
283
+ const ModelFieldPicker: FunctionComponent<ComponentProps> = (
284
+ props: ComponentProps,
285
+ ): ReactElement => {
286
+ const [columns, setColumns] = useState<Array<PickerColumn> | null>(null);
287
+ const [isLoading, setIsLoading] = useState<boolean>(true);
288
+ const [loadError, setLoadError] = useState<string | null>(null);
289
+
290
+ const initial: { text: string; parsed: JSONObject | null } = useMemo(() => {
291
+ return normalizeInitialValue(props.initialValue);
292
+ }, []);
293
+
294
+ const [jsonText, setJsonText] = useState<string>(initial.text);
295
+ const [viewMode, setViewMode] = useState<ViewMode>("picker");
296
+ const [incompatibilityReasons, setIncompatibilityReasons] = useState<
297
+ Array<string>
298
+ >([]);
299
+ const [isLockedToJson, setIsLockedToJson] = useState<boolean>(false);
300
+
301
+ // Checkbox state for picker mode.
302
+ const [scalarChecks, setScalarChecks] = useState<Set<string>>(new Set());
303
+ const [relationChecks, setRelationChecks] = useState<{
304
+ [relation: string]: Set<string>;
305
+ }>({});
306
+ const [expandedRelations, setExpandedRelations] = useState<Set<string>>(
307
+ new Set(),
308
+ );
309
+ const [searchQuery, setSearchQuery] = useState<string>("");
310
+
311
+ // Avoid re-emitting onChange for the initial value (preserves byte-for-byte).
312
+ const hasInitializedFromColumns: React.MutableRefObject<boolean> =
313
+ useRef<boolean>(false);
314
+
315
+ useEffect(() => {
316
+ let cancelled: boolean = false;
317
+
318
+ const loadSchema: () => Promise<void> = async (): Promise<void> => {
319
+ setIsLoading(true);
320
+ setLoadError(null);
321
+ try {
322
+ const url: URL = URL.fromString(WORKFLOW_URL.toString()).addRoute(
323
+ `/model-schema/${encodeURIComponent(props.tableName)}`,
324
+ );
325
+ const result: HTTPResponse<JSONObject> | HTTPErrorResponse =
326
+ await API.get<JSONObject>({ url });
327
+
328
+ if (cancelled) {
329
+ return;
330
+ }
331
+
332
+ if (result instanceof HTTPErrorResponse) {
333
+ throw result;
334
+ }
335
+
336
+ const data: SchemaResponse = result.data as unknown as SchemaResponse;
337
+ setColumns(data.columns || []);
338
+ } catch (err) {
339
+ if (cancelled) {
340
+ return;
341
+ }
342
+ setLoadError(API.getFriendlyMessage(err));
343
+ setColumns([]);
344
+ } finally {
345
+ if (!cancelled) {
346
+ setIsLoading(false);
347
+ }
348
+ }
349
+ };
350
+
351
+ void loadSchema();
352
+
353
+ return () => {
354
+ cancelled = true;
355
+ };
356
+ }, [props.tableName]);
357
+
358
+ /*
359
+ * Apply a classification result to picker state: either seed the checkboxes
360
+ * (compatible) or lock the editor to JSON mode with reasons (incompatible).
361
+ */
362
+ const applyClassification: (
363
+ result: CompatibilityResult,
364
+ cols: Array<PickerColumn>,
365
+ ) => void = (
366
+ result: CompatibilityResult,
367
+ cols: Array<PickerColumn>,
368
+ ): void => {
369
+ if (!result.compatible) {
370
+ setIncompatibilityReasons(result.reasons);
371
+ setIsLockedToJson(true);
372
+ setViewMode("json");
373
+ return;
374
+ }
375
+
376
+ const parsed: JSONObject = (result.parsed as JSONObject) || {};
377
+ const nextScalars: Set<string> = new Set();
378
+ const nextRelations: { [relation: string]: Set<string> } = {};
379
+ const nextExpanded: Set<string> = new Set();
380
+
381
+ const columnsById: { [k: string]: PickerColumn } = {};
382
+ for (const c of cols) {
383
+ columnsById[c.id] = c;
384
+ }
385
+
386
+ for (const key of Object.keys(parsed)) {
387
+ const column: PickerColumn | undefined = columnsById[key];
388
+ if (!column) {
389
+ continue;
390
+ }
391
+ if (column.isRelation) {
392
+ const sub: JSONObject = (parsed[key] as JSONObject) || {};
393
+ const set: Set<string> = new Set();
394
+ for (const subKey of Object.keys(sub)) {
395
+ if (sub[subKey] === true) {
396
+ set.add(subKey);
397
+ }
398
+ }
399
+ if (set.size > 0) {
400
+ nextRelations[key] = set;
401
+ nextExpanded.add(key);
402
+ }
403
+ } else if (parsed[key] === true) {
404
+ nextScalars.add(key);
405
+ }
406
+ }
407
+
408
+ setScalarChecks(nextScalars);
409
+ setRelationChecks(nextRelations);
410
+ setExpandedRelations(nextExpanded);
411
+ setIncompatibilityReasons([]);
412
+ setIsLockedToJson(false);
413
+ setViewMode("picker");
414
+ };
415
+
416
+ // Once we have columns, classify the initial value and seed picker state.
417
+ useEffect(() => {
418
+ if (columns === null) {
419
+ return;
420
+ }
421
+ if (hasInitializedFromColumns.current) {
422
+ return;
423
+ }
424
+ hasInitializedFromColumns.current = true;
425
+
426
+ const result: CompatibilityResult = classifyCompatibility(
427
+ initial.text,
428
+ initial.parsed,
429
+ columns,
430
+ );
431
+
432
+ applyClassification(result, columns);
433
+ }, [columns, initial.text, initial.parsed]);
434
+
435
+ // When the picker emits, push JSON upward.
436
+ const emitFromPicker: (
437
+ scalars: Set<string>,
438
+ relations: { [k: string]: Set<string> },
439
+ ) => void = (
440
+ scalars: Set<string>,
441
+ relations: { [k: string]: Set<string> },
442
+ ): void => {
443
+ const next: string = buildSelectJson(scalars, relations);
444
+ setJsonText(next);
445
+ props.onChange(next);
446
+ };
447
+
448
+ const toggleScalar: (id: string) => void = (id: string): void => {
449
+ const next: Set<string> = new Set(scalarChecks);
450
+ if (next.has(id)) {
451
+ next.delete(id);
452
+ } else {
453
+ next.add(id);
454
+ }
455
+ setScalarChecks(next);
456
+ emitFromPicker(next, relationChecks);
457
+ };
458
+
459
+ const toggleRelationField: (relation: string, sub: string) => void = (
460
+ relation: string,
461
+ sub: string,
462
+ ): void => {
463
+ const existing: Set<string> = new Set(relationChecks[relation] || []);
464
+ if (existing.has(sub)) {
465
+ existing.delete(sub);
466
+ } else {
467
+ existing.add(sub);
468
+ }
469
+ const next: { [k: string]: Set<string> } = { ...relationChecks };
470
+ if (existing.size === 0) {
471
+ delete next[relation];
472
+ } else {
473
+ next[relation] = existing;
474
+ }
475
+ setRelationChecks(next);
476
+ emitFromPicker(scalarChecks, next);
477
+ };
478
+
479
+ const toggleExpand: (relation: string) => void = (relation: string): void => {
480
+ const next: Set<string> = new Set(expandedRelations);
481
+ if (next.has(relation)) {
482
+ next.delete(relation);
483
+ } else {
484
+ next.add(relation);
485
+ }
486
+ setExpandedRelations(next);
487
+ };
488
+
489
+ /*
490
+ * Select-all / clear helpers operate on whatever's currently visible so
491
+ * the actions feel scoped to what the user can see.
492
+ */
493
+ const setScalarSelection: (ids: Array<string>) => void = (
494
+ ids: Array<string>,
495
+ ): void => {
496
+ const next: Set<string> = new Set(ids);
497
+ setScalarChecks(next);
498
+ emitFromPicker(next, relationChecks);
499
+ };
500
+
501
+ const setRelationSelection: (relation: string, ids: Array<string>) => void = (
502
+ relation: string,
503
+ ids: Array<string>,
504
+ ): void => {
505
+ const next: { [k: string]: Set<string> } = { ...relationChecks };
506
+ if (ids.length === 0) {
507
+ delete next[relation];
508
+ } else {
509
+ next[relation] = new Set(ids);
510
+ }
511
+ setRelationChecks(next);
512
+ emitFromPicker(scalarChecks, next);
513
+ };
514
+
515
+ const expandRelation: (relation: string) => void = (
516
+ relation: string,
517
+ ): void => {
518
+ if (expandedRelations.has(relation)) {
519
+ return;
520
+ }
521
+ const next: Set<string> = new Set(expandedRelations);
522
+ next.add(relation);
523
+ setExpandedRelations(next);
524
+ };
525
+
526
+ const onSearchChange: (value: string) => void = (value: string): void => {
527
+ setSearchQuery(value);
528
+ };
529
+
530
+ /*
531
+ * Friendly type-pill label. Returns null for plain text-like columns so the
532
+ * common case stays uncluttered.
533
+ */
534
+ const getTypeLabel: (type: string) => string | null = (
535
+ type: string,
536
+ ): string | null => {
537
+ const map: { [k: string]: string } = {
538
+ Date: "Date",
539
+ Boolean: "Yes / No",
540
+ Number: "Number",
541
+ "Big Number": "Number",
542
+ "Small Number": "Number",
543
+ "Positive Number": "Number",
544
+ "Small Positive Number": "Number",
545
+ "Big Positive Number": "Number",
546
+ "Object ID": "ID",
547
+ Slug: "Slug",
548
+ URL: "URL",
549
+ Email: "Email",
550
+ Phone: "Phone",
551
+ IP: "IP",
552
+ Port: "Port",
553
+ Color: "Color",
554
+ JSON: "JSON",
555
+ Markdown: "Markdown",
556
+ HTML: "HTML",
557
+ "Entity Array": "List",
558
+ };
559
+ return map[type] || null;
560
+ };
561
+
562
+ const matchesQuery: (column: PickerColumn, query: string) => boolean = (
563
+ column: PickerColumn,
564
+ query: string,
565
+ ): boolean => {
566
+ if (!query) {
567
+ return true;
568
+ }
569
+ return (
570
+ column.title.toLowerCase().includes(query) ||
571
+ column.id.toLowerCase().includes(query) ||
572
+ (column.description || "").toLowerCase().includes(query)
573
+ );
574
+ };
575
+
576
+ const onJsonChange: (next: string) => void = (next: string): void => {
577
+ setJsonText(next);
578
+ props.onChange(next);
579
+ };
580
+
581
+ const resetToPicker: () => void = (): void => {
582
+ setIncompatibilityReasons([]);
583
+ setIsLockedToJson(false);
584
+ setScalarChecks(new Set());
585
+ setRelationChecks({});
586
+ setExpandedRelations(new Set());
587
+ setJsonText("");
588
+ props.onChange("");
589
+ setViewMode("picker");
590
+ };
591
+
592
+ if (isLoading) {
593
+ return (
594
+ <div className="py-10">
595
+ <ComponentLoader />
596
+ </div>
597
+ );
598
+ }
599
+
600
+ const scalarColumns: Array<PickerColumn> = (columns || []).filter(
601
+ (c: PickerColumn) => {
602
+ return !c.isRelation;
603
+ },
604
+ );
605
+ const relationColumns: Array<PickerColumn> = (columns || []).filter(
606
+ (c: PickerColumn) => {
607
+ return c.isRelation;
608
+ },
609
+ );
610
+
611
+ const totalSelected: number =
612
+ scalarChecks.size +
613
+ Object.values(relationChecks).reduce((acc: number, s: Set<string>) => {
614
+ return acc + s.size;
615
+ }, 0);
616
+
617
+ const totalAvailable: number =
618
+ scalarColumns.length +
619
+ relationColumns.reduce((acc: number, r: PickerColumn) => {
620
+ return acc + (r.relatedColumns?.length || 0);
621
+ }, 0);
622
+
623
+ const onUsePickerClicked: () => void = (): void => {
624
+ if (!columns) {
625
+ return;
626
+ }
627
+ // Re-classify the current JSON text — the user may have edited it.
628
+ const norm: { text: string; parsed: JSONObject | null } =
629
+ normalizeInitialValue(jsonText);
630
+ const result: CompatibilityResult = classifyCompatibility(
631
+ norm.text,
632
+ norm.parsed,
633
+ columns,
634
+ );
635
+ applyClassification(result, columns);
636
+ };
637
+
638
+ /*
639
+ * Apply search filter. For relations, the relation passes if its own title
640
+ * matches OR any sub-field matches; in the latter case we auto-expand it so
641
+ * the matching child is visible.
642
+ */
643
+ const query: string = searchQuery.trim().toLowerCase();
644
+ const visibleScalars: Array<PickerColumn> = scalarColumns.filter(
645
+ (c: PickerColumn) => {
646
+ return matchesQuery(c, query);
647
+ },
648
+ );
649
+
650
+ const visibleRelations: Array<{
651
+ column: PickerColumn;
652
+ visibleSubs: Array<PickerColumn>;
653
+ matchedByChild: boolean;
654
+ }> = relationColumns
655
+ .map((column: PickerColumn) => {
656
+ const subMatches: Array<PickerColumn> = (
657
+ column.relatedColumns || []
658
+ ).filter((sub: PickerColumn) => {
659
+ return matchesQuery(sub, query);
660
+ });
661
+ const matchesSelf: boolean = matchesQuery(column, query);
662
+ if (query && !matchesSelf && subMatches.length === 0) {
663
+ return null;
664
+ }
665
+ return {
666
+ column,
667
+ visibleSubs:
668
+ query && !matchesSelf ? subMatches : column.relatedColumns || [],
669
+ matchedByChild:
670
+ query.length > 0 && !matchesSelf && subMatches.length > 0,
671
+ };
672
+ })
673
+ .filter(
674
+ (
675
+ x: {
676
+ column: PickerColumn;
677
+ visibleSubs: Array<PickerColumn>;
678
+ matchedByChild: boolean;
679
+ } | null,
680
+ ): x is {
681
+ column: PickerColumn;
682
+ visibleSubs: Array<PickerColumn>;
683
+ matchedByChild: boolean;
684
+ } => {
685
+ return x !== null;
686
+ },
687
+ );
688
+
689
+ const hasNoVisibleResults: boolean =
690
+ visibleScalars.length === 0 && visibleRelations.length === 0;
691
+
692
+ const selectAllScalars: () => void = (): void => {
693
+ const ids: Array<string> = visibleScalars.map((c: PickerColumn) => {
694
+ return c.id;
695
+ });
696
+ // Merge with existing so out-of-view (filtered) selections aren't lost.
697
+ const merged: Set<string> = new Set(scalarChecks);
698
+ for (const id of ids) {
699
+ merged.add(id);
700
+ }
701
+ setScalarSelection(Array.from(merged));
702
+ };
703
+
704
+ const clearAllScalars: () => void = (): void => {
705
+ // Only clear what's visible — preserves selections hidden by search.
706
+ const next: Set<string> = new Set(scalarChecks);
707
+ for (const c of visibleScalars) {
708
+ next.delete(c.id);
709
+ }
710
+ setScalarSelection(Array.from(next));
711
+ };
712
+
713
+ /*
714
+ * Renders one selectable field row. Used for both top-level scalars and
715
+ * relation sub-fields (which pass a denser variant via `dense`).
716
+ */
717
+ const renderFieldRow: (args: {
718
+ column: PickerColumn;
719
+ checked: boolean;
720
+ onToggle: () => void;
721
+ dense?: boolean;
722
+ }) => ReactElement = (args: {
723
+ column: PickerColumn;
724
+ checked: boolean;
725
+ onToggle: () => void;
726
+ dense?: boolean;
727
+ }): ReactElement => {
728
+ const { column, checked, onToggle, dense } = args;
729
+ const typeLabel: string | null = getTypeLabel(column.type);
730
+ return (
731
+ <label
732
+ className={`group relative flex items-center gap-3 ${
733
+ dense ? "py-1.5" : "py-2"
734
+ } px-3 cursor-pointer select-none transition-colors ${
735
+ checked ? "bg-indigo-50/40" : "hover:bg-gray-50/80"
736
+ }`}
737
+ >
738
+ {checked && (
739
+ <span
740
+ className="absolute left-0 top-1 bottom-1 w-0.5 rounded-r bg-indigo-500"
741
+ aria-hidden="true"
742
+ />
743
+ )}
744
+ <input
745
+ type="checkbox"
746
+ checked={checked}
747
+ onChange={onToggle}
748
+ className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 cursor-pointer"
749
+ />
750
+ <span className="flex-1 min-w-0">
751
+ <span className="flex items-baseline gap-2">
752
+ <span
753
+ className={`truncate text-sm ${
754
+ checked
755
+ ? "font-medium text-gray-900"
756
+ : "font-medium text-gray-800"
757
+ }`}
758
+ >
759
+ {column.title}
760
+ </span>
761
+ {typeLabel && (
762
+ <span className="ml-auto flex-shrink-0 text-[11px] uppercase tracking-wider text-gray-400 group-hover:text-gray-500">
763
+ {typeLabel}
764
+ </span>
765
+ )}
766
+ </span>
767
+ {column.description && (
768
+ <span
769
+ className="block text-xs text-gray-500 truncate mt-0.5"
770
+ title={column.description}
771
+ >
772
+ {column.description}
773
+ </span>
774
+ )}
775
+ </span>
776
+ </label>
777
+ );
778
+ };
779
+
780
+ /*
781
+ * Top-level toolbar above everything: search on the left (when in picker
782
+ * mode), then count, then the view-mode toggle. Kept as a single tight row
783
+ * so it doesn't visually compete with the field list below.
784
+ */
785
+ const renderToolbar: () => ReactElement = (): ReactElement => {
786
+ const showSearch: boolean =
787
+ viewMode === "picker" && !isLockedToJson && totalAvailable > 0;
788
+
789
+ let toggleLabel: string;
790
+ let toggleIcon: IconProp;
791
+ let toggleHandler: () => void;
792
+ if (isLockedToJson) {
793
+ toggleLabel = "Reset to picker";
794
+ toggleIcon = IconProp.Refresh;
795
+ toggleHandler = resetToPicker;
796
+ } else if (viewMode === "picker") {
797
+ toggleLabel = "JSON";
798
+ toggleIcon = IconProp.Code;
799
+ toggleHandler = (): void => {
800
+ setViewMode("json");
801
+ };
802
+ } else {
803
+ toggleLabel = "Picker";
804
+ toggleIcon = IconProp.ListBullet;
805
+ toggleHandler = onUsePickerClicked;
806
+ }
807
+
808
+ return (
809
+ <div className="flex items-center gap-2">
810
+ {showSearch && (
811
+ <div className="relative flex-1">
812
+ <span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
813
+ <Icon
814
+ icon={IconProp.MagnifyingGlass}
815
+ className="h-4 w-4 text-gray-400"
816
+ />
817
+ </span>
818
+ <input
819
+ type="text"
820
+ value={searchQuery}
821
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
822
+ return onSearchChange(e.target.value);
823
+ }}
824
+ placeholder={`Search ${totalAvailable} fields`}
825
+ className="block w-full rounded-md border border-gray-200 bg-white pl-9 pr-8 py-1.5 text-sm placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
826
+ />
827
+ {searchQuery && (
828
+ <button
829
+ type="button"
830
+ onClick={() => {
831
+ return setSearchQuery("");
832
+ }}
833
+ className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-gray-400 hover:text-gray-600"
834
+ aria-label="Clear search"
835
+ >
836
+ <Icon icon={IconProp.Close} className="h-3.5 w-3.5" />
837
+ </button>
838
+ )}
839
+ </div>
840
+ )}
841
+ {!showSearch && <div className="flex-1" />}
842
+ {!isLockedToJson && totalAvailable > 0 && viewMode === "picker" && (
843
+ <span className="text-xs text-gray-500 whitespace-nowrap font-mono">
844
+ <span
845
+ className={`${
846
+ totalSelected > 0
847
+ ? "font-semibold text-indigo-600"
848
+ : "text-gray-700"
849
+ }`}
850
+ >
851
+ {totalSelected}
852
+ </span>
853
+ <span className="text-gray-300 mx-1">/</span>
854
+ <span>{totalAvailable}</span>
855
+ </span>
856
+ )}
857
+ <button
858
+ type="button"
859
+ onClick={toggleHandler}
860
+ className="inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:ring-1 focus:ring-indigo-500"
861
+ >
862
+ <Icon icon={toggleIcon} className="h-3.5 w-3.5 text-gray-500" />
863
+ {toggleLabel}
864
+ </button>
865
+ </div>
866
+ );
867
+ };
868
+
869
+ const renderSectionHeader: (args: {
870
+ label: string;
871
+ actions?: ReactElement | null;
872
+ }) => ReactElement = (args: {
873
+ label: string;
874
+ actions?: ReactElement | null;
875
+ }): ReactElement => {
876
+ return (
877
+ <div className="flex items-center justify-between px-3 pt-3 pb-1.5">
878
+ <span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-gray-400">
879
+ {args.label}
880
+ </span>
881
+ {args.actions ? (
882
+ <div className="flex items-center gap-1 text-[11px]">
883
+ {args.actions}
884
+ </div>
885
+ ) : null}
886
+ </div>
887
+ );
888
+ };
889
+
890
+ const renderSectionActions: (args: {
891
+ onSelectAll: () => void;
892
+ onClear: () => void;
893
+ }) => ReactElement = (args: {
894
+ onSelectAll: () => void;
895
+ onClear: () => void;
896
+ }): ReactElement => {
897
+ return (
898
+ <>
899
+ <button
900
+ type="button"
901
+ onClick={args.onSelectAll}
902
+ className="font-medium text-indigo-600 hover:text-indigo-700"
903
+ >
904
+ Select all
905
+ </button>
906
+ <span className="text-gray-300">·</span>
907
+ <button
908
+ type="button"
909
+ onClick={args.onClear}
910
+ className="font-medium text-gray-500 hover:text-gray-700"
911
+ >
912
+ Clear
913
+ </button>
914
+ </>
915
+ );
916
+ };
917
+
918
+ return (
919
+ <div className="space-y-2">
920
+ {renderToolbar()}
921
+
922
+ {loadError && (
923
+ <div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50/60 px-3 py-2 text-xs text-red-800">
924
+ <Icon
925
+ icon={IconProp.AltGlobe}
926
+ className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0"
927
+ />
928
+ <div>
929
+ <p className="font-semibold">Could not load fields</p>
930
+ <p className="text-red-700">{loadError}</p>
931
+ </div>
932
+ </div>
933
+ )}
934
+
935
+ {isLockedToJson && (
936
+ <div className="rounded-md border border-amber-200 bg-amber-50/60 px-3 py-2.5">
937
+ <div className="flex items-start gap-2.5">
938
+ <Icon
939
+ icon={IconProp.Info}
940
+ className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0"
941
+ />
942
+ <div className="text-xs text-amber-900 space-y-1">
943
+ <p className="font-semibold">Keeping your selection as JSON</p>
944
+ <ul className="text-amber-800 space-y-0.5 list-disc pl-4">
945
+ {incompatibilityReasons.map((reason: string, i: number) => {
946
+ return <li key={i}>{reason}</li>;
947
+ })}
948
+ </ul>
949
+ <p className="pt-0.5 text-amber-700/80">
950
+ Your workflow keeps running unchanged. Edit the JSON below, or
951
+ click <strong>Reset to picker</strong> to start fresh.
952
+ </p>
953
+ </div>
954
+ </div>
955
+ </div>
956
+ )}
957
+
958
+ {(viewMode === "json" || loadError) && (
959
+ <div className="rounded-md border border-gray-200 overflow-hidden">
960
+ <CodeEditor
961
+ type={CodeType.JSON}
962
+ value={jsonText}
963
+ onChange={onJsonChange}
964
+ placeholder={
965
+ props.placeholder || 'Example: {"columnName": true, ...}'
966
+ }
967
+ error={props.error}
968
+ tabIndex={props.tabIndex}
969
+ />
970
+ </div>
971
+ )}
972
+
973
+ {viewMode === "picker" && !loadError && (
974
+ <div className="rounded-md border border-gray-200 bg-white">
975
+ {totalAvailable === 0 && (
976
+ <div className="px-6 py-12 text-center">
977
+ <Icon
978
+ icon={IconProp.Database}
979
+ className="h-6 w-6 mx-auto text-gray-300 mb-2"
980
+ />
981
+ <p className="text-sm text-gray-500">
982
+ No readable fields are available for this model.
983
+ </p>
984
+ </div>
985
+ )}
986
+
987
+ {totalAvailable > 0 && hasNoVisibleResults && (
988
+ <div className="px-6 py-10 text-center">
989
+ <Icon
990
+ icon={IconProp.MagnifyingGlass}
991
+ className="h-5 w-5 mx-auto text-gray-300 mb-2"
992
+ />
993
+ <p className="text-sm text-gray-600">
994
+ No fields match{" "}
995
+ <span className="font-medium text-gray-900">
996
+ &ldquo;{searchQuery}&rdquo;
997
+ </span>
998
+ </p>
999
+ <button
1000
+ type="button"
1001
+ onClick={() => {
1002
+ return setSearchQuery("");
1003
+ }}
1004
+ className="mt-2 text-xs font-medium text-indigo-600 hover:text-indigo-700"
1005
+ >
1006
+ Clear search
1007
+ </button>
1008
+ </div>
1009
+ )}
1010
+
1011
+ {visibleScalars.length > 0 && (
1012
+ <>
1013
+ {renderSectionHeader({
1014
+ label: "Fields",
1015
+ actions: renderSectionActions({
1016
+ onSelectAll: selectAllScalars,
1017
+ onClear: clearAllScalars,
1018
+ }),
1019
+ })}
1020
+ <div className="divide-y divide-gray-100">
1021
+ {visibleScalars.map((column: PickerColumn) => {
1022
+ return (
1023
+ <React.Fragment key={column.id}>
1024
+ {renderFieldRow({
1025
+ column,
1026
+ checked: scalarChecks.has(column.id),
1027
+ onToggle: () => {
1028
+ toggleScalar(column.id);
1029
+ },
1030
+ })}
1031
+ </React.Fragment>
1032
+ );
1033
+ })}
1034
+ </div>
1035
+ </>
1036
+ )}
1037
+
1038
+ {visibleScalars.length > 0 && visibleRelations.length > 0 && (
1039
+ <div className="border-t border-gray-100 mt-2" />
1040
+ )}
1041
+
1042
+ {visibleRelations.length > 0 && (
1043
+ <>
1044
+ {renderSectionHeader({
1045
+ label: "Related records",
1046
+ actions: (
1047
+ <span className="text-[10px] font-medium uppercase tracking-wider text-gray-300">
1048
+ 1 level
1049
+ </span>
1050
+ ),
1051
+ })}
1052
+ <div className="divide-y divide-gray-100">
1053
+ {visibleRelations.map(
1054
+ (entry: {
1055
+ column: PickerColumn;
1056
+ visibleSubs: Array<PickerColumn>;
1057
+ matchedByChild: boolean;
1058
+ }) => {
1059
+ const column: PickerColumn = entry.column;
1060
+ const visibleSubs: Array<PickerColumn> = entry.visibleSubs;
1061
+ const totalSubs: number = (column.relatedColumns || [])
1062
+ .length;
1063
+ const selectedSubs: Set<string> =
1064
+ relationChecks[column.id] || new Set();
1065
+ const isExpanded: boolean =
1066
+ expandedRelations.has(column.id) || entry.matchedByChild;
1067
+ const allChecked: boolean =
1068
+ totalSubs > 0 && selectedSubs.size === totalSubs;
1069
+ const someChecked: boolean =
1070
+ selectedSubs.size > 0 && !allChecked;
1071
+
1072
+ const toggleAllSubs: () => void = (): void => {
1073
+ if (allChecked) {
1074
+ setRelationSelection(column.id, []);
1075
+ } else {
1076
+ const ids: Array<string> = (
1077
+ column.relatedColumns || []
1078
+ ).map((s: PickerColumn) => {
1079
+ return s.id;
1080
+ });
1081
+ setRelationSelection(column.id, ids);
1082
+ expandRelation(column.id);
1083
+ }
1084
+ };
1085
+
1086
+ const selectAllSubs: () => void = (): void => {
1087
+ const ids: Array<string> = visibleSubs.map(
1088
+ (s: PickerColumn) => {
1089
+ return s.id;
1090
+ },
1091
+ );
1092
+ const merged: Set<string> = new Set(selectedSubs);
1093
+ for (const id of ids) {
1094
+ merged.add(id);
1095
+ }
1096
+ setRelationSelection(column.id, Array.from(merged));
1097
+ };
1098
+
1099
+ const clearSubs: () => void = (): void => {
1100
+ const next: Set<string> = new Set(selectedSubs);
1101
+ for (const s of visibleSubs) {
1102
+ next.delete(s.id);
1103
+ }
1104
+ setRelationSelection(column.id, Array.from(next));
1105
+ };
1106
+
1107
+ return (
1108
+ <div
1109
+ key={column.id}
1110
+ className={`${isExpanded ? "bg-gray-50/40" : ""}`}
1111
+ >
1112
+ {/* Header row */}
1113
+ <div
1114
+ className={`relative flex items-center gap-3 px-3 py-2 transition-colors ${
1115
+ isExpanded ? "" : "hover:bg-gray-50/80"
1116
+ }`}
1117
+ >
1118
+ {(allChecked || someChecked) && (
1119
+ <span
1120
+ className="absolute left-0 top-1 bottom-1 w-0.5 rounded-r bg-indigo-500"
1121
+ aria-hidden="true"
1122
+ />
1123
+ )}
1124
+ <PickerCheckbox
1125
+ checked={allChecked}
1126
+ indeterminate={someChecked}
1127
+ onChange={toggleAllSubs}
1128
+ ariaLabel={`Select all from ${column.title}`}
1129
+ />
1130
+ <button
1131
+ type="button"
1132
+ onClick={() => {
1133
+ return toggleExpand(column.id);
1134
+ }}
1135
+ className="flex-1 flex items-center justify-between gap-2 text-left min-w-0"
1136
+ >
1137
+ <span className="min-w-0 flex items-baseline gap-2">
1138
+ <span
1139
+ className={`truncate text-sm font-medium ${
1140
+ allChecked || someChecked
1141
+ ? "text-gray-900"
1142
+ : "text-gray-800"
1143
+ }`}
1144
+ >
1145
+ {column.title}
1146
+ </span>
1147
+ {column.relatedTableName && (
1148
+ <span className="text-[11px] text-gray-400">
1149
+ {column.relatedTableName}
1150
+ </span>
1151
+ )}
1152
+ </span>
1153
+ <span className="flex items-center gap-2 flex-shrink-0">
1154
+ <span className="text-[11px] text-gray-500 font-mono whitespace-nowrap">
1155
+ <span
1156
+ className={
1157
+ selectedSubs.size > 0
1158
+ ? "font-semibold text-indigo-600"
1159
+ : "text-gray-400"
1160
+ }
1161
+ >
1162
+ {selectedSubs.size}
1163
+ </span>
1164
+ <span className="text-gray-300 mx-1">/</span>
1165
+ <span>{totalSubs}</span>
1166
+ </span>
1167
+ <Icon
1168
+ icon={
1169
+ isExpanded
1170
+ ? IconProp.ChevronDown
1171
+ : IconProp.ChevronRight
1172
+ }
1173
+ className="h-3.5 w-3.5 text-gray-400"
1174
+ />
1175
+ </span>
1176
+ </button>
1177
+ </div>
1178
+ {/* Sub-fields */}
1179
+ {isExpanded && (
1180
+ <div className="pl-7 pr-0 pb-1 border-l-2 border-indigo-100 ml-5 mb-1">
1181
+ <div className="flex items-center justify-between px-3 py-1.5">
1182
+ <span className="text-[10px] uppercase tracking-wider text-gray-400 font-medium">
1183
+ Sub-fields
1184
+ </span>
1185
+ <div className="flex items-center gap-1 text-[11px]">
1186
+ {renderSectionActions({
1187
+ onSelectAll: selectAllSubs,
1188
+ onClear: clearSubs,
1189
+ })}
1190
+ </div>
1191
+ </div>
1192
+ <div className="divide-y divide-gray-100">
1193
+ {visibleSubs.map((sub: PickerColumn) => {
1194
+ return (
1195
+ <React.Fragment key={sub.id}>
1196
+ {renderFieldRow({
1197
+ column: sub,
1198
+ checked: selectedSubs.has(sub.id),
1199
+ onToggle: () => {
1200
+ toggleRelationField(column.id, sub.id);
1201
+ },
1202
+ dense: true,
1203
+ })}
1204
+ </React.Fragment>
1205
+ );
1206
+ })}
1207
+ </div>
1208
+ </div>
1209
+ )}
1210
+ </div>
1211
+ );
1212
+ },
1213
+ )}
1214
+ </div>
1215
+ </>
1216
+ )}
1217
+
1218
+ {/* Bottom spacer */}
1219
+ {(visibleScalars.length > 0 || visibleRelations.length > 0) && (
1220
+ <div className="py-1" />
1221
+ )}
1222
+ </div>
1223
+ )}
1224
+
1225
+ {props.error && (
1226
+ <p className="text-xs text-red-600 mt-0.5" data-testid="error-message">
1227
+ {props.error}
1228
+ </p>
1229
+ )}
1230
+ </div>
1231
+ );
1232
+ };
1233
+
1234
+ export default ModelFieldPicker;