@olenbetong/appframe-core 2.11.10 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/af.common.js +1988 -626
- package/dist/esm/af.common.legacy.js +1942 -626
- package/dist/esm/af.common.legacy.min.js +4 -3
- package/dist/esm/af.common.legacy.min.js.map +1 -7
- package/dist/esm/af.common.min.js +4 -3
- package/dist/esm/af.common.min.js.map +1 -7
- package/dist/iife/af.common.js +2146 -708
- package/dist/iife/af.common.legacy.js +2100 -708
- package/dist/iife/af.common.legacy.min.js +4 -3
- package/dist/iife/af.common.legacy.min.js.map +1 -7
- package/dist/iife/af.common.min.js +4 -3
- package/dist/iife/af.common.min.js.map +1 -7
- package/es/alert.js +16 -1
- package/es/filter/convertFilter.d.ts +23 -0
- package/es/filter/convertFilter.js +26 -0
- package/es/filter/describeFilter.d.ts +13 -0
- package/es/filter/describeFilter.js +60 -0
- package/es/filter/describeFilter.test.d.ts +1 -0
- package/es/filter/describeFilter.test.js +68 -0
- package/es/filter/fields.d.ts +28 -0
- package/es/filter/fields.js +37 -0
- package/es/filter/fields.test.d.ts +1 -0
- package/es/filter/fields.test.js +49 -0
- package/es/filter/index.d.ts +8 -0
- package/es/filter/index.js +7 -0
- package/es/filter/operators.d.ts +37 -0
- package/es/filter/operators.js +235 -0
- package/es/filter/operators.test.d.ts +1 -0
- package/es/filter/operators.test.js +107 -0
- package/es/filter/savedFilters.d.ts +111 -0
- package/es/filter/savedFilters.js +118 -0
- package/es/filter/savedFilters.test.d.ts +1 -0
- package/es/filter/savedFilters.test.js +108 -0
- package/es/filter/standardLayout.d.ts +18 -0
- package/es/filter/standardLayout.js +55 -0
- package/es/filter/standardLayout.test.d.ts +1 -0
- package/es/filter/standardLayout.test.js +66 -0
- package/es/filter/tree.d.ts +70 -0
- package/es/filter/tree.js +185 -0
- package/es/filter/tree.test.d.ts +1 -0
- package/es/filter/tree.test.js +184 -0
- package/es/filter/types.d.ts +73 -0
- package/es/filter/types.js +1 -0
- package/es/index.d.ts +2 -0
- package/es/index.js +4 -2
- package/es/parseFunc.test.js +5 -6
- package/es/report/index.d.ts +6 -0
- package/es/report/index.js +5 -0
- package/es/report/parameterDefaults.d.ts +26 -0
- package/es/report/parameterDefaults.js +77 -0
- package/es/report/parameters.d.ts +46 -0
- package/es/report/parameters.js +188 -0
- package/es/report/parameters.test.d.ts +1 -0
- package/es/report/parameters.test.js +161 -0
- package/es/report/recordSource.d.ts +30 -0
- package/es/report/recordSource.js +105 -0
- package/es/report/render.d.ts +81 -0
- package/es/report/render.js +219 -0
- package/es/report/render.test.d.ts +1 -0
- package/es/report/render.test.js +144 -0
- package/es/report/report.d.ts +16 -0
- package/es/report/report.js +50 -0
- package/es/report/report.test.d.ts +1 -0
- package/es/report/report.test.js +86 -0
- package/es/report/types.d.ts +124 -0
- package/es/report/types.js +8 -0
- package/package.json +7 -7
- package/src/alert.ts +16 -1
- package/src/filter/convertFilter.ts +47 -0
- package/src/filter/describeFilter.test.ts +103 -0
- package/src/filter/describeFilter.ts +79 -0
- package/src/filter/fields.test.ts +58 -0
- package/src/filter/fields.ts +56 -0
- package/src/filter/index.ts +62 -0
- package/src/filter/operators.test.ts +137 -0
- package/src/filter/operators.ts +258 -0
- package/src/filter/savedFilters.test.ts +145 -0
- package/src/filter/savedFilters.ts +192 -0
- package/src/filter/standardLayout.test.ts +88 -0
- package/src/filter/standardLayout.ts +65 -0
- package/src/filter/tree.test.ts +258 -0
- package/src/filter/tree.ts +238 -0
- package/src/filter/types.ts +146 -0
- package/src/index.ts +3 -1
- package/src/parseFunc.test.ts +5 -6
- package/src/report/index.ts +37 -0
- package/src/report/parameterDefaults.ts +101 -0
- package/src/report/parameters.test.ts +218 -0
- package/src/report/parameters.ts +230 -0
- package/src/report/recordSource.ts +123 -0
- package/src/report/render.test.ts +230 -0
- package/src/report/render.ts +269 -0
- package/src/report/report.test.ts +104 -0
- package/src/report/report.ts +51 -0
- package/src/report/types.ts +135 -0
|
@@ -0,0 +1,219 @@
|
|
|
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 { downloadRequest } from "../downloadRequest.js";
|
|
10
|
+
/**
|
|
11
|
+
* The file extension each format is requested with. The route only accepts
|
|
12
|
+
* `(pdf|csv|xlsx?|html|xbm|mht|rtf|txt)`, which is why `TEXT` is `txt` and
|
|
13
|
+
* `IMAGE` is `xbm` rather than the format name.
|
|
14
|
+
*/
|
|
15
|
+
const REPORT_EXTENSIONS = {
|
|
16
|
+
CSV: "csv",
|
|
17
|
+
HTML: "html",
|
|
18
|
+
IMAGE: "xbm",
|
|
19
|
+
MHT: "mht",
|
|
20
|
+
PDF: "pdf",
|
|
21
|
+
RTF: "rtf",
|
|
22
|
+
TEXT: "txt",
|
|
23
|
+
XLS: "xls",
|
|
24
|
+
XLSX: "xlsx",
|
|
25
|
+
};
|
|
26
|
+
/** The media type each format is served as, for previewing a rendered report. */
|
|
27
|
+
const REPORT_MEDIA_TYPES = {
|
|
28
|
+
CSV: "text/csv",
|
|
29
|
+
HTML: "text/html",
|
|
30
|
+
IMAGE: "image/x-xbitmap",
|
|
31
|
+
MHT: "message/rfc822",
|
|
32
|
+
PDF: "application/pdf",
|
|
33
|
+
RTF: "text/enriched",
|
|
34
|
+
TEXT: "text/plain",
|
|
35
|
+
XLS: "application/vnd.ms-excel",
|
|
36
|
+
XLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
37
|
+
};
|
|
38
|
+
/** The extension a format is requested with. */
|
|
39
|
+
export function getReportExtension(format) {
|
|
40
|
+
return REPORT_EXTENSIONS[format];
|
|
41
|
+
}
|
|
42
|
+
/** The media type a format is served as. */
|
|
43
|
+
export function getReportMediaType(format) {
|
|
44
|
+
return REPORT_MEDIA_TYPES[format];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Characters that must not reach the file name, because it is a path segment of
|
|
48
|
+
* the route: separators, the extension delimiter, the query and fragment
|
|
49
|
+
* delimiters, and control characters.
|
|
50
|
+
*/
|
|
51
|
+
// oxlint-disable-next-line no-control-regex -- control characters are the point
|
|
52
|
+
const UNSAFE_FILE_NAME_CHARACTERS = /[\\/:*?"<>|.#&+%\u0000-\u001f]/g;
|
|
53
|
+
/**
|
|
54
|
+
* The name a rendered report is downloaded as, including its extension.
|
|
55
|
+
*
|
|
56
|
+
* The file name is part of the route rather than a query parameter, so anything
|
|
57
|
+
* that would change the path is replaced.
|
|
58
|
+
*/
|
|
59
|
+
export function getReportFileName(options) {
|
|
60
|
+
let name = (options.fileName ?? options.reportId).trim();
|
|
61
|
+
let safe = name.replace(UNSAFE_FILE_NAME_CHARACTERS, "-").replace(/-{2,}/g, "-").replace(/^-|-$/g, "").trim();
|
|
62
|
+
return `${safe === "" ? options.reportId : safe}.${getReportExtension(options.format)}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The route is always on the same origin, but a `Request` needs an absolute
|
|
66
|
+
* URL, so the path is resolved against the document when there is one.
|
|
67
|
+
*/
|
|
68
|
+
function toRequestUrl(path) {
|
|
69
|
+
let base = globalThis.location?.href;
|
|
70
|
+
return base ? new URL(path, base).toString() : path;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The request that renders a report.
|
|
74
|
+
*
|
|
75
|
+
* A report without parameters is a plain `GET`, so it can be opened directly in
|
|
76
|
+
* a new tab. As soon as there are parameters or a header the options go in a
|
|
77
|
+
* JSON body, which the route only reads from a `POST`.
|
|
78
|
+
*
|
|
79
|
+
* `filter` is always sent alongside `parameters`: the route reads the filter out
|
|
80
|
+
* of the body *guarded by the presence of `parameters`*, and reads it with an
|
|
81
|
+
* indexer that throws when the key is missing.
|
|
82
|
+
*/
|
|
83
|
+
export function getReportRequest(options) {
|
|
84
|
+
let { domain, filter, headerText, hideFilterString, parameters, reportId, signal, timezone } = options;
|
|
85
|
+
let fileName = getReportFileName(options);
|
|
86
|
+
let url = `/api/report/${encodeURIComponent(reportId)}/${encodeURIComponent(fileName)}`;
|
|
87
|
+
let query = new URLSearchParams();
|
|
88
|
+
let entries = toParameterEntries(parameters);
|
|
89
|
+
let needsBody = entries.length > 0 || Boolean(headerText) || hideFilterString === true;
|
|
90
|
+
if (domain)
|
|
91
|
+
query.set("Domain", domain);
|
|
92
|
+
query.set("timezone", String(timezone ?? -new Date().getTimezoneOffset() / 60));
|
|
93
|
+
if (!needsBody) {
|
|
94
|
+
if (filter)
|
|
95
|
+
query.set("Filter", filter);
|
|
96
|
+
return new Request(toRequestUrl(`${url}?${query}`), { credentials: "same-origin", signal });
|
|
97
|
+
}
|
|
98
|
+
let body = {
|
|
99
|
+
filter: filter ?? "",
|
|
100
|
+
parameters: entries,
|
|
101
|
+
};
|
|
102
|
+
if (headerText)
|
|
103
|
+
body.optionalheader = headerText;
|
|
104
|
+
// The route assigns `showfiltercriteria` straight to the report's
|
|
105
|
+
// `HideFilterString`, so despite its name it *hides* the filter string.
|
|
106
|
+
if (hideFilterString === true)
|
|
107
|
+
body.showfiltercriteria = "true";
|
|
108
|
+
return new Request(toRequestUrl(`${url}?${query}`), {
|
|
109
|
+
body: JSON.stringify(body),
|
|
110
|
+
credentials: "same-origin",
|
|
111
|
+
headers: { "Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest" },
|
|
112
|
+
method: "POST",
|
|
113
|
+
signal,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Renders a report and resolves with the rendered file.
|
|
118
|
+
*
|
|
119
|
+
* The route answers with a JSON body carrying an `error` when rendering fails,
|
|
120
|
+
* rather than with a failing status, so a JSON response is turned into an error
|
|
121
|
+
* here.
|
|
122
|
+
*/
|
|
123
|
+
export async function renderReport(options) {
|
|
124
|
+
let response = await fetch(getReportRequest(options));
|
|
125
|
+
let contentType = response.headers.get("content-type") ?? "";
|
|
126
|
+
if (contentType.includes("application/json")) {
|
|
127
|
+
let data = (await response.json());
|
|
128
|
+
throw new Error(data.error ?? "Failed to render report");
|
|
129
|
+
}
|
|
130
|
+
if (!response.ok)
|
|
131
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
132
|
+
return response.blob();
|
|
133
|
+
}
|
|
134
|
+
/** Renders a report and saves it, reporting progress as it downloads. */
|
|
135
|
+
export function downloadReport(options) {
|
|
136
|
+
return downloadRequest(getReportRequest(options), {
|
|
137
|
+
fileName: getReportFileName(options),
|
|
138
|
+
onProgress: options.onProgress,
|
|
139
|
+
signal: options.signal,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Renders a report and opens it in a new tab.
|
|
144
|
+
*
|
|
145
|
+
* The blob is rendered first rather than pointing a tab at the route, so a
|
|
146
|
+
* failure surfaces as an error here instead of as a tab showing JSON, and so
|
|
147
|
+
* reports with parameters — which have to be posted — can be previewed too.
|
|
148
|
+
*/
|
|
149
|
+
export async function openReport(options) {
|
|
150
|
+
let blob = await renderReport(options);
|
|
151
|
+
// The route serves some formats as types a browser downloads rather than
|
|
152
|
+
// displays; the format's own media type is what decides how it opens.
|
|
153
|
+
let url = URL.createObjectURL(blob.type ? blob : new Blob([blob], { type: getReportMediaType(options.format) }));
|
|
154
|
+
let opened = globalThis.open(url, "_blank", "noopener");
|
|
155
|
+
if (!opened) {
|
|
156
|
+
URL.revokeObjectURL(url);
|
|
157
|
+
throw new Error("The report could not be opened. Allow pop-ups for this site and try again.");
|
|
158
|
+
}
|
|
159
|
+
// Revoking immediately would cancel the load in some browsers, and never
|
|
160
|
+
// revoking keeps the whole rendered report in memory.
|
|
161
|
+
globalThis.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Renders a report and opens the browser's print dialog for it.
|
|
165
|
+
*
|
|
166
|
+
* The report is loaded into an offscreen iframe rather than a new tab, so the
|
|
167
|
+
* print dialog opens over the launcher and the user is not left with a stray
|
|
168
|
+
* tab afterwards. Browsers that will not print an embedded document fall back
|
|
169
|
+
* to opening it, which leaves the user one keystroke from printing.
|
|
170
|
+
*/
|
|
171
|
+
export async function printReport(options) {
|
|
172
|
+
let document_ = globalThis.document;
|
|
173
|
+
if (!document_?.body)
|
|
174
|
+
return openReport(options);
|
|
175
|
+
let blob = await renderReport(options);
|
|
176
|
+
let url = URL.createObjectURL(blob.type ? blob : new Blob([blob], { type: getReportMediaType(options.format) }));
|
|
177
|
+
let frame = document_.createElement("iframe");
|
|
178
|
+
// Hidden with `visibility` rather than `display: none`: a frame that is not
|
|
179
|
+
// laid out has nothing to print in some browsers.
|
|
180
|
+
frame.setAttribute("aria-hidden", "true");
|
|
181
|
+
frame.style.cssText =
|
|
182
|
+
"position:fixed;inset-block-start:0;inset-inline-start:0;inline-size:1px;block-size:1px;border:0;visibility:hidden";
|
|
183
|
+
let cleanUp = () => {
|
|
184
|
+
frame.remove();
|
|
185
|
+
URL.revokeObjectURL(url);
|
|
186
|
+
};
|
|
187
|
+
try {
|
|
188
|
+
await new Promise((resolve, reject) => {
|
|
189
|
+
frame.addEventListener("load", () => resolve(), { once: true });
|
|
190
|
+
frame.addEventListener("error", () => reject(new Error("The report could not be loaded for printing")), {
|
|
191
|
+
once: true,
|
|
192
|
+
});
|
|
193
|
+
frame.src = url;
|
|
194
|
+
document_.body.append(frame);
|
|
195
|
+
});
|
|
196
|
+
frame.contentWindow?.focus();
|
|
197
|
+
frame.contentWindow?.print();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
cleanUp();
|
|
201
|
+
return openReport(options);
|
|
202
|
+
}
|
|
203
|
+
// The print dialog is modal but not awaitable, and revoking the URL or
|
|
204
|
+
// removing the frame while it is open cancels the job.
|
|
205
|
+
globalThis.setTimeout(cleanUp, 60_000);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Parameters in the shape the route expects — `{ name, value }` objects.
|
|
209
|
+
*
|
|
210
|
+
* Empty values are dropped: the route skips them anyway, and a report falls back
|
|
211
|
+
* to its own default when a parameter is not sent.
|
|
212
|
+
*/
|
|
213
|
+
function toParameterEntries(parameters) {
|
|
214
|
+
if (!parameters)
|
|
215
|
+
return [];
|
|
216
|
+
return Object.entries(parameters)
|
|
217
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
|
218
|
+
.map(([name, value]) => ({ name, value: typeof value === "boolean" ? String(value) : value }));
|
|
219
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { fetchParameterDefault, resolveParameterDefaults } from "./parameterDefaults.js";
|
|
3
|
+
import { getReportExtension, getReportFileName, getReportRequest, printReport, renderReport, } from "./render.js";
|
|
4
|
+
function options(overrides = {}) {
|
|
5
|
+
return { format: "PDF", reportId: "arpt_Test_Report", timezone: 2, ...overrides };
|
|
6
|
+
}
|
|
7
|
+
function parameter(overrides) {
|
|
8
|
+
return {
|
|
9
|
+
label: overrides.name,
|
|
10
|
+
description: null,
|
|
11
|
+
type: "String",
|
|
12
|
+
editor: "text",
|
|
13
|
+
defaultValue: null,
|
|
14
|
+
items: [],
|
|
15
|
+
recordSource: null,
|
|
16
|
+
valueMember: null,
|
|
17
|
+
displayMember: null,
|
|
18
|
+
sortOrder: 0,
|
|
19
|
+
lookupWidth: null,
|
|
20
|
+
lookupHeight: null,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.unstubAllGlobals();
|
|
26
|
+
});
|
|
27
|
+
// A `Request` needs an absolute URL, which the route paths are resolved into
|
|
28
|
+
// against the document. There is no document in the node test environment.
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.stubGlobal("location", { href: "https://stage.example.com/report" });
|
|
31
|
+
});
|
|
32
|
+
describe("getReportExtension", () => {
|
|
33
|
+
it("uses the extensions the route accepts, not the format names", () => {
|
|
34
|
+
expect(getReportExtension("TEXT")).toBe("txt");
|
|
35
|
+
expect(getReportExtension("IMAGE")).toBe("xbm");
|
|
36
|
+
expect(getReportExtension("XLSX")).toBe("xlsx");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("getReportFileName", () => {
|
|
40
|
+
it("defaults to the report id", () => {
|
|
41
|
+
expect(getReportFileName(options())).toBe("arpt_Test_Report.pdf");
|
|
42
|
+
});
|
|
43
|
+
it("replaces characters that would change the route path", () => {
|
|
44
|
+
expect(getReportFileName(options({ fileName: "Ordre 12/34?a.b" }))).toBe("Ordre 12-34-a-b.pdf");
|
|
45
|
+
});
|
|
46
|
+
it("falls back to the report id when nothing usable is left", () => {
|
|
47
|
+
expect(getReportFileName(options({ fileName: "..." }))).toBe("arpt_Test_Report.pdf");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe("getReportRequest", () => {
|
|
51
|
+
it("is a GET with the filter in the query when there is nothing to post", () => {
|
|
52
|
+
let request = getReportRequest(options({ filter: "[ID] = 1" }));
|
|
53
|
+
expect(request.method).toBe("GET");
|
|
54
|
+
expect(request.url).toContain("/api/report/arpt_Test_Report/arpt_Test_Report.pdf");
|
|
55
|
+
expect(new URL(request.url, "http://x").searchParams.get("Filter")).toBe("[ID] = 1");
|
|
56
|
+
});
|
|
57
|
+
it("posts parameters as name/value entries, dropping empty ones", async () => {
|
|
58
|
+
let request = getReportRequest(options({ filter: "[ID] = 1", parameters: { A: 1, B: "", C: null, D: true } }));
|
|
59
|
+
expect(request.method).toBe("POST");
|
|
60
|
+
await expect(request.json()).resolves.toEqual({
|
|
61
|
+
filter: "[ID] = 1",
|
|
62
|
+
parameters: [
|
|
63
|
+
{ name: "A", value: 1 },
|
|
64
|
+
{ name: "D", value: "true" },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
it("always sends a filter alongside parameters, because the route reads it unguarded", async () => {
|
|
69
|
+
let request = getReportRequest(options({ parameters: { A: 1 } }));
|
|
70
|
+
await expect(request.json()).resolves.toMatchObject({ filter: "" });
|
|
71
|
+
});
|
|
72
|
+
it("sends the header and maps hideFilterString onto the route's misnamed key", async () => {
|
|
73
|
+
let request = getReportRequest(options({ headerText: "Q1", hideFilterString: true }));
|
|
74
|
+
await expect(request.json()).resolves.toMatchObject({
|
|
75
|
+
optionalheader: "Q1",
|
|
76
|
+
showfiltercriteria: "true",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it("passes the domain and timezone in the query", () => {
|
|
80
|
+
let params = new URL(getReportRequest(options({ domain: "OB" })).url, "http://x").searchParams;
|
|
81
|
+
expect(params.get("Domain")).toBe("OB");
|
|
82
|
+
expect(params.get("timezone")).toBe("2");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("renderReport", () => {
|
|
86
|
+
it("turns the route's JSON error body into an error", async () => {
|
|
87
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: "No permissions" }), {
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
})));
|
|
90
|
+
await expect(renderReport(options())).rejects.toThrow("No permissions");
|
|
91
|
+
});
|
|
92
|
+
it("resolves with the rendered file", async () => {
|
|
93
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("%PDF-", { headers: { "Content-Type": "application/pdf" } })));
|
|
94
|
+
await expect(renderReport(options())).resolves.toBeInstanceOf(Blob);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("printReport", () => {
|
|
98
|
+
it("opens the report when there is no document to print it in", async () => {
|
|
99
|
+
let openMock = vi.fn().mockReturnValue({});
|
|
100
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("%PDF-", { headers: { "Content-Type": "application/pdf" } })));
|
|
101
|
+
vi.stubGlobal("open", openMock);
|
|
102
|
+
await printReport(options());
|
|
103
|
+
expect(openMock).toHaveBeenCalledWith(expect.stringContaining("blob:"), "_blank", "noopener");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe("fetchParameterDefault", () => {
|
|
107
|
+
it("ignores a literal default", async () => {
|
|
108
|
+
let fetchMock = vi.fn();
|
|
109
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
110
|
+
await expect(fetchParameterDefault("r", parameter({ name: "P", defaultValue: "201701" }))).resolves.toBeNull();
|
|
111
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
it("reads the first column of the first row of the result set", async () => {
|
|
114
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ Table: [{ Period: 202601, Other: 1 }] }), {
|
|
115
|
+
headers: { "Content-Type": "application/json" },
|
|
116
|
+
})));
|
|
117
|
+
await expect(fetchParameterDefault("r", parameter({ name: "P", defaultValue: "astp_Test_DefaultPeriod" }))).resolves.toBe(202601);
|
|
118
|
+
});
|
|
119
|
+
it("handles a result set of arrays", async () => {
|
|
120
|
+
vi.stubGlobal("fetch", vi
|
|
121
|
+
.fn()
|
|
122
|
+
.mockResolvedValue(new Response(JSON.stringify([[202601]]), { headers: { "Content-Type": "application/json" } })));
|
|
123
|
+
await expect(fetchParameterDefault("r", parameter({ name: "P", defaultValue: "astp_Test_DefaultPeriod" }))).resolves.toBe(202601);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe("resolveParameterDefaults", () => {
|
|
127
|
+
it("returns the literal defaults untouched when nothing is dynamic", async () => {
|
|
128
|
+
let fetchMock = vi.fn();
|
|
129
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
130
|
+
let literal = { P: 1 };
|
|
131
|
+
await expect(resolveParameterDefaults("r", [parameter({ name: "P", defaultValue: "1" })], literal)).resolves.toBe(literal);
|
|
132
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
it("merges resolved values over the literal defaults", async () => {
|
|
135
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ Table: [{ Value: 202601 }] }), {
|
|
136
|
+
headers: { "Content-Type": "application/json" },
|
|
137
|
+
})));
|
|
138
|
+
await expect(resolveParameterDefaults("r", [parameter({ name: "Period", defaultValue: "astp_Test_DefaultPeriod" }), parameter({ name: "Other" })], { Other: "x" })).resolves.toEqual({ Other: "x", Period: 202601 });
|
|
139
|
+
});
|
|
140
|
+
it("leaves a parameter alone when its procedure fails", async () => {
|
|
141
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
|
|
142
|
+
await expect(resolveParameterDefaults("r", [parameter({ name: "Period", defaultValue: "astp_Test_DefaultPeriod" })], {})).resolves.toEqual({});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mapping a `sviw_WinClient_MyReports` record onto the report model, including
|
|
3
|
+
* which output formats the report offers.
|
|
4
|
+
*/
|
|
5
|
+
import type { Report, ReportFormat, ReportRecord } from "./types.js";
|
|
6
|
+
/**
|
|
7
|
+
* The formats a report may be rendered to, in the order the launcher offers
|
|
8
|
+
* them.
|
|
9
|
+
*
|
|
10
|
+
* `FormatCSV` covers the whole spreadsheet family: the Windows launcher enables
|
|
11
|
+
* "save as XLS" and "save as XLSX" from the same flag, because they all come out
|
|
12
|
+
* of the same DevExpress export.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getReportFormats(record: ReportRecord): ReportFormat[];
|
|
15
|
+
/** Maps a report record onto the model the launcher renders. */
|
|
16
|
+
export declare function toReport(record: ReportRecord): Report;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mapping a `sviw_WinClient_MyReports` record onto the report model, including
|
|
3
|
+
* which output formats the report offers.
|
|
4
|
+
*/
|
|
5
|
+
import { parseRecordSource } from "./recordSource.js";
|
|
6
|
+
function isSet(value) {
|
|
7
|
+
return value === true || value === 1;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The formats a report may be rendered to, in the order the launcher offers
|
|
11
|
+
* them.
|
|
12
|
+
*
|
|
13
|
+
* `FormatCSV` covers the whole spreadsheet family: the Windows launcher enables
|
|
14
|
+
* "save as XLS" and "save as XLSX" from the same flag, because they all come out
|
|
15
|
+
* of the same DevExpress export.
|
|
16
|
+
*/
|
|
17
|
+
export function getReportFormats(record) {
|
|
18
|
+
let formats = [];
|
|
19
|
+
if (isSet(record.FormatPDF))
|
|
20
|
+
formats.push("PDF");
|
|
21
|
+
if (isSet(record.FormatCSV))
|
|
22
|
+
formats.push("XLSX", "XLS", "CSV");
|
|
23
|
+
if (isSet(record.FormatHTML))
|
|
24
|
+
formats.push("HTML");
|
|
25
|
+
if (isSet(record.FormatRTF))
|
|
26
|
+
formats.push("RTF");
|
|
27
|
+
if (isSet(record.FormatMHT))
|
|
28
|
+
formats.push("MHT");
|
|
29
|
+
if (isSet(record.FormatTEXT))
|
|
30
|
+
formats.push("TEXT");
|
|
31
|
+
if (isSet(record.FormatIMAGE))
|
|
32
|
+
formats.push("IMAGE");
|
|
33
|
+
return formats;
|
|
34
|
+
}
|
|
35
|
+
/** Maps a report record onto the model the launcher renders. */
|
|
36
|
+
export function toReport(record) {
|
|
37
|
+
let recordSource = parseRecordSource(record.RecordSource);
|
|
38
|
+
return {
|
|
39
|
+
reportId: record.ReportID ?? "",
|
|
40
|
+
shortId: record.ShortID ?? null,
|
|
41
|
+
title: record.Title?.trim() || null,
|
|
42
|
+
description: record.Description?.trim() || null,
|
|
43
|
+
viewName: recordSource?.viewName ?? null,
|
|
44
|
+
initialFilter: recordSource?.filterString ?? null,
|
|
45
|
+
hasTitle: isSet(record.HasTitle),
|
|
46
|
+
hasHeader: isSet(record.HasHeader),
|
|
47
|
+
formats: getReportFormats(record),
|
|
48
|
+
timeout: record.Timeout ?? null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseRecordSource } from "./recordSource.js";
|
|
3
|
+
import { getReportFormats, toReport } from "./report.js";
|
|
4
|
+
const recordSource = `<?xml version="1.0" encoding="utf-16"?>
|
|
5
|
+
<afRecordSource>
|
|
6
|
+
<ViewName>aviw_Personnel_PersonsReportingToMe</ViewName>
|
|
7
|
+
<SelectColumns>
|
|
8
|
+
<afSelectColumn><Name>PersonID</Name></afSelectColumn>
|
|
9
|
+
<afSelectColumn><Name>Name</Name></afSelectColumn>
|
|
10
|
+
</SelectColumns>
|
|
11
|
+
<WhereClause />
|
|
12
|
+
<FilterString>[Level] = 1</FilterString>
|
|
13
|
+
<MaxRecords>0</MaxRecords>
|
|
14
|
+
</afRecordSource>`;
|
|
15
|
+
describe("parseRecordSource", () => {
|
|
16
|
+
it("reads the view, columns and design-time filter", () => {
|
|
17
|
+
expect(parseRecordSource(recordSource)).toEqual({
|
|
18
|
+
viewName: "aviw_Personnel_PersonsReportingToMe",
|
|
19
|
+
columns: [
|
|
20
|
+
{ name: "PersonID", type: "string" },
|
|
21
|
+
{ name: "Name", type: "string" },
|
|
22
|
+
],
|
|
23
|
+
whereClause: null,
|
|
24
|
+
filterString: "[Level] = 1",
|
|
25
|
+
maxRecords: null,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it("maps the CLR type of a column onto a field type", () => {
|
|
29
|
+
let xml = recordSource.replace("<afSelectColumn><Name>Name</Name></afSelectColumn>", "<afSelectColumn><Name>Level</Name><DataType>Int32</DataType></afSelectColumn>");
|
|
30
|
+
expect(parseRecordSource(xml)?.columns).toEqual([
|
|
31
|
+
{ name: "PersonID", type: "string" },
|
|
32
|
+
{ name: "Level", type: "number" },
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
it("decodes entities", () => {
|
|
36
|
+
let xml = recordSource.replace("[Level] = 1", "[Name] Like '%a & b%'");
|
|
37
|
+
expect(parseRecordSource(xml)?.filterString).toBe("[Name] Like '%a & b%'");
|
|
38
|
+
});
|
|
39
|
+
it("returns nothing for anything that is not a record source", () => {
|
|
40
|
+
expect(parseRecordSource(null)).toBeNull();
|
|
41
|
+
expect(parseRecordSource("")).toBeNull();
|
|
42
|
+
expect(parseRecordSource("<afRecordSource></afRecordSource>")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("getReportFormats", () => {
|
|
46
|
+
it("offers the spreadsheet formats from the CSV flag", () => {
|
|
47
|
+
expect(getReportFormats({ FormatPDF: true, FormatCSV: true })).toEqual(["PDF", "XLSX", "XLS", "CSV"]);
|
|
48
|
+
});
|
|
49
|
+
it("accepts bits as numbers", () => {
|
|
50
|
+
expect(getReportFormats({ FormatPDF: 1, FormatHTML: 0 })).toEqual(["PDF"]);
|
|
51
|
+
});
|
|
52
|
+
it("offers nothing for a report with no formats", () => {
|
|
53
|
+
expect(getReportFormats({})).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("toReport", () => {
|
|
57
|
+
it("resolves the view and the initial filter from the record source", () => {
|
|
58
|
+
let report = toReport({
|
|
59
|
+
ReportID: "arpt_Personnel_Persons",
|
|
60
|
+
ShortID: 1234,
|
|
61
|
+
Title: " Persons ",
|
|
62
|
+
RecordSource: recordSource,
|
|
63
|
+
FormatPDF: true,
|
|
64
|
+
HasTitle: 1,
|
|
65
|
+
HasHeader: 0,
|
|
66
|
+
});
|
|
67
|
+
expect(report).toEqual({
|
|
68
|
+
reportId: "arpt_Personnel_Persons",
|
|
69
|
+
shortId: 1234,
|
|
70
|
+
title: "Persons",
|
|
71
|
+
description: null,
|
|
72
|
+
viewName: "aviw_Personnel_PersonsReportingToMe",
|
|
73
|
+
initialFilter: "[Level] = 1",
|
|
74
|
+
hasTitle: true,
|
|
75
|
+
hasHeader: false,
|
|
76
|
+
formats: ["PDF"],
|
|
77
|
+
timeout: null,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
it("survives a report without a record source", () => {
|
|
81
|
+
let report = toReport({ ReportID: "arpt_Test" });
|
|
82
|
+
expect(report.viewName).toBeNull();
|
|
83
|
+
expect(report.initialFilter).toBeNull();
|
|
84
|
+
expect(report.shortId).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report metadata — the shape of a report and its parameters, as the Windows
|
|
3
|
+
* report launcher understands them.
|
|
4
|
+
*
|
|
5
|
+
* The loading lives in `useReport` in `@olenbetong/appframe-react`; everything
|
|
6
|
+
* here is pure.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The parameter types `sviw_WinClient_ReportsParameters.DataType` uses. The
|
|
10
|
+
* Windows launcher creates a text edit, a numeric edit, a date edit or a check
|
|
11
|
+
* edit from this, unless the parameter has a lookup or a fixed item list.
|
|
12
|
+
*/
|
|
13
|
+
export type ReportParameterType = "String" | "Integer" | "Double" | "Date" | "Boolean";
|
|
14
|
+
/** How a parameter should be edited. Derived from the metadata, not stored. */
|
|
15
|
+
export type ReportParameterEditor = "text" | "number" | "date" | "boolean" | "select" | "lookup";
|
|
16
|
+
/** A column selected by an `afRecordSource`. */
|
|
17
|
+
export type ReportRecordSourceColumn = {
|
|
18
|
+
name: string;
|
|
19
|
+
/** The field type the web data API expects, mapped from the CLR type. */
|
|
20
|
+
type: "string" | "number" | "boolean" | "date";
|
|
21
|
+
};
|
|
22
|
+
/** A lookup source parsed out of a parameter's `afRecordSource` XML. */
|
|
23
|
+
export type ReportRecordSource = {
|
|
24
|
+
viewName: string;
|
|
25
|
+
/** The columns the record source selects, in order. */
|
|
26
|
+
columns: ReportRecordSourceColumn[];
|
|
27
|
+
whereClause: string | null;
|
|
28
|
+
filterString: string | null;
|
|
29
|
+
maxRecords: number | null;
|
|
30
|
+
};
|
|
31
|
+
/** A report parameter, ready to be rendered. */
|
|
32
|
+
export type ReportParameter = {
|
|
33
|
+
name: string;
|
|
34
|
+
/** Label shown next to the editor. Falls back to the name. */
|
|
35
|
+
label: string;
|
|
36
|
+
/** Help text for the editor. */
|
|
37
|
+
description: string | null;
|
|
38
|
+
type: ReportParameterType;
|
|
39
|
+
editor: ReportParameterEditor;
|
|
40
|
+
/**
|
|
41
|
+
* The raw default. A value matching `^[a-z]stp_` names a stored procedure
|
|
42
|
+
* that produces the default; see `isProcedureDefault`.
|
|
43
|
+
*/
|
|
44
|
+
defaultValue: string | null;
|
|
45
|
+
/** Fixed list of choices, one per line in `Items`. */
|
|
46
|
+
items: string[];
|
|
47
|
+
/** Lookup source, when the parameter is backed by a view. */
|
|
48
|
+
recordSource: ReportRecordSource | null;
|
|
49
|
+
/** The lookup column holding the value to send to the report. */
|
|
50
|
+
valueMember: string | null;
|
|
51
|
+
/** The lookup column to show. The first column that is not the value member. */
|
|
52
|
+
displayMember: string | null;
|
|
53
|
+
sortOrder: number;
|
|
54
|
+
/** Preferred size of the lookup popup, in pixels. */
|
|
55
|
+
lookupWidth: number | null;
|
|
56
|
+
lookupHeight: number | null;
|
|
57
|
+
};
|
|
58
|
+
/** A raw `sviw_WinClient_ReportsParameters` record. */
|
|
59
|
+
export type ReportParameterRecord = {
|
|
60
|
+
Name?: string | null;
|
|
61
|
+
Label?: string | null;
|
|
62
|
+
Description?: string | null;
|
|
63
|
+
DataType?: string | null;
|
|
64
|
+
DefaultValue?: string | null;
|
|
65
|
+
Items?: string | null;
|
|
66
|
+
RecordSource?: string | null;
|
|
67
|
+
ValueMember?: string | null;
|
|
68
|
+
ValueListViewName?: string | null;
|
|
69
|
+
SortOrder?: number | null;
|
|
70
|
+
LookupWidth?: number | null;
|
|
71
|
+
LookupHeight?: number | null;
|
|
72
|
+
};
|
|
73
|
+
/** Output formats a report can be rendered to. */
|
|
74
|
+
export type ReportFormat = "PDF" | "XLSX" | "XLS" | "CSV" | "HTML" | "RTF" | "MHT" | "TEXT" | "IMAGE";
|
|
75
|
+
/** A report, as the launcher needs it. */
|
|
76
|
+
export type Report = {
|
|
77
|
+
reportId: string;
|
|
78
|
+
/**
|
|
79
|
+
* The report's number, which is what users refer to a report by — it is
|
|
80
|
+
* printed in the footer of every report.
|
|
81
|
+
*/
|
|
82
|
+
shortId: number | null;
|
|
83
|
+
title: string | null;
|
|
84
|
+
description: string | null;
|
|
85
|
+
/** The view the report reads, resolved from its record source. */
|
|
86
|
+
viewName: string | null;
|
|
87
|
+
/**
|
|
88
|
+
* The filter the report is designed with. Used unless the launcher is opened
|
|
89
|
+
* with an explicit filter.
|
|
90
|
+
*/
|
|
91
|
+
initialFilter: string | null;
|
|
92
|
+
/** True when the report has a title band the user may override. */
|
|
93
|
+
hasTitle: boolean;
|
|
94
|
+
/** True when the report has a header band the user may fill in. */
|
|
95
|
+
hasHeader: boolean;
|
|
96
|
+
/** The formats the report may be rendered to, in the order they are offered. */
|
|
97
|
+
formats: ReportFormat[];
|
|
98
|
+
timeout: number | null;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* A raw `sviw_WinClient_MyReports` record, optionally merged with the
|
|
102
|
+
* `HasTitle` / `HasHeader` columns from `sviw_WinClient_ReportsWeb`.
|
|
103
|
+
*/
|
|
104
|
+
export type ReportRecord = {
|
|
105
|
+
ReportID?: string | null;
|
|
106
|
+
ShortID?: number | null;
|
|
107
|
+
Title?: string | null;
|
|
108
|
+
Description?: string | null;
|
|
109
|
+
RecordSource?: string | null;
|
|
110
|
+
FormatPDF?: boolean | number | null;
|
|
111
|
+
FormatCSV?: boolean | number | null;
|
|
112
|
+
FormatHTML?: boolean | number | null;
|
|
113
|
+
FormatRTF?: boolean | number | null;
|
|
114
|
+
FormatMHT?: boolean | number | null;
|
|
115
|
+
FormatTEXT?: boolean | number | null;
|
|
116
|
+
FormatIMAGE?: boolean | number | null;
|
|
117
|
+
HasTitle?: boolean | number | null;
|
|
118
|
+
HasHeader?: boolean | number | null;
|
|
119
|
+
Timeout?: number | null;
|
|
120
|
+
};
|
|
121
|
+
/** A parameter value, as sent to the render endpoint. */
|
|
122
|
+
export type ReportParameterValue = string | number | boolean | null;
|
|
123
|
+
/** Parameter values by parameter name. */
|
|
124
|
+
export type ReportParameterValues = Record<string, ReportParameterValue>;
|