@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.
Files changed (95) hide show
  1. package/dist/esm/af.common.js +1988 -626
  2. package/dist/esm/af.common.legacy.js +1942 -626
  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 +2146 -708
  8. package/dist/iife/af.common.legacy.js +2100 -708
  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 +235 -0
  28. package/es/filter/operators.test.d.ts +1 -0
  29. package/es/filter/operators.test.js +107 -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.test.js +5 -6
  47. package/es/report/index.d.ts +6 -0
  48. package/es/report/index.js +5 -0
  49. package/es/report/parameterDefaults.d.ts +26 -0
  50. package/es/report/parameterDefaults.js +77 -0
  51. package/es/report/parameters.d.ts +46 -0
  52. package/es/report/parameters.js +188 -0
  53. package/es/report/parameters.test.d.ts +1 -0
  54. package/es/report/parameters.test.js +161 -0
  55. package/es/report/recordSource.d.ts +30 -0
  56. package/es/report/recordSource.js +105 -0
  57. package/es/report/render.d.ts +81 -0
  58. package/es/report/render.js +219 -0
  59. package/es/report/render.test.d.ts +1 -0
  60. package/es/report/render.test.js +144 -0
  61. package/es/report/report.d.ts +16 -0
  62. package/es/report/report.js +50 -0
  63. package/es/report/report.test.d.ts +1 -0
  64. package/es/report/report.test.js +86 -0
  65. package/es/report/types.d.ts +124 -0
  66. package/es/report/types.js +8 -0
  67. package/package.json +7 -7
  68. package/src/alert.ts +16 -1
  69. package/src/filter/convertFilter.ts +47 -0
  70. package/src/filter/describeFilter.test.ts +103 -0
  71. package/src/filter/describeFilter.ts +79 -0
  72. package/src/filter/fields.test.ts +58 -0
  73. package/src/filter/fields.ts +56 -0
  74. package/src/filter/index.ts +62 -0
  75. package/src/filter/operators.test.ts +137 -0
  76. package/src/filter/operators.ts +258 -0
  77. package/src/filter/savedFilters.test.ts +145 -0
  78. package/src/filter/savedFilters.ts +192 -0
  79. package/src/filter/standardLayout.test.ts +88 -0
  80. package/src/filter/standardLayout.ts +65 -0
  81. package/src/filter/tree.test.ts +258 -0
  82. package/src/filter/tree.ts +238 -0
  83. package/src/filter/types.ts +146 -0
  84. package/src/index.ts +3 -1
  85. package/src/parseFunc.test.ts +5 -6
  86. package/src/report/index.ts +37 -0
  87. package/src/report/parameterDefaults.ts +101 -0
  88. package/src/report/parameters.test.ts +218 -0
  89. package/src/report/parameters.ts +230 -0
  90. package/src/report/recordSource.ts +123 -0
  91. package/src/report/render.test.ts +230 -0
  92. package/src/report/render.ts +269 -0
  93. package/src/report/report.test.ts +104 -0
  94. package/src/report/report.ts +51 -0
  95. package/src/report/types.ts +135 -0
@@ -0,0 +1,123 @@
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
+
11
+ import type { ReportRecordSource, ReportRecordSourceColumn } from "./types.js";
12
+
13
+ const XML_ENTITIES: Record<string, string> = {
14
+ amp: "&",
15
+ lt: "<",
16
+ gt: ">",
17
+ quot: '"',
18
+ apos: "'",
19
+ };
20
+
21
+ /** Resolves the XML entities the .NET serializer emits. */
22
+ export function decodeXml(value: string) {
23
+ return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity: string) => {
24
+ if (entity.startsWith("#x") || entity.startsWith("#X")) {
25
+ return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
26
+ }
27
+
28
+ if (entity.startsWith("#")) {
29
+ return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
30
+ }
31
+
32
+ return XML_ENTITIES[entity.toLowerCase()] ?? match;
33
+ });
34
+ }
35
+
36
+ /** Escapes a value for use as XML text or as an attribute value. */
37
+ export function encodeXml(value: string) {
38
+ return value
39
+ .replace(/&/g, "&amp;")
40
+ .replace(/</g, "&lt;")
41
+ .replace(/>/g, "&gt;")
42
+ .replace(/"/g, "&quot;")
43
+ .replace(/'/g, "&apos;");
44
+ }
45
+
46
+ /**
47
+ * The text content of the first `<name>` element, or `null` when the element is
48
+ * missing or self-closing (`<FilterString />`, which the serializer writes for
49
+ * an empty value).
50
+ */
51
+ export function readElement(xml: string, name: string): string | null {
52
+ let match = new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`).exec(xml);
53
+
54
+ if (!match) return null;
55
+
56
+ let value = decodeXml(match[1]).trim();
57
+
58
+ return value === "" ? null : value;
59
+ }
60
+
61
+ /** The text content of every `<name>` element, in document order. */
62
+ export function readElements(xml: string, name: string): string[] {
63
+ let matches = xml.matchAll(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, "g"));
64
+
65
+ return [...matches].map((match) => decodeXml(match[1]).trim());
66
+ }
67
+
68
+ /**
69
+ * The CLR types the designer writes on a select column. Anything else — and a
70
+ * column with no `DataType` at all, which is what it writes for strings — is
71
+ * treated as text.
72
+ */
73
+ const COLUMN_TYPES: Record<string, ReportRecordSourceColumn["type"]> = {
74
+ Byte: "number",
75
+ Decimal: "number",
76
+ Double: "number",
77
+ Int16: "number",
78
+ Int32: "number",
79
+ Int64: "number",
80
+ Single: "number",
81
+ Boolean: "boolean",
82
+ DateTime: "date",
83
+ };
84
+
85
+ /** The columns of a `<SelectColumns>` block, in document order. */
86
+ function readSelectColumns(xml: string): ReportRecordSourceColumn[] {
87
+ let block = /<SelectColumns>([\s\S]*?)<\/SelectColumns>/.exec(xml)?.[1] ?? "";
88
+ let columns: ReportRecordSourceColumn[] = [];
89
+
90
+ for (let match of block.matchAll(/<afSelectColumn>([\s\S]*?)<\/afSelectColumn>/g)) {
91
+ let name = readElement(match[1], "Name");
92
+ if (!name) continue;
93
+
94
+ columns.push({ name, type: COLUMN_TYPES[readElement(match[1], "DataType") ?? ""] ?? "string" });
95
+ }
96
+
97
+ return columns;
98
+ }
99
+
100
+ /**
101
+ * Reads the parts of an `afRecordSource` a web client can act on: the view, the
102
+ * selected columns and the design-time filter.
103
+ *
104
+ * Returns `null` for anything that is not a record source, so a missing or
105
+ * empty `RecordSource` column simply means "no lookup".
106
+ */
107
+ export function parseRecordSource(xml: string | null | undefined): ReportRecordSource | null {
108
+ if (!xml || !xml.includes("<afRecordSource")) return null;
109
+
110
+ let viewName = readElement(xml, "ViewName");
111
+ if (!viewName) return null;
112
+
113
+ let maxRecords = readElement(xml, "MaxRecords");
114
+
115
+ return {
116
+ viewName,
117
+ columns: readSelectColumns(xml),
118
+ whereClause: readElement(xml, "WhereClause"),
119
+ filterString: readElement(xml, "FilterString"),
120
+ // The designer writes 0 for "no limit"; the web data API spells that -1.
121
+ maxRecords: maxRecords === null || maxRecords === "0" ? null : Number(maxRecords),
122
+ };
123
+ }
@@ -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
+ }