@olenbetong/appframe-core 2.13.1 → 2.15.1

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.
@@ -4,5 +4,5 @@ export { toFieldMetadata, toFieldMetadataList, toFilterFieldType, type ViewField
4
4
  export { getDefaultOperator, getDynamicValueLabel, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, type OperatorArity, RELATIVE_DATE_VALUES, } from "./operators.js";
5
5
  export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
6
6
  export { canDeleteSavedFilter, canEditSavedFilter, canRenameSavedFilter, canSetCrossDomain, canShareSavedFilter, findSavedFilter, NO_FILTER_ID, partitionSavedFilters, type SavedFilter, type SavedFilterRecord, toSavedFilter, toSavedFilters, } from "./savedFilters.js";
7
- export { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createEmptyGroup, createExpression, type FilterNode, type FilterPath, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, isExpression, isGroup, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, updateNodeAtPath, } from "./tree.js";
7
+ export { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createEmptyGroup, createExpression, type FilterNode, type FilterPath, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, isExpression, isGroup, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, unwrapSingleAndGroup, updateNodeAtPath, } from "./tree.js";
8
8
  export type { DynamicValue, FieldMetadata, FilterAccessLevel, FilterExpression, FilterFieldType, FilterGroup, FilterOperator, FilterTree, FilterValueType, } from "./types.js";
@@ -4,4 +4,4 @@ export { toFieldMetadata, toFieldMetadataList, toFilterFieldType } from "./field
4
4
  export { getDefaultOperator, getDynamicValueLabel, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, RELATIVE_DATE_VALUES, } from "./operators.js";
5
5
  export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
6
6
  export { canDeleteSavedFilter, canEditSavedFilter, canRenameSavedFilter, canSetCrossDomain, canShareSavedFilter, findSavedFilter, NO_FILTER_ID, partitionSavedFilters, toSavedFilter, toSavedFilters, } from "./savedFilters.js";
7
- export { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createEmptyGroup, createExpression, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, isExpression, isGroup, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, updateNodeAtPath, } from "./tree.js";
7
+ export { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createEmptyGroup, createExpression, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, isExpression, isGroup, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, unwrapSingleAndGroup, updateNodeAtPath, } from "./tree.js";
@@ -24,6 +24,15 @@ export declare function isExpression(item: FilterNode): item is FilterExpression
24
24
  * becomes the default layout.
25
25
  */
26
26
  export declare function toFilterTree(filter: FilterNode | null | undefined): FilterTree;
27
+ /**
28
+ * Returns the `and` group alone when it is the only item of an `or` group.
29
+ *
30
+ * The server parses filter strings into the standard layout, which wraps even
31
+ * a plain list of conditions in an `or` group of one `and` group. That extra
32
+ * level means nothing, so the builder shows the `and` group on its own, as the
33
+ * Windows filter editor does.
34
+ */
35
+ export declare function unwrapSingleAndGroup(filter: FilterTree): FilterTree;
27
36
  /**
28
37
  * True when the filter contains no expressions. An empty filter must not be
29
38
  * saved or applied.
package/es/filter/tree.js CHANGED
@@ -26,6 +26,20 @@ export function toFilterTree(filter) {
26
26
  return { type: "group", mode: "and", items: [filter] };
27
27
  return filter;
28
28
  }
29
+ /**
30
+ * Returns the `and` group alone when it is the only item of an `or` group.
31
+ *
32
+ * The server parses filter strings into the standard layout, which wraps even
33
+ * a plain list of conditions in an `or` group of one `and` group. That extra
34
+ * level means nothing, so the builder shows the `and` group on its own, as the
35
+ * Windows filter editor does.
36
+ */
37
+ export function unwrapSingleAndGroup(filter) {
38
+ if (filter.mode !== "or" || filter.items.length !== 1)
39
+ return filter;
40
+ let [only] = filter.items;
41
+ return isGroup(only) && only.mode === "and" ? only : filter;
42
+ }
29
43
  /**
30
44
  * True when the filter contains no expressions. An empty filter must not be
31
45
  * saved or applied.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createExpression, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, updateNodeAtPath, } from "./tree.js";
2
+ import { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createExpression, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, unwrapSingleAndGroup, updateNodeAtPath, } from "./tree.js";
3
3
  function expression(column, operator = "equals", value = "x") {
4
4
  return { type: "expression", column, operator, value, valueType: "string" };
5
5
  }
@@ -182,3 +182,25 @@ describe("path helpers", () => {
182
182
  expect(countExpressions(filter)).toBe(0);
183
183
  });
184
184
  });
185
+ describe("unwrapSingleAndGroup", () => {
186
+ test("returns the and group when it is the only item of an or group", () => {
187
+ let inner = group("and", expression("A"), expression("B"));
188
+ expect(unwrapSingleAndGroup(group("or", inner))).toBe(inner);
189
+ });
190
+ test("keeps an or group with several items", () => {
191
+ let filter = group("or", group("and", expression("A")), group("and", expression("B")));
192
+ expect(unwrapSingleAndGroup(filter)).toBe(filter);
193
+ });
194
+ test("keeps an or group holding a single expression", () => {
195
+ let filter = group("or", expression("A"));
196
+ expect(unwrapSingleAndGroup(filter)).toBe(filter);
197
+ });
198
+ test("keeps an or group holding a single or group", () => {
199
+ let filter = group("or", group("or", expression("A"), expression("B")));
200
+ expect(unwrapSingleAndGroup(filter)).toBe(filter);
201
+ });
202
+ test("keeps an and group as it is", () => {
203
+ let filter = group("and", expression("A"));
204
+ expect(unwrapSingleAndGroup(filter)).toBe(filter);
205
+ });
206
+ });
@@ -1,7 +1,69 @@
1
+ /**
2
+ * Localized strings come from two places on a page:
3
+ *
4
+ * - `af.article.i18n`: the strings the article's project uses, rendered by the framework
5
+ * into every page of that article. A string missing there is harvested by posting it to
6
+ * `/api/user/localize/new/<article>`, which registers it as used by the article and
7
+ * returns the translation.
8
+ * - `af.i18n[<project>]`: the strings of another project, for code that runs on every
9
+ * page without belonging to any one article - the site chrome bundle, the shared
10
+ * component packages. Such a project is a name, not an article; the harvest endpoint
11
+ * accepts any name, so `localize/new/<project>` registers the string under that project.
12
+ *
13
+ * Keeping a project's strings apart from the article's matters when a word means
14
+ * different things: "Articles" is "Artikler" in the chrome and "Varer" in an accounting
15
+ * app, which the framework's project-specific base strings express only if the two are
16
+ * looked up in different dictionaries.
17
+ *
18
+ * A project's dictionary is in play only when something put it there: the template
19
+ * rendered it (`@Render("ChromeStrings", Project: "...")`), or the caller fetched it and
20
+ * assigned `af.i18n[project]` itself (an empty object counts). On a page that did neither -
21
+ * a legacy template - the project falls back to the article's dictionary and the article's
22
+ * harvesting, so the page behaves as it always did instead of posting every string of a
23
+ * grid on every load. The project fills up from the pages that render it.
24
+ */
25
+ export type LocalizeOptions = {
26
+ /**
27
+ * The globalization project the string belongs to. Defaults to the article, whose
28
+ * strings the framework renders into `af.article.i18n`. Falls back to the article when
29
+ * the page carries no dictionary for the project (see the module comment).
30
+ */
31
+ project?: string;
32
+ /** Resolve with the translation once it has been fetched, instead of the base text at once. */
33
+ wait?: boolean;
34
+ };
1
35
  export declare function getLocalizedStringAsync(string: undefined | null): undefined | null;
2
- export declare function getLocalizedStringAsync(string: string): Promise<string>;
36
+ export declare function getLocalizedStringAsync(string: string, options?: Omit<LocalizeOptions, "wait">): Promise<string>;
3
37
  export declare function getLocalizedString(string: undefined | null): undefined | null;
4
38
  export declare function getLocalizedString(string: string): string;
5
39
  export declare function getLocalizedString(string: string, wait: false): string;
6
40
  export declare function getLocalizedString(string: string, wait: true): Promise<string>;
7
41
  export declare function getLocalizedString(string: undefined | null, wait: true): undefined | null;
42
+ export declare function getLocalizedString(string: string, options: LocalizeOptions & {
43
+ wait: true;
44
+ }): Promise<string>;
45
+ export declare function getLocalizedString(string: string, options: LocalizeOptions): string;
46
+ export declare function getLocalizedString(string: undefined | null, options: LocalizeOptions): undefined | null;
47
+ /**
48
+ * `getLocalizedString` and `localize` bound to a globalization project, for code that is
49
+ * not part of the article it runs on: the site chrome, the shared component packages.
50
+ *
51
+ * On a page whose template renders the project (`af.i18n[project]` exists) the strings are
52
+ * looked up and harvested there; on any other page they resolve through the article's
53
+ * dictionary as a plain `getLocalizedString` would.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * const { getLocalizedString: t } = createLocalizer("synergi-chrome");
58
+ * t("Applications");
59
+ * ```
60
+ */
61
+ export declare function createLocalizer(project: string): {
62
+ project: string;
63
+ getLocalizedString: {
64
+ (string: undefined | null): undefined | null;
65
+ (string: string): string;
66
+ (string: string, wait: true): Promise<string>;
67
+ };
68
+ localize: (stringParts: TemplateStringsArray, ...args: unknown[]) => string;
69
+ };
@@ -1,6 +1,30 @@
1
- async function fetchLocalizedString(text) {
1
+ /**
2
+ * Localized strings come from two places on a page:
3
+ *
4
+ * - `af.article.i18n`: the strings the article's project uses, rendered by the framework
5
+ * into every page of that article. A string missing there is harvested by posting it to
6
+ * `/api/user/localize/new/<article>`, which registers it as used by the article and
7
+ * returns the translation.
8
+ * - `af.i18n[<project>]`: the strings of another project, for code that runs on every
9
+ * page without belonging to any one article - the site chrome bundle, the shared
10
+ * component packages. Such a project is a name, not an article; the harvest endpoint
11
+ * accepts any name, so `localize/new/<project>` registers the string under that project.
12
+ *
13
+ * Keeping a project's strings apart from the article's matters when a word means
14
+ * different things: "Articles" is "Artikler" in the chrome and "Varer" in an accounting
15
+ * app, which the framework's project-specific base strings express only if the two are
16
+ * looked up in different dictionaries.
17
+ *
18
+ * A project's dictionary is in play only when something put it there: the template
19
+ * rendered it (`@Render("ChromeStrings", Project: "...")`), or the caller fetched it and
20
+ * assigned `af.i18n[project]` itself (an empty object counts). On a page that did neither -
21
+ * a legacy template - the project falls back to the article's dictionary and the article's
22
+ * harvesting, so the page behaves as it always did instead of posting every string of a
23
+ * grid on every load. The project fills up from the pages that render it.
24
+ */
25
+ async function fetchLocalizedString(text, project) {
2
26
  try {
3
- let response = await fetch(`/api/user/localize/new/${af.article.id}`, {
27
+ let response = await fetch(`/api/user/localize/new/${encodeURIComponent(project)}`, {
4
28
  body: JSON.stringify({ text }),
5
29
  headers: {
6
30
  Accept: "application/json; */*",
@@ -16,32 +40,79 @@ async function fetchLocalizedString(text) {
16
40
  catch { }
17
41
  return text;
18
42
  }
19
- export function getLocalizedStringAsync(string) {
43
+ /**
44
+ * The dictionary a string is resolved in: the project's when the page carries one,
45
+ * otherwise the article's; nothing when the page has no `af` at all.
46
+ */
47
+ function sourceFor(project) {
48
+ let af = globalThis.af;
49
+ if (!af)
50
+ return undefined;
51
+ if (project) {
52
+ let dictionary = af.i18n?.[project];
53
+ if (dictionary)
54
+ return { dictionary, harvestAs: project };
55
+ }
56
+ let article = af.article;
57
+ if (!article?.i18n)
58
+ return undefined;
59
+ return { dictionary: article.i18n, harvestAs: article.id };
60
+ }
61
+ export function getLocalizedStringAsync(string, options) {
20
62
  if (!string)
21
63
  return string;
22
- return getLocalizedString(string, true);
64
+ return getLocalizedString(string, { ...options, wait: true });
23
65
  }
24
- export function getLocalizedString(string, wait) {
66
+ export function getLocalizedString(string, waitOrOptions) {
25
67
  if (typeof string === "string" && string.trim().length === 0) {
26
68
  return string;
27
69
  }
28
70
  if (string === undefined || string === "" || string === null) {
29
71
  return;
30
72
  }
31
- if (globalThis.af?.article?.i18n !== undefined) {
32
- if (!Object.hasOwn(af.article.i18n, string)) {
33
- af.article.i18n[string] = string;
34
- let promise = fetchLocalizedString(string).then((text) => {
35
- af.article.i18n[string] = text;
36
- return text;
37
- });
38
- if (wait) {
39
- return promise;
40
- }
73
+ let { project, wait } = typeof waitOrOptions === "object" ? waitOrOptions : { wait: waitOrOptions };
74
+ let source = sourceFor(project);
75
+ if (source === undefined) {
76
+ return string;
77
+ }
78
+ let { dictionary, harvestAs } = source;
79
+ if (!Object.hasOwn(dictionary, string)) {
80
+ dictionary[string] = string;
81
+ let promise = fetchLocalizedString(string, harvestAs).then((text) => {
82
+ dictionary[string] = text;
83
+ return text;
84
+ });
85
+ if (wait) {
86
+ return promise;
41
87
  }
42
- return af.article.i18n[string];
43
88
  }
44
- else {
45
- return string;
89
+ return dictionary[string];
90
+ }
91
+ /**
92
+ * `getLocalizedString` and `localize` bound to a globalization project, for code that is
93
+ * not part of the article it runs on: the site chrome, the shared component packages.
94
+ *
95
+ * On a page whose template renders the project (`af.i18n[project]` exists) the strings are
96
+ * looked up and harvested there; on any other page they resolve through the article's
97
+ * dictionary as a plain `getLocalizedString` would.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const { getLocalizedString: t } = createLocalizer("synergi-chrome");
102
+ * t("Applications");
103
+ * ```
104
+ */
105
+ export function createLocalizer(project) {
106
+ function bound(string, wait) {
107
+ return getLocalizedString(string, { project, wait });
108
+ }
109
+ function localize(stringParts, ...args) {
110
+ let stringToLocalize = stringParts.map((s, i) => (i === stringParts.length - 1 ? s : `${s}$${i}`)).join("");
111
+ let localized = bound(stringToLocalize);
112
+ args.forEach((arg, index) => {
113
+ localized = localized.replace(`$${index}`, String(arg));
114
+ });
115
+ return localized;
46
116
  }
117
+ return { project, getLocalizedString: bound, localize };
47
118
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { createLocalizer, getLocalizedString } from "./getLocalizedString.js";
3
+ function fetchAnswering(translations) {
4
+ return vi.fn(async (_url, init) => {
5
+ let { text } = JSON.parse(init.body);
6
+ return { ok: true, json: async () => translations[text] ?? text };
7
+ });
8
+ }
9
+ function harvestedProjects(fetchMock) {
10
+ return fetchMock.mock.calls.map(([url]) => url.replace("/api/user/localize/new/", ""));
11
+ }
12
+ describe("getLocalizedString", () => {
13
+ let af;
14
+ let fetchMock;
15
+ beforeEach(() => {
16
+ af = { article: { id: "my-article", i18n: { Save: "Lagre" } } };
17
+ globalThis.af = af;
18
+ fetchMock = fetchAnswering({ Cancel: "Avbryt", Articles: "Artikler" });
19
+ vi.stubGlobal("fetch", fetchMock);
20
+ });
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals();
23
+ delete globalThis.af;
24
+ });
25
+ test("resolves in the article's dictionary and harvests under the article", async () => {
26
+ expect(getLocalizedString("Save")).toBe("Lagre");
27
+ expect(getLocalizedString("Cancel")).toBe("Cancel");
28
+ await vi.waitFor(() => expect(af.article.i18n.Cancel).toBe("Avbryt"));
29
+ expect(harvestedProjects(fetchMock)).toEqual(["my-article"]);
30
+ });
31
+ test("uses the project's dictionary when the page rendered it", async () => {
32
+ af.i18n = { "synergi-chrome": { Articles: "Artikler" } };
33
+ af.article.i18n.Articles = "Varer";
34
+ expect(getLocalizedString("Articles", { project: "synergi-chrome" })).toBe("Artikler");
35
+ expect(getLocalizedString("Articles")).toBe("Varer");
36
+ expect(await getLocalizedString("Cancel", { project: "synergi-chrome", wait: true })).toBe("Avbryt");
37
+ expect(af.i18n["synergi-chrome"].Cancel).toBe("Avbryt");
38
+ expect(af.article.i18n.Cancel).toBeUndefined();
39
+ expect(harvestedProjects(fetchMock)).toEqual(["synergi-chrome"]);
40
+ });
41
+ test("an empty rendered dictionary still means the project is in play", async () => {
42
+ af.i18n = { "appframe-ds": {} };
43
+ expect(await getLocalizedString("Cancel", { project: "appframe-ds", wait: true })).toBe("Avbryt");
44
+ expect(harvestedProjects(fetchMock)).toEqual(["appframe-ds"]);
45
+ });
46
+ test("falls back to the article when the page carries no dictionary for the project", async () => {
47
+ expect(getLocalizedString("Save", { project: "appframe-ds" })).toBe("Lagre");
48
+ expect(await getLocalizedString("Cancel", { project: "appframe-ds", wait: true })).toBe("Avbryt");
49
+ expect(af.i18n).toBeUndefined();
50
+ expect(af.article.i18n.Cancel).toBe("Avbryt");
51
+ expect(harvestedProjects(fetchMock)).toEqual(["my-article"]);
52
+ });
53
+ test("returns the base text when nothing is on the page", () => {
54
+ delete globalThis.af;
55
+ expect(getLocalizedString("Save", { project: "appframe-ds" })).toBe("Save");
56
+ expect(fetchMock).not.toHaveBeenCalled();
57
+ });
58
+ test("createLocalizer binds the project for getLocalizedString and localize", () => {
59
+ af.i18n = { "synergi-chrome": { Articles: "Artikler", "Hello $0": "Hei $0" } };
60
+ let { getLocalizedString: t, localize, project } = createLocalizer("synergi-chrome");
61
+ expect(project).toBe("synergi-chrome");
62
+ expect(t("Articles")).toBe("Artikler");
63
+ expect(localize `Hello ${"Ola"}`).toBe("Hei Ola");
64
+ expect(t(null)).toBeUndefined();
65
+ });
66
+ });
package/es/index.d.ts CHANGED
@@ -13,7 +13,7 @@ export type { DownloadItem, DownloadParameters } from "./downloadRequest.js";
13
13
  export { downloadRequest } from "./downloadRequest.js";
14
14
  export { expose } from "./expose.js";
15
15
  export * from "./filter/index.js";
16
- export { getLocalizedString } from "./getLocalizedString.js";
16
+ export { createLocalizer, getLocalizedString, type LocalizeOptions } from "./getLocalizedString.js";
17
17
  export type { getUserImageOptions } from "./getUserImage.js";
18
18
  export { getUserImage } from "./getUserImage.js";
19
19
  export { invariant } from "./invariant.js";
@@ -43,6 +43,12 @@ export interface AfArticle {
43
43
  }
44
44
  export interface AfCore {
45
45
  article: AfArticle;
46
+ /**
47
+ * Strings of globalization projects other than the article's, by project name, for
48
+ * code that runs on every page without belonging to an article (the site chrome).
49
+ * Rendered server side or filled by the caller; see `getLocalizedString`'s `project`.
50
+ */
51
+ i18n?: Record<string, Record<string, string>>;
46
52
  common: {
47
53
  getLocalizedString: typeof getLocalizedString;
48
54
  expose: typeof expose;
package/es/index.js CHANGED
@@ -7,7 +7,7 @@ export { downloadBlob } from "./downloadBlob.js";
7
7
  export { downloadRequest } from "./downloadRequest.js";
8
8
  export { expose } from "./expose.js";
9
9
  export * from "./filter/index.js";
10
- export { getLocalizedString } from "./getLocalizedString.js";
10
+ export { createLocalizer, getLocalizedString } from "./getLocalizedString.js";
11
11
  export { getUserImage } from "./getUserImage.js";
12
12
  export { invariant } from "./invariant.js";
13
13
  export { localize } from "./localize.js";
@@ -19,4 +19,4 @@ export { UserSession };
19
19
  export { alert, ConfirmButtons, confirm, openAlertDialog, waitForDialog } from "./alert.js";
20
20
  export { getSettings, saveSettings } from "./settings.js";
21
21
  // oxlint-disable-next-line no-use-before-define -- this is injected build time
22
- export const version = "2.13.0";
22
+ export const version = "2.15.1";
@@ -151,11 +151,15 @@ export async function openReport(options) {
151
151
  // The route serves some formats as types a browser downloads rather than
152
152
  // displays; the format's own media type is what decides how it opens.
153
153
  let url = URL.createObjectURL(blob.type ? blob : new Blob([blob], { type: getReportMediaType(options.format) }));
154
- let opened = globalThis.open(url, "_blank", "noopener");
154
+ // Not opened with the `noopener` feature: `open` then returns null even when
155
+ // the tab opens, which read as a blocked pop-up and revoked the URL before
156
+ // the tab could load it. The opener is cut off by hand instead.
157
+ let opened = globalThis.open(url, "_blank");
155
158
  if (!opened) {
156
159
  URL.revokeObjectURL(url);
157
160
  throw new Error("The report could not be opened. Allow pop-ups for this site and try again.");
158
161
  }
162
+ opened.opener = null;
159
163
  // Revoking immediately would cancel the load in some browsers, and never
160
164
  // revoking keeps the whole rendered report in memory.
161
165
  globalThis.setTimeout(() => URL.revokeObjectURL(url), 60_000);
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { fetchParameterDefault, resolveParameterDefaults } from "./parameterDefaults.js";
3
- import { getReportExtension, getReportFileName, getReportRequest, printReport, renderReport, } from "./render.js";
3
+ import { getReportExtension, getReportFileName, getReportRequest, openReport, printReport, renderReport, } from "./render.js";
4
4
  function options(overrides = {}) {
5
5
  return { format: "PDF", reportId: "arpt_Test_Report", timezone: 2, ...overrides };
6
6
  }
@@ -100,7 +100,31 @@ describe("printReport", () => {
100
100
  vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("%PDF-", { headers: { "Content-Type": "application/pdf" } })));
101
101
  vi.stubGlobal("open", openMock);
102
102
  await printReport(options());
103
- expect(openMock).toHaveBeenCalledWith(expect.stringContaining("blob:"), "_blank", "noopener");
103
+ expect(openMock).toHaveBeenCalledWith(expect.stringContaining("blob:"), "_blank");
104
+ });
105
+ });
106
+ describe("openReport", () => {
107
+ afterEach(() => {
108
+ vi.restoreAllMocks();
109
+ });
110
+ function stubRender() {
111
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("%PDF-", { headers: { "Content-Type": "application/pdf" } })));
112
+ }
113
+ it("keeps the file available to the tab it opened, and cuts the tab off from the opener", async () => {
114
+ let tab = { opener: {} };
115
+ let revoke = vi.spyOn(URL, "revokeObjectURL");
116
+ stubRender();
117
+ vi.stubGlobal("open", vi.fn().mockReturnValue(tab));
118
+ await openReport(options());
119
+ expect(revoke).not.toHaveBeenCalled();
120
+ expect(tab.opener).toBeNull();
121
+ });
122
+ it("reports a blocked pop-up and releases the file", async () => {
123
+ let revoke = vi.spyOn(URL, "revokeObjectURL");
124
+ stubRender();
125
+ vi.stubGlobal("open", vi.fn().mockReturnValue(null));
126
+ await expect(openReport(options())).rejects.toThrow("Allow pop-ups");
127
+ expect(revoke).toHaveBeenCalled();
104
128
  });
105
129
  });
106
130
  describe("fetchParameterDefault", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-core",
3
- "version": "2.13.1",
3
+ "version": "2.15.1",
4
4
  "description": "Javascript libraries to utilize the Appframe framework",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -40,9 +40,9 @@
40
40
  "mitt": "^3.0.1"
41
41
  },
42
42
  "devDependencies": {
43
- "@happy-dom/global-registrator": "^20.11.2",
44
- "@types/bun": "^1.3.14",
45
- "chalk": "^5.6.2"
43
+ "@happy-dom/global-registrator": "20.14.5",
44
+ "@types/bun": "^1.4.2",
45
+ "chalk": "^6.0.0"
46
46
  },
47
47
  "peerDependencies": {},
48
48
  "optionalDependencies": {},
@@ -47,6 +47,7 @@ export {
47
47
  removeNodeAtPath,
48
48
  setGroupMode,
49
49
  toFilterTree,
50
+ unwrapSingleAndGroup,
50
51
  updateNodeAtPath,
51
52
  } from "./tree.js";
52
53
  export type {
@@ -15,6 +15,7 @@ import {
15
15
  removeNodeAtPath,
16
16
  setGroupMode,
17
17
  toFilterTree,
18
+ unwrapSingleAndGroup,
18
19
  updateNodeAtPath,
19
20
  } from "./tree.js";
20
21
 
@@ -256,3 +257,35 @@ describe("path helpers", () => {
256
257
  expect(countExpressions(filter)).toBe(0);
257
258
  });
258
259
  });
260
+
261
+ describe("unwrapSingleAndGroup", () => {
262
+ test("returns the and group when it is the only item of an or group", () => {
263
+ let inner = group("and", expression("A"), expression("B"));
264
+
265
+ expect(unwrapSingleAndGroup(group("or", inner))).toBe(inner);
266
+ });
267
+
268
+ test("keeps an or group with several items", () => {
269
+ let filter = group("or", group("and", expression("A")), group("and", expression("B")));
270
+
271
+ expect(unwrapSingleAndGroup(filter)).toBe(filter);
272
+ });
273
+
274
+ test("keeps an or group holding a single expression", () => {
275
+ let filter = group("or", expression("A"));
276
+
277
+ expect(unwrapSingleAndGroup(filter)).toBe(filter);
278
+ });
279
+
280
+ test("keeps an or group holding a single or group", () => {
281
+ let filter = group("or", group("or", expression("A"), expression("B")));
282
+
283
+ expect(unwrapSingleAndGroup(filter)).toBe(filter);
284
+ });
285
+
286
+ test("keeps an and group as it is", () => {
287
+ let filter = group("and", expression("A"));
288
+
289
+ expect(unwrapSingleAndGroup(filter)).toBe(filter);
290
+ });
291
+ });
@@ -46,6 +46,22 @@ export function toFilterTree(filter: FilterNode | null | undefined): FilterTree
46
46
  return filter;
47
47
  }
48
48
 
49
+ /**
50
+ * Returns the `and` group alone when it is the only item of an `or` group.
51
+ *
52
+ * The server parses filter strings into the standard layout, which wraps even
53
+ * a plain list of conditions in an `or` group of one `and` group. That extra
54
+ * level means nothing, so the builder shows the `and` group on its own, as the
55
+ * Windows filter editor does.
56
+ */
57
+ export function unwrapSingleAndGroup(filter: FilterTree): FilterTree {
58
+ if (filter.mode !== "or" || filter.items.length !== 1) return filter;
59
+
60
+ let [only] = filter.items;
61
+
62
+ return isGroup(only) && only.mode === "and" ? only : filter;
63
+ }
64
+
49
65
  /**
50
66
  * True when the filter contains no expressions. An empty filter must not be
51
67
  * saved or applied.
@@ -0,0 +1,85 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+
3
+ import { createLocalizer, getLocalizedString } from "./getLocalizedString.js";
4
+
5
+ type Af = { article: { id: string; i18n: Record<string, string> }; i18n?: Record<string, Record<string, string>> };
6
+
7
+ function fetchAnswering(translations: Record<string, string>) {
8
+ return vi.fn(async (_url: string, init: RequestInit) => {
9
+ let { text } = JSON.parse(init.body as string) as { text: string };
10
+ return { ok: true, json: async () => translations[text] ?? text };
11
+ });
12
+ }
13
+
14
+ function harvestedProjects(fetchMock: ReturnType<typeof fetchAnswering>) {
15
+ return fetchMock.mock.calls.map(([url]) => (url as string).replace("/api/user/localize/new/", ""));
16
+ }
17
+
18
+ describe("getLocalizedString", () => {
19
+ let af: Af;
20
+ let fetchMock: ReturnType<typeof fetchAnswering>;
21
+
22
+ beforeEach(() => {
23
+ af = { article: { id: "my-article", i18n: { Save: "Lagre" } } };
24
+ (globalThis as { af?: Af }).af = af;
25
+ fetchMock = fetchAnswering({ Cancel: "Avbryt", Articles: "Artikler" });
26
+ vi.stubGlobal("fetch", fetchMock);
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.unstubAllGlobals();
31
+ delete (globalThis as { af?: Af }).af;
32
+ });
33
+
34
+ test("resolves in the article's dictionary and harvests under the article", async () => {
35
+ expect(getLocalizedString("Save")).toBe("Lagre");
36
+ expect(getLocalizedString("Cancel")).toBe("Cancel");
37
+ await vi.waitFor(() => expect(af.article.i18n.Cancel).toBe("Avbryt"));
38
+ expect(harvestedProjects(fetchMock)).toEqual(["my-article"]);
39
+ });
40
+
41
+ test("uses the project's dictionary when the page rendered it", async () => {
42
+ af.i18n = { "synergi-chrome": { Articles: "Artikler" } };
43
+ af.article.i18n.Articles = "Varer";
44
+
45
+ expect(getLocalizedString("Articles", { project: "synergi-chrome" })).toBe("Artikler");
46
+ expect(getLocalizedString("Articles")).toBe("Varer");
47
+
48
+ expect(await getLocalizedString("Cancel", { project: "synergi-chrome", wait: true })).toBe("Avbryt");
49
+ expect(af.i18n["synergi-chrome"].Cancel).toBe("Avbryt");
50
+ expect(af.article.i18n.Cancel).toBeUndefined();
51
+ expect(harvestedProjects(fetchMock)).toEqual(["synergi-chrome"]);
52
+ });
53
+
54
+ test("an empty rendered dictionary still means the project is in play", async () => {
55
+ af.i18n = { "appframe-ds": {} };
56
+
57
+ expect(await getLocalizedString("Cancel", { project: "appframe-ds", wait: true })).toBe("Avbryt");
58
+ expect(harvestedProjects(fetchMock)).toEqual(["appframe-ds"]);
59
+ });
60
+
61
+ test("falls back to the article when the page carries no dictionary for the project", async () => {
62
+ expect(getLocalizedString("Save", { project: "appframe-ds" })).toBe("Lagre");
63
+ expect(await getLocalizedString("Cancel", { project: "appframe-ds", wait: true })).toBe("Avbryt");
64
+
65
+ expect(af.i18n).toBeUndefined();
66
+ expect(af.article.i18n.Cancel).toBe("Avbryt");
67
+ expect(harvestedProjects(fetchMock)).toEqual(["my-article"]);
68
+ });
69
+
70
+ test("returns the base text when nothing is on the page", () => {
71
+ delete (globalThis as { af?: Af }).af;
72
+ expect(getLocalizedString("Save", { project: "appframe-ds" })).toBe("Save");
73
+ expect(fetchMock).not.toHaveBeenCalled();
74
+ });
75
+
76
+ test("createLocalizer binds the project for getLocalizedString and localize", () => {
77
+ af.i18n = { "synergi-chrome": { Articles: "Artikler", "Hello $0": "Hei $0" } };
78
+ let { getLocalizedString: t, localize, project } = createLocalizer("synergi-chrome");
79
+
80
+ expect(project).toBe("synergi-chrome");
81
+ expect(t("Articles")).toBe("Artikler");
82
+ expect(localize`Hello ${"Ola"}`).toBe("Hei Ola");
83
+ expect(t(null)).toBeUndefined();
84
+ });
85
+ });