@firecms/core 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.
- package/README.md +1 -1
- package/dist/components/AIIcon.d.ts +16 -0
- package/dist/components/EntityCollectionTable/EntityCollectionRowActions.d.ts +7 -1
- package/dist/components/EntityCollectionTable/EntityCollectionTable.d.ts +1 -1
- package/dist/components/EntityCollectionTable/EntityCollectionTableProps.d.ts +14 -0
- package/dist/components/EntityCollectionTable/PropertyTableCell.d.ts +6 -0
- package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +5 -4
- package/dist/components/EntityCollectionTable/internal/EntityTableCell.d.ts +6 -0
- package/dist/components/EntityCollectionView/Board.d.ts +2 -0
- package/dist/components/EntityCollectionView/BoardColumn.d.ts +42 -0
- package/dist/components/EntityCollectionView/BoardColumnTitle.d.ts +9 -0
- package/dist/components/EntityCollectionView/BoardSortableList.d.ts +14 -0
- package/dist/components/EntityCollectionView/EntityBoardCard.d.ts +26 -0
- package/dist/components/EntityCollectionView/EntityCard.d.ts +19 -0
- package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +20 -0
- package/dist/components/EntityCollectionView/EntityCollectionCardView.d.ts +31 -0
- package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +2 -2
- package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +7 -3
- package/dist/components/EntityCollectionView/FiltersDialog.d.ts +14 -0
- package/dist/components/EntityCollectionView/ViewModeToggle.d.ts +44 -0
- package/dist/components/EntityCollectionView/board_types.d.ts +105 -0
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +60 -0
- package/dist/components/SelectableTable/SelectableTable.d.ts +5 -1
- package/dist/components/SelectableTable/filters/DateTimeFilterField.d.ts +2 -1
- package/dist/components/VirtualTable/VirtualTableCell.d.ts +6 -0
- package/dist/components/VirtualTable/VirtualTableHeader.d.ts +2 -0
- package/dist/components/VirtualTable/VirtualTableHeaderRow.d.ts +1 -1
- package/dist/components/VirtualTable/VirtualTableProps.d.ts +11 -0
- package/dist/components/VirtualTable/fields/VirtualTableDateField.d.ts +1 -0
- package/dist/components/VirtualTable/types.d.ts +2 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/contexts/index.d.ts +10 -0
- package/dist/core/DrawerNavigationGroup.d.ts +45 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/form/validation.d.ts +3 -2
- package/dist/hooks/useBreadcrumbsController.d.ts +16 -0
- package/dist/hooks/useCollapsedGroups.d.ts +4 -1
- package/dist/index.es.js +5185 -1561
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +5179 -1556
- package/dist/index.umd.js.map +1 -1
- package/dist/preview/PropertyPreviewProps.d.ts +5 -0
- package/dist/preview/components/DatePreview.d.ts +13 -3
- package/dist/preview/components/ImagePreview.d.ts +5 -1
- package/dist/preview/components/StorageThumbnail.d.ts +2 -1
- package/dist/preview/components/UrlComponentPreview.d.ts +2 -1
- package/dist/preview/property_previews/ArrayOfStorageComponentsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
- package/dist/preview/property_previews/SkeletonPropertyComponent.d.ts +1 -1
- package/dist/types/collections.d.ts +50 -2
- package/dist/types/datasource.d.ts +0 -1
- package/dist/types/plugins.d.ts +46 -1
- package/dist/types/properties.d.ts +259 -4
- package/dist/util/__tests__/conditions.test.d.ts +1 -0
- package/dist/util/__tests__/objects.test.d.ts +1 -0
- package/dist/util/conditions.d.ts +26 -0
- package/dist/util/entities.d.ts +1 -2
- package/dist/util/index.d.ts +2 -1
- package/dist/util/property_utils.d.ts +2 -1
- package/dist/util/resolutions.d.ts +1 -1
- package/package.json +10 -7
- package/src/app/Scaffold.tsx +14 -15
- package/src/components/AIIcon.tsx +39 -0
- package/src/components/ArrayContainer.tsx +1 -4
- package/src/components/ClearFilterSortButton.tsx +19 -16
- package/src/components/ConfirmationDialog.tsx +0 -2
- package/src/components/DeleteEntityDialog.tsx +2 -4
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +74 -41
- package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +130 -79
- package/src/components/EntityCollectionTable/EntityCollectionTableProps.tsx +121 -104
- package/src/components/EntityCollectionTable/PropertyTableCell.tsx +132 -103
- package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +20 -42
- package/src/components/EntityCollectionTable/internal/EntityTableCell.tsx +90 -49
- package/src/components/EntityCollectionView/Board.tsx +324 -0
- package/src/components/EntityCollectionView/BoardColumn.tsx +158 -0
- package/src/components/EntityCollectionView/BoardColumnTitle.tsx +45 -0
- package/src/components/EntityCollectionView/BoardSortableList.tsx +172 -0
- package/src/components/EntityCollectionView/EntityBoardCard.tsx +212 -0
- package/src/components/EntityCollectionView/EntityCard.tsx +231 -0
- package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +713 -0
- package/src/components/EntityCollectionView/EntityCollectionCardView.tsx +244 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +490 -203
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +31 -19
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +84 -15
- package/src/components/EntityCollectionView/FiltersDialog.tsx +249 -0
- package/src/components/EntityCollectionView/ViewModeToggle.tsx +199 -0
- package/src/components/EntityCollectionView/board_types.ts +113 -0
- package/src/components/EntityCollectionView/useBoardDataController.tsx +490 -0
- package/src/components/ErrorTooltip.tsx +2 -1
- package/src/components/HomePage/DefaultHomePage.tsx +47 -10
- package/src/components/HomePage/HomePageDnD.tsx +56 -41
- package/src/components/HomePage/NavigationCard.tsx +20 -18
- package/src/components/HomePage/NavigationGroup.tsx +17 -16
- package/src/components/HomePage/RenameGroupDialog.tsx +0 -2
- package/src/components/HomePage/SmallNavigationCard.tsx +10 -9
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +3 -10
- package/src/components/ReferenceWidget.tsx +2 -4
- package/src/components/SelectableTable/SelectableTable.tsx +75 -67
- package/src/components/SelectableTable/filters/BooleanFilterField.tsx +7 -6
- package/src/components/SelectableTable/filters/DateTimeFilterField.tsx +39 -40
- package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +38 -38
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +49 -58
- package/src/components/UnsavedChangesDialog.tsx +0 -2
- package/src/components/UserDisplay.tsx +4 -4
- package/src/components/VirtualTable/VirtualTable.tsx +170 -19
- package/src/components/VirtualTable/VirtualTableCell.tsx +18 -2
- package/src/components/VirtualTable/VirtualTableHeader.tsx +20 -11
- package/src/components/VirtualTable/VirtualTableHeaderRow.tsx +158 -42
- package/src/components/VirtualTable/VirtualTableProps.tsx +14 -1
- package/src/components/VirtualTable/VirtualTableRow.tsx +1 -1
- package/src/components/VirtualTable/fields/VirtualTableDateField.tsx +3 -0
- package/src/components/VirtualTable/fields/VirtualTableSelect.tsx +17 -4
- package/src/components/VirtualTable/types.tsx +2 -0
- package/src/components/common/useColumnsIds.tsx +95 -3
- package/src/components/index.tsx +4 -0
- package/src/contexts/BreacrumbsContext.tsx +15 -8
- package/src/contexts/index.ts +10 -0
- package/src/core/DefaultAppBar.tsx +39 -26
- package/src/core/DefaultDrawer.tsx +42 -56
- package/src/core/DrawerNavigationGroup.tsx +118 -0
- package/src/core/DrawerNavigationItem.tsx +4 -3
- package/src/core/EntityEditView.tsx +41 -43
- package/src/core/SideDialogs.tsx +4 -2
- package/src/core/index.tsx +1 -0
- package/src/form/PropertyFieldBinding.tsx +58 -43
- package/src/form/components/StorageItemPreview.tsx +2 -1
- package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +0 -1
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +17 -16
- package/src/form/field_bindings/KeyValueFieldBinding.tsx +0 -1
- package/src/form/field_bindings/MapFieldBinding.tsx +69 -67
- package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +21 -17
- package/src/form/field_bindings/TextFieldBinding.tsx +71 -35
- package/src/form/validation.ts +245 -160
- package/src/hooks/useBreadcrumbsController.tsx +18 -0
- package/src/hooks/useBuildNavigationController.tsx +42 -19
- package/src/hooks/useCollapsedGroups.ts +12 -4
- package/src/internal/useBuildDataSource.ts +69 -34
- package/src/internal/useBuildSideDialogsController.tsx +11 -8
- package/src/internal/useBuildSideEntityController.tsx +2 -4
- package/src/internal/useRestoreScroll.tsx +26 -14
- package/src/preview/PropertyPreview.tsx +40 -32
- package/src/preview/PropertyPreviewProps.tsx +6 -0
- package/src/preview/components/DatePreview.tsx +72 -4
- package/src/preview/components/EmptyValue.tsx +1 -1
- package/src/preview/components/ImagePreview.tsx +37 -21
- package/src/preview/components/StorageThumbnail.tsx +16 -12
- package/src/preview/components/UrlComponentPreview.tsx +28 -25
- package/src/preview/property_previews/ArrayOfStorageComponentsPreview.tsx +9 -7
- package/src/preview/property_previews/ArrayOfStringsPreview.tsx +11 -9
- package/src/preview/property_previews/ArrayPropertyPreview.tsx +26 -24
- package/src/preview/property_previews/SkeletonPropertyComponent.tsx +61 -56
- package/src/routes/CustomCMSRoute.tsx +1 -0
- package/src/routes/FireCMSRoute.tsx +26 -13
- package/src/types/collections.ts +57 -3
- package/src/types/datasource.ts +54 -56
- package/src/types/plugins.tsx +51 -1
- package/src/types/properties.ts +347 -27
- package/src/util/__tests__/conditions.test.ts +506 -0
- package/src/util/__tests__/objects.test.ts +196 -0
- package/src/util/callbacks.ts +6 -3
- package/src/util/collections.ts +51 -6
- package/src/util/conditions.ts +339 -0
- package/src/util/entities.ts +28 -29
- package/src/util/entity_cache.ts +2 -1
- package/src/util/index.ts +2 -1
- package/src/util/objects.ts +31 -13
- package/src/util/{references.ts → previews.ts} +14 -0
- package/src/util/property_utils.tsx +36 -10
- package/src/util/resolutions.ts +57 -55
- /package/dist/util/{references.d.ts → previews.d.ts} +0 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyPropertyConditions,
|
|
3
|
+
buildConditionContext,
|
|
4
|
+
evaluateCondition,
|
|
5
|
+
registerConditionOperations
|
|
6
|
+
} from "../conditions";
|
|
7
|
+
import { ConditionContext, ResolvedProperty } from "../../types";
|
|
8
|
+
|
|
9
|
+
describe("Property Conditions", () => {
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
registerConditionOperations();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("evaluateCondition", () => {
|
|
16
|
+
|
|
17
|
+
it("should evaluate simple equality", () => {
|
|
18
|
+
const context: ConditionContext = {
|
|
19
|
+
values: { status: "archived" },
|
|
20
|
+
previousValues: {},
|
|
21
|
+
propertyValue: undefined,
|
|
22
|
+
path: "products",
|
|
23
|
+
entityId: "123",
|
|
24
|
+
isNew: false,
|
|
25
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
26
|
+
now: Date.now()
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const rule = { "==": [{ "var": "values.status" }, "archived"] };
|
|
30
|
+
expect(evaluateCondition(rule, context)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should evaluate var access to nested values", () => {
|
|
34
|
+
const context: ConditionContext = {
|
|
35
|
+
values: { shipping: { method: "pickup" } },
|
|
36
|
+
previousValues: {},
|
|
37
|
+
propertyValue: undefined,
|
|
38
|
+
path: "orders",
|
|
39
|
+
isNew: false,
|
|
40
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
41
|
+
now: Date.now()
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const rule = { "==": [{ "var": "values.shipping.method" }, "pickup"] };
|
|
45
|
+
expect(evaluateCondition(rule, context)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should check isNew status", () => {
|
|
49
|
+
const contextNew: ConditionContext = {
|
|
50
|
+
values: {},
|
|
51
|
+
previousValues: {},
|
|
52
|
+
propertyValue: undefined,
|
|
53
|
+
path: "products",
|
|
54
|
+
isNew: true,
|
|
55
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
56
|
+
now: Date.now()
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const rule = { "var": "isNew" };
|
|
60
|
+
expect(evaluateCondition(rule, contextNew)).toBe(true);
|
|
61
|
+
|
|
62
|
+
const contextExisting: ConditionContext = {
|
|
63
|
+
...contextNew,
|
|
64
|
+
entityId: "123",
|
|
65
|
+
isNew: false
|
|
66
|
+
};
|
|
67
|
+
expect(evaluateCondition(rule, contextExisting)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should handle if/then/else with object values (Firestore workaround)", () => {
|
|
71
|
+
const context: ConditionContext = {
|
|
72
|
+
values: { status: "active" },
|
|
73
|
+
previousValues: {},
|
|
74
|
+
propertyValue: undefined,
|
|
75
|
+
path: "products",
|
|
76
|
+
isNew: false,
|
|
77
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
78
|
+
now: Date.now()
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Firestore stores arrays as objects like {"0": "a", "1": "b"}
|
|
82
|
+
const rule = {
|
|
83
|
+
"if": [
|
|
84
|
+
{ "==": [{ "var": "values.status" }, "active"] },
|
|
85
|
+
{ "0": "electronics", "1": "clothing" },
|
|
86
|
+
{ "0": "electronics", "1": "clothing", "2": "food" }
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const result = evaluateCondition(rule, context);
|
|
91
|
+
expect(result).toEqual({ "0": "electronics", "1": "clothing" });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should evaluate truthy operator (!!)", () => {
|
|
95
|
+
const context: ConditionContext = {
|
|
96
|
+
values: { name: "Product", emptyField: "" },
|
|
97
|
+
previousValues: {},
|
|
98
|
+
propertyValue: undefined,
|
|
99
|
+
path: "products",
|
|
100
|
+
isNew: false,
|
|
101
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
102
|
+
now: Date.now()
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
expect(evaluateCondition({ "!!": { "var": "values.name" } }, context)).toBe(true);
|
|
106
|
+
expect(evaluateCondition({ "!!": { "var": "values.emptyField" } }, context)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should evaluate falsy operator (!)", () => {
|
|
110
|
+
const context: ConditionContext = {
|
|
111
|
+
values: { name: "Product", emptyField: "" },
|
|
112
|
+
previousValues: {},
|
|
113
|
+
propertyValue: undefined,
|
|
114
|
+
path: "products",
|
|
115
|
+
isNew: false,
|
|
116
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
117
|
+
now: Date.now()
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
expect(evaluateCondition({ "!": { "var": "values.name" } }, context)).toBe(false);
|
|
121
|
+
expect(evaluateCondition({ "!": { "var": "values.emptyField" } }, context)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should evaluate greater than operator", () => {
|
|
125
|
+
const context: ConditionContext = {
|
|
126
|
+
values: { price: 100 },
|
|
127
|
+
previousValues: {},
|
|
128
|
+
propertyValue: undefined,
|
|
129
|
+
path: "products",
|
|
130
|
+
isNew: false,
|
|
131
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
132
|
+
now: Date.now()
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
expect(evaluateCondition({ ">": [{ "var": "values.price" }, 50] }, context)).toBe(true);
|
|
136
|
+
expect(evaluateCondition({ ">": [{ "var": "values.price" }, 100] }, context)).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should evaluate in operator with array", () => {
|
|
140
|
+
const context: ConditionContext = {
|
|
141
|
+
values: { category: "electronics" },
|
|
142
|
+
previousValues: {},
|
|
143
|
+
propertyValue: undefined,
|
|
144
|
+
path: "products",
|
|
145
|
+
isNew: false,
|
|
146
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
147
|
+
now: Date.now()
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
expect(evaluateCondition({ "in": [{ "var": "values.category" }, ["electronics", "clothing"]] }, context)).toBe(true);
|
|
151
|
+
expect(evaluateCondition({ "in": [{ "var": "values.category" }, ["food", "toys"]] }, context)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("Custom operations", () => {
|
|
156
|
+
|
|
157
|
+
it("isPast should check if timestamp is in the past", () => {
|
|
158
|
+
const context: ConditionContext = {
|
|
159
|
+
values: {},
|
|
160
|
+
previousValues: {},
|
|
161
|
+
propertyValue: undefined,
|
|
162
|
+
path: "products",
|
|
163
|
+
isNew: false,
|
|
164
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
165
|
+
now: Date.now()
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const pastTimestamp = Date.now() - 86400000;
|
|
169
|
+
const futureTimestamp = Date.now() + 86400000;
|
|
170
|
+
|
|
171
|
+
expect(evaluateCondition({ "isPast": pastTimestamp }, context)).toBe(true);
|
|
172
|
+
expect(evaluateCondition({ "isPast": futureTimestamp }, context)).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("isFuture should check if timestamp is in the future", () => {
|
|
176
|
+
const context: ConditionContext = {
|
|
177
|
+
values: {},
|
|
178
|
+
previousValues: {},
|
|
179
|
+
propertyValue: undefined,
|
|
180
|
+
path: "products",
|
|
181
|
+
isNew: false,
|
|
182
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: [] },
|
|
183
|
+
now: Date.now()
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const pastTimestamp = Date.now() - 86400000;
|
|
187
|
+
const futureTimestamp = Date.now() + 86400000;
|
|
188
|
+
|
|
189
|
+
expect(evaluateCondition({ "isFuture": futureTimestamp }, context)).toBe(true);
|
|
190
|
+
expect(evaluateCondition({ "isFuture": pastTimestamp }, context)).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("applyPropertyConditions", () => {
|
|
195
|
+
|
|
196
|
+
const baseContext: ConditionContext = {
|
|
197
|
+
values: { status: "archived" },
|
|
198
|
+
previousValues: {},
|
|
199
|
+
propertyValue: undefined,
|
|
200
|
+
path: "products",
|
|
201
|
+
entityId: "123",
|
|
202
|
+
isNew: false,
|
|
203
|
+
user: { uid: "user1", email: null, displayName: null, photoURL: null, roles: ["admin"] },
|
|
204
|
+
now: Date.now()
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
it("should apply disabled condition", () => {
|
|
208
|
+
const property = {
|
|
209
|
+
dataType: "string",
|
|
210
|
+
name: "Title",
|
|
211
|
+
resolved: true,
|
|
212
|
+
fromBuilder: false,
|
|
213
|
+
conditions: {
|
|
214
|
+
disabled: { "==": [{ "var": "values.status" }, "archived"] },
|
|
215
|
+
disabledMessage: "Cannot edit archived items"
|
|
216
|
+
}
|
|
217
|
+
} as ResolvedProperty<string>;
|
|
218
|
+
|
|
219
|
+
const result = applyPropertyConditions(property, baseContext);
|
|
220
|
+
|
|
221
|
+
expect(result.disabled).toEqual({
|
|
222
|
+
clearOnDisabled: false,
|
|
223
|
+
disabledMessage: "Cannot edit archived items",
|
|
224
|
+
hidden: false
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should apply hidden condition", () => {
|
|
229
|
+
const property = {
|
|
230
|
+
dataType: "string",
|
|
231
|
+
name: "Internal Notes",
|
|
232
|
+
resolved: true,
|
|
233
|
+
fromBuilder: false,
|
|
234
|
+
conditions: {
|
|
235
|
+
hidden: { "==": [{ "var": "values.status" }, "archived"] }
|
|
236
|
+
}
|
|
237
|
+
} as ResolvedProperty<string>;
|
|
238
|
+
|
|
239
|
+
const result = applyPropertyConditions(property, baseContext);
|
|
240
|
+
|
|
241
|
+
expect(result.disabled).toEqual(expect.objectContaining({
|
|
242
|
+
hidden: true
|
|
243
|
+
}));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should apply required condition", () => {
|
|
247
|
+
const property = {
|
|
248
|
+
dataType: "string",
|
|
249
|
+
name: "Email",
|
|
250
|
+
resolved: true,
|
|
251
|
+
fromBuilder: false,
|
|
252
|
+
conditions: {
|
|
253
|
+
required: { "!!": { "var": "values.status" } }
|
|
254
|
+
}
|
|
255
|
+
} as ResolvedProperty<string>;
|
|
256
|
+
|
|
257
|
+
const result = applyPropertyConditions(property, baseContext);
|
|
258
|
+
|
|
259
|
+
expect(result.validation?.required).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("should not apply disabled when condition is false", () => {
|
|
263
|
+
const property = {
|
|
264
|
+
dataType: "string",
|
|
265
|
+
name: "Title",
|
|
266
|
+
resolved: true,
|
|
267
|
+
fromBuilder: false,
|
|
268
|
+
conditions: {
|
|
269
|
+
disabled: { "==": [{ "var": "values.status" }, "draft"] }
|
|
270
|
+
}
|
|
271
|
+
} as ResolvedProperty<string>;
|
|
272
|
+
|
|
273
|
+
const result = applyPropertyConditions(property, baseContext);
|
|
274
|
+
|
|
275
|
+
expect(result.disabled).toBeUndefined();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should apply enum conditions to filter values", () => {
|
|
279
|
+
const property = {
|
|
280
|
+
dataType: "string",
|
|
281
|
+
name: "Category",
|
|
282
|
+
resolved: true,
|
|
283
|
+
fromBuilder: false,
|
|
284
|
+
enumValues: [
|
|
285
|
+
{ id: "electronics", label: "Electronics" },
|
|
286
|
+
{ id: "clothing", label: "Clothing" },
|
|
287
|
+
{ id: "food", label: "Food" }
|
|
288
|
+
],
|
|
289
|
+
conditions: {
|
|
290
|
+
allowedEnumValues: ["electronics", "clothing"]
|
|
291
|
+
}
|
|
292
|
+
} as any;
|
|
293
|
+
|
|
294
|
+
const result = applyPropertyConditions(property, baseContext) as any;
|
|
295
|
+
|
|
296
|
+
expect(result.enumValues).toHaveLength(2);
|
|
297
|
+
expect(result.enumValues.map((e: any) => e.id)).toEqual(["electronics", "clothing"]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should apply enum conditions with object format (Firestore workaround)", () => {
|
|
301
|
+
const property = {
|
|
302
|
+
dataType: "string",
|
|
303
|
+
name: "Category",
|
|
304
|
+
resolved: true,
|
|
305
|
+
fromBuilder: false,
|
|
306
|
+
enumValues: [
|
|
307
|
+
{ id: "electronics", label: "Electronics" },
|
|
308
|
+
{ id: "clothing", label: "Clothing" },
|
|
309
|
+
{ id: "food", label: "Food" }
|
|
310
|
+
],
|
|
311
|
+
conditions: {
|
|
312
|
+
allowedEnumValues: {
|
|
313
|
+
"if": [
|
|
314
|
+
{ "!!": { "var": "values.status" } },
|
|
315
|
+
{ "0": "electronics", "1": "clothing" },
|
|
316
|
+
{ "0": "electronics", "1": "clothing", "2": "food" }
|
|
317
|
+
]
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} as any;
|
|
321
|
+
|
|
322
|
+
const result = applyPropertyConditions(property, baseContext) as any;
|
|
323
|
+
|
|
324
|
+
expect(result.enumValues).toHaveLength(2);
|
|
325
|
+
expect(result.enumValues.map((e: any) => e.id)).toEqual(["electronics", "clothing"]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should apply excludedEnumValues to remove specific values", () => {
|
|
329
|
+
const property = {
|
|
330
|
+
dataType: "string",
|
|
331
|
+
name: "Status",
|
|
332
|
+
resolved: true,
|
|
333
|
+
fromBuilder: false,
|
|
334
|
+
enumValues: [
|
|
335
|
+
{ id: "draft", label: "Draft" },
|
|
336
|
+
{ id: "published", label: "Published" },
|
|
337
|
+
{ id: "archived", label: "Archived" }
|
|
338
|
+
],
|
|
339
|
+
conditions: {
|
|
340
|
+
// Simple array of excluded values
|
|
341
|
+
excludedEnumValues: ["published"]
|
|
342
|
+
}
|
|
343
|
+
} as any;
|
|
344
|
+
|
|
345
|
+
const result = applyPropertyConditions(property, baseContext) as any;
|
|
346
|
+
|
|
347
|
+
expect(result.enumValues).toHaveLength(2);
|
|
348
|
+
expect(result.enumValues.map((e: any) => e.id)).toEqual(["draft", "archived"]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("should apply enum conditions to disable specific values", () => {
|
|
352
|
+
const property = {
|
|
353
|
+
dataType: "string",
|
|
354
|
+
name: "Status",
|
|
355
|
+
resolved: true,
|
|
356
|
+
fromBuilder: false,
|
|
357
|
+
enumValues: [
|
|
358
|
+
{ id: "draft", label: "Draft" },
|
|
359
|
+
{ id: "published", label: "Published" },
|
|
360
|
+
{ id: "archived", label: "Archived" }
|
|
361
|
+
],
|
|
362
|
+
conditions: {
|
|
363
|
+
enumConditions: {
|
|
364
|
+
archived: {
|
|
365
|
+
disabled: { "!=": [{ "var": "values.status" }, "archived"] }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} as any;
|
|
370
|
+
|
|
371
|
+
const result = applyPropertyConditions(property, baseContext) as any;
|
|
372
|
+
const archivedOption = result.enumValues.find((e: any) => e.id === "archived");
|
|
373
|
+
expect(archivedOption.disabled).toBeFalsy();
|
|
374
|
+
|
|
375
|
+
const contextDraft = { ...baseContext, values: { status: "draft" } };
|
|
376
|
+
const resultDraft = applyPropertyConditions(property, contextDraft) as any;
|
|
377
|
+
const archivedOptionDraft = resultDraft.enumValues.find((e: any) => e.id === "archived");
|
|
378
|
+
expect(archivedOptionDraft.disabled).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("should handle multiple conditions together", () => {
|
|
382
|
+
const property = {
|
|
383
|
+
dataType: "string",
|
|
384
|
+
name: "Notes",
|
|
385
|
+
resolved: true,
|
|
386
|
+
fromBuilder: false,
|
|
387
|
+
conditions: {
|
|
388
|
+
disabled: { "==": [{ "var": "values.status" }, "archived"] },
|
|
389
|
+
required: { "==": [{ "var": "values.status" }, "published"] },
|
|
390
|
+
disabledMessage: "Cannot edit notes on archived items"
|
|
391
|
+
}
|
|
392
|
+
} as ResolvedProperty<string>;
|
|
393
|
+
|
|
394
|
+
const resultArchived = applyPropertyConditions(property, baseContext);
|
|
395
|
+
expect(resultArchived.disabled).toBeDefined();
|
|
396
|
+
expect(resultArchived.validation?.required).toBeFalsy();
|
|
397
|
+
|
|
398
|
+
const contextPublished = { ...baseContext, values: { status: "published" } };
|
|
399
|
+
const resultPublished = applyPropertyConditions(property, contextPublished);
|
|
400
|
+
expect(resultPublished.disabled).toBeUndefined();
|
|
401
|
+
expect(resultPublished.validation?.required).toBe(true);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("should handle clearOnDisabled option", () => {
|
|
405
|
+
const property = {
|
|
406
|
+
dataType: "string",
|
|
407
|
+
name: "Title",
|
|
408
|
+
resolved: true,
|
|
409
|
+
fromBuilder: false,
|
|
410
|
+
conditions: {
|
|
411
|
+
disabled: { "==": [{ "var": "values.status" }, "archived"] },
|
|
412
|
+
clearOnDisabled: true
|
|
413
|
+
}
|
|
414
|
+
} as ResolvedProperty<string>;
|
|
415
|
+
|
|
416
|
+
const result = applyPropertyConditions(property, baseContext);
|
|
417
|
+
|
|
418
|
+
expect(result.disabled).toEqual(expect.objectContaining({
|
|
419
|
+
clearOnDisabled: true
|
|
420
|
+
}));
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe("buildConditionContext", () => {
|
|
425
|
+
|
|
426
|
+
it("should build context with serialized dates", () => {
|
|
427
|
+
const mockAuthController = {
|
|
428
|
+
user: {
|
|
429
|
+
uid: "user123",
|
|
430
|
+
email: "test@example.com",
|
|
431
|
+
displayName: "Test User",
|
|
432
|
+
photoURL: null,
|
|
433
|
+
providerId: "google.com",
|
|
434
|
+
isAnonymous: false,
|
|
435
|
+
roles: [{ id: "admin", name: "Admin" }, { id: "editor", name: "Editor" }]
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const now = new Date();
|
|
440
|
+
const context = buildConditionContext({
|
|
441
|
+
propertyKey: "title",
|
|
442
|
+
values: { title: "Hello", createdAt: now },
|
|
443
|
+
path: "products",
|
|
444
|
+
entityId: "123",
|
|
445
|
+
authController: mockAuthController as any
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
expect(context.values.createdAt).toBe(now.getTime());
|
|
449
|
+
expect(context.user.uid).toBe("user123");
|
|
450
|
+
expect(context.user.roles).toEqual(["admin", "editor"]);
|
|
451
|
+
expect(context.isNew).toBe(false);
|
|
452
|
+
expect(context.entityId).toBe("123");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("should mark isNew as true when no entityId", () => {
|
|
456
|
+
const mockAuthController = {
|
|
457
|
+
user: null
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const context = buildConditionContext({
|
|
461
|
+
path: "products",
|
|
462
|
+
authController: mockAuthController as any
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
expect(context.isNew).toBe(true);
|
|
466
|
+
expect(context.entityId).toBeUndefined();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("should handle user with Role object roles", () => {
|
|
470
|
+
const mockAuthController = {
|
|
471
|
+
user: {
|
|
472
|
+
uid: "user123",
|
|
473
|
+
email: "test@example.com",
|
|
474
|
+
displayName: "Test User",
|
|
475
|
+
photoURL: null,
|
|
476
|
+
roles: [{ id: "admin", name: "Admin" }, { id: "editor", name: "Editor" }]
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const context = buildConditionContext({
|
|
481
|
+
path: "products",
|
|
482
|
+
entityId: "123",
|
|
483
|
+
authController: mockAuthController as any
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Roles are mapped from Role.id
|
|
487
|
+
expect(context.user.roles).toEqual(["admin", "editor"]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("should handle null user", () => {
|
|
491
|
+
const mockAuthController = {
|
|
492
|
+
user: null
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const context = buildConditionContext({
|
|
496
|
+
path: "products",
|
|
497
|
+
authController: mockAuthController as any
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(context.user).toBeDefined();
|
|
501
|
+
// uid defaults to empty string when user is null
|
|
502
|
+
expect(context.user.uid).toBe("");
|
|
503
|
+
expect(context.user.roles).toEqual([]);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { removeFunctions, removeUndefined, isPlainObject } from "../objects";
|
|
2
|
+
|
|
3
|
+
describe("objects utilities", () => {
|
|
4
|
+
|
|
5
|
+
describe("removeFunctions", () => {
|
|
6
|
+
|
|
7
|
+
it("should remove functions from objects", () => {
|
|
8
|
+
const obj = {
|
|
9
|
+
name: "test",
|
|
10
|
+
value: 42,
|
|
11
|
+
callback: () => console.log("hi")
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const result = removeFunctions(obj);
|
|
15
|
+
|
|
16
|
+
expect(result.name).toBe("test");
|
|
17
|
+
expect(result.value).toBe(42);
|
|
18
|
+
expect(result.callback).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should preserve arrays at top level", () => {
|
|
22
|
+
const arr = ["a", "b", "c"];
|
|
23
|
+
const result = removeFunctions(arr);
|
|
24
|
+
|
|
25
|
+
expect(Array.isArray(result)).toBe(true);
|
|
26
|
+
expect(result).toEqual(["a", "b", "c"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should preserve nested arrays", () => {
|
|
30
|
+
const obj = {
|
|
31
|
+
items: ["a", "b", "c"],
|
|
32
|
+
numbers: [1, 2, 3]
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = removeFunctions(obj);
|
|
36
|
+
|
|
37
|
+
expect(Array.isArray(result.items)).toBe(true);
|
|
38
|
+
expect(result.items).toEqual(["a", "b", "c"]);
|
|
39
|
+
expect(Array.isArray(result.numbers)).toBe(true);
|
|
40
|
+
expect(result.numbers).toEqual([1, 2, 3]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should NOT convert arrays to objects with numeric keys", () => {
|
|
44
|
+
const arr = ["first", "second", "third"];
|
|
45
|
+
const result = removeFunctions(arr);
|
|
46
|
+
|
|
47
|
+
// Key check: the result should be an Array, not a plain object
|
|
48
|
+
// If it were converted to an object, Array.isArray would return false
|
|
49
|
+
expect(Array.isArray(result)).toBe(true);
|
|
50
|
+
expect(result.length).toBe(3);
|
|
51
|
+
expect(result[0]).toBe("first");
|
|
52
|
+
expect(result[1]).toBe("second");
|
|
53
|
+
expect(result[2]).toBe("third");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should handle undefined", () => {
|
|
57
|
+
expect(removeFunctions(undefined)).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should handle null", () => {
|
|
61
|
+
expect(removeFunctions(null as any)).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should preserve class instances (not recurse into them)", () => {
|
|
65
|
+
class CustomClass {
|
|
66
|
+
value = 42;
|
|
67
|
+
getDouble() { return this.value * 2; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const instance = new CustomClass();
|
|
71
|
+
const obj = { custom: instance };
|
|
72
|
+
|
|
73
|
+
const result = removeFunctions(obj);
|
|
74
|
+
|
|
75
|
+
// Class instances should be preserved as-is (not have functions stripped)
|
|
76
|
+
expect(result.custom).toBe(instance);
|
|
77
|
+
expect(result.custom.getDouble()).toBe(84);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should recursively remove functions from nested objects", () => {
|
|
81
|
+
const obj = {
|
|
82
|
+
level1: {
|
|
83
|
+
name: "nested",
|
|
84
|
+
fn: () => "should be removed",
|
|
85
|
+
level2: {
|
|
86
|
+
value: 100,
|
|
87
|
+
anotherFn: () => "also removed"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const result = removeFunctions(obj);
|
|
93
|
+
|
|
94
|
+
expect(result.level1.name).toBe("nested");
|
|
95
|
+
expect(result.level1.fn).toBeUndefined();
|
|
96
|
+
expect(result.level1.level2.value).toBe(100);
|
|
97
|
+
expect(result.level1.level2.anotherFn).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should handle arrays containing objects with functions", () => {
|
|
101
|
+
const arr = [
|
|
102
|
+
{ name: "item1", fn: () => { } },
|
|
103
|
+
{ name: "item2", fn: () => { } }
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const result = removeFunctions(arr);
|
|
107
|
+
|
|
108
|
+
expect(Array.isArray(result)).toBe(true);
|
|
109
|
+
expect(result[0].name).toBe("item1");
|
|
110
|
+
expect(result[0].fn).toBeUndefined();
|
|
111
|
+
expect(result[1].name).toBe("item2");
|
|
112
|
+
expect(result[1].fn).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should handle deeply nested arrays", () => {
|
|
116
|
+
const obj = {
|
|
117
|
+
conditions: {
|
|
118
|
+
allowedEnumValues: {
|
|
119
|
+
if: [
|
|
120
|
+
{ "==": [{ var: "values.status" }, "active"] },
|
|
121
|
+
["a", "b"],
|
|
122
|
+
["a", "b", "c"]
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const result = removeFunctions(obj);
|
|
129
|
+
|
|
130
|
+
// Ensure arrays inside the if array are preserved as arrays
|
|
131
|
+
expect(Array.isArray(result.conditions.allowedEnumValues.if)).toBe(true);
|
|
132
|
+
expect(Array.isArray(result.conditions.allowedEnumValues.if[1])).toBe(true);
|
|
133
|
+
expect(result.conditions.allowedEnumValues.if[1]).toEqual(["a", "b"]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("removeUndefined", () => {
|
|
138
|
+
|
|
139
|
+
it("should remove undefined values from objects", () => {
|
|
140
|
+
const obj = {
|
|
141
|
+
name: "test",
|
|
142
|
+
value: undefined,
|
|
143
|
+
count: 0
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const result = removeUndefined(obj);
|
|
147
|
+
|
|
148
|
+
expect(result.name).toBe("test");
|
|
149
|
+
expect(result.count).toBe(0);
|
|
150
|
+
expect("value" in result).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should preserve arrays", () => {
|
|
154
|
+
const arr = ["a", "b", "c"];
|
|
155
|
+
const result = removeUndefined(arr);
|
|
156
|
+
|
|
157
|
+
expect(Array.isArray(result)).toBe(true);
|
|
158
|
+
expect(result).toEqual(["a", "b", "c"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should handle null values", () => {
|
|
162
|
+
const obj = { value: null };
|
|
163
|
+
const result = removeUndefined(obj);
|
|
164
|
+
|
|
165
|
+
expect(result.value).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("isPlainObject", () => {
|
|
170
|
+
|
|
171
|
+
it("should return true for plain objects", () => {
|
|
172
|
+
expect(isPlainObject({})).toBe(true);
|
|
173
|
+
expect(isPlainObject({ a: 1 })).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should return false for arrays", () => {
|
|
177
|
+
expect(isPlainObject([])).toBe(false);
|
|
178
|
+
expect(isPlainObject([1, 2, 3])).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should return false for null", () => {
|
|
182
|
+
expect(isPlainObject(null)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should return false for primitives", () => {
|
|
186
|
+
expect(isPlainObject("string")).toBe(false);
|
|
187
|
+
expect(isPlainObject(42)).toBe(false);
|
|
188
|
+
expect(isPlainObject(true)).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should return false for class instances", () => {
|
|
192
|
+
class MyClass { }
|
|
193
|
+
expect(isPlainObject(new MyClass())).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|