@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.
- package/dist/esm/af.common.js +1988 -626
- package/dist/esm/af.common.legacy.js +1942 -626
- package/dist/esm/af.common.legacy.min.js +4 -3
- package/dist/esm/af.common.legacy.min.js.map +1 -7
- package/dist/esm/af.common.min.js +4 -3
- package/dist/esm/af.common.min.js.map +1 -7
- package/dist/iife/af.common.js +2146 -708
- package/dist/iife/af.common.legacy.js +2100 -708
- package/dist/iife/af.common.legacy.min.js +4 -3
- package/dist/iife/af.common.legacy.min.js.map +1 -7
- package/dist/iife/af.common.min.js +4 -3
- package/dist/iife/af.common.min.js.map +1 -7
- package/es/alert.js +16 -1
- package/es/filter/convertFilter.d.ts +23 -0
- package/es/filter/convertFilter.js +26 -0
- package/es/filter/describeFilter.d.ts +13 -0
- package/es/filter/describeFilter.js +60 -0
- package/es/filter/describeFilter.test.d.ts +1 -0
- package/es/filter/describeFilter.test.js +68 -0
- package/es/filter/fields.d.ts +28 -0
- package/es/filter/fields.js +37 -0
- package/es/filter/fields.test.d.ts +1 -0
- package/es/filter/fields.test.js +49 -0
- package/es/filter/index.d.ts +8 -0
- package/es/filter/index.js +7 -0
- package/es/filter/operators.d.ts +37 -0
- package/es/filter/operators.js +235 -0
- package/es/filter/operators.test.d.ts +1 -0
- package/es/filter/operators.test.js +107 -0
- package/es/filter/savedFilters.d.ts +111 -0
- package/es/filter/savedFilters.js +118 -0
- package/es/filter/savedFilters.test.d.ts +1 -0
- package/es/filter/savedFilters.test.js +108 -0
- package/es/filter/standardLayout.d.ts +18 -0
- package/es/filter/standardLayout.js +55 -0
- package/es/filter/standardLayout.test.d.ts +1 -0
- package/es/filter/standardLayout.test.js +66 -0
- package/es/filter/tree.d.ts +70 -0
- package/es/filter/tree.js +185 -0
- package/es/filter/tree.test.d.ts +1 -0
- package/es/filter/tree.test.js +184 -0
- package/es/filter/types.d.ts +73 -0
- package/es/filter/types.js +1 -0
- package/es/index.d.ts +2 -0
- package/es/index.js +4 -2
- package/es/parseFunc.test.js +5 -6
- package/es/report/index.d.ts +6 -0
- package/es/report/index.js +5 -0
- package/es/report/parameterDefaults.d.ts +26 -0
- package/es/report/parameterDefaults.js +77 -0
- package/es/report/parameters.d.ts +46 -0
- package/es/report/parameters.js +188 -0
- package/es/report/parameters.test.d.ts +1 -0
- package/es/report/parameters.test.js +161 -0
- package/es/report/recordSource.d.ts +30 -0
- package/es/report/recordSource.js +105 -0
- package/es/report/render.d.ts +81 -0
- package/es/report/render.js +219 -0
- package/es/report/render.test.d.ts +1 -0
- package/es/report/render.test.js +144 -0
- package/es/report/report.d.ts +16 -0
- package/es/report/report.js +50 -0
- package/es/report/report.test.d.ts +1 -0
- package/es/report/report.test.js +86 -0
- package/es/report/types.d.ts +124 -0
- package/es/report/types.js +8 -0
- package/package.json +7 -7
- package/src/alert.ts +16 -1
- package/src/filter/convertFilter.ts +47 -0
- package/src/filter/describeFilter.test.ts +103 -0
- package/src/filter/describeFilter.ts +79 -0
- package/src/filter/fields.test.ts +58 -0
- package/src/filter/fields.ts +56 -0
- package/src/filter/index.ts +62 -0
- package/src/filter/operators.test.ts +137 -0
- package/src/filter/operators.ts +258 -0
- package/src/filter/savedFilters.test.ts +145 -0
- package/src/filter/savedFilters.ts +192 -0
- package/src/filter/standardLayout.test.ts +88 -0
- package/src/filter/standardLayout.ts +65 -0
- package/src/filter/tree.test.ts +258 -0
- package/src/filter/tree.ts +238 -0
- package/src/filter/types.ts +146 -0
- package/src/index.ts +3 -1
- package/src/parseFunc.test.ts +5 -6
- package/src/report/index.ts +37 -0
- package/src/report/parameterDefaults.ts +101 -0
- package/src/report/parameters.test.ts +218 -0
- package/src/report/parameters.ts +230 -0
- package/src/report/recordSource.ts +123 -0
- package/src/report/render.test.ts +230 -0
- package/src/report/render.ts +269 -0
- package/src/report/report.test.ts +104 -0
- package/src/report/report.ts +51 -0
- package/src/report/types.ts +135 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { getDefaultOperator, getOperatorArity, getValueType, normalizeOperator } from "./operators.js";
|
|
2
|
+
/**
|
|
3
|
+
* Creates the default empty filter: a single `and` group with no conditions.
|
|
4
|
+
*/
|
|
5
|
+
export function createEmptyFilter() {
|
|
6
|
+
return createEmptyGroup("and");
|
|
7
|
+
}
|
|
8
|
+
export function createEmptyGroup(mode = "and") {
|
|
9
|
+
return { type: "group", mode, items: [] };
|
|
10
|
+
}
|
|
11
|
+
export function isGroup(item) {
|
|
12
|
+
return item.type === "group";
|
|
13
|
+
}
|
|
14
|
+
export function isExpression(item) {
|
|
15
|
+
return item.type === "expression";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes any filter value into a tree the editor can render, without
|
|
19
|
+
* changing its structure. An expression is wrapped in a group; a missing filter
|
|
20
|
+
* becomes the default layout.
|
|
21
|
+
*/
|
|
22
|
+
export function toFilterTree(filter) {
|
|
23
|
+
if (!filter)
|
|
24
|
+
return createEmptyFilter();
|
|
25
|
+
if (isExpression(filter))
|
|
26
|
+
return { type: "group", mode: "and", items: [filter] };
|
|
27
|
+
return filter;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* True when the filter contains no expressions. An empty filter must not be
|
|
31
|
+
* saved or applied.
|
|
32
|
+
*/
|
|
33
|
+
export function isEmptyFilter(filter) {
|
|
34
|
+
return countExpressions(filter) === 0;
|
|
35
|
+
}
|
|
36
|
+
export function countExpressions(filter) {
|
|
37
|
+
if (!filter)
|
|
38
|
+
return 0;
|
|
39
|
+
if (isExpression(filter))
|
|
40
|
+
return 1;
|
|
41
|
+
return filter.items.reduce((total, item) => total + countExpressions(item), 0);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Removes incomplete expressions and empty groups from the whole tree. Returns
|
|
45
|
+
* null when nothing is left. Use before applying a filter to a data object so
|
|
46
|
+
* incomplete rows are not sent.
|
|
47
|
+
*/
|
|
48
|
+
export function pruneFilter(filter) {
|
|
49
|
+
if (!filter)
|
|
50
|
+
return null;
|
|
51
|
+
if (isExpression(filter)) {
|
|
52
|
+
return isCompleteExpression(filter) ? { type: "group", mode: "and", items: [filter] } : null;
|
|
53
|
+
}
|
|
54
|
+
let items = filter.items
|
|
55
|
+
.map((item) => (isGroup(item) ? pruneFilter(item) : isCompleteExpression(item) ? item : null))
|
|
56
|
+
.filter((item) => item !== null);
|
|
57
|
+
if (items.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
return { ...filter, items };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* True when an expression has everything it needs to be sent to the server.
|
|
63
|
+
*/
|
|
64
|
+
export function isCompleteExpression(item) {
|
|
65
|
+
if (isGroup(item))
|
|
66
|
+
return item.items.some(isCompleteExpression);
|
|
67
|
+
if (!item.column || !item.operator)
|
|
68
|
+
return false;
|
|
69
|
+
let arity = getOperatorArity(normalizeOperator(item.operator));
|
|
70
|
+
if (arity === "none")
|
|
71
|
+
return true;
|
|
72
|
+
if (item.value === null || item.value === undefined || item.value === "")
|
|
73
|
+
return false;
|
|
74
|
+
if (arity === "pair") {
|
|
75
|
+
return Array.isArray(item.value) && item.value.length === 2 && item.value.every((v) => v !== "" && v != null);
|
|
76
|
+
}
|
|
77
|
+
if (arity === "list") {
|
|
78
|
+
return Array.isArray(item.value) && item.value.length > 0;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Creates an expression for a field, using the field's default operator and an
|
|
84
|
+
* empty value of the right shape.
|
|
85
|
+
*/
|
|
86
|
+
export function createExpression(field, operator) {
|
|
87
|
+
let op = operator ?? getDefaultOperator(field);
|
|
88
|
+
return {
|
|
89
|
+
type: "expression",
|
|
90
|
+
column: field.name,
|
|
91
|
+
operator: op,
|
|
92
|
+
value: createEmptyValue(op),
|
|
93
|
+
valueType: getValueType(field, op),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function createEmptyValue(operator) {
|
|
97
|
+
switch (getOperatorArity(operator)) {
|
|
98
|
+
case "none":
|
|
99
|
+
return null;
|
|
100
|
+
case "pair":
|
|
101
|
+
return ["", ""];
|
|
102
|
+
case "list":
|
|
103
|
+
return [];
|
|
104
|
+
default:
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Returns a copy of the expression with a new operator, adjusting the value to
|
|
110
|
+
* the new operator's arity and the value type accordingly.
|
|
111
|
+
*/
|
|
112
|
+
export function changeOperator(expression, operator, field) {
|
|
113
|
+
let previousArity = getOperatorArity(normalizeOperator(expression.operator));
|
|
114
|
+
let arity = getOperatorArity(operator);
|
|
115
|
+
let value = expression.value;
|
|
116
|
+
if (arity !== previousArity) {
|
|
117
|
+
value = createEmptyValue(operator);
|
|
118
|
+
}
|
|
119
|
+
// A dynamic value stays valid across operators of the same arity.
|
|
120
|
+
let valueType = expression.valueType === "special" && arity === previousArity ? "special" : getValueType(field, operator);
|
|
121
|
+
return { ...expression, operator, value, valueType };
|
|
122
|
+
}
|
|
123
|
+
/** Returns the node at `path`, or null when the path does not exist. */
|
|
124
|
+
export function getNodeAtPath(filter, path) {
|
|
125
|
+
let node = filter;
|
|
126
|
+
for (let index of path) {
|
|
127
|
+
if (!node || !isGroup(node))
|
|
128
|
+
return null;
|
|
129
|
+
node = node.items[index];
|
|
130
|
+
}
|
|
131
|
+
return node ?? null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Returns a copy of the tree with `updater` applied to the node at `path`.
|
|
135
|
+
* Returning null removes the node. An unknown path leaves the tree untouched.
|
|
136
|
+
*/
|
|
137
|
+
export function updateNodeAtPath(filter, path, updater) {
|
|
138
|
+
if (path.length === 0) {
|
|
139
|
+
return updater(filter) ?? filter;
|
|
140
|
+
}
|
|
141
|
+
if (!isGroup(filter))
|
|
142
|
+
return filter;
|
|
143
|
+
let [index, ...rest] = path;
|
|
144
|
+
let child = filter.items[index];
|
|
145
|
+
if (!child)
|
|
146
|
+
return filter;
|
|
147
|
+
let items = [...filter.items];
|
|
148
|
+
if (rest.length === 0) {
|
|
149
|
+
let updated = updater(child);
|
|
150
|
+
if (updated === null) {
|
|
151
|
+
items.splice(index, 1);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
items[index] = updated;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
items[index] = updateNodeAtPath(child, rest, updater);
|
|
159
|
+
}
|
|
160
|
+
return { ...filter, items };
|
|
161
|
+
}
|
|
162
|
+
/** Returns a copy of the tree with `node` appended to the group at `path`. */
|
|
163
|
+
export function insertNodeAtPath(filter, path, node) {
|
|
164
|
+
return updateNodeAtPath(filter, path, (target) => isGroup(target) ? { ...target, items: [...target.items, node] } : target);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Returns a copy of the tree with the node at `path` removed. The root is never
|
|
168
|
+
* removed; it is reset to the default layout instead.
|
|
169
|
+
*/
|
|
170
|
+
export function removeNodeAtPath(filter, path) {
|
|
171
|
+
if (path.length === 0)
|
|
172
|
+
return createEmptyFilter();
|
|
173
|
+
return updateNodeAtPath(filter, path, () => null);
|
|
174
|
+
}
|
|
175
|
+
/** Returns a copy of the tree with the mode of the group at `path` changed. */
|
|
176
|
+
export function setGroupMode(filter, path, mode) {
|
|
177
|
+
return updateNodeAtPath(filter, path, (node) => (isGroup(node) ? { ...node, mode } : node));
|
|
178
|
+
}
|
|
179
|
+
/** Returns a copy of the tree with an empty group appended to the group at `path`. */
|
|
180
|
+
export function addGroupAtPath(filter, path = []) {
|
|
181
|
+
let parent = getNodeAtPath(filter, path);
|
|
182
|
+
// A nested group is only useful when it combines differently to its parent.
|
|
183
|
+
let mode = parent && isGroup(parent) && parent.mode === "and" ? "or" : "and";
|
|
184
|
+
return insertNodeAtPath(filter, path, createEmptyGroup(mode));
|
|
185
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createExpression, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, updateNodeAtPath, } from "./tree.js";
|
|
3
|
+
function expression(column, operator = "equals", value = "x") {
|
|
4
|
+
return { type: "expression", column, operator, value, valueType: "string" };
|
|
5
|
+
}
|
|
6
|
+
function group(mode, ...items) {
|
|
7
|
+
return { type: "group", mode, items };
|
|
8
|
+
}
|
|
9
|
+
describe("createEmptyFilter", () => {
|
|
10
|
+
test("creates a single empty and group", () => {
|
|
11
|
+
expect(createEmptyFilter()).toEqual({ type: "group", mode: "and", items: [] });
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe("toFilterTree", () => {
|
|
15
|
+
test("null becomes the default layout", () => {
|
|
16
|
+
expect(toFilterTree(null)).toEqual(createEmptyFilter());
|
|
17
|
+
});
|
|
18
|
+
test("a bare expression is wrapped in an and group", () => {
|
|
19
|
+
expect(toFilterTree(expression("Name"))).toEqual(group("and", expression("Name")));
|
|
20
|
+
});
|
|
21
|
+
test("nesting is preserved", () => {
|
|
22
|
+
let nested = group("and", expression("A"), group("or", expression("B"), expression("C")));
|
|
23
|
+
expect(toFilterTree(nested)).toBe(nested);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe("isEmptyFilter", () => {
|
|
27
|
+
test("a fresh filter is empty", () => {
|
|
28
|
+
expect(isEmptyFilter(createEmptyFilter())).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
test("null is empty", () => {
|
|
31
|
+
expect(isEmptyFilter(null)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
test("a filter with an expression is not empty", () => {
|
|
34
|
+
expect(isEmptyFilter(insertNodeAtPath(createEmptyFilter(), [], expression("A")))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe("isCompleteExpression", () => {
|
|
38
|
+
test("parameterless operators need no value", () => {
|
|
39
|
+
expect(isCompleteExpression(expression("A", "isnull", null))).toBe(true);
|
|
40
|
+
expect(isCompleteExpression(expression("A", "istrue", null))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
test("a missing column makes the expression incomplete", () => {
|
|
43
|
+
expect(isCompleteExpression(expression("", "equals", "x"))).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
test("an empty value makes the expression incomplete", () => {
|
|
46
|
+
expect(isCompleteExpression(expression("A", "equals", ""))).toBe(false);
|
|
47
|
+
expect(isCompleteExpression(expression("A", "equals", null))).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
test("between needs both values", () => {
|
|
50
|
+
expect(isCompleteExpression(expression("A", "between", ["1", "2"]))).toBe(true);
|
|
51
|
+
expect(isCompleteExpression(expression("A", "between", ["1", ""]))).toBe(false);
|
|
52
|
+
expect(isCompleteExpression(expression("A", "between", ["1"]))).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
test("in list needs at least one value", () => {
|
|
55
|
+
expect(isCompleteExpression(expression("A", "inlist", ["1"]))).toBe(true);
|
|
56
|
+
expect(isCompleteExpression(expression("A", "inlist", []))).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
test("PascalCase operators are recognised", () => {
|
|
59
|
+
expect(isCompleteExpression(expression("A", "IsNull", null))).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("pruneFilter", () => {
|
|
63
|
+
test("returns null when nothing is complete", () => {
|
|
64
|
+
let filter = insertNodeAtPath(createEmptyFilter(), [], expression("A", "equals", ""));
|
|
65
|
+
expect(pruneFilter(filter)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
test("drops incomplete expressions and empty groups", () => {
|
|
68
|
+
let filter = createEmptyFilter();
|
|
69
|
+
filter = insertNodeAtPath(filter, [], expression("A"));
|
|
70
|
+
filter = insertNodeAtPath(filter, [], expression("B", "equals", ""));
|
|
71
|
+
filter = addGroupAtPath(filter);
|
|
72
|
+
filter = insertNodeAtPath(filter, [2], expression("C", "equals", ""));
|
|
73
|
+
let pruned = pruneFilter(filter);
|
|
74
|
+
expect(pruned?.items).toHaveLength(1);
|
|
75
|
+
expect(countExpressions(pruned)).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
test("keeps complete groups untouched", () => {
|
|
78
|
+
let filter = insertNodeAtPath(createEmptyFilter(), [], expression("A"));
|
|
79
|
+
expect(pruneFilter(filter)).toEqual(group("and", expression("A")));
|
|
80
|
+
});
|
|
81
|
+
test("prunes nested groups recursively", () => {
|
|
82
|
+
let filter = group("and", expression("A"), group("or", expression("B", "equals", ""), group("and", expression("C"))));
|
|
83
|
+
expect(pruneFilter(filter)).toEqual(group("and", expression("A"), group("or", group("and", expression("C")))));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe("createExpression", () => {
|
|
87
|
+
test("uses the field's default operator and an empty value", () => {
|
|
88
|
+
expect(createExpression({ name: "Name", type: "string" })).toEqual({
|
|
89
|
+
type: "expression",
|
|
90
|
+
column: "Name",
|
|
91
|
+
operator: "contains",
|
|
92
|
+
value: "",
|
|
93
|
+
valueType: "string",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
test("parameterless default operators get a null value", () => {
|
|
97
|
+
expect(createExpression({ name: "Active", type: "boolean" })).toMatchObject({
|
|
98
|
+
operator: "istrue",
|
|
99
|
+
value: null,
|
|
100
|
+
valueType: "null",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
test("an explicit operator shapes the value", () => {
|
|
104
|
+
expect(createExpression({ name: "Amount", type: "number" }, "between")).toMatchObject({
|
|
105
|
+
value: ["", ""],
|
|
106
|
+
valueType: "number",
|
|
107
|
+
});
|
|
108
|
+
expect(createExpression({ name: "Amount", type: "number" }, "inlist")).toMatchObject({ value: [] });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe("changeOperator", () => {
|
|
112
|
+
test("keeps the value when the arity is unchanged", () => {
|
|
113
|
+
let result = changeOperator(expression("A", "equals", "abc"), "contains", { type: "string" });
|
|
114
|
+
expect(result).toMatchObject({ operator: "contains", value: "abc" });
|
|
115
|
+
});
|
|
116
|
+
test("resets the value when the arity changes", () => {
|
|
117
|
+
let result = changeOperator(expression("A", "equals", "abc"), "between", { type: "string" });
|
|
118
|
+
expect(result.value).toEqual(["", ""]);
|
|
119
|
+
});
|
|
120
|
+
test("switching to a parameterless operator clears the value", () => {
|
|
121
|
+
let result = changeOperator(expression("A", "equals", "abc"), "isnull", { type: "string" });
|
|
122
|
+
expect(result).toMatchObject({ value: null, valueType: "null" });
|
|
123
|
+
});
|
|
124
|
+
test("a dynamic value survives a same-arity operator change", () => {
|
|
125
|
+
let dynamic = {
|
|
126
|
+
type: "expression",
|
|
127
|
+
column: "Created",
|
|
128
|
+
operator: "equals",
|
|
129
|
+
value: "ThisMonth",
|
|
130
|
+
valueType: "special",
|
|
131
|
+
};
|
|
132
|
+
let result = changeOperator(dynamic, "greaterthan", { type: "date" });
|
|
133
|
+
expect(result).toMatchObject({ valueType: "special", value: "ThisMonth" });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe("path helpers", () => {
|
|
137
|
+
let nested = group("or", group("and", expression("A"), group("or", expression("B"), expression("C"))));
|
|
138
|
+
test("getNodeAtPath walks into nested groups", () => {
|
|
139
|
+
expect(getNodeAtPath(nested, [])).toBe(nested);
|
|
140
|
+
expect(getNodeAtPath(nested, [0, 0])).toEqual(expression("A"));
|
|
141
|
+
expect(getNodeAtPath(nested, [0, 1, 1])).toEqual(expression("C"));
|
|
142
|
+
});
|
|
143
|
+
test("getNodeAtPath returns null for an unknown path", () => {
|
|
144
|
+
expect(getNodeAtPath(nested, [5])).toBeNull();
|
|
145
|
+
expect(getNodeAtPath(nested, [0, 0, 0])).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
test("insertNodeAtPath appends to a nested group", () => {
|
|
148
|
+
let result = insertNodeAtPath(nested, [0, 1], expression("D"));
|
|
149
|
+
expect(countExpressions(result)).toBe(4);
|
|
150
|
+
expect(getNodeAtPath(result, [0, 1, 2])).toEqual(expression("D"));
|
|
151
|
+
});
|
|
152
|
+
test("insertNodeAtPath ignores an unknown path", () => {
|
|
153
|
+
expect(insertNodeAtPath(nested, [5], expression("D"))).toBe(nested);
|
|
154
|
+
});
|
|
155
|
+
test("updateNodeAtPath replaces one node", () => {
|
|
156
|
+
let result = updateNodeAtPath(nested, [0, 1, 0], (node) => ({ ...node, value: "changed" }));
|
|
157
|
+
expect(getNodeAtPath(result, [0, 1, 0])).toMatchObject({ value: "changed" });
|
|
158
|
+
expect(getNodeAtPath(nested, [0, 1, 0])).toMatchObject({ value: "x" });
|
|
159
|
+
});
|
|
160
|
+
test("removeNodeAtPath removes a nested node", () => {
|
|
161
|
+
let result = removeNodeAtPath(nested, [0, 1, 0]);
|
|
162
|
+
expect(countExpressions(result)).toBe(2);
|
|
163
|
+
});
|
|
164
|
+
test("removing the root resets to the default layout", () => {
|
|
165
|
+
expect(removeNodeAtPath(nested, [])).toEqual(createEmptyFilter());
|
|
166
|
+
});
|
|
167
|
+
test("setGroupMode changes the mode of the group at the path", () => {
|
|
168
|
+
expect(setGroupMode(nested, [], "and").mode).toBe("and");
|
|
169
|
+
expect(getNodeAtPath(setGroupMode(nested, [0], "or"), [0]).mode).toBe("or");
|
|
170
|
+
});
|
|
171
|
+
test("setGroupMode leaves expressions alone", () => {
|
|
172
|
+
expect(setGroupMode(nested, [0, 0], "or")).toEqual(nested);
|
|
173
|
+
});
|
|
174
|
+
test("addGroupAtPath uses the opposite mode to its parent", () => {
|
|
175
|
+
let withGroup = addGroupAtPath(createEmptyFilter());
|
|
176
|
+
expect(getNodeAtPath(withGroup, [0]).mode).toBe("or");
|
|
177
|
+
expect(getNodeAtPath(addGroupAtPath(withGroup, [0]), [0, 0]).mode).toBe("and");
|
|
178
|
+
});
|
|
179
|
+
test("mutations do not modify the original filter", () => {
|
|
180
|
+
let filter = createEmptyFilter();
|
|
181
|
+
insertNodeAtPath(filter, [], expression("A"));
|
|
182
|
+
expect(countExpressions(filter)).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
/** A single `column operator value` comparison. */
|
|
14
|
+
export type FilterExpression = {
|
|
15
|
+
type: "expression";
|
|
16
|
+
column: string;
|
|
17
|
+
operator: string;
|
|
18
|
+
value: string | string[] | Date | number | null;
|
|
19
|
+
valueType: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The value types understood by the Appframe filter element converter.
|
|
23
|
+
*
|
|
24
|
+
* @see `appframe/Appframe.Web.Core/Filtering/FilterElementConverter.vb`
|
|
25
|
+
*/
|
|
26
|
+
export type FilterValueType = "string" | "number" | "boolean" | "uniqueidentifier" | "array" | "datetime" | "special" | "null";
|
|
27
|
+
/**
|
|
28
|
+
* Canonical operator names. These are the lower-cased `eOperators` enum member
|
|
29
|
+
* names as emitted by the server, including the two aliases the server
|
|
30
|
+
* special-cases (`notequals` for `Not`, `notdatequals` for `NotDateEquals`).
|
|
31
|
+
*
|
|
32
|
+
* @see `appframe/Appframe3.Common.Data/Filtering/eOperators.vb`
|
|
33
|
+
*/
|
|
34
|
+
export type FilterOperator = "equals" | "notequals" | "greaterthan" | "greaterthanorequal" | "lessthan" | "lessthanorequal" | "beginswith" | "notbeginswith" | "endswith" | "notendswith" | "contains" | "notcontains" | "like" | "notlike" | "isnull" | "isnotnull" | "isblank" | "isnotblank" | "istrue" | "isfalse" | "inlist" | "notinlist" | "between" | "notbetween" | "dateequals" | "notdatequals" | "dategreaterthan" | "dategreaterthanorequal" | "datelessthan" | "datelessthanorequal" | "datebetween" | "datenotbetween";
|
|
35
|
+
/**
|
|
36
|
+
* Dynamic ("special") filter values. These are resolved server-side into a
|
|
37
|
+
* relative date or the current user name, and are sent with
|
|
38
|
+
* `valueType: "special"`.
|
|
39
|
+
*
|
|
40
|
+
* @see `appframe/Appframe3.Common.Data/Filtering/eDynamicValue.vb`
|
|
41
|
+
*/
|
|
42
|
+
export type DynamicValue = "CurrentUser" | "Yesterday" | "Today" | "Tomorrow" | "ThisWeek" | "ThisMonth" | "ThisYear" | "LastWeek" | "LastMonth" | "LastYear" | "NextWeek" | "NextMonth" | "NextYear" | "UserYesterday" | "UserToday" | "UserTomorrow" | "UserThisWeek" | "UserThisMonth" | "UserThisYear" | "UserLastWeek" | "UserLastMonth" | "UserLastYear" | "UserNextWeek" | "UserNextMonth" | "UserNextYear";
|
|
43
|
+
/**
|
|
44
|
+
* The simplified data types the filter builder groups fields into. Mirrors the
|
|
45
|
+
* normalized `DataType` column on `sviw_WebSiteCMS_FilterBuilder_ViewFields`.
|
|
46
|
+
*/
|
|
47
|
+
export type FilterFieldType = "string" | "number" | "boolean" | "date" | "datetime" | "uniqueidentifier";
|
|
48
|
+
/**
|
|
49
|
+
* Metadata for one filterable field.
|
|
50
|
+
*/
|
|
51
|
+
export type FieldMetadata = {
|
|
52
|
+
/** Database field name, used as `FilterExpression.column`. */
|
|
53
|
+
name: string;
|
|
54
|
+
/** Display name. Falls back to `name` when not set. */
|
|
55
|
+
caption?: string;
|
|
56
|
+
type: FilterFieldType;
|
|
57
|
+
nullable?: boolean;
|
|
58
|
+
/** When true the field is hidden from the field picker. */
|
|
59
|
+
excludeFromFilter?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Serialized record source for a lookup editor. When present the value
|
|
62
|
+
* editor should offer a list of values instead of free text.
|
|
63
|
+
*/
|
|
64
|
+
valueListRecordSource?: string;
|
|
65
|
+
valueListValueMember?: string;
|
|
66
|
+
};
|
|
67
|
+
/** Access level a user has on a saved filter. */
|
|
68
|
+
export type FilterAccessLevel = "Reader" | "Editor" | "Manager";
|
|
69
|
+
/**
|
|
70
|
+
* The filter tree used by the filter builder. Groups can be nested to any depth
|
|
71
|
+
* and each group combines its items with either `and` or `or`.
|
|
72
|
+
*/
|
|
73
|
+
export type FilterTree = FilterGroup;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/es/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { downloadBlob } from "./downloadBlob.js";
|
|
|
12
12
|
export type { DownloadItem, DownloadParameters } from "./downloadRequest.js";
|
|
13
13
|
export { downloadRequest } from "./downloadRequest.js";
|
|
14
14
|
export { expose } from "./expose.js";
|
|
15
|
+
export * from "./filter/index.js";
|
|
15
16
|
export { getLocalizedString } from "./getLocalizedString.js";
|
|
16
17
|
export type { getUserImageOptions } from "./getUserImage.js";
|
|
17
18
|
export { getUserImage } from "./getUserImage.js";
|
|
@@ -19,6 +20,7 @@ export { invariant } from "./invariant.js";
|
|
|
19
20
|
export { localize } from "./localize.js";
|
|
20
21
|
export { afLocalStorage as localStorage };
|
|
21
22
|
export { getSessionData, logActivity, logError, startActivity, time } from "./log.js";
|
|
23
|
+
export * from "./report/index.js";
|
|
22
24
|
export { default as SessionHandler } from "./SessionHandler.js";
|
|
23
25
|
export { UserSession };
|
|
24
26
|
export type { AlertDialogButton, AlertDialogOptions, AlertOptions, ConfirmOptions } from "./alert.js";
|
package/es/index.js
CHANGED
|
@@ -6,15 +6,17 @@ export { toLocalDateTime, toLocalISODate, toLocalISODateTime } from "./date.js";
|
|
|
6
6
|
export { downloadBlob } from "./downloadBlob.js";
|
|
7
7
|
export { downloadRequest } from "./downloadRequest.js";
|
|
8
8
|
export { expose } from "./expose.js";
|
|
9
|
+
export * from "./filter/index.js";
|
|
9
10
|
export { getLocalizedString } from "./getLocalizedString.js";
|
|
10
11
|
export { getUserImage } from "./getUserImage.js";
|
|
11
12
|
export { invariant } from "./invariant.js";
|
|
12
13
|
export { localize } from "./localize.js";
|
|
13
14
|
export { afLocalStorage as localStorage };
|
|
14
15
|
export { getSessionData, logActivity, logError, startActivity, time } from "./log.js";
|
|
16
|
+
export * from "./report/index.js";
|
|
15
17
|
export { default as SessionHandler } from "./SessionHandler.js";
|
|
16
18
|
export { UserSession };
|
|
17
19
|
export { alert, ConfirmButtons, confirm, openAlertDialog, waitForDialog } from "./alert.js";
|
|
18
20
|
export { getSettings, saveSettings } from "./settings.js";
|
|
19
|
-
//
|
|
20
|
-
export const version = "2.
|
|
21
|
+
// oxlint-disable-next-line no-use-before-define -- this is injected build time
|
|
22
|
+
export const version = "2.12.0";
|
package/es/parseFunc.test.js
CHANGED
|
@@ -5,11 +5,10 @@ function testFunction(arg1, arg2) {
|
|
|
5
5
|
}
|
|
6
6
|
describe("parseFunc", () => {
|
|
7
7
|
test("should parse function", () => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
});
|
|
8
|
+
const result = parseFunc(testFunction);
|
|
9
|
+
expect(result.name).toBe("testFunction");
|
|
10
|
+
expect(result.args).toEqual(["arg1", "arg2"]);
|
|
11
|
+
expect(result.body.trim()).toBe("console.log(arg1, arg2);");
|
|
13
12
|
});
|
|
14
13
|
test("should parse arrow function returning object literal", () => {
|
|
15
14
|
const arrow = () => ({ foo: "bar" });
|
|
@@ -44,7 +43,7 @@ describe("parseFunc", () => {
|
|
|
44
43
|
expect(result.name).toBe("");
|
|
45
44
|
expect(result.args).toEqual(["x", "y"]);
|
|
46
45
|
expect(result.body.trim()).toBe("return x * y;");
|
|
47
|
-
//
|
|
46
|
+
// oxlint-disable-next-line prefer-arrow-callback -- that would defeat the purpose of the test
|
|
48
47
|
const secondResult = parseFunc(function (x, y) {
|
|
49
48
|
return x * y;
|
|
50
49
|
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { fetchParameterDefault, resolveParameterDefaults } from "./parameterDefaults.js";
|
|
2
|
+
export { coerceParameterValue, getDefaultParameterValues, isProcedureDefault, parseParameterItems, parseParameters, restoreParameterValues, serializeParameters, toReportParameter, toReportParameters, } from "./parameters.js";
|
|
3
|
+
export { decodeXml, encodeXml, parseRecordSource, readElement, readElements } from "./recordSource.js";
|
|
4
|
+
export { downloadReport, getReportExtension, getReportFileName, getReportMediaType, getReportRequest, openReport, printReport, renderReport, type ReportRenderOptions, } from "./render.js";
|
|
5
|
+
export { getReportFormats, toReport } from "./report.js";
|
|
6
|
+
export type { Report, ReportFormat, ReportParameter, ReportParameterEditor, ReportParameterRecord, ReportParameterType, ReportParameterValue, ReportParameterValues, ReportRecord, ReportRecordSource, } from "./types.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { fetchParameterDefault, resolveParameterDefaults } from "./parameterDefaults.js";
|
|
2
|
+
export { coerceParameterValue, getDefaultParameterValues, isProcedureDefault, parseParameterItems, parseParameters, restoreParameterValues, serializeParameters, toReportParameter, toReportParameters, } from "./parameters.js";
|
|
3
|
+
export { decodeXml, encodeXml, parseRecordSource, readElement, readElements } from "./recordSource.js";
|
|
4
|
+
export { downloadReport, getReportExtension, getReportFileName, getReportMediaType, getReportRequest, openReport, printReport, renderReport, } from "./render.js";
|
|
5
|
+
export { getReportFormats, toReport } from "./report.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving report parameter defaults that name a stored procedure.
|
|
3
|
+
*
|
|
4
|
+
* A `DefaultValue` matching `^[a-z]stp_` names a procedure that produces the
|
|
5
|
+
* default rather than being the default itself. Executing it is a server
|
|
6
|
+
* concern, and the framework already exposes it through the `WebsiteCMS`
|
|
7
|
+
* `api/reporting/filters` route.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReportParameter, ReportParameterValue, ReportParameterValues } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Runs the stored procedure a parameter's default names and returns the value
|
|
12
|
+
* it produces — the first column of the first row, the way the Windows launcher
|
|
13
|
+
* reads it.
|
|
14
|
+
*
|
|
15
|
+
* Returns `null` when the parameter has no procedure default or the procedure
|
|
16
|
+
* produced no rows.
|
|
17
|
+
*/
|
|
18
|
+
export declare function fetchParameterDefault(reportId: string, parameter: ReportParameter, signal?: AbortSignal): Promise<ReportParameterValue>;
|
|
19
|
+
/**
|
|
20
|
+
* Resolves every procedure default of a report in parallel and merges the
|
|
21
|
+
* results over the literal defaults.
|
|
22
|
+
*
|
|
23
|
+
* A parameter whose procedure fails is left at its literal default rather than
|
|
24
|
+
* failing the whole form — the user can still type a value.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveParameterDefaults(reportId: string, parameters: ReportParameter[], literalDefaults: ReportParameterValues, signal?: AbortSignal): Promise<ReportParameterValues>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving report parameter defaults that name a stored procedure.
|
|
3
|
+
*
|
|
4
|
+
* A `DefaultValue` matching `^[a-z]stp_` names a procedure that produces the
|
|
5
|
+
* default rather than being the default itself. Executing it is a server
|
|
6
|
+
* concern, and the framework already exposes it through the `WebsiteCMS`
|
|
7
|
+
* `api/reporting/filters` route.
|
|
8
|
+
*/
|
|
9
|
+
import { isProcedureDefault } from "./parameters.js";
|
|
10
|
+
/**
|
|
11
|
+
* Runs the stored procedure a parameter's default names and returns the value
|
|
12
|
+
* it produces — the first column of the first row, the way the Windows launcher
|
|
13
|
+
* reads it.
|
|
14
|
+
*
|
|
15
|
+
* Returns `null` when the parameter has no procedure default or the procedure
|
|
16
|
+
* produced no rows.
|
|
17
|
+
*/
|
|
18
|
+
export async function fetchParameterDefault(reportId, parameter, signal) {
|
|
19
|
+
if (!parameter.defaultValue || !isProcedureDefault(parameter.defaultValue))
|
|
20
|
+
return null;
|
|
21
|
+
let response = await fetch(`/api/reporting/filters/${encodeURIComponent(reportId)}/GetParameterDefaultValue`, {
|
|
22
|
+
body: JSON.stringify({ parameterName: parameter.name }),
|
|
23
|
+
credentials: "same-origin",
|
|
24
|
+
headers: {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
// The route rejects anything that is not an ajax request, as CSRF
|
|
27
|
+
// protection.
|
|
28
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
29
|
+
},
|
|
30
|
+
method: "POST",
|
|
31
|
+
signal,
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
35
|
+
let result = (await response.json());
|
|
36
|
+
return firstValue(result);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolves every procedure default of a report in parallel and merges the
|
|
40
|
+
* results over the literal defaults.
|
|
41
|
+
*
|
|
42
|
+
* A parameter whose procedure fails is left at its literal default rather than
|
|
43
|
+
* failing the whole form — the user can still type a value.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveParameterDefaults(reportId, parameters, literalDefaults, signal) {
|
|
46
|
+
let dynamic = parameters.filter((parameter) => isProcedureDefault(parameter.defaultValue ?? ""));
|
|
47
|
+
if (dynamic.length === 0)
|
|
48
|
+
return literalDefaults;
|
|
49
|
+
let resolved = await Promise.all(dynamic.map(async (parameter) => {
|
|
50
|
+
try {
|
|
51
|
+
return [parameter.name, await fetchParameterDefault(reportId, parameter, signal)];
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return [parameter.name, null];
|
|
55
|
+
}
|
|
56
|
+
}));
|
|
57
|
+
let values = { ...literalDefaults };
|
|
58
|
+
for (let [name, value] of resolved) {
|
|
59
|
+
if (value !== null)
|
|
60
|
+
values[name] = value;
|
|
61
|
+
}
|
|
62
|
+
return values;
|
|
63
|
+
}
|
|
64
|
+
/** The first column of the first row of whichever result set came back. */
|
|
65
|
+
function firstValue(result) {
|
|
66
|
+
let rows = Array.isArray(result) ? result : Object.values(result).find((value) => Array.isArray(value));
|
|
67
|
+
let row = rows?.[0];
|
|
68
|
+
if (row === undefined || row === null)
|
|
69
|
+
return null;
|
|
70
|
+
if (Array.isArray(row))
|
|
71
|
+
return row[0] ?? null;
|
|
72
|
+
if (typeof row === "object") {
|
|
73
|
+
let values = Object.values(row);
|
|
74
|
+
return values[0] ?? null;
|
|
75
|
+
}
|
|
76
|
+
return row;
|
|
77
|
+
}
|