@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/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { downloadBlob } from "./downloadBlob.js";
|
|
|
13
13
|
export type { DownloadItem, DownloadParameters } from "./downloadRequest.js";
|
|
14
14
|
export { downloadRequest } from "./downloadRequest.js";
|
|
15
15
|
export { expose } from "./expose.js";
|
|
16
|
+
export * from "./filter/index.js";
|
|
16
17
|
export { getLocalizedString } from "./getLocalizedString.js";
|
|
17
18
|
export type { getUserImageOptions } from "./getUserImage.js";
|
|
18
19
|
export { getUserImage } from "./getUserImage.js";
|
|
@@ -20,13 +21,14 @@ export { invariant } from "./invariant.js";
|
|
|
20
21
|
export { localize } from "./localize.js";
|
|
21
22
|
export { afLocalStorage as localStorage };
|
|
22
23
|
export { getSessionData, logActivity, logError, startActivity, time } from "./log.js";
|
|
24
|
+
export * from "./report/index.js";
|
|
23
25
|
export { default as SessionHandler } from "./SessionHandler.js";
|
|
24
26
|
export { UserSession };
|
|
25
27
|
export type { AlertDialogButton, AlertDialogOptions, AlertOptions, ConfirmOptions } from "./alert.js";
|
|
26
28
|
export { alert, ConfirmButtons, confirm, openAlertDialog, waitForDialog } from "./alert.js";
|
|
27
29
|
export { getSettings, saveSettings } from "./settings.js";
|
|
28
30
|
|
|
29
|
-
//
|
|
31
|
+
// oxlint-disable-next-line no-use-before-define -- this is injected build time
|
|
30
32
|
export const version = __VERSION__;
|
|
31
33
|
|
|
32
34
|
declare global {
|
package/src/parseFunc.test.ts
CHANGED
|
@@ -8,11 +8,10 @@ function testFunction(arg1: string, arg2: string) {
|
|
|
8
8
|
|
|
9
9
|
describe("parseFunc", () => {
|
|
10
10
|
test("should parse function", () => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
});
|
|
11
|
+
const result = parseFunc(testFunction);
|
|
12
|
+
expect(result.name).toBe("testFunction");
|
|
13
|
+
expect(result.args).toEqual(["arg1", "arg2"]);
|
|
14
|
+
expect(result.body.trim()).toBe("console.log(arg1, arg2);");
|
|
16
15
|
});
|
|
17
16
|
|
|
18
17
|
test("should parse arrow function returning object literal", () => {
|
|
@@ -57,7 +56,7 @@ describe("parseFunc", () => {
|
|
|
57
56
|
expect(result.args).toEqual(["x", "y"]);
|
|
58
57
|
expect(result.body.trim()).toBe("return x * y;");
|
|
59
58
|
|
|
60
|
-
//
|
|
59
|
+
// oxlint-disable-next-line prefer-arrow-callback -- that would defeat the purpose of the test
|
|
61
60
|
const secondResult = parseFunc(function (x, y) {
|
|
62
61
|
return x * y;
|
|
63
62
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export { fetchParameterDefault, resolveParameterDefaults } from "./parameterDefaults.js";
|
|
2
|
+
export {
|
|
3
|
+
coerceParameterValue,
|
|
4
|
+
getDefaultParameterValues,
|
|
5
|
+
isProcedureDefault,
|
|
6
|
+
parseParameterItems,
|
|
7
|
+
parseParameters,
|
|
8
|
+
restoreParameterValues,
|
|
9
|
+
serializeParameters,
|
|
10
|
+
toReportParameter,
|
|
11
|
+
toReportParameters,
|
|
12
|
+
} from "./parameters.js";
|
|
13
|
+
export { decodeXml, encodeXml, parseRecordSource, readElement, readElements } from "./recordSource.js";
|
|
14
|
+
export {
|
|
15
|
+
downloadReport,
|
|
16
|
+
getReportExtension,
|
|
17
|
+
getReportFileName,
|
|
18
|
+
getReportMediaType,
|
|
19
|
+
getReportRequest,
|
|
20
|
+
openReport,
|
|
21
|
+
printReport,
|
|
22
|
+
renderReport,
|
|
23
|
+
type ReportRenderOptions,
|
|
24
|
+
} from "./render.js";
|
|
25
|
+
export { getReportFormats, toReport } from "./report.js";
|
|
26
|
+
export type {
|
|
27
|
+
Report,
|
|
28
|
+
ReportFormat,
|
|
29
|
+
ReportParameter,
|
|
30
|
+
ReportParameterEditor,
|
|
31
|
+
ReportParameterRecord,
|
|
32
|
+
ReportParameterType,
|
|
33
|
+
ReportParameterValue,
|
|
34
|
+
ReportParameterValues,
|
|
35
|
+
ReportRecord,
|
|
36
|
+
ReportRecordSource,
|
|
37
|
+
} from "./types.js";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving report parameter defaults that name a stored procedure.
|
|
3
|
+
*
|
|
4
|
+
* A `DefaultValue` matching `^[a-z]stp_` names a procedure that produces the
|
|
5
|
+
* default rather than being the default itself. Executing it is a server
|
|
6
|
+
* concern, and the framework already exposes it through the `WebsiteCMS`
|
|
7
|
+
* `api/reporting/filters` route.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isProcedureDefault } from "./parameters.js";
|
|
11
|
+
import type { ReportParameter, ReportParameterValue, ReportParameterValues } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** A result set as the reporting route serializes it. */
|
|
14
|
+
type ProcedureResult = Record<string, unknown[]> | unknown[];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs the stored procedure a parameter's default names and returns the value
|
|
18
|
+
* it produces — the first column of the first row, the way the Windows launcher
|
|
19
|
+
* reads it.
|
|
20
|
+
*
|
|
21
|
+
* Returns `null` when the parameter has no procedure default or the procedure
|
|
22
|
+
* produced no rows.
|
|
23
|
+
*/
|
|
24
|
+
export async function fetchParameterDefault(
|
|
25
|
+
reportId: string,
|
|
26
|
+
parameter: ReportParameter,
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
): Promise<ReportParameterValue> {
|
|
29
|
+
if (!parameter.defaultValue || !isProcedureDefault(parameter.defaultValue)) return null;
|
|
30
|
+
|
|
31
|
+
let response = await fetch(`/api/reporting/filters/${encodeURIComponent(reportId)}/GetParameterDefaultValue`, {
|
|
32
|
+
body: JSON.stringify({ parameterName: parameter.name }),
|
|
33
|
+
credentials: "same-origin",
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
// The route rejects anything that is not an ajax request, as CSRF
|
|
37
|
+
// protection.
|
|
38
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
39
|
+
},
|
|
40
|
+
method: "POST",
|
|
41
|
+
signal,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
45
|
+
|
|
46
|
+
let result = (await response.json()) as ProcedureResult;
|
|
47
|
+
|
|
48
|
+
return firstValue(result);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolves every procedure default of a report in parallel and merges the
|
|
53
|
+
* results over the literal defaults.
|
|
54
|
+
*
|
|
55
|
+
* A parameter whose procedure fails is left at its literal default rather than
|
|
56
|
+
* failing the whole form — the user can still type a value.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveParameterDefaults(
|
|
59
|
+
reportId: string,
|
|
60
|
+
parameters: ReportParameter[],
|
|
61
|
+
literalDefaults: ReportParameterValues,
|
|
62
|
+
signal?: AbortSignal,
|
|
63
|
+
): Promise<ReportParameterValues> {
|
|
64
|
+
let dynamic = parameters.filter((parameter) => isProcedureDefault(parameter.defaultValue ?? ""));
|
|
65
|
+
|
|
66
|
+
if (dynamic.length === 0) return literalDefaults;
|
|
67
|
+
|
|
68
|
+
let resolved = await Promise.all(
|
|
69
|
+
dynamic.map(async (parameter) => {
|
|
70
|
+
try {
|
|
71
|
+
return [parameter.name, await fetchParameterDefault(reportId, parameter, signal)] as const;
|
|
72
|
+
} catch {
|
|
73
|
+
return [parameter.name, null] as const;
|
|
74
|
+
}
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
let values: ReportParameterValues = { ...literalDefaults };
|
|
79
|
+
|
|
80
|
+
for (let [name, value] of resolved) {
|
|
81
|
+
if (value !== null) values[name] = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return values;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The first column of the first row of whichever result set came back. */
|
|
88
|
+
function firstValue(result: ProcedureResult): ReportParameterValue {
|
|
89
|
+
let rows = Array.isArray(result) ? result : Object.values(result).find((value) => Array.isArray(value));
|
|
90
|
+
let row = rows?.[0];
|
|
91
|
+
|
|
92
|
+
if (row === undefined || row === null) return null;
|
|
93
|
+
if (Array.isArray(row)) return (row[0] as ReportParameterValue) ?? null;
|
|
94
|
+
if (typeof row === "object") {
|
|
95
|
+
let values = Object.values(row as Record<string, unknown>);
|
|
96
|
+
|
|
97
|
+
return (values[0] as ReportParameterValue) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return row as ReportParameterValue;
|
|
101
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
coerceParameterValue,
|
|
5
|
+
getDefaultParameterValues,
|
|
6
|
+
isProcedureDefault,
|
|
7
|
+
parseParameterItems,
|
|
8
|
+
parseParameters,
|
|
9
|
+
restoreParameterValues,
|
|
10
|
+
serializeParameters,
|
|
11
|
+
toReportParameter,
|
|
12
|
+
toReportParameters,
|
|
13
|
+
} from "./parameters.js";
|
|
14
|
+
import type { ReportParameterRecord } from "./types.js";
|
|
15
|
+
|
|
16
|
+
const lookupRecordSource = `<?xml version="1.0" encoding="utf-16"?>
|
|
17
|
+
<afRecordSource>
|
|
18
|
+
<ViewName>aviw_Accounting_Departments</ViewName>
|
|
19
|
+
<SelectColumns>
|
|
20
|
+
<afSelectColumn><Name>DepartmentID</Name><Size>5</Size></afSelectColumn>
|
|
21
|
+
<afSelectColumn><Name>Description</Name><Size>300</Size></afSelectColumn>
|
|
22
|
+
</SelectColumns>
|
|
23
|
+
<WhereClause>Archived = 0</WhereClause>
|
|
24
|
+
<FilterString />
|
|
25
|
+
<MaxRecords>0</MaxRecords>
|
|
26
|
+
</afRecordSource>`;
|
|
27
|
+
|
|
28
|
+
function record(overrides: ReportParameterRecord = {}): ReportParameterRecord {
|
|
29
|
+
return { Name: "Period", Label: "Periode", DataType: "Integer", SortOrder: 0, ...overrides };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("isProcedureDefault", () => {
|
|
33
|
+
it("recognizes a stored procedure name", () => {
|
|
34
|
+
expect(isProcedureDefault("astp_Reports_CurrentPeriod")).toBe(true);
|
|
35
|
+
expect(isProcedureDefault("sstp_System_Something")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("treats a literal as a literal", () => {
|
|
39
|
+
expect(isProcedureDefault("202601")).toBe(false);
|
|
40
|
+
expect(isProcedureDefault("stp_NotNamespaced")).toBe(false);
|
|
41
|
+
expect(isProcedureDefault(null)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("parseParameterItems", () => {
|
|
46
|
+
it("splits on line breaks and drops blank lines", () => {
|
|
47
|
+
expect(parseParameterItems("Ja\r\nNei\r\n")).toEqual(["Ja", "Nei"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns nothing for an empty column", () => {
|
|
51
|
+
expect(parseParameterItems(null)).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("toReportParameter", () => {
|
|
56
|
+
it("falls back to the name when there is no label", () => {
|
|
57
|
+
expect(toReportParameter(record({ Label: " " })).label).toBe("Period");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("picks an editor from the data type", () => {
|
|
61
|
+
expect(toReportParameter(record({ DataType: "Integer" })).editor).toBe("number");
|
|
62
|
+
expect(toReportParameter(record({ DataType: "Double" })).editor).toBe("number");
|
|
63
|
+
expect(toReportParameter(record({ DataType: "Date" })).editor).toBe("date");
|
|
64
|
+
expect(toReportParameter(record({ DataType: "Boolean" })).editor).toBe("boolean");
|
|
65
|
+
expect(toReportParameter(record({ DataType: "String" })).editor).toBe("text");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to text for an unknown data type", () => {
|
|
69
|
+
let parameter = toReportParameter(record({ DataType: "Guid" }));
|
|
70
|
+
|
|
71
|
+
expect(parameter.type).toBe("String");
|
|
72
|
+
expect(parameter.editor).toBe("text");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("prefers a fixed item list over the data type", () => {
|
|
76
|
+
let parameter = toReportParameter(record({ DataType: "String", Items: "Ja\r\nNei" }));
|
|
77
|
+
|
|
78
|
+
expect(parameter.editor).toBe("select");
|
|
79
|
+
expect(parameter.items).toEqual(["Ja", "Nei"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("prefers a lookup over an item list", () => {
|
|
83
|
+
let parameter = toReportParameter(
|
|
84
|
+
record({ RecordSource: lookupRecordSource, ValueMember: "DepartmentID", Items: "Ja\r\nNei" }),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(parameter.editor).toBe("lookup");
|
|
88
|
+
expect(parameter.recordSource?.viewName).toBe("aviw_Accounting_Departments");
|
|
89
|
+
expect(parameter.recordSource?.whereClause).toBe("Archived = 0");
|
|
90
|
+
expect(parameter.valueMember).toBe("DepartmentID");
|
|
91
|
+
expect(parameter.displayMember).toBe("Description");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("uses the first selected column when the record source has no value member", () => {
|
|
95
|
+
let parameter = toReportParameter(record({ RecordSource: lookupRecordSource, ValueMember: null }));
|
|
96
|
+
|
|
97
|
+
expect(parameter.valueMember).toBe("DepartmentID");
|
|
98
|
+
expect(parameter.displayMember).toBe("Description");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("ignores a value member without a record source", () => {
|
|
102
|
+
let parameter = toReportParameter(record({ ValueMember: "DepartmentID" }));
|
|
103
|
+
|
|
104
|
+
expect(parameter.editor).toBe("number");
|
|
105
|
+
expect(parameter.valueMember).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("toReportParameters", () => {
|
|
110
|
+
it("sorts by sort order and drops nameless records", () => {
|
|
111
|
+
let parameters = toReportParameters([
|
|
112
|
+
record({ Name: "B", Label: "B", SortOrder: 2 }),
|
|
113
|
+
record({ Name: "", Label: "" }),
|
|
114
|
+
record({ Name: "A", Label: "A", SortOrder: 1 }),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
expect(parameters.map((parameter) => parameter.name)).toEqual(["A", "B"]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("coerceParameterValue", () => {
|
|
122
|
+
let integer = toReportParameter(record({ DataType: "Integer" }));
|
|
123
|
+
let double = toReportParameter(record({ DataType: "Double" }));
|
|
124
|
+
let boolean = toReportParameter(record({ DataType: "Boolean" }));
|
|
125
|
+
let text = toReportParameter(record({ DataType: "String" }));
|
|
126
|
+
|
|
127
|
+
it("parses numbers", () => {
|
|
128
|
+
expect(coerceParameterValue(integer, "202601")).toBe(202601);
|
|
129
|
+
expect(coerceParameterValue(integer, "not a number")).toBeNull();
|
|
130
|
+
expect(coerceParameterValue(double, "1,5")).toBe(1.5);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("parses the booleans the designer writes", () => {
|
|
134
|
+
expect(coerceParameterValue(boolean, "True")).toBe(true);
|
|
135
|
+
expect(coerceParameterValue(boolean, "Ja")).toBe(true);
|
|
136
|
+
expect(coerceParameterValue(boolean, "0")).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("treats an empty value as unset, except for booleans", () => {
|
|
140
|
+
expect(coerceParameterValue(text, "")).toBeNull();
|
|
141
|
+
expect(coerceParameterValue(boolean, "")).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("getDefaultParameterValues", () => {
|
|
146
|
+
it("uses literal defaults", () => {
|
|
147
|
+
let parameters = toReportParameters([record({ DefaultValue: "202601" })]);
|
|
148
|
+
|
|
149
|
+
expect(getDefaultParameterValues(parameters)).toEqual({ Period: 202601 });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("leaves stored procedure defaults unset", () => {
|
|
153
|
+
let parameters = toReportParameters([record({ DefaultValue: "astp_Reports_CurrentPeriod" })]);
|
|
154
|
+
|
|
155
|
+
expect(getDefaultParameterValues(parameters)).toEqual({ Period: null });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("serializeParameters", () => {
|
|
160
|
+
it("writes the shape the Windows client reads", () => {
|
|
161
|
+
let parameters = toReportParameters([
|
|
162
|
+
record({ Name: "Period", DataType: "Integer", SortOrder: 0 }),
|
|
163
|
+
record({ Name: "Hide0", DataType: "Boolean", SortOrder: 1 }),
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
expect(serializeParameters(parameters, { Period: 202601, Hide0: true })).toBe(
|
|
167
|
+
[
|
|
168
|
+
'<Parameters Version="1">',
|
|
169
|
+
' <Parameter Name="Period" Type="Integer">202601</Parameter>',
|
|
170
|
+
' <Parameter Name="Hide0" Type="Boolean">True</Parameter>',
|
|
171
|
+
"</Parameters>",
|
|
172
|
+
].join("\n"),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("escapes values", () => {
|
|
177
|
+
let parameters = toReportParameters([record({ Name: "Text", DataType: "String" })]);
|
|
178
|
+
|
|
179
|
+
expect(serializeParameters(parameters, { Text: "a & <b>" })).toContain(">a & <b><");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("parseParameters", () => {
|
|
184
|
+
it("reads the values back", () => {
|
|
185
|
+
let xml = '<Parameters Version="1"><Parameter Name="Period" Type="Integer">202601</Parameter></Parameters>';
|
|
186
|
+
|
|
187
|
+
expect(parseParameters(xml)).toEqual({ Period: "202601" });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("reads an empty value written as a self-closing element", () => {
|
|
191
|
+
let xml = '<Parameters Version="1"><Parameter Name="Period" Type="Integer" /></Parameters>';
|
|
192
|
+
|
|
193
|
+
expect(parseParameters(xml)).toEqual({ Period: "" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("returns nothing for a filter without parameters", () => {
|
|
197
|
+
expect(parseParameters(null)).toEqual({});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("restoreParameterValues", () => {
|
|
202
|
+
it("keeps the defaults for parameters the filter does not mention", () => {
|
|
203
|
+
let parameters = toReportParameters([
|
|
204
|
+
record({ Name: "Period", DataType: "Integer", DefaultValue: "202601", SortOrder: 0 }),
|
|
205
|
+
record({ Name: "Hide0", DataType: "Boolean", DefaultValue: "True", SortOrder: 1 }),
|
|
206
|
+
]);
|
|
207
|
+
let xml = '<Parameters Version="1"><Parameter Name="Period" Type="Integer">202512</Parameter></Parameters>';
|
|
208
|
+
|
|
209
|
+
expect(restoreParameterValues(parameters, xml)).toEqual({ Period: 202512, Hide0: true });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("ignores parameters the report no longer has", () => {
|
|
213
|
+
let parameters = toReportParameters([record({ Name: "Period", DataType: "Integer" })]);
|
|
214
|
+
let xml = '<Parameters Version="1"><Parameter Name="Gone" Type="String">x</Parameter></Parameters>';
|
|
215
|
+
|
|
216
|
+
expect(restoreParameterValues(parameters, xml)).toEqual({ Period: null });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report parameters: mapping the metadata records onto something renderable,
|
|
3
|
+
* and the XML the parameter values are stored as on a saved filter.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { encodeXml, parseRecordSource, readElement } from "./recordSource.js";
|
|
7
|
+
import type {
|
|
8
|
+
ReportParameter,
|
|
9
|
+
ReportParameterEditor,
|
|
10
|
+
ReportParameterRecord,
|
|
11
|
+
ReportParameterType,
|
|
12
|
+
ReportParameterValue,
|
|
13
|
+
ReportParameterValues,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
const PARAMETER_TYPES: ReportParameterType[] = ["String", "Integer", "Double", "Date", "Boolean"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A default value naming a stored procedure, e.g. `astp_Reports_CurrentPeriod`.
|
|
20
|
+
*
|
|
21
|
+
* The Windows launcher executes the procedure and uses the first column of the
|
|
22
|
+
* first row. The web client cannot execute an arbitrary procedure, so these are
|
|
23
|
+
* resolved through the report endpoint instead.
|
|
24
|
+
*/
|
|
25
|
+
const PROCEDURE_DEFAULT = /^[a-z]stp_/i;
|
|
26
|
+
|
|
27
|
+
/** True when a `DefaultValue` names a stored procedure rather than a literal. */
|
|
28
|
+
export function isProcedureDefault(defaultValue: string | null | undefined): boolean {
|
|
29
|
+
return typeof defaultValue === "string" && PROCEDURE_DEFAULT.test(defaultValue.trim());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The choices of a parameter, one per line of its `Items` column. */
|
|
33
|
+
export function parseParameterItems(items: string | null | undefined): string[] {
|
|
34
|
+
if (!items) return [];
|
|
35
|
+
|
|
36
|
+
return items
|
|
37
|
+
.split(/\r\n|\r|\n/)
|
|
38
|
+
.map((item) => item.trim())
|
|
39
|
+
.filter((item) => item !== "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toParameterType(dataType: string | null | undefined): ReportParameterType {
|
|
43
|
+
let match = PARAMETER_TYPES.find((type) => type.toLowerCase() === dataType?.trim().toLowerCase());
|
|
44
|
+
|
|
45
|
+
// The designer only offers the five known types, but an unknown one is still
|
|
46
|
+
// editable as text.
|
|
47
|
+
return match ?? "String";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The editor a parameter should use. A lookup or a fixed item list wins over the
|
|
52
|
+
* data type, which is what the Windows launcher does as well.
|
|
53
|
+
*/
|
|
54
|
+
function toParameterEditor(type: ReportParameterType, hasLookup: boolean, hasItems: boolean): ReportParameterEditor {
|
|
55
|
+
if (hasLookup) return "lookup";
|
|
56
|
+
if (hasItems) return "select";
|
|
57
|
+
|
|
58
|
+
switch (type) {
|
|
59
|
+
case "Integer":
|
|
60
|
+
case "Double":
|
|
61
|
+
return "number";
|
|
62
|
+
case "Date":
|
|
63
|
+
return "date";
|
|
64
|
+
case "Boolean":
|
|
65
|
+
return "boolean";
|
|
66
|
+
default:
|
|
67
|
+
return "text";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Maps a `sviw_WinClient_ReportsParameters` record onto a `ReportParameter`. */
|
|
72
|
+
export function toReportParameter(record: ReportParameterRecord): ReportParameter {
|
|
73
|
+
let name = record.Name ?? "";
|
|
74
|
+
let type = toParameterType(record.DataType);
|
|
75
|
+
let items = parseParameterItems(record.Items);
|
|
76
|
+
let recordSource = parseRecordSource(record.RecordSource);
|
|
77
|
+
let valueMember = record.ValueMember ?? null;
|
|
78
|
+
|
|
79
|
+
if (recordSource && !valueMember) {
|
|
80
|
+
valueMember = recordSource.columns[0]?.name ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* The designer has no display member: the Windows lookup grid shows every
|
|
85
|
+
* selected column. On the web a combobox shows one, so the first column that
|
|
86
|
+
* is not the value is used.
|
|
87
|
+
*/
|
|
88
|
+
let displayMember = recordSource?.columns.find((column) => column.name !== valueMember)?.name ?? null;
|
|
89
|
+
let hasLookup = Boolean(recordSource && valueMember);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
name,
|
|
93
|
+
label: record.Label?.trim() || name,
|
|
94
|
+
description: record.Description?.trim() || null,
|
|
95
|
+
type,
|
|
96
|
+
editor: toParameterEditor(type, hasLookup, items.length > 0),
|
|
97
|
+
defaultValue: record.DefaultValue ?? null,
|
|
98
|
+
items,
|
|
99
|
+
recordSource: hasLookup ? recordSource : null,
|
|
100
|
+
valueMember: hasLookup ? valueMember : null,
|
|
101
|
+
displayMember: hasLookup ? displayMember : null,
|
|
102
|
+
sortOrder: record.SortOrder ?? 0,
|
|
103
|
+
lookupWidth: record.LookupWidth ?? null,
|
|
104
|
+
lookupHeight: record.LookupHeight ?? null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Maps and sorts a report's parameter records. */
|
|
109
|
+
export function toReportParameters(records: ReportParameterRecord[]): ReportParameter[] {
|
|
110
|
+
return records
|
|
111
|
+
.filter((record) => record.Name)
|
|
112
|
+
.map(toReportParameter)
|
|
113
|
+
.sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Converts a raw value to the type a parameter expects. */
|
|
117
|
+
export function coerceParameterValue(
|
|
118
|
+
parameter: ReportParameter,
|
|
119
|
+
value: ReportParameterValue | undefined,
|
|
120
|
+
): ReportParameterValue {
|
|
121
|
+
if (value === null || value === undefined || value === "") {
|
|
122
|
+
return parameter.type === "Boolean" ? false : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
switch (parameter.type) {
|
|
126
|
+
case "Boolean":
|
|
127
|
+
return typeof value === "boolean" ? value : /^(true|1|ja|yes)$/i.test(String(value));
|
|
128
|
+
case "Integer": {
|
|
129
|
+
let parsed = Number.parseInt(String(value), 10);
|
|
130
|
+
|
|
131
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
132
|
+
}
|
|
133
|
+
case "Double": {
|
|
134
|
+
// Norwegian keyboards produce a comma, which `Number` does not accept.
|
|
135
|
+
let parsed = Number(String(value).replace(",", "."));
|
|
136
|
+
|
|
137
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
138
|
+
}
|
|
139
|
+
default:
|
|
140
|
+
return String(value);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The initial values for a set of parameters.
|
|
146
|
+
*
|
|
147
|
+
* Defaults naming a stored procedure are left unset: they have to be resolved
|
|
148
|
+
* server-side, and the caller fills them in once they are.
|
|
149
|
+
*/
|
|
150
|
+
export function getDefaultParameterValues(parameters: ReportParameter[]): ReportParameterValues {
|
|
151
|
+
let values: ReportParameterValues = {};
|
|
152
|
+
|
|
153
|
+
for (let parameter of parameters) {
|
|
154
|
+
values[parameter.name] = isProcedureDefault(parameter.defaultValue)
|
|
155
|
+
? coerceParameterValue(parameter, null)
|
|
156
|
+
: coerceParameterValue(parameter, parameter.defaultValue);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return values;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toXmlValue(parameter: ReportParameter, value: ReportParameterValue): string {
|
|
163
|
+
if (value === null || value === undefined) return "";
|
|
164
|
+
// The Windows client writes booleans the way .NET does.
|
|
165
|
+
if (typeof value === "boolean") return value ? "True" : "False";
|
|
166
|
+
|
|
167
|
+
return String(value);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Serializes parameter values the way the Windows filter builder does, so that
|
|
172
|
+
* a filter saved on the web is readable in the Windows client and the other way
|
|
173
|
+
* around.
|
|
174
|
+
*
|
|
175
|
+
* ```xml
|
|
176
|
+
* <Parameters Version="1">
|
|
177
|
+
* <Parameter Name="Period" Type="Integer">202601</Parameter>
|
|
178
|
+
* </Parameters>
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
export function serializeParameters(parameters: ReportParameter[], values: ReportParameterValues): string {
|
|
182
|
+
let entries = parameters.map((parameter) => {
|
|
183
|
+
let value = toXmlValue(parameter, values[parameter.name] ?? null);
|
|
184
|
+
|
|
185
|
+
return ` <Parameter Name="${encodeXml(parameter.name)}" Type="${parameter.type}">${encodeXml(value)}</Parameter>`;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return `<Parameters Version="1">\n${entries.join("\n")}\n</Parameters>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reads the values back out of the XML stored on a saved filter. Values are
|
|
193
|
+
* returned as strings and coerced against the report's own parameters by
|
|
194
|
+
* `restoreParameterValues`, since the XML may name parameters the report no
|
|
195
|
+
* longer has.
|
|
196
|
+
*/
|
|
197
|
+
export function parseParameters(xml: string | null | undefined): Record<string, string> {
|
|
198
|
+
if (!xml) return {};
|
|
199
|
+
|
|
200
|
+
let values: Record<string, string> = {};
|
|
201
|
+
|
|
202
|
+
for (let match of xml.matchAll(/<Parameter\s+([^>]*?)\/?>(?:([\s\S]*?)<\/Parameter>)?/g)) {
|
|
203
|
+
let name = /Name\s*=\s*"([^"]*)"/.exec(match[1])?.[1];
|
|
204
|
+
if (!name) continue;
|
|
205
|
+
|
|
206
|
+
values[name] = readElement(match[0], "Parameter") ?? "";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return values;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Applies the values stored on a saved filter to a report's parameters, keeping
|
|
214
|
+
* the defaults for anything the filter does not mention.
|
|
215
|
+
*/
|
|
216
|
+
export function restoreParameterValues(
|
|
217
|
+
parameters: ReportParameter[],
|
|
218
|
+
xml: string | null | undefined,
|
|
219
|
+
): ReportParameterValues {
|
|
220
|
+
let stored = parseParameters(xml);
|
|
221
|
+
let values = getDefaultParameterValues(parameters);
|
|
222
|
+
|
|
223
|
+
for (let parameter of parameters) {
|
|
224
|
+
if (parameter.name in stored) {
|
|
225
|
+
values[parameter.name] = coerceParameterValue(parameter, stored[parameter.name]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return values;
|
|
230
|
+
}
|