@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,106 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { getDefaultOperator, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, } from "./operators.js";
|
|
3
|
+
describe("getOperatorsForField", () => {
|
|
4
|
+
test("boolean fields only get truth checks", () => {
|
|
5
|
+
expect(getOperatorsForField({ type: "boolean" })).toEqual(["istrue", "isfalse"]);
|
|
6
|
+
});
|
|
7
|
+
test("boolean fields never get null checks", () => {
|
|
8
|
+
expect(getOperatorsForField({ type: "boolean", nullable: true })).toEqual(["istrue", "isfalse"]);
|
|
9
|
+
});
|
|
10
|
+
test("nullable fields get null checks last", () => {
|
|
11
|
+
let operators = getOperatorsForField({ type: "number", nullable: true });
|
|
12
|
+
expect(operators.slice(-2)).toEqual(["isnull", "isnotnull"]);
|
|
13
|
+
});
|
|
14
|
+
test("string fields get the string comparison operators", () => {
|
|
15
|
+
let operators = getOperatorsForField({ type: "string" });
|
|
16
|
+
expect(operators).toContain("contains");
|
|
17
|
+
expect(operators).toContain("beginswith");
|
|
18
|
+
expect(operators).toContain("isblank");
|
|
19
|
+
});
|
|
20
|
+
test("number fields do not get string comparison operators", () => {
|
|
21
|
+
let operators = getOperatorsForField({ type: "number" });
|
|
22
|
+
expect(operators).not.toContain("contains");
|
|
23
|
+
expect(operators).not.toContain("beginswith");
|
|
24
|
+
});
|
|
25
|
+
test("unknown field types fall back to the string operators", () => {
|
|
26
|
+
let operators = getOperatorsForField({ type: "something" });
|
|
27
|
+
expect(operators).toEqual(getOperatorsForField({ type: "string" }));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("getDefaultOperator", () => {
|
|
31
|
+
test.each([
|
|
32
|
+
["string", "beginswith"],
|
|
33
|
+
["number", "equals"],
|
|
34
|
+
["date", "equals"],
|
|
35
|
+
["datetime", "equals"],
|
|
36
|
+
["boolean", "istrue"],
|
|
37
|
+
])("%s defaults to %s", (type, expected) => {
|
|
38
|
+
expect(getDefaultOperator({ type })).toBe(expected);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("getOperatorArity", () => {
|
|
42
|
+
test.each([
|
|
43
|
+
["isnull", "none"],
|
|
44
|
+
["istrue", "none"],
|
|
45
|
+
["isblank", "none"],
|
|
46
|
+
["between", "pair"],
|
|
47
|
+
["datenotbetween", "pair"],
|
|
48
|
+
["inlist", "list"],
|
|
49
|
+
["notinlist", "list"],
|
|
50
|
+
["equals", "single"],
|
|
51
|
+
["contains", "single"],
|
|
52
|
+
])("%s has arity %s", (operator, expected) => {
|
|
53
|
+
expect(getOperatorArity(operator)).toBe(expected);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("getOperatorLabel", () => {
|
|
57
|
+
test("date fields get the relative wording for comparisons", () => {
|
|
58
|
+
expect(getOperatorLabel("greaterthan", "date")).toBe("After");
|
|
59
|
+
expect(getOperatorLabel("lessthanorequal", "datetime")).toBe("Up to");
|
|
60
|
+
});
|
|
61
|
+
test("number fields get the numeric wording", () => {
|
|
62
|
+
expect(getOperatorLabel("greaterthan", "number")).toBe("Greater than");
|
|
63
|
+
});
|
|
64
|
+
test("non-comparison operators are unaffected by the field type", () => {
|
|
65
|
+
expect(getOperatorLabel("inlist", "date")).toBe("In list");
|
|
66
|
+
expect(getOperatorLabel("inlist", "number")).toBe("In list");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("getValueType", () => {
|
|
70
|
+
test("parameterless operators send a null value type", () => {
|
|
71
|
+
expect(getValueType({ type: "string" }, "isnull")).toBe("null");
|
|
72
|
+
expect(getValueType({ type: "boolean" }, "istrue")).toBe("null");
|
|
73
|
+
});
|
|
74
|
+
test.each([
|
|
75
|
+
["string", "string"],
|
|
76
|
+
["number", "number"],
|
|
77
|
+
["date", "datetime"],
|
|
78
|
+
["datetime", "datetime"],
|
|
79
|
+
["uniqueidentifier", "uniqueidentifier"],
|
|
80
|
+
])("%s fields send %s", (type, expected) => {
|
|
81
|
+
expect(getValueType({ type }, "equals")).toBe(expected);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe("normalizeOperator", () => {
|
|
85
|
+
test.each([
|
|
86
|
+
["Equals", "equals"],
|
|
87
|
+
["BeginsWith", "beginswith"],
|
|
88
|
+
["NotDateEquals", "notdatequals"],
|
|
89
|
+
["Not", "notequals"],
|
|
90
|
+
["Tweens", "notbetween"],
|
|
91
|
+
["Not, Date", "notdatequals"],
|
|
92
|
+
])("%s normalizes to %s", (input, expected) => {
|
|
93
|
+
expect(normalizeOperator(input)).toBe(expected);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe("getDynamicValuesForField", () => {
|
|
97
|
+
test("date fields get relative dates", () => {
|
|
98
|
+
expect(getDynamicValuesForField({ type: "date" })).toContain("ThisMonth");
|
|
99
|
+
});
|
|
100
|
+
test("string fields only get the current user", () => {
|
|
101
|
+
expect(getDynamicValuesForField({ type: "string" })).toEqual(["CurrentUser"]);
|
|
102
|
+
});
|
|
103
|
+
test("number fields get none", () => {
|
|
104
|
+
expect(getDynamicValuesForField({ type: "number" })).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
import type { FilterAccessLevel } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Access a user has to a saved filter.
|
|
14
|
+
*
|
|
15
|
+
* `sviw_Database_MyFilters` only ever reports `Reader`, `Editor` or `Manager`,
|
|
16
|
+
* and a filter the current user created is always reported as `Manager`.
|
|
17
|
+
*/
|
|
18
|
+
export type { FilterAccessLevel };
|
|
19
|
+
/**
|
|
20
|
+
* `sviw_Database_MyFilters` unions in a synthetic "No Filter" row so the Windows
|
|
21
|
+
* tree can render a "no filter" entry. It is not a real filter and must be
|
|
22
|
+
* dropped before the list is shown.
|
|
23
|
+
*/
|
|
24
|
+
export declare const NO_FILTER_ID = -1;
|
|
25
|
+
/** A saved filter, as used by the filter builder UI. */
|
|
26
|
+
export type SavedFilter = {
|
|
27
|
+
filterId: number;
|
|
28
|
+
primKey: string;
|
|
29
|
+
name: string;
|
|
30
|
+
/** DevExpress criteria string, e.g. `[Name] Like '%a%'`. */
|
|
31
|
+
criteria: string;
|
|
32
|
+
dbObjectId: string;
|
|
33
|
+
accessLevel: FilterAccessLevel;
|
|
34
|
+
/** True when the current user created the filter. */
|
|
35
|
+
createdByUser: boolean;
|
|
36
|
+
createdBy: string;
|
|
37
|
+
domain: string | null;
|
|
38
|
+
/** True when the filter is visible in every domain the user has access to. */
|
|
39
|
+
crossDomain: boolean;
|
|
40
|
+
/** True when the filter is shared with individual users rather than groups. */
|
|
41
|
+
isPersonallyShared: boolean;
|
|
42
|
+
/** True when the criteria are hidden from readers of a shared filter. */
|
|
43
|
+
hideCriteria: boolean;
|
|
44
|
+
headerText: string | null;
|
|
45
|
+
comment: string | null;
|
|
46
|
+
/** Report parameter values, stored as XML. Only used by report filters. */
|
|
47
|
+
parameters: string | null;
|
|
48
|
+
reportTitle: string | null;
|
|
49
|
+
created: Date | null;
|
|
50
|
+
updated: Date | null;
|
|
51
|
+
};
|
|
52
|
+
/** A raw `sviw_Database_MyFilters` / `sviw_Database_FiltersFromReportID` record. */
|
|
53
|
+
export type SavedFilterRecord = {
|
|
54
|
+
PrimKey?: string | null;
|
|
55
|
+
FilterID?: number | null;
|
|
56
|
+
DBObjectID?: string | null;
|
|
57
|
+
FilterName?: string | null;
|
|
58
|
+
FilterCriteria?: string | null;
|
|
59
|
+
AccessLevel?: string | null;
|
|
60
|
+
CreatedByUser?: string | boolean | null;
|
|
61
|
+
CreatedBy?: string | null;
|
|
62
|
+
Domain?: string | null;
|
|
63
|
+
CrossDomain?: boolean | number | null;
|
|
64
|
+
IsPersonallyShared?: boolean | number | null;
|
|
65
|
+
HideFilterCriteria?: boolean | number | null;
|
|
66
|
+
OptionalHeaderText?: string | null;
|
|
67
|
+
OptionalComment?: string | null;
|
|
68
|
+
Parameters?: string | null;
|
|
69
|
+
ReportTitle?: string | null;
|
|
70
|
+
Created?: Date | string | null;
|
|
71
|
+
Updated?: Date | string | null;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Maps a database record onto a `SavedFilter`.
|
|
75
|
+
*
|
|
76
|
+
* `CreatedByUser` needs care: the view computes it as the *string* `'true'` or
|
|
77
|
+
* `'false'` (and `'False'` on the synthetic row), not as a bit.
|
|
78
|
+
*/
|
|
79
|
+
export declare function toSavedFilter(record: SavedFilterRecord): SavedFilter;
|
|
80
|
+
/** Drops the synthetic "No Filter" row and sorts by name. */
|
|
81
|
+
export declare function toSavedFilters(records: SavedFilterRecord[]): SavedFilter[];
|
|
82
|
+
/**
|
|
83
|
+
* Splits filters into the two branches the Windows tree shows: the ones the user
|
|
84
|
+
* created, and the ones that have been shared with them.
|
|
85
|
+
*/
|
|
86
|
+
export declare function partitionSavedFilters(filters: SavedFilter[]): {
|
|
87
|
+
mine: SavedFilter[];
|
|
88
|
+
shared: SavedFilter[];
|
|
89
|
+
};
|
|
90
|
+
export declare function findSavedFilter(filters: SavedFilter[], filterId: number | null | undefined): SavedFilter | undefined;
|
|
91
|
+
/** True when the user may overwrite the filter's criteria. */
|
|
92
|
+
export declare function canEditSavedFilter(filter: SavedFilter): boolean;
|
|
93
|
+
/** True when the user may change who the filter is shared with. */
|
|
94
|
+
export declare function canShareSavedFilter(filter: SavedFilter): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* True when the user may rename or delete the filter.
|
|
97
|
+
*
|
|
98
|
+
* `sstp_WebSiteCMS_FilterBuilder_RenameFilter` and `…_DeleteFilter` both require
|
|
99
|
+
* `CreatedBy = SUSER_SNAME()` — the manager-access branch is commented out in
|
|
100
|
+
* both procedures, deliberately, because the UI only ever offers these for a
|
|
101
|
+
* user's own filters.
|
|
102
|
+
*/
|
|
103
|
+
export declare function canRenameSavedFilter(filter: SavedFilter): boolean;
|
|
104
|
+
export declare const canDeleteSavedFilter: typeof canRenameSavedFilter;
|
|
105
|
+
/**
|
|
106
|
+
* True when the user may toggle the cross-domain flag.
|
|
107
|
+
*
|
|
108
|
+
* `sstp_Database_Filters_SetCrossDomain` also filters on
|
|
109
|
+
* `CreatedBy = SUSER_SNAME()`.
|
|
110
|
+
*/
|
|
111
|
+
export declare const canSetCrossDomain: typeof canRenameSavedFilter;
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
* `sviw_Database_MyFilters` unions in a synthetic "No Filter" row so the Windows
|
|
13
|
+
* tree can render a "no filter" entry. It is not a real filter and must be
|
|
14
|
+
* dropped before the list is shown.
|
|
15
|
+
*/
|
|
16
|
+
export const NO_FILTER_ID = -1;
|
|
17
|
+
/**
|
|
18
|
+
* Maps a database record onto a `SavedFilter`.
|
|
19
|
+
*
|
|
20
|
+
* `CreatedByUser` needs care: the view computes it as the *string* `'true'` or
|
|
21
|
+
* `'false'` (and `'False'` on the synthetic row), not as a bit.
|
|
22
|
+
*/
|
|
23
|
+
export function toSavedFilter(record) {
|
|
24
|
+
return {
|
|
25
|
+
filterId: record.FilterID ?? NO_FILTER_ID,
|
|
26
|
+
primKey: record.PrimKey ?? "",
|
|
27
|
+
name: record.FilterName ?? "",
|
|
28
|
+
criteria: record.FilterCriteria ?? "",
|
|
29
|
+
dbObjectId: record.DBObjectID ?? "",
|
|
30
|
+
accessLevel: toAccessLevel(record.AccessLevel),
|
|
31
|
+
createdByUser: toBoolean(record.CreatedByUser),
|
|
32
|
+
createdBy: record.CreatedBy ?? "",
|
|
33
|
+
domain: record.Domain ?? null,
|
|
34
|
+
crossDomain: toBoolean(record.CrossDomain),
|
|
35
|
+
isPersonallyShared: toBoolean(record.IsPersonallyShared),
|
|
36
|
+
hideCriteria: toBoolean(record.HideFilterCriteria),
|
|
37
|
+
headerText: record.OptionalHeaderText || null,
|
|
38
|
+
comment: record.OptionalComment || null,
|
|
39
|
+
parameters: record.Parameters || null,
|
|
40
|
+
reportTitle: record.ReportTitle || null,
|
|
41
|
+
created: toDate(record.Created),
|
|
42
|
+
updated: toDate(record.Updated),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function toAccessLevel(value) {
|
|
46
|
+
if (value === "Manager" || value === "Editor")
|
|
47
|
+
return value;
|
|
48
|
+
return "Reader";
|
|
49
|
+
}
|
|
50
|
+
function toBoolean(value) {
|
|
51
|
+
if (typeof value === "string")
|
|
52
|
+
return value.toLowerCase() === "true";
|
|
53
|
+
return Boolean(value);
|
|
54
|
+
}
|
|
55
|
+
function toDate(value) {
|
|
56
|
+
if (!value)
|
|
57
|
+
return null;
|
|
58
|
+
if (value instanceof Date)
|
|
59
|
+
return value;
|
|
60
|
+
let date = new Date(value);
|
|
61
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
62
|
+
}
|
|
63
|
+
/** Drops the synthetic "No Filter" row and sorts by name. */
|
|
64
|
+
export function toSavedFilters(records) {
|
|
65
|
+
return records
|
|
66
|
+
.filter((record) => (record.FilterID ?? NO_FILTER_ID) !== NO_FILTER_ID)
|
|
67
|
+
.map(toSavedFilter)
|
|
68
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Splits filters into the two branches the Windows tree shows: the ones the user
|
|
72
|
+
* created, and the ones that have been shared with them.
|
|
73
|
+
*/
|
|
74
|
+
export function partitionSavedFilters(filters) {
|
|
75
|
+
let mine = [];
|
|
76
|
+
let shared = [];
|
|
77
|
+
for (let filter of filters) {
|
|
78
|
+
if (filter.createdByUser) {
|
|
79
|
+
mine.push(filter);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
shared.push(filter);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { mine, shared };
|
|
86
|
+
}
|
|
87
|
+
export function findSavedFilter(filters, filterId) {
|
|
88
|
+
if (filterId == null)
|
|
89
|
+
return undefined;
|
|
90
|
+
return filters.find((filter) => filter.filterId === filterId);
|
|
91
|
+
}
|
|
92
|
+
/** True when the user may overwrite the filter's criteria. */
|
|
93
|
+
export function canEditSavedFilter(filter) {
|
|
94
|
+
return filter.accessLevel === "Editor" || filter.accessLevel === "Manager";
|
|
95
|
+
}
|
|
96
|
+
/** True when the user may change who the filter is shared with. */
|
|
97
|
+
export function canShareSavedFilter(filter) {
|
|
98
|
+
return filter.accessLevel === "Manager";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* True when the user may rename or delete the filter.
|
|
102
|
+
*
|
|
103
|
+
* `sstp_WebSiteCMS_FilterBuilder_RenameFilter` and `…_DeleteFilter` both require
|
|
104
|
+
* `CreatedBy = SUSER_SNAME()` — the manager-access branch is commented out in
|
|
105
|
+
* both procedures, deliberately, because the UI only ever offers these for a
|
|
106
|
+
* user's own filters.
|
|
107
|
+
*/
|
|
108
|
+
export function canRenameSavedFilter(filter) {
|
|
109
|
+
return filter.createdByUser;
|
|
110
|
+
}
|
|
111
|
+
export const canDeleteSavedFilter = canRenameSavedFilter;
|
|
112
|
+
/**
|
|
113
|
+
* True when the user may toggle the cross-domain flag.
|
|
114
|
+
*
|
|
115
|
+
* `sstp_Database_Filters_SetCrossDomain` also filters on
|
|
116
|
+
* `CreatedBy = SUSER_SNAME()`.
|
|
117
|
+
*/
|
|
118
|
+
export const canSetCrossDomain = canRenameSavedFilter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { canDeleteSavedFilter, canEditSavedFilter, canRenameSavedFilter, canShareSavedFilter, findSavedFilter, NO_FILTER_ID, partitionSavedFilters, toSavedFilter, toSavedFilters, } from "./savedFilters.js";
|
|
3
|
+
function record(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
PrimKey: "11111111-1111-1111-1111-111111111111",
|
|
6
|
+
FilterID: 1,
|
|
7
|
+
DBObjectID: "aviw_Test_View",
|
|
8
|
+
FilterName: "My filter",
|
|
9
|
+
FilterCriteria: "[Name] = 'a'",
|
|
10
|
+
AccessLevel: "Manager",
|
|
11
|
+
CreatedByUser: "true",
|
|
12
|
+
CreatedBy: "bvh",
|
|
13
|
+
Domain: "OB",
|
|
14
|
+
CrossDomain: false,
|
|
15
|
+
IsPersonallyShared: 0,
|
|
16
|
+
HideFilterCriteria: 0,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
describe("toSavedFilter", () => {
|
|
21
|
+
test("maps a record", () => {
|
|
22
|
+
let filter = toSavedFilter(record());
|
|
23
|
+
expect(filter).toMatchObject({
|
|
24
|
+
filterId: 1,
|
|
25
|
+
name: "My filter",
|
|
26
|
+
criteria: "[Name] = 'a'",
|
|
27
|
+
dbObjectId: "aviw_Test_View",
|
|
28
|
+
accessLevel: "Manager",
|
|
29
|
+
createdByUser: true,
|
|
30
|
+
crossDomain: false,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
test("CreatedByUser is a string, not a bit", () => {
|
|
34
|
+
expect(toSavedFilter(record({ CreatedByUser: "false" })).createdByUser).toBe(false);
|
|
35
|
+
expect(toSavedFilter(record({ CreatedByUser: "False" })).createdByUser).toBe(false);
|
|
36
|
+
expect(toSavedFilter(record({ CreatedByUser: "true" })).createdByUser).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
test("bit columns arrive as numbers", () => {
|
|
39
|
+
let filter = toSavedFilter(record({ CrossDomain: 1, IsPersonallyShared: 1, HideFilterCriteria: 1 }));
|
|
40
|
+
expect(filter.crossDomain).toBe(true);
|
|
41
|
+
expect(filter.isPersonallyShared).toBe(true);
|
|
42
|
+
expect(filter.hideCriteria).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
test("an unknown access level falls back to Reader", () => {
|
|
45
|
+
expect(toSavedFilter(record({ AccessLevel: null })).accessLevel).toBe("Reader");
|
|
46
|
+
});
|
|
47
|
+
test("empty optional strings become null", () => {
|
|
48
|
+
let filter = toSavedFilter(record({ OptionalHeaderText: "", OptionalComment: "" }));
|
|
49
|
+
expect(filter.headerText).toBeNull();
|
|
50
|
+
expect(filter.comment).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
test("dates are parsed", () => {
|
|
53
|
+
expect(toSavedFilter(record({ Created: "2026-01-02T03:04:05Z" })).created).toBeInstanceOf(Date);
|
|
54
|
+
expect(toSavedFilter(record({ Created: null })).created).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("toSavedFilters", () => {
|
|
58
|
+
test("drops the synthetic No Filter row", () => {
|
|
59
|
+
let filters = toSavedFilters([record({ FilterID: NO_FILTER_ID, FilterName: "No Filter" }), record()]);
|
|
60
|
+
expect(filters).toHaveLength(1);
|
|
61
|
+
expect(filters[0].name).toBe("My filter");
|
|
62
|
+
});
|
|
63
|
+
test("sorts by name", () => {
|
|
64
|
+
let filters = toSavedFilters([
|
|
65
|
+
record({ FilterID: 1, FilterName: "Beta" }),
|
|
66
|
+
record({ FilterID: 2, FilterName: "Alpha" }),
|
|
67
|
+
]);
|
|
68
|
+
expect(filters.map((filter) => filter.name)).toEqual(["Alpha", "Beta"]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe("partitionSavedFilters", () => {
|
|
72
|
+
test("splits on createdByUser", () => {
|
|
73
|
+
let filters = toSavedFilters([
|
|
74
|
+
record({ FilterID: 1, FilterName: "Mine", CreatedByUser: "true" }),
|
|
75
|
+
record({ FilterID: 2, FilterName: "Theirs", CreatedByUser: "false" }),
|
|
76
|
+
]);
|
|
77
|
+
let { mine, shared } = partitionSavedFilters(filters);
|
|
78
|
+
expect(mine.map((filter) => filter.name)).toEqual(["Mine"]);
|
|
79
|
+
expect(shared.map((filter) => filter.name)).toEqual(["Theirs"]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("permissions", () => {
|
|
83
|
+
test("a reader can neither edit nor share", () => {
|
|
84
|
+
let filter = toSavedFilter(record({ AccessLevel: "Reader", CreatedByUser: "false" }));
|
|
85
|
+
expect(canEditSavedFilter(filter)).toBe(false);
|
|
86
|
+
expect(canShareSavedFilter(filter)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
test("an editor can edit but not share", () => {
|
|
89
|
+
let filter = toSavedFilter(record({ AccessLevel: "Editor", CreatedByUser: "false" }));
|
|
90
|
+
expect(canEditSavedFilter(filter)).toBe(true);
|
|
91
|
+
expect(canShareSavedFilter(filter)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
test("renaming and deleting need ownership, not manager access", () => {
|
|
94
|
+
let managed = toSavedFilter(record({ AccessLevel: "Manager", CreatedByUser: "false" }));
|
|
95
|
+
expect(canShareSavedFilter(managed)).toBe(true);
|
|
96
|
+
expect(canRenameSavedFilter(managed)).toBe(false);
|
|
97
|
+
expect(canDeleteSavedFilter(managed)).toBe(false);
|
|
98
|
+
expect(canRenameSavedFilter(toSavedFilter(record()))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe("findSavedFilter", () => {
|
|
102
|
+
test("finds by id and tolerates null", () => {
|
|
103
|
+
let filters = toSavedFilters([record({ FilterID: 7, FilterName: "Seven" })]);
|
|
104
|
+
expect(findSavedFilter(filters, 7)?.name).toBe("Seven");
|
|
105
|
+
expect(findSavedFilter(filters, 8)).toBeUndefined();
|
|
106
|
+
expect(findSavedFilter(filters, null)).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FilterExpression, FilterGroup, FilterTree } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The "standard layout" is the simplified shape the Windows filter editor can
|
|
4
|
+
* render in its flow view: an outer `or` group whose items are `and` groups of
|
|
5
|
+
* expressions. Anything else falls back to the advanced (DevExpress) editor
|
|
6
|
+
* there, and to the nested tree editor on the web.
|
|
7
|
+
*
|
|
8
|
+
* @see `afFilterEditor.CanRenderFilter`
|
|
9
|
+
*/
|
|
10
|
+
export declare function isStandardLayout(filter: FilterGroup | FilterExpression | null | undefined): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Coerces an arbitrary filter tree into the standard layout.
|
|
13
|
+
*
|
|
14
|
+
* An `or` group's items each become their own `and` group. Anything else is
|
|
15
|
+
* flattened into a single `and` group. This loses nesting, so only use it when
|
|
16
|
+
* the consumer cannot handle a nested tree.
|
|
17
|
+
*/
|
|
18
|
+
export declare function coerceToStandardLayout(filter: FilterGroup | FilterExpression | null | undefined): FilterTree;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isExpression, isGroup } from "./tree.js";
|
|
2
|
+
/**
|
|
3
|
+
* The "standard layout" is the simplified shape the Windows filter editor can
|
|
4
|
+
* render in its flow view: an outer `or` group whose items are `and` groups of
|
|
5
|
+
* expressions. Anything else falls back to the advanced (DevExpress) editor
|
|
6
|
+
* there, and to the nested tree editor on the web.
|
|
7
|
+
*
|
|
8
|
+
* @see `afFilterEditor.CanRenderFilter`
|
|
9
|
+
*/
|
|
10
|
+
export function isStandardLayout(filter) {
|
|
11
|
+
if (!filter || isExpression(filter) || filter.mode !== "or")
|
|
12
|
+
return false;
|
|
13
|
+
return filter.items.every((item) => isGroup(item) && item.mode === "and" && item.items.every(isExpression));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Coerces an arbitrary filter tree into the standard layout.
|
|
17
|
+
*
|
|
18
|
+
* An `or` group's items each become their own `and` group. Anything else is
|
|
19
|
+
* flattened into a single `and` group. This loses nesting, so only use it when
|
|
20
|
+
* the consumer cannot handle a nested tree.
|
|
21
|
+
*/
|
|
22
|
+
export function coerceToStandardLayout(filter) {
|
|
23
|
+
if (!filter)
|
|
24
|
+
return { type: "group", mode: "or", items: [{ type: "group", mode: "and", items: [] }] };
|
|
25
|
+
if (isExpression(filter)) {
|
|
26
|
+
return { type: "group", mode: "or", items: [{ type: "group", mode: "and", items: [filter] }] };
|
|
27
|
+
}
|
|
28
|
+
if (filter.mode === "or") {
|
|
29
|
+
let groups = filter.items.map((item) => {
|
|
30
|
+
let expressions = [];
|
|
31
|
+
collectExpressions(item, expressions);
|
|
32
|
+
return { type: "group", mode: "and", items: expressions };
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
type: "group",
|
|
36
|
+
mode: "or",
|
|
37
|
+
items: groups.length > 0 ? groups : [{ type: "group", mode: "and", items: [] }],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
let expressions = [];
|
|
41
|
+
collectExpressions(filter, expressions);
|
|
42
|
+
return { type: "group", mode: "or", items: [{ type: "group", mode: "and", items: expressions }] };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Collects every expression in a (possibly nested) filter, in document order.
|
|
46
|
+
*/
|
|
47
|
+
function collectExpressions(item, into) {
|
|
48
|
+
if (isExpression(item)) {
|
|
49
|
+
into.push(item);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (let child of item.items) {
|
|
53
|
+
collectExpressions(child, into);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
|
|
3
|
+
import { countExpressions } from "./tree.js";
|
|
4
|
+
function expression(column, operator = "equals", value = "x") {
|
|
5
|
+
return { type: "expression", column, operator, value, valueType: "string" };
|
|
6
|
+
}
|
|
7
|
+
function group(mode, ...items) {
|
|
8
|
+
return { type: "group", mode, items };
|
|
9
|
+
}
|
|
10
|
+
describe("isStandardLayout", () => {
|
|
11
|
+
test("an or of and groups is standard", () => {
|
|
12
|
+
expect(isStandardLayout(group("or", group("and", expression("A"))))).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
test("an and root is not standard", () => {
|
|
15
|
+
expect(isStandardLayout(group("and", group("or", expression("A"))))).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
test("an expression directly under the root is not standard", () => {
|
|
18
|
+
expect(isStandardLayout(group("or", expression("A")))).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
test("a group nested three levels deep is not standard", () => {
|
|
21
|
+
expect(isStandardLayout(group("or", group("and", group("or", expression("A")))))).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
test("null and bare expressions are not standard", () => {
|
|
24
|
+
expect(isStandardLayout(null)).toBe(false);
|
|
25
|
+
expect(isStandardLayout(expression("A"))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("coerceToStandardLayout", () => {
|
|
29
|
+
test("null becomes an empty or/and pair", () => {
|
|
30
|
+
expect(coerceToStandardLayout(null)).toEqual(group("or", group("and")));
|
|
31
|
+
});
|
|
32
|
+
test("a bare expression is wrapped in an or/and pair", () => {
|
|
33
|
+
let result = coerceToStandardLayout(expression("Name"));
|
|
34
|
+
expect(result.mode).toBe("or");
|
|
35
|
+
expect(result.items).toHaveLength(1);
|
|
36
|
+
expect(result.items[0].mode).toBe("and");
|
|
37
|
+
expect(result.items[0].items).toEqual([expression("Name")]);
|
|
38
|
+
});
|
|
39
|
+
test("an and group becomes a single group inside an or group", () => {
|
|
40
|
+
let result = coerceToStandardLayout(group("and", expression("A"), expression("B")));
|
|
41
|
+
expect(result.mode).toBe("or");
|
|
42
|
+
expect(result.items).toHaveLength(1);
|
|
43
|
+
expect(countExpressions(result)).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
test("an or group keeps one and group per item", () => {
|
|
46
|
+
let result = coerceToStandardLayout(group("or", expression("A"), group("and", expression("B"), expression("C"))));
|
|
47
|
+
expect(result.items).toHaveLength(2);
|
|
48
|
+
expect(result.items[0].items).toHaveLength(1);
|
|
49
|
+
expect(result.items[1].items).toHaveLength(2);
|
|
50
|
+
});
|
|
51
|
+
test("deeper nesting is flattened, keeping every expression", () => {
|
|
52
|
+
let nested = group("and", expression("A"), group("or", expression("B"), group("and", expression("C"))));
|
|
53
|
+
let result = coerceToStandardLayout(nested);
|
|
54
|
+
expect(result.items).toHaveLength(1);
|
|
55
|
+
expect(countExpressions(result)).toBe(3);
|
|
56
|
+
});
|
|
57
|
+
test("an empty or group still yields one editable group", () => {
|
|
58
|
+
let result = coerceToStandardLayout(group("or"));
|
|
59
|
+
expect(result.items).toHaveLength(1);
|
|
60
|
+
expect(result.items[0].items).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
test("is idempotent", () => {
|
|
63
|
+
let once = coerceToStandardLayout(group("or", expression("A"), expression("B")));
|
|
64
|
+
expect(coerceToStandardLayout(once)).toEqual(once);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { FieldMetadata, FilterExpression, FilterGroup, FilterOperator, FilterTree } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Path to a node in a filter tree, as a list of item indexes from the root
|
|
4
|
+
* group. The root itself is the empty path.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* // The second condition of the first group
|
|
9
|
+
* let path: FilterPath = [0, 1];
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export type FilterPath = number[];
|
|
13
|
+
export type FilterNode = FilterGroup | FilterExpression;
|
|
14
|
+
/**
|
|
15
|
+
* Creates the default empty filter: a single `and` group with no conditions.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createEmptyFilter(): FilterTree;
|
|
18
|
+
export declare function createEmptyGroup(mode?: FilterGroup["mode"]): FilterGroup;
|
|
19
|
+
export declare function isGroup(item: FilterNode): item is FilterGroup;
|
|
20
|
+
export declare function isExpression(item: FilterNode): item is FilterExpression;
|
|
21
|
+
/**
|
|
22
|
+
* Normalizes any filter value into a tree the editor can render, without
|
|
23
|
+
* changing its structure. An expression is wrapped in a group; a missing filter
|
|
24
|
+
* becomes the default layout.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toFilterTree(filter: FilterNode | null | undefined): FilterTree;
|
|
27
|
+
/**
|
|
28
|
+
* True when the filter contains no expressions. An empty filter must not be
|
|
29
|
+
* saved or applied.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isEmptyFilter(filter: FilterNode | null | undefined): boolean;
|
|
32
|
+
export declare function countExpressions(filter: FilterNode | null | undefined): number;
|
|
33
|
+
/**
|
|
34
|
+
* Removes incomplete expressions and empty groups from the whole tree. Returns
|
|
35
|
+
* null when nothing is left. Use before applying a filter to a data object so
|
|
36
|
+
* incomplete rows are not sent.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pruneFilter(filter: FilterNode | null | undefined): FilterGroup | null;
|
|
39
|
+
/**
|
|
40
|
+
* True when an expression has everything it needs to be sent to the server.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isCompleteExpression(item: FilterNode): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Creates an expression for a field, using the field's default operator and an
|
|
45
|
+
* empty value of the right shape.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createExpression(field: FieldMetadata, operator?: FilterOperator): FilterExpression;
|
|
48
|
+
/**
|
|
49
|
+
* Returns a copy of the expression with a new operator, adjusting the value to
|
|
50
|
+
* the new operator's arity and the value type accordingly.
|
|
51
|
+
*/
|
|
52
|
+
export declare function changeOperator(expression: FilterExpression, operator: FilterOperator, field: Pick<FieldMetadata, "type">): FilterExpression;
|
|
53
|
+
/** Returns the node at `path`, or null when the path does not exist. */
|
|
54
|
+
export declare function getNodeAtPath(filter: FilterNode, path: FilterPath): FilterNode | null;
|
|
55
|
+
/**
|
|
56
|
+
* Returns a copy of the tree with `updater` applied to the node at `path`.
|
|
57
|
+
* Returning null removes the node. An unknown path leaves the tree untouched.
|
|
58
|
+
*/
|
|
59
|
+
export declare function updateNodeAtPath<T extends FilterNode>(filter: T, path: FilterPath, updater: (node: FilterNode) => FilterNode | null): T;
|
|
60
|
+
/** Returns a copy of the tree with `node` appended to the group at `path`. */
|
|
61
|
+
export declare function insertNodeAtPath<T extends FilterNode>(filter: T, path: FilterPath, node: FilterNode): T;
|
|
62
|
+
/**
|
|
63
|
+
* Returns a copy of the tree with the node at `path` removed. The root is never
|
|
64
|
+
* removed; it is reset to the default layout instead.
|
|
65
|
+
*/
|
|
66
|
+
export declare function removeNodeAtPath<T extends FilterNode>(filter: T, path: FilterPath): T;
|
|
67
|
+
/** Returns a copy of the tree with the mode of the group at `path` changed. */
|
|
68
|
+
export declare function setGroupMode<T extends FilterNode>(filter: T, path: FilterPath, mode: FilterGroup["mode"]): T;
|
|
69
|
+
/** Returns a copy of the tree with an empty group appended to the group at `path`. */
|
|
70
|
+
export declare function addGroupAtPath<T extends FilterNode>(filter: T, path?: FilterPath): T;
|