@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,143 @@
1
+ // Desk quick entry (MPO-414): required fields validate inline, Create inserts
2
+ // and hands back the server name, and doctypes that do not quick-enter route
3
+ // to the full form without drawing the dialog.
4
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
5
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
6
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+ import { BackofficeProvider, type BackofficeFrappeConfig } from "../config/BackofficeProvider";
9
+ import { createMemoryNavigator } from "../navigation/navigator";
10
+ import type { DoctypeMeta } from "../shared/useDoctypeMeta";
11
+ import { QuickEntryDialog } from "./QuickEntryDialog";
12
+
13
+ const todoMeta: DoctypeMeta = {
14
+ name: "ToDo",
15
+ module: "Desk",
16
+ quick_entry: 1,
17
+ autoname: "hash",
18
+ fields: [
19
+ { fieldname: "description", fieldtype: "Small Text", label: "Description", reqd: 1 },
20
+ {
21
+ fieldname: "priority",
22
+ fieldtype: "Select",
23
+ label: "Priority",
24
+ options: "High\nMedium\nLow",
25
+ bold: 1,
26
+ default: "Medium",
27
+ },
28
+ { fieldname: "status", fieldtype: "Select", label: "Status", options: "Open\nClosed" },
29
+ ],
30
+ permissions: [{ role: "All", read: 1, write: 1, create: 1 }],
31
+ };
32
+
33
+ const invoiceMeta: DoctypeMeta = {
34
+ name: "Sales Invoice",
35
+ module: "Accounts",
36
+ quick_entry: 0,
37
+ fields: [{ fieldname: "customer", fieldtype: "Data", label: "Customer", reqd: 1 }],
38
+ permissions: [{ role: "All", read: 1, write: 1, create: 1 }],
39
+ };
40
+
41
+ let host = 0;
42
+
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ vi.unstubAllGlobals();
46
+ });
47
+
48
+ function renderDialog(meta: DoctypeMeta, frappe: Partial<BackofficeFrappeConfig> = {}) {
49
+ host += 1;
50
+ const onOpenChange = vi.fn();
51
+ const onCreated = vi.fn();
52
+ const onEditFullForm = vi.fn();
53
+ const fixtures = new InMemoryFixtureProvider({ [meta.name]: [] });
54
+ renderWithProviders(
55
+ <BackofficeProvider
56
+ frappe={{ baseURL: `https://quick-entry-${host}.screen.spec.test`, fixtures, ...frappe }}
57
+ navigation={createMemoryNavigator("/backoffice")}
58
+ >
59
+ <QuickEntryDialog
60
+ doctype={meta.name}
61
+ open
62
+ onOpenChange={onOpenChange}
63
+ onCreated={onCreated}
64
+ onEditFullForm={onEditFullForm}
65
+ metaOverride={meta}
66
+ />
67
+ </BackofficeProvider>,
68
+ );
69
+ return { fixtures, onOpenChange, onCreated, onEditFullForm };
70
+ }
71
+
72
+ function descriptionInput(): HTMLTextAreaElement | HTMLInputElement {
73
+ const field = screen.getByLabelText("Description", { exact: false });
74
+ return (
75
+ field instanceof HTMLTextAreaElement || field instanceof HTMLInputElement
76
+ ? field
77
+ : field.querySelector("textarea, input")
78
+ ) as HTMLTextAreaElement;
79
+ }
80
+
81
+ describe("QuickEntryDialog", () => {
82
+ it("holds Create while a required field is empty", async () => {
83
+ const { onCreated, onOpenChange } = renderDialog(todoMeta);
84
+ fireEvent.click(await screen.findByTestId("quick-entry-create"));
85
+ expect(await screen.findByText("Description is required")).toBeTruthy();
86
+ expect(onCreated).not.toHaveBeenCalled();
87
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
88
+ });
89
+
90
+ it("inserts the draft and hands back the created name", async () => {
91
+ const { fixtures, onCreated, onOpenChange } = renderDialog(todoMeta);
92
+ await screen.findByTestId("quick-entry-form");
93
+ fireEvent.change(descriptionInput(), { target: { value: "Call the vendor" } });
94
+ fireEvent.click(screen.getByTestId("quick-entry-create"));
95
+ await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1));
96
+ const created = await fixtures.getList("ToDo");
97
+ expect(created).toHaveLength(1);
98
+ expect(created[0]?.description).toBe("Call the vendor");
99
+ expect(onCreated).toHaveBeenCalledWith(created[0]?.name);
100
+ expect(onOpenChange).toHaveBeenLastCalledWith(false);
101
+ });
102
+
103
+ it("shows a server rejection inline and stays open", async () => {
104
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
105
+ const url = new URL(String(input instanceof Request ? input.url : input));
106
+ if (url.pathname.endsWith(".create_doc")) {
107
+ return new Response(
108
+ JSON.stringify({
109
+ exc_type: "ValidationError",
110
+ exception: "frappe.exceptions.ValidationError: Vendor calls are closed today",
111
+ }),
112
+ { status: 417, headers: { "content-type": "application/json" } },
113
+ );
114
+ }
115
+ return new Response(JSON.stringify({ data: [] }), {
116
+ headers: { "content-type": "application/json" },
117
+ });
118
+ });
119
+ const { onCreated, onOpenChange } = renderDialog(todoMeta, { fixtures: undefined });
120
+ await screen.findByTestId("quick-entry-form");
121
+ fireEvent.change(descriptionInput(), { target: { value: "Call the vendor" } });
122
+ fireEvent.click(screen.getByTestId("quick-entry-create"));
123
+ const error = await screen.findByTestId("quick-entry-error", {}, { timeout: 5000 });
124
+ expect(error.textContent).toBe("Vendor calls are closed today");
125
+ expect(onCreated).not.toHaveBeenCalled();
126
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
127
+ });
128
+
129
+ it("routes a doctype that does not quick-enter to the full form", async () => {
130
+ const { onEditFullForm, onOpenChange } = renderDialog(invoiceMeta);
131
+ await waitFor(() => expect(onEditFullForm).toHaveBeenCalledTimes(1));
132
+ expect(onOpenChange).toHaveBeenCalledWith(false);
133
+ expect(screen.queryByTestId("quick-entry-form")).toBeNull();
134
+ expect(screen.queryByText("New Sales Invoice")).toBeNull();
135
+ });
136
+
137
+ it("escapes to the full form from the footer", async () => {
138
+ const { onEditFullForm, onOpenChange } = renderDialog(todoMeta);
139
+ fireEvent.click(await screen.findByTestId("quick-entry-edit-full-form"));
140
+ expect(onOpenChange).toHaveBeenCalledWith(false);
141
+ expect(onEditFullForm).toHaveBeenCalledTimes(1);
142
+ });
143
+ });
@@ -0,0 +1,154 @@
1
+ // The desk report view (MPO-414): rows from the live collection, a row press
2
+ // opens the form, the per-column filter row and the ?f= base filters narrow
3
+ // the result, the desk scope is ANDed in, and Save As round-trips ?report=.
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 { DoctypeReportView } from "./DoctypeReportView";
14
+
15
+ const orderMeta = {
16
+ name: "QaReportOrder",
17
+ doctype: "DocType",
18
+ module: "QA",
19
+ title_field: "customer",
20
+ sort_field: "modified",
21
+ sort_order: "DESC",
22
+ fields: [
23
+ { fieldname: "customer", fieldtype: "Data", label: "Customer", in_list_view: 1 },
24
+ { fieldname: "amount", fieldtype: "Currency", label: "Amount", in_list_view: 1 },
25
+ {
26
+ fieldname: "region",
27
+ fieldtype: "Select",
28
+ label: "Region",
29
+ options: "North\nSouth",
30
+ in_standard_filter: 1,
31
+ },
32
+ {
33
+ fieldname: "status",
34
+ fieldtype: "Select",
35
+ label: "Status",
36
+ options: "Draft\nPaid",
37
+ in_list_view: 1,
38
+ },
39
+ ],
40
+ permissions: [{ role: "All", read: 1 }],
41
+ };
42
+
43
+ const orders = [
44
+ ["QA-ORD-0001", "ACME Corp", "North", "Paid", 1],
45
+ ["QA-ORD-0002", "Globex", "South", "Draft", 2],
46
+ ["QA-ORD-0003", "Initech", "North", "Draft", 3],
47
+ ].map(([name, customer, region, status, day]) => ({
48
+ name: String(name),
49
+ doctype: "QaReportOrder",
50
+ customer,
51
+ amount: 1000,
52
+ region,
53
+ status,
54
+ modified: `2026-08-0${day} 09:00:00`,
55
+ }));
56
+
57
+ let run = 0;
58
+
59
+ function renderReport(
60
+ props: { filtersParam?: string; scope?: FrappeFilters; doctype?: string } = {},
61
+ ): MemoryNavigator {
62
+ run += 1;
63
+ const navigation = createMemoryNavigator("/backoffice/qareportorder/view/report");
64
+ renderWithProviders(
65
+ <BackofficeProvider
66
+ frappe={{
67
+ baseURL: `https://doctype-report-${run}.spec.test`,
68
+ fixtures: new InMemoryFixtureProvider({
69
+ DocType: [orderMeta, { ...orderMeta, name: "QaReportEmpty" }],
70
+ QaReportOrder: orders,
71
+ QaReportEmpty: [],
72
+ }),
73
+ }}
74
+ navigation={navigation}
75
+ >
76
+ <DeskScopeProvider scope={props.scope}>
77
+ <DoctypeReportView
78
+ doctype={props.doctype ?? "QaReportOrder"}
79
+ filtersParam={props.filtersParam}
80
+ />
81
+ </DeskScopeProvider>
82
+ </BackofficeProvider>,
83
+ );
84
+ return navigation;
85
+ }
86
+
87
+ function rowNames(): string[] {
88
+ return Array.from(
89
+ document.querySelectorAll('[data-report-row="true"]'),
90
+ (row) => row.getAttribute("aria-label") ?? "",
91
+ );
92
+ }
93
+
94
+ async function rowsAre(names: string[]) {
95
+ await waitFor(() => expect(rowNames().sort()).toEqual([...names].sort()), { timeout: 5000 });
96
+ }
97
+
98
+ describe("<DoctypeReportView />", () => {
99
+ it("draws a row per record and opens the form on press", async () => {
100
+ const navigation = renderReport();
101
+ await rowsAre(["QA-ORD-0001", "QA-ORD-0002", "QA-ORD-0003"]);
102
+ fireEvent.click(screen.getByText("Globex"));
103
+ expect(navigation.getLocation().path).toBe("/backoffice/qareportorder/QA-ORD-0002");
104
+ });
105
+
106
+ it("narrows the rows from the column filter row and clears it again", async () => {
107
+ renderReport();
108
+ await rowsAre(["QA-ORD-0001", "QA-ORD-0002", "QA-ORD-0003"]);
109
+ fireEvent.change(await screen.findByRole("textbox", { name: "Filter Region" }), {
110
+ target: { value: "North" },
111
+ });
112
+ await rowsAre(["QA-ORD-0001", "QA-ORD-0003"]);
113
+ fireEvent.click(screen.getByRole("button", { name: /Clear column filters \(1\)/ }));
114
+ await rowsAre(["QA-ORD-0001", "QA-ORD-0002", "QA-ORD-0003"]);
115
+ });
116
+
117
+ it("applies the ?f= base filters and drops them from the address on clear", async () => {
118
+ const navigation = renderReport({
119
+ filtersParam: encodeURIComponent(JSON.stringify([["status", "=", "Draft"]])),
120
+ });
121
+ await rowsAre(["QA-ORD-0002", "QA-ORD-0003"]);
122
+ fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
123
+ await rowsAre(["QA-ORD-0001", "QA-ORD-0002", "QA-ORD-0003"]);
124
+ expect(navigation.getLocation().path).toBe("/backoffice/qareportorder/view/report");
125
+ });
126
+
127
+ it("ANDs the desk scope in, with nothing to clear", async () => {
128
+ renderReport({ scope: [["region", "=", "South"]] });
129
+ await rowsAre(["QA-ORD-0002"]);
130
+ expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull();
131
+ });
132
+
133
+ it("says so when the doctype has no records", async () => {
134
+ renderReport({ doctype: "QaReportEmpty" });
135
+ expect(await screen.findByText("No results", {}, { timeout: 5000 })).toBeTruthy();
136
+ expect(screen.getByText("There are no QaReportEmpty records yet")).toBeTruthy();
137
+ });
138
+
139
+ it("saves the current report under a name and selects it", async () => {
140
+ const navigation = renderReport();
141
+ await rowsAre(["QA-ORD-0001", "QA-ORD-0002", "QA-ORD-0003"]);
142
+ fireEvent.click(screen.getByRole("button", { name: "Save As…" }));
143
+ fireEvent.change(await screen.findByRole("textbox", { name: "New report name" }), {
144
+ target: { value: "Weekly Northern" },
145
+ });
146
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
147
+ await waitFor(() => expect(navigation.getLocation().params.report).toBe("Weekly Northern"), {
148
+ timeout: 5000,
149
+ });
150
+ expect(
151
+ await screen.findByRole("button", { name: "Report: Weekly Northern" }, { timeout: 5000 }),
152
+ ).toBeTruthy();
153
+ });
154
+ });
@@ -167,15 +167,16 @@ function WorkspaceNav({ sections }: { sections: SidebarSection[] }) {
167
167
  {sections.map((section, sectionIdx) => (
168
168
  <YStack key={section.title ?? sectionIdx} {...knobProps.gap}>
169
169
  {section.title ? (
170
- <SizableText
171
- size="$2"
172
- fontWeight="400"
173
- textTransform="uppercase"
174
- color="$color11"
175
- marginHorizontal={rowPad}
176
- >
177
- {section.title}
178
- </SizableText>
170
+ <YStack paddingHorizontal={rowPad}>
171
+ <SizableText
172
+ size="$2"
173
+ fontWeight="400"
174
+ textTransform="uppercase"
175
+ color="$color11"
176
+ >
177
+ {section.title}
178
+ </SizableText>
179
+ </YStack>
179
180
  ) : null}
180
181
  <YStack>
181
182
  {section.items.map((item, itemIdx) => {
@@ -0,0 +1,241 @@
1
+ // The calendar view (MPO-414): a Calendar View settings doc picks the date
2
+ // axis, an event opens its form, the selector writes ?field=, and a day click
3
+ // starts a record prefilled with that day only for a session that may create
4
+ // (MPO-390).
5
+ import React from "react";
6
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
7
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
8
+ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+ import { BackofficeProvider } from "../config/BackofficeProvider";
11
+ import { createMemoryNavigator, type MemoryNavigator } from "../navigation/navigator";
12
+ import { CalendarView } from "./CalendarView";
13
+
14
+ const eventMeta = {
15
+ name: "QaCalEvent",
16
+ doctype: "DocType",
17
+ module: "QA",
18
+ fields: [
19
+ { fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 },
20
+ { fieldname: "created_on", fieldtype: "Date", label: "Created On" },
21
+ { fieldname: "scheduled_at", fieldtype: "Datetime", label: "Scheduled At" },
22
+ { fieldname: "wrap_up_at", fieldtype: "Datetime", label: "Wrap Up At" },
23
+ ],
24
+ permissions: [{ role: "All", read: 1 }],
25
+ };
26
+
27
+ const multiMeta = {
28
+ name: "QaCalMulti",
29
+ doctype: "DocType",
30
+ module: "QA",
31
+ title_field: "title",
32
+ fields: [
33
+ { fieldname: "title", fieldtype: "Data", label: "Title", in_list_view: 1 },
34
+ { fieldname: "due_date", fieldtype: "Date", label: "Due Date" },
35
+ { fieldname: "follow_up_on", fieldtype: "Date", label: "Follow Up On" },
36
+ ],
37
+ permissions: [{ role: "All", read: 1 }],
38
+ };
39
+
40
+ const noDateMeta = {
41
+ name: "QaCalNone",
42
+ doctype: "DocType",
43
+ module: "QA",
44
+ fields: [{ fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 }],
45
+ permissions: [{ role: "All", read: 1 }],
46
+ };
47
+
48
+ const moveMeta = {
49
+ name: "Poke Move",
50
+ doctype: "DocType",
51
+ title_field: "move_name",
52
+ fields: [
53
+ { fieldname: "move_name", fieldtype: "Data", label: "Move Name", in_list_view: 1 },
54
+ { fieldname: "learned_on", fieldtype: "Date", label: "Learned On" },
55
+ ],
56
+ permissions: [
57
+ { role: "Pokemon Editor", read: 1, write: 1, create: 1, delete: 1 },
58
+ { role: "Guest", read: 1 },
59
+ ],
60
+ };
61
+
62
+ function dayKey(date: Date): string {
63
+ const month = String(date.getMonth() + 1).padStart(2, "0");
64
+ const day = String(date.getDate()).padStart(2, "0");
65
+ return `${date.getFullYear()}-${month}-${day}`;
66
+ }
67
+
68
+ const today = dayKey(new Date());
69
+
70
+ const events = [
71
+ {
72
+ name: "QCE-001",
73
+ doctype: "QaCalEvent",
74
+ subject: "Team sync",
75
+ created_on: "2020-01-01",
76
+ scheduled_at: `${today} 09:00:00`,
77
+ wrap_up_at: `${today} 10:00:00`,
78
+ },
79
+ ];
80
+
81
+ const calendarViewDoc = {
82
+ name: "QaCalEvent Calendar",
83
+ doctype: "Calendar View",
84
+ reference_doctype: "QaCalEvent",
85
+ subject_field: "subject",
86
+ start_date_field: "scheduled_at",
87
+ end_date_field: "wrap_up_at",
88
+ };
89
+
90
+ let run = 0;
91
+
92
+ afterEach(() => {
93
+ vi.restoreAllMocks();
94
+ vi.unstubAllGlobals();
95
+ });
96
+
97
+ function renderFixtureCalendar(doctype: string, dateField?: string): MemoryNavigator {
98
+ run += 1;
99
+ const navigation = createMemoryNavigator("/backoffice");
100
+ renderWithProviders(
101
+ <BackofficeProvider
102
+ frappe={{
103
+ baseURL: `https://calendar-view-${run}.spec.test`,
104
+ fixtures: new InMemoryFixtureProvider({
105
+ DocType: [eventMeta, multiMeta, noDateMeta],
106
+ "Calendar View": [calendarViewDoc],
107
+ QaCalEvent: events,
108
+ QaCalMulti: [],
109
+ }),
110
+ }}
111
+ navigation={navigation}
112
+ >
113
+ <CalendarView doctype={doctype} dateField={dateField} />
114
+ </BackofficeProvider>,
115
+ );
116
+ return navigation;
117
+ }
118
+
119
+ function json(body: unknown) {
120
+ return new Response(JSON.stringify(body), {
121
+ status: 200,
122
+ headers: { "content-type": "application/json" },
123
+ });
124
+ }
125
+
126
+ function renderBenchCalendar(roles: string[]) {
127
+ run += 1;
128
+ let rolesAnswered = false;
129
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
130
+ const url = new URL(String(input instanceof Request ? input.url : input));
131
+ const method = url.pathname.replace(/^\/api\/method\//, "");
132
+ if (method === "frappe.auth.get_logged_user") return json({ message: "user@pokemon.test" });
133
+ if (method === "frappe.core.doctype.user.user.get_roles") {
134
+ rolesAnswered = true;
135
+ return json({ message: roles });
136
+ }
137
+ if (method === "frappe.desk.form.load.getdoctype") return json({ docs: [moveMeta] });
138
+ if (url.pathname === `/api/resource/${encodeURIComponent("Poke Move")}`) {
139
+ return json({
140
+ data: [{ name: "m1", doctype: "Poke Move", move_name: "tackle", learned_on: today }],
141
+ });
142
+ }
143
+ return json({ data: [], message: [] });
144
+ });
145
+ const navigation = createMemoryNavigator("/backoffice");
146
+ renderWithProviders(
147
+ <BackofficeProvider
148
+ frappe={{ baseURL: `https://calendar-view-bench-${run}.spec.test` }}
149
+ navigation={navigation}
150
+ >
151
+ <CalendarView doctype="Poke Move" />
152
+ </BackofficeProvider>,
153
+ );
154
+ return { navigation, rolesAnswered: () => rolesAnswered };
155
+ }
156
+
157
+ async function todayCell(): Promise<HTMLElement> {
158
+ return waitFor(
159
+ () => {
160
+ const cell = document.querySelector(`[data-calendar-day="${today}"]`);
161
+ expect(cell).toBeTruthy();
162
+ return cell as HTMLElement;
163
+ },
164
+ { timeout: 5000 },
165
+ );
166
+ }
167
+
168
+ function draggable(title: string): boolean {
169
+ let node: HTMLElement | null = screen.getByText(title);
170
+ for (let depth = 0; node && depth < 6; depth += 1) {
171
+ if (node.className.includes("_cur-grab")) return true;
172
+ node = node.parentElement;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ async function settle(ms = 300) {
178
+ await act(async () => {
179
+ await new Promise((resolve) => setTimeout(resolve, ms));
180
+ });
181
+ }
182
+
183
+ describe("<CalendarView />", () => {
184
+ it("lays events on the settings doc's axis and opens the form on press", async () => {
185
+ const navigation = renderFixtureCalendar("QaCalEvent");
186
+ expect(
187
+ await screen.findByLabelText("Choose calendar date field", {}, { timeout: 5000 }),
188
+ ).toHaveProperty("textContent", "Scheduled At");
189
+ fireEvent.click(await screen.findByText("Team sync", {}, { timeout: 5000 }));
190
+ expect(navigation.getLocation().path).toBe("/backoffice/qacalevent/QCE-001");
191
+ });
192
+
193
+ it("writes the picked date field into ?field=", async () => {
194
+ const navigation = renderFixtureCalendar("QaCalMulti");
195
+ fireEvent.click(
196
+ await screen.findByLabelText("Choose calendar date field", {}, { timeout: 5000 }),
197
+ );
198
+ fireEvent.click(await screen.findByText("Follow Up On", {}, { timeout: 5000 }));
199
+ await waitFor(() => expect(navigation.getLocation().params.field).toBe("follow_up_on"));
200
+ expect(navigation.getLocation().path).toBe("/backoffice/qacalmulti/view/calendar");
201
+ });
202
+
203
+ it("starts a record prefilled with the clicked day", async () => {
204
+ const navigation = renderFixtureCalendar("QaCalMulti");
205
+ fireEvent.click(await todayCell());
206
+ await waitFor(() => expect(navigation.getLocation().path).toBe("/backoffice/qacalmulti/new"));
207
+ const prefill = navigation.getLocation().params.d;
208
+ expect(JSON.parse(String(prefill))).toEqual({ due_date: today });
209
+ });
210
+
211
+ it("offers the list back when the doctype has no date field", async () => {
212
+ const navigation = renderFixtureCalendar("QaCalNone");
213
+ expect(await screen.findByText("Calendar not available", {}, { timeout: 5000 })).toBeTruthy();
214
+ fireEvent.click(screen.getByText("Back to list"));
215
+ expect(navigation.getLocation().path).toBe("/backoffice/qacalnone");
216
+ });
217
+
218
+ it("lets an editor move events and start a record from a day on a live bench", async () => {
219
+ const bench = renderBenchCalendar(["Pokemon Editor", "Guest"]);
220
+ const cell = await todayCell();
221
+ await screen.findByText("tackle", {}, { timeout: 5000 });
222
+ await waitFor(() => expect(bench.rolesAnswered()).toBe(true), { timeout: 5000 });
223
+ await waitFor(() => expect(draggable("tackle")).toBe(true));
224
+ fireEvent.click(cell);
225
+ await waitFor(() =>
226
+ expect(bench.navigation.getLocation().path).toBe("/backoffice/poke-move/new"),
227
+ );
228
+ });
229
+
230
+ it("neither moves events nor starts a record for a reader on a live bench", async () => {
231
+ const bench = renderBenchCalendar(["Guest"]);
232
+ const cell = await todayCell();
233
+ await screen.findByText("tackle", {}, { timeout: 5000 });
234
+ await settle();
235
+ expect(draggable("tackle")).toBe(false);
236
+ fireEvent.click(cell);
237
+ await settle(100);
238
+ expect(bench.navigation.getLocation().path).toBe("/backoffice");
239
+ expect(bench.rolesAnswered()).toBe(true);
240
+ });
241
+ });
@@ -9,7 +9,12 @@
9
9
  // Event click opens the form; day/slot click starts a new document
10
10
  // prefilled with the clicked date (`?d=` param on the /new route).
11
11
  import { XStack, YStack } from "@multiplatform.one/components";
12
- import { FrappeCalendar, type FrappeCalendarProps } from "@multiplatform.one/frappe-ui";
12
+ import {
13
+ FrappeCalendar,
14
+ useDocRights,
15
+ useFrappeConfig,
16
+ type FrappeCalendarProps,
17
+ } from "@multiplatform.one/frappe-ui";
13
18
  import { formatCalendarDate, useResolvedKnobs } from "@multiplatform.one/theme";
14
19
  import type { ReactElement } from "react";
15
20
  import { useTranslation } from "react-i18next";
@@ -73,6 +78,7 @@ export function CalendarView({
73
78
  const connection = useViewConnection();
74
79
  const { meta, isLoading: isMetaLoading, error: metaError } = useViewMeta(doctype);
75
80
  const settings = useCalendarViewSettings(connection, doctype);
81
+ const { rights } = useDocRights(useFrappeConfig(), meta);
76
82
 
77
83
  if (isMetaLoading || settings.isLoading) return <ViewLoading />;
78
84
  if (metaError && !meta) return <ViewError doctype={doctype} error={metaError} />;
@@ -168,16 +174,18 @@ export function CalendarView({
168
174
  defaultView="month"
169
175
  showNavigation
170
176
  showViewSwitcher
177
+ enableEventDrag={rights.write}
171
178
  onEventClick={(doc) => {
172
179
  if (doc.name) navigator.navigate(paths.form(doctype, doc.name));
173
180
  }}
174
181
  // Desk parity: desk seeds its new-event form with the clicked day
175
182
  // (and slot time in week/day grids) — the date rides the `?d=`
176
183
  // prefill param into the create form, shaped to the field's type.
177
- // `onSlotCreate` overrides; the default is gated by `newDoc`.
184
+ // `onSlotCreate` overrides; the default is gated by `newDoc` and the
185
+ // session's create right.
178
186
  onDateClick={
179
187
  onSlotCreate ??
180
- (newDoc.enabled
188
+ (newDoc.enabled && rights.create
181
189
  ? (date) =>
182
190
  navigator.navigate(
183
191
  paths.newDoc(doctype, { [dateField]: toFrappeDateValue(date, dateFieldType) }),