@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.
- package/package.json +14 -13
- package/src/chrome/CommandPalette.spec.tsx +54 -0
- package/src/chrome/CommandPalette.stories.tsx +207 -0
- package/src/desk/Desk.spec.tsx +44 -0
- package/src/desk/Desk.stories.tsx +299 -0
- package/src/desk/DeskSurface.spec.tsx +160 -0
- package/src/desk/DeskSurface.stories.tsx +297 -0
- package/src/shell/NotFoundScreen.spec.tsx +51 -0
- package/src/shell/NotFoundScreen.stories.tsx +79 -0
- package/src/views/DoctypeViewScreen.spec.tsx +110 -0
- package/src/views/DoctypeViewScreen.stories.tsx +279 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeskSurface specs (MPO-189) — the target→surface switch, mounted over the
|
|
3
|
+
* same fixture shape its stories use.
|
|
4
|
+
*
|
|
5
|
+
* Desk.spec.tsx already covers dispatch THROUGH <Desk />; this file pins the
|
|
6
|
+
* switch itself: every DeskTarget kind reaches a surface, and the override
|
|
7
|
+
* registry resolves per-doctype before per-kind before the built-in.
|
|
8
|
+
*/
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
11
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
12
|
+
import { screen, waitFor } from "@testing-library/react";
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { BackofficeProvider } from "../config/BackofficeProvider";
|
|
15
|
+
import { createMemoryNavigator } from "../navigation/navigator";
|
|
16
|
+
import { DeskSurface, DeskSurfaceLoading, type DeskSurfaceProps } from "./DeskSurface";
|
|
17
|
+
import type { DeskTarget } from "./target";
|
|
18
|
+
|
|
19
|
+
const MOCK_HOST = "https://backoffice-desk-surface-spec.mock.test";
|
|
20
|
+
const TASK_DOCTYPE = "QaDeskTask";
|
|
21
|
+
|
|
22
|
+
const taskMeta = {
|
|
23
|
+
name: TASK_DOCTYPE,
|
|
24
|
+
doctype: "DocType",
|
|
25
|
+
module: "QA",
|
|
26
|
+
title_field: "subject",
|
|
27
|
+
fields: [
|
|
28
|
+
{ fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1 },
|
|
29
|
+
{
|
|
30
|
+
fieldname: "status",
|
|
31
|
+
fieldtype: "Select",
|
|
32
|
+
label: "Status",
|
|
33
|
+
options: "Open\nWorking\nDone",
|
|
34
|
+
in_list_view: 1,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
permissions: [{ role: "All", read: 1 }],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const makeFixtures = () =>
|
|
41
|
+
new InMemoryFixtureProvider({
|
|
42
|
+
DocType: [taskMeta],
|
|
43
|
+
Workspace: [
|
|
44
|
+
{
|
|
45
|
+
name: "Tools",
|
|
46
|
+
doctype: "Workspace",
|
|
47
|
+
title: "Tools",
|
|
48
|
+
icon: "tool",
|
|
49
|
+
parent_page: "",
|
|
50
|
+
public: 1,
|
|
51
|
+
for_user: "",
|
|
52
|
+
sequence_id: 1,
|
|
53
|
+
module: "Automation",
|
|
54
|
+
is_hidden: 0,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
"Notification Log": [],
|
|
58
|
+
[TASK_DOCTYPE]: [{ name: "QDT-0001", doctype: TASK_DOCTYPE, subject: "First", status: "Open" }],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function Probe({ target }: DeskSurfaceProps) {
|
|
62
|
+
return <div data-testid={`probe-${target.kind}`}>{JSON.stringify(target)}</div>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderSurface(node: React.ReactNode) {
|
|
66
|
+
return renderWithProviders(
|
|
67
|
+
<BackofficeProvider
|
|
68
|
+
frappe={{ baseURL: MOCK_HOST, fixtures: makeFixtures() }}
|
|
69
|
+
navigation={createMemoryNavigator("/backoffice")}
|
|
70
|
+
>
|
|
71
|
+
{node}
|
|
72
|
+
</BackofficeProvider>,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const everyKind: DeskTarget[] = [
|
|
77
|
+
{ kind: "home" },
|
|
78
|
+
{ kind: "workspace", workspace: "Tools" },
|
|
79
|
+
{ kind: "list", doctype: TASK_DOCTYPE },
|
|
80
|
+
{ kind: "view", doctype: TASK_DOCTYPE, view: "kanban" },
|
|
81
|
+
{ kind: "new", doctype: TASK_DOCTYPE },
|
|
82
|
+
{ kind: "form", doctype: TASK_DOCTYPE, name: "QDT-0001" },
|
|
83
|
+
{ kind: "report", reportName: "Open Tasks" },
|
|
84
|
+
{ kind: "not-found", slug: "nope" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
describe("<DeskSurface />", () => {
|
|
88
|
+
it("dispatches every DeskTarget kind — no kind falls through the switch", () => {
|
|
89
|
+
// A probe per kind proves the switch reaches a component for each one,
|
|
90
|
+
// without depending on any individual surface's async chrome.
|
|
91
|
+
for (const target of everyKind) {
|
|
92
|
+
const { unmount } = renderSurface(
|
|
93
|
+
<DeskSurface
|
|
94
|
+
target={target}
|
|
95
|
+
overrides={{
|
|
96
|
+
home: Probe,
|
|
97
|
+
workspace: Probe,
|
|
98
|
+
list: Probe,
|
|
99
|
+
view: Probe,
|
|
100
|
+
new: Probe,
|
|
101
|
+
form: Probe,
|
|
102
|
+
report: Probe,
|
|
103
|
+
"not-found": Probe,
|
|
104
|
+
}}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
expect(screen.getByTestId(`probe-${target.kind}`)).toBeTruthy();
|
|
108
|
+
unmount();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("renders the built-in not-found surface when no override matches", async () => {
|
|
113
|
+
renderSurface(<DeskSurface target={{ kind: "not-found", slug: "nope" }} />);
|
|
114
|
+
await waitFor(() => expect(screen.getByText("Not found")).toBeTruthy());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("per-doctype override beats per-kind; a miss falls through to the built-in", () => {
|
|
118
|
+
function DoctypeProbe({ target }: DeskSurfaceProps) {
|
|
119
|
+
return <div data-testid="doctype-probe">{JSON.stringify(target)}</div>;
|
|
120
|
+
}
|
|
121
|
+
const { unmount } = renderSurface(
|
|
122
|
+
<DeskSurface
|
|
123
|
+
target={{ kind: "list", doctype: TASK_DOCTYPE }}
|
|
124
|
+
overrides={{ list: { [TASK_DOCTYPE]: DoctypeProbe } }}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
expect(screen.getByTestId("doctype-probe")).toBeTruthy();
|
|
128
|
+
unmount();
|
|
129
|
+
|
|
130
|
+
// Keyed by a different doctype: the record misses, so the built-in list
|
|
131
|
+
// mounts instead of the probe.
|
|
132
|
+
renderSurface(
|
|
133
|
+
<DeskSurface
|
|
134
|
+
target={{ kind: "list", doctype: TASK_DOCTYPE }}
|
|
135
|
+
overrides={{ list: { "Sales Order": DoctypeProbe } }}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
expect(screen.queryByTestId("doctype-probe")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("forwards readOnly to the override contract", () => {
|
|
142
|
+
function ReadOnlyProbe({ readOnly }: DeskSurfaceProps) {
|
|
143
|
+
return <div data-testid="readonly-probe">{String(readOnly)}</div>;
|
|
144
|
+
}
|
|
145
|
+
renderSurface(
|
|
146
|
+
<DeskSurface
|
|
147
|
+
target={{ kind: "form", doctype: TASK_DOCTYPE, name: "QDT-0001" }}
|
|
148
|
+
readOnly
|
|
149
|
+
overrides={{ form: ReadOnlyProbe }}
|
|
150
|
+
/>,
|
|
151
|
+
);
|
|
152
|
+
expect(screen.getByTestId("readonly-probe").textContent).toBe("true");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("DeskSurfaceLoading renders the resolving state", () => {
|
|
156
|
+
renderSurface(<DeskSurfaceLoading />);
|
|
157
|
+
// The spinner is the whole surface; nothing else may paint over it.
|
|
158
|
+
expect(screen.queryByText("Not found")).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// OWNER: backoffice screens workstream (MPO-189).
|
|
2
|
+
// DeskSurface stories: the target→surface switch. One arm per DeskTarget
|
|
3
|
+
// kind, so the whole dispatch table is visible in one place, plus the
|
|
4
|
+
// override registry (per-kind and per-doctype).
|
|
5
|
+
//
|
|
6
|
+
// Everything rides fixtures: doctype meta through the fixture provider's
|
|
7
|
+
// "DocType" collection (shared/useDoctypeMeta's fixture branch), rows and
|
|
8
|
+
// Workspace docs through the same InMemoryFixtureProvider. Query reports
|
|
9
|
+
// are the one surface with no fixture branch — they run three desk RPCs —
|
|
10
|
+
// so a fetch mock scoped to this story's mock host answers those and keeps
|
|
11
|
+
// every arm off the network.
|
|
12
|
+
import { Text, YStack } from "@multiplatform.one/components";
|
|
13
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
14
|
+
import type { ReactNode } from "react";
|
|
15
|
+
import { BackofficeProvider } from "../config/BackofficeProvider";
|
|
16
|
+
import { createMemoryNavigator } from "../navigation/navigator";
|
|
17
|
+
import { DeskSurface, DeskSurfaceLoading, type DeskSurfaceProps } from "./DeskSurface";
|
|
18
|
+
import type { DeskTarget } from "./target";
|
|
19
|
+
|
|
20
|
+
const meta = {
|
|
21
|
+
title: "Screens/Backoffice/DeskSurface",
|
|
22
|
+
component: DeskSurface,
|
|
23
|
+
tags: ["!test"],
|
|
24
|
+
parameters: {
|
|
25
|
+
status: { type: "stable" },
|
|
26
|
+
layout: "fullscreen",
|
|
27
|
+
docs: {
|
|
28
|
+
description: {
|
|
29
|
+
component:
|
|
30
|
+
"The desk's target→surface switch: a resolved DeskTarget in, one wired screen out (home redirect, workspace page, list, alternate view, create form, document form, query report, not-found). It is pure dispatch — no fetch, no filter construction, no visual values — and the override registry lets a host replace a whole surface per kind, or per doctype under a kind.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
export default meta;
|
|
36
|
+
|
|
37
|
+
const MOCK_HOST = "https://backoffice-desk-surface.mock.test";
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Fixtures
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const TASK_DOCTYPE = "QaDeskTask";
|
|
44
|
+
|
|
45
|
+
/** Getdoctype-shaped meta doc for the fixture "DocType" collection. */
|
|
46
|
+
const taskMeta = {
|
|
47
|
+
name: TASK_DOCTYPE,
|
|
48
|
+
doctype: "DocType",
|
|
49
|
+
module: "QA",
|
|
50
|
+
title_field: "subject",
|
|
51
|
+
fields: [
|
|
52
|
+
{ fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1, reqd: 1 },
|
|
53
|
+
{
|
|
54
|
+
fieldname: "status",
|
|
55
|
+
fieldtype: "Select",
|
|
56
|
+
label: "Status",
|
|
57
|
+
options: "Open\nWorking\nDone",
|
|
58
|
+
in_list_view: 1,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
fieldname: "priority",
|
|
62
|
+
fieldtype: "Select",
|
|
63
|
+
label: "Priority",
|
|
64
|
+
options: "High\nMedium\nLow",
|
|
65
|
+
in_list_view: 1,
|
|
66
|
+
},
|
|
67
|
+
{ fieldname: "owner_name", fieldtype: "Data", label: "Owner", in_list_view: 1 },
|
|
68
|
+
],
|
|
69
|
+
permissions: [{ role: "All", read: 1, write: 1, create: 1 }],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const statuses = ["Open", "Working", "Done"];
|
|
73
|
+
const priorities = ["High", "Medium", "Low"];
|
|
74
|
+
const owners = ["ana", "bo", "cyrus", "dee"];
|
|
75
|
+
|
|
76
|
+
const firstTaskName = "QDT-0001";
|
|
77
|
+
|
|
78
|
+
const taskDocs = Array.from({ length: 12 }, (_, i) => ({
|
|
79
|
+
name: `QDT-${String(i + 1).padStart(4, "0")}`,
|
|
80
|
+
doctype: TASK_DOCTYPE,
|
|
81
|
+
subject: `Task ${i + 1}: ${["triage inbox", "ship release", "write docs", "fix flaky test"][i % 4]}`,
|
|
82
|
+
status: statuses[i % statuses.length],
|
|
83
|
+
priority: priorities[i % priorities.length],
|
|
84
|
+
owner_name: owners[i % owners.length],
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const workspaceDocs = [
|
|
88
|
+
{
|
|
89
|
+
name: "Tools",
|
|
90
|
+
doctype: "Workspace",
|
|
91
|
+
label: "Tools",
|
|
92
|
+
title: "Tools",
|
|
93
|
+
icon: "tool",
|
|
94
|
+
module: "Automation",
|
|
95
|
+
parent_page: "",
|
|
96
|
+
public: 1,
|
|
97
|
+
for_user: "",
|
|
98
|
+
sequence_id: 1,
|
|
99
|
+
is_hidden: 0,
|
|
100
|
+
content: JSON.stringify([
|
|
101
|
+
{
|
|
102
|
+
id: "h1",
|
|
103
|
+
type: "header",
|
|
104
|
+
data: { text: '<span class="h4"><b>Your Shortcuts</b></span>', col: 12 },
|
|
105
|
+
},
|
|
106
|
+
{ id: "s1", type: "shortcut", data: { shortcut_name: "Tasks", col: 3 } },
|
|
107
|
+
{
|
|
108
|
+
id: "p1",
|
|
109
|
+
type: "paragraph",
|
|
110
|
+
data: { text: "Everything on this page comes from fixtures.<br>", col: 12 },
|
|
111
|
+
},
|
|
112
|
+
]),
|
|
113
|
+
shortcuts: [{ name: "sc1", type: "DocType", link_to: TASK_DOCTYPE, label: "Tasks" }],
|
|
114
|
+
links: [],
|
|
115
|
+
charts: [],
|
|
116
|
+
number_cards: [],
|
|
117
|
+
quick_lists: [],
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const makeFixtures = () =>
|
|
122
|
+
new InMemoryFixtureProvider({
|
|
123
|
+
DocType: [taskMeta],
|
|
124
|
+
Workspace: workspaceDocs,
|
|
125
|
+
"Notification Log": [],
|
|
126
|
+
[TASK_DOCTYPE]: taskDocs,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Query-report RPCs. useQueryReport has no fixture branch (it calls
|
|
131
|
+
// frappe.client.get, query_report.get_script and query_report.run), so the
|
|
132
|
+
// `report` arm needs these three answered. Scoped to MOCK_HOST, installed
|
|
133
|
+
// once; stories only run on web. Same precedent as DeskShell.stories.
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
const reportColumns = [
|
|
137
|
+
{ label: "Task", fieldname: "task", fieldtype: "Data", width: "140" },
|
|
138
|
+
{ label: "Status", fieldname: "status", fieldtype: "Data", width: "110" },
|
|
139
|
+
{ label: "Owner", fieldname: "owner_name", fieldtype: "Data", width: "120" },
|
|
140
|
+
{ label: "Age (days)", fieldname: "age", fieldtype: "Int", width: "100" },
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
const reportRows = taskDocs.map((doc, i) => ({
|
|
144
|
+
task: doc.name,
|
|
145
|
+
status: doc.status,
|
|
146
|
+
owner_name: doc.owner_name,
|
|
147
|
+
age: (i * 3) % 41,
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
function jsonResponse(body: unknown): Response {
|
|
151
|
+
return new Response(JSON.stringify(body), {
|
|
152
|
+
status: 200,
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
(() => {
|
|
158
|
+
if (typeof window === "undefined") return;
|
|
159
|
+
const w = window as unknown as Record<string, unknown>;
|
|
160
|
+
if (w.__deskSurfaceMockFetch) return;
|
|
161
|
+
w.__deskSurfaceMockFetch = true;
|
|
162
|
+
const realFetch = window.fetch.bind(window);
|
|
163
|
+
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
164
|
+
const urlStr =
|
|
165
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
166
|
+
if (!urlStr.startsWith(MOCK_HOST)) return realFetch(input as RequestInfo, init);
|
|
167
|
+
const { pathname } = new URL(urlStr);
|
|
168
|
+
if (pathname.endsWith("frappe.desk.query_report.run")) {
|
|
169
|
+
return jsonResponse({ message: { columns: reportColumns, result: reportRows } });
|
|
170
|
+
}
|
|
171
|
+
if (pathname.endsWith("frappe.desk.query_report.get_script")) {
|
|
172
|
+
return jsonResponse({ message: { script: null } });
|
|
173
|
+
}
|
|
174
|
+
if (pathname.endsWith("frappe.client.get")) {
|
|
175
|
+
return jsonResponse({
|
|
176
|
+
message: { name: "Open Tasks", report_name: "Open Tasks", ref_doctype: TASK_DOCTYPE },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// No session against the inert mock host: get_logged_user must answer
|
|
180
|
+
// null, not a truthy docname (BackofficeShell.stories precedent).
|
|
181
|
+
if (pathname.endsWith("frappe.auth.get_logged_user")) return jsonResponse({ message: null });
|
|
182
|
+
return jsonResponse({ data: [], message: [] });
|
|
183
|
+
};
|
|
184
|
+
})();
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Harness
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
function Surface({ target, children }: { target?: DeskTarget; children?: ReactNode }) {
|
|
191
|
+
return (
|
|
192
|
+
<BackofficeProvider
|
|
193
|
+
frappe={{ baseURL: MOCK_HOST, fixtures: makeFixtures() }}
|
|
194
|
+
navigation={createMemoryNavigator("/backoffice")}
|
|
195
|
+
>
|
|
196
|
+
<YStack flex={1} height={560} minHeight={0}>
|
|
197
|
+
{children ?? (target ? <DeskSurface target={target} /> : null)}
|
|
198
|
+
</YStack>
|
|
199
|
+
</BackofficeProvider>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// One arm per DeskTarget kind
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/** kind "list" — the doctype list surface, the desk's most-visited screen. */
|
|
208
|
+
export const List = () => <Surface target={{ kind: "list", doctype: TASK_DOCTYPE }} />;
|
|
209
|
+
List.storyName = "Main";
|
|
210
|
+
|
|
211
|
+
/** kind "home" — the home redirect, resolving the default workspace. */
|
|
212
|
+
export const Home = () => <Surface target={{ kind: "home" }} />;
|
|
213
|
+
|
|
214
|
+
/** kind "workspace" — a workspace page rendered from its fixture doc. */
|
|
215
|
+
export const Workspace = () => <Surface target={{ kind: "workspace", workspace: "Tools" }} />;
|
|
216
|
+
|
|
217
|
+
/** kind "view" — an alternate view; the switch hands off to DoctypeViewScreen. */
|
|
218
|
+
export const AlternateView = () => (
|
|
219
|
+
<Surface target={{ kind: "view", doctype: TASK_DOCTYPE, view: "kanban" }} />
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
/** kind "new" — create mode; `defaults` route through the ONE prefill stash. */
|
|
223
|
+
export const NewDocument = () => (
|
|
224
|
+
<Surface
|
|
225
|
+
target={{
|
|
226
|
+
kind: "new",
|
|
227
|
+
doctype: TASK_DOCTYPE,
|
|
228
|
+
defaults: { status: "Working", priority: "High" },
|
|
229
|
+
}}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
/** kind "form" — an existing document. */
|
|
234
|
+
export const Form = () => (
|
|
235
|
+
<Surface target={{ kind: "form", doctype: TASK_DOCTYPE, name: firstTaskName }} />
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
/** kind "report" — a Query/Script Report, served by the scoped RPC mock. */
|
|
239
|
+
export const Report = () => <Surface target={{ kind: "report", reportName: "Open Tasks" }} />;
|
|
240
|
+
|
|
241
|
+
/** kind "not-found" — a dead address. */
|
|
242
|
+
export const NotFound = () => <Surface target={{ kind: "not-found", slug: "no-such-doctype" }} />;
|
|
243
|
+
|
|
244
|
+
/** The resolving state: an ambiguous slug still in flight against the index. */
|
|
245
|
+
export const Loading = () => (
|
|
246
|
+
<Surface>
|
|
247
|
+
<DeskSurfaceLoading />
|
|
248
|
+
</Surface>
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Override registry
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
function TargetCard({ target }: DeskSurfaceProps) {
|
|
256
|
+
return (
|
|
257
|
+
<YStack
|
|
258
|
+
flex={1}
|
|
259
|
+
padding="$4"
|
|
260
|
+
gap="$2"
|
|
261
|
+
borderWidth={1}
|
|
262
|
+
borderColor="$borderColor"
|
|
263
|
+
borderRadius="$3"
|
|
264
|
+
backgroundColor="$color2"
|
|
265
|
+
>
|
|
266
|
+
<Text>{`host surface for kind "${target.kind}"`}</Text>
|
|
267
|
+
<Text>{JSON.stringify(target)}</Text>
|
|
268
|
+
</YStack>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Per-kind override: the host replaces the whole list surface. The override
|
|
274
|
+
* receives the target and the readOnly flag — thin enough to wrap the
|
|
275
|
+
* built-in rather than reimplement it.
|
|
276
|
+
*/
|
|
277
|
+
export const OverrideByKind = () => (
|
|
278
|
+
<Surface>
|
|
279
|
+
<DeskSurface
|
|
280
|
+
target={{ kind: "list", doctype: TASK_DOCTYPE }}
|
|
281
|
+
overrides={{ list: TargetCard }}
|
|
282
|
+
/>
|
|
283
|
+
</Surface>
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Per-doctype override beats per-kind, and a miss falls through to the
|
|
288
|
+
* built-in: the same registry keyed by doctype under one kind.
|
|
289
|
+
*/
|
|
290
|
+
export const OverrideByDoctype = () => (
|
|
291
|
+
<Surface>
|
|
292
|
+
<DeskSurface
|
|
293
|
+
target={{ kind: "list", doctype: TASK_DOCTYPE }}
|
|
294
|
+
overrides={{ list: { [TASK_DOCTYPE]: TargetCard } }}
|
|
295
|
+
/>
|
|
296
|
+
</Surface>
|
|
297
|
+
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotFoundScreen specs (MPO-189) — the unmatched-path surface, mounted over
|
|
3
|
+
* the same fixture shape its stories use.
|
|
4
|
+
*/
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
7
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
8
|
+
import { fireEvent, screen } from "@testing-library/react";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { BackofficeProvider } from "../config/BackofficeProvider";
|
|
11
|
+
import { createMemoryNavigator, type MemoryNavigator } from "../navigation/navigator";
|
|
12
|
+
import { NotFoundScreen } from "./NotFoundScreen";
|
|
13
|
+
|
|
14
|
+
const MOCK_HOST = "https://backoffice-not-found-spec.mock.test";
|
|
15
|
+
|
|
16
|
+
function renderAt(path: string, basePath?: string) {
|
|
17
|
+
const navigation: MemoryNavigator = createMemoryNavigator(path);
|
|
18
|
+
renderWithProviders(
|
|
19
|
+
<BackofficeProvider
|
|
20
|
+
frappe={{ baseURL: MOCK_HOST, fixtures: new InMemoryFixtureProvider({}) }}
|
|
21
|
+
navigation={navigation}
|
|
22
|
+
basePath={basePath}
|
|
23
|
+
>
|
|
24
|
+
<NotFoundScreen />
|
|
25
|
+
</BackofficeProvider>,
|
|
26
|
+
);
|
|
27
|
+
return navigation;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("<NotFoundScreen />", () => {
|
|
31
|
+
it("names the failed path so a dead deep link is diagnosable", () => {
|
|
32
|
+
renderAt("/backoffice/sales-invoice/SI-0001/edit");
|
|
33
|
+
expect(screen.getByText("Not found")).toBeTruthy();
|
|
34
|
+
expect(screen.getByText("/backoffice/sales-invoice/SI-0001/edit")).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("REPLACES history with the default workspace, so Back does not bounce", () => {
|
|
38
|
+
const navigation = renderAt("/backoffice/no-such-thing");
|
|
39
|
+
fireEvent.click(screen.getByRole("button", { name: "Go to workspaces" }));
|
|
40
|
+
expect(navigation.getLocation().path).toBe("/backoffice");
|
|
41
|
+
// replace, not push: going back must not land on the dead address again.
|
|
42
|
+
navigation.back();
|
|
43
|
+
expect(navigation.getLocation().path).toBe("/backoffice");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("honors a rebound basePath (mounted at /admin)", () => {
|
|
47
|
+
const navigation = renderAt("/admin/nope", "/admin");
|
|
48
|
+
fireEvent.click(screen.getByRole("button", { name: "Go to workspaces" }));
|
|
49
|
+
expect(navigation.getLocation().path).toBe("/admin");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// OWNER: backoffice screens workstream (MPO-189).
|
|
2
|
+
// NotFoundScreen stories: the unmatched-path surface. Nothing here touches
|
|
3
|
+
// the network — the screen reads only the provider's location (the failed
|
|
4
|
+
// path becomes the description line) plus its navigator and paths (the "Go
|
|
5
|
+
// to workspaces" action), so an empty InMemoryFixtureProvider and a
|
|
6
|
+
// MemoryNavigator seeded at the dead path are the whole harness.
|
|
7
|
+
import { YStack } from "@multiplatform.one/components";
|
|
8
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { BackofficeProvider } from "../config/BackofficeProvider";
|
|
11
|
+
import { createMemoryNavigator } from "../navigation/navigator";
|
|
12
|
+
import { NotFoundScreen } from "./NotFoundScreen";
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: "Screens/Backoffice/NotFoundScreen",
|
|
16
|
+
component: NotFoundScreen,
|
|
17
|
+
tags: ["!test"],
|
|
18
|
+
parameters: {
|
|
19
|
+
status: { type: "stable" },
|
|
20
|
+
layout: "fullscreen",
|
|
21
|
+
docs: {
|
|
22
|
+
description: {
|
|
23
|
+
component:
|
|
24
|
+
"Any backoffice path that matches no route pattern lands here (deep or malformed links, and the DeskSurface `not-found` target). Same EmptyState presentation as the SlugScreen unknown-slug state: the failed path is the description, and one button replaces history with the default workspace so Back does not bounce off the dead address again.",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
export default meta;
|
|
30
|
+
|
|
31
|
+
const MOCK_HOST = "https://backoffice-not-found.mock.test";
|
|
32
|
+
|
|
33
|
+
const deepLink = createMemoryNavigator("/backoffice/sales-invoice/SI-0001/edit");
|
|
34
|
+
const bareSlug = createMemoryNavigator("/backoffice/no-such-thing");
|
|
35
|
+
const rootPath = createMemoryNavigator("/backoffice");
|
|
36
|
+
|
|
37
|
+
function Host({
|
|
38
|
+
navigation,
|
|
39
|
+
children,
|
|
40
|
+
}: {
|
|
41
|
+
navigation: ReturnType<typeof createMemoryNavigator>;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<BackofficeProvider
|
|
46
|
+
frappe={{ baseURL: MOCK_HOST, fixtures: new InMemoryFixtureProvider({}) }}
|
|
47
|
+
navigation={navigation}
|
|
48
|
+
>
|
|
49
|
+
<YStack flex={1} height={420} justifyContent="center">
|
|
50
|
+
{children}
|
|
51
|
+
</YStack>
|
|
52
|
+
</BackofficeProvider>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A malformed deep link: the whole path is echoed back as the description. */
|
|
57
|
+
export const Main = () => (
|
|
58
|
+
<Host navigation={deepLink}>
|
|
59
|
+
<NotFoundScreen />
|
|
60
|
+
</Host>
|
|
61
|
+
);
|
|
62
|
+
Main.storyName = "Main";
|
|
63
|
+
|
|
64
|
+
/** An unknown slug — what the slug index reports when it resolves to nothing. */
|
|
65
|
+
export const UnknownSlug = () => (
|
|
66
|
+
<Host navigation={bareSlug}>
|
|
67
|
+
<NotFoundScreen />
|
|
68
|
+
</Host>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Mounted at the base path itself: `location.path` is the base, so the
|
|
73
|
+
* description shows it rather than collapsing to an empty line.
|
|
74
|
+
*/
|
|
75
|
+
export const AtBasePath = () => (
|
|
76
|
+
<Host navigation={rootPath}>
|
|
77
|
+
<NotFoundScreen />
|
|
78
|
+
</Host>
|
|
79
|
+
);
|