@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.
- package/dist/cjs/FrappeLikedBy.cjs +5 -4
- package/dist/cjs/FrappeLikedBy.native.js +5 -5
- package/dist/cjs/FrappeLikedBy.native.js.map +1 -1
- package/dist/cjs/FrappeReportBuilder.cjs +7 -16
- package/dist/cjs/FrappeReportBuilder.native.js +7 -16
- package/dist/cjs/FrappeReportBuilder.native.js.map +1 -1
- package/dist/cjs/formEngine/useSessionRoles.cjs +21 -17
- package/dist/cjs/formEngine/useSessionRoles.native.js +20 -20
- package/dist/cjs/formEngine/useSessionRoles.native.js.map +1 -1
- package/dist/cjs/index.cjs +10 -0
- package/dist/cjs/index.native.js +10 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/report/exportCsv.cjs +82 -0
- package/dist/cjs/report/exportCsv.native.js +96 -0
- package/dist/cjs/report/exportCsv.native.js.map +1 -0
- package/dist/cjs/sessionUser.cjs +76 -0
- package/dist/cjs/sessionUser.native.js +104 -0
- package/dist/cjs/sessionUser.native.js.map +1 -0
- package/dist/esm/FrappeLikedBy.mjs +5 -4
- package/dist/esm/FrappeLikedBy.mjs.map +1 -1
- package/dist/esm/FrappeLikedBy.native.js +5 -5
- package/dist/esm/FrappeLikedBy.native.js.map +1 -1
- package/dist/esm/FrappeReportBuilder.mjs +7 -16
- package/dist/esm/FrappeReportBuilder.mjs.map +1 -1
- package/dist/esm/FrappeReportBuilder.native.js +7 -16
- package/dist/esm/FrappeReportBuilder.native.js.map +1 -1
- package/dist/esm/formEngine/useSessionRoles.mjs +21 -17
- package/dist/esm/formEngine/useSessionRoles.mjs.map +1 -1
- package/dist/esm/formEngine/useSessionRoles.native.js +20 -20
- package/dist/esm/formEngine/useSessionRoles.native.js.map +1 -1
- package/dist/esm/index.mjs +3 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +3 -1
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/report/exportCsv.mjs +55 -0
- package/dist/esm/report/exportCsv.mjs.map +1 -0
- package/dist/esm/report/exportCsv.native.js +66 -0
- package/dist/esm/report/exportCsv.native.js.map +1 -0
- package/dist/esm/sessionUser.mjs +47 -0
- package/dist/esm/sessionUser.mjs.map +1 -0
- package/dist/esm/sessionUser.native.js +72 -0
- package/dist/esm/sessionUser.native.js.map +1 -0
- package/dist/jsx/FrappeLikedBy.mjs +5 -4
- package/dist/jsx/FrappeLikedBy.mjs.map +1 -1
- package/dist/jsx/FrappeLikedBy.native.js +5 -5
- package/dist/jsx/FrappeLikedBy.native.js.map +1 -1
- package/dist/jsx/FrappeReportBuilder.mjs +7 -16
- package/dist/jsx/FrappeReportBuilder.mjs.map +1 -1
- package/dist/jsx/FrappeReportBuilder.native.js +7 -16
- package/dist/jsx/FrappeReportBuilder.native.js.map +1 -1
- package/dist/jsx/formEngine/useSessionRoles.mjs +21 -17
- package/dist/jsx/formEngine/useSessionRoles.mjs.map +1 -1
- package/dist/jsx/formEngine/useSessionRoles.native.js +20 -20
- package/dist/jsx/formEngine/useSessionRoles.native.js.map +1 -1
- package/dist/jsx/index.js +3 -1
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +3 -1
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +10 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/report/exportCsv.mjs +55 -0
- package/dist/jsx/report/exportCsv.mjs.map +1 -0
- package/dist/jsx/report/exportCsv.native.js +96 -0
- package/dist/jsx/report/exportCsv.native.js.map +1 -0
- package/dist/jsx/sessionUser.mjs +47 -0
- package/dist/jsx/sessionUser.mjs.map +1 -0
- package/dist/jsx/sessionUser.native.js +104 -0
- package/dist/jsx/sessionUser.native.js.map +1 -0
- package/package.json +18 -18
- package/src/FrappeLikedBy.tsx +6 -6
- package/src/FrappeReportBuilder.export.spec.tsx +143 -0
- package/src/FrappeReportBuilder.tsx +7 -31
- package/src/formEngine/useSessionRoles.ts +21 -14
- package/src/index.ts +14 -0
- package/src/report/exportCsv.spec.ts +118 -0
- package/src/report/exportCsv.ts +84 -0
- package/src/sessionUser.spec.tsx +84 -0
- package/src/sessionUser.ts +65 -0
- package/types/FrappeLikedBy.d.ts.map +1 -1
- package/types/FrappeReportBuilder.d.ts.map +1 -1
- package/types/formEngine/useSessionRoles.d.ts.map +1 -1
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/report/exportCsv.d.ts +23 -0
- package/types/report/exportCsv.d.ts.map +1 -0
- package/types/sessionUser.d.ts +14 -0
- 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) ||
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
77
|
+
const key = `${authKey}#${revision}`;
|
|
78
|
+
let promise = sessionCache.get(key);
|
|
76
79
|
if (!promise) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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((
|
|
83
|
-
if (
|
|
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;
|
|
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"}
|