@multiplatform.one/frappe-ui 7.23.1 → 7.23.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 (149) hide show
  1. package/dist/cjs/FrappeForm.cjs +8 -0
  2. package/dist/cjs/FrappeForm.native.js +9 -0
  3. package/dist/cjs/FrappeForm.native.js.map +1 -1
  4. package/dist/cjs/FrappeListView.cjs +14 -1
  5. package/dist/cjs/FrappeListView.native.js +15 -1
  6. package/dist/cjs/FrappeListView.native.js.map +1 -1
  7. package/dist/cjs/FrappeNotConfigured.cjs +54 -0
  8. package/dist/cjs/FrappeNotConfigured.native.js +58 -0
  9. package/dist/cjs/FrappeNotConfigured.native.js.map +1 -0
  10. package/dist/cjs/FrappeReportView.cjs +1 -1
  11. package/dist/cjs/FrappeReportView.native.js +2 -2
  12. package/dist/cjs/FrappeReportView.native.js.map +1 -1
  13. package/dist/cjs/fields/CodeEditor/index.cjs +1 -1
  14. package/dist/cjs/fields/CodeEditor/index.native.js +1 -1
  15. package/dist/cjs/fields/CodeEditor/index.native.js.map +1 -1
  16. package/dist/cjs/fields/Color/index.cjs +1 -0
  17. package/dist/cjs/fields/Color/index.native.js +1 -0
  18. package/dist/cjs/fields/Color/index.native.js.map +1 -1
  19. package/dist/cjs/fields/FileUpload.cjs +1 -1
  20. package/dist/cjs/fields/FileUpload.native.js +1 -1
  21. package/dist/cjs/fields/FileUpload.native.js.map +1 -1
  22. package/dist/cjs/fields/Heading/index.cjs +1 -1
  23. package/dist/cjs/fields/Heading/index.native.js +1 -1
  24. package/dist/cjs/fields/Heading/index.native.js.map +1 -1
  25. package/dist/cjs/fields/MarkdownEditor/index.cjs +2 -1
  26. package/dist/cjs/fields/MarkdownEditor/index.native.js +1 -1
  27. package/dist/cjs/fields/MarkdownEditor/index.native.js.map +1 -1
  28. package/dist/cjs/useFrappeFacets.cjs +1 -1
  29. package/dist/cjs/useFrappeFacets.native.js +1 -1
  30. package/dist/cjs/useFrappeFacets.native.js.map +1 -1
  31. package/dist/esm/FrappeForm.mjs +8 -0
  32. package/dist/esm/FrappeForm.mjs.map +1 -1
  33. package/dist/esm/FrappeForm.native.js +9 -0
  34. package/dist/esm/FrappeForm.native.js.map +1 -1
  35. package/dist/esm/FrappeListView.mjs +14 -1
  36. package/dist/esm/FrappeListView.mjs.map +1 -1
  37. package/dist/esm/FrappeListView.native.js +15 -1
  38. package/dist/esm/FrappeListView.native.js.map +1 -1
  39. package/dist/esm/FrappeNotConfigured.mjs +29 -0
  40. package/dist/esm/FrappeNotConfigured.mjs.map +1 -0
  41. package/dist/esm/FrappeNotConfigured.native.js +30 -0
  42. package/dist/esm/FrappeNotConfigured.native.js.map +1 -0
  43. package/dist/esm/FrappeReportView.mjs +1 -1
  44. package/dist/esm/FrappeReportView.mjs.map +1 -1
  45. package/dist/esm/FrappeReportView.native.js +2 -2
  46. package/dist/esm/FrappeReportView.native.js.map +1 -1
  47. package/dist/esm/fields/CodeEditor/index.mjs +2 -2
  48. package/dist/esm/fields/CodeEditor/index.mjs.map +1 -1
  49. package/dist/esm/fields/CodeEditor/index.native.js +2 -2
  50. package/dist/esm/fields/CodeEditor/index.native.js.map +1 -1
  51. package/dist/esm/fields/Color/index.mjs +1 -0
  52. package/dist/esm/fields/Color/index.mjs.map +1 -1
  53. package/dist/esm/fields/Color/index.native.js +1 -0
  54. package/dist/esm/fields/Color/index.native.js.map +1 -1
  55. package/dist/esm/fields/FileUpload.mjs +2 -2
  56. package/dist/esm/fields/FileUpload.mjs.map +1 -1
  57. package/dist/esm/fields/FileUpload.native.js +2 -2
  58. package/dist/esm/fields/FileUpload.native.js.map +1 -1
  59. package/dist/esm/fields/Heading/index.mjs +2 -2
  60. package/dist/esm/fields/Heading/index.mjs.map +1 -1
  61. package/dist/esm/fields/Heading/index.native.js +2 -2
  62. package/dist/esm/fields/Heading/index.native.js.map +1 -1
  63. package/dist/esm/fields/MarkdownEditor/index.mjs +2 -1
  64. package/dist/esm/fields/MarkdownEditor/index.mjs.map +1 -1
  65. package/dist/esm/fields/MarkdownEditor/index.native.js +2 -2
  66. package/dist/esm/fields/MarkdownEditor/index.native.js.map +1 -1
  67. package/dist/esm/useFrappeFacets.mjs +1 -1
  68. package/dist/esm/useFrappeFacets.mjs.map +1 -1
  69. package/dist/esm/useFrappeFacets.native.js +1 -1
  70. package/dist/esm/useFrappeFacets.native.js.map +1 -1
  71. package/dist/jsx/FrappeForm.mjs +8 -0
  72. package/dist/jsx/FrappeForm.mjs.map +1 -1
  73. package/dist/jsx/FrappeForm.native.js +9 -0
  74. package/dist/jsx/FrappeForm.native.js.map +1 -1
  75. package/dist/jsx/FrappeListView.mjs +14 -1
  76. package/dist/jsx/FrappeListView.mjs.map +1 -1
  77. package/dist/jsx/FrappeListView.native.js +15 -1
  78. package/dist/jsx/FrappeListView.native.js.map +1 -1
  79. package/dist/jsx/FrappeNotConfigured.mjs +29 -0
  80. package/dist/jsx/FrappeNotConfigured.mjs.map +1 -0
  81. package/dist/jsx/FrappeNotConfigured.native.js +58 -0
  82. package/dist/jsx/FrappeNotConfigured.native.js.map +1 -0
  83. package/dist/jsx/FrappeReportView.mjs +1 -1
  84. package/dist/jsx/FrappeReportView.mjs.map +1 -1
  85. package/dist/jsx/FrappeReportView.native.js +2 -2
  86. package/dist/jsx/FrappeReportView.native.js.map +1 -1
  87. package/dist/jsx/fields/CodeEditor/index.mjs +2 -2
  88. package/dist/jsx/fields/CodeEditor/index.mjs.map +1 -1
  89. package/dist/jsx/fields/CodeEditor/index.native.js +1 -1
  90. package/dist/jsx/fields/CodeEditor/index.native.js.map +1 -1
  91. package/dist/jsx/fields/Color/index.mjs +1 -0
  92. package/dist/jsx/fields/Color/index.mjs.map +1 -1
  93. package/dist/jsx/fields/Color/index.native.js +1 -0
  94. package/dist/jsx/fields/Color/index.native.js.map +1 -1
  95. package/dist/jsx/fields/FileUpload.mjs +2 -2
  96. package/dist/jsx/fields/FileUpload.mjs.map +1 -1
  97. package/dist/jsx/fields/FileUpload.native.js +1 -1
  98. package/dist/jsx/fields/FileUpload.native.js.map +1 -1
  99. package/dist/jsx/fields/Heading/index.mjs +2 -2
  100. package/dist/jsx/fields/Heading/index.mjs.map +1 -1
  101. package/dist/jsx/fields/Heading/index.native.js +1 -1
  102. package/dist/jsx/fields/Heading/index.native.js.map +1 -1
  103. package/dist/jsx/fields/MarkdownEditor/index.mjs +2 -1
  104. package/dist/jsx/fields/MarkdownEditor/index.mjs.map +1 -1
  105. package/dist/jsx/fields/MarkdownEditor/index.native.js +1 -1
  106. package/dist/jsx/fields/MarkdownEditor/index.native.js.map +1 -1
  107. package/dist/jsx/useFrappeFacets.mjs +1 -1
  108. package/dist/jsx/useFrappeFacets.mjs.map +1 -1
  109. package/dist/jsx/useFrappeFacets.native.js +1 -1
  110. package/dist/jsx/useFrappeFacets.native.js.map +1 -1
  111. package/package.json +16 -16
  112. package/src/FormSidebar.stories.tsx +2 -0
  113. package/src/FrappeCalendar.stories.tsx +2 -0
  114. package/src/FrappeChartCard.stories.tsx +2 -0
  115. package/src/FrappeDashboard.stories.tsx +2 -0
  116. package/src/FrappeForm.tsx +7 -1
  117. package/src/FrappeImageView.stories.tsx +2 -0
  118. package/src/FrappeKPICard.stories.tsx +3 -3
  119. package/src/FrappeKanban.stories.tsx +4 -8
  120. package/src/FrappeListView.stories.tsx +2 -0
  121. package/src/FrappeListView.tsx +14 -1
  122. package/src/FrappeNotConfigured.tsx +29 -0
  123. package/src/FrappeReportView.tsx +1 -1
  124. package/src/clientCache.auth.spec.tsx +94 -0
  125. package/src/desk/DeskFormSidebar.stories.tsx +2 -0
  126. package/src/desk/DeskShell.stories.tsx +12 -6
  127. package/src/fields/CodeEditor/index.tsx +8 -2
  128. package/src/fields/Color/Color.stories.tsx +2 -0
  129. package/src/fields/Color/index.tsx +1 -0
  130. package/src/fields/FileUpload.tsx +8 -2
  131. package/src/fields/Heading/index.tsx +8 -2
  132. package/src/fields/MarkdownEditor/index.native.tsx +8 -2
  133. package/src/fields/MarkdownEditor/index.tsx +8 -1
  134. package/src/fields/ReadOnly/ReadOnly.stories.tsx +2 -0
  135. package/src/frappeRoutes.notConfigured.spec.tsx +77 -0
  136. package/src/useFrappeFacets.ts +14 -7
  137. package/types/FrappeForm.d.ts +2 -1
  138. package/types/FrappeForm.d.ts.map +1 -1
  139. package/types/FrappeListView.d.ts +3 -1
  140. package/types/FrappeListView.d.ts.map +1 -1
  141. package/types/FrappeNotConfigured.d.ts +11 -0
  142. package/types/FrappeNotConfigured.d.ts.map +1 -0
  143. package/types/fields/CodeEditor/index.d.ts.map +1 -1
  144. package/types/fields/Color/index.d.ts.map +1 -1
  145. package/types/fields/FileUpload.d.ts.map +1 -1
  146. package/types/fields/Heading/index.d.ts.map +1 -1
  147. package/types/fields/MarkdownEditor/index.d.ts.map +1 -1
  148. package/types/fields/MarkdownEditor/index.native.d.ts.map +1 -1
  149. package/types/useFrappeFacets.d.ts.map +1 -1
@@ -29,6 +29,8 @@ import { FrappeForm } from "./FrappeForm";
29
29
  import { FrappeProvider } from "./FrappeProvider";
30
30
  import { FrappeStoryProvider, LiveToDoGate, frappeStoriesLive } from "./frappeStoryTarget";
31
31
 
32
+ // hex-escape: generated image fixtures are data, not paint
33
+
32
34
  export default {
33
35
  title: "FrappeUI/FormSidebar",
34
36
  component: FormSidebar,
@@ -6,6 +6,8 @@ import { FrappeCalendar } from "./FrappeCalendar";
6
6
  import { FrappeProvider } from "./FrappeProvider";
7
7
  import { FrappeStoryProvider } from "./frappeStoryTarget";
8
8
 
9
+ // hex-escape: Frappe sends column, event and series colours as hex: fixture data, not paint
10
+
9
11
  export default {
10
12
  title: "FrappeUI/FrappeCalendar",
11
13
  component: FrappeCalendar,
@@ -5,6 +5,8 @@ import { FrappeChartCard } from "./FrappeChartCard";
5
5
  import { FrappeProvider } from "./FrappeProvider";
6
6
  import { FrappeStoryProvider } from "./frappeStoryTarget";
7
7
 
8
+ // hex-escape: Frappe sends column, event and series colours as hex: fixture data, not paint
9
+
8
10
  export default {
9
11
  title: "FrappeUI/FrappeChartCard",
10
12
  component: ChartCard,
@@ -5,6 +5,8 @@ import { FrappeDashboard } from "./FrappeDashboard";
5
5
  import { FrappeProvider } from "./FrappeProvider";
6
6
  import { FrappeStoryProvider } from "./frappeStoryTarget";
7
7
 
8
+ // hex-escape: Frappe sends column, event and series colours as hex: fixture data, not paint
9
+
8
10
  export default {
9
11
  title: "FrappeUI/FrappeDashboard",
10
12
  component: FrappeDashboard,
@@ -52,6 +52,7 @@ import { extractServerError } from "./formEngine/useFormEngine";
52
52
  import { FrappeFormActions } from "./FrappeFormActions";
53
53
  import { connectionFields, connectionsFromMeta, type FrappeConnection } from "./frappeConnections";
54
54
  import { FrappeFormConnections } from "./FrappeFormConnections";
55
+ import { FrappeNotConfigured } from "./FrappeNotConfigured";
55
56
  import { FrappeRowsReader, type FrappeRowsSnapshot } from "./FrappeRowsReader";
56
57
  import {
57
58
  frappeRouteHref,
@@ -377,12 +378,17 @@ export function mapDocToSidebarMetadata(
377
378
  * `buildFormTabs` (tabs, sections, `depends_on`, `mandatory_depends_on`,
378
379
  * `read_only_depends_on`, `collapsible_depends_on`, permlevel,
379
380
  * `set_only_once`, `fetch_from` and docstatus), and renders each tab with
380
- * `SchemaForm`. With a `route` it is the doctype's record page.
381
+ * `SchemaForm`. With a `route` it is the doctype's record page. With neither
382
+ * a bench URL nor a fixture corpus it says Frappe is not configured.
381
383
  */
382
384
  export function FrappeForm<TDoc extends { name: string; doctype: string }>(
383
385
  props: FrappeFormProps<TDoc>,
384
386
  ) {
387
+ const config = useFrappeConfig();
385
388
  const { route, ...formProps } = props;
389
+ if (!(props.baseURL ?? config?.baseURL) && !config?.fixtures) {
390
+ return <FrappeNotConfigured doctype={props.doctype} page={route !== undefined} />;
391
+ }
386
392
  if (route === undefined) return <FrappeFormBody<TDoc> {...formProps} />;
387
393
  return <FrappeFormRoute<TDoc> {...formProps} route={route} />;
388
394
  }
@@ -4,6 +4,8 @@ import { FrappeImageView } from "./FrappeImageView";
4
4
  import { FrappeProvider } from "./FrappeProvider";
5
5
  import { FrappeStoryProvider } from "./frappeStoryTarget";
6
6
 
7
+ // hex-escape: generated image fixtures are data, not paint
8
+
7
9
  export default {
8
10
  title: "FrappeUI/FrappeImageView",
9
11
  component: FrappeImageView,
@@ -4,6 +4,7 @@ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
4
4
  // catalog names it does not define).
5
5
  import { KPICard } from "@multiplatform.one/components";
6
6
  import { action } from "@multiplatform.one/storybook";
7
+ import { sizeRecipeEscape } from "@multiplatform.one/theme";
7
8
  import { CurrencyDollarIcon } from "@phosphor-icons/react";
8
9
  import { XStack } from "tamagui";
9
10
  import { FrappeKPICard } from "./FrappeKPICard";
@@ -81,8 +82,7 @@ function jsonResponse(body: unknown, status = 200): Response {
81
82
  return jsonResponse({ docs: [todoMetaRaw(decodeURIComponent(doctype))] });
82
83
  }
83
84
  const metaMatch = url.pathname.match(/^\/api\/resource\/DocType\/(.+)$/);
84
- if (metaMatch)
85
- return jsonResponse({ data: todoMetaRaw(decodeURIComponent(metaMatch[1])) });
85
+ if (metaMatch) return jsonResponse({ data: todoMetaRaw(decodeURIComponent(metaMatch[1])) });
86
86
  if (url.pathname === "/api/resource/DocType") {
87
87
  return jsonResponse({ data: [{ name: "ToDo", module: "Desk" }] });
88
88
  }
@@ -201,7 +201,7 @@ export const Clickable = () => (
201
201
 
202
202
  export const LongTitle = () => (
203
203
  <KPICard
204
- width={220}
204
+ width={sizeRecipeEscape(220, "story fixture: a card narrow enough to truncate the long title")}
205
205
  config={{
206
206
  id: "long",
207
207
  title:
@@ -5,6 +5,8 @@ import { FrappeKanban, type KanbanColumn } from "./FrappeKanban";
5
5
  import { FrappeProvider } from "./FrappeProvider";
6
6
  import { FrappeStoryProvider } from "./frappeStoryTarget";
7
7
 
8
+ // hex-escape: Frappe sends column, event and series colours as hex: fixture data, not paint
9
+
8
10
  export default {
9
11
  title: "FrappeUI/FrappeKanban",
10
12
  component: FrappeKanban,
@@ -310,10 +312,7 @@ const bugColumns: KanbanColumn[] = [
310
312
  ];
311
313
 
312
314
  export const BugTracker = () => (
313
- <FrappeProvider
314
- baseURL={MOCK_HOST}
315
- fixtures={new InMemoryFixtureProvider({ Bug: bugFixtures })}
316
- >
315
+ <FrappeProvider baseURL={MOCK_HOST} fixtures={new InMemoryFixtureProvider({ Bug: bugFixtures })}>
317
316
  <FrappeKanban<Bug>
318
317
  doctype="Bug"
319
318
  columns={bugColumns}
@@ -360,10 +359,7 @@ export const Loading = () => (
360
359
 
361
360
  /** Zero documents, no failure — configured columns render with per-column "No items" (empty ≠ error). */
362
361
  export const Empty = () => (
363
- <FrappeProvider
364
- baseURL={MOCK_HOST}
365
- fixtures={new InMemoryFixtureProvider({ ToDo: [] })}
366
- >
362
+ <FrappeProvider baseURL={MOCK_HOST} fixtures={new InMemoryFixtureProvider({ ToDo: [] })}>
367
363
  <FrappeKanban<ToDo>
368
364
  doctype="ToDo"
369
365
  columns={todoColumns}
@@ -4,6 +4,8 @@ import { FrappeListView } from "./FrappeListView";
4
4
  import { FrappeProvider } from "./FrappeProvider";
5
5
  import { FrappeStoryProvider } from "./frappeStoryTarget";
6
6
 
7
+ // hex-escape: generated image fixtures are data, not paint
8
+
7
9
  export default {
8
10
  title: "FrappeUI/FrappeListView",
9
11
  component: FrappeListView,
@@ -19,6 +19,7 @@ import { pressTargetStyle, useResolvedKnobs } from "@multiplatform.one/theme";
19
19
  import { storage } from "@multiplatform.one/store";
20
20
  import { useFrappeConfig } from "./FrappeProvider";
21
21
  import { FrappeList, type FrappeListProps } from "./FrappeList";
22
+ import { FrappeNotConfigured } from "./FrappeNotConfigured";
22
23
  import { FrappeTable } from "./FrappeTable";
23
24
  import { useDocRights } from "./formEngine/useDocRights";
24
25
  import { frappeRouteHref, useFrappeRouter } from "./frappeNavigation";
@@ -170,8 +171,20 @@ async function setStoredViewMode(doctype: string, mode: ListViewMode): Promise<v
170
171
  *
171
172
  * Delegates to `FrappeList` (which delegates to generic `List`) or
172
173
  * `FrappeTable` (which delegates to `DataTable`) based on the view mode.
174
+ * With neither a bench URL nor a fixture corpus it says Frappe is not
175
+ * configured.
173
176
  */
174
- export function FrappeListView<TDoc extends { name: string; doctype: string }>({
177
+ export function FrappeListView<TDoc extends { name: string; doctype: string }>(
178
+ props: FrappeListViewProps<TDoc>,
179
+ ) {
180
+ const config = useFrappeConfig();
181
+ if (!(props.baseURL ?? config?.baseURL) && !config?.fixtures) {
182
+ return <FrappeNotConfigured doctype={props.doctype} page={props.route !== undefined} />;
183
+ }
184
+ return <FrappeListViewBody<TDoc> {...props} />;
185
+ }
186
+
187
+ function FrappeListViewBody<TDoc extends { name: string; doctype: string }>({
175
188
  doctype,
176
189
  fields,
177
190
  connections,
@@ -0,0 +1,29 @@
1
+ import { EmptyState, ListPageLayout, PageHeader } from "@multiplatform.one/components";
2
+ import { useTranslation } from "react-i18next";
3
+
4
+ export interface FrappeNotConfiguredProps {
5
+ doctype: string;
6
+ /** A routed page keeps the page template, the doctype as its title. */
7
+ page?: boolean;
8
+ }
9
+
10
+ /**
11
+ * What a doctype's list or form shows when neither a bench URL nor a fixture
12
+ * corpus is configured (a host built with Frappe off), in place of the view.
13
+ */
14
+ export function FrappeNotConfigured({ doctype, page = false }: FrappeNotConfiguredProps) {
15
+ const { t } = useTranslation();
16
+ const state = (
17
+ <EmptyState
18
+ testID="frappe-not-configured"
19
+ title={t("Frappe is not configured")}
20
+ description={t("{{doctype}} can't load without a Frappe bench URL.", { doctype })}
21
+ />
22
+ );
23
+ if (!page) return state;
24
+ return (
25
+ <ListPageLayout scroll={false} header={<PageHeader title={doctype} divider={false} />}>
26
+ {state}
27
+ </ListPageLayout>
28
+ );
29
+ }
@@ -349,7 +349,7 @@ export interface FrappeReportConnection {
349
349
  const callCache = new Map<string, CallModule>();
350
350
 
351
351
  function getCall(connection: FrappeReportConnection): CallModule {
352
- const key = `${connection.baseURL}:${connection.auth?.token ?? ""}:${connection.auth?.type ?? ""}`;
352
+ const key = `${connection.baseURL}:${connection.auth?.useToken ? "t" : ""}:${connection.auth?.token ?? ""}:${connection.auth?.type ?? ""}`;
353
353
  let call = callCache.get(key);
354
354
  if (!call) {
355
355
  const authConfig: AuthConfig | undefined = connection.auth
@@ -0,0 +1,94 @@
1
+ // Each hook here caches its REST client per connection. Two connections to
2
+ // one bench that differ only in useToken must not share a client, or the
3
+ // first one built decides whether the other sends its token (MPO-399,
4
+ // MPO-406). The bench answers a tokenless call as Guest.
5
+ import { renderHook, waitFor } from "@testing-library/react";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { useFrappeReport } from "./FrappeReportView";
8
+ import { useFrappeAggregate } from "./useFrappeAggregate";
9
+ import { useFrappeFacets } from "./useFrappeFacets";
10
+
11
+ const token = "token key:secret";
12
+ let sent: (string | null)[] = [];
13
+
14
+ function reply(url: URL): unknown {
15
+ const method = url.pathname.replace(/^\/api\/method\//, "");
16
+ if (method === "frappe.client.get_count") return { message: 3 };
17
+ if (method === "frappe.desk.reportview.get_sidebar_stats") return { message: { stats: {} } };
18
+ if (method === "frappe.desk.query_report.run") return { message: { columns: [], result: [] } };
19
+ return { message: [] };
20
+ }
21
+
22
+ beforeEach(() => {
23
+ sent = [];
24
+ vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => {
25
+ sent.push(new Headers(init?.headers).get("Authorization"));
26
+ return new Response(JSON.stringify(reply(new URL(String(input)))), {
27
+ headers: { "content-type": "application/json" },
28
+ });
29
+ });
30
+ });
31
+
32
+ afterEach(() => vi.unstubAllGlobals());
33
+
34
+ function connection(baseURL: string, useToken: boolean) {
35
+ return { baseURL, realtime: false, auth: { useToken, token: "key:secret", type: "token" } };
36
+ }
37
+
38
+ async function authorizationPerConnection(
39
+ mount: (useToken: boolean) => () => boolean,
40
+ ): Promise<{ off: (string | null)[]; on: (string | null)[] }> {
41
+ const offSettled = mount(false);
42
+ await waitFor(() => expect(offSettled()).toBe(true));
43
+ const off = sent.splice(0);
44
+ const onSettled = mount(true);
45
+ await waitFor(() => expect(onSettled()).toBe(true));
46
+ return { off, on: sent.splice(0) };
47
+ }
48
+
49
+ describe("cached REST clients never drop a connection's token", () => {
50
+ it("useFrappeAggregate", async () => {
51
+ const { off, on } = await authorizationPerConnection((useToken) => {
52
+ const { result } = renderHook(() =>
53
+ useFrappeAggregate(connection("http://aggregate-auth.test", useToken), {
54
+ doctype: "ToDo",
55
+ }),
56
+ );
57
+ return () => !result.current.isLoading;
58
+ });
59
+ expect(off).toEqual([null]);
60
+ expect(on).toEqual([token]);
61
+ });
62
+
63
+ it("useFrappeFacets", async () => {
64
+ const { off, on } = await authorizationPerConnection((useToken) => {
65
+ const { result } = renderHook(() =>
66
+ useFrappeFacets("ToDo", [], {
67
+ connection: connection("http://facets-auth.test", useToken),
68
+ }),
69
+ );
70
+ return () =>
71
+ !result.current.assigned.isLoading &&
72
+ !result.current.owner.isLoading &&
73
+ !result.current.tags.isLoading;
74
+ });
75
+ expect(off.length).toBeGreaterThan(0);
76
+ expect(new Set(off)).toEqual(new Set([null]));
77
+ expect(on.length).toBe(off.length);
78
+ expect(new Set(on)).toEqual(new Set([token]));
79
+ });
80
+
81
+ it("useFrappeReport (FrappeReportView)", async () => {
82
+ const { off, on } = await authorizationPerConnection((useToken) => {
83
+ const { result } = renderHook(() =>
84
+ useFrappeReport(connection("http://report-auth.test", useToken), {
85
+ reportName: "ToDo Summary",
86
+ debounceMs: 0,
87
+ }),
88
+ );
89
+ return () => !result.current.isLoading;
90
+ });
91
+ expect(off).toEqual([null]);
92
+ expect(on).toEqual([token]);
93
+ });
94
+ });
@@ -3,6 +3,8 @@ import { YStack } from "tamagui";
3
3
  import { FrappeProvider } from "../FrappeProvider";
4
4
  import { DeskFormSidebar } from "./DeskFormSidebar";
5
5
 
6
+ // hex-escape: generated image fixtures are data, not paint
7
+
6
8
  export default {
7
9
  title: "FrappeUI/Desk/DeskFormSidebar",
8
10
  component: DeskFormSidebar,
@@ -1,9 +1,15 @@
1
1
  import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
2
2
  import { action } from "@multiplatform.one/storybook";
3
3
  import { Text, YStack } from "@multiplatform.one/components";
4
+ import { sizeRecipeEscape } from "@multiplatform.one/theme";
4
5
  import { FrappeProvider } from "../FrappeProvider";
5
6
  import { DeskShell } from "./DeskShell";
6
7
 
8
+ const frameHeight = sizeRecipeEscape(
9
+ 640,
10
+ "story canvas: a fixed-height frame for a full-height screen to fill",
11
+ );
12
+
7
13
  export default {
8
14
  title: "FrappeUI/Desk/DeskShell",
9
15
  component: DeskShell,
@@ -85,7 +91,7 @@ const content = (blocks: number) => (
85
91
 
86
92
  export const Main = (args: Record<string, unknown>) => (
87
93
  <FrappeProvider baseURL={MOCK_HOST} fixtures={new InMemoryFixtureProvider({})}>
88
- <YStack height={640}>
94
+ <YStack height={frameHeight}>
89
95
  <DeskShell
90
96
  breadcrumbs={[{ label: "Desk", href: "/desk" }, { label: "Todo" }]}
91
97
  onNavigate={action("onNavigate")}
@@ -102,7 +108,7 @@ Main.storyName = "Main";
102
108
 
103
109
  export const NoSidebar = () => (
104
110
  <FrappeProvider baseURL={MOCK_HOST} fixtures={new InMemoryFixtureProvider({})}>
105
- <YStack height={640}>
111
+ <YStack height={frameHeight}>
106
112
  <DeskShell showSidebar={false} onNavigate={action("onNavigate")}>
107
113
  {content(2)}
108
114
  </DeskShell>
@@ -112,7 +118,7 @@ export const NoSidebar = () => (
112
118
 
113
119
  export const NoAwesomebar = () => (
114
120
  <FrappeProvider baseURL={MOCK_HOST} fixtures={new InMemoryFixtureProvider({})}>
115
- <YStack height={640}>
121
+ <YStack height={frameHeight}>
116
122
  <DeskShell showAwesomebar={false} onNavigate={action("onNavigate")}>
117
123
  {content(2)}
118
124
  </DeskShell>
@@ -123,7 +129,7 @@ export const NoAwesomebar = () => (
123
129
  /** Long page content must scroll inside the main area, header staying fixed. */
124
130
  export const LongContent = () => (
125
131
  <FrappeProvider baseURL={MOCK_HOST} fixtures={new InMemoryFixtureProvider({})}>
126
- <YStack height={640}>
132
+ <YStack height={frameHeight}>
127
133
  <DeskShell
128
134
  breadcrumbs={[{ label: "Desk", href: "/desk" }, { label: "Long" }]}
129
135
  onNavigate={action("onNavigate")}
@@ -136,7 +142,7 @@ export const LongContent = () => (
136
142
 
137
143
  /** No breadcrumbs, no provider config: shell must still render header + nav + content. */
138
144
  export const Bare = () => (
139
- <YStack height={640}>
145
+ <YStack height={frameHeight}>
140
146
  <DeskShell onNavigate={action("onNavigate")}>{content(1)}</DeskShell>
141
147
  </YStack>
142
148
  );
@@ -144,7 +150,7 @@ export const Bare = () => (
144
150
  /** Compact viewport: rail collapses to a phosphor toggle + overlay drawer. */
145
151
  export const Narrow = () => (
146
152
  <FrappeProvider baseURL={MOCK_HOST} fixtures={new InMemoryFixtureProvider({})}>
147
- <YStack height={640}>
153
+ <YStack height={frameHeight}>
148
154
  <DeskShell
149
155
  breadcrumbs={[{ label: "Desk", href: "/desk" }, { label: "Todo" }]}
150
156
  onNavigate={action("onNavigate")}
@@ -29,7 +29,7 @@ import {
29
29
  useResolvedValidators,
30
30
  type AnyFormApi,
31
31
  } from "@multiplatform.one/forms";
32
- import { useResolvedKnobs } from "@multiplatform.one/theme";
32
+ import { sizeRecipeForToken, useResolvedKnobs } from "@multiplatform.one/theme";
33
33
  import { useEffect, useState, type ReactNode } from "react";
34
34
  import { useTranslation } from "react-i18next";
35
35
  import type { FontSizeTokens, LabelProps, SizeTokens } from "tamagui";
@@ -183,7 +183,13 @@ export function CodeEditorField({
183
183
  if (skeleton) {
184
184
  return (
185
185
  <YStack {...knobProps.gap} width="100%">
186
- {label && <Skeleton variant="text" width="30%" height={14} />}
186
+ {label && (
187
+ <Skeleton
188
+ variant="text"
189
+ width="30%"
190
+ height={sizeRecipeForToken(knobProps.sizeToken).fontSize}
191
+ />
192
+ )}
187
193
  <Skeleton variant="rounded" width="100%" height={compact ? 120 : 200} />
188
194
  </YStack>
189
195
  );
@@ -6,6 +6,8 @@ import { fieldtypeToTableField } from "../../fieldRegistry";
6
6
  import { FrappeStoryProvider } from "../../frappeStoryTarget";
7
7
  import { Color, type ColorProps } from ".";
8
8
 
9
+ // hex-escape: Color field values are hex strings: specimen data, not paint
10
+
9
11
  export default {
10
12
  title: "FrappeUI/Color",
11
13
  component: Color,
@@ -62,6 +62,7 @@ export function Color<
62
62
  validators,
63
63
  disabled,
64
64
  value,
65
+ // hex-escape: a Color field stores hex; the placeholder shows that format and paints nothing
65
66
  placeholder = "#000000",
66
67
  _showPresets = true,
67
68
  presetColors,
@@ -1,7 +1,7 @@
1
1
  import { useFrappeCollection } from "@multiplatform.one/frappe";
2
2
  import { Skeleton } from "@multiplatform.one/forms";
3
3
  import { FileIcon, UploadIcon, XIcon } from "@phosphor-icons/react";
4
- import { useResolvedKnobs } from "@multiplatform.one/theme";
4
+ import { sizeRecipeForToken, useResolvedKnobs } from "@multiplatform.one/theme";
5
5
  import type React from "react";
6
6
  import { useCallback, useEffect, useRef, useState } from "react";
7
7
  import { useTranslation } from "react-i18next";
@@ -276,7 +276,13 @@ export function FileUpload({
276
276
  if (skeleton) {
277
277
  return (
278
278
  <YStack {...knobProps.gap}>
279
- {label && <Skeleton variant="text" width="30%" height={14} />}
279
+ {label && (
280
+ <Skeleton
281
+ variant="text"
282
+ width="30%"
283
+ height={sizeRecipeForToken(knobProps.sizeToken).fontSize}
284
+ />
285
+ )}
280
286
  <Skeleton variant="rounded" width="100%" height={minHeight} />
281
287
  </YStack>
282
288
  );
@@ -1,6 +1,6 @@
1
1
  import { useRegisterPageHeading } from "@multiplatform.one/components";
2
2
  import { Skeleton } from "@multiplatform.one/forms";
3
- import { useResolvedKnobs } from "@multiplatform.one/theme";
3
+ import { sizeRecipeEscape, useResolvedKnobs } from "@multiplatform.one/theme";
4
4
  import type React from "react";
5
5
  import { useCallback, useEffect, useId, useState } from "react";
6
6
  import { H1, H2, H3, H4, Separator, Text, XStack, YStack, type YStackProps } from "tamagui";
@@ -105,7 +105,13 @@ export function Heading({
105
105
  <YStack {...knobProps.gap} {...stackProps}>
106
106
  <Skeleton variant="text" width="45%" height={compact ? 18 : 24} />
107
107
  <Skeleton variant="text" width="70%" height={compact ? 12 : 14} />
108
- {showSeparator && <Skeleton variant="rounded" width="100%" height={1} />}
108
+ {showSeparator && (
109
+ <Skeleton
110
+ variant="rounded"
111
+ width="100%"
112
+ height={sizeRecipeEscape(1, "separator bone: the 1px rule a Separator draws")}
113
+ />
114
+ )}
109
115
  </YStack>
110
116
  );
111
117
  }
@@ -31,7 +31,7 @@ import { useEffect, useState, type ComponentType, type ReactNode } from "react";
31
31
  import { useTranslation } from "react-i18next";
32
32
  import type { FontSizeTokens, LabelProps, SizeTokens } from "tamagui";
33
33
  import { Paragraph, YStack } from "tamagui";
34
- import { useResolvedKnobs } from "@multiplatform.one/theme";
34
+ import { sizeRecipeForToken, useResolvedKnobs } from "@multiplatform.one/theme";
35
35
  import { EditorPreviewPanes, PreviewSurface } from "../shared/EditorPreviewPanes";
36
36
 
37
37
  // ---------------------------------------------------------------------------
@@ -176,7 +176,13 @@ export function MarkdownEditorField({
176
176
  if (skeleton) {
177
177
  return (
178
178
  <YStack {...knobProps.gap} width="100%">
179
- {label && <Skeleton variant="text" width="30%" height={14} />}
179
+ {label && (
180
+ <Skeleton
181
+ variant="text"
182
+ width="30%"
183
+ height={sizeRecipeForToken(knobProps.sizeToken).fontSize}
184
+ />
185
+ )}
180
186
  <Skeleton variant="rounded" width="100%" height={compact ? 120 : 200} />
181
187
  </YStack>
182
188
  );
@@ -27,6 +27,7 @@ import {
27
27
  type AnyFormApi,
28
28
  } from "@multiplatform.one/forms";
29
29
  import { RichEditor, type RichEditorRef } from "@multiplatform.one/rich-text";
30
+ import { sizeRecipeForToken } from "@multiplatform.one/theme";
30
31
  import { useEffect, useRef, useState, type ReactNode } from "react";
31
32
  import type { LabelProps, SizeTokens } from "tamagui";
32
33
  import { YStack } from "tamagui";
@@ -111,7 +112,13 @@ export function MarkdownEditorField({
111
112
  if (skeleton) {
112
113
  return (
113
114
  <YStack {...knobProps.gap} width="100%">
114
- {label && <Skeleton variant="text" width="30%" height={14} />}
115
+ {label && (
116
+ <Skeleton
117
+ variant="text"
118
+ width="30%"
119
+ height={sizeRecipeForToken(knobProps.sizeToken).fontSize}
120
+ />
121
+ )}
115
122
  <Skeleton variant="rounded" width="100%" height={compact ? 120 : 200} />
116
123
  </YStack>
117
124
  );
@@ -4,6 +4,8 @@ import { fieldtypeToTableField } from "../../fieldRegistry";
4
4
  import { FrappeStoryProvider } from "../../frappeStoryTarget";
5
5
  import { ReadOnly } from ".";
6
6
 
7
+ // hex-escape: Color field values are hex strings: specimen data, not paint
8
+
7
9
  export default {
8
10
  title: "FrappeUI/ReadOnly",
9
11
  component: ReadOnly,
@@ -0,0 +1,77 @@
1
+ // A host built with Frappe off (MPO-403): createApp mounts no FrappeProvider
2
+ // without FRAPPE_ENABLED, so a doctype's routed pages find no bench URL and no
3
+ // fixture corpus. They say the backend isn't configured instead of throwing
4
+ // and taking the route down, and they dial nothing.
5
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
6
+ import { cleanup, screen } from "@testing-library/react";
7
+ import i18next from "i18next";
8
+ import { initReactI18next } from "react-i18next";
9
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { FrappeForm } from "./FrappeForm";
11
+ import { FrappeListView } from "./FrappeListView";
12
+ import { FrappeProvider } from "./FrappeProvider";
13
+
14
+ interface Move {
15
+ name: string;
16
+ doctype: string;
17
+ move_name?: string;
18
+ }
19
+
20
+ const doctype = "Poke Move";
21
+ const route = "/moves";
22
+
23
+ let fetchSpy: ReturnType<typeof vi.fn>;
24
+
25
+ beforeAll(async () => {
26
+ await i18next.use(initReactI18next).init({
27
+ lng: "en",
28
+ interpolation: { escapeValue: false },
29
+ resources: { en: { translation: {} } },
30
+ });
31
+ });
32
+
33
+ beforeEach(() => {
34
+ fetchSpy = vi.fn(async () => {
35
+ throw new Error("a page with no bench dialled one");
36
+ });
37
+ vi.stubGlobal("fetch", fetchSpy);
38
+ });
39
+
40
+ afterEach(() => {
41
+ cleanup();
42
+ vi.restoreAllMocks();
43
+ vi.unstubAllGlobals();
44
+ });
45
+
46
+ function expectNotConfigured() {
47
+ expect(screen.getByTestId("frappe-not-configured")).toBeTruthy();
48
+ expect(screen.getByText("Frappe is not configured")).toBeTruthy();
49
+ expect(fetchSpy).not.toHaveBeenCalled();
50
+ }
51
+
52
+ describe("a doctype's pages on a host with no Frappe backend", () => {
53
+ it("the new page says the backend isn't configured", () => {
54
+ renderWithProviders(<FrappeForm<Move> doctype={doctype} route={route} />);
55
+ expectNotConfigured();
56
+ expect(screen.getByText(`${doctype} can't load without a Frappe bench URL.`)).toBeTruthy();
57
+ });
58
+
59
+ it("a record page says the backend isn't configured", () => {
60
+ renderWithProviders(<FrappeForm<Move> doctype={doctype} name="a1b2c3" />);
61
+ expectNotConfigured();
62
+ });
63
+
64
+ it("the list page says the backend isn't configured", () => {
65
+ renderWithProviders(<FrappeListView<Move> doctype={doctype} route={route} />);
66
+ expectNotConfigured();
67
+ });
68
+
69
+ it("a provider that resolved no bench URL reads the same", () => {
70
+ renderWithProviders(
71
+ <FrappeProvider realtime={false}>
72
+ <FrappeForm<Move> doctype={doctype} route={route} />
73
+ </FrappeProvider>,
74
+ );
75
+ expectNotConfigured();
76
+ });
77
+ });