@olenbetong/appframe-core 2.11.10 → 2.13.0

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 (95) hide show
  1. package/dist/esm/af.common.js +1988 -626
  2. package/dist/esm/af.common.legacy.js +1942 -626
  3. package/dist/esm/af.common.legacy.min.js +4 -3
  4. package/dist/esm/af.common.legacy.min.js.map +1 -7
  5. package/dist/esm/af.common.min.js +4 -3
  6. package/dist/esm/af.common.min.js.map +1 -7
  7. package/dist/iife/af.common.js +2146 -708
  8. package/dist/iife/af.common.legacy.js +2100 -708
  9. package/dist/iife/af.common.legacy.min.js +4 -3
  10. package/dist/iife/af.common.legacy.min.js.map +1 -7
  11. package/dist/iife/af.common.min.js +4 -3
  12. package/dist/iife/af.common.min.js.map +1 -7
  13. package/es/alert.js +16 -1
  14. package/es/filter/convertFilter.d.ts +23 -0
  15. package/es/filter/convertFilter.js +26 -0
  16. package/es/filter/describeFilter.d.ts +13 -0
  17. package/es/filter/describeFilter.js +60 -0
  18. package/es/filter/describeFilter.test.d.ts +1 -0
  19. package/es/filter/describeFilter.test.js +68 -0
  20. package/es/filter/fields.d.ts +28 -0
  21. package/es/filter/fields.js +37 -0
  22. package/es/filter/fields.test.d.ts +1 -0
  23. package/es/filter/fields.test.js +49 -0
  24. package/es/filter/index.d.ts +8 -0
  25. package/es/filter/index.js +7 -0
  26. package/es/filter/operators.d.ts +37 -0
  27. package/es/filter/operators.js +235 -0
  28. package/es/filter/operators.test.d.ts +1 -0
  29. package/es/filter/operators.test.js +107 -0
  30. package/es/filter/savedFilters.d.ts +111 -0
  31. package/es/filter/savedFilters.js +118 -0
  32. package/es/filter/savedFilters.test.d.ts +1 -0
  33. package/es/filter/savedFilters.test.js +108 -0
  34. package/es/filter/standardLayout.d.ts +18 -0
  35. package/es/filter/standardLayout.js +55 -0
  36. package/es/filter/standardLayout.test.d.ts +1 -0
  37. package/es/filter/standardLayout.test.js +66 -0
  38. package/es/filter/tree.d.ts +70 -0
  39. package/es/filter/tree.js +185 -0
  40. package/es/filter/tree.test.d.ts +1 -0
  41. package/es/filter/tree.test.js +184 -0
  42. package/es/filter/types.d.ts +73 -0
  43. package/es/filter/types.js +1 -0
  44. package/es/index.d.ts +2 -0
  45. package/es/index.js +4 -2
  46. package/es/parseFunc.test.js +5 -6
  47. package/es/report/index.d.ts +6 -0
  48. package/es/report/index.js +5 -0
  49. package/es/report/parameterDefaults.d.ts +26 -0
  50. package/es/report/parameterDefaults.js +77 -0
  51. package/es/report/parameters.d.ts +46 -0
  52. package/es/report/parameters.js +188 -0
  53. package/es/report/parameters.test.d.ts +1 -0
  54. package/es/report/parameters.test.js +161 -0
  55. package/es/report/recordSource.d.ts +30 -0
  56. package/es/report/recordSource.js +105 -0
  57. package/es/report/render.d.ts +81 -0
  58. package/es/report/render.js +219 -0
  59. package/es/report/render.test.d.ts +1 -0
  60. package/es/report/render.test.js +144 -0
  61. package/es/report/report.d.ts +16 -0
  62. package/es/report/report.js +50 -0
  63. package/es/report/report.test.d.ts +1 -0
  64. package/es/report/report.test.js +86 -0
  65. package/es/report/types.d.ts +124 -0
  66. package/es/report/types.js +8 -0
  67. package/package.json +7 -7
  68. package/src/alert.ts +16 -1
  69. package/src/filter/convertFilter.ts +47 -0
  70. package/src/filter/describeFilter.test.ts +103 -0
  71. package/src/filter/describeFilter.ts +79 -0
  72. package/src/filter/fields.test.ts +58 -0
  73. package/src/filter/fields.ts +56 -0
  74. package/src/filter/index.ts +62 -0
  75. package/src/filter/operators.test.ts +137 -0
  76. package/src/filter/operators.ts +258 -0
  77. package/src/filter/savedFilters.test.ts +145 -0
  78. package/src/filter/savedFilters.ts +192 -0
  79. package/src/filter/standardLayout.test.ts +88 -0
  80. package/src/filter/standardLayout.ts +65 -0
  81. package/src/filter/tree.test.ts +258 -0
  82. package/src/filter/tree.ts +238 -0
  83. package/src/filter/types.ts +146 -0
  84. package/src/index.ts +3 -1
  85. package/src/parseFunc.test.ts +5 -6
  86. package/src/report/index.ts +37 -0
  87. package/src/report/parameterDefaults.ts +101 -0
  88. package/src/report/parameters.test.ts +218 -0
  89. package/src/report/parameters.ts +230 -0
  90. package/src/report/recordSource.ts +123 -0
  91. package/src/report/render.test.ts +230 -0
  92. package/src/report/render.ts +269 -0
  93. package/src/report/report.test.ts +104 -0
  94. package/src/report/report.ts +51 -0
  95. package/src/report/types.ts +135 -0
@@ -0,0 +1,65 @@
1
+ import { isExpression, isGroup } from "./tree.js";
2
+ import type { FilterExpression, FilterGroup, FilterTree } from "./types.js";
3
+
4
+ /**
5
+ * The "standard layout" is the simplified shape the Windows filter editor can
6
+ * render in its flow view: an outer `or` group whose items are `and` groups of
7
+ * expressions. Anything else falls back to the advanced (DevExpress) editor
8
+ * there, and to the nested tree editor on the web.
9
+ *
10
+ * @see `afFilterEditor.CanRenderFilter`
11
+ */
12
+ export function isStandardLayout(filter: FilterGroup | FilterExpression | null | undefined): boolean {
13
+ if (!filter || isExpression(filter) || filter.mode !== "or") return false;
14
+
15
+ return filter.items.every((item) => isGroup(item) && item.mode === "and" && item.items.every(isExpression));
16
+ }
17
+
18
+ /**
19
+ * Coerces an arbitrary filter tree into the standard layout.
20
+ *
21
+ * An `or` group's items each become their own `and` group. Anything else is
22
+ * flattened into a single `and` group. This loses nesting, so only use it when
23
+ * the consumer cannot handle a nested tree.
24
+ */
25
+ export function coerceToStandardLayout(filter: FilterGroup | FilterExpression | null | undefined): FilterTree {
26
+ if (!filter) return { type: "group", mode: "or", items: [{ type: "group", mode: "and", items: [] }] };
27
+
28
+ if (isExpression(filter)) {
29
+ return { type: "group", mode: "or", items: [{ type: "group", mode: "and", items: [filter] }] };
30
+ }
31
+
32
+ if (filter.mode === "or") {
33
+ let groups = filter.items.map((item) => {
34
+ let expressions: FilterExpression[] = [];
35
+ collectExpressions(item, expressions);
36
+
37
+ return { type: "group", mode: "and", items: expressions } satisfies FilterGroup;
38
+ });
39
+
40
+ return {
41
+ type: "group",
42
+ mode: "or",
43
+ items: groups.length > 0 ? groups : [{ type: "group", mode: "and", items: [] }],
44
+ };
45
+ }
46
+
47
+ let expressions: FilterExpression[] = [];
48
+ collectExpressions(filter, expressions);
49
+
50
+ return { type: "group", mode: "or", items: [{ type: "group", mode: "and", items: expressions }] };
51
+ }
52
+
53
+ /**
54
+ * Collects every expression in a (possibly nested) filter, in document order.
55
+ */
56
+ function collectExpressions(item: FilterGroup | FilterExpression, into: FilterExpression[]) {
57
+ if (isExpression(item)) {
58
+ into.push(item);
59
+ return;
60
+ }
61
+
62
+ for (let child of item.items) {
63
+ collectExpressions(child, into);
64
+ }
65
+ }
@@ -0,0 +1,258 @@
1
+ import type { FilterExpression, FilterGroup } from "./types.js";
2
+ import { describe, expect, test } from "vitest";
3
+
4
+ import {
5
+ addGroupAtPath,
6
+ changeOperator,
7
+ countExpressions,
8
+ createEmptyFilter,
9
+ createExpression,
10
+ getNodeAtPath,
11
+ insertNodeAtPath,
12
+ isCompleteExpression,
13
+ isEmptyFilter,
14
+ pruneFilter,
15
+ removeNodeAtPath,
16
+ setGroupMode,
17
+ toFilterTree,
18
+ updateNodeAtPath,
19
+ } from "./tree.js";
20
+
21
+ function expression(column: string, operator = "equals", value: FilterExpression["value"] = "x"): FilterExpression {
22
+ return { type: "expression", column, operator, value, valueType: "string" };
23
+ }
24
+
25
+ function group(mode: "and" | "or", ...items: Array<FilterGroup | FilterExpression>): FilterGroup {
26
+ return { type: "group", mode, items };
27
+ }
28
+
29
+ describe("createEmptyFilter", () => {
30
+ test("creates a single empty and group", () => {
31
+ expect(createEmptyFilter()).toEqual({ type: "group", mode: "and", items: [] });
32
+ });
33
+ });
34
+
35
+ describe("toFilterTree", () => {
36
+ test("null becomes the default layout", () => {
37
+ expect(toFilterTree(null)).toEqual(createEmptyFilter());
38
+ });
39
+
40
+ test("a bare expression is wrapped in an and group", () => {
41
+ expect(toFilterTree(expression("Name"))).toEqual(group("and", expression("Name")));
42
+ });
43
+
44
+ test("nesting is preserved", () => {
45
+ let nested = group("and", expression("A"), group("or", expression("B"), expression("C")));
46
+
47
+ expect(toFilterTree(nested)).toBe(nested);
48
+ });
49
+ });
50
+
51
+ describe("isEmptyFilter", () => {
52
+ test("a fresh filter is empty", () => {
53
+ expect(isEmptyFilter(createEmptyFilter())).toBe(true);
54
+ });
55
+
56
+ test("null is empty", () => {
57
+ expect(isEmptyFilter(null)).toBe(true);
58
+ });
59
+
60
+ test("a filter with an expression is not empty", () => {
61
+ expect(isEmptyFilter(insertNodeAtPath(createEmptyFilter(), [], expression("A")))).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("isCompleteExpression", () => {
66
+ test("parameterless operators need no value", () => {
67
+ expect(isCompleteExpression(expression("A", "isnull", null))).toBe(true);
68
+ expect(isCompleteExpression(expression("A", "istrue", null))).toBe(true);
69
+ });
70
+
71
+ test("a missing column makes the expression incomplete", () => {
72
+ expect(isCompleteExpression(expression("", "equals", "x"))).toBe(false);
73
+ });
74
+
75
+ test("an empty value makes the expression incomplete", () => {
76
+ expect(isCompleteExpression(expression("A", "equals", ""))).toBe(false);
77
+ expect(isCompleteExpression(expression("A", "equals", null))).toBe(false);
78
+ });
79
+
80
+ test("between needs both values", () => {
81
+ expect(isCompleteExpression(expression("A", "between", ["1", "2"]))).toBe(true);
82
+ expect(isCompleteExpression(expression("A", "between", ["1", ""]))).toBe(false);
83
+ expect(isCompleteExpression(expression("A", "between", ["1"]))).toBe(false);
84
+ });
85
+
86
+ test("in list needs at least one value", () => {
87
+ expect(isCompleteExpression(expression("A", "inlist", ["1"]))).toBe(true);
88
+ expect(isCompleteExpression(expression("A", "inlist", []))).toBe(false);
89
+ });
90
+
91
+ test("PascalCase operators are recognised", () => {
92
+ expect(isCompleteExpression(expression("A", "IsNull", null))).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe("pruneFilter", () => {
97
+ test("returns null when nothing is complete", () => {
98
+ let filter = insertNodeAtPath(createEmptyFilter(), [], expression("A", "equals", ""));
99
+
100
+ expect(pruneFilter(filter)).toBeNull();
101
+ });
102
+
103
+ test("drops incomplete expressions and empty groups", () => {
104
+ let filter = createEmptyFilter();
105
+ filter = insertNodeAtPath(filter, [], expression("A"));
106
+ filter = insertNodeAtPath(filter, [], expression("B", "equals", ""));
107
+ filter = addGroupAtPath(filter);
108
+ filter = insertNodeAtPath(filter, [2], expression("C", "equals", ""));
109
+
110
+ let pruned = pruneFilter(filter);
111
+
112
+ expect(pruned?.items).toHaveLength(1);
113
+ expect(countExpressions(pruned)).toBe(1);
114
+ });
115
+
116
+ test("keeps complete groups untouched", () => {
117
+ let filter = insertNodeAtPath(createEmptyFilter(), [], expression("A"));
118
+
119
+ expect(pruneFilter(filter)).toEqual(group("and", expression("A")));
120
+ });
121
+
122
+ test("prunes nested groups recursively", () => {
123
+ let filter = group(
124
+ "and",
125
+ expression("A"),
126
+ group("or", expression("B", "equals", ""), group("and", expression("C"))),
127
+ );
128
+
129
+ expect(pruneFilter(filter)).toEqual(group("and", expression("A"), group("or", group("and", expression("C")))));
130
+ });
131
+ });
132
+
133
+ describe("createExpression", () => {
134
+ test("uses the field's default operator and an empty value", () => {
135
+ expect(createExpression({ name: "Name", type: "string" })).toEqual({
136
+ type: "expression",
137
+ column: "Name",
138
+ operator: "contains",
139
+ value: "",
140
+ valueType: "string",
141
+ });
142
+ });
143
+
144
+ test("parameterless default operators get a null value", () => {
145
+ expect(createExpression({ name: "Active", type: "boolean" })).toMatchObject({
146
+ operator: "istrue",
147
+ value: null,
148
+ valueType: "null",
149
+ });
150
+ });
151
+
152
+ test("an explicit operator shapes the value", () => {
153
+ expect(createExpression({ name: "Amount", type: "number" }, "between")).toMatchObject({
154
+ value: ["", ""],
155
+ valueType: "number",
156
+ });
157
+ expect(createExpression({ name: "Amount", type: "number" }, "inlist")).toMatchObject({ value: [] });
158
+ });
159
+ });
160
+
161
+ describe("changeOperator", () => {
162
+ test("keeps the value when the arity is unchanged", () => {
163
+ let result = changeOperator(expression("A", "equals", "abc"), "contains", { type: "string" });
164
+
165
+ expect(result).toMatchObject({ operator: "contains", value: "abc" });
166
+ });
167
+
168
+ test("resets the value when the arity changes", () => {
169
+ let result = changeOperator(expression("A", "equals", "abc"), "between", { type: "string" });
170
+
171
+ expect(result.value).toEqual(["", ""]);
172
+ });
173
+
174
+ test("switching to a parameterless operator clears the value", () => {
175
+ let result = changeOperator(expression("A", "equals", "abc"), "isnull", { type: "string" });
176
+
177
+ expect(result).toMatchObject({ value: null, valueType: "null" });
178
+ });
179
+
180
+ test("a dynamic value survives a same-arity operator change", () => {
181
+ let dynamic: FilterExpression = {
182
+ type: "expression",
183
+ column: "Created",
184
+ operator: "equals",
185
+ value: "ThisMonth",
186
+ valueType: "special",
187
+ };
188
+ let result = changeOperator(dynamic, "greaterthan", { type: "date" });
189
+
190
+ expect(result).toMatchObject({ valueType: "special", value: "ThisMonth" });
191
+ });
192
+ });
193
+
194
+ describe("path helpers", () => {
195
+ let nested = group("or", group("and", expression("A"), group("or", expression("B"), expression("C"))));
196
+
197
+ test("getNodeAtPath walks into nested groups", () => {
198
+ expect(getNodeAtPath(nested, [])).toBe(nested);
199
+ expect(getNodeAtPath(nested, [0, 0])).toEqual(expression("A"));
200
+ expect(getNodeAtPath(nested, [0, 1, 1])).toEqual(expression("C"));
201
+ });
202
+
203
+ test("getNodeAtPath returns null for an unknown path", () => {
204
+ expect(getNodeAtPath(nested, [5])).toBeNull();
205
+ expect(getNodeAtPath(nested, [0, 0, 0])).toBeNull();
206
+ });
207
+
208
+ test("insertNodeAtPath appends to a nested group", () => {
209
+ let result = insertNodeAtPath(nested, [0, 1], expression("D"));
210
+
211
+ expect(countExpressions(result)).toBe(4);
212
+ expect(getNodeAtPath(result, [0, 1, 2])).toEqual(expression("D"));
213
+ });
214
+
215
+ test("insertNodeAtPath ignores an unknown path", () => {
216
+ expect(insertNodeAtPath(nested, [5], expression("D"))).toBe(nested);
217
+ });
218
+
219
+ test("updateNodeAtPath replaces one node", () => {
220
+ let result = updateNodeAtPath(nested, [0, 1, 0], (node) => ({ ...(node as FilterExpression), value: "changed" }));
221
+
222
+ expect(getNodeAtPath(result, [0, 1, 0])).toMatchObject({ value: "changed" });
223
+ expect(getNodeAtPath(nested, [0, 1, 0])).toMatchObject({ value: "x" });
224
+ });
225
+
226
+ test("removeNodeAtPath removes a nested node", () => {
227
+ let result = removeNodeAtPath(nested, [0, 1, 0]);
228
+
229
+ expect(countExpressions(result)).toBe(2);
230
+ });
231
+
232
+ test("removing the root resets to the default layout", () => {
233
+ expect(removeNodeAtPath(nested, [])).toEqual(createEmptyFilter());
234
+ });
235
+
236
+ test("setGroupMode changes the mode of the group at the path", () => {
237
+ expect(setGroupMode(nested, [], "and").mode).toBe("and");
238
+ expect((getNodeAtPath(setGroupMode(nested, [0], "or"), [0]) as FilterGroup).mode).toBe("or");
239
+ });
240
+
241
+ test("setGroupMode leaves expressions alone", () => {
242
+ expect(setGroupMode(nested, [0, 0], "or")).toEqual(nested);
243
+ });
244
+
245
+ test("addGroupAtPath uses the opposite mode to its parent", () => {
246
+ let withGroup = addGroupAtPath(createEmptyFilter());
247
+
248
+ expect((getNodeAtPath(withGroup, [0]) as FilterGroup).mode).toBe("or");
249
+ expect((getNodeAtPath(addGroupAtPath(withGroup, [0]), [0, 0]) as FilterGroup).mode).toBe("and");
250
+ });
251
+
252
+ test("mutations do not modify the original filter", () => {
253
+ let filter = createEmptyFilter();
254
+ insertNodeAtPath(filter, [], expression("A"));
255
+
256
+ expect(countExpressions(filter)).toBe(0);
257
+ });
258
+ });
@@ -0,0 +1,238 @@
1
+ import { getDefaultOperator, getOperatorArity, getValueType, normalizeOperator } from "./operators.js";
2
+ import type { FieldMetadata, FilterExpression, FilterGroup, FilterOperator, FilterTree } from "./types.js";
3
+
4
+ /**
5
+ * Path to a node in a filter tree, as a list of item indexes from the root
6
+ * group. The root itself is the empty path.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * // The second condition of the first group
11
+ * let path: FilterPath = [0, 1];
12
+ * ```
13
+ */
14
+ export type FilterPath = number[];
15
+
16
+ export type FilterNode = FilterGroup | FilterExpression;
17
+
18
+ /**
19
+ * Creates the default empty filter: a single `and` group with no conditions.
20
+ */
21
+ export function createEmptyFilter(): FilterTree {
22
+ return createEmptyGroup("and");
23
+ }
24
+
25
+ export function createEmptyGroup(mode: FilterGroup["mode"] = "and"): FilterGroup {
26
+ return { type: "group", mode, items: [] };
27
+ }
28
+
29
+ export function isGroup(item: FilterNode): item is FilterGroup {
30
+ return item.type === "group";
31
+ }
32
+
33
+ export function isExpression(item: FilterNode): item is FilterExpression {
34
+ return item.type === "expression";
35
+ }
36
+
37
+ /**
38
+ * Normalizes any filter value into a tree the editor can render, without
39
+ * changing its structure. An expression is wrapped in a group; a missing filter
40
+ * becomes the default layout.
41
+ */
42
+ export function toFilterTree(filter: FilterNode | null | undefined): FilterTree {
43
+ if (!filter) return createEmptyFilter();
44
+ if (isExpression(filter)) return { type: "group", mode: "and", items: [filter] };
45
+
46
+ return filter;
47
+ }
48
+
49
+ /**
50
+ * True when the filter contains no expressions. An empty filter must not be
51
+ * saved or applied.
52
+ */
53
+ export function isEmptyFilter(filter: FilterNode | null | undefined): boolean {
54
+ return countExpressions(filter) === 0;
55
+ }
56
+
57
+ export function countExpressions(filter: FilterNode | null | undefined): number {
58
+ if (!filter) return 0;
59
+ if (isExpression(filter)) return 1;
60
+
61
+ return filter.items.reduce((total, item) => total + countExpressions(item), 0);
62
+ }
63
+
64
+ /**
65
+ * Removes incomplete expressions and empty groups from the whole tree. Returns
66
+ * null when nothing is left. Use before applying a filter to a data object so
67
+ * incomplete rows are not sent.
68
+ */
69
+ export function pruneFilter(filter: FilterNode | null | undefined): FilterGroup | null {
70
+ if (!filter) return null;
71
+
72
+ if (isExpression(filter)) {
73
+ return isCompleteExpression(filter) ? { type: "group", mode: "and", items: [filter] } : null;
74
+ }
75
+
76
+ let items = filter.items
77
+ .map((item) => (isGroup(item) ? pruneFilter(item) : isCompleteExpression(item) ? item : null))
78
+ .filter((item): item is FilterNode => item !== null);
79
+
80
+ if (items.length === 0) return null;
81
+
82
+ return { ...filter, items };
83
+ }
84
+
85
+ /**
86
+ * True when an expression has everything it needs to be sent to the server.
87
+ */
88
+ export function isCompleteExpression(item: FilterNode): boolean {
89
+ if (isGroup(item)) return item.items.some(isCompleteExpression);
90
+ if (!item.column || !item.operator) return false;
91
+
92
+ let arity = getOperatorArity(normalizeOperator(item.operator));
93
+
94
+ if (arity === "none") return true;
95
+ if (item.value === null || item.value === undefined || item.value === "") return false;
96
+
97
+ if (arity === "pair") {
98
+ return Array.isArray(item.value) && item.value.length === 2 && item.value.every((v) => v !== "" && v != null);
99
+ }
100
+
101
+ if (arity === "list") {
102
+ return Array.isArray(item.value) && item.value.length > 0;
103
+ }
104
+
105
+ return true;
106
+ }
107
+
108
+ /**
109
+ * Creates an expression for a field, using the field's default operator and an
110
+ * empty value of the right shape.
111
+ */
112
+ export function createExpression(field: FieldMetadata, operator?: FilterOperator): FilterExpression {
113
+ let op = operator ?? getDefaultOperator(field);
114
+
115
+ return {
116
+ type: "expression",
117
+ column: field.name,
118
+ operator: op,
119
+ value: createEmptyValue(op),
120
+ valueType: getValueType(field, op),
121
+ };
122
+ }
123
+
124
+ function createEmptyValue(operator: FilterOperator): FilterExpression["value"] {
125
+ switch (getOperatorArity(operator)) {
126
+ case "none":
127
+ return null;
128
+ case "pair":
129
+ return ["", ""];
130
+ case "list":
131
+ return [];
132
+ default:
133
+ return "";
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Returns a copy of the expression with a new operator, adjusting the value to
139
+ * the new operator's arity and the value type accordingly.
140
+ */
141
+ export function changeOperator(
142
+ expression: FilterExpression,
143
+ operator: FilterOperator,
144
+ field: Pick<FieldMetadata, "type">,
145
+ ): FilterExpression {
146
+ let previousArity = getOperatorArity(normalizeOperator(expression.operator));
147
+ let arity = getOperatorArity(operator);
148
+ let value = expression.value;
149
+
150
+ if (arity !== previousArity) {
151
+ value = createEmptyValue(operator);
152
+ }
153
+
154
+ // A dynamic value stays valid across operators of the same arity.
155
+ let valueType =
156
+ expression.valueType === "special" && arity === previousArity ? "special" : getValueType(field, operator);
157
+
158
+ return { ...expression, operator, value, valueType };
159
+ }
160
+
161
+ /** Returns the node at `path`, or null when the path does not exist. */
162
+ export function getNodeAtPath(filter: FilterNode, path: FilterPath): FilterNode | null {
163
+ let node: FilterNode | undefined = filter;
164
+
165
+ for (let index of path) {
166
+ if (!node || !isGroup(node)) return null;
167
+ node = node.items[index];
168
+ }
169
+
170
+ return node ?? null;
171
+ }
172
+
173
+ /**
174
+ * Returns a copy of the tree with `updater` applied to the node at `path`.
175
+ * Returning null removes the node. An unknown path leaves the tree untouched.
176
+ */
177
+ export function updateNodeAtPath<T extends FilterNode>(
178
+ filter: T,
179
+ path: FilterPath,
180
+ updater: (node: FilterNode) => FilterNode | null,
181
+ ): T {
182
+ if (path.length === 0) {
183
+ return (updater(filter) as T | null) ?? filter;
184
+ }
185
+
186
+ if (!isGroup(filter)) return filter;
187
+
188
+ let [index, ...rest] = path;
189
+ let child = filter.items[index as number];
190
+ if (!child) return filter;
191
+
192
+ let items = [...filter.items];
193
+
194
+ if (rest.length === 0) {
195
+ let updated = updater(child);
196
+
197
+ if (updated === null) {
198
+ items.splice(index as number, 1);
199
+ } else {
200
+ items[index as number] = updated;
201
+ }
202
+ } else {
203
+ items[index as number] = updateNodeAtPath(child, rest, updater);
204
+ }
205
+
206
+ return { ...filter, items };
207
+ }
208
+
209
+ /** Returns a copy of the tree with `node` appended to the group at `path`. */
210
+ export function insertNodeAtPath<T extends FilterNode>(filter: T, path: FilterPath, node: FilterNode): T {
211
+ return updateNodeAtPath(filter, path, (target) =>
212
+ isGroup(target) ? { ...target, items: [...target.items, node] } : target,
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Returns a copy of the tree with the node at `path` removed. The root is never
218
+ * removed; it is reset to the default layout instead.
219
+ */
220
+ export function removeNodeAtPath<T extends FilterNode>(filter: T, path: FilterPath): T {
221
+ if (path.length === 0) return createEmptyFilter() as T;
222
+
223
+ return updateNodeAtPath(filter, path, () => null);
224
+ }
225
+
226
+ /** Returns a copy of the tree with the mode of the group at `path` changed. */
227
+ export function setGroupMode<T extends FilterNode>(filter: T, path: FilterPath, mode: FilterGroup["mode"]): T {
228
+ return updateNodeAtPath(filter, path, (node) => (isGroup(node) ? { ...node, mode } : node));
229
+ }
230
+
231
+ /** Returns a copy of the tree with an empty group appended to the group at `path`. */
232
+ export function addGroupAtPath<T extends FilterNode>(filter: T, path: FilterPath = []): T {
233
+ let parent = getNodeAtPath(filter, path);
234
+ // A nested group is only useful when it combines differently to its parent.
235
+ let mode: FilterGroup["mode"] = parent && isGroup(parent) && parent.mode === "and" ? "or" : "and";
236
+
237
+ return insertNodeAtPath(filter, path, createEmptyGroup(mode));
238
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A group of filter items, combined with either `and` or `or`.
3
+ *
4
+ * This is the shape accepted by the `filterObject` / `whereObject` parameters
5
+ * on a data object. `@olenbetong/appframe-data` re-exports these types.
6
+ */
7
+ export type FilterGroup = {
8
+ type: "group";
9
+ name?: string;
10
+ mode: "and" | "or";
11
+ items: Array<FilterGroup | FilterExpression>;
12
+ };
13
+
14
+ /** A single `column operator value` comparison. */
15
+ export type FilterExpression = {
16
+ type: "expression";
17
+ column: string;
18
+ operator: string;
19
+ value: string | string[] | Date | number | null;
20
+ valueType: string;
21
+ };
22
+
23
+ /**
24
+ * The value types understood by the Appframe filter element converter.
25
+ *
26
+ * @see `appframe/Appframe.Web.Core/Filtering/FilterElementConverter.vb`
27
+ */
28
+ export type FilterValueType =
29
+ | "string"
30
+ | "number"
31
+ | "boolean"
32
+ | "uniqueidentifier"
33
+ | "array"
34
+ | "datetime"
35
+ | "special"
36
+ | "null";
37
+
38
+ /**
39
+ * Canonical operator names. These are the lower-cased `eOperators` enum member
40
+ * names as emitted by the server, including the two aliases the server
41
+ * special-cases (`notequals` for `Not`, `notdatequals` for `NotDateEquals`).
42
+ *
43
+ * @see `appframe/Appframe3.Common.Data/Filtering/eOperators.vb`
44
+ */
45
+ export type FilterOperator =
46
+ | "equals"
47
+ | "notequals"
48
+ | "greaterthan"
49
+ | "greaterthanorequal"
50
+ | "lessthan"
51
+ | "lessthanorequal"
52
+ | "beginswith"
53
+ | "notbeginswith"
54
+ | "endswith"
55
+ | "notendswith"
56
+ | "contains"
57
+ | "notcontains"
58
+ | "like"
59
+ | "notlike"
60
+ | "isnull"
61
+ | "isnotnull"
62
+ | "isblank"
63
+ | "isnotblank"
64
+ | "istrue"
65
+ | "isfalse"
66
+ | "inlist"
67
+ | "notinlist"
68
+ | "between"
69
+ | "notbetween"
70
+ | "dateequals"
71
+ | "notdatequals"
72
+ | "dategreaterthan"
73
+ | "dategreaterthanorequal"
74
+ | "datelessthan"
75
+ | "datelessthanorequal"
76
+ | "datebetween"
77
+ | "datenotbetween";
78
+
79
+ /**
80
+ * Dynamic ("special") filter values. These are resolved server-side into a
81
+ * relative date or the current user name, and are sent with
82
+ * `valueType: "special"`.
83
+ *
84
+ * @see `appframe/Appframe3.Common.Data/Filtering/eDynamicValue.vb`
85
+ */
86
+ export type DynamicValue =
87
+ | "CurrentUser"
88
+ | "Yesterday"
89
+ | "Today"
90
+ | "Tomorrow"
91
+ | "ThisWeek"
92
+ | "ThisMonth"
93
+ | "ThisYear"
94
+ | "LastWeek"
95
+ | "LastMonth"
96
+ | "LastYear"
97
+ | "NextWeek"
98
+ | "NextMonth"
99
+ | "NextYear"
100
+ | "UserYesterday"
101
+ | "UserToday"
102
+ | "UserTomorrow"
103
+ | "UserThisWeek"
104
+ | "UserThisMonth"
105
+ | "UserThisYear"
106
+ | "UserLastWeek"
107
+ | "UserLastMonth"
108
+ | "UserLastYear"
109
+ | "UserNextWeek"
110
+ | "UserNextMonth"
111
+ | "UserNextYear";
112
+
113
+ /**
114
+ * The simplified data types the filter builder groups fields into. Mirrors the
115
+ * normalized `DataType` column on `sviw_WebSiteCMS_FilterBuilder_ViewFields`.
116
+ */
117
+ export type FilterFieldType = "string" | "number" | "boolean" | "date" | "datetime" | "uniqueidentifier";
118
+
119
+ /**
120
+ * Metadata for one filterable field.
121
+ */
122
+ export type FieldMetadata = {
123
+ /** Database field name, used as `FilterExpression.column`. */
124
+ name: string;
125
+ /** Display name. Falls back to `name` when not set. */
126
+ caption?: string;
127
+ type: FilterFieldType;
128
+ nullable?: boolean;
129
+ /** When true the field is hidden from the field picker. */
130
+ excludeFromFilter?: boolean;
131
+ /**
132
+ * Serialized record source for a lookup editor. When present the value
133
+ * editor should offer a list of values instead of free text.
134
+ */
135
+ valueListRecordSource?: string;
136
+ valueListValueMember?: string;
137
+ };
138
+
139
+ /** Access level a user has on a saved filter. */
140
+ export type FilterAccessLevel = "Reader" | "Editor" | "Manager";
141
+
142
+ /**
143
+ * The filter tree used by the filter builder. Groups can be nested to any depth
144
+ * and each group combines its items with either `and` or `or`.
145
+ */
146
+ export type FilterTree = FilterGroup;