@multiplatform.one/frappe-ui 7.83.0 → 7.83.2

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 (87) hide show
  1. package/dist/cjs/FrappeLikedBy.cjs +5 -4
  2. package/dist/cjs/FrappeLikedBy.native.js +5 -5
  3. package/dist/cjs/FrappeLikedBy.native.js.map +1 -1
  4. package/dist/cjs/FrappeReportBuilder.cjs +7 -16
  5. package/dist/cjs/FrappeReportBuilder.native.js +7 -16
  6. package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
  7. package/dist/cjs/formEngine/useSessionRoles.cjs +21 -17
  8. package/dist/cjs/formEngine/useSessionRoles.native.js +20 -20
  9. package/dist/cjs/formEngine/useSessionRoles.native.js.map +1 -1
  10. package/dist/cjs/index.cjs +10 -0
  11. package/dist/cjs/index.native.js +10 -0
  12. package/dist/cjs/index.native.js.map +1 -1
  13. package/dist/cjs/report/exportCsv.cjs +82 -0
  14. package/dist/cjs/report/exportCsv.native.js +96 -0
  15. package/dist/cjs/report/exportCsv.native.js.map +1 -0
  16. package/dist/cjs/sessionUser.cjs +76 -0
  17. package/dist/cjs/sessionUser.native.js +104 -0
  18. package/dist/cjs/sessionUser.native.js.map +1 -0
  19. package/dist/esm/FrappeLikedBy.mjs +5 -4
  20. package/dist/esm/FrappeLikedBy.mjs.map +1 -1
  21. package/dist/esm/FrappeLikedBy.native.js +5 -5
  22. package/dist/esm/FrappeLikedBy.native.js.map +1 -1
  23. package/dist/esm/FrappeReportBuilder.mjs +7 -16
  24. package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
  25. package/dist/esm/FrappeReportBuilder.native.js +7 -16
  26. package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
  27. package/dist/esm/formEngine/useSessionRoles.mjs +21 -17
  28. package/dist/esm/formEngine/useSessionRoles.mjs.map +1 -1
  29. package/dist/esm/formEngine/useSessionRoles.native.js +20 -20
  30. package/dist/esm/formEngine/useSessionRoles.native.js.map +1 -1
  31. package/dist/esm/index.mjs +3 -1
  32. package/dist/esm/index.mjs.map +1 -1
  33. package/dist/esm/index.native.js +3 -1
  34. package/dist/esm/index.native.js.map +1 -1
  35. package/dist/esm/report/exportCsv.mjs +55 -0
  36. package/dist/esm/report/exportCsv.mjs.map +1 -0
  37. package/dist/esm/report/exportCsv.native.js +66 -0
  38. package/dist/esm/report/exportCsv.native.js.map +1 -0
  39. package/dist/esm/sessionUser.mjs +47 -0
  40. package/dist/esm/sessionUser.mjs.map +1 -0
  41. package/dist/esm/sessionUser.native.js +72 -0
  42. package/dist/esm/sessionUser.native.js.map +1 -0
  43. package/dist/jsx/FrappeLikedBy.mjs +5 -4
  44. package/dist/jsx/FrappeLikedBy.mjs.map +1 -1
  45. package/dist/jsx/FrappeLikedBy.native.js +5 -5
  46. package/dist/jsx/FrappeLikedBy.native.js.map +1 -1
  47. package/dist/jsx/FrappeReportBuilder.mjs +7 -16
  48. package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
  49. package/dist/jsx/FrappeReportBuilder.native.js +7 -16
  50. package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
  51. package/dist/jsx/formEngine/useSessionRoles.mjs +21 -17
  52. package/dist/jsx/formEngine/useSessionRoles.mjs.map +1 -1
  53. package/dist/jsx/formEngine/useSessionRoles.native.js +20 -20
  54. package/dist/jsx/formEngine/useSessionRoles.native.js.map +1 -1
  55. package/dist/jsx/index.js +3 -1
  56. package/dist/jsx/index.js.map +1 -1
  57. package/dist/jsx/index.mjs +3 -1
  58. package/dist/jsx/index.mjs.map +1 -1
  59. package/dist/jsx/index.native.js +10 -0
  60. package/dist/jsx/index.native.js.map +1 -1
  61. package/dist/jsx/report/exportCsv.mjs +55 -0
  62. package/dist/jsx/report/exportCsv.mjs.map +1 -0
  63. package/dist/jsx/report/exportCsv.native.js +96 -0
  64. package/dist/jsx/report/exportCsv.native.js.map +1 -0
  65. package/dist/jsx/sessionUser.mjs +47 -0
  66. package/dist/jsx/sessionUser.mjs.map +1 -0
  67. package/dist/jsx/sessionUser.native.js +104 -0
  68. package/dist/jsx/sessionUser.native.js.map +1 -0
  69. package/package.json +18 -18
  70. package/src/FrappeLikedBy.tsx +6 -6
  71. package/src/FrappeReportBuilder.export.spec.tsx +143 -0
  72. package/src/FrappeReportBuilder.tsx +7 -31
  73. package/src/formEngine/useSessionRoles.ts +21 -14
  74. package/src/index.ts +14 -0
  75. package/src/report/exportCsv.spec.ts +118 -0
  76. package/src/report/exportCsv.ts +84 -0
  77. package/src/sessionUser.spec.tsx +84 -0
  78. package/src/sessionUser.ts +65 -0
  79. package/types/FrappeLikedBy.d.ts.map +1 -1
  80. package/types/FrappeReportBuilder.d.ts.map +1 -1
  81. package/types/formEngine/useSessionRoles.d.ts.map +1 -1
  82. package/types/index.d.ts +2 -0
  83. package/types/index.d.ts.map +1 -1
  84. package/types/report/exportCsv.d.ts +23 -0
  85. package/types/report/exportCsv.d.ts.map +1 -0
  86. package/types/sessionUser.d.ts +14 -0
  87. package/types/sessionUser.d.ts.map +1 -0
@@ -0,0 +1,143 @@
1
+ // FZ-8893: the built-in Export CSV saves through @multiplatform.one/platform/file,
2
+ // so native gets the directory picker instead of a dead download, and the toast
3
+ // says what happened: written, download requested, nothing on cancel, or the
4
+ // FileSaveError.
5
+ import { notify } from "@multiplatform.one/components";
6
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
7
+ import { FileSaveError, saveFile } from "@multiplatform.one/platform/file";
8
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
9
+ import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
10
+ import i18next from "i18next";
11
+ import React from "react";
12
+ import { initReactI18next } from "react-i18next";
13
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
14
+ import { FrappeProvider } from "./FrappeProvider";
15
+ import { FrappeReportBuilder } from "./FrappeReportBuilder";
16
+
17
+ vi.mock("@multiplatform.one/components", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("@multiplatform.one/components")>()),
19
+ notify: vi.fn(),
20
+ }));
21
+ vi.mock("@multiplatform.one/platform/file", async (importOriginal) => ({
22
+ ...(await importOriginal<typeof import("@multiplatform.one/platform/file")>()),
23
+ saveFile: vi.fn(),
24
+ }));
25
+
26
+ const save = vi.mocked(saveFile);
27
+ const toast = vi.mocked(notify);
28
+
29
+ beforeAll(async () => {
30
+ await i18next.use(initReactI18next).init({
31
+ lng: "en",
32
+ interpolation: { escapeValue: false },
33
+ resources: { en: { translation: {} } },
34
+ });
35
+ });
36
+
37
+ afterEach(() => {
38
+ cleanup();
39
+ save.mockReset();
40
+ toast.mockReset();
41
+ });
42
+
43
+ const customers = [
44
+ { name: "CUST-001", doctype: "Customer", customer_name: "Acme Corp" },
45
+ { name: "CUST-002", doctype: "Customer", customer_name: "Globex, Inc" },
46
+ ];
47
+
48
+ let run = 0;
49
+ function renderBuilder() {
50
+ run += 1;
51
+ renderWithProviders(
52
+ <FrappeProvider
53
+ baseURL={`https://report-export-${run}.spec.test`}
54
+ fixtures={
55
+ new InMemoryFixtureProvider({
56
+ DocType: [
57
+ {
58
+ name: "Customer",
59
+ doctype: "DocType",
60
+ module: "Selling",
61
+ title_field: "customer_name",
62
+ fields: [
63
+ {
64
+ fieldname: "customer_name",
65
+ fieldtype: "Data",
66
+ label: "Customer Name",
67
+ in_list_view: 1,
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ Customer: customers,
73
+ })
74
+ }
75
+ >
76
+ <FrappeReportBuilder doctype="Customer" />
77
+ </FrappeProvider>,
78
+ );
79
+ }
80
+
81
+ async function exportCsv() {
82
+ await screen.findByText("Acme Corp");
83
+ fireEvent.click(screen.getByRole("button", { name: "Export CSV" }));
84
+ await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
85
+ }
86
+
87
+ function toasts(severity: string) {
88
+ return toast.mock.calls.map(([event]) => event).filter((event) => event.severity === severity);
89
+ }
90
+
91
+ describe("FrappeReportBuilder Export CSV (FZ-8893)", () => {
92
+ it("saves the filtered result's CSV bytes as text/csv", async () => {
93
+ save.mockResolvedValue({ status: "download-requested" });
94
+ renderBuilder();
95
+ await exportCsv();
96
+ const [options] = save.mock.calls[0];
97
+ expect(options.filename).toBe("Customer_report.csv");
98
+ expect(options.mimeType).toBe("text/csv;charset=utf-8;");
99
+ const text = new TextDecoder().decode(options.bytes);
100
+ expect(text).toContain("Acme Corp");
101
+ expect(text).toContain('"Globex, Inc"');
102
+ });
103
+
104
+ it("a requested download is the export-done toast", async () => {
105
+ save.mockResolvedValue({ status: "download-requested" });
106
+ renderBuilder();
107
+ await exportCsv();
108
+ await waitFor(() => expect(toasts("success")).toHaveLength(1));
109
+ expect(String(toasts("success")[0].body)).toMatch(/Exported 2 rows to CSV/);
110
+ });
111
+
112
+ it("a written file is a success toast naming the file", async () => {
113
+ save.mockResolvedValue({
114
+ status: "written",
115
+ uri: "file:///docs/Customer_report.csv",
116
+ requestedFilename: "Customer_report.csv",
117
+ filename: "Customer_report.csv",
118
+ byteLength: 40,
119
+ });
120
+ renderBuilder();
121
+ await exportCsv();
122
+ await waitFor(() => expect(toasts("success")).toHaveLength(1));
123
+ expect(String(toasts("success")[0].body)).toMatch(/Saved 2 rows to Customer_report\.csv/);
124
+ });
125
+
126
+ it("a cancelled picker shows no success toast", async () => {
127
+ save.mockResolvedValue({ status: "not-saved", reason: "cancelled-or-refused" });
128
+ renderBuilder();
129
+ await exportCsv();
130
+ await new Promise((resolve) => setTimeout(resolve, 0));
131
+ expect(toasts("success")).toHaveLength(0);
132
+ expect(toasts("error")).toHaveLength(0);
133
+ });
134
+
135
+ it("a FileSaveError is an error toast", async () => {
136
+ save.mockRejectedValue(new FileSaveError("picker-failed", "No writable folder was chosen."));
137
+ renderBuilder();
138
+ await exportCsv();
139
+ await waitFor(() => expect(toasts("error")).toHaveLength(1));
140
+ expect(toasts("error")[0].body).toBe("No writable folder was chosen.");
141
+ expect(toasts("success")).toHaveLength(0);
142
+ });
143
+ });
@@ -39,7 +39,6 @@ import {
39
39
  type FrappeFieldType,
40
40
  } from "@multiplatform.one/frappe";
41
41
  import { Button } from "@multiplatform.one/forms";
42
- import { downloadBlob } from "@multiplatform.one/platform";
43
42
  import { storage } from "@multiplatform.one/store";
44
43
  import { DataTablePagination } from "@multiplatform.one/table";
45
44
  import { useLayoutSizeClass, useResolvedKnobs } from "@multiplatform.one/theme";
@@ -50,6 +49,7 @@ import { FrappeNotConfigured } from "./FrappeNotConfigured";
50
49
  import { useFrappeConfig } from "./FrappeProvider";
51
50
  import { isPermissionError, refusalReason } from "./liveErrors";
52
51
  import { recordKeyLabel, useRecordShape } from "./recordShape";
52
+ import { canSaveCsv, exportCsvFile } from "./report/exportCsv";
53
53
  import { ReportGrid } from "./report/ReportGrid";
54
54
  import { ReportViewToolbar } from "./report/ReportViewToolbar";
55
55
  import {
@@ -237,21 +237,6 @@ function aggregateGroups(
237
237
  });
238
238
  }
239
239
 
240
- /**
241
- * The export button needs somewhere to put a file: a DOM anchor and an
242
- * object URL. Native and GTK have neither, so the built-in CSV path is left
243
- * out there (B12); a host `onExport` owns delivery and shows everywhere.
244
- */
245
- function canDownloadCsv(): boolean {
246
- return (
247
- typeof document !== "undefined" &&
248
- typeof document.createElement === "function" &&
249
- typeof Blob !== "undefined" &&
250
- typeof URL !== "undefined" &&
251
- typeof URL.createObjectURL === "function"
252
- );
253
- }
254
-
255
240
  export function FrappeReportBuilder(props: FrappeReportBuilderProps) {
256
241
  const config = useFrappeConfig();
257
242
  if (!(props.baseURL ?? config?.baseURL) && !config?.fixtures) {
@@ -688,26 +673,17 @@ function FrappeReportBuilderBody({
688
673
  );
689
674
 
690
675
  // ── Export: full filtered + ordered result ──────────────────
691
- const exportEnabled = Boolean(onExport) || canDownloadCsv();
676
+ const exportEnabled = Boolean(onExport) || canSaveCsv();
692
677
  const handleExport = useCallback(() => {
693
678
  if (onExport) {
694
679
  onExport(orderedRows);
695
680
  return;
696
681
  }
697
- downloadBlob(
698
- new Blob([buildReportCsv(selectedColumns, orderedRows)], {
699
- type: "text/csv;charset=utf-8;",
700
- }),
701
- `${doctype}_report.csv`,
702
- );
703
- notify({
704
- severity: "success",
705
- scope: "page",
706
- body: t("report.exportDone", {
707
- count: orderedRows.length,
708
- defaultValue_one: "Exported {{count}} row to CSV",
709
- defaultValue_other: "Exported {{count}} rows to CSV",
710
- }),
682
+ void exportCsvFile({
683
+ csv: buildReportCsv(selectedColumns, orderedRows),
684
+ filename: `${doctype}_report.csv`,
685
+ rowCount: orderedRows.length,
686
+ t,
711
687
  });
712
688
  }, [onExport, selectedColumns, orderedRows, doctype, t]);
713
689
 
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { useEffect, useRef, useState } from "react";
14
+ import { loadSessionUser, sessionUserKey, useSessionRevision } from "../sessionUser";
14
15
  import { createHttpClient, type FrappeConnection } from "./formActions";
15
16
 
16
17
  interface SessionIdentity {
@@ -18,14 +19,12 @@ interface SessionIdentity {
18
19
  roles: string[];
19
20
  }
20
21
 
21
- const sessionCache = new Map<string, Promise<SessionIdentity>>();
22
+ const sessionCache = new Map<string, Promise<SessionIdentity | null>>();
22
23
 
23
- function fetchSession(connection: FrappeConnection): Promise<SessionIdentity> {
24
- const http = createHttpClient(connection);
25
- return http.get<{ message?: string }>("/api/method/frappe.auth.get_logged_user").then((res) => {
26
- const user = res.payload?.message;
27
- if (!user || typeof user !== "string") throw new Error("No session user");
28
- return http
24
+ function fetchSession(connection: FrappeConnection): Promise<SessionIdentity | null> {
25
+ return loadSessionUser(connection).then((user) => {
26
+ if (user === "Guest") return null;
27
+ return createHttpClient(connection)
29
28
  .get<{ message?: string[] }>("/api/method/frappe.core.doctype.user.user.get_roles", {
30
29
  uid: user,
31
30
  })
@@ -64,6 +63,9 @@ export function useFrappeSession(connection: SessionConnection | undefined): Fra
64
63
  const connectionRef = useRef(connection);
65
64
  connectionRef.current = connection;
66
65
 
66
+ const authKey = connection ? sessionUserKey(connection) : "";
67
+ const revision = useSessionRevision();
68
+
67
69
  useEffect(() => {
68
70
  const current = connectionRef.current;
69
71
  if (!current || fixtures || !baseURL) {
@@ -72,15 +74,20 @@ export function useFrappeSession(connection: SessionConnection | undefined): Fra
72
74
  }
73
75
  let cancelled = false;
74
76
  setSession((prev) => (prev.state === "loading" ? prev : { state: "loading" }));
75
- let promise = sessionCache.get(baseURL);
77
+ const key = `${authKey}#${revision}`;
78
+ let promise = sessionCache.get(key);
76
79
  if (!promise) {
77
- promise = fetchSession({ ...current, baseURL });
78
- sessionCache.set(baseURL, promise);
79
- promise.catch(() => sessionCache.delete(baseURL));
80
+ const pending = fetchSession({ ...current, baseURL });
81
+ promise = pending;
82
+ sessionCache.set(key, pending);
83
+ pending.catch(() => {
84
+ if (sessionCache.get(key) === pending) sessionCache.delete(key);
85
+ });
80
86
  }
81
87
  promise
82
- .then(({ user, roles }) => {
83
- if (!cancelled) setSession({ user, roles, state: "ready" });
88
+ .then((identity) => {
89
+ if (cancelled) return;
90
+ setSession(identity ? { ...identity, state: "ready" } : { state: "failed" });
84
91
  })
85
92
  .catch(() => {
86
93
  if (!cancelled) setSession({ state: "failed" });
@@ -88,7 +95,7 @@ export function useFrappeSession(connection: SessionConnection | undefined): Fra
88
95
  return () => {
89
96
  cancelled = true;
90
97
  };
91
- }, [baseURL, fixtures]);
98
+ }, [baseURL, fixtures, authKey, revision]);
92
99
 
93
100
  if (!active) return idleSession;
94
101
  return session.state === "idle" ? loadingSession : session;
package/src/index.ts CHANGED
@@ -543,6 +543,12 @@ export {
543
543
  type UseQueryReportOptions,
544
544
  type UseQueryReportResult,
545
545
  } from "./report/useQueryReport";
546
+ export {
547
+ canSaveCsv,
548
+ CSV_MIME_TYPE,
549
+ exportCsvFile,
550
+ type ExportCsvFileOptions,
551
+ } from "./report/exportCsv";
546
552
  export {
547
553
  buildQueryReportColumns,
548
554
  buildQueryReportCsv,
@@ -658,6 +664,14 @@ export type {
658
664
  ResolveDocRightsInput,
659
665
  SessionDocRightsInput,
660
666
  } from "./formEngine";
667
+ export {
668
+ clearSessionIdentityCache,
669
+ getSessionRevision,
670
+ loadSessionUser,
671
+ sessionUserKey,
672
+ useSessionRevision,
673
+ type SessionUserSource,
674
+ } from "./sessionUser";
661
675
  export { connectionFields, connectionsFromMeta, type FrappeConnection } from "./frappeConnections";
662
676
 
663
677
  // ---------------------------------------------------------------------------
@@ -0,0 +1,118 @@
1
+ import { notify } from "@multiplatform.one/components";
2
+ import { FileSaveError, saveFile } from "@multiplatform.one/platform/file";
3
+ import type { TFunction } from "i18next";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { CSV_MIME_TYPE, canSaveCsv, exportCsvFile } from "./exportCsv";
6
+
7
+ vi.mock("@multiplatform.one/components", async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import("@multiplatform.one/components")>()),
9
+ notify: vi.fn(),
10
+ }));
11
+ vi.mock("@multiplatform.one/platform/file", async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import("@multiplatform.one/platform/file")>()),
13
+ saveFile: vi.fn(),
14
+ }));
15
+ const native = vi.hoisted(() => ({ ios: false }));
16
+ vi.mock("@multiplatform.one/platform", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("@multiplatform.one/platform")>();
18
+ return {
19
+ ...actual,
20
+ get isIos() {
21
+ return native.ios;
22
+ },
23
+ };
24
+ });
25
+
26
+ const t = ((key: string, options?: unknown) =>
27
+ typeof options === "string"
28
+ ? `${key}|${options}`
29
+ : `${key}|${JSON.stringify(options)}`) as TFunction;
30
+ const save = vi.mocked(saveFile);
31
+ const toast = vi.mocked(notify);
32
+ const csv = 'Name,Café\nA-1,"a, b"';
33
+
34
+ afterEach(() => {
35
+ save.mockReset();
36
+ toast.mockReset();
37
+ native.ios = false;
38
+ vi.unstubAllGlobals();
39
+ });
40
+
41
+ describe("exportCsvFile (FZ-8893)", () => {
42
+ it("hands saveFile the CSV's UTF-8 bytes, its filename and the CSV type", async () => {
43
+ save.mockResolvedValue({ status: "download-requested" });
44
+ await exportCsvFile({ csv, filename: "Customer_report.csv", rowCount: 1, t });
45
+ const [options] = save.mock.calls[0];
46
+ expect(options.filename).toBe("Customer_report.csv");
47
+ expect(options.mimeType).toBe(CSV_MIME_TYPE);
48
+ expect(Array.from(options.bytes)).toEqual(Array.from(new TextEncoder().encode(csv)));
49
+ });
50
+
51
+ it("a written file is a success toast naming the file", async () => {
52
+ save.mockResolvedValue({
53
+ status: "written",
54
+ uri: "file:///docs/Customer_report%202.csv",
55
+ requestedFilename: "Customer_report.csv",
56
+ filename: "Customer_report 2.csv",
57
+ byteLength: 20,
58
+ });
59
+ const result = await exportCsvFile({ csv, filename: "Customer_report.csv", rowCount: 3, t });
60
+ expect(result?.status).toBe("written");
61
+ expect(toast).toHaveBeenCalledTimes(1);
62
+ expect(toast.mock.calls[0][0]).toMatchObject({ severity: "success" });
63
+ expect(String(toast.mock.calls[0][0].body)).toContain("report.exportSaved");
64
+ expect(String(toast.mock.calls[0][0].body)).toContain("Customer_report 2.csv");
65
+ });
66
+
67
+ it("a requested download is the export-done toast", async () => {
68
+ save.mockResolvedValue({ status: "download-requested" });
69
+ await exportCsvFile({ csv, filename: "x.csv", rowCount: 2, t });
70
+ expect(toast).toHaveBeenCalledTimes(1);
71
+ expect(toast.mock.calls[0][0]).toMatchObject({ severity: "success" });
72
+ expect(String(toast.mock.calls[0][0].body)).toContain("report.exportDone");
73
+ });
74
+
75
+ it("a cancelled picker says nothing", async () => {
76
+ save.mockResolvedValue({ status: "not-saved", reason: "cancelled-or-refused" });
77
+ const result = await exportCsvFile({ csv, filename: "x.csv", rowCount: 2, t });
78
+ expect(result).toEqual({ status: "not-saved", reason: "cancelled-or-refused" });
79
+ expect(toast).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("a FileSaveError is an error toast with its message", async () => {
83
+ save.mockRejectedValue(new FileSaveError("write-failed", "The file could not be written."));
84
+ const result = await exportCsvFile({ csv, filename: "x.csv", rowCount: 2, t });
85
+ expect(result).toBeUndefined();
86
+ expect(toast).toHaveBeenCalledTimes(1);
87
+ expect(toast.mock.calls[0][0]).toMatchObject({
88
+ severity: "error",
89
+ body: "The file could not be written.",
90
+ });
91
+ });
92
+
93
+ it("any other failure is the generic error toast", async () => {
94
+ save.mockRejectedValue(new Error("boom"));
95
+ await exportCsvFile({ csv, filename: "x.csv", rowCount: 2, t });
96
+ expect(toast.mock.calls[0][0]).toMatchObject({
97
+ severity: "error",
98
+ body: "report.exportFailed|CSV export failed",
99
+ });
100
+ });
101
+ });
102
+
103
+ describe("canSaveCsv (FZ-8893)", () => {
104
+ it("is true on web with a DOM download", () => {
105
+ expect(canSaveCsv()).toBe(true);
106
+ });
107
+
108
+ it("is false with neither a DOM nor a native picker (GTK)", () => {
109
+ vi.stubGlobal("document", undefined);
110
+ expect(canSaveCsv()).toBe(false);
111
+ });
112
+
113
+ it("is true on iOS, where there is no DOM download", () => {
114
+ vi.stubGlobal("document", undefined);
115
+ native.ios = true;
116
+ expect(canSaveCsv()).toBe(true);
117
+ });
118
+ });
@@ -0,0 +1,84 @@
1
+ import { notify } from "@multiplatform.one/components";
2
+ import { isAndroid, isIos } from "@multiplatform.one/platform";
3
+ import { FileSaveError, saveFile, type FileSaveResult } from "@multiplatform.one/platform/file";
4
+ import type { TFunction } from "i18next";
5
+
6
+ /** The Blob type web CSV exports have always carried. */
7
+ export const CSV_MIME_TYPE = "text/csv;charset=utf-8;";
8
+
9
+ /**
10
+ * Whether this platform can put a CSV somewhere: the native directory picker
11
+ * on iOS and Android, a DOM download on web. GTK has neither.
12
+ */
13
+ export function canSaveCsv(): boolean {
14
+ if (isIos || isAndroid) return true;
15
+ return (
16
+ typeof document !== "undefined" &&
17
+ typeof document.createElement === "function" &&
18
+ typeof Blob !== "undefined" &&
19
+ typeof URL !== "undefined" &&
20
+ typeof URL.createObjectURL === "function"
21
+ );
22
+ }
23
+
24
+ export interface ExportCsvFileOptions {
25
+ csv: string;
26
+ filename: string;
27
+ rowCount: number;
28
+ /** The caller's translator; keys live under `report.*` in its namespace. */
29
+ t: TFunction;
30
+ }
31
+
32
+ /**
33
+ * Saves a CSV through `@multiplatform.one/platform/file` and says what
34
+ * happened: a written file or a requested download is a success toast, a
35
+ * cancelled picker says nothing, a `FileSaveError` is an error toast.
36
+ */
37
+ export async function exportCsvFile({
38
+ csv,
39
+ filename,
40
+ rowCount,
41
+ t,
42
+ }: ExportCsvFileOptions): Promise<FileSaveResult | undefined> {
43
+ let result: FileSaveResult;
44
+ try {
45
+ result = await saveFile({
46
+ bytes: new TextEncoder().encode(csv),
47
+ filename,
48
+ mimeType: CSV_MIME_TYPE,
49
+ });
50
+ } catch (error) {
51
+ notify({
52
+ severity: "error",
53
+ scope: "page",
54
+ body:
55
+ error instanceof FileSaveError
56
+ ? error.message
57
+ : t("report.exportFailed", "CSV export failed"),
58
+ });
59
+ return undefined;
60
+ }
61
+ if (result.status === "written") {
62
+ notify({
63
+ severity: "success",
64
+ scope: "page",
65
+ body: t("report.exportSaved", {
66
+ count: rowCount,
67
+ filename: result.filename ?? result.requestedFilename,
68
+ defaultValue_one: "Saved {{count}} row to {{filename}}",
69
+ defaultValue_other: "Saved {{count}} rows to {{filename}}",
70
+ }),
71
+ });
72
+ } else if (result.status === "download-requested") {
73
+ notify({
74
+ severity: "success",
75
+ scope: "page",
76
+ body: t("report.exportDone", {
77
+ count: rowCount,
78
+ defaultValue_one: "Exported {{count}} row to CSV",
79
+ defaultValue_other: "Exported {{count}} rows to CSV",
80
+ }),
81
+ });
82
+ }
83
+ return result;
84
+ }
@@ -0,0 +1,84 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
4
+ import { useFrappeSession } from "./formEngine/useSessionRoles";
5
+ import { FrappeProvider, useFrappeConfig } from "./FrappeProvider";
6
+ import { clearSessionIdentityCache, loadSessionUser } from "./sessionUser";
7
+
8
+ let run = 0;
9
+ let status = 403;
10
+ const calls = { user: 0, roles: 0 };
11
+
12
+ function Consumer({ id }: { id: string }) {
13
+ const session = useFrappeSession(useFrappeConfig());
14
+ return <span data-testid={id}>{session.state}</span>;
15
+ }
16
+
17
+ function Tree() {
18
+ return (
19
+ <FrappeProvider baseURL={`https://session-${run}.spec.test`} realtime={false}>
20
+ <Consumer id="a" />
21
+ <Consumer id="b" />
22
+ <Consumer id="c" />
23
+ </FrappeProvider>
24
+ );
25
+ }
26
+
27
+ async function settled(state: string) {
28
+ await waitFor(() => {
29
+ for (const id of ["a", "b", "c"]) expect(screen.getByTestId(id).textContent).toBe(state);
30
+ });
31
+ }
32
+
33
+ beforeEach(() => {
34
+ run += 1;
35
+ status = 403;
36
+ calls.user = 0;
37
+ calls.roles = 0;
38
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
39
+ const url = new URL(String(input instanceof Request ? input.url : input));
40
+ if (url.pathname.endsWith("frappe.auth.get_logged_user")) {
41
+ calls.user += 1;
42
+ return Response.json(status === 200 ? { message: "reader@example.test" } : {}, { status });
43
+ }
44
+ if (url.pathname.endsWith("frappe.core.doctype.user.user.get_roles")) {
45
+ calls.roles += 1;
46
+ return Response.json({ message: ["Reader"] });
47
+ }
48
+ return Response.json({});
49
+ });
50
+ });
51
+
52
+ afterEach(() => {
53
+ cleanup();
54
+ vi.unstubAllGlobals();
55
+ });
56
+
57
+ it("asks a guest's bench who is signed in once, across consumers and remounts", async () => {
58
+ const view = render(<Tree />);
59
+ await settled("failed");
60
+ await loadSessionUser({ baseURL: `https://session-${run}.spec.test` });
61
+ view.unmount();
62
+ render(<Tree />);
63
+ await settled("failed");
64
+ expect(calls).toEqual({ user: 1, roles: 0 });
65
+ });
66
+
67
+ it("reads a signed-in user and their roles once", async () => {
68
+ status = 200;
69
+ const view = render(<Tree />);
70
+ await settled("ready");
71
+ view.unmount();
72
+ render(<Tree />);
73
+ await settled("ready");
74
+ expect(calls).toEqual({ user: 1, roles: 1 });
75
+ });
76
+
77
+ it("asks again once the session identity is cleared", async () => {
78
+ render(<Tree />);
79
+ await settled("failed");
80
+ status = 200;
81
+ act(() => clearSessionIdentityCache());
82
+ await settled("ready");
83
+ expect(calls).toEqual({ user: 2, roles: 1 });
84
+ });
@@ -0,0 +1,65 @@
1
+ import { HttpClient, type AuthConfig } from "@multiplatform.one/frappe";
2
+ import { useSyncExternalStore } from "react";
3
+
4
+ export interface SessionUserSource {
5
+ baseURL?: string;
6
+ auth?: {
7
+ useToken?: boolean;
8
+ token?: string | (() => string | Promise<string>);
9
+ type?: string;
10
+ };
11
+ }
12
+
13
+ const users = new Map<string, Promise<string>>();
14
+ let revision = 0;
15
+ const listeners = new Set<() => void>();
16
+
17
+ function subscribe(listener: () => void) {
18
+ listeners.add(listener);
19
+ return () => {
20
+ listeners.delete(listener);
21
+ };
22
+ }
23
+
24
+ function getRevision() {
25
+ return revision;
26
+ }
27
+
28
+ export function getSessionRevision(): number {
29
+ return revision;
30
+ }
31
+
32
+ export function useSessionRevision(): number {
33
+ return useSyncExternalStore(subscribe, getRevision, getRevision);
34
+ }
35
+
36
+ export function sessionUserKey(source: SessionUserSource): string {
37
+ const auth = source.auth;
38
+ return `${source.baseURL ?? ""}:${auth?.useToken ? "t" : ""}:${auth?.token ?? ""}:${auth?.type ?? ""}`;
39
+ }
40
+
41
+ export function clearSessionIdentityCache(): void {
42
+ users.clear();
43
+ revision += 1;
44
+ for (const listener of listeners) listener();
45
+ }
46
+
47
+ export function loadSessionUser(source: SessionUserSource): Promise<string> {
48
+ if (!source.baseURL) return Promise.reject(new Error("No session endpoint"));
49
+ const key = sessionUserKey(source);
50
+ let promise = users.get(key);
51
+ if (!promise) {
52
+ const client = new HttpClient(source.baseURL, source.auth as AuthConfig | undefined);
53
+ promise = client
54
+ .get<{ message?: string }>("/api/method/frappe.auth.get_logged_user")
55
+ .then((response) => response.payload?.message || "Guest")
56
+ .catch((error: unknown) => {
57
+ const status = (error as { status?: number })?.status;
58
+ if (status === 401 || status === 403) return "Guest";
59
+ if (users.get(key) === promise) users.delete(key);
60
+ throw error;
61
+ });
62
+ users.set(key, promise);
63
+ }
64
+ return promise;
65
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"FrappeLikedBy.d.ts","sourceRoot":"","sources":["../src/FrappeLikedBy.tsx"],"names":[],"mappings":"AAoBA,OAAO,EAAiD,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAY1F;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,EAAE,CAanD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,IAAI,EAAE,MAAM,GACX;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,CAKlC;AAYD,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,WAAW,EAAE,UAAU,GAAG,MAAM,CAAC;IAChF,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wDAAwD;IACxD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;CACxD;AA8PD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,2CAMtD"}
1
+ {"version":3,"file":"FrappeLikedBy.d.ts","sourceRoot":"","sources":["../src/FrappeLikedBy.tsx"],"names":[],"mappings":"AAoBA,OAAO,EAAiD,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAa1F;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,EAAE,CAanD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,IAAI,EAAE,MAAM,GACX;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAE,CAKlC;AAYD,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,WAAW,EAAE,UAAU,GAAG,MAAM,CAAC;IAChF,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,wDAAwD;IACxD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;CACxD;AA6PD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,2CAMtD"}