@firecms/collection_editor 3.0.1 → 3.1.0-canary.9e89e98

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.
Files changed (90) hide show
  1. package/dist/ConfigControllerProvider.d.ts +6 -0
  2. package/dist/api/generateCollectionApi.d.ts +71 -0
  3. package/dist/api/index.d.ts +1 -0
  4. package/dist/index.d.ts +5 -1
  5. package/dist/index.es.js +9418 -5587
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +9413 -5582
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/types/collection_editor_controller.d.ts +14 -0
  10. package/dist/types/collection_inference.d.ts +8 -2
  11. package/dist/types/config_controller.d.ts +23 -2
  12. package/dist/ui/AddKanbanColumnAction.d.ts +11 -0
  13. package/dist/ui/KanbanSetupAction.d.ts +10 -0
  14. package/dist/ui/collection_editor/AICollectionGeneratorPopover.d.ts +33 -0
  15. package/dist/ui/collection_editor/AIModifiedPathsContext.d.ts +20 -0
  16. package/dist/ui/collection_editor/CollectionDetailsForm.d.ts +2 -3
  17. package/dist/ui/collection_editor/CollectionEditorDialog.d.ts +20 -0
  18. package/dist/ui/collection_editor/CollectionEditorWelcomeView.d.ts +3 -1
  19. package/dist/ui/collection_editor/CollectionJsonImportDialog.d.ts +7 -0
  20. package/dist/ui/collection_editor/CollectionYupValidation.d.ts +9 -13
  21. package/dist/ui/collection_editor/DisplaySettingsForm.d.ts +3 -0
  22. package/dist/ui/collection_editor/EntityActionsEditTab.d.ts +2 -1
  23. package/dist/ui/collection_editor/ExtendSettingsForm.d.ts +14 -0
  24. package/dist/ui/collection_editor/GeneralSettingsForm.d.ts +7 -0
  25. package/dist/ui/collection_editor/KanbanConfigSection.d.ts +4 -0
  26. package/dist/ui/collection_editor/PropertyEditView.d.ts +6 -1
  27. package/dist/ui/collection_editor/PropertyTree.d.ts +2 -1
  28. package/dist/ui/collection_editor/SubcollectionsEditTab.d.ts +2 -1
  29. package/dist/ui/collection_editor/ViewModeSwitch.d.ts +6 -0
  30. package/dist/ui/collection_editor/properties/EnumPropertyField.d.ts +2 -1
  31. package/dist/ui/collection_editor/properties/conditions/ConditionsEditor.d.ts +10 -0
  32. package/dist/ui/collection_editor/properties/conditions/ConditionsPanel.d.ts +2 -0
  33. package/dist/ui/collection_editor/properties/conditions/EnumConditionsEditor.d.ts +6 -0
  34. package/dist/ui/collection_editor/properties/conditions/index.d.ts +6 -0
  35. package/dist/ui/collection_editor/properties/conditions/property_paths.d.ts +19 -0
  36. package/dist/useCollectionEditorPlugin.d.ts +7 -1
  37. package/dist/utils/validateCollectionJson.d.ts +22 -0
  38. package/package.json +11 -11
  39. package/src/ConfigControllerProvider.tsx +81 -47
  40. package/src/api/generateCollectionApi.ts +119 -0
  41. package/src/api/index.ts +1 -0
  42. package/src/index.ts +28 -1
  43. package/src/types/collection_editor_controller.tsx +16 -3
  44. package/src/types/collection_inference.ts +15 -2
  45. package/src/types/config_controller.tsx +27 -2
  46. package/src/ui/AddKanbanColumnAction.tsx +203 -0
  47. package/src/ui/EditorCollectionActionStart.tsx +1 -2
  48. package/src/ui/HomePageEditorCollectionAction.tsx +41 -13
  49. package/src/ui/KanbanSetupAction.tsx +38 -0
  50. package/src/ui/MissingReferenceWidget.tsx +1 -1
  51. package/src/ui/NewCollectionButton.tsx +1 -1
  52. package/src/ui/PropertyAddColumnComponent.tsx +1 -1
  53. package/src/ui/collection_editor/AICollectionGeneratorPopover.tsx +225 -0
  54. package/src/ui/collection_editor/AIModifiedPathsContext.tsx +88 -0
  55. package/src/ui/collection_editor/CollectionDetailsForm.tsx +209 -257
  56. package/src/ui/collection_editor/CollectionEditorDialog.tsx +226 -167
  57. package/src/ui/collection_editor/CollectionEditorWelcomeView.tsx +130 -67
  58. package/src/ui/collection_editor/CollectionJsonImportDialog.tsx +171 -0
  59. package/src/ui/collection_editor/CollectionPropertiesEditorForm.tsx +190 -91
  60. package/src/ui/collection_editor/DisplaySettingsForm.tsx +333 -0
  61. package/src/ui/collection_editor/EntityActionsEditTab.tsx +106 -96
  62. package/src/ui/collection_editor/EntityActionsSelectDialog.tsx +6 -7
  63. package/src/ui/collection_editor/EntityCustomViewsSelectDialog.tsx +1 -3
  64. package/src/ui/collection_editor/EnumForm.tsx +147 -100
  65. package/src/ui/collection_editor/ExtendSettingsForm.tsx +93 -0
  66. package/src/ui/collection_editor/GeneralSettingsForm.tsx +335 -0
  67. package/src/ui/collection_editor/GetCodeDialog.tsx +57 -36
  68. package/src/ui/collection_editor/KanbanConfigSection.tsx +207 -0
  69. package/src/ui/collection_editor/LayoutModeSwitch.tsx +22 -41
  70. package/src/ui/collection_editor/PropertyEditView.tsx +205 -141
  71. package/src/ui/collection_editor/PropertyFieldPreview.tsx +1 -1
  72. package/src/ui/collection_editor/PropertyTree.tsx +130 -58
  73. package/src/ui/collection_editor/SubcollectionsEditTab.tsx +171 -162
  74. package/src/ui/collection_editor/UnsavedChangesDialog.tsx +0 -2
  75. package/src/ui/collection_editor/ViewModeSwitch.tsx +41 -0
  76. package/src/ui/collection_editor/properties/BlockPropertyField.tsx +0 -2
  77. package/src/ui/collection_editor/properties/BooleanPropertyField.tsx +1 -0
  78. package/src/ui/collection_editor/properties/DateTimePropertyField.tsx +117 -35
  79. package/src/ui/collection_editor/properties/EnumPropertyField.tsx +28 -21
  80. package/src/ui/collection_editor/properties/MapPropertyField.tsx +0 -2
  81. package/src/ui/collection_editor/properties/MarkdownPropertyField.tsx +115 -39
  82. package/src/ui/collection_editor/properties/StoragePropertyField.tsx +1 -1
  83. package/src/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +861 -0
  84. package/src/ui/collection_editor/properties/conditions/ConditionsPanel.tsx +28 -0
  85. package/src/ui/collection_editor/properties/conditions/EnumConditionsEditor.tsx +599 -0
  86. package/src/ui/collection_editor/properties/conditions/index.ts +6 -0
  87. package/src/ui/collection_editor/properties/conditions/property_paths.ts +92 -0
  88. package/src/ui/collection_editor/properties/validation/ValidationPanel.tsx +1 -1
  89. package/src/useCollectionEditorPlugin.tsx +32 -17
  90. package/src/utils/validateCollectionJson.ts +380 -0
@@ -0,0 +1,861 @@
1
+ import React from "react";
2
+ import { useFormex } from "@firecms/formex";
3
+ import {
4
+ DeleteIcon,
5
+ IconButton,
6
+ Select,
7
+ SelectItem,
8
+ DebouncedTextField,
9
+ Typography,
10
+ Chip,
11
+ BooleanSwitchWithLabel,
12
+ TextField,
13
+ cls,
14
+ defaultBorderMixin
15
+ } from "@firecms/ui";
16
+ import {
17
+ Properties,
18
+ Property,
19
+ PropertyOrBuilder,
20
+ getFieldConfig,
21
+ DEFAULT_FIELD_CONFIGS,
22
+ getIconForWidget,
23
+ EnumValueConfig,
24
+ isPropertyBuilder
25
+ } from "@firecms/core";
26
+ import { PropertyWithId } from "../../PropertyEditView";
27
+ import { getPropertyPaths } from "./property_paths";
28
+
29
+ /**
30
+ * Condition types that can be configured
31
+ */
32
+ const CONDITION_TYPES = [
33
+ { id: "disabled", label: "Disabled", description: "Disable this field when condition is true" },
34
+ { id: "hidden", label: "Hidden", description: "Hide this field when condition is true" },
35
+ { id: "required", label: "Required", description: "Make this field required when condition is true" },
36
+ { id: "readOnly", label: "Read Only", description: "Make this field read-only when condition is true" }
37
+ ] as const;
38
+
39
+ type ConditionType = typeof CONDITION_TYPES[number]["id"];
40
+
41
+ /**
42
+ * Operators for building conditions with their applicable data types
43
+ */
44
+ const OPERATORS = [
45
+ { id: "==", label: "equals", valueType: "any", applicableTo: ["string", "number", "boolean", "date"] },
46
+ { id: "!=", label: "not equals", valueType: "any", applicableTo: ["string", "number", "boolean", "date"] },
47
+ { id: ">", label: "greater than", valueType: "number", applicableTo: ["number", "date"] },
48
+ { id: "<", label: "less than", valueType: "number", applicableTo: ["number", "date"] },
49
+ { id: ">=", label: "greater or equal", valueType: "number", applicableTo: ["number", "date"] },
50
+ { id: "<=", label: "less or equal", valueType: "number", applicableTo: ["number", "date"] },
51
+ { id: "in", label: "contains", valueType: "any", applicableTo: ["array"] },
52
+ { id: "!in", label: "not contains", valueType: "any", applicableTo: ["array"] },
53
+ { id: "!!", label: "has a value", valueType: "none", applicableTo: ["string", "number", "boolean", "array", "map", "date"] },
54
+ { id: "!", label: "is empty", valueType: "none", applicableTo: ["string", "number", "boolean", "array", "map", "date"] },
55
+ { id: "isPast", label: "is in the past", valueType: "none", applicableTo: ["date"] },
56
+ { id: "isFuture", label: "is in the future", valueType: "none", applicableTo: ["date"] }
57
+ ] as const;
58
+
59
+ type OperatorId = typeof OPERATORS[number]["id"];
60
+
61
+ // Context fields with their types
62
+ const CONTEXT_FIELDS = [
63
+ { id: "isNew", label: "Is New Entity", dataType: "boolean", color: "#9c27b0" },
64
+ { id: "entityId", label: "Entity ID", dataType: "string", color: "#2196f3" },
65
+ { id: "user.roles", label: "User Roles", dataType: "array", color: "#ff9800" }
66
+ ] as const;
67
+
68
+ interface SimpleRule {
69
+ field: string;
70
+ operator: OperatorId;
71
+ value: string;
72
+ }
73
+
74
+ type LogicOperator = "and" | "or";
75
+
76
+ /**
77
+ * A condition group can contain rules and/or nested groups
78
+ */
79
+ interface ConditionGroup {
80
+ logic: LogicOperator;
81
+ rules: SimpleRule[];
82
+ groups?: ConditionGroup[]; // Nested groups for complex logic
83
+ }
84
+
85
+ /**
86
+ * Get the property at a given path.
87
+ * Returns undefined if the path contains a PropertyBuilder (callback).
88
+ */
89
+ function getPropertyAtPath(
90
+ fieldPath: string,
91
+ collectionProperties?: Properties
92
+ ): Property | undefined {
93
+ if (!collectionProperties) return undefined;
94
+
95
+ const parts = fieldPath.split(".");
96
+ let current: Properties | undefined = collectionProperties;
97
+ let property: Property | undefined;
98
+
99
+ for (const part of parts) {
100
+ if (!current) return undefined;
101
+ const propertyOrBuilder = current[part] as PropertyOrBuilder | undefined;
102
+ if (!propertyOrBuilder) return undefined;
103
+
104
+ // Skip PropertyBuilder functions - they require runtime values
105
+ if (isPropertyBuilder(propertyOrBuilder)) return undefined;
106
+
107
+ property = propertyOrBuilder as Property;
108
+
109
+ if (property.dataType === "map" && property.properties) {
110
+ current = property.properties as Properties;
111
+ } else {
112
+ current = undefined;
113
+ }
114
+ }
115
+
116
+ return property;
117
+ }
118
+
119
+ /**
120
+ * Get the data type for a field path
121
+ */
122
+ function getFieldDataType(
123
+ fieldPath: string,
124
+ collectionProperties?: Properties
125
+ ): string {
126
+ // Check context fields first
127
+ const contextField = CONTEXT_FIELDS.find(f => f.id === fieldPath);
128
+ if (contextField) return contextField.dataType;
129
+
130
+ const property = getPropertyAtPath(fieldPath, collectionProperties);
131
+ return property?.dataType ?? "string";
132
+ }
133
+
134
+ /**
135
+ * Get enum values for a property if it has them
136
+ */
137
+ function getEnumValues(property: Property | undefined): EnumValueConfig[] | undefined {
138
+ if (!property) return undefined;
139
+ if (property.dataType === "string" && property.enumValues) {
140
+ // Normalize to array format
141
+ if (Array.isArray(property.enumValues)) {
142
+ return property.enumValues;
143
+ }
144
+ // Handle object format { key: label }
145
+ return Object.entries(property.enumValues).map(([id, label]) => ({
146
+ id,
147
+ label: typeof label === "string" ? label : (label as EnumValueConfig).label
148
+ }));
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ /**
154
+ * Get operators applicable for a given data type
155
+ */
156
+ function getOperatorsForDataType(dataType: string) {
157
+ return OPERATORS.filter(op => {
158
+ const applicable = op.applicableTo as readonly string[];
159
+ return applicable.includes(dataType) ||
160
+ (applicable.includes("string") && !["number", "date", "array"].includes(dataType));
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Props for a single rule row (used inside a group)
166
+ */
167
+ interface RuleRowProps {
168
+ rule: SimpleRule;
169
+ onRuleChange: (rule: SimpleRule) => void;
170
+ onRemove: () => void;
171
+ disabled: boolean;
172
+ availableFields: string[];
173
+ collectionProperties?: Properties;
174
+ showErrors: boolean;
175
+ showRemoveButton: boolean;
176
+ }
177
+
178
+ /**
179
+ * Props for a condition group (with AND/OR logic)
180
+ */
181
+ interface ConditionGroupRowProps {
182
+ conditionType: ConditionType;
183
+ group: ConditionGroup;
184
+ onGroupChange: (group: ConditionGroup) => void;
185
+ onRemove: () => void;
186
+ disabled: boolean;
187
+ availableFields: string[];
188
+ collectionProperties?: Properties;
189
+ showErrors: boolean;
190
+ }
191
+
192
+ /**
193
+ * Dynamic value input based on field data type and enum values
194
+ */
195
+ function ConditionValueInput({
196
+ value,
197
+ onChange,
198
+ dataType,
199
+ enumValues,
200
+ disabled,
201
+ operator
202
+ }: {
203
+ value: string;
204
+ onChange: (value: string) => void;
205
+ dataType: string;
206
+ enumValues?: EnumValueConfig[];
207
+ disabled: boolean;
208
+ operator: OperatorId;
209
+ }) {
210
+ // For truthy/falsy operators, no value needed
211
+ if (operator === "!!" || operator === "!") {
212
+ return null;
213
+ }
214
+
215
+ // Boolean - use switch
216
+ if (dataType === "boolean") {
217
+ return (
218
+ <BooleanSwitchWithLabel
219
+ value={value === "true"}
220
+ size="small"
221
+ position="start"
222
+ disabled={disabled}
223
+ onValueChange={(newValue) => onChange(newValue ? "true" : "false")}
224
+ />
225
+ );
226
+ }
227
+
228
+ // Enum/string with enumValues - use select
229
+ if (enumValues && enumValues.length > 0) {
230
+ return (
231
+ <Select
232
+ value={value}
233
+ onValueChange={onChange}
234
+ disabled={disabled}
235
+ size="small"
236
+ className="min-w-[120px]"
237
+ placeholder="Select value">
238
+ {enumValues.map(ev => (
239
+ <SelectItem key={ev.id} value={String(ev.id)}>
240
+ {ev.label}
241
+ </SelectItem>
242
+ ))}
243
+ </Select>
244
+ );
245
+ }
246
+
247
+ // Number - use number input
248
+ if (dataType === "number") {
249
+ return (
250
+ <TextField
251
+ value={value}
252
+ type="number"
253
+ size="small"
254
+ disabled={disabled}
255
+ placeholder="Value"
256
+ className="flex-1 min-w-[80px]"
257
+ onChange={(e) => onChange(e.target.value)}
258
+ />
259
+ );
260
+ }
261
+
262
+ // Array - comma separated values
263
+ if (dataType === "array") {
264
+ return (
265
+ <DebouncedTextField
266
+ value={value}
267
+ onChange={(e) => onChange(e.target.value)}
268
+ disabled={disabled}
269
+ size="small"
270
+ placeholder="value1, value2"
271
+ className="flex-1 min-w-[120px]"
272
+ />
273
+ );
274
+ }
275
+
276
+ // Default - text input
277
+ return (
278
+ <DebouncedTextField
279
+ value={value}
280
+ onChange={(e) => onChange(e.target.value)}
281
+ disabled={disabled}
282
+ size="small"
283
+ placeholder="Value"
284
+ className="flex-1 min-w-[100px]"
285
+ inputClassName="min-w-[100px]"
286
+ />
287
+ );
288
+ }
289
+
290
+ function ConditionRow({
291
+ rule,
292
+ onRuleChange,
293
+ onRemove,
294
+ disabled,
295
+ availableFields,
296
+ collectionProperties,
297
+ showErrors,
298
+ showRemoveButton = true
299
+ }: RuleRowProps & { showRemoveButton?: boolean }) {
300
+ const fieldDataType = getFieldDataType(rule.field, collectionProperties);
301
+ const applicableOperators = getOperatorsForDataType(fieldDataType);
302
+ const property = getPropertyAtPath(rule.field, collectionProperties);
303
+ const enumValues = getEnumValues(property);
304
+ const operator = OPERATORS.find(op => op.id === rule.operator);
305
+ const showValueField = operator?.valueType !== "none";
306
+
307
+ // Validation: check for incomplete condition (only show after submission attempt)
308
+ const isFieldMissing = !rule.field;
309
+ const isValueMissing = showValueField && !rule.value && rule.value !== "0";
310
+ const hasError = showErrors && (isFieldMissing || isValueMissing);
311
+
312
+ // Get color for the field
313
+ const getFieldColor = (fieldPath: string): string => {
314
+ const contextField = CONTEXT_FIELDS.find(f => f.id === fieldPath);
315
+ if (contextField) return contextField.color;
316
+
317
+ if (!collectionProperties) return "#888";
318
+
319
+ const prop = getPropertyAtPath(fieldPath, collectionProperties);
320
+ if (!prop) return "#888";
321
+ const config = getFieldConfig(prop, DEFAULT_FIELD_CONFIGS);
322
+ return config?.color ?? "#888";
323
+ };
324
+
325
+ return (
326
+ <div className={cls(
327
+ "flex items-center gap-2 p-2 rounded-md",
328
+ hasError ? "bg-red-50 dark:bg-red-900/20" : "bg-surface-100 dark:bg-surface-800"
329
+ )}>
330
+ {/* Field selector with colored badge */}
331
+ <Select
332
+ value={rule.field}
333
+ onValueChange={(value) => {
334
+ const newDataType = getFieldDataType(value, collectionProperties);
335
+ const newApplicableOps = getOperatorsForDataType(newDataType);
336
+ // Reset operator if current one isn't applicable
337
+ const newOperator = newApplicableOps.find(op => op.id === rule.operator)
338
+ ? rule.operator
339
+ : newApplicableOps[0]?.id ?? "==";
340
+ // Reset value when changing field
341
+ onRuleChange({ ...rule, field: value, operator: newOperator as OperatorId, value: "" });
342
+ }}
343
+ disabled={disabled}
344
+ size="small"
345
+ inputClassName="min-w-[180px]"
346
+ renderValue={(value) => (
347
+ <div className="flex items-center gap-2">
348
+ <div
349
+ className="w-2 h-2 rounded-full shrink-0"
350
+ style={{ backgroundColor: getFieldColor(value) }}
351
+ />
352
+ <span className="truncate">{value}</span>
353
+ </div>
354
+ )}
355
+ placeholder="Select field">
356
+ {/* Context fields */}
357
+ {CONTEXT_FIELDS.map(field => (
358
+ <SelectItem key={field.id} value={field.id}>
359
+ <div className="flex items-center gap-2">
360
+ <div
361
+ className="w-3 h-3 rounded-full shrink-0"
362
+ style={{ backgroundColor: field.color }}
363
+ />
364
+ <span>{field.label}</span>
365
+ <Chip size="small" colorScheme="grayLight" className="ml-auto">
366
+ {field.dataType}
367
+ </Chip>
368
+ </div>
369
+ </SelectItem>
370
+ ))}
371
+
372
+ {/* Separator */}
373
+ {availableFields.length > 0 && (
374
+ <SelectItem value="_divider" disabled>
375
+ <span className="text-xs text-surface-500">─ Entity Fields ─</span>
376
+ </SelectItem>
377
+ )}
378
+
379
+ {/* Property fields */}
380
+ {availableFields.map(field => {
381
+ const color = getFieldColor(field);
382
+ const dataType = getFieldDataType(field, collectionProperties);
383
+ const prop = getPropertyAtPath(field, collectionProperties);
384
+ const hasEnum = !!getEnumValues(prop);
385
+ return (
386
+ <SelectItem key={field} value={field}>
387
+ <div className="flex items-center gap-2">
388
+ <div
389
+ className="w-3 h-3 rounded-full shrink-0"
390
+ style={{ backgroundColor: color }}
391
+ />
392
+ <span>{field}</span>
393
+ <Chip size="small" colorScheme="grayLight" className="ml-auto">
394
+ {hasEnum ? "enum" : dataType}
395
+ </Chip>
396
+ </div>
397
+ </SelectItem>
398
+ );
399
+ })}
400
+ </Select>
401
+
402
+ {/* Operator - filtered by field type */}
403
+ <Select
404
+ value={rule.operator}
405
+ onValueChange={(value) => onRuleChange({ ...rule, operator: value as OperatorId })}
406
+ disabled={disabled}
407
+ size="small"
408
+ inputClassName="min-w-[120px]">
409
+ {applicableOperators.map(op => (
410
+ <SelectItem key={op.id} value={op.id}>
411
+ {op.label}
412
+ </SelectItem>
413
+ ))}
414
+ </Select>
415
+
416
+ {/* Dynamic Value input */}
417
+ {showValueField && (
418
+ <ConditionValueInput
419
+ value={rule.value}
420
+ onChange={(newValue) => onRuleChange({ ...rule, value: newValue })}
421
+ dataType={fieldDataType}
422
+ enumValues={enumValues}
423
+ disabled={disabled}
424
+ operator={rule.operator}
425
+ />
426
+ )}
427
+
428
+ {/* Remove button */}
429
+ {showRemoveButton && (
430
+ <IconButton onClick={onRemove} disabled={disabled} size="small" variant="ghost">
431
+ <DeleteIcon size="smallest" />
432
+ </IconButton>
433
+ )}
434
+ </div>
435
+ );
436
+ }
437
+
438
+ /**
439
+ * A condition group row with AND/OR logic selector and multiple rules
440
+ */
441
+ function ConditionGroupRow({
442
+ conditionType,
443
+ group,
444
+ onGroupChange,
445
+ onRemove,
446
+ disabled,
447
+ availableFields,
448
+ collectionProperties,
449
+ showErrors
450
+ }: ConditionGroupRowProps) {
451
+ const handleLogicChange = (logic: LogicOperator) => {
452
+ onGroupChange({ ...group, logic });
453
+ };
454
+
455
+ const handleRuleChange = (index: number, rule: SimpleRule) => {
456
+ const newRules = [...group.rules];
457
+ newRules[index] = rule;
458
+ onGroupChange({ ...group, rules: newRules });
459
+ };
460
+
461
+ const handleRemoveRule = (index: number) => {
462
+ const newRules = group.rules.filter((_, i) => i !== index);
463
+ if (newRules.length === 0) {
464
+ onRemove();
465
+ } else {
466
+ onGroupChange({ ...group, rules: newRules });
467
+ }
468
+ };
469
+
470
+ const handleAddRule = () => {
471
+ const defaultRule: SimpleRule = {
472
+ field: availableFields[0] ?? "isNew",
473
+ operator: "==",
474
+ value: ""
475
+ };
476
+ onGroupChange({ ...group, rules: [...group.rules, defaultRule] });
477
+ };
478
+
479
+ return (
480
+ <div className={cls("p-3 bg-surface-50 dark:bg-surface-900 rounded-lg border", defaultBorderMixin)}>
481
+ <div className="flex items-center justify-between mb-3">
482
+ <div className="flex items-center gap-2">
483
+ <Typography variant="label" className="font-medium text-primary">
484
+ {CONDITION_TYPES.find(ct => ct.id === conditionType)?.label} when
485
+ </Typography>
486
+ {group.rules.length > 1 && (
487
+ <Select
488
+ value={group.logic}
489
+ onValueChange={(value) => handleLogicChange(value as LogicOperator)}
490
+ disabled={disabled}
491
+ size="small"
492
+ inputClassName="min-w-[100px]">
493
+ <SelectItem value="and">All of these</SelectItem>
494
+ <SelectItem value="or">Any of these</SelectItem>
495
+ </Select>
496
+ )}
497
+ </div>
498
+ <IconButton onClick={onRemove} disabled={disabled} size="small" variant="ghost">
499
+ <DeleteIcon size="smallest" />
500
+ </IconButton>
501
+ </div>
502
+
503
+ <div className="flex flex-col gap-2">
504
+ {group.rules.map((rule, index) => (
505
+ <React.Fragment key={index}>
506
+ {index > 0 && (
507
+ <Typography variant="caption" className="text-center text-secondary font-medium uppercase">
508
+ {group.logic === "and" ? "AND" : "OR"}
509
+ </Typography>
510
+ )}
511
+ <ConditionRow
512
+ rule={rule}
513
+ onRuleChange={(newRule: SimpleRule) => handleRuleChange(index, newRule)}
514
+ onRemove={() => handleRemoveRule(index)}
515
+ disabled={disabled}
516
+ availableFields={availableFields}
517
+ collectionProperties={collectionProperties}
518
+ showErrors={showErrors}
519
+ showRemoveButton={group.rules.length > 1}
520
+ />
521
+ </React.Fragment>
522
+ ))}
523
+ </div>
524
+
525
+ <button
526
+ onClick={handleAddRule}
527
+ disabled={disabled}
528
+ type="button"
529
+ className={cls(
530
+ "mt-3 w-full py-2 px-3 rounded-md text-sm",
531
+ "border border-dashed text-secondary",
532
+ "hover:bg-surface-100 dark:hover:bg-surface-800",
533
+ "transition-colors",
534
+ defaultBorderMixin
535
+ )}
536
+ >
537
+ + Add condition
538
+ </button>
539
+ </div>
540
+ );
541
+ }
542
+
543
+ /**
544
+ * Convert a simple rule to JSON-Logic format
545
+ */
546
+ function simpleRuleToJsonLogic(rule: SimpleRule): Record<string, any> {
547
+ // Guard against empty field or operator which creates invalid Firestore keys
548
+ if (!rule.field || !rule.operator) {
549
+ // Return a valid default rule instead of creating invalid data
550
+ return { "!!": { var: "values._placeholder" } };
551
+ }
552
+
553
+ const varRef = { var: `values.${rule.field}` };
554
+
555
+ // Handle special fields that don't need the "values." prefix
556
+ const fieldVar = rule.field.startsWith("user.") || rule.field === "isNew" || rule.field === "entityId"
557
+ ? { var: rule.field }
558
+ : varRef;
559
+
560
+ if (rule.operator === "!!") {
561
+ return { "!!": fieldVar };
562
+ }
563
+ if (rule.operator === "!") {
564
+ return { "!": fieldVar };
565
+ }
566
+ if (rule.operator === "isPast") {
567
+ return { "isPast": fieldVar };
568
+ }
569
+ if (rule.operator === "isFuture") {
570
+ return { "isFuture": fieldVar };
571
+ }
572
+
573
+ // Parse value - try to convert to appropriate type
574
+ let parsedValue: any = rule.value;
575
+ if (rule.value === "true") parsedValue = true;
576
+ else if (rule.value === "false") parsedValue = false;
577
+ else if (rule.value === "null") parsedValue = null;
578
+ else if (!isNaN(Number(rule.value)) && rule.value !== "") parsedValue = Number(rule.value);
579
+
580
+ // Handle array values for "in" and "!in" operators
581
+ if ((rule.operator === "in" || rule.operator === "!in") && typeof parsedValue === "string") {
582
+ parsedValue = parsedValue.split(",").map(s => s.trim());
583
+ }
584
+
585
+ // Handle "!in" (not contains) - wrap "in" with negation
586
+ if (rule.operator === "!in") {
587
+ return { "!": { "in": [fieldVar, parsedValue] } };
588
+ }
589
+
590
+ return { [rule.operator]: [fieldVar, parsedValue] };
591
+ }
592
+
593
+ /**
594
+ * Try to parse JSON-Logic back to a simple rule
595
+ */
596
+ function jsonLogicToSimpleRule(jsonLogic: Record<string, any>): SimpleRule | null {
597
+ try {
598
+ const operator = Object.keys(jsonLogic)[0] as OperatorId;
599
+ const args = jsonLogic[operator];
600
+
601
+ // Handle "!" operator - could be negation of a value or negation of "in" (not contains)
602
+ if (operator === "!") {
603
+ // Check if it's a negated "in" operation: {"!": {"in": [...]}}
604
+ if (typeof args === "object" && args !== null && "in" in args) {
605
+ const inArgs = args.in;
606
+ if (Array.isArray(inArgs) && inArgs.length === 2) {
607
+ const [left, right] = inArgs;
608
+ if (left?.var) {
609
+ const field = left.var.replace(/^values\./, "");
610
+ const value = Array.isArray(right)
611
+ ? right.join(", ")
612
+ : String(right ?? "");
613
+ return { field, operator: "!in", value };
614
+ }
615
+ }
616
+ }
617
+ // Otherwise it's a simple negation (is falsy)
618
+ if (args?.var) {
619
+ const field = args.var.replace(/^values\./, "");
620
+ return { field, operator: "!", value: "" };
621
+ }
622
+ }
623
+
624
+ if (operator === "!!") {
625
+ const varObj = args;
626
+ if (varObj?.var) {
627
+ const field = varObj.var.replace(/^values\./, "");
628
+ return { field, operator, value: "" };
629
+ }
630
+ }
631
+
632
+ // Handle isPast and isFuture operators
633
+ if (operator === "isPast" || operator === "isFuture") {
634
+ const varObj = args;
635
+ if (varObj?.var) {
636
+ const field = varObj.var.replace(/^values\./, "");
637
+ return { field, operator, value: "" };
638
+ }
639
+ }
640
+
641
+ if (Array.isArray(args) && args.length === 2) {
642
+ const [left, right] = args;
643
+ if (left?.var) {
644
+ const field = left.var.replace(/^values\./, "");
645
+ const value = Array.isArray(right)
646
+ ? right.join(", ")
647
+ : String(right ?? "");
648
+ return { field, operator, value };
649
+ }
650
+ }
651
+ } catch {
652
+ // Fall through
653
+ }
654
+ return null;
655
+ }
656
+
657
+ /**
658
+ * Convert a condition group to JSON-Logic format
659
+ */
660
+ function groupToJsonLogic(group: ConditionGroup): Record<string, any> {
661
+ const ruleLogics = group.rules
662
+ .filter(rule => rule.field && rule.operator) // Filter out incomplete rules
663
+ .map(rule => simpleRuleToJsonLogic(rule));
664
+
665
+ const groupLogics = (group.groups || [])
666
+ .map(g => groupToJsonLogic(g));
667
+
668
+ const allLogics = [...ruleLogics, ...groupLogics];
669
+
670
+ // If only one condition, don't wrap in and/or
671
+ if (allLogics.length === 1) {
672
+ return allLogics[0];
673
+ }
674
+
675
+ // If no conditions, return a default true condition
676
+ if (allLogics.length === 0) {
677
+ return { "!!": { var: "values._placeholder" } };
678
+ }
679
+
680
+ return { [group.logic]: allLogics };
681
+ }
682
+
683
+ /**
684
+ * Try to parse JSON-Logic back to a condition group
685
+ */
686
+ function jsonLogicToGroup(jsonLogic: Record<string, any>): ConditionGroup | null {
687
+ try {
688
+ const operator = Object.keys(jsonLogic)[0];
689
+
690
+ // Check if it's an AND or OR group
691
+ if (operator === "and" || operator === "or") {
692
+ const args = jsonLogic[operator];
693
+ if (Array.isArray(args)) {
694
+ const rules: SimpleRule[] = [];
695
+ const groups: ConditionGroup[] = [];
696
+
697
+ for (const arg of args) {
698
+ // Check if this is a nested group
699
+ const nestedOperator = Object.keys(arg)[0];
700
+ if (nestedOperator === "and" || nestedOperator === "or") {
701
+ const nestedGroup = jsonLogicToGroup(arg);
702
+ if (nestedGroup) {
703
+ groups.push(nestedGroup);
704
+ }
705
+ } else {
706
+ // It's a simple rule
707
+ const rule = jsonLogicToSimpleRule(arg);
708
+ if (rule) {
709
+ rules.push(rule);
710
+ }
711
+ }
712
+ }
713
+
714
+ return {
715
+ logic: operator,
716
+ rules,
717
+ groups: groups.length > 0 ? groups : undefined
718
+ };
719
+ }
720
+ }
721
+
722
+ // It's a single rule - wrap in an AND group
723
+ const rule = jsonLogicToSimpleRule(jsonLogic);
724
+ if (rule) {
725
+ return {
726
+ logic: "and",
727
+ rules: [rule]
728
+ };
729
+ }
730
+ } catch {
731
+ // Fall through
732
+ }
733
+ return null;
734
+ }
735
+
736
+ export interface ConditionsEditorProps {
737
+ disabled: boolean;
738
+ /**
739
+ * Optional collection properties for populating the field selector.
740
+ * If not provided, a basic set of common fields is used.
741
+ */
742
+ collectionProperties?: Properties;
743
+ }
744
+
745
+ export function ConditionsEditor({ disabled, collectionProperties }: ConditionsEditorProps) {
746
+ const { values, setFieldValue, submitCount } = useFormex<PropertyWithId>();
747
+
748
+ // Get property paths from collection properties (includes nested maps)
749
+ const availableFields: string[] = collectionProperties
750
+ ? getPropertyPaths(collectionProperties)
751
+ : [];
752
+
753
+ // Get current conditions from form values
754
+ const conditions = (values as any).conditions ?? {};
755
+
756
+ // DEBUG: Log conditions to see what's being loaded
757
+ console.log("[ConditionsEditor] Loaded conditions:", conditions);
758
+
759
+ const activeConditions: { type: ConditionType; group: ConditionGroup }[] = [];
760
+
761
+ for (const type of CONDITION_TYPES) {
762
+ const jsonLogic = conditions[type.id as keyof typeof conditions];
763
+ if (jsonLogic) {
764
+ console.log(`[ConditionsEditor] Parsing ${type.id}:`, jsonLogic);
765
+ const group = jsonLogicToGroup(jsonLogic as Record<string, any>);
766
+ console.log(`[ConditionsEditor] Parsed group:`, group);
767
+ if (group) {
768
+ activeConditions.push({ type: type.id, group });
769
+ }
770
+ }
771
+ }
772
+
773
+ const handleAddCondition = (conditionType: ConditionType) => {
774
+ const defaultRule: SimpleRule = {
775
+ field: availableFields[0] ?? "isNew",
776
+ operator: "==",
777
+ value: ""
778
+ };
779
+
780
+ const defaultGroup: ConditionGroup = {
781
+ logic: "and",
782
+ rules: [defaultRule]
783
+ };
784
+
785
+ const jsonLogic = groupToJsonLogic(defaultGroup);
786
+ setFieldValue(`conditions.${conditionType}`, jsonLogic);
787
+ };
788
+
789
+ const handleGroupChange = (type: ConditionType, group: ConditionGroup) => {
790
+ const jsonLogic = groupToJsonLogic(group);
791
+ setFieldValue(`conditions.${type}`, jsonLogic);
792
+ };
793
+
794
+ const handleRemoveCondition = (type: ConditionType) => {
795
+ const newConditions = { ...conditions };
796
+ delete (newConditions as any)[type];
797
+ if (Object.keys(newConditions).length === 0) {
798
+ setFieldValue("conditions", undefined);
799
+ } else {
800
+ setFieldValue("conditions", newConditions);
801
+ }
802
+ };
803
+
804
+ // Condition types that aren't already used
805
+ const availableConditionTypes = CONDITION_TYPES.filter(
806
+ ct => !conditions[ct.id as keyof typeof conditions]
807
+ );
808
+
809
+ return (
810
+ <div className="flex flex-col gap-4">
811
+ <Typography variant="caption" color="secondary">
812
+ Add conditions to dynamically control this field based on other field values or user context.
813
+ </Typography>
814
+
815
+ {/* Active conditions */}
816
+ {activeConditions.length > 0 && (
817
+ <div className="flex flex-col gap-3">
818
+ {activeConditions.map(({ type, group }) => (
819
+ <ConditionGroupRow
820
+ key={type}
821
+ conditionType={type}
822
+ group={group}
823
+ onGroupChange={(newGroup) => handleGroupChange(type, newGroup)}
824
+ onRemove={() => handleRemoveCondition(type)}
825
+ disabled={disabled}
826
+ availableFields={availableFields}
827
+ collectionProperties={collectionProperties}
828
+ showErrors={submitCount > 0}
829
+ />
830
+ ))}
831
+ </div>
832
+ )}
833
+
834
+ {/* Add new condition - click to add directly */}
835
+ {availableConditionTypes.length > 0 && (
836
+ <Select
837
+ value=""
838
+ onValueChange={(value) => handleAddCondition(value as ConditionType)}
839
+ disabled={disabled}
840
+ size="small"
841
+ placeholder="+ Add condition..."
842
+ className="w-full max-w-xs">
843
+ {availableConditionTypes.map(ct => (
844
+ <SelectItem key={ct.id} value={ct.id}>
845
+ <div className="flex flex-col">
846
+ <span className="font-medium">{ct.label}</span>
847
+ <span className="text-xs text-surface-500">{ct.description}</span>
848
+ </div>
849
+ </SelectItem>
850
+ ))}
851
+ </Select>
852
+ )}
853
+
854
+ {activeConditions.length === 0 && availableConditionTypes.length === 0 && (
855
+ <Typography variant="caption" color="disabled" className="text-center py-4">
856
+ All condition types are configured.
857
+ </Typography>
858
+ )}
859
+ </div>
860
+ );
861
+ }