@multiplatform.one/backoffice 7.34.2 → 7.34.4

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 (181) hide show
  1. package/dist/cjs/chrome/BackofficeAwesomebar.cjs +7 -5
  2. package/dist/cjs/chrome/BackofficeAwesomebar.native.js +7 -5
  3. package/dist/cjs/chrome/BackofficeAwesomebar.native.js.map +1 -1
  4. package/dist/cjs/chrome/CommandPalette.cjs +4 -2
  5. package/dist/cjs/chrome/CommandPalette.native.js +4 -2
  6. package/dist/cjs/chrome/CommandPalette.native.js.map +1 -1
  7. package/dist/cjs/chrome/NotificationsButton.cjs +1 -0
  8. package/dist/cjs/chrome/NotificationsButton.native.js +1 -0
  9. package/dist/cjs/chrome/NotificationsButton.native.js.map +1 -1
  10. package/dist/cjs/chrome/UserMenu.cjs +1 -1
  11. package/dist/cjs/chrome/UserMenu.native.js +1 -1
  12. package/dist/cjs/chrome/UserMenu.native.js.map +1 -1
  13. package/dist/cjs/chrome/awesomebar-data.cjs +45 -18
  14. package/dist/cjs/chrome/awesomebar-data.native.js +45 -17
  15. package/dist/cjs/chrome/awesomebar-data.native.js.map +1 -1
  16. package/dist/cjs/chrome/command-palette-model.cjs +8 -7
  17. package/dist/cjs/chrome/command-palette-model.native.js +10 -7
  18. package/dist/cjs/chrome/command-palette-model.native.js.map +1 -1
  19. package/dist/cjs/chrome/notifications-data.cjs +2 -38
  20. package/dist/cjs/chrome/notifications-data.native.js +2 -39
  21. package/dist/cjs/chrome/notifications-data.native.js.map +1 -1
  22. package/dist/cjs/chrome/user-session.cjs +19 -11
  23. package/dist/cjs/chrome/user-session.native.js +20 -13
  24. package/dist/cjs/chrome/user-session.native.js.map +1 -1
  25. package/dist/cjs/list/DoctypeListScreen.cjs +11 -5
  26. package/dist/cjs/list/DoctypeListScreen.native.js +13 -5
  27. package/dist/cjs/list/DoctypeListScreen.native.js.map +1 -1
  28. package/dist/cjs/shared/sessionUser.cjs +89 -0
  29. package/dist/cjs/shared/sessionUser.native.js +96 -0
  30. package/dist/cjs/shared/sessionUser.native.js.map +1 -0
  31. package/dist/cjs/shared/useSessionUserId.cjs +2 -26
  32. package/dist/cjs/shared/useSessionUserId.native.js +2 -28
  33. package/dist/cjs/shared/useSessionUserId.native.js.map +1 -1
  34. package/dist/cjs/shell/BackofficeShell.cjs +6 -2
  35. package/dist/cjs/shell/BackofficeShell.native.js +6 -2
  36. package/dist/cjs/shell/BackofficeShell.native.js.map +1 -1
  37. package/dist/cjs/shell/useWorkspaces.cjs +2 -31
  38. package/dist/cjs/shell/useWorkspaces.native.js +2 -35
  39. package/dist/cjs/shell/useWorkspaces.native.js.map +1 -1
  40. package/dist/esm/chrome/BackofficeAwesomebar.mjs +7 -5
  41. package/dist/esm/chrome/BackofficeAwesomebar.mjs.map +1 -1
  42. package/dist/esm/chrome/BackofficeAwesomebar.native.js +7 -5
  43. package/dist/esm/chrome/BackofficeAwesomebar.native.js.map +1 -1
  44. package/dist/esm/chrome/CommandPalette.mjs +4 -2
  45. package/dist/esm/chrome/CommandPalette.mjs.map +1 -1
  46. package/dist/esm/chrome/CommandPalette.native.js +4 -2
  47. package/dist/esm/chrome/CommandPalette.native.js.map +1 -1
  48. package/dist/esm/chrome/NotificationsButton.mjs +1 -0
  49. package/dist/esm/chrome/NotificationsButton.mjs.map +1 -1
  50. package/dist/esm/chrome/NotificationsButton.native.js +1 -0
  51. package/dist/esm/chrome/NotificationsButton.native.js.map +1 -1
  52. package/dist/esm/chrome/UserMenu.mjs +1 -1
  53. package/dist/esm/chrome/UserMenu.mjs.map +1 -1
  54. package/dist/esm/chrome/UserMenu.native.js +1 -1
  55. package/dist/esm/chrome/UserMenu.native.js.map +1 -1
  56. package/dist/esm/chrome/awesomebar-data.mjs +45 -18
  57. package/dist/esm/chrome/awesomebar-data.mjs.map +1 -1
  58. package/dist/esm/chrome/awesomebar-data.native.js +45 -17
  59. package/dist/esm/chrome/awesomebar-data.native.js.map +1 -1
  60. package/dist/esm/chrome/command-palette-model.mjs +8 -7
  61. package/dist/esm/chrome/command-palette-model.mjs.map +1 -1
  62. package/dist/esm/chrome/command-palette-model.native.js +10 -7
  63. package/dist/esm/chrome/command-palette-model.native.js.map +1 -1
  64. package/dist/esm/chrome/notifications-data.mjs +3 -39
  65. package/dist/esm/chrome/notifications-data.mjs.map +1 -1
  66. package/dist/esm/chrome/notifications-data.native.js +3 -40
  67. package/dist/esm/chrome/notifications-data.native.js.map +1 -1
  68. package/dist/esm/chrome/user-session.mjs +19 -11
  69. package/dist/esm/chrome/user-session.mjs.map +1 -1
  70. package/dist/esm/chrome/user-session.native.js +20 -13
  71. package/dist/esm/chrome/user-session.native.js.map +1 -1
  72. package/dist/esm/list/DoctypeListScreen.mjs +12 -6
  73. package/dist/esm/list/DoctypeListScreen.mjs.map +1 -1
  74. package/dist/esm/list/DoctypeListScreen.native.js +14 -6
  75. package/dist/esm/list/DoctypeListScreen.native.js.map +1 -1
  76. package/dist/esm/shared/sessionUser.mjs +61 -0
  77. package/dist/esm/shared/sessionUser.mjs.map +1 -0
  78. package/dist/esm/shared/sessionUser.native.js +65 -0
  79. package/dist/esm/shared/sessionUser.native.js.map +1 -0
  80. package/dist/esm/shared/useSessionUserId.mjs +2 -26
  81. package/dist/esm/shared/useSessionUserId.mjs.map +1 -1
  82. package/dist/esm/shared/useSessionUserId.native.js +2 -28
  83. package/dist/esm/shared/useSessionUserId.native.js.map +1 -1
  84. package/dist/esm/shell/BackofficeShell.mjs +6 -2
  85. package/dist/esm/shell/BackofficeShell.mjs.map +1 -1
  86. package/dist/esm/shell/BackofficeShell.native.js +6 -2
  87. package/dist/esm/shell/BackofficeShell.native.js.map +1 -1
  88. package/dist/esm/shell/useWorkspaces.mjs +4 -33
  89. package/dist/esm/shell/useWorkspaces.mjs.map +1 -1
  90. package/dist/esm/shell/useWorkspaces.native.js +4 -37
  91. package/dist/esm/shell/useWorkspaces.native.js.map +1 -1
  92. package/dist/jsx/chrome/BackofficeAwesomebar.mjs +7 -5
  93. package/dist/jsx/chrome/BackofficeAwesomebar.mjs.map +1 -1
  94. package/dist/jsx/chrome/BackofficeAwesomebar.native.js +7 -5
  95. package/dist/jsx/chrome/BackofficeAwesomebar.native.js.map +1 -1
  96. package/dist/jsx/chrome/CommandPalette.mjs +4 -2
  97. package/dist/jsx/chrome/CommandPalette.mjs.map +1 -1
  98. package/dist/jsx/chrome/CommandPalette.native.js +4 -2
  99. package/dist/jsx/chrome/CommandPalette.native.js.map +1 -1
  100. package/dist/jsx/chrome/NotificationsButton.mjs +1 -0
  101. package/dist/jsx/chrome/NotificationsButton.mjs.map +1 -1
  102. package/dist/jsx/chrome/NotificationsButton.native.js +1 -0
  103. package/dist/jsx/chrome/NotificationsButton.native.js.map +1 -1
  104. package/dist/jsx/chrome/UserMenu.mjs +1 -1
  105. package/dist/jsx/chrome/UserMenu.mjs.map +1 -1
  106. package/dist/jsx/chrome/UserMenu.native.js +1 -1
  107. package/dist/jsx/chrome/UserMenu.native.js.map +1 -1
  108. package/dist/jsx/chrome/awesomebar-data.mjs +45 -18
  109. package/dist/jsx/chrome/awesomebar-data.mjs.map +1 -1
  110. package/dist/jsx/chrome/awesomebar-data.native.js +45 -17
  111. package/dist/jsx/chrome/awesomebar-data.native.js.map +1 -1
  112. package/dist/jsx/chrome/command-palette-model.mjs +8 -7
  113. package/dist/jsx/chrome/command-palette-model.mjs.map +1 -1
  114. package/dist/jsx/chrome/command-palette-model.native.js +10 -7
  115. package/dist/jsx/chrome/command-palette-model.native.js.map +1 -1
  116. package/dist/jsx/chrome/notifications-data.mjs +3 -39
  117. package/dist/jsx/chrome/notifications-data.mjs.map +1 -1
  118. package/dist/jsx/chrome/notifications-data.native.js +2 -39
  119. package/dist/jsx/chrome/notifications-data.native.js.map +1 -1
  120. package/dist/jsx/chrome/user-session.mjs +19 -11
  121. package/dist/jsx/chrome/user-session.mjs.map +1 -1
  122. package/dist/jsx/chrome/user-session.native.js +20 -13
  123. package/dist/jsx/chrome/user-session.native.js.map +1 -1
  124. package/dist/jsx/list/DoctypeListScreen.mjs +12 -6
  125. package/dist/jsx/list/DoctypeListScreen.mjs.map +1 -1
  126. package/dist/jsx/list/DoctypeListScreen.native.js +13 -5
  127. package/dist/jsx/list/DoctypeListScreen.native.js.map +1 -1
  128. package/dist/jsx/shared/sessionUser.mjs +61 -0
  129. package/dist/jsx/shared/sessionUser.mjs.map +1 -0
  130. package/dist/jsx/shared/sessionUser.native.js +96 -0
  131. package/dist/jsx/shared/sessionUser.native.js.map +1 -0
  132. package/dist/jsx/shared/useSessionUserId.mjs +2 -26
  133. package/dist/jsx/shared/useSessionUserId.mjs.map +1 -1
  134. package/dist/jsx/shared/useSessionUserId.native.js +2 -28
  135. package/dist/jsx/shared/useSessionUserId.native.js.map +1 -1
  136. package/dist/jsx/shell/BackofficeShell.mjs +6 -2
  137. package/dist/jsx/shell/BackofficeShell.mjs.map +1 -1
  138. package/dist/jsx/shell/BackofficeShell.native.js +6 -2
  139. package/dist/jsx/shell/BackofficeShell.native.js.map +1 -1
  140. package/dist/jsx/shell/useWorkspaces.mjs +4 -33
  141. package/dist/jsx/shell/useWorkspaces.mjs.map +1 -1
  142. package/dist/jsx/shell/useWorkspaces.native.js +2 -35
  143. package/dist/jsx/shell/useWorkspaces.native.js.map +1 -1
  144. package/package.json +12 -12
  145. package/src/chrome/BackofficeAwesomebar.tsx +11 -6
  146. package/src/chrome/CommandPalette.spec.tsx +12 -0
  147. package/src/chrome/CommandPalette.tsx +9 -7
  148. package/src/chrome/NotificationsButton.tsx +1 -0
  149. package/src/chrome/UserMenu.screen.spec.tsx +1 -1
  150. package/src/chrome/UserMenu.tsx +1 -1
  151. package/src/chrome/awesomebar-data.tsx +28 -19
  152. package/src/chrome/command-palette-model.spec.tsx +148 -0
  153. package/src/chrome/command-palette-model.ts +13 -5
  154. package/src/chrome/notifications-data.tsx +3 -50
  155. package/src/chrome/user-session.tsx +20 -15
  156. package/src/form/DocFormScreen.guest.screen.spec.tsx +3 -3
  157. package/src/list/DoctypeListScreen.guest.screen.spec.tsx +82 -3
  158. package/src/list/DoctypeListScreen.tsx +19 -13
  159. package/src/report/DoctypeReportView.screen.spec.tsx +12 -0
  160. package/src/shared/sessionUser.spec.tsx +106 -0
  161. package/src/shared/sessionUser.ts +59 -0
  162. package/src/shared/useSessionUserId.ts +2 -40
  163. package/src/shell/BackofficeShell.spec.tsx +31 -2
  164. package/src/shell/BackofficeShell.tsx +5 -3
  165. package/src/shell/useWorkspaces.ts +5 -56
  166. package/types/chrome/BackofficeAwesomebar.d.ts.map +1 -1
  167. package/types/chrome/NotificationsButton.d.ts.map +1 -1
  168. package/types/chrome/awesomebar-data.d.ts +4 -3
  169. package/types/chrome/awesomebar-data.d.ts.map +1 -1
  170. package/types/chrome/command-palette-model.d.ts +1 -0
  171. package/types/chrome/command-palette-model.d.ts.map +1 -1
  172. package/types/chrome/notifications-data.d.ts.map +1 -1
  173. package/types/chrome/user-session.d.ts.map +1 -1
  174. package/types/list/DoctypeListScreen.d.ts.map +1 -1
  175. package/types/shared/sessionUser.d.ts +9 -0
  176. package/types/shared/sessionUser.d.ts.map +1 -0
  177. package/types/shared/useSessionUserId.d.ts +0 -4
  178. package/types/shared/useSessionUserId.d.ts.map +1 -1
  179. package/types/shell/BackofficeShell.d.ts.map +1 -1
  180. package/types/shell/useWorkspaces.d.ts +0 -5
  181. package/types/shell/useWorkspaces.d.ts.map +1 -1
@@ -24,7 +24,8 @@ import {
24
24
  type FrappeApp,
25
25
  } from "@multiplatform.one/frappe";
26
26
  import { useFrappeConfig } from "@multiplatform.one/frappe-ui";
27
- import { useCallback, useEffect, useMemo, useState } from "react";
27
+ import { useCallback, useMemo, useState } from "react";
28
+ import { useSessionIdentity } from "../shared/sessionUser";
28
29
  import { useFrappeApp } from "../shared/useFrappeApp";
29
30
  import { parseFrappeTimestamp, stripHtmlToText } from "./notifications-utils";
30
31
 
@@ -72,54 +73,6 @@ const MARK_ALL_AS_READ_METHOD =
72
73
  // Session user (module cache — one fetch per FrappeApp per session)
73
74
  // ---------------------------------------------------------------------------
74
75
 
75
- const sessionUserCache = new WeakMap<FrappeApp, Promise<string | undefined>>();
76
-
77
- interface SessionUserState {
78
- user?: string;
79
- /** True once the probe settled (Guest / unreachable resolve to no user). */
80
- resolved: boolean;
81
- }
82
-
83
- function useNotificationsSessionUser(fixtureMode: boolean): SessionUserState {
84
- const app = useFrappeApp();
85
- const [state, setState] = useState<SessionUserState>({ resolved: false });
86
-
87
- useEffect(() => {
88
- // Fixture mode has no session endpoint; report resolved-with-no-user.
89
- // No app (no provider config) settles the same way so the panel never
90
- // skeletons forever.
91
- if (fixtureMode || !app) {
92
- setState({ resolved: true });
93
- return;
94
- }
95
- let cancelled = false;
96
- let promise = sessionUserCache.get(app);
97
- if (!promise) {
98
- promise = app
99
- .call()
100
- .get<{ message?: string }>("frappe.auth.get_logged_user")
101
- .then((res) => {
102
- const user = res.payload?.message;
103
- return user && user !== "Guest" ? user : undefined;
104
- })
105
- .catch(() => {
106
- // Evict failed probes so a later mount can retry.
107
- sessionUserCache.delete(app);
108
- return undefined;
109
- });
110
- sessionUserCache.set(app, promise);
111
- }
112
- promise.then((user) => {
113
- if (!cancelled) setState({ user, resolved: true });
114
- });
115
- return () => {
116
- cancelled = true;
117
- };
118
- }, [app, fixtureMode]);
119
-
120
- return state;
121
- }
122
-
123
76
  // ---------------------------------------------------------------------------
124
77
  // Mark-as-read RPCs
125
78
  // ---------------------------------------------------------------------------
@@ -174,7 +127,7 @@ export function useNotifications(): UseNotificationsResult {
174
127
  const config = useFrappeConfig();
175
128
  const app = useFrappeApp();
176
129
  const fixtureMode = Boolean(config?.fixtures);
177
- const { user: sessionUser, resolved: sessionResolved } = useNotificationsSessionUser(fixtureMode);
130
+ const { user: sessionUser, resolved: sessionResolved } = useSessionIdentity();
178
131
 
179
132
  // Stable connection config (fresh identities would churn the collection).
180
133
  const connectionConfig = useMemo(
@@ -7,8 +7,9 @@ import React from "react";
7
7
  * frappe.client.get_value(User, <name>, ["full_name","user_image","email"]).
8
8
  */
9
9
  import type { FrappeApp } from "@multiplatform.one/frappe";
10
- import { useFrappeConfig } from "@multiplatform.one/frappe-ui";
10
+ import { useFrappeConfig, type FrappeConfig } from "@multiplatform.one/frappe-ui";
11
11
  import { useEffect, useMemo, useState } from "react";
12
+ import { clearSessionIdentityCache, loadSessionUser, sessionUserKey } from "../shared/sessionUser";
12
13
  import { useFrappeApp } from "../shared/useFrappeApp";
13
14
 
14
15
  export interface SessionUserInfo {
@@ -26,14 +27,12 @@ interface UserDocBits {
26
27
  email?: string;
27
28
  }
28
29
 
29
- // Keyed by FrappeApp instance: a different bench/auth config gets its own
30
- // entry (useFrappeApp memoizes apps per config). Failed fetches self-evict
31
- // so the next mount retries.
32
- let sessionCache = new WeakMap<FrappeApp, Promise<SessionUserInfo>>();
30
+ const sessionCache = new Map<string, Promise<SessionUserInfo>>();
33
31
 
34
32
  /** Logout hygiene: drops every cached session-user fetch in this module. */
35
33
  export function clearSessionUserCache(): void {
36
- sessionCache = new WeakMap();
34
+ sessionCache.clear();
35
+ clearSessionIdentityCache();
37
36
  }
38
37
 
39
38
  /** Resolve a User.user_image value (usually "/files/…") to an absolute URL. */
@@ -47,8 +46,13 @@ export function resolveUserImageUrl(
47
46
  return `${baseURL.replace(/\/+$/, "")}${userImage.startsWith("/") ? "" : "/"}${userImage}`;
48
47
  }
49
48
 
50
- async function fetchSessionUserInfo(app: FrappeApp, baseURL: string): Promise<SessionUserInfo> {
51
- const user = await app.auth().getLoggedInUser();
49
+ async function fetchSessionUserInfo(
50
+ app: FrappeApp,
51
+ config: FrappeConfig,
52
+ ): Promise<SessionUserInfo> {
53
+ const user = await loadSessionUser(config);
54
+ const baseURL = config.baseURL ?? "";
55
+ if (user === "Guest") return { user, fullName: user };
52
56
  if (!user) throw new Error("No session user");
53
57
  // Param format probed against the bench: filters takes the plain docname,
54
58
  // fieldname a JSON array (the http client JSON-encodes non-string params).
@@ -74,26 +78,27 @@ export function useSessionUserInfo(): {
74
78
  } {
75
79
  const app = useFrappeApp();
76
80
  const config = useFrappeConfig();
77
- const baseURL = config?.baseURL ?? "";
81
+ const key = config ? sessionUserKey(config) : "";
78
82
  const [info, setInfo] = useState<SessionUserInfo | undefined>(undefined);
79
83
  const [loading, setLoading] = useState(true);
80
84
  const [error, setError] = useState<Error | undefined>(undefined);
81
85
 
82
86
  useEffect(() => {
83
- if (!app) {
87
+ if (!app || !config || config.fixtures) {
84
88
  setInfo(undefined);
85
89
  setLoading(false);
86
90
  return;
87
91
  }
88
92
  let cancelled = false;
89
- let promise = sessionCache.get(app);
93
+ let promise = sessionCache.get(key);
90
94
  if (!promise) {
91
- promise = fetchSessionUserInfo(app, baseURL);
92
- sessionCache.set(app, promise);
95
+ promise = fetchSessionUserInfo(app, config);
96
+ sessionCache.set(key, promise);
93
97
  promise.catch(() => {
94
- if (sessionCache.get(app) === promise) sessionCache.delete(app);
98
+ if (sessionCache.get(key) === promise) sessionCache.delete(key);
95
99
  });
96
100
  }
101
+ setInfo(undefined);
97
102
  setLoading(true);
98
103
  setError(undefined);
99
104
  promise
@@ -110,7 +115,7 @@ export function useSessionUserInfo(): {
110
115
  return () => {
111
116
  cancelled = true;
112
117
  };
113
- }, [app, baseURL]);
118
+ }, [app, key, config]);
114
119
 
115
120
  return useMemo(() => ({ info, loading, error }), [info, loading, error]);
116
121
  }
@@ -149,13 +149,13 @@ describe("DocFormScreen with DocType meta refused (LC-96)", () => {
149
149
  expect(screen.queryByRole("button", { name: /retry/i })).toBeNull();
150
150
  });
151
151
 
152
- it("says the reader can't create, with each server message on its own line", async () => {
152
+ it("explains a create refusal without exposing server internals", async () => {
153
153
  renderForm("new");
154
154
  const state = await screen.findByTestId("frappe-form-not-permitted", {}, settle);
155
155
  expect(state.textContent).toContain(`You can't create ${doctype}`);
156
156
  const lines = Array.from(state.querySelectorAll("p")).map((node) => node.textContent);
157
- expect(lines).toContain("You are not permitted to access this resource. Login to access");
158
- expect(lines).toContain("Function frappe.desk.form.load.getdoctype is not whitelisted.");
157
+ expect(lines).toContain("You don't have permission to create this document.");
158
+ expect(state.textContent).not.toMatch(/frappe\.|whitelisted|Function/);
159
159
  expect(lines.some((line) => line?.includes("accessFunction"))).toBe(false);
160
160
  expect(button("Save")).toBeUndefined();
161
161
  });
@@ -1,3 +1,4 @@
1
+ import { StrictMode } from "react";
1
2
  // LC-96 on the desk list (MPO-477), the list twin of MPO-473. Signed out,
2
3
  // pokemon's bench refuses getdoctype, get_count and every desk RPC to Guest
3
4
  // while Guest can still read the rows through /api/resource, and the desk list
@@ -11,6 +12,12 @@ import { BackofficeProvider } from "../config/BackofficeProvider";
11
12
  import { createMemoryNavigator } from "../navigation/navigator";
12
13
  import { DoctypeListScreen } from "./DoctypeListScreen";
13
14
 
15
+ const layout = vi.hoisted(() => ({ size: "wide" }));
16
+ vi.mock("@multiplatform.one/theme", async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import("@multiplatform.one/theme")>()),
18
+ useLayoutSizeClass: () => layout.size,
19
+ }));
20
+
14
21
  const doctype = "Pokemon";
15
22
 
16
23
  const rows = [
@@ -29,6 +36,13 @@ const rows = [
29
36
  pokemon_name: "Bulbasaur",
30
37
  species: "Seed Pokémon",
31
38
  base_experience: 64,
39
+ image: "/files/bulbasaur.png",
40
+ height: 7,
41
+ weight: 69,
42
+ habitat: "grassland",
43
+ color: "green",
44
+ ability: "overgrow",
45
+ title: "Seed monster",
32
46
  },
33
47
  {
34
48
  name: "ivysaur",
@@ -61,6 +75,8 @@ let run = 0;
61
75
  let metaAnswer: "refused" | "broken" = "refused";
62
76
  let rowsReadable = true;
63
77
  let metaReads = 0;
78
+ let countReads = 0;
79
+ let countGate: Promise<void> | undefined;
64
80
 
65
81
  function json(body: unknown, status = 200) {
66
82
  return new Response(JSON.stringify(body), {
@@ -83,9 +99,12 @@ function refused(method: string) {
83
99
 
84
100
  beforeEach(() => {
85
101
  run += 1;
102
+ layout.size = "wide";
86
103
  metaAnswer = "refused";
87
104
  rowsReadable = true;
88
105
  metaReads = 0;
106
+ countReads = 0;
107
+ countGate = undefined;
89
108
  vi.stubGlobal("fetch", async (input: string | URL | Request) => {
90
109
  const url = new URL(String(input instanceof Request ? input.url : input));
91
110
  const method = url.pathname.replace(/^\/api\/method\//, "");
@@ -95,6 +114,11 @@ beforeEach(() => {
95
114
  ? refused(method)
96
115
  : json({ exc_type: "ValidationError", exception: "Pokemon is being migrated" }, 417);
97
116
  }
117
+ if (method === "frappe.client.get_count") {
118
+ countReads += 1;
119
+ await countGate;
120
+ return refused(method);
121
+ }
98
122
  if (url.pathname.startsWith("/api/method/")) return refused(method);
99
123
  if (url.pathname === `/api/resource/${doctype}`) {
100
124
  if (!rowsReadable) {
@@ -112,15 +136,16 @@ afterEach(() => {
112
136
  vi.unstubAllGlobals();
113
137
  });
114
138
 
115
- function renderList() {
116
- return renderWithProviders(
139
+ function renderList(strict = false) {
140
+ const tree = (
117
141
  <BackofficeProvider
118
142
  frappe={{ baseURL: `https://desk-list-guest-${run}.spec.test` }}
119
143
  navigation={createMemoryNavigator("/backoffice")}
120
144
  >
121
145
  <DoctypeListScreen doctype={doctype} />
122
- </BackofficeProvider>,
146
+ </BackofficeProvider>
123
147
  );
148
+ return renderWithProviders(strict ? <StrictMode>{tree}</StrictMode> : tree);
124
149
  }
125
150
 
126
151
  function button(name: string): HTMLElement | undefined {
@@ -130,6 +155,40 @@ function button(name: string): HTMLElement | undefined {
130
155
  }
131
156
 
132
157
  describe("DoctypeListScreen with DocType meta refused (LC-96)", () => {
158
+ it.each(["wide", "compact"])(
159
+ "owns a bounded vertical scroll region in %s layout",
160
+ async (size) => {
161
+ layout.size = size;
162
+ renderList();
163
+ await waitFor(() => expect(screen.getByText("Bulbasaur")).toBeTruthy(), settle);
164
+ const region = screen.getByTestId("backoffice-list-scroll");
165
+ expect(["auto", "scroll"]).toContain(getComputedStyle(region).overflowY);
166
+ expect(getComputedStyle(region).minHeight).toBe("0px");
167
+ const content = region.firstElementChild?.firstElementChild as HTMLElement;
168
+ expect(getComputedStyle(content).flexBasis).toBe("auto");
169
+ expect(getComputedStyle(content).flexShrink).toBe("0");
170
+ expect(region.contains(screen.getByRole("heading", { name: doctype }))).toBe(false);
171
+ },
172
+ );
173
+
174
+ it("renders headers on wide screens and bounds sampled columns", async () => {
175
+ renderList();
176
+ await waitFor(() => expect(screen.getByText("Bulbasaur")).toBeTruthy(), settle);
177
+ expect(screen.getAllByRole("columnheader").length).toBeGreaterThan(0);
178
+ expect(screen.queryByText("/files/bulbasaur.png")).toBeNull();
179
+ expect(screen.queryByText("overgrow")).toBeNull();
180
+ expect(screen.getByText("Seed monster")).toBeTruthy();
181
+ });
182
+
183
+ it("keeps compact rows in a bounded document list", async () => {
184
+ layout.size = "compact";
185
+ renderList();
186
+ await waitFor(() => expect(screen.getByText("Bulbasaur")).toBeTruthy(), settle);
187
+ expect(screen.queryAllByRole("columnheader")).toEqual([]);
188
+ expect(screen.queryByText("/files/bulbasaur.png")).toBeNull();
189
+ expect(screen.queryByText("overgrow")).toBeNull();
190
+ });
191
+
133
192
  it("lists the rows Guest can read, laid out from a record's own fields", async () => {
134
193
  renderList();
135
194
  await waitFor(() => expect(screen.getByText("Bulbasaur")).toBeTruthy(), settle);
@@ -179,3 +238,23 @@ describe("DoctypeListScreen with DocType meta refused (LC-96)", () => {
179
238
  await waitFor(() => expect(metaReads).toBe(2), settle);
180
239
  });
181
240
  });
241
+
242
+ it("requests one count for the list filter state, including strict effect replay", async () => {
243
+ renderList(true);
244
+ await waitFor(() => expect(screen.getByText("Bulbasaur")).toBeTruthy(), settle);
245
+ expect(countReads).toBe(1);
246
+ });
247
+
248
+ it("renders rows before a slow count settles", async () => {
249
+ let release!: () => void;
250
+ countGate = new Promise<void>((resolve) => {
251
+ release = resolve;
252
+ });
253
+ try {
254
+ renderList();
255
+ await waitFor(() => expect(screen.getByText("Bulbasaur")).toBeTruthy(), settle);
256
+ expect(countReads).toBe(1);
257
+ } finally {
258
+ release();
259
+ }
260
+ });
@@ -17,7 +17,6 @@ import {
17
17
  Spinner,
18
18
  XStack,
19
19
  YStack,
20
- isWeb,
21
20
  notify,
22
21
  } from "@multiplatform.one/components";
23
22
  import { useLayoutSizeClass, useResolvedKnobs } from "@multiplatform.one/theme";
@@ -235,7 +234,16 @@ export function DoctypeListScreen({
235
234
  (recordShape.fields
236
235
  ? {
237
236
  name: doctype,
238
- fields: recordShape.fields.map((field) => ({ ...field, in_list_view: 1 as const })),
237
+ fields: [...recordShape.fields]
238
+ .sort(
239
+ (a, b) =>
240
+ Number(b.fieldname === "title" || b.fieldname === "name") -
241
+ Number(a.fieldname === "title" || a.fieldname === "name"),
242
+ )
243
+ .map((field, index) => ({
244
+ ...field,
245
+ in_list_view: index < 6 ? (1 as const) : (0 as const),
246
+ })),
239
247
  }
240
248
  : undefined),
241
249
  [meta, recordShape.fields, doctype],
@@ -981,7 +989,7 @@ export function DoctypeListScreen({
981
989
  );
982
990
 
983
991
  return (
984
- <YStack flex={1} {...knobProps.gap} {...knobProps.panelPadding}>
992
+ <YStack flex={1} minHeight={0} {...knobProps.gap} {...knobProps.panelPadding}>
985
993
  <PageHeader
986
994
  gap="$2"
987
995
  heading={
@@ -1012,7 +1020,7 @@ export function DoctypeListScreen({
1012
1020
 
1013
1021
  {(() => {
1014
1022
  const listBody = (
1015
- <XStack flex={1} gap="$2" alignItems="stretch">
1023
+ <XStack flexGrow={1} flexShrink={0} flexBasis="auto" gap="$2" alignItems="stretch">
1016
1024
  {/* Facet sidebar needs bench RPCs (fixture mode hides it) and is
1017
1025
  hidden on narrow viewports (< $sm) like desk. MOD-04 keeps it:
1018
1026
  facet clicks toggle tuples through the same baseFilters
@@ -1077,6 +1085,7 @@ export function DoctypeListScreen({
1077
1085
  refreshToken={refreshGen}
1078
1086
  enablePagination={!fixtureMode}
1079
1087
  paginationMode="infinite"
1088
+ fetchTotalCount={false}
1080
1089
  compact
1081
1090
  // ONE FILTER SYSTEM (MOD-04 / LC-16): the list chrome is
1082
1091
  // the single filtering authority; tuples reach the table
@@ -1094,20 +1103,17 @@ export function DoctypeListScreen({
1094
1103
  onRowClick={handleRowClick}
1095
1104
  onStateChange={handleStateChange}
1096
1105
  rights={rights}
1097
- layout="list"
1106
+ layout={compactChrome ? "list" : "table"}
1098
1107
  />
1099
1108
  )}
1100
1109
  </YStack>
1101
1110
  </XStack>
1102
1111
  );
1103
- // Native has no page-level scroll and the table body ships none —
1104
- // wrap in a vertical ScrollView (DocFormScreen precedent) so rows
1105
- // below the fold stay reachable. Compact web is in the same boat:
1106
- // the card list renders in flow inside the shell's overflow-hidden
1107
- // frame, so it needs the same scroll wrapper (the compact header
1108
- // stays pinned above it). Wide web keeps the flex layout as-is —
1109
- // the grid body scrolls internally.
1110
- return isWeb && !compactChrome ? listBody : <ScrollView flex={1}>{listBody}</ScrollView>;
1112
+ return (
1113
+ <ScrollView flex={1} minHeight={0} testID="backoffice-list-scroll">
1114
+ {listBody}
1115
+ </ScrollView>
1116
+ );
1111
1117
  })()}
1112
1118
 
1113
1119
  {showNew && (
@@ -152,3 +152,15 @@ describe("<DoctypeReportView />", () => {
152
152
  ).toBeTruthy();
153
153
  });
154
154
  });
155
+
156
+ it("keeps the report toolbar outside a bounded vertical body scrollport", async () => {
157
+ renderReport();
158
+ await waitFor(() => expect(screen.getByText("ACME Corp")).toBeTruthy());
159
+ const region = screen.getByTestId("frappe-report-scroll");
160
+ expect(["auto", "scroll"]).toContain(getComputedStyle(region).overflowY);
161
+ expect(getComputedStyle(region).minHeight).toBe("0px");
162
+ const content = region.firstElementChild?.firstElementChild as HTMLElement;
163
+ expect(getComputedStyle(content).flexBasis).toBe("auto");
164
+ expect(getComputedStyle(content).flexShrink).toBe("0");
165
+ expect(region.querySelector('[aria-label="Columns"]')).toBeNull();
166
+ });
@@ -0,0 +1,106 @@
1
+ import React from "react";
2
+ import { FrappeProvider } from "@multiplatform.one/frappe-ui";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { cleanup, screen, waitFor } from "@testing-library/react";
5
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
6
+ import { useWorkspaces } from "../shell/useWorkspaces";
7
+ import { useSessionUserInfo } from "../chrome/user-session";
8
+ import { useNotifications } from "../chrome/notifications-data";
9
+ import { clearSessionUserCache } from "../chrome/user-session";
10
+ import { loadSessionUser } from "./sessionUser";
11
+ import { useSessionUserId } from "./useSessionUserId";
12
+
13
+ let run = 0;
14
+ let reads = 0;
15
+ let status = 200;
16
+ function Consumers() {
17
+ const workspaces = useWorkspaces();
18
+ const { info, loading } = useSessionUserInfo();
19
+ useNotifications();
20
+ const id = useSessionUserId();
21
+ return (
22
+ <div data-testid="session">
23
+ {JSON.stringify({ workspace: workspaces.sessionUser, user: info?.user, loading, id })}
24
+ </div>
25
+ );
26
+ }
27
+ function Tree({ token = "first" }: { token?: string }) {
28
+ return (
29
+ <FrappeProvider
30
+ baseURL={`https://shared-session-${run}.spec.test`}
31
+ auth={{ useToken: true, token, type: "Bearer" }}
32
+ realtime={false}
33
+ >
34
+ <Consumers />
35
+ </FrappeProvider>
36
+ );
37
+ }
38
+ beforeEach(() => {
39
+ run += 1;
40
+ reads = 0;
41
+ status = 200;
42
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
43
+ const url = new URL(String(input instanceof Request ? input.url : input));
44
+ let body: unknown = { data: [], message: {} };
45
+ if (url.pathname.endsWith("frappe.auth.get_logged_user")) {
46
+ reads += 1;
47
+ body = status === 200 ? { message: "reader@example.test" } : { exc_type: "PermissionError" };
48
+ return new Response(JSON.stringify(body), {
49
+ status,
50
+ headers: { "content-type": "application/json" },
51
+ });
52
+ }
53
+ return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
54
+ });
55
+ });
56
+ afterEach(() => {
57
+ cleanup();
58
+ vi.unstubAllGlobals();
59
+ });
60
+ it.each([200, 401, 403])(
61
+ "shares identity across consumers and remounts after %s",
62
+ async (answer) => {
63
+ status = answer;
64
+ const view = renderWithProviders(<Tree />);
65
+ await waitFor(() =>
66
+ expect(screen.getByTestId("session").textContent).toContain('"loading":false'),
67
+ );
68
+ expect(reads).toBe(1);
69
+ view.unmount();
70
+ renderWithProviders(<Tree />);
71
+ await waitFor(() =>
72
+ expect(screen.getByTestId("session").textContent).toContain('"loading":false'),
73
+ );
74
+ expect(reads).toBe(1);
75
+ },
76
+ );
77
+ it("refetches identity when credentials change", async () => {
78
+ const view = renderWithProviders(<Tree />);
79
+ await waitFor(() =>
80
+ expect(screen.getByTestId("session").textContent).toContain('"loading":false'),
81
+ );
82
+ expect(reads).toBe(1);
83
+ view.rerender(<Tree token="second" />);
84
+ await waitFor(() => expect(reads).toBe(2));
85
+ });
86
+
87
+ it("does not retain a failed network request", async () => {
88
+ const request = vi.fn().mockRejectedValue(new TypeError("Network unavailable"));
89
+ vi.stubGlobal("fetch", request);
90
+ const config = { baseURL: `https://network-session-${run}.spec.test` };
91
+ await expect(loadSessionUser(config)).rejects.toThrow("Network unavailable");
92
+ request.mockResolvedValue(
93
+ new Response(JSON.stringify({ message: "reader" }), {
94
+ headers: { "content-type": "application/json" },
95
+ }),
96
+ );
97
+ await expect(loadSessionUser(config)).resolves.toBe("reader");
98
+ });
99
+
100
+ it("clears the shared identity when the user menu clears session caches", async () => {
101
+ const config = { baseURL: `https://logout-session-${run}.spec.test` };
102
+ await loadSessionUser(config);
103
+ clearSessionUserCache();
104
+ await loadSessionUser(config);
105
+ expect(reads).toBe(2);
106
+ });
@@ -0,0 +1,59 @@
1
+ import { HttpClient, type AuthConfig } from "@multiplatform.one/frappe";
2
+ import { useFrappeConfig, type FrappeConfig } from "@multiplatform.one/frappe-ui";
3
+ import { useEffect, useState } from "react";
4
+
5
+ const cache = new Map<string, Promise<string>>();
6
+
7
+ export function sessionUserKey(config: Pick<FrappeConfig, "baseURL" | "auth">): string {
8
+ const auth = config.auth;
9
+ return `${config.baseURL}:${auth?.useToken ? "t" : ""}:${auth?.token ?? ""}:${auth?.type ?? ""}`;
10
+ }
11
+
12
+ export function clearSessionIdentityCache(): void {
13
+ cache.clear();
14
+ }
15
+
16
+ export function loadSessionUser(config: Pick<FrappeConfig, "baseURL" | "auth">): Promise<string> {
17
+ if (!config.baseURL) return Promise.reject(new Error("No session endpoint"));
18
+ const key = sessionUserKey(config);
19
+ let promise = cache.get(key);
20
+ if (!promise) {
21
+ const client = new HttpClient(config.baseURL, config.auth as AuthConfig | undefined);
22
+ promise = client
23
+ .get<{ message?: string }>("/api/method/frappe.auth.get_logged_user")
24
+ .then((response) => response.payload?.message || "Guest")
25
+ .catch((error: unknown) => {
26
+ const status = (error as { status?: number })?.status;
27
+ if (status === 401 || status === 403) return "Guest";
28
+ if (cache.get(key) === promise) cache.delete(key);
29
+ throw error;
30
+ });
31
+ cache.set(key, promise);
32
+ }
33
+ return promise;
34
+ }
35
+
36
+ export function useSessionIdentity(): { user?: string; resolved: boolean } {
37
+ const config = useFrappeConfig();
38
+ const key = config?.baseURL && !config.fixtures ? sessionUserKey(config) : undefined;
39
+ const [state, setState] = useState<{ key?: string; user?: string; resolved: boolean }>({
40
+ resolved: false,
41
+ });
42
+ useEffect(() => {
43
+ if (!key || !config) return;
44
+ let cancelled = false;
45
+ loadSessionUser(config)
46
+ .then((user) => {
47
+ if (!cancelled)
48
+ setState({ key, user: user === "Guest" ? undefined : user, resolved: true });
49
+ })
50
+ .catch(() => {
51
+ if (!cancelled) setState({ key, resolved: true });
52
+ });
53
+ return () => {
54
+ cancelled = true;
55
+ };
56
+ }, [key, config]);
57
+ if (!key) return { resolved: true };
58
+ return state.key === key ? state : { resolved: false };
59
+ }
@@ -1,43 +1,5 @@
1
- // Session user id (for liked-hearts + "assigned to me"), cached per app.
2
- // (The shell's useSessionUser in shell/useWorkspaces.ts is the HttpClient
3
- // variant used by the sidebar; this one rides the FrappeApp auth module.)
4
- import { useFrappeConfig } from "@multiplatform.one/frappe-ui";
5
- import { useEffect, useState } from "react";
6
- import { useFrappeApp } from "./useFrappeApp";
1
+ import { useSessionIdentity } from "./sessionUser";
7
2
 
8
- const userCache = new Map<string, Promise<string>>();
9
-
10
- /**
11
- * The logged-in user's id (`frappe.auth.get_logged_user`), or undefined
12
- * while loading / in fixture mode (no session exists against fixtures).
13
- */
14
3
  export function useSessionUserId(): string | undefined {
15
- const config = useFrappeConfig();
16
- const app = useFrappeApp();
17
- const fixtureMode = Boolean(config?.fixtures);
18
- const [user, setUser] = useState<string | undefined>(undefined);
19
-
20
- useEffect(() => {
21
- const baseURL = config?.baseURL;
22
- if (!app || fixtureMode || !baseURL) return;
23
- let cancelled = false;
24
- let promise = userCache.get(baseURL);
25
- if (!promise) {
26
- promise = app.auth().getLoggedInUser();
27
- userCache.set(baseURL, promise);
28
- promise.catch(() => userCache.delete(baseURL));
29
- }
30
- promise
31
- .then((value) => {
32
- if (!cancelled && value) setUser(value);
33
- })
34
- .catch(() => {
35
- // No session — hearts render unfilled, "me" facets hidden.
36
- });
37
- return () => {
38
- cancelled = true;
39
- };
40
- }, [app, fixtureMode, config?.baseURL]);
41
-
42
- return user;
4
+ return useSessionIdentity().user;
43
5
  }