@olenbetong/appframe-core 2.11.9 → 2.12.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 +1979 -609
- package/dist/esm/af.common.legacy.js +1933 -609
- 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 +2140 -694
- package/dist/iife/af.common.legacy.js +2094 -694
- 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 +229 -0
- package/es/filter/operators.test.d.ts +1 -0
- package/es/filter/operators.test.js +106 -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.js +19 -2
- package/es/parseFunc.test.js +48 -4
- 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 +136 -0
- package/src/filter/operators.ts +252 -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 +61 -4
- package/src/parseFunc.ts +29 -2
- 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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved filters — the filters the Windows filter builder stores in
|
|
3
|
+
* `stbl_Database_Filters` and shares between users through
|
|
4
|
+
* `stbl_Database_FiltersPermissions`.
|
|
5
|
+
*
|
|
6
|
+
* This module is the pure part: the shape of a saved filter, how the raw
|
|
7
|
+
* database records map onto it, and who is allowed to do what with one. The
|
|
8
|
+
* loading and the mutations live in `useSavedFilters` in
|
|
9
|
+
* `@olenbetong/appframe-react`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { FilterAccessLevel } from "./types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Access a user has to a saved filter.
|
|
16
|
+
*
|
|
17
|
+
* `sviw_Database_MyFilters` only ever reports `Reader`, `Editor` or `Manager`,
|
|
18
|
+
* and a filter the current user created is always reported as `Manager`.
|
|
19
|
+
*/
|
|
20
|
+
export type { FilterAccessLevel };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `sviw_Database_MyFilters` unions in a synthetic "No Filter" row so the Windows
|
|
24
|
+
* tree can render a "no filter" entry. It is not a real filter and must be
|
|
25
|
+
* dropped before the list is shown.
|
|
26
|
+
*/
|
|
27
|
+
export const NO_FILTER_ID = -1;
|
|
28
|
+
|
|
29
|
+
/** A saved filter, as used by the filter builder UI. */
|
|
30
|
+
export type SavedFilter = {
|
|
31
|
+
filterId: number;
|
|
32
|
+
primKey: string;
|
|
33
|
+
name: string;
|
|
34
|
+
/** DevExpress criteria string, e.g. `[Name] Like '%a%'`. */
|
|
35
|
+
criteria: string;
|
|
36
|
+
dbObjectId: string;
|
|
37
|
+
accessLevel: FilterAccessLevel;
|
|
38
|
+
/** True when the current user created the filter. */
|
|
39
|
+
createdByUser: boolean;
|
|
40
|
+
createdBy: string;
|
|
41
|
+
domain: string | null;
|
|
42
|
+
/** True when the filter is visible in every domain the user has access to. */
|
|
43
|
+
crossDomain: boolean;
|
|
44
|
+
/** True when the filter is shared with individual users rather than groups. */
|
|
45
|
+
isPersonallyShared: boolean;
|
|
46
|
+
/** True when the criteria are hidden from readers of a shared filter. */
|
|
47
|
+
hideCriteria: boolean;
|
|
48
|
+
headerText: string | null;
|
|
49
|
+
comment: string | null;
|
|
50
|
+
/** Report parameter values, stored as XML. Only used by report filters. */
|
|
51
|
+
parameters: string | null;
|
|
52
|
+
reportTitle: string | null;
|
|
53
|
+
created: Date | null;
|
|
54
|
+
updated: Date | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** A raw `sviw_Database_MyFilters` / `sviw_Database_FiltersFromReportID` record. */
|
|
58
|
+
export type SavedFilterRecord = {
|
|
59
|
+
PrimKey?: string | null;
|
|
60
|
+
FilterID?: number | null;
|
|
61
|
+
DBObjectID?: string | null;
|
|
62
|
+
FilterName?: string | null;
|
|
63
|
+
FilterCriteria?: string | null;
|
|
64
|
+
AccessLevel?: string | null;
|
|
65
|
+
CreatedByUser?: string | boolean | null;
|
|
66
|
+
CreatedBy?: string | null;
|
|
67
|
+
Domain?: string | null;
|
|
68
|
+
CrossDomain?: boolean | number | null;
|
|
69
|
+
IsPersonallyShared?: boolean | number | null;
|
|
70
|
+
HideFilterCriteria?: boolean | number | null;
|
|
71
|
+
OptionalHeaderText?: string | null;
|
|
72
|
+
OptionalComment?: string | null;
|
|
73
|
+
Parameters?: string | null;
|
|
74
|
+
ReportTitle?: string | null;
|
|
75
|
+
Created?: Date | string | null;
|
|
76
|
+
Updated?: Date | string | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Maps a database record onto a `SavedFilter`.
|
|
81
|
+
*
|
|
82
|
+
* `CreatedByUser` needs care: the view computes it as the *string* `'true'` or
|
|
83
|
+
* `'false'` (and `'False'` on the synthetic row), not as a bit.
|
|
84
|
+
*/
|
|
85
|
+
export function toSavedFilter(record: SavedFilterRecord): SavedFilter {
|
|
86
|
+
return {
|
|
87
|
+
filterId: record.FilterID ?? NO_FILTER_ID,
|
|
88
|
+
primKey: record.PrimKey ?? "",
|
|
89
|
+
name: record.FilterName ?? "",
|
|
90
|
+
criteria: record.FilterCriteria ?? "",
|
|
91
|
+
dbObjectId: record.DBObjectID ?? "",
|
|
92
|
+
accessLevel: toAccessLevel(record.AccessLevel),
|
|
93
|
+
createdByUser: toBoolean(record.CreatedByUser),
|
|
94
|
+
createdBy: record.CreatedBy ?? "",
|
|
95
|
+
domain: record.Domain ?? null,
|
|
96
|
+
crossDomain: toBoolean(record.CrossDomain),
|
|
97
|
+
isPersonallyShared: toBoolean(record.IsPersonallyShared),
|
|
98
|
+
hideCriteria: toBoolean(record.HideFilterCriteria),
|
|
99
|
+
headerText: record.OptionalHeaderText || null,
|
|
100
|
+
comment: record.OptionalComment || null,
|
|
101
|
+
parameters: record.Parameters || null,
|
|
102
|
+
reportTitle: record.ReportTitle || null,
|
|
103
|
+
created: toDate(record.Created),
|
|
104
|
+
updated: toDate(record.Updated),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function toAccessLevel(value: string | null | undefined): FilterAccessLevel {
|
|
109
|
+
if (value === "Manager" || value === "Editor") return value;
|
|
110
|
+
|
|
111
|
+
return "Reader";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toBoolean(value: string | boolean | number | null | undefined): boolean {
|
|
115
|
+
if (typeof value === "string") return value.toLowerCase() === "true";
|
|
116
|
+
|
|
117
|
+
return Boolean(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toDate(value: Date | string | null | undefined): Date | null {
|
|
121
|
+
if (!value) return null;
|
|
122
|
+
if (value instanceof Date) return value;
|
|
123
|
+
|
|
124
|
+
let date = new Date(value);
|
|
125
|
+
|
|
126
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Drops the synthetic "No Filter" row and sorts by name. */
|
|
130
|
+
export function toSavedFilters(records: SavedFilterRecord[]): SavedFilter[] {
|
|
131
|
+
return records
|
|
132
|
+
.filter((record) => (record.FilterID ?? NO_FILTER_ID) !== NO_FILTER_ID)
|
|
133
|
+
.map(toSavedFilter)
|
|
134
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Splits filters into the two branches the Windows tree shows: the ones the user
|
|
139
|
+
* created, and the ones that have been shared with them.
|
|
140
|
+
*/
|
|
141
|
+
export function partitionSavedFilters(filters: SavedFilter[]): { mine: SavedFilter[]; shared: SavedFilter[] } {
|
|
142
|
+
let mine: SavedFilter[] = [];
|
|
143
|
+
let shared: SavedFilter[] = [];
|
|
144
|
+
|
|
145
|
+
for (let filter of filters) {
|
|
146
|
+
if (filter.createdByUser) {
|
|
147
|
+
mine.push(filter);
|
|
148
|
+
} else {
|
|
149
|
+
shared.push(filter);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { mine, shared };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function findSavedFilter(filters: SavedFilter[], filterId: number | null | undefined) {
|
|
157
|
+
if (filterId == null) return undefined;
|
|
158
|
+
|
|
159
|
+
return filters.find((filter) => filter.filterId === filterId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True when the user may overwrite the filter's criteria. */
|
|
163
|
+
export function canEditSavedFilter(filter: SavedFilter): boolean {
|
|
164
|
+
return filter.accessLevel === "Editor" || filter.accessLevel === "Manager";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when the user may change who the filter is shared with. */
|
|
168
|
+
export function canShareSavedFilter(filter: SavedFilter): boolean {
|
|
169
|
+
return filter.accessLevel === "Manager";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* True when the user may rename or delete the filter.
|
|
174
|
+
*
|
|
175
|
+
* `sstp_WebSiteCMS_FilterBuilder_RenameFilter` and `…_DeleteFilter` both require
|
|
176
|
+
* `CreatedBy = SUSER_SNAME()` — the manager-access branch is commented out in
|
|
177
|
+
* both procedures, deliberately, because the UI only ever offers these for a
|
|
178
|
+
* user's own filters.
|
|
179
|
+
*/
|
|
180
|
+
export function canRenameSavedFilter(filter: SavedFilter): boolean {
|
|
181
|
+
return filter.createdByUser;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const canDeleteSavedFilter = canRenameSavedFilter;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* True when the user may toggle the cross-domain flag.
|
|
188
|
+
*
|
|
189
|
+
* `sstp_Database_Filters_SetCrossDomain` also filters on
|
|
190
|
+
* `CreatedBy = SUSER_SNAME()`.
|
|
191
|
+
*/
|
|
192
|
+
export const canSetCrossDomain = canRenameSavedFilter;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { FilterExpression, FilterGroup } from "./types.js";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
|
|
5
|
+
import { countExpressions } from "./tree.js";
|
|
6
|
+
|
|
7
|
+
function expression(column: string, operator = "equals", value: FilterExpression["value"] = "x"): FilterExpression {
|
|
8
|
+
return { type: "expression", column, operator, value, valueType: "string" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function group(mode: "and" | "or", ...items: Array<FilterGroup | FilterExpression>): FilterGroup {
|
|
12
|
+
return { type: "group", mode, items };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("isStandardLayout", () => {
|
|
16
|
+
test("an or of and groups is standard", () => {
|
|
17
|
+
expect(isStandardLayout(group("or", group("and", expression("A"))))).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("an and root is not standard", () => {
|
|
21
|
+
expect(isStandardLayout(group("and", group("or", expression("A"))))).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("an expression directly under the root is not standard", () => {
|
|
25
|
+
expect(isStandardLayout(group("or", expression("A")))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a group nested three levels deep is not standard", () => {
|
|
29
|
+
expect(isStandardLayout(group("or", group("and", group("or", expression("A")))))).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("null and bare expressions are not standard", () => {
|
|
33
|
+
expect(isStandardLayout(null)).toBe(false);
|
|
34
|
+
expect(isStandardLayout(expression("A"))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("coerceToStandardLayout", () => {
|
|
39
|
+
test("null becomes an empty or/and pair", () => {
|
|
40
|
+
expect(coerceToStandardLayout(null)).toEqual(group("or", group("and")));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a bare expression is wrapped in an or/and pair", () => {
|
|
44
|
+
let result = coerceToStandardLayout(expression("Name"));
|
|
45
|
+
|
|
46
|
+
expect(result.mode).toBe("or");
|
|
47
|
+
expect(result.items).toHaveLength(1);
|
|
48
|
+
expect((result.items[0] as FilterGroup).mode).toBe("and");
|
|
49
|
+
expect((result.items[0] as FilterGroup).items).toEqual([expression("Name")]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("an and group becomes a single group inside an or group", () => {
|
|
53
|
+
let result = coerceToStandardLayout(group("and", expression("A"), expression("B")));
|
|
54
|
+
|
|
55
|
+
expect(result.mode).toBe("or");
|
|
56
|
+
expect(result.items).toHaveLength(1);
|
|
57
|
+
expect(countExpressions(result)).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("an or group keeps one and group per item", () => {
|
|
61
|
+
let result = coerceToStandardLayout(group("or", expression("A"), group("and", expression("B"), expression("C"))));
|
|
62
|
+
|
|
63
|
+
expect(result.items).toHaveLength(2);
|
|
64
|
+
expect((result.items[0] as FilterGroup).items).toHaveLength(1);
|
|
65
|
+
expect((result.items[1] as FilterGroup).items).toHaveLength(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("deeper nesting is flattened, keeping every expression", () => {
|
|
69
|
+
let nested = group("and", expression("A"), group("or", expression("B"), group("and", expression("C"))));
|
|
70
|
+
let result = coerceToStandardLayout(nested);
|
|
71
|
+
|
|
72
|
+
expect(result.items).toHaveLength(1);
|
|
73
|
+
expect(countExpressions(result)).toBe(3);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an empty or group still yields one editable group", () => {
|
|
77
|
+
let result = coerceToStandardLayout(group("or"));
|
|
78
|
+
|
|
79
|
+
expect(result.items).toHaveLength(1);
|
|
80
|
+
expect((result.items[0] as FilterGroup).items).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("is idempotent", () => {
|
|
84
|
+
let once = coerceToStandardLayout(group("or", expression("A"), expression("B")));
|
|
85
|
+
|
|
86
|
+
expect(coerceToStandardLayout(once)).toEqual(once);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -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: "beginswith",
|
|
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
|
+
});
|