@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,258 @@
|
|
|
1
|
+
import { getLocalizedString } from "../getLocalizedString.js";
|
|
2
|
+
import type { DynamicValue, FieldMetadata, FilterFieldType, FilterOperator, FilterValueType } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Operators that take no value at all.
|
|
6
|
+
*
|
|
7
|
+
* `eOperators.ParameterLessChecks` plus the blank checks.
|
|
8
|
+
*/
|
|
9
|
+
const PARAMETERLESS_OPERATORS: FilterOperator[] = ["isnull", "isnotnull", "istrue", "isfalse", "isblank", "isnotblank"];
|
|
10
|
+
|
|
11
|
+
/** Operators whose value is a two element `[from, to]` array. */
|
|
12
|
+
const PAIR_OPERATORS: FilterOperator[] = ["between", "notbetween", "datebetween", "datenotbetween"];
|
|
13
|
+
|
|
14
|
+
/** Operators whose value is an array of values. */
|
|
15
|
+
const LIST_OPERATORS: FilterOperator[] = ["inlist", "notinlist"];
|
|
16
|
+
|
|
17
|
+
/** How many values an operator needs. */
|
|
18
|
+
export type OperatorArity = "none" | "single" | "pair" | "list";
|
|
19
|
+
|
|
20
|
+
export function getOperatorArity(operator: FilterOperator): OperatorArity {
|
|
21
|
+
if (PARAMETERLESS_OPERATORS.includes(operator)) return "none";
|
|
22
|
+
if (PAIR_OPERATORS.includes(operator)) return "pair";
|
|
23
|
+
if (LIST_OPERATORS.includes(operator)) return "list";
|
|
24
|
+
return "single";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Operator labels, keyed by operator. Comparison operators have a different
|
|
29
|
+
* label for dates than for numbers, matching the Windows filter editor.
|
|
30
|
+
*
|
|
31
|
+
* @see `appframe/Appframe3.Win/Controls/afFilterEditor/Utils/FilterLineControlUtils.vb`
|
|
32
|
+
*/
|
|
33
|
+
const OPERATOR_LABELS: Record<FilterOperator, string> = {
|
|
34
|
+
equals: "Equal to",
|
|
35
|
+
notequals: "Not equal to",
|
|
36
|
+
greaterthan: "Greater than",
|
|
37
|
+
greaterthanorequal: "Greater than or equal to",
|
|
38
|
+
lessthan: "Less than",
|
|
39
|
+
lessthanorequal: "Less than or equal to",
|
|
40
|
+
beginswith: "Begins with",
|
|
41
|
+
notbeginswith: "Does not begin with",
|
|
42
|
+
endswith: "Ends with",
|
|
43
|
+
notendswith: "Does not end with",
|
|
44
|
+
contains: "Contains",
|
|
45
|
+
notcontains: "Does not contain",
|
|
46
|
+
like: "Like",
|
|
47
|
+
notlike: "Not like",
|
|
48
|
+
isnull: "Is null",
|
|
49
|
+
isnotnull: "Is not null",
|
|
50
|
+
isblank: "Is empty",
|
|
51
|
+
isnotblank: "Is not empty",
|
|
52
|
+
istrue: "Is true",
|
|
53
|
+
isfalse: "Is false",
|
|
54
|
+
inlist: "In list",
|
|
55
|
+
notinlist: "Not in list",
|
|
56
|
+
between: "Between",
|
|
57
|
+
notbetween: "Not between",
|
|
58
|
+
dateequals: "Equal to",
|
|
59
|
+
notdatequals: "Not equal to",
|
|
60
|
+
dategreaterthan: "After",
|
|
61
|
+
dategreaterthanorequal: "On or after",
|
|
62
|
+
datelessthan: "Before",
|
|
63
|
+
datelessthanorequal: "Up to",
|
|
64
|
+
datebetween: "Between",
|
|
65
|
+
datenotbetween: "Not between",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Comparison operator labels used for date and time fields. */
|
|
69
|
+
const DATE_OPERATOR_LABELS: Partial<Record<FilterOperator, string>> = {
|
|
70
|
+
greaterthan: "After",
|
|
71
|
+
greaterthanorequal: "On or after",
|
|
72
|
+
lessthan: "Before",
|
|
73
|
+
lessthanorequal: "Up to",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the localized label for an operator. Date fields get the
|
|
78
|
+
* "After"/"Before" wording instead of "Greater than"/"Less than".
|
|
79
|
+
*/
|
|
80
|
+
export function getOperatorLabel(operator: FilterOperator, fieldType?: FilterFieldType): string {
|
|
81
|
+
let isDate = fieldType === "date" || fieldType === "datetime";
|
|
82
|
+
let label = (isDate ? DATE_OPERATOR_LABELS[operator] : undefined) ?? OPERATOR_LABELS[operator];
|
|
83
|
+
|
|
84
|
+
return getLocalizedString(label ?? operator);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const EQUALITY: FilterOperator[] = ["equals", "notequals"];
|
|
88
|
+
const LIST_CHECKS: FilterOperator[] = ["inlist", "notinlist"];
|
|
89
|
+
const RANGE: FilterOperator[] = ["between", "notbetween"];
|
|
90
|
+
const COMPARISONS: FilterOperator[] = ["greaterthan", "greaterthanorequal", "lessthan", "lessthanorequal"];
|
|
91
|
+
const STRING_CHECKS: FilterOperator[] = [
|
|
92
|
+
"contains",
|
|
93
|
+
"notcontains",
|
|
94
|
+
"beginswith",
|
|
95
|
+
"notbeginswith",
|
|
96
|
+
"endswith",
|
|
97
|
+
"notendswith",
|
|
98
|
+
"isblank",
|
|
99
|
+
"isnotblank",
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Operators offered per field type, in the order the Windows filter editor
|
|
104
|
+
* presents them.
|
|
105
|
+
*/
|
|
106
|
+
const OPERATORS_BY_TYPE: Record<FilterFieldType, FilterOperator[]> = {
|
|
107
|
+
date: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
|
|
108
|
+
datetime: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
|
|
109
|
+
number: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
|
|
110
|
+
boolean: ["istrue", "isfalse"],
|
|
111
|
+
string: [...EQUALITY, ...STRING_CHECKS, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
|
|
112
|
+
uniqueidentifier: [...EQUALITY, ...STRING_CHECKS, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Default operator per field type.
|
|
117
|
+
*
|
|
118
|
+
* Text defaults to `contains` because a substring search is what people
|
|
119
|
+
* reach for when they type into a filter box without picking an operator —
|
|
120
|
+
* `beginswith` silently misses matches in the middle of the value.
|
|
121
|
+
*/
|
|
122
|
+
const DEFAULT_OPERATOR_BY_TYPE: Record<FilterFieldType, FilterOperator> = {
|
|
123
|
+
date: "equals",
|
|
124
|
+
datetime: "equals",
|
|
125
|
+
number: "equals",
|
|
126
|
+
boolean: "istrue",
|
|
127
|
+
string: "contains",
|
|
128
|
+
uniqueidentifier: "beginswith",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Returns the operators available for a field. Nullable fields additionally get
|
|
133
|
+
* the null checks. Boolean fields never get null checks, since `istrue` and
|
|
134
|
+
* `isfalse` already cover the parameterless cases.
|
|
135
|
+
*/
|
|
136
|
+
export function getOperatorsForField(field: Pick<FieldMetadata, "type" | "nullable">): FilterOperator[] {
|
|
137
|
+
let operators = [...(OPERATORS_BY_TYPE[field.type] ?? OPERATORS_BY_TYPE.string)];
|
|
138
|
+
|
|
139
|
+
if (field.nullable && field.type !== "boolean") {
|
|
140
|
+
operators.push("isnull", "isnotnull");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return operators;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getDefaultOperator(field: Pick<FieldMetadata, "type">): FilterOperator {
|
|
147
|
+
return DEFAULT_OPERATOR_BY_TYPE[field.type] ?? "equals";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The `valueType` to send for a field/operator combination.
|
|
152
|
+
*
|
|
153
|
+
* Parameterless operators send `"null"`, list and range operators send
|
|
154
|
+
* `"array"`, everything else sends the field's own value type.
|
|
155
|
+
*/
|
|
156
|
+
export function getValueType(field: Pick<FieldMetadata, "type">, operator: FilterOperator): FilterValueType {
|
|
157
|
+
if (getOperatorArity(operator) === "none") return "null";
|
|
158
|
+
|
|
159
|
+
switch (field.type) {
|
|
160
|
+
case "number":
|
|
161
|
+
return "number";
|
|
162
|
+
case "boolean":
|
|
163
|
+
return "boolean";
|
|
164
|
+
case "date":
|
|
165
|
+
case "datetime":
|
|
166
|
+
return "datetime";
|
|
167
|
+
case "uniqueidentifier":
|
|
168
|
+
return "uniqueidentifier";
|
|
169
|
+
default:
|
|
170
|
+
return "string";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Relative date values offered in the date value editor, in menu order. */
|
|
175
|
+
export const RELATIVE_DATE_VALUES: DynamicValue[] = [
|
|
176
|
+
"Yesterday",
|
|
177
|
+
"Today",
|
|
178
|
+
"Tomorrow",
|
|
179
|
+
"ThisWeek",
|
|
180
|
+
"ThisMonth",
|
|
181
|
+
"ThisYear",
|
|
182
|
+
"LastWeek",
|
|
183
|
+
"LastMonth",
|
|
184
|
+
"LastYear",
|
|
185
|
+
"NextWeek",
|
|
186
|
+
"NextMonth",
|
|
187
|
+
"NextYear",
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const DYNAMIC_VALUE_LABELS: Record<DynamicValue, string> = {
|
|
191
|
+
CurrentUser: "Current user",
|
|
192
|
+
Yesterday: "Yesterday",
|
|
193
|
+
Today: "Today",
|
|
194
|
+
Tomorrow: "Tomorrow",
|
|
195
|
+
ThisWeek: "This week",
|
|
196
|
+
ThisMonth: "This month",
|
|
197
|
+
ThisYear: "This year",
|
|
198
|
+
LastWeek: "Last week",
|
|
199
|
+
LastMonth: "Last month",
|
|
200
|
+
LastYear: "Last year",
|
|
201
|
+
NextWeek: "Next week",
|
|
202
|
+
NextMonth: "Next month",
|
|
203
|
+
NextYear: "Next year",
|
|
204
|
+
UserYesterday: "Yesterday",
|
|
205
|
+
UserToday: "Today",
|
|
206
|
+
UserTomorrow: "Tomorrow",
|
|
207
|
+
UserThisWeek: "This week",
|
|
208
|
+
UserThisMonth: "This month",
|
|
209
|
+
UserThisYear: "This year",
|
|
210
|
+
UserLastWeek: "Last week",
|
|
211
|
+
UserLastMonth: "Last month",
|
|
212
|
+
UserLastYear: "Last year",
|
|
213
|
+
UserNextWeek: "Next week",
|
|
214
|
+
UserNextMonth: "Next month",
|
|
215
|
+
UserNextYear: "Next year",
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export function getDynamicValueLabel(value: DynamicValue): string {
|
|
219
|
+
return getLocalizedString(DYNAMIC_VALUE_LABELS[value] ?? value);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Returns the dynamic ("special") values that can be used with a field. Date
|
|
224
|
+
* fields get the relative dates; string fields can be compared to the current
|
|
225
|
+
* user name.
|
|
226
|
+
*/
|
|
227
|
+
export function getDynamicValuesForField(field: Pick<FieldMetadata, "type">): DynamicValue[] {
|
|
228
|
+
if (field.type === "date" || field.type === "datetime") return [...RELATIVE_DATE_VALUES];
|
|
229
|
+
if (field.type === "string") return ["CurrentUser"];
|
|
230
|
+
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Aliases for names `eOperators.ToString()` can produce that are not operators
|
|
236
|
+
* in their own right. `Not` is the bare negation flag (i.e. "not equal to"),
|
|
237
|
+
* `Tweens` shares its numeric value with `NotBetween`, and combined flags are
|
|
238
|
+
* rendered as a comma separated list.
|
|
239
|
+
*
|
|
240
|
+
* @see `FilterElementConverter.FilterExpressionToDictionary`
|
|
241
|
+
*/
|
|
242
|
+
const OPERATOR_ALIASES: Record<string, FilterOperator> = {
|
|
243
|
+
not: "notequals",
|
|
244
|
+
tweens: "notbetween",
|
|
245
|
+
notdate: "notdatequals",
|
|
246
|
+
// The enum member is `NotDateEquals`, but the wire format drops an "e".
|
|
247
|
+
notdateequals: "notdatequals",
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Normalizes an operator string coming from the server or from another filter
|
|
252
|
+
* producer (`AfGrid` emits PascalCase names) to the canonical lower-case form.
|
|
253
|
+
*/
|
|
254
|
+
export function normalizeOperator(operator: string): FilterOperator {
|
|
255
|
+
let normalized = operator.toLowerCase().replace(/[\s,]/g, "");
|
|
256
|
+
|
|
257
|
+
return OPERATOR_ALIASES[normalized] ?? (normalized as FilterOperator);
|
|
258
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
canDeleteSavedFilter,
|
|
5
|
+
canEditSavedFilter,
|
|
6
|
+
canRenameSavedFilter,
|
|
7
|
+
canShareSavedFilter,
|
|
8
|
+
findSavedFilter,
|
|
9
|
+
NO_FILTER_ID,
|
|
10
|
+
partitionSavedFilters,
|
|
11
|
+
type SavedFilterRecord,
|
|
12
|
+
toSavedFilter,
|
|
13
|
+
toSavedFilters,
|
|
14
|
+
} from "./savedFilters.js";
|
|
15
|
+
|
|
16
|
+
function record(overrides: SavedFilterRecord = {}): SavedFilterRecord {
|
|
17
|
+
return {
|
|
18
|
+
PrimKey: "11111111-1111-1111-1111-111111111111",
|
|
19
|
+
FilterID: 1,
|
|
20
|
+
DBObjectID: "aviw_Test_View",
|
|
21
|
+
FilterName: "My filter",
|
|
22
|
+
FilterCriteria: "[Name] = 'a'",
|
|
23
|
+
AccessLevel: "Manager",
|
|
24
|
+
CreatedByUser: "true",
|
|
25
|
+
CreatedBy: "bvh",
|
|
26
|
+
Domain: "OB",
|
|
27
|
+
CrossDomain: false,
|
|
28
|
+
IsPersonallyShared: 0,
|
|
29
|
+
HideFilterCriteria: 0,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("toSavedFilter", () => {
|
|
35
|
+
test("maps a record", () => {
|
|
36
|
+
let filter = toSavedFilter(record());
|
|
37
|
+
|
|
38
|
+
expect(filter).toMatchObject({
|
|
39
|
+
filterId: 1,
|
|
40
|
+
name: "My filter",
|
|
41
|
+
criteria: "[Name] = 'a'",
|
|
42
|
+
dbObjectId: "aviw_Test_View",
|
|
43
|
+
accessLevel: "Manager",
|
|
44
|
+
createdByUser: true,
|
|
45
|
+
crossDomain: false,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("CreatedByUser is a string, not a bit", () => {
|
|
50
|
+
expect(toSavedFilter(record({ CreatedByUser: "false" })).createdByUser).toBe(false);
|
|
51
|
+
expect(toSavedFilter(record({ CreatedByUser: "False" })).createdByUser).toBe(false);
|
|
52
|
+
expect(toSavedFilter(record({ CreatedByUser: "true" })).createdByUser).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("bit columns arrive as numbers", () => {
|
|
56
|
+
let filter = toSavedFilter(record({ CrossDomain: 1, IsPersonallyShared: 1, HideFilterCriteria: 1 }));
|
|
57
|
+
|
|
58
|
+
expect(filter.crossDomain).toBe(true);
|
|
59
|
+
expect(filter.isPersonallyShared).toBe(true);
|
|
60
|
+
expect(filter.hideCriteria).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an unknown access level falls back to Reader", () => {
|
|
64
|
+
expect(toSavedFilter(record({ AccessLevel: null })).accessLevel).toBe("Reader");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("empty optional strings become null", () => {
|
|
68
|
+
let filter = toSavedFilter(record({ OptionalHeaderText: "", OptionalComment: "" }));
|
|
69
|
+
|
|
70
|
+
expect(filter.headerText).toBeNull();
|
|
71
|
+
expect(filter.comment).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("dates are parsed", () => {
|
|
75
|
+
expect(toSavedFilter(record({ Created: "2026-01-02T03:04:05Z" })).created).toBeInstanceOf(Date);
|
|
76
|
+
expect(toSavedFilter(record({ Created: null })).created).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("toSavedFilters", () => {
|
|
81
|
+
test("drops the synthetic No Filter row", () => {
|
|
82
|
+
let filters = toSavedFilters([record({ FilterID: NO_FILTER_ID, FilterName: "No Filter" }), record()]);
|
|
83
|
+
|
|
84
|
+
expect(filters).toHaveLength(1);
|
|
85
|
+
expect(filters[0].name).toBe("My filter");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("sorts by name", () => {
|
|
89
|
+
let filters = toSavedFilters([
|
|
90
|
+
record({ FilterID: 1, FilterName: "Beta" }),
|
|
91
|
+
record({ FilterID: 2, FilterName: "Alpha" }),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
expect(filters.map((filter) => filter.name)).toEqual(["Alpha", "Beta"]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("partitionSavedFilters", () => {
|
|
99
|
+
test("splits on createdByUser", () => {
|
|
100
|
+
let filters = toSavedFilters([
|
|
101
|
+
record({ FilterID: 1, FilterName: "Mine", CreatedByUser: "true" }),
|
|
102
|
+
record({ FilterID: 2, FilterName: "Theirs", CreatedByUser: "false" }),
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
let { mine, shared } = partitionSavedFilters(filters);
|
|
106
|
+
|
|
107
|
+
expect(mine.map((filter) => filter.name)).toEqual(["Mine"]);
|
|
108
|
+
expect(shared.map((filter) => filter.name)).toEqual(["Theirs"]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("permissions", () => {
|
|
113
|
+
test("a reader can neither edit nor share", () => {
|
|
114
|
+
let filter = toSavedFilter(record({ AccessLevel: "Reader", CreatedByUser: "false" }));
|
|
115
|
+
|
|
116
|
+
expect(canEditSavedFilter(filter)).toBe(false);
|
|
117
|
+
expect(canShareSavedFilter(filter)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("an editor can edit but not share", () => {
|
|
121
|
+
let filter = toSavedFilter(record({ AccessLevel: "Editor", CreatedByUser: "false" }));
|
|
122
|
+
|
|
123
|
+
expect(canEditSavedFilter(filter)).toBe(true);
|
|
124
|
+
expect(canShareSavedFilter(filter)).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("renaming and deleting need ownership, not manager access", () => {
|
|
128
|
+
let managed = toSavedFilter(record({ AccessLevel: "Manager", CreatedByUser: "false" }));
|
|
129
|
+
|
|
130
|
+
expect(canShareSavedFilter(managed)).toBe(true);
|
|
131
|
+
expect(canRenameSavedFilter(managed)).toBe(false);
|
|
132
|
+
expect(canDeleteSavedFilter(managed)).toBe(false);
|
|
133
|
+
expect(canRenameSavedFilter(toSavedFilter(record()))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("findSavedFilter", () => {
|
|
138
|
+
test("finds by id and tolerates null", () => {
|
|
139
|
+
let filters = toSavedFilters([record({ FilterID: 7, FilterName: "Seven" })]);
|
|
140
|
+
|
|
141
|
+
expect(findSavedFilter(filters, 7)?.name).toBe("Seven");
|
|
142
|
+
expect(findSavedFilter(filters, 8)).toBeUndefined();
|
|
143
|
+
expect(findSavedFilter(filters, null)).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -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
|
+
});
|