@olenbetong/appframe-core 2.11.9 → 2.12.0

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