@multiplatform.one/backoffice 7.7.0 → 7.7.1

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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * DoctypeViewScreen specs (MPO-189) — the alternate-view dispatcher, over
3
+ * the same fixture shape its stories use.
4
+ *
5
+ * What is pinned here is DISPATCH, not any individual view's chrome: the
6
+ * shared header rides above every view, view="list" bounces to the list
7
+ * route by REPLACE, and the search-param overrides are read here rather
8
+ * than in the leaf views.
9
+ */
10
+ import React from "react";
11
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
12
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
13
+ import { screen, waitFor } from "@testing-library/react";
14
+ import { describe, expect, it } from "vitest";
15
+ import { BackofficeProvider } from "../config/BackofficeProvider";
16
+ import { createMemoryNavigator, type MemoryNavigator } from "../navigation/navigator";
17
+ import type { BackofficeView } from "../navigation/paths";
18
+ import { DoctypeViewScreen } from "./DoctypeViewScreen";
19
+
20
+ const MOCK_HOST = "https://backoffice-doctype-view-spec.mock.test";
21
+ const TASK_DOCTYPE = "QaViewTask";
22
+
23
+ const taskMeta = {
24
+ name: TASK_DOCTYPE,
25
+ doctype: "DocType",
26
+ module: "QA",
27
+ title_field: "subject",
28
+ fields: [
29
+ { fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 },
30
+ {
31
+ fieldname: "status",
32
+ fieldtype: "Select",
33
+ label: "Status",
34
+ options: "Open\nWorking\nDone",
35
+ in_list_view: 1,
36
+ },
37
+ ],
38
+ permissions: [{ role: "All", read: 1 }],
39
+ };
40
+
41
+ const makeFixtures = () =>
42
+ new InMemoryFixtureProvider({
43
+ DocType: [taskMeta],
44
+ "Notification Log": [],
45
+ [TASK_DOCTYPE]: [{ name: "QVT-0001", doctype: TASK_DOCTYPE, subject: "First", status: "Open" }],
46
+ });
47
+
48
+ function renderView(view: BackofficeView, path = "/backoffice") {
49
+ const navigation: MemoryNavigator = createMemoryNavigator(path);
50
+ renderWithProviders(
51
+ <BackofficeProvider
52
+ frappe={{ baseURL: MOCK_HOST, fixtures: makeFixtures() }}
53
+ navigation={navigation}
54
+ >
55
+ <DoctypeViewScreen doctype={TASK_DOCTYPE} view={view} />
56
+ </BackofficeProvider>,
57
+ );
58
+ return navigation;
59
+ }
60
+
61
+ describe("<DoctypeViewScreen />", () => {
62
+ it("renders the shared header above every dispatched view", async () => {
63
+ for (const view of ["report", "kanban", "dashboard"] as const) {
64
+ const { unmount } = renderWithProviders(
65
+ <BackofficeProvider
66
+ frappe={{ baseURL: MOCK_HOST, fixtures: makeFixtures() }}
67
+ navigation={createMemoryNavigator("/backoffice")}
68
+ >
69
+ <DoctypeViewScreen doctype={TASK_DOCTYPE} view={view} />
70
+ </BackofficeProvider>,
71
+ );
72
+ // The doctype name is the page title on every view — that is the
73
+ // header, and it is what makes the switcher reachable from each one.
74
+ await waitFor(() => {
75
+ expect(screen.getAllByText(TASK_DOCTYPE).length).toBeGreaterThan(0);
76
+ });
77
+ unmount();
78
+ }
79
+ });
80
+
81
+ it('view="list" REPLACES the location with the list route, keeping Back working', async () => {
82
+ const navigation = renderView("list");
83
+ await waitFor(() => {
84
+ expect(navigation.getLocation().path).toBe("/backoffice/qaviewtask");
85
+ });
86
+ // replace, not push: the /view/list address never enters history.
87
+ navigation.back();
88
+ expect(navigation.getLocation().path).toBe("/backoffice/qaviewtask");
89
+ });
90
+
91
+ it("reads ?board= from the location so the leaf view stays param-free", async () => {
92
+ const navigation = renderView(
93
+ "kanban",
94
+ "/backoffice/qaviewtask/view/kanban?board=QA%20View%20Board",
95
+ );
96
+ expect(navigation.getLocation().params.board).toBe("QA View Board");
97
+ await waitFor(() => {
98
+ expect(screen.getAllByText(TASK_DOCTYPE).length).toBeGreaterThan(0);
99
+ });
100
+ });
101
+
102
+ it("renders a view the doctype's meta does not support rather than blanking", async () => {
103
+ // Tree on a flat doctype: the body owns the unsupported state, and the
104
+ // screen still paints its header instead of rendering nothing.
105
+ renderView("tree");
106
+ await waitFor(() => {
107
+ expect(screen.getAllByText(TASK_DOCTYPE).length).toBeGreaterThan(0);
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,279 @@
1
+ // OWNER: backoffice screens workstream (MPO-189).
2
+ // DoctypeViewScreen stories: the alternate-view dispatcher. One arm per
3
+ // view, each showing the SHARED ViewHeader (doctype title, the switcher
4
+ // across the views that doctype's meta supports, New button) above the view
5
+ // body — the composition the individual view stories cannot show, because
6
+ // they mount their view without the header.
7
+ //
8
+ // Everything rides fixtures: getdoctype-shaped meta docs live in the fixture
9
+ // provider's "DocType" collection (shared/useDoctypeMeta's fixture branch),
10
+ // rows and Kanban Board docs in the same InMemoryFixtureProvider. Search-param
11
+ // overrides (?board=, ?field=, ?f=) are read from the navigator's location,
12
+ // so an arm sets them by seeding the MemoryNavigator path.
13
+ import { YStack } from "@multiplatform.one/components";
14
+ import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
15
+ import { BackofficeProvider } from "../config/BackofficeProvider";
16
+ import { createMemoryNavigator } from "../navigation/navigator";
17
+ import type { BackofficeView } from "../navigation/paths";
18
+ import { DoctypeViewScreen } from "./DoctypeViewScreen";
19
+
20
+ const meta = {
21
+ title: "Screens/Backoffice/DoctypeViewScreen",
22
+ component: DoctypeViewScreen,
23
+ tags: ["!test"],
24
+ parameters: {
25
+ status: { type: "stable" },
26
+ layout: "fullscreen",
27
+ docs: {
28
+ description: {
29
+ component:
30
+ "Dispatches a doctype's alternate views — report, kanban, calendar, tree, image, dashboard — under one shared ViewHeader, and bounces a deep link to /view/list back to the list route. The switcher only offers views the doctype's meta actually supports (viewAvailability.ts), so a flat doctype never grows a Tree tab. Search-param overrides (?board=, ?field=, ?f=) are read here, never in the leaf views, so each view still mounts standalone.",
31
+ },
32
+ },
33
+ },
34
+ };
35
+ export default meta;
36
+
37
+ const MOCK_HOST = "https://backoffice-doctype-view.mock.test";
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Fixture metas — one doctype per supported view, mirroring the shapes the
41
+ // individual view stories use.
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const TASK_DOCTYPE = "QaViewTask";
45
+ const EVENT_DOCTYPE = "QaViewEvent";
46
+ const NODE_DOCTYPE = "QaViewNode";
47
+ const PHOTO_DOCTYPE = "QaViewPhoto";
48
+
49
+ /** Select fields → kanban is supported; report and dashboard always are. */
50
+ const taskMeta = {
51
+ name: TASK_DOCTYPE,
52
+ doctype: "DocType",
53
+ module: "QA",
54
+ title_field: "subject",
55
+ fields: [
56
+ { fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 },
57
+ {
58
+ fieldname: "status",
59
+ fieldtype: "Select",
60
+ label: "Status",
61
+ options: "Open\nWorking\nDone",
62
+ in_list_view: 1,
63
+ },
64
+ {
65
+ fieldname: "priority",
66
+ fieldtype: "Select",
67
+ label: "Priority",
68
+ options: "High\nMedium\nLow",
69
+ in_list_view: 1,
70
+ },
71
+ { fieldname: "owner_name", fieldtype: "Data", label: "Owner", in_list_view: 1 },
72
+ ],
73
+ permissions: [{ role: "All", read: 1 }],
74
+ };
75
+
76
+ /** Date axes → the calendar picks one; the toolbar selector switches them. */
77
+ const eventMeta = {
78
+ name: EVENT_DOCTYPE,
79
+ doctype: "DocType",
80
+ module: "QA",
81
+ title_field: "title",
82
+ fields: [
83
+ { fieldname: "title", fieldtype: "Data", label: "Title", in_list_view: 1 },
84
+ { fieldname: "starts_on", fieldtype: "Datetime", label: "Starts On" },
85
+ { fieldname: "ends_on", fieldtype: "Datetime", label: "Ends On" },
86
+ ],
87
+ permissions: [{ role: "All", read: 1 }],
88
+ };
89
+
90
+ /** is_tree + the nested-set parent link → the Tree tab appears. */
91
+ const nodeMeta = {
92
+ name: NODE_DOCTYPE,
93
+ doctype: "DocType",
94
+ module: "QA",
95
+ is_tree: 1,
96
+ title_field: "title",
97
+ fields: [
98
+ { fieldname: "title", fieldtype: "Data", label: "Title", in_list_view: 1 },
99
+ {
100
+ fieldname: "parent_qa_view_node",
101
+ fieldtype: "Link",
102
+ label: "Parent Node",
103
+ options: NODE_DOCTYPE,
104
+ hidden: 1,
105
+ },
106
+ { fieldname: "is_group", fieldtype: "Check", label: "Is Group" },
107
+ ],
108
+ permissions: [{ role: "All", read: 1 }],
109
+ };
110
+
111
+ /** image_field → the Image tab appears (a stray Attach Image alone does not). */
112
+ const photoMeta = {
113
+ name: PHOTO_DOCTYPE,
114
+ doctype: "DocType",
115
+ module: "QA",
116
+ title_field: "caption",
117
+ image_field: "shot",
118
+ fields: [
119
+ { fieldname: "caption", fieldtype: "Data", label: "Caption", in_list_view: 1 },
120
+ { fieldname: "taken_on", fieldtype: "Date", label: "Taken On", in_list_view: 1 },
121
+ { fieldname: "shot", fieldtype: "Attach Image", label: "Shot" },
122
+ ],
123
+ permissions: [{ role: "All", read: 1 }],
124
+ };
125
+
126
+ const statuses = ["Open", "Working", "Done"];
127
+ const priorities = ["High", "Medium", "Low"];
128
+ const owners = ["ana", "bo", "cyrus", "dee"];
129
+
130
+ const taskDocs = Array.from({ length: 9 }, (_, i) => ({
131
+ name: `QVT-${String(i + 1).padStart(4, "0")}`,
132
+ doctype: TASK_DOCTYPE,
133
+ subject: `Task ${i + 1}: ${["triage inbox", "ship release", "write docs", "fix flaky test"][i % 4]}`,
134
+ status: statuses[i % statuses.length],
135
+ priority: priorities[i % priorities.length],
136
+ owner_name: owners[i % owners.length],
137
+ }));
138
+
139
+ const eventDocs = Array.from({ length: 6 }, (_, i) => ({
140
+ name: `QVE-${String(i + 1).padStart(4, "0")}`,
141
+ doctype: EVENT_DOCTYPE,
142
+ title: `Event ${i + 1}`,
143
+ starts_on: `2026-09-${String((i % 27) + 1).padStart(2, "0")} 10:00:00`,
144
+ ends_on: `2026-09-${String((i % 27) + 1).padStart(2, "0")} 11:00:00`,
145
+ }));
146
+
147
+ const nodeDocs = [
148
+ { name: "Root A", doctype: NODE_DOCTYPE, title: "Root A", parent_qa_view_node: "", is_group: 1 },
149
+ { name: "Root B", doctype: NODE_DOCTYPE, title: "Root B", parent_qa_view_node: "", is_group: 1 },
150
+ {
151
+ name: "A-1",
152
+ doctype: NODE_DOCTYPE,
153
+ title: "A-1",
154
+ parent_qa_view_node: "Root A",
155
+ is_group: 0,
156
+ },
157
+ {
158
+ name: "A-2",
159
+ doctype: NODE_DOCTYPE,
160
+ title: "A-2",
161
+ parent_qa_view_node: "Root A",
162
+ is_group: 0,
163
+ },
164
+ {
165
+ name: "B-1",
166
+ doctype: NODE_DOCTYPE,
167
+ title: "B-1",
168
+ parent_qa_view_node: "Root B",
169
+ is_group: 0,
170
+ },
171
+ ];
172
+
173
+ const photoDocs = Array.from({ length: 8 }, (_, i) => ({
174
+ name: `QVP-${String(i + 1).padStart(4, "0")}`,
175
+ doctype: PHOTO_DOCTYPE,
176
+ caption: `Frame ${i + 1}`,
177
+ taken_on: `2026-0${(i % 9) + 1}-14`,
178
+ shot: "",
179
+ }));
180
+
181
+ const taskBoard = {
182
+ name: "QA View Board",
183
+ doctype: "Kanban Board",
184
+ kanban_board_name: "QA View Board",
185
+ reference_doctype: TASK_DOCTYPE,
186
+ field_name: "status",
187
+ columns: [
188
+ { column_name: "Open", status: "Active", indicator: "Blue" },
189
+ { column_name: "Working", status: "Active", indicator: "Orange" },
190
+ { column_name: "Done", status: "Active", indicator: "Green" },
191
+ ],
192
+ };
193
+
194
+ const makeFixtures = () =>
195
+ new InMemoryFixtureProvider({
196
+ DocType: [taskMeta, eventMeta, nodeMeta, photoMeta],
197
+ "Kanban Board": [taskBoard],
198
+ "Notification Log": [],
199
+ [TASK_DOCTYPE]: taskDocs,
200
+ [EVENT_DOCTYPE]: eventDocs,
201
+ [NODE_DOCTYPE]: nodeDocs,
202
+ [PHOTO_DOCTYPE]: photoDocs,
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Harness
207
+ // ---------------------------------------------------------------------------
208
+
209
+ function View({ doctype, view, path }: { doctype: string; view: BackofficeView; path?: string }) {
210
+ return (
211
+ <BackofficeProvider
212
+ frappe={{ baseURL: MOCK_HOST, fixtures: makeFixtures() }}
213
+ navigation={createMemoryNavigator(path ?? "/backoffice")}
214
+ >
215
+ <YStack flex={1} height={600} minHeight={0}>
216
+ <DoctypeViewScreen doctype={doctype} view={view} />
217
+ </YStack>
218
+ </BackofficeProvider>
219
+ );
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // One arm per view
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /** view="report" — the desk-parity report surface under the shared header. */
227
+ export const Report = () => <View doctype={TASK_DOCTYPE} view="report" />;
228
+ Report.storyName = "Main";
229
+
230
+ /** view="kanban" — the Kanban Board doc drives the columns. */
231
+ export const Kanban = () => <View doctype={TASK_DOCTYPE} view="kanban" />;
232
+
233
+ /**
234
+ * `?board=` picks a saved board. The dispatcher reads it from the location
235
+ * and hands it down, so KanbanView itself stays param-free.
236
+ */
237
+ export const KanbanBoardParam = () => (
238
+ <View
239
+ doctype={TASK_DOCTYPE}
240
+ view="kanban"
241
+ path={`/backoffice/qaviewtask/view/kanban?board=${encodeURIComponent(taskBoard.name)}`}
242
+ />
243
+ );
244
+
245
+ /** view="calendar" — date axes detected from meta; `?field=` overrides. */
246
+ export const Calendar = () => <View doctype={EVENT_DOCTYPE} view="calendar" />;
247
+
248
+ /** `?field=` pins the calendar's date axis to ends_on instead of starts_on. */
249
+ export const CalendarFieldParam = () => (
250
+ <View
251
+ doctype={EVENT_DOCTYPE}
252
+ view="calendar"
253
+ path="/backoffice/qaviewevent/view/calendar?field=ends_on"
254
+ />
255
+ );
256
+
257
+ /** view="tree" — only meta.is_tree doctypes render a forest. */
258
+ export const Tree = () => <View doctype={NODE_DOCTYPE} view="tree" />;
259
+
260
+ /** view="image" — the card grid over meta.image_field. */
261
+ export const Image = () => <View doctype={PHOTO_DOCTYPE} view="image" />;
262
+
263
+ /** view="dashboard" — unconditional, offered for every doctype. */
264
+ export const Dashboard = () => <View doctype={TASK_DOCTYPE} view="dashboard" />;
265
+
266
+ /**
267
+ * A doctype whose meta does NOT support the requested view still renders:
268
+ * the header keeps the current view in the switcher (so the user can see
269
+ * where they are) and the body shows its own unsupported state with a way
270
+ * back to the list. Here: tree on a flat doctype.
271
+ */
272
+ export const UnsupportedForDoctype = () => <View doctype={TASK_DOCTYPE} view="tree" />;
273
+
274
+ /**
275
+ * view="list" is owned by the list route: the dispatcher REPLACES the
276
+ * location with the list path (Back keeps working) and paints the loading
277
+ * state while it happens.
278
+ */
279
+ export const ListRedirect = () => <View doctype={TASK_DOCTYPE} view="list" />;