@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
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
import type { ReportParameter, ReportParameterRecord, ReportParameterValue, ReportParameterValues } from "./types.js";
|
|
6
|
+
/** True when a `DefaultValue` names a stored procedure rather than a literal. */
|
|
7
|
+
export declare function isProcedureDefault(defaultValue: string | null | undefined): boolean;
|
|
8
|
+
/** The choices of a parameter, one per line of its `Items` column. */
|
|
9
|
+
export declare function parseParameterItems(items: string | null | undefined): string[];
|
|
10
|
+
/** Maps a `sviw_WinClient_ReportsParameters` record onto a `ReportParameter`. */
|
|
11
|
+
export declare function toReportParameter(record: ReportParameterRecord): ReportParameter;
|
|
12
|
+
/** Maps and sorts a report's parameter records. */
|
|
13
|
+
export declare function toReportParameters(records: ReportParameterRecord[]): ReportParameter[];
|
|
14
|
+
/** Converts a raw value to the type a parameter expects. */
|
|
15
|
+
export declare function coerceParameterValue(parameter: ReportParameter, value: ReportParameterValue | undefined): ReportParameterValue;
|
|
16
|
+
/**
|
|
17
|
+
* The initial values for a set of parameters.
|
|
18
|
+
*
|
|
19
|
+
* Defaults naming a stored procedure are left unset: they have to be resolved
|
|
20
|
+
* server-side, and the caller fills them in once they are.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getDefaultParameterValues(parameters: ReportParameter[]): ReportParameterValues;
|
|
23
|
+
/**
|
|
24
|
+
* Serializes parameter values the way the Windows filter builder does, so that
|
|
25
|
+
* a filter saved on the web is readable in the Windows client and the other way
|
|
26
|
+
* around.
|
|
27
|
+
*
|
|
28
|
+
* ```xml
|
|
29
|
+
* <Parameters Version="1">
|
|
30
|
+
* <Parameter Name="Period" Type="Integer">202601</Parameter>
|
|
31
|
+
* </Parameters>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function serializeParameters(parameters: ReportParameter[], values: ReportParameterValues): string;
|
|
35
|
+
/**
|
|
36
|
+
* Reads the values back out of the XML stored on a saved filter. Values are
|
|
37
|
+
* returned as strings and coerced against the report's own parameters by
|
|
38
|
+
* `restoreParameterValues`, since the XML may name parameters the report no
|
|
39
|
+
* longer has.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseParameters(xml: string | null | undefined): Record<string, string>;
|
|
42
|
+
/**
|
|
43
|
+
* Applies the values stored on a saved filter to a report's parameters, keeping
|
|
44
|
+
* the defaults for anything the filter does not mention.
|
|
45
|
+
*/
|
|
46
|
+
export declare function restoreParameterValues(parameters: ReportParameter[], xml: string | null | undefined): ReportParameterValues;
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
import { encodeXml, parseRecordSource, readElement } from "./recordSource.js";
|
|
6
|
+
const PARAMETER_TYPES = ["String", "Integer", "Double", "Date", "Boolean"];
|
|
7
|
+
/**
|
|
8
|
+
* A default value naming a stored procedure, e.g. `astp_Reports_CurrentPeriod`.
|
|
9
|
+
*
|
|
10
|
+
* The Windows launcher executes the procedure and uses the first column of the
|
|
11
|
+
* first row. The web client cannot execute an arbitrary procedure, so these are
|
|
12
|
+
* resolved through the report endpoint instead.
|
|
13
|
+
*/
|
|
14
|
+
const PROCEDURE_DEFAULT = /^[a-z]stp_/i;
|
|
15
|
+
/** True when a `DefaultValue` names a stored procedure rather than a literal. */
|
|
16
|
+
export function isProcedureDefault(defaultValue) {
|
|
17
|
+
return typeof defaultValue === "string" && PROCEDURE_DEFAULT.test(defaultValue.trim());
|
|
18
|
+
}
|
|
19
|
+
/** The choices of a parameter, one per line of its `Items` column. */
|
|
20
|
+
export function parseParameterItems(items) {
|
|
21
|
+
if (!items)
|
|
22
|
+
return [];
|
|
23
|
+
return items
|
|
24
|
+
.split(/\r\n|\r|\n/)
|
|
25
|
+
.map((item) => item.trim())
|
|
26
|
+
.filter((item) => item !== "");
|
|
27
|
+
}
|
|
28
|
+
function toParameterType(dataType) {
|
|
29
|
+
let match = PARAMETER_TYPES.find((type) => type.toLowerCase() === dataType?.trim().toLowerCase());
|
|
30
|
+
// The designer only offers the five known types, but an unknown one is still
|
|
31
|
+
// editable as text.
|
|
32
|
+
return match ?? "String";
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The editor a parameter should use. A lookup or a fixed item list wins over the
|
|
36
|
+
* data type, which is what the Windows launcher does as well.
|
|
37
|
+
*/
|
|
38
|
+
function toParameterEditor(type, hasLookup, hasItems) {
|
|
39
|
+
if (hasLookup)
|
|
40
|
+
return "lookup";
|
|
41
|
+
if (hasItems)
|
|
42
|
+
return "select";
|
|
43
|
+
switch (type) {
|
|
44
|
+
case "Integer":
|
|
45
|
+
case "Double":
|
|
46
|
+
return "number";
|
|
47
|
+
case "Date":
|
|
48
|
+
return "date";
|
|
49
|
+
case "Boolean":
|
|
50
|
+
return "boolean";
|
|
51
|
+
default:
|
|
52
|
+
return "text";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Maps a `sviw_WinClient_ReportsParameters` record onto a `ReportParameter`. */
|
|
56
|
+
export function toReportParameter(record) {
|
|
57
|
+
let name = record.Name ?? "";
|
|
58
|
+
let type = toParameterType(record.DataType);
|
|
59
|
+
let items = parseParameterItems(record.Items);
|
|
60
|
+
let recordSource = parseRecordSource(record.RecordSource);
|
|
61
|
+
let valueMember = record.ValueMember ?? null;
|
|
62
|
+
if (recordSource && !valueMember) {
|
|
63
|
+
valueMember = recordSource.columns[0]?.name ?? null;
|
|
64
|
+
}
|
|
65
|
+
/*
|
|
66
|
+
* The designer has no display member: the Windows lookup grid shows every
|
|
67
|
+
* selected column. On the web a combobox shows one, so the first column that
|
|
68
|
+
* is not the value is used.
|
|
69
|
+
*/
|
|
70
|
+
let displayMember = recordSource?.columns.find((column) => column.name !== valueMember)?.name ?? null;
|
|
71
|
+
let hasLookup = Boolean(recordSource && valueMember);
|
|
72
|
+
return {
|
|
73
|
+
name,
|
|
74
|
+
label: record.Label?.trim() || name,
|
|
75
|
+
description: record.Description?.trim() || null,
|
|
76
|
+
type,
|
|
77
|
+
editor: toParameterEditor(type, hasLookup, items.length > 0),
|
|
78
|
+
defaultValue: record.DefaultValue ?? null,
|
|
79
|
+
items,
|
|
80
|
+
recordSource: hasLookup ? recordSource : null,
|
|
81
|
+
valueMember: hasLookup ? valueMember : null,
|
|
82
|
+
displayMember: hasLookup ? displayMember : null,
|
|
83
|
+
sortOrder: record.SortOrder ?? 0,
|
|
84
|
+
lookupWidth: record.LookupWidth ?? null,
|
|
85
|
+
lookupHeight: record.LookupHeight ?? null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Maps and sorts a report's parameter records. */
|
|
89
|
+
export function toReportParameters(records) {
|
|
90
|
+
return records
|
|
91
|
+
.filter((record) => record.Name)
|
|
92
|
+
.map(toReportParameter)
|
|
93
|
+
.sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label));
|
|
94
|
+
}
|
|
95
|
+
/** Converts a raw value to the type a parameter expects. */
|
|
96
|
+
export function coerceParameterValue(parameter, value) {
|
|
97
|
+
if (value === null || value === undefined || value === "") {
|
|
98
|
+
return parameter.type === "Boolean" ? false : null;
|
|
99
|
+
}
|
|
100
|
+
switch (parameter.type) {
|
|
101
|
+
case "Boolean":
|
|
102
|
+
return typeof value === "boolean" ? value : /^(true|1|ja|yes)$/i.test(String(value));
|
|
103
|
+
case "Integer": {
|
|
104
|
+
let parsed = Number.parseInt(String(value), 10);
|
|
105
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
106
|
+
}
|
|
107
|
+
case "Double": {
|
|
108
|
+
// Norwegian keyboards produce a comma, which `Number` does not accept.
|
|
109
|
+
let parsed = Number(String(value).replace(",", "."));
|
|
110
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
return String(value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The initial values for a set of parameters.
|
|
118
|
+
*
|
|
119
|
+
* Defaults naming a stored procedure are left unset: they have to be resolved
|
|
120
|
+
* server-side, and the caller fills them in once they are.
|
|
121
|
+
*/
|
|
122
|
+
export function getDefaultParameterValues(parameters) {
|
|
123
|
+
let values = {};
|
|
124
|
+
for (let parameter of parameters) {
|
|
125
|
+
values[parameter.name] = isProcedureDefault(parameter.defaultValue)
|
|
126
|
+
? coerceParameterValue(parameter, null)
|
|
127
|
+
: coerceParameterValue(parameter, parameter.defaultValue);
|
|
128
|
+
}
|
|
129
|
+
return values;
|
|
130
|
+
}
|
|
131
|
+
function toXmlValue(parameter, value) {
|
|
132
|
+
if (value === null || value === undefined)
|
|
133
|
+
return "";
|
|
134
|
+
// The Windows client writes booleans the way .NET does.
|
|
135
|
+
if (typeof value === "boolean")
|
|
136
|
+
return value ? "True" : "False";
|
|
137
|
+
return String(value);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Serializes parameter values the way the Windows filter builder does, so that
|
|
141
|
+
* a filter saved on the web is readable in the Windows client and the other way
|
|
142
|
+
* around.
|
|
143
|
+
*
|
|
144
|
+
* ```xml
|
|
145
|
+
* <Parameters Version="1">
|
|
146
|
+
* <Parameter Name="Period" Type="Integer">202601</Parameter>
|
|
147
|
+
* </Parameters>
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export function serializeParameters(parameters, values) {
|
|
151
|
+
let entries = parameters.map((parameter) => {
|
|
152
|
+
let value = toXmlValue(parameter, values[parameter.name] ?? null);
|
|
153
|
+
return ` <Parameter Name="${encodeXml(parameter.name)}" Type="${parameter.type}">${encodeXml(value)}</Parameter>`;
|
|
154
|
+
});
|
|
155
|
+
return `<Parameters Version="1">\n${entries.join("\n")}\n</Parameters>`;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Reads the values back out of the XML stored on a saved filter. Values are
|
|
159
|
+
* returned as strings and coerced against the report's own parameters by
|
|
160
|
+
* `restoreParameterValues`, since the XML may name parameters the report no
|
|
161
|
+
* longer has.
|
|
162
|
+
*/
|
|
163
|
+
export function parseParameters(xml) {
|
|
164
|
+
if (!xml)
|
|
165
|
+
return {};
|
|
166
|
+
let values = {};
|
|
167
|
+
for (let match of xml.matchAll(/<Parameter\s+([^>]*?)\/?>(?:([\s\S]*?)<\/Parameter>)?/g)) {
|
|
168
|
+
let name = /Name\s*=\s*"([^"]*)"/.exec(match[1])?.[1];
|
|
169
|
+
if (!name)
|
|
170
|
+
continue;
|
|
171
|
+
values[name] = readElement(match[0], "Parameter") ?? "";
|
|
172
|
+
}
|
|
173
|
+
return values;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Applies the values stored on a saved filter to a report's parameters, keeping
|
|
177
|
+
* the defaults for anything the filter does not mention.
|
|
178
|
+
*/
|
|
179
|
+
export function restoreParameterValues(parameters, xml) {
|
|
180
|
+
let stored = parseParameters(xml);
|
|
181
|
+
let values = getDefaultParameterValues(parameters);
|
|
182
|
+
for (let parameter of parameters) {
|
|
183
|
+
if (parameter.name in stored) {
|
|
184
|
+
values[parameter.name] = coerceParameterValue(parameter, stored[parameter.name]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return values;
|
|
188
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { coerceParameterValue, getDefaultParameterValues, isProcedureDefault, parseParameterItems, parseParameters, restoreParameterValues, serializeParameters, toReportParameter, toReportParameters, } from "./parameters.js";
|
|
3
|
+
const lookupRecordSource = `<?xml version="1.0" encoding="utf-16"?>
|
|
4
|
+
<afRecordSource>
|
|
5
|
+
<ViewName>aviw_Accounting_Departments</ViewName>
|
|
6
|
+
<SelectColumns>
|
|
7
|
+
<afSelectColumn><Name>DepartmentID</Name><Size>5</Size></afSelectColumn>
|
|
8
|
+
<afSelectColumn><Name>Description</Name><Size>300</Size></afSelectColumn>
|
|
9
|
+
</SelectColumns>
|
|
10
|
+
<WhereClause>Archived = 0</WhereClause>
|
|
11
|
+
<FilterString />
|
|
12
|
+
<MaxRecords>0</MaxRecords>
|
|
13
|
+
</afRecordSource>`;
|
|
14
|
+
function record(overrides = {}) {
|
|
15
|
+
return { Name: "Period", Label: "Periode", DataType: "Integer", SortOrder: 0, ...overrides };
|
|
16
|
+
}
|
|
17
|
+
describe("isProcedureDefault", () => {
|
|
18
|
+
it("recognizes a stored procedure name", () => {
|
|
19
|
+
expect(isProcedureDefault("astp_Reports_CurrentPeriod")).toBe(true);
|
|
20
|
+
expect(isProcedureDefault("sstp_System_Something")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
it("treats a literal as a literal", () => {
|
|
23
|
+
expect(isProcedureDefault("202601")).toBe(false);
|
|
24
|
+
expect(isProcedureDefault("stp_NotNamespaced")).toBe(false);
|
|
25
|
+
expect(isProcedureDefault(null)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("parseParameterItems", () => {
|
|
29
|
+
it("splits on line breaks and drops blank lines", () => {
|
|
30
|
+
expect(parseParameterItems("Ja\r\nNei\r\n")).toEqual(["Ja", "Nei"]);
|
|
31
|
+
});
|
|
32
|
+
it("returns nothing for an empty column", () => {
|
|
33
|
+
expect(parseParameterItems(null)).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("toReportParameter", () => {
|
|
37
|
+
it("falls back to the name when there is no label", () => {
|
|
38
|
+
expect(toReportParameter(record({ Label: " " })).label).toBe("Period");
|
|
39
|
+
});
|
|
40
|
+
it("picks an editor from the data type", () => {
|
|
41
|
+
expect(toReportParameter(record({ DataType: "Integer" })).editor).toBe("number");
|
|
42
|
+
expect(toReportParameter(record({ DataType: "Double" })).editor).toBe("number");
|
|
43
|
+
expect(toReportParameter(record({ DataType: "Date" })).editor).toBe("date");
|
|
44
|
+
expect(toReportParameter(record({ DataType: "Boolean" })).editor).toBe("boolean");
|
|
45
|
+
expect(toReportParameter(record({ DataType: "String" })).editor).toBe("text");
|
|
46
|
+
});
|
|
47
|
+
it("falls back to text for an unknown data type", () => {
|
|
48
|
+
let parameter = toReportParameter(record({ DataType: "Guid" }));
|
|
49
|
+
expect(parameter.type).toBe("String");
|
|
50
|
+
expect(parameter.editor).toBe("text");
|
|
51
|
+
});
|
|
52
|
+
it("prefers a fixed item list over the data type", () => {
|
|
53
|
+
let parameter = toReportParameter(record({ DataType: "String", Items: "Ja\r\nNei" }));
|
|
54
|
+
expect(parameter.editor).toBe("select");
|
|
55
|
+
expect(parameter.items).toEqual(["Ja", "Nei"]);
|
|
56
|
+
});
|
|
57
|
+
it("prefers a lookup over an item list", () => {
|
|
58
|
+
let parameter = toReportParameter(record({ RecordSource: lookupRecordSource, ValueMember: "DepartmentID", Items: "Ja\r\nNei" }));
|
|
59
|
+
expect(parameter.editor).toBe("lookup");
|
|
60
|
+
expect(parameter.recordSource?.viewName).toBe("aviw_Accounting_Departments");
|
|
61
|
+
expect(parameter.recordSource?.whereClause).toBe("Archived = 0");
|
|
62
|
+
expect(parameter.valueMember).toBe("DepartmentID");
|
|
63
|
+
expect(parameter.displayMember).toBe("Description");
|
|
64
|
+
});
|
|
65
|
+
it("uses the first selected column when the record source has no value member", () => {
|
|
66
|
+
let parameter = toReportParameter(record({ RecordSource: lookupRecordSource, ValueMember: null }));
|
|
67
|
+
expect(parameter.valueMember).toBe("DepartmentID");
|
|
68
|
+
expect(parameter.displayMember).toBe("Description");
|
|
69
|
+
});
|
|
70
|
+
it("ignores a value member without a record source", () => {
|
|
71
|
+
let parameter = toReportParameter(record({ ValueMember: "DepartmentID" }));
|
|
72
|
+
expect(parameter.editor).toBe("number");
|
|
73
|
+
expect(parameter.valueMember).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("toReportParameters", () => {
|
|
77
|
+
it("sorts by sort order and drops nameless records", () => {
|
|
78
|
+
let parameters = toReportParameters([
|
|
79
|
+
record({ Name: "B", Label: "B", SortOrder: 2 }),
|
|
80
|
+
record({ Name: "", Label: "" }),
|
|
81
|
+
record({ Name: "A", Label: "A", SortOrder: 1 }),
|
|
82
|
+
]);
|
|
83
|
+
expect(parameters.map((parameter) => parameter.name)).toEqual(["A", "B"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe("coerceParameterValue", () => {
|
|
87
|
+
let integer = toReportParameter(record({ DataType: "Integer" }));
|
|
88
|
+
let double = toReportParameter(record({ DataType: "Double" }));
|
|
89
|
+
let boolean = toReportParameter(record({ DataType: "Boolean" }));
|
|
90
|
+
let text = toReportParameter(record({ DataType: "String" }));
|
|
91
|
+
it("parses numbers", () => {
|
|
92
|
+
expect(coerceParameterValue(integer, "202601")).toBe(202601);
|
|
93
|
+
expect(coerceParameterValue(integer, "not a number")).toBeNull();
|
|
94
|
+
expect(coerceParameterValue(double, "1,5")).toBe(1.5);
|
|
95
|
+
});
|
|
96
|
+
it("parses the booleans the designer writes", () => {
|
|
97
|
+
expect(coerceParameterValue(boolean, "True")).toBe(true);
|
|
98
|
+
expect(coerceParameterValue(boolean, "Ja")).toBe(true);
|
|
99
|
+
expect(coerceParameterValue(boolean, "0")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
it("treats an empty value as unset, except for booleans", () => {
|
|
102
|
+
expect(coerceParameterValue(text, "")).toBeNull();
|
|
103
|
+
expect(coerceParameterValue(boolean, "")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe("getDefaultParameterValues", () => {
|
|
107
|
+
it("uses literal defaults", () => {
|
|
108
|
+
let parameters = toReportParameters([record({ DefaultValue: "202601" })]);
|
|
109
|
+
expect(getDefaultParameterValues(parameters)).toEqual({ Period: 202601 });
|
|
110
|
+
});
|
|
111
|
+
it("leaves stored procedure defaults unset", () => {
|
|
112
|
+
let parameters = toReportParameters([record({ DefaultValue: "astp_Reports_CurrentPeriod" })]);
|
|
113
|
+
expect(getDefaultParameterValues(parameters)).toEqual({ Period: null });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe("serializeParameters", () => {
|
|
117
|
+
it("writes the shape the Windows client reads", () => {
|
|
118
|
+
let parameters = toReportParameters([
|
|
119
|
+
record({ Name: "Period", DataType: "Integer", SortOrder: 0 }),
|
|
120
|
+
record({ Name: "Hide0", DataType: "Boolean", SortOrder: 1 }),
|
|
121
|
+
]);
|
|
122
|
+
expect(serializeParameters(parameters, { Period: 202601, Hide0: true })).toBe([
|
|
123
|
+
'<Parameters Version="1">',
|
|
124
|
+
' <Parameter Name="Period" Type="Integer">202601</Parameter>',
|
|
125
|
+
' <Parameter Name="Hide0" Type="Boolean">True</Parameter>',
|
|
126
|
+
"</Parameters>",
|
|
127
|
+
].join("\n"));
|
|
128
|
+
});
|
|
129
|
+
it("escapes values", () => {
|
|
130
|
+
let parameters = toReportParameters([record({ Name: "Text", DataType: "String" })]);
|
|
131
|
+
expect(serializeParameters(parameters, { Text: "a & <b>" })).toContain(">a & <b><");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe("parseParameters", () => {
|
|
135
|
+
it("reads the values back", () => {
|
|
136
|
+
let xml = '<Parameters Version="1"><Parameter Name="Period" Type="Integer">202601</Parameter></Parameters>';
|
|
137
|
+
expect(parseParameters(xml)).toEqual({ Period: "202601" });
|
|
138
|
+
});
|
|
139
|
+
it("reads an empty value written as a self-closing element", () => {
|
|
140
|
+
let xml = '<Parameters Version="1"><Parameter Name="Period" Type="Integer" /></Parameters>';
|
|
141
|
+
expect(parseParameters(xml)).toEqual({ Period: "" });
|
|
142
|
+
});
|
|
143
|
+
it("returns nothing for a filter without parameters", () => {
|
|
144
|
+
expect(parseParameters(null)).toEqual({});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe("restoreParameterValues", () => {
|
|
148
|
+
it("keeps the defaults for parameters the filter does not mention", () => {
|
|
149
|
+
let parameters = toReportParameters([
|
|
150
|
+
record({ Name: "Period", DataType: "Integer", DefaultValue: "202601", SortOrder: 0 }),
|
|
151
|
+
record({ Name: "Hide0", DataType: "Boolean", DefaultValue: "True", SortOrder: 1 }),
|
|
152
|
+
]);
|
|
153
|
+
let xml = '<Parameters Version="1"><Parameter Name="Period" Type="Integer">202512</Parameter></Parameters>';
|
|
154
|
+
expect(restoreParameterValues(parameters, xml)).toEqual({ Period: 202512, Hide0: true });
|
|
155
|
+
});
|
|
156
|
+
it("ignores parameters the report no longer has", () => {
|
|
157
|
+
let parameters = toReportParameters([record({ Name: "Period", DataType: "Integer" })]);
|
|
158
|
+
let xml = '<Parameters Version="1"><Parameter Name="Gone" Type="String">x</Parameter></Parameters>';
|
|
159
|
+
expect(restoreParameterValues(parameters, xml)).toEqual({ Period: null });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the `afRecordSource` XML the Windows designer stores on reports and
|
|
3
|
+
* on report parameters.
|
|
4
|
+
*
|
|
5
|
+
* A record source is a serialized .NET object, so the document is machine
|
|
6
|
+
* generated and its shape is fixed. Only a handful of elements matter here, and
|
|
7
|
+
* parsing them with a reader rather than `DOMParser` keeps this module usable
|
|
8
|
+
* outside a browser.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReportRecordSource } from "./types.js";
|
|
11
|
+
/** Resolves the XML entities the .NET serializer emits. */
|
|
12
|
+
export declare function decodeXml(value: string): string;
|
|
13
|
+
/** Escapes a value for use as XML text or as an attribute value. */
|
|
14
|
+
export declare function encodeXml(value: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* The text content of the first `<name>` element, or `null` when the element is
|
|
17
|
+
* missing or self-closing (`<FilterString />`, which the serializer writes for
|
|
18
|
+
* an empty value).
|
|
19
|
+
*/
|
|
20
|
+
export declare function readElement(xml: string, name: string): string | null;
|
|
21
|
+
/** The text content of every `<name>` element, in document order. */
|
|
22
|
+
export declare function readElements(xml: string, name: string): string[];
|
|
23
|
+
/**
|
|
24
|
+
* Reads the parts of an `afRecordSource` a web client can act on: the view, the
|
|
25
|
+
* selected columns and the design-time filter.
|
|
26
|
+
*
|
|
27
|
+
* Returns `null` for anything that is not a record source, so a missing or
|
|
28
|
+
* empty `RecordSource` column simply means "no lookup".
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseRecordSource(xml: string | null | undefined): ReportRecordSource | null;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the `afRecordSource` XML the Windows designer stores on reports and
|
|
3
|
+
* on report parameters.
|
|
4
|
+
*
|
|
5
|
+
* A record source is a serialized .NET object, so the document is machine
|
|
6
|
+
* generated and its shape is fixed. Only a handful of elements matter here, and
|
|
7
|
+
* parsing them with a reader rather than `DOMParser` keeps this module usable
|
|
8
|
+
* outside a browser.
|
|
9
|
+
*/
|
|
10
|
+
const XML_ENTITIES = {
|
|
11
|
+
amp: "&",
|
|
12
|
+
lt: "<",
|
|
13
|
+
gt: ">",
|
|
14
|
+
quot: '"',
|
|
15
|
+
apos: "'",
|
|
16
|
+
};
|
|
17
|
+
/** Resolves the XML entities the .NET serializer emits. */
|
|
18
|
+
export function decodeXml(value) {
|
|
19
|
+
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity) => {
|
|
20
|
+
if (entity.startsWith("#x") || entity.startsWith("#X")) {
|
|
21
|
+
return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
|
|
22
|
+
}
|
|
23
|
+
if (entity.startsWith("#")) {
|
|
24
|
+
return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
|
|
25
|
+
}
|
|
26
|
+
return XML_ENTITIES[entity.toLowerCase()] ?? match;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Escapes a value for use as XML text or as an attribute value. */
|
|
30
|
+
export function encodeXml(value) {
|
|
31
|
+
return value
|
|
32
|
+
.replace(/&/g, "&")
|
|
33
|
+
.replace(/</g, "<")
|
|
34
|
+
.replace(/>/g, ">")
|
|
35
|
+
.replace(/"/g, """)
|
|
36
|
+
.replace(/'/g, "'");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The text content of the first `<name>` element, or `null` when the element is
|
|
40
|
+
* missing or self-closing (`<FilterString />`, which the serializer writes for
|
|
41
|
+
* an empty value).
|
|
42
|
+
*/
|
|
43
|
+
export function readElement(xml, name) {
|
|
44
|
+
let match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`).exec(xml);
|
|
45
|
+
if (!match)
|
|
46
|
+
return null;
|
|
47
|
+
let value = decodeXml(match[1]).trim();
|
|
48
|
+
return value === "" ? null : value;
|
|
49
|
+
}
|
|
50
|
+
/** The text content of every `<name>` element, in document order. */
|
|
51
|
+
export function readElements(xml, name) {
|
|
52
|
+
let matches = xml.matchAll(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "g"));
|
|
53
|
+
return [...matches].map((match) => decodeXml(match[1]).trim());
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The CLR types the designer writes on a select column. Anything else — and a
|
|
57
|
+
* column with no `DataType` at all, which is what it writes for strings — is
|
|
58
|
+
* treated as text.
|
|
59
|
+
*/
|
|
60
|
+
const COLUMN_TYPES = {
|
|
61
|
+
Byte: "number",
|
|
62
|
+
Decimal: "number",
|
|
63
|
+
Double: "number",
|
|
64
|
+
Int16: "number",
|
|
65
|
+
Int32: "number",
|
|
66
|
+
Int64: "number",
|
|
67
|
+
Single: "number",
|
|
68
|
+
Boolean: "boolean",
|
|
69
|
+
DateTime: "date",
|
|
70
|
+
};
|
|
71
|
+
/** The columns of a `<SelectColumns>` block, in document order. */
|
|
72
|
+
function readSelectColumns(xml) {
|
|
73
|
+
let block = /<SelectColumns>([\s\S]*?)<\/SelectColumns>/.exec(xml)?.[1] ?? "";
|
|
74
|
+
let columns = [];
|
|
75
|
+
for (let match of block.matchAll(/<afSelectColumn>([\s\S]*?)<\/afSelectColumn>/g)) {
|
|
76
|
+
let name = readElement(match[1], "Name");
|
|
77
|
+
if (!name)
|
|
78
|
+
continue;
|
|
79
|
+
columns.push({ name, type: COLUMN_TYPES[readElement(match[1], "DataType") ?? ""] ?? "string" });
|
|
80
|
+
}
|
|
81
|
+
return columns;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads the parts of an `afRecordSource` a web client can act on: the view, the
|
|
85
|
+
* selected columns and the design-time filter.
|
|
86
|
+
*
|
|
87
|
+
* Returns `null` for anything that is not a record source, so a missing or
|
|
88
|
+
* empty `RecordSource` column simply means "no lookup".
|
|
89
|
+
*/
|
|
90
|
+
export function parseRecordSource(xml) {
|
|
91
|
+
if (!xml || !xml.includes("<afRecordSource"))
|
|
92
|
+
return null;
|
|
93
|
+
let viewName = readElement(xml, "ViewName");
|
|
94
|
+
if (!viewName)
|
|
95
|
+
return null;
|
|
96
|
+
let maxRecords = readElement(xml, "MaxRecords");
|
|
97
|
+
return {
|
|
98
|
+
viewName,
|
|
99
|
+
columns: readSelectColumns(xml),
|
|
100
|
+
whereClause: readElement(xml, "WhereClause"),
|
|
101
|
+
filterString: readElement(xml, "FilterString"),
|
|
102
|
+
// The designer writes 0 for "no limit"; the web data API spells that -1.
|
|
103
|
+
maxRecords: maxRecords === null || maxRecords === "0" ? null : Number(maxRecords),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering a report through the framework's report route.
|
|
3
|
+
*
|
|
4
|
+
* The route is a CMS code module owned by the Appframe framework
|
|
5
|
+
* (`WebsiteCMS.Reports`), registered on
|
|
6
|
+
* `api/report/{ReportID}/{FileName}.{Extension}`. Everything here builds
|
|
7
|
+
* requests for it — there is no report endpoint of our own.
|
|
8
|
+
*/
|
|
9
|
+
import { type DownloadItem } from "../downloadRequest.js";
|
|
10
|
+
import type { ReportFormat, ReportParameterValues } from "./types.js";
|
|
11
|
+
export type ReportRenderOptions = {
|
|
12
|
+
reportId: string;
|
|
13
|
+
format: ReportFormat;
|
|
14
|
+
/** File name without an extension. Defaults to the report ID. */
|
|
15
|
+
fileName?: string;
|
|
16
|
+
/** The filter string the report is run with. */
|
|
17
|
+
filter?: string | null;
|
|
18
|
+
/** Parameter values by name. Only non-empty values are sent. */
|
|
19
|
+
parameters?: ReportParameterValues | null;
|
|
20
|
+
/** Text for the report's optional header band. */
|
|
21
|
+
headerText?: string | null;
|
|
22
|
+
/** Hides the filter string printed on the report. */
|
|
23
|
+
hideFilterString?: boolean;
|
|
24
|
+
/** Runs the report in another domain than the current one. */
|
|
25
|
+
domain?: string | null;
|
|
26
|
+
/** Timezone offset in hours or minutes. Defaults to the browser's. */
|
|
27
|
+
timezone?: number | null;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
};
|
|
30
|
+
/** The extension a format is requested with. */
|
|
31
|
+
export declare function getReportExtension(format: ReportFormat): string;
|
|
32
|
+
/** The media type a format is served as. */
|
|
33
|
+
export declare function getReportMediaType(format: ReportFormat): string;
|
|
34
|
+
/**
|
|
35
|
+
* The name a rendered report is downloaded as, including its extension.
|
|
36
|
+
*
|
|
37
|
+
* The file name is part of the route rather than a query parameter, so anything
|
|
38
|
+
* that would change the path is replaced.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getReportFileName(options: Pick<ReportRenderOptions, "reportId" | "format" | "fileName">): string;
|
|
41
|
+
/**
|
|
42
|
+
* The request that renders a report.
|
|
43
|
+
*
|
|
44
|
+
* A report without parameters is a plain `GET`, so it can be opened directly in
|
|
45
|
+
* a new tab. As soon as there are parameters or a header the options go in a
|
|
46
|
+
* JSON body, which the route only reads from a `POST`.
|
|
47
|
+
*
|
|
48
|
+
* `filter` is always sent alongside `parameters`: the route reads the filter out
|
|
49
|
+
* of the body *guarded by the presence of `parameters`*, and reads it with an
|
|
50
|
+
* indexer that throws when the key is missing.
|
|
51
|
+
*/
|
|
52
|
+
export declare function getReportRequest(options: ReportRenderOptions): Request;
|
|
53
|
+
/**
|
|
54
|
+
* Renders a report and resolves with the rendered file.
|
|
55
|
+
*
|
|
56
|
+
* The route answers with a JSON body carrying an `error` when rendering fails,
|
|
57
|
+
* rather than with a failing status, so a JSON response is turned into an error
|
|
58
|
+
* here.
|
|
59
|
+
*/
|
|
60
|
+
export declare function renderReport(options: ReportRenderOptions): Promise<Blob>;
|
|
61
|
+
/** Renders a report and saves it, reporting progress as it downloads. */
|
|
62
|
+
export declare function downloadReport(options: ReportRenderOptions & {
|
|
63
|
+
onProgress?: (progress: DownloadItem) => void;
|
|
64
|
+
}): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Renders a report and opens it in a new tab.
|
|
67
|
+
*
|
|
68
|
+
* The blob is rendered first rather than pointing a tab at the route, so a
|
|
69
|
+
* failure surfaces as an error here instead of as a tab showing JSON, and so
|
|
70
|
+
* reports with parameters — which have to be posted — can be previewed too.
|
|
71
|
+
*/
|
|
72
|
+
export declare function openReport(options: ReportRenderOptions): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Renders a report and opens the browser's print dialog for it.
|
|
75
|
+
*
|
|
76
|
+
* The report is loaded into an offscreen iframe rather than a new tab, so the
|
|
77
|
+
* print dialog opens over the launcher and the user is not left with a stray
|
|
78
|
+
* tab afterwards. Browsers that will not print an embedded document fall back
|
|
79
|
+
* to opening it, which leaves the user one keystroke from printing.
|
|
80
|
+
*/
|
|
81
|
+
export declare function printReport(options: ReportRenderOptions): Promise<void>;
|