@firecms/collection_editor 3.0.1 → 3.1.0-canary.02232f4

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