@olenbetong/appframe-core 2.11.10 → 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 +1982 -626
- package/dist/esm/af.common.legacy.js +1936 -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 +2140 -708
- package/dist/iife/af.common.legacy.js +2094 -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 +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 +3 -1
- 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 +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 +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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/appframe-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Javascript libraries to utilize the Appframe framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -40,17 +40,17 @@
|
|
|
40
40
|
"mitt": "^3.0.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@happy-dom/global-registrator": "^
|
|
44
|
-
"@types/bun": "^1.
|
|
45
|
-
"chalk": "^5.
|
|
43
|
+
"@happy-dom/global-registrator": "^20.11.2",
|
|
44
|
+
"@types/bun": "^1.3.14",
|
|
45
|
+
"chalk": "^5.6.2"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {},
|
|
48
48
|
"optionalDependencies": {},
|
|
49
49
|
"scripts": {
|
|
50
|
-
"build": "pnpm run build:tsc && pnpm test && pnpm run build:
|
|
50
|
+
"build": "pnpm run build:tsc && pnpm test && pnpm run build:bundle && pnpm run build:replace-version",
|
|
51
51
|
"test": "vitest run --config vitest.node.config.ts",
|
|
52
|
-
"build:
|
|
52
|
+
"build:bundle": "node --no-warnings ../../scripts/packages-bundle.ts",
|
|
53
53
|
"build:replace-version": "node ./scripts/replace-version.js",
|
|
54
|
-
"build:tsc": "
|
|
54
|
+
"build:tsc": "node --no-warnings ../../scripts/clean.ts ./es tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json"
|
|
55
55
|
}
|
|
56
56
|
}
|
package/src/alert.ts
CHANGED
|
@@ -11,13 +11,28 @@ const template = `
|
|
|
11
11
|
</dialog>
|
|
12
12
|
`;
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/*
|
|
15
|
+
* The module can be loaded from `<head>`, where there is no body to append to
|
|
16
|
+
* yet. Waiting for the DOM in that case keeps the whole bundle from throwing on
|
|
17
|
+
* load — everything else it exports would be lost with it.
|
|
18
|
+
*/
|
|
19
|
+
function addDialogTemplate() {
|
|
20
|
+
if (document.querySelector("#AlertDialogTemplate")) return;
|
|
21
|
+
|
|
15
22
|
let $template = document.createElement("template");
|
|
16
23
|
$template.id = "AlertDialogTemplate";
|
|
17
24
|
$template.innerHTML = template;
|
|
18
25
|
document.body.append($template);
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
if (globalThis.document) {
|
|
29
|
+
if (document.body) {
|
|
30
|
+
addDialogTemplate();
|
|
31
|
+
} else {
|
|
32
|
+
document.addEventListener("DOMContentLoaded", addDialogTemplate, { once: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export function waitForDialog(dialog: HTMLDialogElement, cancelValue = "No"): Promise<string> {
|
|
22
37
|
return new Promise((resolve) => {
|
|
23
38
|
let controller = new AbortController();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { toFilterTree } from "./tree.js";
|
|
2
|
+
import type { FilterGroup } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const CONVERT_URL = "/api/filter/convert";
|
|
5
|
+
|
|
6
|
+
export type FilterConversionResult = {
|
|
7
|
+
filterObject: FilterGroup | null;
|
|
8
|
+
filterString: string;
|
|
9
|
+
whereClause: string;
|
|
10
|
+
/** Set when the filter string could not be parsed into a structured filter. */
|
|
11
|
+
warning?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ConvertRequest = {
|
|
15
|
+
filterObject?: FilterGroup | null;
|
|
16
|
+
filterString?: string;
|
|
17
|
+
/** Optional view name, used server-side to validate field names. */
|
|
18
|
+
viewName?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Converts between a structured filter and a DevExpress filter string.
|
|
23
|
+
*
|
|
24
|
+
* The conversion is done server-side by `api/filter/convert`, because the
|
|
25
|
+
* criteria string grammar is implemented by the licensed DevExpress parser that
|
|
26
|
+
* the Windows client and the report engine use. Doing it in the browser would
|
|
27
|
+
* risk producing strings the server cannot parse back.
|
|
28
|
+
*/
|
|
29
|
+
export async function convertFilter(request: ConvertRequest, signal?: AbortSignal): Promise<FilterConversionResult> {
|
|
30
|
+
let response = await fetch(CONVERT_URL, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify(request),
|
|
34
|
+
signal,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(`Failed to convert filter: ${response.status} ${response.statusText}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let result = (await response.json()) as FilterConversionResult;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
...result,
|
|
45
|
+
filterObject: result.filterObject ? toFilterTree(result.filterObject) : null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { FilterExpression } from "./types.js";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { describeFilter } from "./describeFilter.js";
|
|
5
|
+
import { createEmptyFilter } from "./tree.js";
|
|
6
|
+
|
|
7
|
+
const fields = [
|
|
8
|
+
{ name: "PersonID", caption: "Person ID", type: "string" as const },
|
|
9
|
+
{ name: "Created", caption: "Created", type: "date" as const },
|
|
10
|
+
{ name: "Amount", caption: "Amount", type: "number" as const },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function expression(
|
|
14
|
+
column: string,
|
|
15
|
+
operator: string,
|
|
16
|
+
value: FilterExpression["value"],
|
|
17
|
+
valueType = "string",
|
|
18
|
+
): FilterExpression {
|
|
19
|
+
return { type: "expression", column, operator, value, valueType };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("describeFilter", () => {
|
|
23
|
+
test("empty filters describe as an empty string", () => {
|
|
24
|
+
expect(describeFilter(null)).toBe("");
|
|
25
|
+
expect(describeFilter(createEmptyFilter())).toBe("");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("uses the field caption when available", () => {
|
|
29
|
+
let filter = expression("PersonID", "contains", "silv");
|
|
30
|
+
|
|
31
|
+
expect(describeFilter(filter, { fields })).toBe("[Person ID] Contains 'silv'");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("falls back to the column name when the field is unknown", () => {
|
|
35
|
+
expect(describeFilter(expression("Unknown", "equals", "x"), { fields })).toBe("[Unknown] Equal to 'x'");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("parameterless operators render without a value", () => {
|
|
39
|
+
expect(describeFilter(expression("PersonID", "isnull", null, "null"), { fields })).toBe("[Person ID] Is null");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("numbers are not quoted", () => {
|
|
43
|
+
expect(describeFilter(expression("Amount", "greaterthan", 100, "number"), { fields })).toBe(
|
|
44
|
+
"[Amount] Greater than 100",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("date fields use the relative operator wording", () => {
|
|
49
|
+
expect(describeFilter(expression("Created", "greaterthan", "2026-01-01", "datetime"), { fields })).toBe(
|
|
50
|
+
"[Created] After '2026-01-01'",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("dynamic values render as their label", () => {
|
|
55
|
+
expect(describeFilter(expression("Created", "equals", "ThisMonth", "special"), { fields })).toBe(
|
|
56
|
+
"[Created] Equal to This month",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("between renders a range", () => {
|
|
61
|
+
expect(describeFilter(expression("Amount", "between", ["1", "9"], "number"), { fields })).toBe(
|
|
62
|
+
"[Amount] Between 1 - 9",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("in list renders a parenthesised list", () => {
|
|
67
|
+
expect(describeFilter(expression("PersonID", "inlist", ["a", "b"]), { fields })).toBe(
|
|
68
|
+
"[Person ID] In list ('a', 'b')",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("groups are joined by their mode and nested groups are parenthesised", () => {
|
|
73
|
+
let filter = {
|
|
74
|
+
type: "group" as const,
|
|
75
|
+
mode: "or" as const,
|
|
76
|
+
items: [
|
|
77
|
+
{
|
|
78
|
+
type: "group" as const,
|
|
79
|
+
mode: "and" as const,
|
|
80
|
+
items: [expression("PersonID", "equals", "a"), expression("Amount", "greaterthan", 5, "number")],
|
|
81
|
+
},
|
|
82
|
+
{ type: "group" as const, mode: "and" as const, items: [expression("PersonID", "equals", "b")] },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
expect(describeFilter(filter, { fields })).toBe(
|
|
87
|
+
"([Person ID] Equal to 'a' AND [Amount] Greater than 5) OR [Person ID] Equal to 'b'",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("empty groups are omitted", () => {
|
|
92
|
+
let filter = {
|
|
93
|
+
type: "group" as const,
|
|
94
|
+
mode: "or" as const,
|
|
95
|
+
items: [
|
|
96
|
+
{ type: "group" as const, mode: "and" as const, items: [expression("PersonID", "equals", "a")] },
|
|
97
|
+
{ type: "group" as const, mode: "and" as const, items: [] },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
expect(describeFilter(filter, { fields })).toBe("[Person ID] Equal to 'a'");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { FilterExpression, FilterGroup } from "./types.js";
|
|
2
|
+
import { getDynamicValueLabel, getOperatorArity, getOperatorLabel, normalizeOperator } from "./operators.js";
|
|
3
|
+
import { isExpression } from "./tree.js";
|
|
4
|
+
import type { DynamicValue, FieldMetadata } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type DescribeFilterOptions = {
|
|
7
|
+
/** Field metadata, used to resolve captions and date operator wording. */
|
|
8
|
+
fields?: FieldMetadata[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds a human-readable description of a filter, without contacting the
|
|
13
|
+
* server. Use this for read-only summaries; the authoritative filter string is
|
|
14
|
+
* produced by `api/filter/convert`, since only the server can generate the
|
|
15
|
+
* DevExpress criteria syntax.
|
|
16
|
+
*/
|
|
17
|
+
export function describeFilter(
|
|
18
|
+
filter: FilterGroup | FilterExpression | null | undefined,
|
|
19
|
+
options: DescribeFilterOptions = {},
|
|
20
|
+
): string {
|
|
21
|
+
if (!filter) return "";
|
|
22
|
+
|
|
23
|
+
let fieldsByName = new Map((options.fields ?? []).map((field) => [field.name.toLowerCase(), field]));
|
|
24
|
+
|
|
25
|
+
return describe(filter, fieldsByName, 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function describe(item: FilterGroup | FilterExpression, fields: Map<string, FieldMetadata>, depth: number): string {
|
|
29
|
+
if (isExpression(item)) return describeExpression(item, fields);
|
|
30
|
+
|
|
31
|
+
let parts = item.items.map((child) => describe(child, fields, depth + 1)).filter((part) => part !== "");
|
|
32
|
+
|
|
33
|
+
if (parts.length === 0) return "";
|
|
34
|
+
if (parts.length === 1) return parts[0] as string;
|
|
35
|
+
|
|
36
|
+
let separator = item.mode === "or" ? " OR " : " AND ";
|
|
37
|
+
let joined = parts.join(separator);
|
|
38
|
+
|
|
39
|
+
return depth === 0 ? joined : `(${joined})`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function describeExpression(expression: FilterExpression, fields: Map<string, FieldMetadata>): string {
|
|
43
|
+
if (!expression.column || !expression.operator) return "";
|
|
44
|
+
|
|
45
|
+
let field = fields.get(expression.column.toLowerCase());
|
|
46
|
+
let caption = field?.caption ?? expression.column;
|
|
47
|
+
let operator = normalizeOperator(expression.operator);
|
|
48
|
+
let label = getOperatorLabel(operator, field?.type);
|
|
49
|
+
let arity = getOperatorArity(operator);
|
|
50
|
+
|
|
51
|
+
if (arity === "none") return `[${caption}] ${label}`;
|
|
52
|
+
|
|
53
|
+
return `[${caption}] ${label} ${formatValue(expression, arity)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatValue(expression: FilterExpression, arity: ReturnType<typeof getOperatorArity>): string {
|
|
57
|
+
let { value, valueType } = expression;
|
|
58
|
+
|
|
59
|
+
if (valueType === "special" && typeof value === "string") {
|
|
60
|
+
return getDynamicValueLabel(value as DynamicValue);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
let formatted = value.map((item) => formatScalar(item, valueType));
|
|
65
|
+
|
|
66
|
+
return arity === "pair" ? formatted.join(" - ") : `(${formatted.join(", ")})`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return formatScalar(value, valueType);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatScalar(value: unknown, valueType: string): string {
|
|
73
|
+
if (value === null || value === undefined) return "";
|
|
74
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
75
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
76
|
+
if (valueType === "number" || valueType === "boolean") return String(value);
|
|
77
|
+
|
|
78
|
+
return `'${String(value)}'`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { toFieldMetadata, toFieldMetadataList, toFilterFieldType } from "./fields.js";
|
|
4
|
+
|
|
5
|
+
describe("toFilterFieldType", () => {
|
|
6
|
+
test("passes through the types the view normalizes to", () => {
|
|
7
|
+
expect(toFilterFieldType("boolean")).toBe("boolean");
|
|
8
|
+
expect(toFilterFieldType("datetime")).toBe("datetime");
|
|
9
|
+
expect(toFilterFieldType("uniqueidentifier")).toBe("uniqueidentifier");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("an unknown or missing type is filtered as text", () => {
|
|
13
|
+
expect(toFilterFieldType("geography")).toBe("string");
|
|
14
|
+
expect(toFilterFieldType(null)).toBe("string");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("toFieldMetadata", () => {
|
|
19
|
+
test("maps a record", () => {
|
|
20
|
+
let field = toFieldMetadata({
|
|
21
|
+
FieldName: "PersonID",
|
|
22
|
+
DataType: "string",
|
|
23
|
+
Nullable: true,
|
|
24
|
+
ExcludeFromFilter: false,
|
|
25
|
+
ValueListRecordSource: "<afRecordSource />",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(field).toEqual({
|
|
29
|
+
name: "PersonID",
|
|
30
|
+
type: "string",
|
|
31
|
+
nullable: true,
|
|
32
|
+
excludeFromFilter: false,
|
|
33
|
+
valueListRecordSource: "<afRecordSource />",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("bit columns arrive as numbers", () => {
|
|
38
|
+
expect(toFieldMetadata({ FieldName: "A", Nullable: 1 }).nullable).toBe(true);
|
|
39
|
+
expect(toFieldMetadata({ FieldName: "A", Nullable: 0 }).nullable).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an empty record source is dropped rather than kept as an empty string", () => {
|
|
43
|
+
expect(toFieldMetadata({ FieldName: "A", ValueListRecordSource: "" }).valueListRecordSource).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("toFieldMetadataList", () => {
|
|
48
|
+
test("sorts by name and drops excluded and unnamed fields", () => {
|
|
49
|
+
let fields = toFieldMetadataList([
|
|
50
|
+
{ FieldName: "Name", DataType: "string" },
|
|
51
|
+
{ FieldName: "Age", DataType: "number" },
|
|
52
|
+
{ FieldName: "CDL", DataType: "boolean", ExcludeFromFilter: true },
|
|
53
|
+
{ FieldName: "", DataType: "string" },
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
expect(fields.map((field) => field.name)).toEqual(["Age", "Name"]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field metadata for the filter builder.
|
|
3
|
+
*
|
|
4
|
+
* `sviw_WebSiteCMS_FilterBuilder_ViewFields` normalizes the SQL data types into
|
|
5
|
+
* the handful of types the builder groups fields by, so the mapping here is
|
|
6
|
+
* mostly a rename. The loading lives in `useFilterFields` in
|
|
7
|
+
* `@olenbetong/appframe-react`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { FieldMetadata, FilterFieldType } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** A raw `sviw_WebSiteCMS_FilterBuilder_ViewFields` record. */
|
|
13
|
+
export type ViewFieldRecord = {
|
|
14
|
+
FieldName?: string | null;
|
|
15
|
+
/** Already normalized by the view; anything unexpected is treated as text. */
|
|
16
|
+
DataType?: string | null;
|
|
17
|
+
DBObjectID?: string | null;
|
|
18
|
+
Nullable?: boolean | number | null;
|
|
19
|
+
ExcludeFromFilter?: boolean | number | null;
|
|
20
|
+
ValueListRecordSource?: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const FIELD_TYPES: FilterFieldType[] = ["string", "number", "boolean", "date", "datetime", "uniqueidentifier"];
|
|
24
|
+
|
|
25
|
+
/** The builder type for a normalized `DataType`. Unknown types are filtered as text. */
|
|
26
|
+
export function toFilterFieldType(dataType: string | null | undefined): FilterFieldType {
|
|
27
|
+
let normalized = dataType?.trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
return FIELD_TYPES.find((type) => type === normalized) ?? "string";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toBoolean(value: boolean | number | null | undefined) {
|
|
33
|
+
return value === true || value === 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Maps a view field record onto the metadata the builder renders. */
|
|
37
|
+
export function toFieldMetadata(record: ViewFieldRecord): FieldMetadata {
|
|
38
|
+
return {
|
|
39
|
+
name: record.FieldName ?? "",
|
|
40
|
+
type: toFilterFieldType(record.DataType),
|
|
41
|
+
nullable: toBoolean(record.Nullable),
|
|
42
|
+
excludeFromFilter: toBoolean(record.ExcludeFromFilter),
|
|
43
|
+
valueListRecordSource: record.ValueListRecordSource || undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Maps and sorts view field records. Fields the view marks as excluded are
|
|
49
|
+
* dropped rather than hidden, since nothing else can use them.
|
|
50
|
+
*/
|
|
51
|
+
export function toFieldMetadataList(records: ViewFieldRecord[]): FieldMetadata[] {
|
|
52
|
+
return records
|
|
53
|
+
.map(toFieldMetadata)
|
|
54
|
+
.filter((field) => field.name !== "" && !field.excludeFromFilter)
|
|
55
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
56
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export { type ConvertRequest, convertFilter, type FilterConversionResult } from "./convertFilter.js";
|
|
2
|
+
export { describeFilter, type DescribeFilterOptions } from "./describeFilter.js";
|
|
3
|
+
export { toFieldMetadata, toFieldMetadataList, toFilterFieldType, type ViewFieldRecord } from "./fields.js";
|
|
4
|
+
export {
|
|
5
|
+
getDefaultOperator,
|
|
6
|
+
getDynamicValueLabel,
|
|
7
|
+
getDynamicValuesForField,
|
|
8
|
+
getOperatorArity,
|
|
9
|
+
getOperatorLabel,
|
|
10
|
+
getOperatorsForField,
|
|
11
|
+
getValueType,
|
|
12
|
+
normalizeOperator,
|
|
13
|
+
type OperatorArity,
|
|
14
|
+
RELATIVE_DATE_VALUES,
|
|
15
|
+
} from "./operators.js";
|
|
16
|
+
export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
|
|
17
|
+
export {
|
|
18
|
+
canDeleteSavedFilter,
|
|
19
|
+
canEditSavedFilter,
|
|
20
|
+
canRenameSavedFilter,
|
|
21
|
+
canSetCrossDomain,
|
|
22
|
+
canShareSavedFilter,
|
|
23
|
+
findSavedFilter,
|
|
24
|
+
NO_FILTER_ID,
|
|
25
|
+
partitionSavedFilters,
|
|
26
|
+
type SavedFilter,
|
|
27
|
+
type SavedFilterRecord,
|
|
28
|
+
toSavedFilter,
|
|
29
|
+
toSavedFilters,
|
|
30
|
+
} from "./savedFilters.js";
|
|
31
|
+
export {
|
|
32
|
+
addGroupAtPath,
|
|
33
|
+
changeOperator,
|
|
34
|
+
countExpressions,
|
|
35
|
+
createEmptyFilter,
|
|
36
|
+
createEmptyGroup,
|
|
37
|
+
createExpression,
|
|
38
|
+
type FilterNode,
|
|
39
|
+
type FilterPath,
|
|
40
|
+
getNodeAtPath,
|
|
41
|
+
insertNodeAtPath,
|
|
42
|
+
isCompleteExpression,
|
|
43
|
+
isEmptyFilter,
|
|
44
|
+
isExpression,
|
|
45
|
+
isGroup,
|
|
46
|
+
pruneFilter,
|
|
47
|
+
removeNodeAtPath,
|
|
48
|
+
setGroupMode,
|
|
49
|
+
toFilterTree,
|
|
50
|
+
updateNodeAtPath,
|
|
51
|
+
} from "./tree.js";
|
|
52
|
+
export type {
|
|
53
|
+
DynamicValue,
|
|
54
|
+
FieldMetadata,
|
|
55
|
+
FilterAccessLevel,
|
|
56
|
+
FilterExpression,
|
|
57
|
+
FilterFieldType,
|
|
58
|
+
FilterGroup,
|
|
59
|
+
FilterOperator,
|
|
60
|
+
FilterTree,
|
|
61
|
+
FilterValueType,
|
|
62
|
+
} from "./types.js";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getDefaultOperator,
|
|
5
|
+
getDynamicValuesForField,
|
|
6
|
+
getOperatorArity,
|
|
7
|
+
getOperatorLabel,
|
|
8
|
+
getOperatorsForField,
|
|
9
|
+
getValueType,
|
|
10
|
+
normalizeOperator,
|
|
11
|
+
} from "./operators.js";
|
|
12
|
+
|
|
13
|
+
describe("getOperatorsForField", () => {
|
|
14
|
+
test("boolean fields only get truth checks", () => {
|
|
15
|
+
expect(getOperatorsForField({ type: "boolean" })).toEqual(["istrue", "isfalse"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("boolean fields never get null checks", () => {
|
|
19
|
+
expect(getOperatorsForField({ type: "boolean", nullable: true })).toEqual(["istrue", "isfalse"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("nullable fields get null checks last", () => {
|
|
23
|
+
let operators = getOperatorsForField({ type: "number", nullable: true });
|
|
24
|
+
|
|
25
|
+
expect(operators.slice(-2)).toEqual(["isnull", "isnotnull"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("string fields get the string comparison operators", () => {
|
|
29
|
+
let operators = getOperatorsForField({ type: "string" });
|
|
30
|
+
|
|
31
|
+
expect(operators).toContain("contains");
|
|
32
|
+
expect(operators).toContain("beginswith");
|
|
33
|
+
expect(operators).toContain("isblank");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("number fields do not get string comparison operators", () => {
|
|
37
|
+
let operators = getOperatorsForField({ type: "number" });
|
|
38
|
+
|
|
39
|
+
expect(operators).not.toContain("contains");
|
|
40
|
+
expect(operators).not.toContain("beginswith");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("unknown field types fall back to the string operators", () => {
|
|
44
|
+
let operators = getOperatorsForField({ type: "something" as never });
|
|
45
|
+
|
|
46
|
+
expect(operators).toEqual(getOperatorsForField({ type: "string" }));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("getDefaultOperator", () => {
|
|
51
|
+
test.each([
|
|
52
|
+
["string", "beginswith"],
|
|
53
|
+
["number", "equals"],
|
|
54
|
+
["date", "equals"],
|
|
55
|
+
["datetime", "equals"],
|
|
56
|
+
["boolean", "istrue"],
|
|
57
|
+
] as const)("%s defaults to %s", (type, expected) => {
|
|
58
|
+
expect(getDefaultOperator({ type })).toBe(expected);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("getOperatorArity", () => {
|
|
63
|
+
test.each([
|
|
64
|
+
["isnull", "none"],
|
|
65
|
+
["istrue", "none"],
|
|
66
|
+
["isblank", "none"],
|
|
67
|
+
["between", "pair"],
|
|
68
|
+
["datenotbetween", "pair"],
|
|
69
|
+
["inlist", "list"],
|
|
70
|
+
["notinlist", "list"],
|
|
71
|
+
["equals", "single"],
|
|
72
|
+
["contains", "single"],
|
|
73
|
+
] as const)("%s has arity %s", (operator, expected) => {
|
|
74
|
+
expect(getOperatorArity(operator)).toBe(expected);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("getOperatorLabel", () => {
|
|
79
|
+
test("date fields get the relative wording for comparisons", () => {
|
|
80
|
+
expect(getOperatorLabel("greaterthan", "date")).toBe("After");
|
|
81
|
+
expect(getOperatorLabel("lessthanorequal", "datetime")).toBe("Up to");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("number fields get the numeric wording", () => {
|
|
85
|
+
expect(getOperatorLabel("greaterthan", "number")).toBe("Greater than");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("non-comparison operators are unaffected by the field type", () => {
|
|
89
|
+
expect(getOperatorLabel("inlist", "date")).toBe("In list");
|
|
90
|
+
expect(getOperatorLabel("inlist", "number")).toBe("In list");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("getValueType", () => {
|
|
95
|
+
test("parameterless operators send a null value type", () => {
|
|
96
|
+
expect(getValueType({ type: "string" }, "isnull")).toBe("null");
|
|
97
|
+
expect(getValueType({ type: "boolean" }, "istrue")).toBe("null");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test.each([
|
|
101
|
+
["string", "string"],
|
|
102
|
+
["number", "number"],
|
|
103
|
+
["date", "datetime"],
|
|
104
|
+
["datetime", "datetime"],
|
|
105
|
+
["uniqueidentifier", "uniqueidentifier"],
|
|
106
|
+
] as const)("%s fields send %s", (type, expected) => {
|
|
107
|
+
expect(getValueType({ type }, "equals")).toBe(expected);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("normalizeOperator", () => {
|
|
112
|
+
test.each([
|
|
113
|
+
["Equals", "equals"],
|
|
114
|
+
["BeginsWith", "beginswith"],
|
|
115
|
+
["NotDateEquals", "notdatequals"],
|
|
116
|
+
["Not", "notequals"],
|
|
117
|
+
["Tweens", "notbetween"],
|
|
118
|
+
["Not, Date", "notdatequals"],
|
|
119
|
+
])("%s normalizes to %s", (input, expected) => {
|
|
120
|
+
expect(normalizeOperator(input)).toBe(expected);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("getDynamicValuesForField", () => {
|
|
125
|
+
test("date fields get relative dates", () => {
|
|
126
|
+
expect(getDynamicValuesForField({ type: "date" })).toContain("ThisMonth");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("string fields only get the current user", () => {
|
|
130
|
+
expect(getDynamicValuesForField({ type: "string" })).toEqual(["CurrentUser"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("number fields get none", () => {
|
|
134
|
+
expect(getDynamicValuesForField({ type: "number" })).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|