@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
@@ -944,53 +944,62 @@ export function DocFormScreen({
944
944
  })
945
945
  : toolbar}
946
946
 
947
- {engine.wasDeleted && (
948
- <XStack
949
- role="alert"
950
- {...knobProps.containerRadius}
951
- {...knobProps.panelPadding}
952
- marginHorizontal="$3"
953
- backgroundColor="$red3"
954
- >
955
- <Text color="$red11" fontSize="$3">
956
- {t("deletedBanner", "This document was deleted on the server. Fields are read-only.")}
957
- </Text>
958
- </XStack>
959
- )}
947
+ {(engine.wasDeleted || validationSummary || engine.conflict) && (
948
+ <YStack paddingHorizontal="$3">
949
+ {engine.wasDeleted && (
950
+ <XStack
951
+ role="alert"
952
+ {...knobProps.containerRadius}
953
+ {...knobProps.panelPadding}
954
+ backgroundColor="$red3"
955
+ >
956
+ <Text color="$red11" fontSize="$3">
957
+ {t(
958
+ "deletedBanner",
959
+ "This document was deleted on the server. Fields are read-only.",
960
+ )}
961
+ </Text>
962
+ </XStack>
963
+ )}
960
964
 
961
- {validationSummary && !engine.wasDeleted && (
962
- <XStack
963
- role="alert"
964
- {...knobProps.containerRadius}
965
- {...knobProps.panelPadding}
966
- marginHorizontal="$3"
967
- backgroundColor="$red3"
968
- testID="doc-form-validation-summary"
969
- >
970
- <Text color="$red11" fontSize="$3">
971
- {validationSummary}
972
- </Text>
973
- </XStack>
974
- )}
965
+ {validationSummary && !engine.wasDeleted && (
966
+ <XStack
967
+ role="alert"
968
+ {...knobProps.containerRadius}
969
+ {...knobProps.panelPadding}
970
+ backgroundColor="$red3"
971
+ testID="doc-form-validation-summary"
972
+ >
973
+ <Text color="$red11" fontSize="$3">
974
+ {validationSummary}
975
+ </Text>
976
+ </XStack>
977
+ )}
975
978
 
976
- {engine.conflict && !engine.wasDeleted && (
977
- <XStack
978
- role="status"
979
- alignItems="center"
980
- justifyContent="space-between"
981
- {...knobProps.containerRadius}
982
- {...knobProps.panelPadding}
983
- marginHorizontal="$3"
984
- backgroundColor="$orange3"
985
- gap="$3"
986
- >
987
- <Text color="$orange11" fontSize="$3" flex={1}>
988
- {t("conflictBanner", "This document changed on the server while you were editing.")}
989
- </Text>
990
- <Button size="$2" outlined aria-label={t("reloadDoc", "Reload")} onPress={engine.reload}>
991
- {t("reloadDoc", "Reload")}
992
- </Button>
993
- </XStack>
979
+ {engine.conflict && !engine.wasDeleted && (
980
+ <XStack
981
+ role="status"
982
+ alignItems="center"
983
+ justifyContent="space-between"
984
+ {...knobProps.containerRadius}
985
+ {...knobProps.panelPadding}
986
+ backgroundColor="$orange3"
987
+ gap="$3"
988
+ >
989
+ <Text color="$orange11" fontSize="$3" flex={1}>
990
+ {t("conflictBanner", "This document changed on the server while you were editing.")}
991
+ </Text>
992
+ <Button
993
+ size="$2"
994
+ outlined
995
+ aria-label={t("reloadDoc", "Reload")}
996
+ onPress={engine.reload}
997
+ >
998
+ {t("reloadDoc", "Reload")}
999
+ </Button>
1000
+ </XStack>
1001
+ )}
1002
+ </YStack>
994
1003
  )}
995
1004
 
996
1005
  <ScrollView flex={1}>
@@ -0,0 +1,153 @@
1
+ // The desk Connections tab (MPO-414): meta.links grouped with get_open_count
2
+ // badges, rows open the filtered target list, + opens a prefilled new doc,
3
+ // and a failed get_open_count falls back to get_count per external link.
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 } from "../config/BackofficeProvider";
8
+ import { createMemoryNavigator } from "../navigation/navigator";
9
+ import type { DoctypeMeta } from "../shared/useDoctypeMeta";
10
+ import { FormConnections } from "./FormConnections";
11
+
12
+ const projectMeta: DoctypeMeta = {
13
+ name: "QA Project",
14
+ fields: [],
15
+ links: [
16
+ { link_doctype: "Contact", link_fieldname: "project", group: "Profile" },
17
+ { link_doctype: "Blogger", link_fieldname: "project", group: "Profile" },
18
+ { link_doctype: "Access Log", link_fieldname: "project", group: "Logs" },
19
+ { link_doctype: "ToDo", link_fieldname: "project" },
20
+ { link_doctype: "Note", link_fieldname: "project", group: "Logs", hidden: 1 },
21
+ {
22
+ link_doctype: "QA Invoice Item",
23
+ link_fieldname: "qa_invoice",
24
+ parent_doctype: "QA Invoice",
25
+ table_fieldname: "items",
26
+ is_child_table: 1,
27
+ group: "Billing",
28
+ },
29
+ ],
30
+ };
31
+
32
+ const openCount = {
33
+ message: {
34
+ count: {
35
+ external_links_found: [
36
+ { doctype: "Contact", count: 3, open_count: 1 },
37
+ { doctype: "Blogger", count: 0, open_count: 0 },
38
+ { doctype: "Access Log", count: 120, open_count: 0 },
39
+ { doctype: "ToDo", count: 5, open_count: 2 },
40
+ ],
41
+ internal_links_found: [
42
+ { doctype: "QA Invoice", count: 2, open_count: 0, names: ["QA-INV-001", "QA-INV-002"] },
43
+ ],
44
+ },
45
+ },
46
+ };
47
+
48
+ let host = 0;
49
+ let openCountFails = false;
50
+ let requests: URL[] = [];
51
+
52
+ function json(body: unknown, status = 200) {
53
+ return new Response(JSON.stringify(body), {
54
+ status,
55
+ headers: { "content-type": "application/json" },
56
+ });
57
+ }
58
+
59
+ beforeEach(() => {
60
+ host += 1;
61
+ openCountFails = false;
62
+ requests = [];
63
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
64
+ const url = new URL(String(input instanceof Request ? input.url : input));
65
+ requests.push(url);
66
+ if (url.pathname === "/api/method/frappe.desk.notifications.get_open_count") {
67
+ return openCountFails ? json({ exc_type: "PermissionError" }, 403) : json(openCount);
68
+ }
69
+ if (url.pathname === "/api/method/frappe.client.get_count") {
70
+ return json({ message: url.searchParams.get("doctype") === "Contact" ? 4 : 0 });
71
+ }
72
+ return json({ message: null });
73
+ });
74
+ });
75
+
76
+ afterEach(() => {
77
+ vi.restoreAllMocks();
78
+ vi.unstubAllGlobals();
79
+ });
80
+
81
+ function renderConnections(meta: DoctypeMeta = projectMeta) {
82
+ const navigation = createMemoryNavigator("/backoffice/qa-project/QA-PROJ-0001");
83
+ renderWithProviders(
84
+ <BackofficeProvider
85
+ frappe={{ baseURL: `https://connections-${host}.screen.spec.test` }}
86
+ navigation={navigation}
87
+ >
88
+ <FormConnections doctype="QA Project" name="QA-PROJ-0001" meta={meta} />
89
+ </BackofficeProvider>,
90
+ );
91
+ return navigation;
92
+ }
93
+
94
+ function row(doctype: string): HTMLElement {
95
+ return screen.getByRole("link", { name: doctype });
96
+ }
97
+
98
+ describe("FormConnections", () => {
99
+ it("groups visible links and badges their counts", async () => {
100
+ renderConnections();
101
+ await waitFor(() => expect(within(row("Contact")).getByText("3")).toBeTruthy());
102
+ for (const heading of ["Profile", "Logs", "Reference", "Billing"]) {
103
+ expect(screen.getByText(heading)).toBeTruthy();
104
+ }
105
+ expect(screen.queryByRole("link", { name: "Note" })).toBeNull();
106
+ expect(within(row("Contact")).getByText("1")).toBeTruthy();
107
+ expect(within(row("Access Log")).getByText("99+")).toBeTruthy();
108
+ expect(row("Blogger").textContent).toBe("Blogger");
109
+ });
110
+
111
+ it("opens the target list filtered to this document", async () => {
112
+ const navigation = renderConnections();
113
+ await waitFor(() => expect(within(row("Contact")).getByText("3")).toBeTruthy());
114
+ fireEvent.click(row("Contact"));
115
+ const location = navigation.getLocation();
116
+ expect(location.path).toBe("/backoffice/contact");
117
+ expect(JSON.parse(String(location.params.f))).toEqual([["project", "=", "QA-PROJ-0001"]]);
118
+ fireEvent.click(row("QA Invoice"));
119
+ expect(JSON.parse(String(navigation.getLocation().params.f))).toEqual([
120
+ ["name", "in", ["QA-INV-001", "QA-INV-002"]],
121
+ ]);
122
+ });
123
+
124
+ it("creates a linked document with the back-reference prefilled", async () => {
125
+ const navigation = renderConnections();
126
+ await waitFor(() => expect(within(row("Contact")).getByText("3")).toBeTruthy());
127
+ fireEvent.click(screen.getByLabelText("New Contact"));
128
+ const location = navigation.getLocation();
129
+ expect(location.path).toBe("/backoffice/contact/new");
130
+ expect(JSON.parse(String(location.params.d))).toEqual({ project: "QA-PROJ-0001" });
131
+ });
132
+
133
+ it("falls back to get_count and disables the uncounted internal link", async () => {
134
+ openCountFails = true;
135
+ const navigation = renderConnections();
136
+ await waitFor(() => expect(within(row("Contact")).getByText("4")).toBeTruthy(), {
137
+ timeout: 5000,
138
+ });
139
+ const counted = requests
140
+ .filter((url) => url.pathname === "/api/method/frappe.client.get_count")
141
+ .map((url) => url.searchParams.get("doctype"))
142
+ .sort();
143
+ expect(counted).toEqual(["Access Log", "Blogger", "Contact", "ToDo"]);
144
+ fireEvent.click(row("QA Invoice"));
145
+ expect(navigation.getLocation().path).toBe("/backoffice/qa-project/QA-PROJ-0001");
146
+ });
147
+
148
+ it("says so when the doctype links nowhere", async () => {
149
+ renderConnections({ name: "QA Loner", fields: [], links: [] });
150
+ expect(await screen.findByText("No connections")).toBeTruthy();
151
+ expect(requests).toHaveLength(0);
152
+ });
153
+ });
@@ -0,0 +1,119 @@
1
+ // The form menu's input dialogs (MPO-414): each confirm stays gated until
2
+ // its input is usable, then hands the trimmed value to the caller.
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { fireEvent, screen } from "@testing-library/react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { BackofficeProvider } from "../config/BackofficeProvider";
7
+ import { createMemoryNavigator } from "../navigation/navigator";
8
+ import { AssignDialog, EmailDialog, JumpToFieldDialog, RenameDialog } from "./FormDialogs";
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ function button(label: string): HTMLElement {
15
+ const found = Array.from(document.body.querySelectorAll('[role="button"], button')).find((node) =>
16
+ node.textContent?.trim().startsWith(label),
17
+ );
18
+ if (!found) throw new Error(`no button ${label}`);
19
+ return found as HTMLElement;
20
+ }
21
+
22
+ function input(label: string): HTMLInputElement | HTMLTextAreaElement {
23
+ const field = screen.getByLabelText(label);
24
+ return (
25
+ field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement
26
+ ? field
27
+ : field.querySelector("input, textarea")
28
+ ) as HTMLInputElement;
29
+ }
30
+
31
+ function gated(label: string): boolean {
32
+ return button(label).getAttribute("aria-disabled") === "true";
33
+ }
34
+
35
+ describe("RenameDialog", () => {
36
+ it("renames only to a different, trimmed name", () => {
37
+ const onRename = vi.fn();
38
+ renderWithProviders(
39
+ <RenameDialog open onOpenChange={() => {}} currentName="TODO-0001" onRename={onRename} />,
40
+ );
41
+ expect(gated("Rename")).toBe(true);
42
+ fireEvent.change(input("New name"), { target: { value: " TODO-0001 " } });
43
+ expect(gated("Rename")).toBe(true);
44
+ fireEvent.change(input("New name"), { target: { value: " TODO-0042 " } });
45
+ expect(gated("Rename")).toBe(false);
46
+ fireEvent.click(button("Rename"));
47
+ expect(onRename).toHaveBeenCalledWith("TODO-0042");
48
+ });
49
+ });
50
+
51
+ describe("EmailDialog", () => {
52
+ it("sends only with recipients and a subject", () => {
53
+ const onSend = vi.fn();
54
+ renderWithProviders(
55
+ <EmailDialog open onOpenChange={() => {}} defaultSubject="ToDo TODO-0001" onSend={onSend} />,
56
+ );
57
+ expect(input("Subject").value).toBe("ToDo TODO-0001");
58
+ expect(gated("Send")).toBe(true);
59
+ fireEvent.change(input("To (comma-separated emails)"), {
60
+ target: { value: " jane@example.com " },
61
+ });
62
+ fireEvent.change(input("Message"), { target: { value: "See attached." } });
63
+ fireEvent.click(button("Send"));
64
+ expect(onSend).toHaveBeenCalledWith({
65
+ recipients: "jane@example.com",
66
+ subject: "ToDo TODO-0001",
67
+ content: "See attached.",
68
+ });
69
+ });
70
+ });
71
+
72
+ describe("AssignDialog", () => {
73
+ it("will not assign before a user is picked", () => {
74
+ const onAssign = vi.fn();
75
+ renderWithProviders(
76
+ <BackofficeProvider
77
+ frappe={{ baseURL: "https://form-dialogs.screen.spec.test" }}
78
+ navigation={createMemoryNavigator("/backoffice")}
79
+ >
80
+ <AssignDialog open onOpenChange={() => {}} onAssign={onAssign} />
81
+ </BackofficeProvider>,
82
+ );
83
+ expect(screen.getByText("Assign to")).toBeTruthy();
84
+ expect(gated("Assign")).toBe(true);
85
+ fireEvent.click(button("Assign"));
86
+ expect(onAssign).not.toHaveBeenCalled();
87
+ });
88
+ });
89
+
90
+ describe("JumpToFieldDialog", () => {
91
+ const fields = [
92
+ { name: "customer", label: "Customer", tabKey: "details", tabLabel: "Details" },
93
+ { name: "due_date", label: "Due Date", tabKey: "details", tabLabel: "Details" },
94
+ { name: "terms", label: "Payment Terms", tabKey: "terms", tabLabel: "Terms" },
95
+ ];
96
+
97
+ it("filters fields by label or fieldname and jumps to a picked one", () => {
98
+ const onJump = vi.fn();
99
+ renderWithProviders(
100
+ <JumpToFieldDialog open onOpenChange={() => {}} fields={fields} onJump={onJump} />,
101
+ );
102
+ fireEvent.change(input("Search fields…"), { target: { value: "due" } });
103
+ expect(screen.queryByText("Customer")).toBeNull();
104
+ fireEvent.click(screen.getByText("Due Date"));
105
+ expect(onJump).toHaveBeenCalledWith(fields[1]);
106
+ });
107
+
108
+ it("jumps to the first match on confirm and gates an empty result", () => {
109
+ const onJump = vi.fn();
110
+ renderWithProviders(
111
+ <JumpToFieldDialog open onOpenChange={() => {}} fields={fields} onJump={onJump} />,
112
+ );
113
+ fireEvent.change(input("Search fields…"), { target: { value: "terms" } });
114
+ fireEvent.click(button("Jump"));
115
+ expect(onJump).toHaveBeenCalledWith(fields[2]);
116
+ fireEvent.change(input("Search fields…"), { target: { value: "nothing like it" } });
117
+ expect(gated("Jump")).toBe(true);
118
+ });
119
+ });
@@ -0,0 +1,147 @@
1
+ // The desk form toolbar (MPO-414): one lifecycle primary per docstatus, no
2
+ // primary the session's rights withhold (MPO-390), the status pill, the ⋯
3
+ // menu and the prev/next pager.
4
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
5
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import {
8
+ FormToolbar,
9
+ resolveIndicator,
10
+ type FormToolbarMenuItem,
11
+ type FormToolbarProps,
12
+ } from "./FormToolbar";
13
+
14
+ function props(overrides: Partial<FormToolbarProps> = {}): FormToolbarProps {
15
+ return {
16
+ breadcrumbs: [
17
+ { label: "Home", href: "/backoffice" },
18
+ { label: "Sales Order", href: "/backoffice/sales-order" },
19
+ { label: "SO-0001" },
20
+ ],
21
+ onNavigate: vi.fn(),
22
+ title: "SO-0001",
23
+ indicator: "draft",
24
+ isSaving: false,
25
+ canSave: true,
26
+ canSubmit: true,
27
+ canCancel: false,
28
+ canAmend: false,
29
+ onSave: vi.fn(),
30
+ onSubmit: vi.fn(),
31
+ onCancel: vi.fn(),
32
+ onAmend: vi.fn(),
33
+ menuItems: [],
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ const primaries = ["save", "submit", "cancel", "amend"] as const;
39
+
40
+ function visiblePrimaries(): string[] {
41
+ return primaries.filter((action) => screen.queryByTestId(`form-toolbar-${action}`));
42
+ }
43
+
44
+ describe("FormToolbar", () => {
45
+ it("offers Save on a dirty draft and Submit once it is saved", () => {
46
+ const onSave = vi.fn();
47
+ const onSubmit = vi.fn();
48
+ const { rerender } = renderWithProviders(
49
+ <FormToolbar {...props({ indicator: "notSaved", onSave, onSubmit })} />,
50
+ );
51
+ expect(visiblePrimaries()).toEqual(["save"]);
52
+ fireEvent.click(screen.getByTestId("form-toolbar-save"));
53
+ expect(onSave).toHaveBeenCalledTimes(1);
54
+ rerender(<FormToolbar {...props({ indicator: "draft", onSave, onSubmit })} />);
55
+ expect(visiblePrimaries()).toEqual(["submit"]);
56
+ fireEvent.click(screen.getByTestId("form-toolbar-submit"));
57
+ expect(onSubmit).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ it("offers Cancel on a submitted doc and Amend on a cancelled one", () => {
61
+ const onCancel = vi.fn();
62
+ const { rerender } = renderWithProviders(
63
+ <FormToolbar
64
+ {...props({ indicator: "submitted", canSave: false, canSubmit: false, canCancel: true })}
65
+ onCancel={onCancel}
66
+ />,
67
+ );
68
+ expect(visiblePrimaries()).toEqual(["cancel"]);
69
+ fireEvent.click(screen.getByTestId("form-toolbar-cancel"));
70
+ expect(onCancel).toHaveBeenCalledTimes(1);
71
+ rerender(
72
+ <FormToolbar
73
+ {...props({ indicator: "cancelled", canSave: false, canSubmit: false, canAmend: true })}
74
+ />,
75
+ );
76
+ expect(visiblePrimaries()).toEqual(["amend"]);
77
+ });
78
+
79
+ it("draws no primary a reader's rights withhold", () => {
80
+ renderWithProviders(
81
+ <FormToolbar {...props({ indicator: "notSaved", canSave: false, canSubmit: false })} />,
82
+ );
83
+ expect(visiblePrimaries()).toEqual([]);
84
+ });
85
+
86
+ it("pills the status from meta.states before guessing a color", () => {
87
+ const { rerender } = renderWithProviders(<FormToolbar {...props({ indicator: "new" })} />);
88
+ expect(screen.getByText("Not Saved")).toBeTruthy();
89
+ const approved = resolveIndicator({
90
+ isCreate: false,
91
+ isDirty: false,
92
+ docstatus: 1,
93
+ isSubmittable: true,
94
+ statusValue: "Approved",
95
+ states: [{ title: "Approved", color: "Green" }],
96
+ });
97
+ expect(approved).toEqual({ label: "Approved", color: "green" });
98
+ rerender(<FormToolbar {...props({ indicator: approved })} />);
99
+ expect(screen.getByText("Approved")).toBeTruthy();
100
+ expect(screen.queryByText("Submitted")).toBeNull();
101
+ rerender(<FormToolbar {...props({ indicator: "saved" })} />);
102
+ for (const label of ["Not Saved", "Draft", "Submitted", "Cancelled", "Approved"]) {
103
+ expect(screen.queryByText(label)).toBeNull();
104
+ }
105
+ });
106
+
107
+ it("runs ⋯ menu items and holds the disabled ones", async () => {
108
+ const onDuplicate = vi.fn();
109
+ const onDelete = vi.fn();
110
+ const menuItems: FormToolbarMenuItem[] = [
111
+ { key: "duplicate", label: "Duplicate", icon: "duplicate", onSelect: onDuplicate },
112
+ {
113
+ key: "delete",
114
+ label: "Delete",
115
+ icon: "delete",
116
+ destructive: true,
117
+ disabled: true,
118
+ disabledReason: "No delete permission",
119
+ onSelect: onDelete,
120
+ },
121
+ ];
122
+ renderWithProviders(<FormToolbar {...props({ menuItems })} />);
123
+ fireEvent.click(screen.getByTestId("form-toolbar-menu"));
124
+ const item = (label: string) =>
125
+ Array.from(document.querySelectorAll('[role="menuitem"]')).find((node) =>
126
+ node.textContent?.startsWith(label),
127
+ ) as HTMLElement;
128
+ await waitFor(() => expect(item("Delete")).toBeTruthy());
129
+ expect(item("Delete").getAttribute("aria-disabled")).toBe("true");
130
+ fireEvent.click(item("Delete"));
131
+ expect(onDelete).not.toHaveBeenCalled();
132
+ fireEvent.click(item("Duplicate"));
133
+ expect(onDuplicate).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it("pages prev/next and disables the boundary arrow instead of hiding it", () => {
137
+ const onPrevDoc = vi.fn();
138
+ const onNextDoc = vi.fn();
139
+ const { rerender } = renderWithProviders(<FormToolbar {...props()} />);
140
+ expect(screen.queryByTestId("form-toolbar-prev")).toBeNull();
141
+ rerender(<FormToolbar {...props({ onPrevDoc, onNextDoc, prevDocDisabled: true })} />);
142
+ fireEvent.click(screen.getByTestId("form-toolbar-prev"));
143
+ fireEvent.click(screen.getByTestId("form-toolbar-next"));
144
+ expect(onPrevDoc).not.toHaveBeenCalled();
145
+ expect(onNextDoc).toHaveBeenCalledTimes(1);
146
+ });
147
+ });
@@ -5,12 +5,14 @@
5
5
  * initials fallback — desk parity for user chips.
6
6
  */
7
7
 
8
- import { Avatar, Button, Text, XStack, YStack } from "@multiplatform.one/components";
8
+ import { Avatar, Button, styled, Text, XStack, YStack } from "@multiplatform.one/components";
9
9
  import { useResolvedKnobs } from "@multiplatform.one/theme";
10
10
  import { XIcon } from "@phosphor-icons/react";
11
11
  import { useTranslation } from "react-i18next";
12
12
  import { BACKOFFICE_I18N_NAMESPACE } from "../i18n";
13
13
 
14
+ const SidebarAvatarSlot = styled(XStack, { name: "SidebarAvatarSlot" });
15
+
14
16
  export interface SidebarPersonItem {
15
17
  /** Stable row id (assignment/share row name, or the user id). */
16
18
  id: string;
@@ -104,12 +106,12 @@ export function SidebarAvatarStrip({ items, max }: SidebarAvatarStripProps) {
104
106
  return (
105
107
  <XStack alignItems="center">
106
108
  {visible.map((item, index) => (
107
- <XStack key={item.id} marginInlineStart={index === 0 ? 0 : -6}>
109
+ <SidebarAvatarSlot key={item.id} marginInlineStart={index === 0 ? 0 : -6}>
108
110
  <Avatar size="$1" src={item.image} name={item.label} alt={item.label} />
109
- </XStack>
111
+ </SidebarAvatarSlot>
110
112
  ))}
111
113
  {overflow > 0 && (
112
- <XStack marginInlineStart={-6}>
114
+ <SidebarAvatarSlot marginInlineStart={-6}>
113
115
  <Avatar
114
116
  size="$1"
115
117
  alt={`+${overflow}`}
@@ -119,7 +121,7 @@ export function SidebarAvatarStrip({ items, max }: SidebarAvatarStripProps) {
119
121
  </Text>
120
122
  }
121
123
  />
122
- </XStack>
124
+ </SidebarAvatarSlot>
123
125
  )}
124
126
  </XStack>
125
127
  );
package/src/i18n/en.ts CHANGED
@@ -405,7 +405,7 @@ export const en = {
405
405
  list: "List",
406
406
  loadFailed: "Couldn't load this view",
407
407
  loading: "Loading",
408
- new: "New",
408
+ new: "New {{doctype}}",
409
409
  report: "Report",
410
410
  switchView: "Switch view",
411
411
  tree: "Tree",