@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.
- package/UI/Components/Workflow/ArgumentsForm.tsx +55 -12
- package/UI/Components/Workflow/ComponentSettingsModal.tsx +143 -255
- package/UI/Components/Workflow/ModelFieldPicker.tsx +1234 -0
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +32 -10
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +57 -145
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ModelFieldPicker.js +694 -0
- package/build/dist/UI/Components/Workflow/ModelFieldPicker.js.map +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
“{searchQuery}”
|
|
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;
|