@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,181 @@
1
+ // ListBulkActionsBar over a selection (MPO-414): the bar's gates, its
2
+ // confirm-guarded delete and docstatus RPCs, the Bulk Edit dialog it opens,
3
+ // and the host list actions it offers for the doctype on screen.
4
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
5
+ import { fireEvent, screen, waitFor, within } from "@testing-library/react";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { BackofficeProvider, type BackofficeActions } from "../config/BackofficeProvider";
8
+ import type { BackofficeCapabilities } from "../config/capabilities";
9
+ import { createMemoryNavigator } from "../navigation/navigator";
10
+ import type { DoctypeMeta } from "../shared/useDoctypeMeta";
11
+ import { ListBulkActionsBar } from "./ListBulkActionsBar";
12
+
13
+ const todoMeta: DoctypeMeta = {
14
+ name: "ToDo",
15
+ fields: [
16
+ { fieldname: "description", fieldtype: "Data", label: "Description" },
17
+ { fieldname: "status", fieldtype: "Select", label: "Status", options: "Open\nClosed" },
18
+ ],
19
+ };
20
+
21
+ interface Call {
22
+ method: string;
23
+ path: string;
24
+ body: Record<string, unknown>;
25
+ }
26
+
27
+ let run = 0;
28
+ let calls: Call[] = [];
29
+
30
+ function json(body: unknown, status = 200) {
31
+ return new Response(JSON.stringify(body), {
32
+ status,
33
+ headers: { "content-type": "application/json" },
34
+ });
35
+ }
36
+
37
+ beforeEach(() => {
38
+ run += 1;
39
+ calls = [];
40
+ vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => {
41
+ const url = new URL(String(input instanceof Request ? input.url : input));
42
+ const raw = typeof init?.body === "string" ? init.body : "";
43
+ calls.push({
44
+ method: (init?.method ?? "GET").toUpperCase(),
45
+ path: decodeURIComponent(url.pathname),
46
+ body: raw.startsWith("{") ? (JSON.parse(raw) as Record<string, unknown>) : {},
47
+ });
48
+ if (url.pathname.endsWith("submit_cancel_or_update_docs")) return json({ message: [] });
49
+ return json({ message: "ok", data: {} });
50
+ });
51
+ });
52
+
53
+ afterEach(() => {
54
+ vi.restoreAllMocks();
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ interface BarOptions {
59
+ meta?: DoctypeMeta;
60
+ names?: string[];
61
+ capabilities?: BackofficeCapabilities;
62
+ actions?: BackofficeActions;
63
+ }
64
+
65
+ function renderBar({
66
+ meta = todoMeta,
67
+ names = ["TD-001", "TD-002"],
68
+ capabilities,
69
+ actions,
70
+ }: BarOptions = {}) {
71
+ const onClearSelection = vi.fn();
72
+ const onActionComplete = vi.fn();
73
+ const view = renderWithProviders(
74
+ <BackofficeProvider
75
+ frappe={{ baseURL: `https://list-bulk-${run}.spec.test` }}
76
+ navigation={createMemoryNavigator("/backoffice/ToDo")}
77
+ capabilities={capabilities}
78
+ actions={actions}
79
+ >
80
+ <ListBulkActionsBar
81
+ doctype={meta.name}
82
+ meta={meta}
83
+ selectedNames={names}
84
+ onClearSelection={onClearSelection}
85
+ onActionComplete={onActionComplete}
86
+ />
87
+ </BackofficeProvider>,
88
+ );
89
+ return { ...view, onClearSelection, onActionComplete };
90
+ }
91
+
92
+ function button(name: string, root: HTMLElement = document.body) {
93
+ return within(root).getByRole("button", { name });
94
+ }
95
+
96
+ async function dialog() {
97
+ return (await screen.findByRole("alertdialog")) as HTMLElement;
98
+ }
99
+
100
+ describe("ListBulkActionsBar", () => {
101
+ it("counts the selection and offers the desk bulk actions", () => {
102
+ renderBar();
103
+ expect(screen.getByText("2 selected")).toBeTruthy();
104
+ for (const name of ["Bulk Edit", "Export CSV", "Assign To", "Add Tags", "Delete"]) {
105
+ expect(button(name)).toBeTruthy();
106
+ }
107
+ expect(screen.queryByRole("button", { name: "Submit" })).toBeNull();
108
+ });
109
+
110
+ it("renders nothing without a selection or with bulk actions switched off", () => {
111
+ const empty = renderBar({ names: [] });
112
+ expect(screen.queryByText(/selected/)).toBeNull();
113
+ empty.unmount();
114
+ renderBar({ capabilities: { bulkActions: false } });
115
+ expect(screen.queryByText("2 selected")).toBeNull();
116
+ });
117
+
118
+ it("drops Assign To and Add Tags when their capabilities are off", () => {
119
+ renderBar({ capabilities: { assignments: false, tags: false } });
120
+ expect(screen.getByText("2 selected")).toBeTruthy();
121
+ expect(screen.queryByRole("button", { name: "Assign To" })).toBeNull();
122
+ expect(screen.queryByRole("button", { name: "Add Tags" })).toBeNull();
123
+ });
124
+
125
+ it("deletes each selected document only after the confirm, then clears and refreshes", async () => {
126
+ const { onClearSelection, onActionComplete } = renderBar();
127
+ fireEvent.click(button("Delete"));
128
+ const confirm = await dialog();
129
+ expect(within(confirm).getByText("Permanently delete 2 ToDo document(s)?")).toBeTruthy();
130
+ expect(calls.some((call) => call.method !== "GET")).toBe(false);
131
+ fireEvent.click(button("Delete", confirm));
132
+ await waitFor(() => expect(onActionComplete).toHaveBeenCalledTimes(1));
133
+ expect(onClearSelection).toHaveBeenCalledTimes(1);
134
+ const deleted = calls
135
+ .filter((call) => call.method === "DELETE" || call.path.endsWith("delete_doc"))
136
+ .map((call) => (call.method === "DELETE" ? call.path.split("/").pop() : call.body.name));
137
+ expect(deleted.sort()).toEqual(["TD-001", "TD-002"]);
138
+ });
139
+
140
+ it("submits a submittable selection through the bulk docstatus RPC", async () => {
141
+ const { onActionComplete } = renderBar({
142
+ meta: { ...todoMeta, name: "QA Submittable", is_submittable: 1 },
143
+ names: ["SUB-001", "SUB-002"],
144
+ });
145
+ expect(button("Cancel")).toBeTruthy();
146
+ fireEvent.click(button("Submit"));
147
+ const confirm = await dialog();
148
+ expect(within(confirm).getByText("Submit 2 QA Submittable document(s)?")).toBeTruthy();
149
+ fireEvent.click(button("Submit", confirm));
150
+ await waitFor(() => expect(onActionComplete).toHaveBeenCalledTimes(1));
151
+ const rpc = calls.find((call) => call.path.endsWith("submit_cancel_or_update_docs"));
152
+ expect(rpc?.body).toMatchObject({
153
+ doctype: "QA Submittable",
154
+ docnames: JSON.stringify(["SUB-001", "SUB-002"]),
155
+ action: "submit",
156
+ });
157
+ });
158
+
159
+ it("opens Bulk Edit over the selection, with Update held until a field is picked", async () => {
160
+ renderBar({ names: ["TD-001"] });
161
+ fireEvent.click(button("Bulk Edit"));
162
+ const edit = await dialog();
163
+ expect(within(edit).getByText("Set one field on 1 selected document")).toBeTruthy();
164
+ expect(button("Update", edit).getAttribute("aria-disabled")).toBe("true");
165
+ });
166
+
167
+ it("offers only the host list actions scoped to this doctype", () => {
168
+ const archive = vi.fn();
169
+ renderBar({
170
+ actions: {
171
+ list: [
172
+ { id: "archive", label: "Archive", onPress: archive },
173
+ { id: "reset", label: "Reset Password", doctypes: ["User"], onPress: vi.fn() },
174
+ ],
175
+ },
176
+ });
177
+ expect(screen.queryByRole("button", { name: "Reset Password" })).toBeNull();
178
+ fireEvent.click(button("Archive"));
179
+ expect(archive).toHaveBeenCalledWith({ doctype: "ToDo", selection: ["TD-001", "TD-002"] });
180
+ });
181
+ });
@@ -14,7 +14,11 @@ import { Button } from "@multiplatform.one/forms";
14
14
  import { useResolvedKnobs } from "@multiplatform.one/theme";
15
15
  import { useState } from "react";
16
16
  import { useTranslation } from "react-i18next";
17
- import { useBackofficeCapabilities, useBackofficeConfig } from "../config/BackofficeProvider";
17
+ import {
18
+ actionsForDoctype,
19
+ useBackofficeCapabilities,
20
+ useBackofficeConfig,
21
+ } from "../config/BackofficeProvider";
18
22
  import { BACKOFFICE_I18N_NAMESPACE } from "../i18n";
19
23
  import type { DoctypeMeta } from "../shared/useDoctypeMeta";
20
24
  import { useFrappeApp } from "../shared/useFrappeApp";
@@ -65,7 +69,7 @@ export function ListBulkActionsBar({
65
69
  const isSubmittable = meta?.is_submittable === 1;
66
70
  const showAssign = capabilities.assignments.enabled;
67
71
  const showTags = capabilities.tags.enabled;
68
- const hostActions = actions.list ?? [];
72
+ const hostActions = actionsForDoctype(actions.list, doctype);
69
73
 
70
74
  const handleDelete = async () => {
71
75
  if (!app) return;
@@ -0,0 +1,176 @@
1
+ // ListFilterBar as the list's one filter authority (MPO-414): what each
2
+ // control writes through onFiltersChange / onSortChange, the chips row, the
3
+ // liked-by heart, the saved-filter source and the compact collapse.
4
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
5
+ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
6
+ import { useState } from "react";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { BackofficeProvider } from "../config/BackofficeProvider";
9
+ import { createMemoryNavigator } from "../navigation/navigator";
10
+ import type { FrappeFilters } from "../shared/types";
11
+ import type { DoctypeMeta } from "../shared/useDoctypeMeta";
12
+ import { ListFilterBar, type ListFilterBarProps } from "./ListFilterBar";
13
+
14
+ const todoMeta: DoctypeMeta = {
15
+ name: "ToDo",
16
+ sort_field: "modified",
17
+ sort_order: "DESC",
18
+ fields: [
19
+ {
20
+ fieldname: "status",
21
+ label: "Status",
22
+ fieldtype: "Select",
23
+ options: "Open\nClosed",
24
+ in_standard_filter: 1,
25
+ },
26
+ { fieldname: "sender", label: "Sender", fieldtype: "Data", in_standard_filter: 1 },
27
+ { fieldname: "priority", label: "Priority", fieldtype: "Select", options: "High\nLow" },
28
+ ],
29
+ };
30
+
31
+ let run = 0;
32
+ let requests: URL[] = [];
33
+
34
+ function json(body: unknown, status = 200) {
35
+ return new Response(JSON.stringify(body), {
36
+ status,
37
+ headers: { "content-type": "application/json" },
38
+ });
39
+ }
40
+
41
+ beforeEach(() => {
42
+ run += 1;
43
+ requests = [];
44
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
45
+ const url = new URL(String(input instanceof Request ? input.url : input));
46
+ requests.push(url);
47
+ if (url.pathname.endsWith("frappe.auth.get_logged_user")) {
48
+ return json({ message: "jane@example.com" });
49
+ }
50
+ return json({ data: [], message: [] });
51
+ });
52
+ });
53
+
54
+ afterEach(() => {
55
+ vi.restoreAllMocks();
56
+ vi.unstubAllGlobals();
57
+ });
58
+
59
+ type BarProps = Partial<ListFilterBarProps>;
60
+
61
+ function renderBar(props: BarProps = {}) {
62
+ const onFiltersChange = vi.fn();
63
+ const onSortChange = vi.fn();
64
+ renderWithProviders(
65
+ <BackofficeProvider
66
+ frappe={{ baseURL: `https://list-filter-bar-${run}.spec.test` }}
67
+ navigation={createMemoryNavigator("/backoffice/ToDo")}
68
+ >
69
+ <ListFilterBar
70
+ doctype="ToDo"
71
+ meta={todoMeta}
72
+ filters={[]}
73
+ onFiltersChange={onFiltersChange}
74
+ onSortChange={onSortChange}
75
+ {...props}
76
+ />
77
+ </BackofficeProvider>,
78
+ );
79
+ return { onFiltersChange, onSortChange };
80
+ }
81
+
82
+ function StatefulBar({ onChange }: { onChange: (filters: FrappeFilters) => void }) {
83
+ const [filters, setFilters] = useState<FrappeFilters>([]);
84
+ return (
85
+ <ListFilterBar
86
+ doctype="ToDo"
87
+ meta={todoMeta}
88
+ filters={filters}
89
+ onFiltersChange={(next) => {
90
+ setFilters(next);
91
+ onChange(next);
92
+ }}
93
+ />
94
+ );
95
+ }
96
+
97
+ describe("ListFilterBar", () => {
98
+ it("commits a text standard filter as a like tuple once typing pauses", async () => {
99
+ const onChange = vi.fn();
100
+ renderWithProviders(
101
+ <BackofficeProvider
102
+ frappe={{ baseURL: `https://list-filter-bar-${run}.spec.test` }}
103
+ navigation={createMemoryNavigator("/backoffice/ToDo")}
104
+ >
105
+ <StatefulBar onChange={onChange} />
106
+ </BackofficeProvider>,
107
+ );
108
+ const sender = screen.getByLabelText("Sender");
109
+ const input = (
110
+ sender instanceof HTMLInputElement ? sender : sender.querySelector("input")
111
+ ) as HTMLInputElement;
112
+ fireEvent.change(input, { target: { value: "bob" } });
113
+ expect(onChange).not.toHaveBeenCalled();
114
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith([["sender", "like", "%bob%"]]));
115
+ expect(await screen.findByText("Sender like %bob%")).toBeTruthy();
116
+ });
117
+
118
+ it("shows each applied filter as a chip that removes its own tuple, or all of them", () => {
119
+ const filters: FrappeFilters = [
120
+ ["status", "=", "Open"],
121
+ ["priority", "in", ["High", "Low"]],
122
+ ];
123
+ const { onFiltersChange } = renderBar({ filters });
124
+ expect(screen.getByText("Status = Open")).toBeTruthy();
125
+ expect(screen.getByText("Priority in High, Low")).toBeTruthy();
126
+ expect(screen.getByRole("button", { name: /save filter/i })).toBeTruthy();
127
+ fireEvent.click(screen.getAllByLabelText("Remove")[1]);
128
+ expect(onFiltersChange).toHaveBeenLastCalledWith([["status", "=", "Open"]]);
129
+ fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
130
+ expect(onFiltersChange).toHaveBeenLastCalledWith([]);
131
+ });
132
+
133
+ it("leaves the chips to the host when showChips is false", () => {
134
+ renderBar({ filters: [["status", "=", "Open"]], showChips: false });
135
+ expect(screen.queryByText("Status = Open")).toBeNull();
136
+ });
137
+
138
+ it("sorts on meta's default field and flips its order", () => {
139
+ const { onSortChange } = renderBar();
140
+ expect(screen.getByRole("button", { name: "Sort" }).textContent).toContain("Last Updated On");
141
+ fireEvent.click(screen.getByRole("button", { name: "Toggle sort order (Descending)" }));
142
+ expect(onSortChange).toHaveBeenCalledWith("modified", "asc");
143
+ });
144
+
145
+ it("toggles the liked-by-me filter from the heart", () => {
146
+ const liked: FrappeFilters = [["_liked_by", "like", "%jane@example.com%"]];
147
+ const { onFiltersChange } = renderBar({ likedByUser: "jane@example.com" });
148
+ const heart = screen.getByRole("button", { name: "Liked by me" });
149
+ expect(heart.getAttribute("aria-pressed")).toBe("false");
150
+ fireEvent.click(heart);
151
+ expect(onFiltersChange).toHaveBeenCalledWith(liked);
152
+ });
153
+
154
+ it("reads the doctype's saved filters and offers Save Filter only while filters apply", async () => {
155
+ renderBar();
156
+ await waitFor(() =>
157
+ expect(
158
+ requests.some(
159
+ (url) =>
160
+ url.pathname.endsWith("frappe.client.get_list") &&
161
+ url.searchParams.get("doctype") === "List Filter" &&
162
+ url.searchParams.get("filters") ===
163
+ JSON.stringify([["reference_doctype", "=", "ToDo"]]),
164
+ ),
165
+ ).toBe(true),
166
+ );
167
+ expect(screen.queryByRole("button", { name: /save filter/i })).toBeNull();
168
+ });
169
+
170
+ it("collapses behind one counted Filter button on compact viewports", async () => {
171
+ renderBar({ compact: true, filters: [["status", "=", "Open"]] });
172
+ await act(async () => {});
173
+ expect(screen.getByRole("button", { name: "Filter" }).textContent).toContain("Filter (1)");
174
+ expect(screen.queryByText("Status = Open")).toBeNull();
175
+ });
176
+ });
@@ -0,0 +1,202 @@
1
+ // ListSidebar against a stubbed bench (MPO-414): the facet RPCs it sends,
2
+ // the rows it draws from them, and the desk toggle semantics a row press
3
+ // writes through onFiltersChange.
4
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
5
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { BackofficeProvider } from "../config/BackofficeProvider";
8
+ import type { BackofficeCapabilities } from "../config/capabilities";
9
+ import { createMemoryNavigator } from "../navigation/navigator";
10
+ import type { FrappeFilters } from "../shared/types";
11
+ import type { DoctypeMeta } from "../shared/useDoctypeMeta";
12
+ import { ListSidebar, type ListSidebarSection } from "./ListSidebar";
13
+
14
+ const todoMeta: DoctypeMeta = {
15
+ name: "ToDo",
16
+ fields: [
17
+ { fieldname: "status", fieldtype: "Select", label: "Status", options: "Open\nClosed" },
18
+ { fieldname: "description", fieldtype: "Text Editor", label: "Description" },
19
+ ],
20
+ };
21
+
22
+ interface FacetCall {
23
+ method: string;
24
+ body: Record<string, string>;
25
+ }
26
+
27
+ let run = 0;
28
+ let calls: FacetCall[] = [];
29
+ let failFacets = false;
30
+
31
+ function json(body: unknown, status = 200) {
32
+ return new Response(JSON.stringify(body), {
33
+ status,
34
+ headers: { "content-type": "application/json" },
35
+ });
36
+ }
37
+
38
+ const groupBy: Record<string, { name: string | null; count: number }[]> = {
39
+ assigned_to: [
40
+ { name: "jane@example.com", count: 2 },
41
+ { name: "Administrator", count: 1 },
42
+ { name: null, count: 3 },
43
+ ],
44
+ owner: [
45
+ { name: "joe@example.com", count: 2 },
46
+ { name: "jane@example.com", count: 1 },
47
+ { name: "Guest", count: 1 },
48
+ ],
49
+ };
50
+
51
+ const columnStats: Record<string, [string, number][]> = {
52
+ _user_tags: [
53
+ ["urgent", 2],
54
+ ["No Tags", 1],
55
+ ],
56
+ status: [
57
+ ["Closed", 1],
58
+ ["Open", 3],
59
+ ["", 2],
60
+ ],
61
+ };
62
+
63
+ beforeEach(() => {
64
+ run += 1;
65
+ calls = [];
66
+ failFacets = false;
67
+ vi.stubGlobal("fetch", async (input: string | URL | Request, init?: RequestInit) => {
68
+ const url = new URL(String(input instanceof Request ? input.url : input));
69
+ const method = url.pathname.replace(/^\/api\/method\//, "");
70
+ const raw = typeof init?.body === "string" ? init.body : "";
71
+ const body = (raw.startsWith("{") ? JSON.parse(raw) : {}) as Record<string, string>;
72
+ if (method === "frappe.auth.get_logged_user") return json({ message: "jane@example.com" });
73
+ if (method.startsWith("frappe.desk.")) {
74
+ calls.push({ method, body });
75
+ if (failFacets) return json({ exc_type: "PermissionError" }, 403);
76
+ }
77
+ if (method === "frappe.desk.listview.get_group_by_count") {
78
+ return json({ message: groupBy[body.field] ?? [] });
79
+ }
80
+ if (method === "frappe.desk.reportview.get_sidebar_stats") {
81
+ const [fieldname] = JSON.parse(body.stats) as string[];
82
+ return json({ message: { stats: { [fieldname]: columnStats[fieldname] ?? [] } } });
83
+ }
84
+ return json({ data: [], message: [] });
85
+ });
86
+ });
87
+
88
+ afterEach(() => {
89
+ vi.restoreAllMocks();
90
+ vi.unstubAllGlobals();
91
+ });
92
+
93
+ interface SidebarOptions {
94
+ meta?: DoctypeMeta;
95
+ filters?: FrappeFilters;
96
+ hiddenSections?: ListSidebarSection[];
97
+ capabilities?: BackofficeCapabilities;
98
+ }
99
+
100
+ function renderSidebar({
101
+ meta = todoMeta,
102
+ filters = [],
103
+ hiddenSections,
104
+ capabilities,
105
+ }: SidebarOptions = {}) {
106
+ const onFiltersChange = vi.fn();
107
+ renderWithProviders(
108
+ <BackofficeProvider
109
+ frappe={{ baseURL: `https://list-sidebar-${run}.spec.test` }}
110
+ navigation={createMemoryNavigator("/backoffice/ToDo")}
111
+ capabilities={capabilities}
112
+ >
113
+ <ListSidebar
114
+ doctype="ToDo"
115
+ meta={meta}
116
+ filters={filters}
117
+ onFiltersChange={onFiltersChange}
118
+ hiddenSections={hiddenSections}
119
+ />
120
+ </BackofficeProvider>,
121
+ );
122
+ return { onFiltersChange };
123
+ }
124
+
125
+ function facetRow(label: string) {
126
+ return screen.getByRole("button", { name: label });
127
+ }
128
+
129
+ describe("ListSidebar", () => {
130
+ it("draws each facet from the bench, the session user pinned as Me and desk noise scrubbed", async () => {
131
+ renderSidebar();
132
+ await waitFor(() => expect(screen.getAllByRole("button", { name: "Me" })).toHaveLength(2));
133
+ for (const title of ["Assigned To", "Created By", "Tags", "Status"]) {
134
+ expect(screen.getByText(title)).toBeTruthy();
135
+ }
136
+ expect(facetRow("joe@example.com").textContent).toContain("2");
137
+ expect(facetRow("Open").textContent).toContain("3");
138
+ expect(facetRow("urgent")).toBeTruthy();
139
+ expect(facetRow("No Tags")).toBeTruthy();
140
+ expect(screen.queryByRole("button", { name: "Administrator" })).toBeNull();
141
+ expect(screen.queryByRole("button", { name: "Guest" })).toBeNull();
142
+ });
143
+
144
+ it("counts each facet under every filter but its own", async () => {
145
+ renderSidebar({
146
+ filters: [
147
+ ["status", "=", "Open"],
148
+ ["owner", "=", "joe@example.com"],
149
+ ],
150
+ });
151
+ await waitFor(() => expect(calls).toHaveLength(4));
152
+ const owner = calls.find((call) => call.body.field === "owner");
153
+ expect(owner?.body).toMatchObject({
154
+ doctype: "ToDo",
155
+ current_filters: JSON.stringify([["status", "=", "Open"]]),
156
+ });
157
+ const status = calls.find((call) => call.body.stats === JSON.stringify(["status"]));
158
+ expect(status?.body.filters).toBe(JSON.stringify([["owner", "=", "joe@example.com"]]));
159
+ });
160
+
161
+ it("replaces a facet's filter on press and clears it when the active row is pressed", async () => {
162
+ const filters: FrappeFilters = [
163
+ ["owner", "=", "joe@example.com"],
164
+ ["status", "=", "Closed"],
165
+ ];
166
+ const { onFiltersChange } = renderSidebar({ filters });
167
+ await waitFor(() => expect(facetRow("Open")).toBeTruthy());
168
+ expect(facetRow("Closed").getAttribute("aria-pressed")).toBe("true");
169
+ fireEvent.click(facetRow("Open"));
170
+ expect(onFiltersChange).toHaveBeenLastCalledWith([
171
+ ["owner", "=", "joe@example.com"],
172
+ ["status", "=", "Open"],
173
+ ]);
174
+ fireEvent.click(facetRow("Closed"));
175
+ expect(onFiltersChange).toHaveBeenLastCalledWith([["owner", "=", "joe@example.com"]]);
176
+ fireEvent.click(facetRow("No Tags"));
177
+ expect(onFiltersChange).toHaveBeenLastCalledWith([
178
+ ...filters,
179
+ ["_user_tags", "not like", "%,%"],
180
+ ]);
181
+ });
182
+
183
+ it("drops sections hidden by the host or its capabilities, and the status facet without a status field", async () => {
184
+ renderSidebar({
185
+ meta: { name: "ToDo", fields: [] },
186
+ hiddenSections: ["owner"],
187
+ capabilities: { tags: false },
188
+ });
189
+ await waitFor(() => expect(facetRow("Me")).toBeTruthy());
190
+ expect(screen.getByText("Assigned To")).toBeTruthy();
191
+ for (const title of ["Created By", "Tags", "Status"]) {
192
+ expect(screen.queryByText(title)).toBeNull();
193
+ }
194
+ expect(calls.some((call) => call.body.stats === JSON.stringify(["status"]))).toBe(false);
195
+ });
196
+
197
+ it("says the sidebar is unavailable when every facet fails", async () => {
198
+ failFacets = true;
199
+ renderSidebar();
200
+ expect(await screen.findByText("Filters sidebar unavailable")).toBeTruthy();
201
+ });
202
+ });