@multiplatform.one/backoffice 7.23.2 → 7.24.0

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 (205) hide show
  1. package/dist/cjs/chrome/awesomebar-data.cjs +4 -2
  2. package/dist/cjs/chrome/awesomebar-data.native.js +7 -4
  3. package/dist/cjs/chrome/awesomebar-data.native.js.map +1 -1
  4. package/dist/cjs/chrome/help-about-dialog.cjs +2 -2
  5. package/dist/cjs/chrome/help-about-dialog.native.js +2 -2
  6. package/dist/cjs/chrome/help-shortcuts-dialog.cjs +2 -2
  7. package/dist/cjs/chrome/help-shortcuts-dialog.native.js +2 -2
  8. package/dist/cjs/chrome/notifications-panel.cjs +8 -6
  9. package/dist/cjs/chrome/notifications-panel.native.js +8 -6
  10. package/dist/cjs/chrome/notifications-panel.native.js.map +1 -1
  11. package/dist/cjs/form/DocFormScreen.cjs +43 -43
  12. package/dist/cjs/form/DocFormScreen.native.js +43 -43
  13. package/dist/cjs/form/DocFormScreen.native.js.map +1 -1
  14. package/dist/cjs/form/sidebar-people.cjs +5 -2
  15. package/dist/cjs/form/sidebar-people.native.js +5 -2
  16. package/dist/cjs/form/sidebar-people.native.js.map +1 -1
  17. package/dist/cjs/i18n/en.cjs +1 -1
  18. package/dist/cjs/i18n/en.native.js +1 -1
  19. package/dist/cjs/i18n/en.native.js.map +1 -1
  20. package/dist/cjs/list/ListBulkActionsBar.cjs +1 -1
  21. package/dist/cjs/list/ListBulkActionsBar.native.js +1 -2
  22. package/dist/cjs/list/ListBulkActionsBar.native.js.map +1 -1
  23. package/dist/cjs/shell/WorkspaceSidebar.cjs +9 -7
  24. package/dist/cjs/shell/WorkspaceSidebar.native.js +9 -7
  25. package/dist/cjs/shell/WorkspaceSidebar.native.js.map +1 -1
  26. package/dist/cjs/views/CalendarView.cjs +5 -1
  27. package/dist/cjs/views/CalendarView.native.js +7 -2
  28. package/dist/cjs/views/CalendarView.native.js.map +1 -1
  29. package/dist/cjs/views/DashboardView.cjs +9 -3
  30. package/dist/cjs/views/DashboardView.native.js +9 -3
  31. package/dist/cjs/views/DashboardView.native.js.map +1 -1
  32. package/dist/cjs/views/KanbanView.cjs +5 -2
  33. package/dist/cjs/views/KanbanView.native.js +7 -3
  34. package/dist/cjs/views/KanbanView.native.js.map +1 -1
  35. package/dist/cjs/views/ReportView.cjs +8 -5
  36. package/dist/cjs/views/ReportView.native.js +11 -6
  37. package/dist/cjs/views/ReportView.native.js.map +1 -1
  38. package/dist/cjs/views/ViewHeader.cjs +8 -1
  39. package/dist/cjs/views/ViewHeader.native.js +8 -1
  40. package/dist/cjs/views/ViewHeader.native.js.map +1 -1
  41. package/dist/cjs/views/dashboard-data.cjs +17 -11
  42. package/dist/cjs/views/dashboard-data.native.js +20 -13
  43. package/dist/cjs/views/dashboard-data.native.js.map +1 -1
  44. package/dist/esm/chrome/awesomebar-data.mjs +4 -2
  45. package/dist/esm/chrome/awesomebar-data.mjs.map +1 -1
  46. package/dist/esm/chrome/awesomebar-data.native.js +7 -4
  47. package/dist/esm/chrome/awesomebar-data.native.js.map +1 -1
  48. package/dist/esm/chrome/help-about-dialog.mjs +2 -2
  49. package/dist/esm/chrome/help-about-dialog.mjs.map +1 -1
  50. package/dist/esm/chrome/help-about-dialog.native.js +2 -2
  51. package/dist/esm/chrome/help-about-dialog.native.js.map +1 -1
  52. package/dist/esm/chrome/help-shortcuts-dialog.mjs +2 -2
  53. package/dist/esm/chrome/help-shortcuts-dialog.mjs.map +1 -1
  54. package/dist/esm/chrome/help-shortcuts-dialog.native.js +2 -2
  55. package/dist/esm/chrome/notifications-panel.mjs +8 -6
  56. package/dist/esm/chrome/notifications-panel.mjs.map +1 -1
  57. package/dist/esm/chrome/notifications-panel.native.js +8 -6
  58. package/dist/esm/chrome/notifications-panel.native.js.map +1 -1
  59. package/dist/esm/form/DocFormScreen.mjs +43 -43
  60. package/dist/esm/form/DocFormScreen.mjs.map +1 -1
  61. package/dist/esm/form/DocFormScreen.native.js +43 -43
  62. package/dist/esm/form/DocFormScreen.native.js.map +1 -1
  63. package/dist/esm/form/sidebar-people.mjs +6 -3
  64. package/dist/esm/form/sidebar-people.mjs.map +1 -1
  65. package/dist/esm/form/sidebar-people.native.js +6 -3
  66. package/dist/esm/form/sidebar-people.native.js.map +1 -1
  67. package/dist/esm/i18n/en.mjs +1 -1
  68. package/dist/esm/i18n/en.native.js +1 -1
  69. package/dist/esm/list/ListBulkActionsBar.mjs +2 -2
  70. package/dist/esm/list/ListBulkActionsBar.mjs.map +1 -1
  71. package/dist/esm/list/ListBulkActionsBar.native.js +2 -3
  72. package/dist/esm/list/ListBulkActionsBar.native.js.map +1 -1
  73. package/dist/esm/shell/WorkspaceSidebar.mjs +9 -7
  74. package/dist/esm/shell/WorkspaceSidebar.mjs.map +1 -1
  75. package/dist/esm/shell/WorkspaceSidebar.native.js +9 -7
  76. package/dist/esm/shell/WorkspaceSidebar.native.js.map +1 -1
  77. package/dist/esm/views/CalendarView.mjs +6 -2
  78. package/dist/esm/views/CalendarView.mjs.map +1 -1
  79. package/dist/esm/views/CalendarView.native.js +8 -3
  80. package/dist/esm/views/CalendarView.native.js.map +1 -1
  81. package/dist/esm/views/DashboardView.mjs +9 -3
  82. package/dist/esm/views/DashboardView.mjs.map +1 -1
  83. package/dist/esm/views/DashboardView.native.js +9 -3
  84. package/dist/esm/views/DashboardView.native.js.map +1 -1
  85. package/dist/esm/views/KanbanView.mjs +6 -3
  86. package/dist/esm/views/KanbanView.mjs.map +1 -1
  87. package/dist/esm/views/KanbanView.native.js +8 -4
  88. package/dist/esm/views/KanbanView.native.js.map +1 -1
  89. package/dist/esm/views/ReportView.mjs +8 -5
  90. package/dist/esm/views/ReportView.mjs.map +1 -1
  91. package/dist/esm/views/ReportView.native.js +11 -6
  92. package/dist/esm/views/ReportView.native.js.map +1 -1
  93. package/dist/esm/views/ViewHeader.mjs +8 -1
  94. package/dist/esm/views/ViewHeader.mjs.map +1 -1
  95. package/dist/esm/views/ViewHeader.native.js +8 -1
  96. package/dist/esm/views/ViewHeader.native.js.map +1 -1
  97. package/dist/esm/views/dashboard-data.mjs +17 -11
  98. package/dist/esm/views/dashboard-data.mjs.map +1 -1
  99. package/dist/esm/views/dashboard-data.native.js +20 -13
  100. package/dist/esm/views/dashboard-data.native.js.map +1 -1
  101. package/dist/jsx/chrome/awesomebar-data.mjs +4 -2
  102. package/dist/jsx/chrome/awesomebar-data.mjs.map +1 -1
  103. package/dist/jsx/chrome/awesomebar-data.native.js +7 -4
  104. package/dist/jsx/chrome/awesomebar-data.native.js.map +1 -1
  105. package/dist/jsx/chrome/help-about-dialog.mjs +2 -2
  106. package/dist/jsx/chrome/help-about-dialog.mjs.map +1 -1
  107. package/dist/jsx/chrome/help-about-dialog.native.js +2 -2
  108. package/dist/jsx/chrome/help-shortcuts-dialog.mjs +2 -2
  109. package/dist/jsx/chrome/help-shortcuts-dialog.mjs.map +1 -1
  110. package/dist/jsx/chrome/help-shortcuts-dialog.native.js +2 -2
  111. package/dist/jsx/chrome/help-shortcuts-dialog.native.js.map +1 -1
  112. package/dist/jsx/chrome/notifications-panel.mjs +8 -6
  113. package/dist/jsx/chrome/notifications-panel.mjs.map +1 -1
  114. package/dist/jsx/chrome/notifications-panel.native.js +8 -6
  115. package/dist/jsx/chrome/notifications-panel.native.js.map +1 -1
  116. package/dist/jsx/form/DocFormScreen.mjs +43 -43
  117. package/dist/jsx/form/DocFormScreen.mjs.map +1 -1
  118. package/dist/jsx/form/DocFormScreen.native.js +43 -43
  119. package/dist/jsx/form/DocFormScreen.native.js.map +1 -1
  120. package/dist/jsx/form/sidebar-people.mjs +6 -3
  121. package/dist/jsx/form/sidebar-people.mjs.map +1 -1
  122. package/dist/jsx/form/sidebar-people.native.js +5 -2
  123. package/dist/jsx/form/sidebar-people.native.js.map +1 -1
  124. package/dist/jsx/i18n/en.mjs +1 -1
  125. package/dist/jsx/i18n/en.native.js +1 -1
  126. package/dist/jsx/i18n/en.native.js.map +1 -1
  127. package/dist/jsx/list/ListBulkActionsBar.mjs +2 -2
  128. package/dist/jsx/list/ListBulkActionsBar.mjs.map +1 -1
  129. package/dist/jsx/list/ListBulkActionsBar.native.js +1 -2
  130. package/dist/jsx/list/ListBulkActionsBar.native.js.map +1 -1
  131. package/dist/jsx/shell/WorkspaceSidebar.mjs +9 -7
  132. package/dist/jsx/shell/WorkspaceSidebar.mjs.map +1 -1
  133. package/dist/jsx/shell/WorkspaceSidebar.native.js +9 -7
  134. package/dist/jsx/shell/WorkspaceSidebar.native.js.map +1 -1
  135. package/dist/jsx/views/CalendarView.mjs +6 -2
  136. package/dist/jsx/views/CalendarView.mjs.map +1 -1
  137. package/dist/jsx/views/CalendarView.native.js +7 -2
  138. package/dist/jsx/views/CalendarView.native.js.map +1 -1
  139. package/dist/jsx/views/DashboardView.mjs +9 -3
  140. package/dist/jsx/views/DashboardView.mjs.map +1 -1
  141. package/dist/jsx/views/DashboardView.native.js +9 -3
  142. package/dist/jsx/views/DashboardView.native.js.map +1 -1
  143. package/dist/jsx/views/KanbanView.mjs +6 -3
  144. package/dist/jsx/views/KanbanView.mjs.map +1 -1
  145. package/dist/jsx/views/KanbanView.native.js +7 -3
  146. package/dist/jsx/views/KanbanView.native.js.map +1 -1
  147. package/dist/jsx/views/ReportView.mjs +8 -5
  148. package/dist/jsx/views/ReportView.mjs.map +1 -1
  149. package/dist/jsx/views/ReportView.native.js +11 -6
  150. package/dist/jsx/views/ReportView.native.js.map +1 -1
  151. package/dist/jsx/views/ViewHeader.mjs +8 -1
  152. package/dist/jsx/views/ViewHeader.mjs.map +1 -1
  153. package/dist/jsx/views/ViewHeader.native.js +8 -1
  154. package/dist/jsx/views/ViewHeader.native.js.map +1 -1
  155. package/dist/jsx/views/dashboard-data.mjs +17 -11
  156. package/dist/jsx/views/dashboard-data.mjs.map +1 -1
  157. package/dist/jsx/views/dashboard-data.native.js +20 -13
  158. package/dist/jsx/views/dashboard-data.native.js.map +1 -1
  159. package/package.json +12 -12
  160. package/src/chrome/NotificationsButton.screen.spec.tsx +162 -0
  161. package/src/chrome/UserMenu.screen.spec.tsx +107 -0
  162. package/src/chrome/awesomebar-data.auth.spec.ts +56 -0
  163. package/src/chrome/awesomebar-data.tsx +4 -2
  164. package/src/chrome/help-about-dialog.tsx +2 -2
  165. package/src/chrome/help-shortcuts-dialog.tsx +2 -2
  166. package/src/chrome/notifications-panel.tsx +8 -9
  167. package/src/form/DocFormScreen.tsx +54 -45
  168. package/src/form/FormConnections.screen.spec.tsx +153 -0
  169. package/src/form/FormDialogs.screen.spec.tsx +119 -0
  170. package/src/form/FormToolbar.screen.spec.tsx +147 -0
  171. package/src/form/sidebar-people.tsx +7 -5
  172. package/src/i18n/en.ts +1 -1
  173. package/src/list/ListBulkActionsBar.screen.spec.tsx +181 -0
  174. package/src/list/ListBulkActionsBar.tsx +6 -2
  175. package/src/list/ListFilterBar.screen.spec.tsx +176 -0
  176. package/src/list/ListSidebar.screen.spec.tsx +202 -0
  177. package/src/quickEntry/QuickEntryDialog.screen.spec.tsx +143 -0
  178. package/src/report/DoctypeReportView.screen.spec.tsx +154 -0
  179. package/src/shell/WorkspaceSidebar.tsx +10 -9
  180. package/src/views/CalendarView.screen.spec.tsx +241 -0
  181. package/src/views/CalendarView.tsx +11 -3
  182. package/src/views/DashboardView.screen.spec.tsx +144 -0
  183. package/src/views/DashboardView.tsx +7 -3
  184. package/src/views/ImageView.screen.spec.tsx +108 -0
  185. package/src/views/KanbanView.screen.spec.tsx +282 -0
  186. package/src/views/KanbanView.tsx +7 -3
  187. package/src/views/ReportView.screen.spec.tsx +108 -0
  188. package/src/views/ReportView.tsx +10 -5
  189. package/src/views/ViewHeader.screen.spec.tsx +185 -0
  190. package/src/views/ViewHeader.tsx +4 -1
  191. package/src/views/dashboard-data.tsx +21 -12
  192. package/types/chrome/awesomebar-data.d.ts.map +1 -1
  193. package/types/chrome/notifications-panel.d.ts.map +1 -1
  194. package/types/form/DocFormScreen.d.ts.map +1 -1
  195. package/types/form/sidebar-people.d.ts.map +1 -1
  196. package/types/i18n/en.d.ts +1 -1
  197. package/types/list/ListBulkActionsBar.d.ts.map +1 -1
  198. package/types/shell/WorkspaceSidebar.d.ts.map +1 -1
  199. package/types/views/CalendarView.d.ts.map +1 -1
  200. package/types/views/DashboardView.d.ts.map +1 -1
  201. package/types/views/KanbanView.d.ts.map +1 -1
  202. package/types/views/ReportView.d.ts.map +1 -1
  203. package/types/views/ViewHeader.d.ts.map +1 -1
  204. package/types/views/dashboard-data.d.ts +4 -2
  205. package/types/views/dashboard-data.d.ts.map +1 -1
@@ -0,0 +1,144 @@
1
+ // The per-doctype dashboard (MPO-414): KPI counts over the rolling windows,
2
+ // a card per status option that opens the pre-filtered list, the weekly
3
+ // chart's empty state, and the desk scope ANDed into every query (GL-5).
4
+ import React from "react";
5
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
6
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
7
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
8
+ import { describe, expect, it } from "vitest";
9
+ import { BackofficeProvider } from "../config/BackofficeProvider";
10
+ import { DeskScopeProvider } from "../desk/scope";
11
+ import { createMemoryNavigator, type MemoryNavigator } from "../navigation/navigator";
12
+ import type { FrappeFilters } from "../shared/types";
13
+ import { DashboardView } from "./DashboardView";
14
+
15
+ function dashboardMeta(name: string, withStatus = true) {
16
+ return {
17
+ name,
18
+ doctype: "DocType",
19
+ module: "QA",
20
+ title_field: "subject",
21
+ fields: [
22
+ { fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 },
23
+ ...(withStatus
24
+ ? [
25
+ {
26
+ fieldname: "status",
27
+ fieldtype: "Select",
28
+ label: "Status",
29
+ options: "Open\nWorking\nDone",
30
+ in_list_view: 1,
31
+ },
32
+ ]
33
+ : []),
34
+ { fieldname: "owner_name", fieldtype: "Data", label: "Owner", in_list_view: 1 },
35
+ ],
36
+ permissions: [{ role: "All", read: 1 }],
37
+ };
38
+ }
39
+
40
+ function daysAgo(days: number): string {
41
+ const date = new Date();
42
+ date.setDate(date.getDate() - days);
43
+ const pad = (n: number) => String(n).padStart(2, "0");
44
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 10:00:00`;
45
+ }
46
+
47
+ // bo's records are recent; ana's were created and last touched long ago.
48
+ const docs = [
49
+ ["QAD-001", "Open", "bo", 2, 1],
50
+ ["QAD-002", "Working", "bo", 10, 3],
51
+ ["QAD-003", "Working", "bo", 40, 20],
52
+ ["QAD-004", "Done", "ana", 200, 150],
53
+ ["QAD-005", "Done", "ana", 300, 250],
54
+ ].map(([name, status, owner, created, modified]) => ({
55
+ name: String(name),
56
+ doctype: "QaDash",
57
+ subject: `Doc ${name}`,
58
+ status,
59
+ owner_name: owner,
60
+ creation: daysAgo(Number(created)),
61
+ modified: daysAgo(Number(modified)),
62
+ }));
63
+
64
+ let run = 0;
65
+
66
+ function renderDashboard(doctype: string, scope?: FrappeFilters): MemoryNavigator {
67
+ run += 1;
68
+ const navigation = createMemoryNavigator("/backoffice");
69
+ renderWithProviders(
70
+ <BackofficeProvider
71
+ frappe={{
72
+ baseURL: `https://dashboard-view-${run}.spec.test`,
73
+ fixtures: new InMemoryFixtureProvider({
74
+ DocType: [dashboardMeta("QaDash"), dashboardMeta("QaDashPlain", false)],
75
+ QaDash: docs,
76
+ QaDashPlain: docs.map((doc) => ({ ...doc, doctype: "QaDashPlain" })),
77
+ }),
78
+ }}
79
+ navigation={navigation}
80
+ >
81
+ <DeskScopeProvider scope={scope}>
82
+ <DashboardView doctype={doctype} />
83
+ </DeskScopeProvider>
84
+ </BackofficeProvider>,
85
+ );
86
+ return navigation;
87
+ }
88
+
89
+ async function kpi(title: string, value: string) {
90
+ await waitFor(
91
+ () => {
92
+ let node: HTMLElement | null = screen.getAllByText(title)[0] ?? null;
93
+ for (let depth = 0; node && depth < 6; depth += 1) {
94
+ const text = node.textContent ?? "";
95
+ if (text !== title && /\d/.test(text.replace(title, ""))) {
96
+ expect(text.replace(title, "").trim()).toBe(value);
97
+ return;
98
+ }
99
+ node = node.parentElement;
100
+ }
101
+ throw new Error(`no value drawn next to ${title}`);
102
+ },
103
+ { timeout: 5000 },
104
+ );
105
+ }
106
+
107
+ describe("<DashboardView />", () => {
108
+ it("counts the records in each rolling window", async () => {
109
+ renderDashboard("QaDash");
110
+ await kpi("Total QaDash", "5");
111
+ await kpi("Created (last 30 days)", "2");
112
+ await kpi("Modified (last 7 days)", "2");
113
+ });
114
+
115
+ it("draws a card per status option that opens the list filtered to it", async () => {
116
+ const navigation = renderDashboard("QaDash");
117
+ await kpi("Open", "1");
118
+ await kpi("Working", "2");
119
+ await kpi("Done", "2");
120
+ fireEvent.click(screen.getAllByText("Working")[0] as HTMLElement);
121
+ expect(navigation.getLocation().path).toBe("/backoffice/qadash");
122
+ expect(JSON.parse(String(navigation.getLocation().params.f))).toEqual([
123
+ ["status", "=", "Working"],
124
+ ]);
125
+ });
126
+
127
+ it("leaves the status cards and chart out for a doctype with no Select field", async () => {
128
+ renderDashboard("QaDashPlain");
129
+ await kpi("Total QaDashPlain", "5");
130
+ expect(screen.queryByText("Working")).toBeNull();
131
+ expect(screen.queryByText("By Status")).toBeNull();
132
+ });
133
+
134
+ it("ANDs the desk scope into the counts and both charts", async () => {
135
+ renderDashboard("QaDash", [["owner_name", "=", "ana"]]);
136
+ await kpi("Total QaDash", "2");
137
+ await kpi("Created (last 30 days)", "0");
138
+ await kpi("Done", "2");
139
+ await kpi("Working", "0");
140
+ expect(await screen.findByText("No recent activity", {}, { timeout: 5000 })).toBeTruthy();
141
+ await waitFor(() => expect(screen.getAllByText("Done")).toHaveLength(2), { timeout: 5000 });
142
+ expect(screen.getAllByText("Working")).toHaveLength(1);
143
+ });
144
+ });
@@ -81,7 +81,7 @@ export function DashboardView({ doctype }: DashboardViewProps) {
81
81
  const paths = useBackofficePaths();
82
82
  const connection = useViewConnection();
83
83
  const { meta, isLoading: isMetaLoading, error: metaError } = useViewMeta(doctype);
84
- const weekly = useWeeklyCreated(connection, doctype);
84
+ const weekly = useWeeklyCreated(connection, doctype, WEEKS_WINDOW, deskScope);
85
85
  // Rolling-window anchors resolve once per mount so the aggregate query
86
86
  // identities stay stable across re-renders (no refetch churn).
87
87
  const [anchors] = useState(() => ({
@@ -109,7 +109,7 @@ export function DashboardView({ doctype }: DashboardViewProps) {
109
109
  value: 0,
110
110
  format: "number",
111
111
  }}
112
- dataSource={{ doctype }}
112
+ dataSource={{ doctype, ...(deskScope.length > 0 && { filters: deskScope }) }}
113
113
  />
114
114
  </YStack>
115
115
  <YStack flexGrow={1} flexBasis={kpiColumnWidth} minWidth={kpiColumnWidth}>
@@ -220,7 +220,11 @@ export function DashboardView({ doctype }: DashboardViewProps) {
220
220
  type: "pie",
221
221
  height: CHART_HEIGHT,
222
222
  }}
223
- dataSource={{ doctype, groupBy: statusField.fieldname }}
223
+ dataSource={{
224
+ doctype,
225
+ groupBy: statusField.fieldname,
226
+ ...(deskScope.length > 0 && { filters: deskScope }),
227
+ }}
224
228
  />
225
229
  </YStack>
226
230
  )}
@@ -0,0 +1,108 @@
1
+ // The image view (MPO-414): a card per record over the doctype's image field,
2
+ // card press opens the form, the desk scope narrows the grid, and a doctype
3
+ // with no image field gets the unsupported state with a way back to the list.
4
+ import React from "react";
5
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
6
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
7
+ import { fireEvent, screen } from "@testing-library/react";
8
+ import { describe, expect, it } from "vitest";
9
+ import { BackofficeProvider } from "../config/BackofficeProvider";
10
+ import { DeskScopeProvider } from "../desk/scope";
11
+ import { createMemoryNavigator, type MemoryNavigator } from "../navigation/navigator";
12
+ import type { FrappeFilters } from "../shared/types";
13
+ import { ImageView } from "./ImageView";
14
+
15
+ function pokedexMeta(name: string) {
16
+ return {
17
+ name,
18
+ doctype: "DocType",
19
+ module: "App",
20
+ title_field: "pokemon_name",
21
+ image_field: "sprite_front",
22
+ fields: [
23
+ { fieldname: "pokemon_name", fieldtype: "Data", label: "Pokemon Name", in_list_view: 1 },
24
+ { fieldname: "poke_type", fieldtype: "Data", label: "Type", in_list_view: 1 },
25
+ { fieldname: "sprite_front", fieldtype: "Attach Image", label: "Sprite (Front)" },
26
+ ],
27
+ permissions: [{ role: "All", read: 1 }],
28
+ };
29
+ }
30
+
31
+ const plainMeta = {
32
+ name: "QaImagePlain",
33
+ doctype: "DocType",
34
+ module: "QA",
35
+ fields: [{ fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 }],
36
+ permissions: [{ role: "All", read: 1 }],
37
+ };
38
+
39
+ const sprite = "data:image/gif;base64,R0lGODlhAQABAAAAACw=";
40
+
41
+ const pokemon = [
42
+ ["bulbasaur", "grass"],
43
+ ["charmander", "fire"],
44
+ ["squirtle", "water"],
45
+ ].map(([name, type]) => ({
46
+ name,
47
+ doctype: "QaImageDex",
48
+ pokemon_name: name,
49
+ poke_type: type,
50
+ sprite_front: sprite,
51
+ }));
52
+
53
+ let run = 0;
54
+
55
+ function renderImageView(doctype: string, scope?: FrappeFilters): MemoryNavigator {
56
+ run += 1;
57
+ const navigation = createMemoryNavigator("/backoffice");
58
+ renderWithProviders(
59
+ <BackofficeProvider
60
+ frappe={{
61
+ baseURL: `https://image-view-${run}.spec.test`,
62
+ fixtures: new InMemoryFixtureProvider({
63
+ DocType: [pokedexMeta("QaImageDex"), pokedexMeta("QaImageEmpty"), plainMeta],
64
+ QaImageDex: pokemon,
65
+ QaImageEmpty: [],
66
+ }),
67
+ }}
68
+ navigation={navigation}
69
+ >
70
+ <DeskScopeProvider scope={scope}>
71
+ <ImageView doctype={doctype} />
72
+ </DeskScopeProvider>
73
+ </BackofficeProvider>,
74
+ );
75
+ return navigation;
76
+ }
77
+
78
+ describe("<ImageView />", () => {
79
+ it("draws a titled card per record and opens the form on press", async () => {
80
+ const navigation = renderImageView("QaImageDex");
81
+ expect(
82
+ (await screen.findByTestId("qaimagedex-name-bulbasaur", {}, { timeout: 5000 })).textContent,
83
+ ).toBe("bulbasaur");
84
+ expect(screen.getByTestId("qaimagedex-subtitle-charmander").textContent).toBe("fire");
85
+ fireEvent.click(screen.getByTestId("qaimagedex-card-squirtle"));
86
+ expect(navigation.getLocation().path).toBe("/backoffice/qaimagedex/squirtle");
87
+ });
88
+
89
+ it("shows only the records inside the desk scope", async () => {
90
+ renderImageView("QaImageDex", [["poke_type", "=", "fire"]]);
91
+ await screen.findByTestId("qaimagedex-card-charmander", {}, { timeout: 5000 });
92
+ expect(screen.queryByTestId("qaimagedex-card-bulbasaur")).toBeNull();
93
+ expect(screen.queryByTestId("qaimagedex-card-squirtle")).toBeNull();
94
+ });
95
+
96
+ it("says so when an image doctype has no records", async () => {
97
+ renderImageView("QaImageEmpty");
98
+ expect(await screen.findByText("No items found", {}, { timeout: 5000 })).toBeTruthy();
99
+ });
100
+
101
+ it("offers the list back when the doctype has no image field", async () => {
102
+ const navigation = renderImageView("QaImagePlain");
103
+ expect(await screen.findByText("Image view not available", {}, { timeout: 5000 })).toBeTruthy();
104
+ expect(screen.getByText("QaImagePlain has no image field.")).toBeTruthy();
105
+ fireEvent.click(screen.getByText("Back to list"));
106
+ expect(navigation.getLocation().path).toBe("/backoffice/qaimageplain");
107
+ });
108
+ });
@@ -0,0 +1,282 @@
1
+ // The kanban view and its board controls (MPO-414): a saved Kanban Board
2
+ // drives the columns, the picker switches and creates boards, a doctype with
3
+ // no board falls back to its Select field, the column "+" prefills the new
4
+ // record, and cards move or get added only as the session's rights allow
5
+ // (MPO-390).
6
+ import React from "react";
7
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
8
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
9
+ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import { BackofficeProvider } from "../config/BackofficeProvider";
12
+ import { createMemoryNavigator } from "../navigation/navigator";
13
+ import { KanbanView } from "./KanbanView";
14
+
15
+ // happy-dom has no layout, so the column virtualizer would draw no cards.
16
+ vi.mock("@tanstack/react-virtual", () => ({
17
+ useVirtualizer: (opts: { count: number; estimateSize: () => number }) => {
18
+ const size = opts.estimateSize();
19
+ const items = Array.from({ length: opts.count }, (_, index) => ({
20
+ index,
21
+ key: String(index),
22
+ start: index * size,
23
+ end: (index + 1) * size,
24
+ size,
25
+ }));
26
+ return {
27
+ getVirtualItems: () => items,
28
+ getTotalSize: () => opts.count * size,
29
+ measureElement: () => {},
30
+ scrollToIndex: () => {},
31
+ };
32
+ },
33
+ }));
34
+
35
+ function taskMeta(name: string, withSelect = true) {
36
+ return {
37
+ name,
38
+ doctype: "DocType",
39
+ module: "QA",
40
+ title_field: "subject",
41
+ fields: [
42
+ { fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 },
43
+ ...(withSelect
44
+ ? [
45
+ {
46
+ fieldname: "status",
47
+ fieldtype: "Select",
48
+ label: "Status",
49
+ options: "Open\nWorking\nDone",
50
+ in_list_view: 1,
51
+ },
52
+ {
53
+ fieldname: "priority",
54
+ fieldtype: "Select",
55
+ label: "Priority",
56
+ options: "High\nMedium\nLow",
57
+ in_list_view: 1,
58
+ },
59
+ ]
60
+ : []),
61
+ ],
62
+ permissions: [{ role: "All", read: 1 }],
63
+ };
64
+ }
65
+
66
+ const tasks = [
67
+ ["QABT-001", "triage inbox", "Open", "High"],
68
+ ["QABT-002", "ship release", "Working", "Medium"],
69
+ ["QABT-003", "write docs", "Done", "Low"],
70
+ ].map(([name, subject, status, priority]) => ({
71
+ name,
72
+ doctype: "QaBoardTask",
73
+ subject,
74
+ status,
75
+ priority,
76
+ }));
77
+
78
+ const savedBoards = [
79
+ {
80
+ name: "QA Task Board",
81
+ doctype: "Kanban Board",
82
+ kanban_board_name: "QA Task Board",
83
+ reference_doctype: "QaBoardTask",
84
+ field_name: "status",
85
+ columns: [
86
+ { column_name: "Done", status: "Active", indicator: "Green" },
87
+ { column_name: "Open", status: "Active", indicator: "Blue" },
88
+ { column_name: "Working", status: "Active", indicator: "Orange" },
89
+ { column_name: "Retired", status: "Archived", indicator: "Gray" },
90
+ ],
91
+ },
92
+ {
93
+ name: "Priority Lane",
94
+ doctype: "Kanban Board",
95
+ kanban_board_name: "Priority Lane",
96
+ reference_doctype: "QaBoardTask",
97
+ field_name: "priority",
98
+ columns: [
99
+ { column_name: "High", status: "Active", indicator: "Red" },
100
+ { column_name: "Medium", status: "Active", indicator: "Orange" },
101
+ { column_name: "Low", status: "Active", indicator: "Gray" },
102
+ ],
103
+ },
104
+ ];
105
+
106
+ const moveMeta = {
107
+ name: "Poke Move",
108
+ doctype: "DocType",
109
+ title_field: "move_name",
110
+ fields: [
111
+ { fieldname: "move_name", fieldtype: "Data", label: "Move Name", in_list_view: 1 },
112
+ {
113
+ fieldname: "category",
114
+ fieldtype: "Select",
115
+ label: "Category",
116
+ options: "Physical\nSpecial",
117
+ in_list_view: 1,
118
+ },
119
+ ],
120
+ permissions: [
121
+ { role: "Pokemon Editor", read: 1, write: 1, create: 1, delete: 1 },
122
+ { role: "Guest", read: 1 },
123
+ ],
124
+ };
125
+
126
+ let run = 0;
127
+
128
+ afterEach(() => {
129
+ vi.restoreAllMocks();
130
+ vi.unstubAllGlobals();
131
+ });
132
+
133
+ function renderFixtureKanban(doctype: string, board?: string) {
134
+ run += 1;
135
+ const fixtures = new InMemoryFixtureProvider({
136
+ DocType: [taskMeta("QaBoardTask"), taskMeta("QaBoardLoose"), taskMeta("QaBoardNone", false)],
137
+ "Kanban Board": savedBoards.map((entry) => ({ ...entry })),
138
+ QaBoardTask: tasks,
139
+ QaBoardLoose: tasks.map((task) => ({ ...task, doctype: "QaBoardLoose" })),
140
+ });
141
+ const navigation = createMemoryNavigator("/backoffice");
142
+ renderWithProviders(
143
+ <BackofficeProvider
144
+ frappe={{ baseURL: `https://kanban-view-${run}.spec.test`, fixtures }}
145
+ navigation={navigation}
146
+ >
147
+ <KanbanView doctype={doctype} board={board} />
148
+ </BackofficeProvider>,
149
+ );
150
+ return { navigation, fixtures };
151
+ }
152
+
153
+ function json(body: unknown) {
154
+ return new Response(JSON.stringify(body), {
155
+ status: 200,
156
+ headers: { "content-type": "application/json" },
157
+ });
158
+ }
159
+
160
+ function renderBenchKanban(roles: string[]) {
161
+ run += 1;
162
+ let rolesAnswered = false;
163
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
164
+ const url = new URL(String(input instanceof Request ? input.url : input));
165
+ const method = url.pathname.replace(/^\/api\/method\//, "");
166
+ if (method === "frappe.auth.get_logged_user") return json({ message: "user@pokemon.test" });
167
+ if (method === "frappe.core.doctype.user.user.get_roles") {
168
+ rolesAnswered = true;
169
+ return json({ message: roles });
170
+ }
171
+ if (method === "frappe.desk.form.load.getdoctype") return json({ docs: [moveMeta] });
172
+ if (url.pathname === `/api/resource/${encodeURIComponent("Poke Move")}`) {
173
+ return json({
174
+ data: [{ name: "m1", doctype: "Poke Move", move_name: "tackle", category: "Physical" }],
175
+ });
176
+ }
177
+ return json({ data: [], message: [] });
178
+ });
179
+ renderWithProviders(
180
+ <BackofficeProvider
181
+ frappe={{ baseURL: `https://kanban-view-bench-${run}.spec.test` }}
182
+ navigation={createMemoryNavigator("/backoffice")}
183
+ >
184
+ <KanbanView doctype="Poke Move" />
185
+ </BackofficeProvider>,
186
+ );
187
+ return { rolesAnswered: () => rolesAnswered };
188
+ }
189
+
190
+ function draggable(title: string): boolean {
191
+ return Boolean(screen.getByText(title).closest('[data-kanban-draggable="true"]'));
192
+ }
193
+
194
+ async function settle(ms = 300) {
195
+ await act(async () => {
196
+ await new Promise((resolve) => setTimeout(resolve, ms));
197
+ });
198
+ }
199
+
200
+ function precedes(first: HTMLElement, second: HTMLElement): boolean {
201
+ return Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING);
202
+ }
203
+
204
+ describe("<KanbanView />", () => {
205
+ it("draws the saved board's active columns in its order and opens a card's form", async () => {
206
+ const { navigation } = renderFixtureKanban("QaBoardTask");
207
+ const card = await screen.findByText("ship release", {}, { timeout: 5000 });
208
+ const done = screen.getByLabelText("Add card to Done");
209
+ const open = screen.getByLabelText("Add card to Open");
210
+ const working = screen.getByLabelText("Add card to Working");
211
+ expect(precedes(done, open) && precedes(open, working)).toBe(true);
212
+ expect(screen.queryByLabelText("Add card to Retired")).toBeNull();
213
+ fireEvent.click(card);
214
+ await waitFor(() =>
215
+ expect(navigation.getLocation().path).toBe("/backoffice/qaboardtask/QABT-002"),
216
+ );
217
+ });
218
+
219
+ it("opens a new record prefilled with the column from its +", async () => {
220
+ const { navigation } = renderFixtureKanban("QaBoardTask");
221
+ fireEvent.click(await screen.findByLabelText("Add card to Working", {}, { timeout: 5000 }));
222
+ expect(navigation.getLocation().path).toBe("/backoffice/qaboardtask/new");
223
+ expect(JSON.parse(String(navigation.getLocation().params.d))).toEqual({ status: "Working" });
224
+ });
225
+
226
+ it("switches to another saved board from the picker", async () => {
227
+ const { navigation } = renderFixtureKanban("QaBoardTask");
228
+ fireEvent.click(await screen.findByLabelText("Switch board", {}, { timeout: 5000 }));
229
+ fireEvent.click(await screen.findByText("Priority Lane", {}, { timeout: 5000 }));
230
+ await waitFor(() => expect(navigation.getLocation().params.board).toBe("Priority Lane"));
231
+ expect(navigation.getLocation().path).toBe("/backoffice/qaboardtask/view/kanban");
232
+ });
233
+
234
+ it("creates a board from the picker's New board dialog and switches to it", async () => {
235
+ const { navigation, fixtures } = renderFixtureKanban("QaBoardTask");
236
+ fireEvent.click(await screen.findByLabelText("Switch board", {}, { timeout: 5000 }));
237
+ fireEvent.click(await screen.findByText("New board…", {}, { timeout: 5000 }));
238
+ expect(await screen.findByText("New kanban board", {}, { timeout: 5000 })).toBeTruthy();
239
+ fireEvent.change(screen.getByRole("textbox", { name: /board name/i }), {
240
+ target: { value: "Release Train" },
241
+ });
242
+ fireEvent.click(screen.getByRole("button", { name: "Create board" }));
243
+ await waitFor(() => expect(navigation.getLocation().params.board).toBe("Release Train"));
244
+ const created = await fixtures.getDoc("Kanban Board", "Release Train");
245
+ expect(created).toMatchObject({ reference_doctype: "QaBoardTask", field_name: "status" });
246
+ });
247
+
248
+ it("falls back to the status Select with a create-board call to action", async () => {
249
+ renderFixtureKanban("QaBoardLoose");
250
+ expect(
251
+ await screen.findByText("No kanban board configured", {}, { timeout: 5000 }),
252
+ ).toBeTruthy();
253
+ expect(screen.getByRole("button", { name: "Create board" })).toBeTruthy();
254
+ expect(await screen.findByLabelText("Add card to Open", {}, { timeout: 5000 })).toBeTruthy();
255
+ });
256
+
257
+ it("offers the list back when there is no board and no Select field", async () => {
258
+ const { navigation } = renderFixtureKanban("QaBoardNone");
259
+ expect(
260
+ await screen.findByText("No Select field to board by", {}, { timeout: 5000 }),
261
+ ).toBeTruthy();
262
+ fireEvent.click(screen.getByText("Back to list"));
263
+ expect(navigation.getLocation().path).toBe("/backoffice/qaboardnone");
264
+ });
265
+
266
+ it("lets an editor move and add cards on a live bench", async () => {
267
+ const bench = renderBenchKanban(["Pokemon Editor", "Guest"]);
268
+ await screen.findByText("tackle", {}, { timeout: 5000 });
269
+ await waitFor(() => expect(bench.rolesAnswered()).toBe(true), { timeout: 5000 });
270
+ await waitFor(() => expect(draggable("tackle")).toBe(true));
271
+ expect(screen.getByLabelText("Add card to Physical")).toBeTruthy();
272
+ });
273
+
274
+ it("lets a reader neither move nor add cards on a live bench", async () => {
275
+ const bench = renderBenchKanban(["Guest"]);
276
+ await screen.findByText("tackle", {}, { timeout: 5000 });
277
+ await settle();
278
+ expect(draggable("tackle")).toBe(false);
279
+ expect(screen.queryByLabelText("Add card to Physical")).toBeNull();
280
+ expect(bench.rolesAnswered()).toBe(true);
281
+ });
282
+ });
@@ -16,6 +16,8 @@
16
16
  import { Alert, Button, Text, XStack, YStack, notify } from "@multiplatform.one/components";
17
17
  import {
18
18
  FrappeKanban,
19
+ useDocRights,
20
+ useFrappeConfig,
19
21
  type FrappeKanbanProps,
20
22
  type KanbanColumn,
21
23
  } from "@multiplatform.one/frappe-ui";
@@ -117,6 +119,7 @@ export function KanbanView({
117
119
  const connection = useViewConnection();
118
120
  const { meta, isLoading: isMetaLoading, error: metaError } = useViewMeta(doctype);
119
121
  const boards = useKanbanBoard(connection, doctype, boardOverride);
122
+ const { rights } = useDocRights(useFrappeConfig(), meta);
120
123
  // Session-scoped grouping-axis override from the picker, keyed by doctype
121
124
  // so navigating to another doctype never inherits a stale field.
122
125
  const [axisChoice, setAxisChoice] = useState<{ doctype: string; fieldname: string } | null>(null);
@@ -385,13 +388,14 @@ export function KanbanView({
385
388
  if (onCardOpen) onCardOpen(doctype, card.name);
386
389
  else navigator.navigate(paths.form(doctype, card.name));
387
390
  }}
388
- readOnly={readOnly}
391
+ readOnly={readOnly || !rights.write}
389
392
  // Desk parity: the per-column "+" (anchored in the column header by
390
393
  // FrappeKanban) carries the clicked column into the create form —
391
394
  // the active grouping axis is prefilled via the `?d=` param on the
392
395
  // /new route (consumed by NewDocRouteScreen → createPrefill). The
393
- // newDoc capability gates the affordance.
394
- enableCardCreation={newDoc.enabled}
396
+ // newDoc capability and the session's create right gate the
397
+ // affordance.
398
+ enableCardCreation={newDoc.enabled && rights.create}
395
399
  onCreateCard={(columnValue) =>
396
400
  navigator.navigate(paths.newDoc(doctype, { [statusField]: columnValue }))
397
401
  }