@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/backoffice",
|
|
3
|
-
"version": "7.7.
|
|
3
|
+
"version": "7.7.1",
|
|
4
4
|
"description": "Embeddable Frappe desk recreation (backoffice) for the multiplatform.one stack",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"backoffice",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"!types/.tsbuildinfo"
|
|
34
34
|
],
|
|
35
35
|
"sideEffects": [
|
|
36
|
-
"**/*.css"
|
|
36
|
+
"**/*.css",
|
|
37
|
+
"src/**"
|
|
37
38
|
],
|
|
38
39
|
"main": "src/index.ts",
|
|
39
40
|
"module": "src/index.ts",
|
|
@@ -72,15 +73,15 @@
|
|
|
72
73
|
"dependencies": {
|
|
73
74
|
"@phosphor-icons/react": "^2.1.10",
|
|
74
75
|
"tamagui": "2.7.6",
|
|
75
|
-
"@multiplatform.one/components": "7.7.
|
|
76
|
-
"@multiplatform.one/forms": "7.7.
|
|
77
|
-
"@multiplatform.one/frappe": "7.7.
|
|
78
|
-
"@multiplatform.one/frappe-ui": "7.7.
|
|
79
|
-
"@multiplatform.one/i18n": "7.7.
|
|
80
|
-
"@multiplatform.one/platform": "7.7.
|
|
81
|
-
"@multiplatform.one/store": "7.7.
|
|
82
|
-
"@multiplatform.one/table": "7.7.
|
|
83
|
-
"@multiplatform.one/theme": "7.7.
|
|
76
|
+
"@multiplatform.one/components": "7.7.1",
|
|
77
|
+
"@multiplatform.one/forms": "7.7.1",
|
|
78
|
+
"@multiplatform.one/frappe": "7.7.1",
|
|
79
|
+
"@multiplatform.one/frappe-ui": "7.7.1",
|
|
80
|
+
"@multiplatform.one/i18n": "7.7.1",
|
|
81
|
+
"@multiplatform.one/platform": "7.7.1",
|
|
82
|
+
"@multiplatform.one/store": "7.7.1",
|
|
83
|
+
"@multiplatform.one/table": "7.7.1",
|
|
84
|
+
"@multiplatform.one/theme": "7.7.1"
|
|
84
85
|
},
|
|
85
86
|
"devDependencies": {
|
|
86
87
|
"@tamagui/build": "2.7.6",
|
|
@@ -94,8 +95,8 @@
|
|
|
94
95
|
"react-dom": "19.2.5",
|
|
95
96
|
"react-i18next": "^16.6.6",
|
|
96
97
|
"vitest": "^4.1.5",
|
|
97
|
-
"@multiplatform.one/config": "7.7.
|
|
98
|
-
"@multiplatform.one/test-utils": "7.7.
|
|
98
|
+
"@multiplatform.one/config": "7.7.1",
|
|
99
|
+
"@multiplatform.one/test-utils": "7.7.1"
|
|
99
100
|
},
|
|
100
101
|
"peerDependencies": {
|
|
101
102
|
"i18next": "^25.0.0",
|
|
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import { createMemoryNavigator } from "../navigation/navigator";
|
|
7
7
|
import { BackofficeShell } from "../shell/BackofficeShell";
|
|
8
8
|
import { BackofficeProvider, type BackofficeProviderProps } from "../config/BackofficeProvider";
|
|
9
|
+
import { CommandPalette } from "./CommandPalette";
|
|
9
10
|
import type { RecentItem } from "./awesomebar-data";
|
|
10
11
|
|
|
11
12
|
// The shared desk recents store rides @multiplatform.one/store, which the
|
|
@@ -304,3 +305,56 @@ describe("CommandPalette capability + scoping", () => {
|
|
|
304
305
|
expect(screen.queryByTestId("backoffice-palette-section-documents")).toBeNull();
|
|
305
306
|
});
|
|
306
307
|
});
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Standalone mount (MPO-189) — the shape CommandPalette.stories.tsx uses:
|
|
311
|
+
// the palette controlled directly, with no shell and no trigger, so the
|
|
312
|
+
// section model is reachable without the navbar.
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function renderStandalone(open = true) {
|
|
316
|
+
const navigation = createMemoryNavigator("/backoffice");
|
|
317
|
+
const onOpenChange = vi.fn();
|
|
318
|
+
return {
|
|
319
|
+
navigation,
|
|
320
|
+
onOpenChange,
|
|
321
|
+
...renderWithProviders(
|
|
322
|
+
<BackofficeProvider
|
|
323
|
+
frappe={{ baseURL: MOCK_HOST, fixtures: makeFixtures() }}
|
|
324
|
+
navigation={navigation}
|
|
325
|
+
>
|
|
326
|
+
<CommandPalette open={open} onOpenChange={onOpenChange} />
|
|
327
|
+
</BackofficeProvider>,
|
|
328
|
+
),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
describe("CommandPalette standalone (story shape)", () => {
|
|
333
|
+
it("mounts open with no shell and no trigger, showing recents", async () => {
|
|
334
|
+
renderStandalone();
|
|
335
|
+
const palette = await screen.findByTestId("backoffice-command-palette");
|
|
336
|
+
expect(palette).toBeTruthy();
|
|
337
|
+
expect(await screen.findByTestId("backoffice-palette-section-recents")).toBeTruthy();
|
|
338
|
+
// No navbar in this shape — the palette owns no trigger of its own.
|
|
339
|
+
expect(screen.queryByTestId("backoffice-awesomebar")).toBeNull();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("open={false} paints nothing", () => {
|
|
343
|
+
renderStandalone(false);
|
|
344
|
+
expect(screen.queryByTestId("backoffice-command-palette")).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("a pick navigates through the provider navigator and asks the host to close", async () => {
|
|
348
|
+
const { navigation, onOpenChange } = renderStandalone();
|
|
349
|
+
const palette = await screen.findByTestId("backoffice-command-palette");
|
|
350
|
+
const input = palette.querySelector("input");
|
|
351
|
+
if (!input) throw new Error("palette input not found");
|
|
352
|
+
|
|
353
|
+
fireEvent.change(input, { target: { value: "note" } });
|
|
354
|
+
await waitFor(() => findOption("New Note"));
|
|
355
|
+
fireEvent.keyDown(input, { key: "Enter" });
|
|
356
|
+
|
|
357
|
+
expect(navigation.getLocation().path).toBe("/backoffice/note/new");
|
|
358
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// OWNER: backoffice screens workstream (MPO-189).
|
|
2
|
+
// CommandPalette stories (MOD-01). The Awesomebar story only shows the
|
|
3
|
+
// TRIGGER; these mount the palette itself, open, so the section model
|
|
4
|
+
// (recents → actions → navigation → documents) and the roving highlight are
|
|
5
|
+
// visible without a keystroke.
|
|
6
|
+
//
|
|
7
|
+
// Fixtures do the work: the DocType + Workspace catalog is read straight
|
|
8
|
+
// from the fixture provider (awesomebar-data's fixture branch), recents come
|
|
9
|
+
// from the shared desk store seeded below, and only the document section
|
|
10
|
+
// needs the desk full-text RPC — answered by a fetch mock scoped to this
|
|
11
|
+
// story's mock host.
|
|
12
|
+
import { Button } from "@multiplatform.one/forms";
|
|
13
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
14
|
+
import { storage } from "@multiplatform.one/store";
|
|
15
|
+
import { Text, YStack } from "@multiplatform.one/components";
|
|
16
|
+
import { useState, type ReactNode } from "react";
|
|
17
|
+
import { BackofficeProvider } from "../config/BackofficeProvider";
|
|
18
|
+
import { createMemoryNavigator } from "../navigation/navigator";
|
|
19
|
+
import { CommandPalette } from "./CommandPalette";
|
|
20
|
+
|
|
21
|
+
const meta = {
|
|
22
|
+
title: "Screens/Backoffice/CommandPalette",
|
|
23
|
+
component: CommandPalette,
|
|
24
|
+
tags: ["!test"],
|
|
25
|
+
parameters: {
|
|
26
|
+
status: { type: "stable" },
|
|
27
|
+
layout: "fullscreen",
|
|
28
|
+
docs: {
|
|
29
|
+
description: {
|
|
30
|
+
component:
|
|
31
|
+
"Linear / Spotlight / Frappe awesomebar in one surface: an empty query lists recent documents; typing adds contextual actions for the best doctype match (New / List / Report / Kanban), workspace and doctype navigation, and document hits from the desk full-text index. Keyboard-first — the search input is the single tab stop, arrows move one roving highlight across sections, Enter opens, Escape closes and returns focus. Two containers, one model: a centered modal overlay on wide web, a full-height sheet below the medium breakpoint.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
export default meta;
|
|
37
|
+
|
|
38
|
+
const MOCK_HOST = "https://backoffice-command-palette.mock.test";
|
|
39
|
+
|
|
40
|
+
const doctypes = [
|
|
41
|
+
"ToDo",
|
|
42
|
+
"ToDo Template",
|
|
43
|
+
"Note",
|
|
44
|
+
"User",
|
|
45
|
+
"Role",
|
|
46
|
+
"Sales Order",
|
|
47
|
+
"Sales Invoice",
|
|
48
|
+
"Item",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const workspaces = [
|
|
52
|
+
{
|
|
53
|
+
name: "todos",
|
|
54
|
+
doctype: "Workspace",
|
|
55
|
+
title: "Todos",
|
|
56
|
+
icon: "tool",
|
|
57
|
+
parent_page: "",
|
|
58
|
+
public: 1,
|
|
59
|
+
for_user: "",
|
|
60
|
+
sequence_id: 1,
|
|
61
|
+
module: "Automation",
|
|
62
|
+
is_hidden: 0,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "selling",
|
|
66
|
+
doctype: "Workspace",
|
|
67
|
+
title: "Selling",
|
|
68
|
+
icon: "sell",
|
|
69
|
+
parent_page: "",
|
|
70
|
+
public: 1,
|
|
71
|
+
for_user: "",
|
|
72
|
+
sequence_id: 2,
|
|
73
|
+
module: "Selling",
|
|
74
|
+
is_hidden: 0,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const makeFixtures = () =>
|
|
79
|
+
new InMemoryFixtureProvider({
|
|
80
|
+
DocType: doctypes.map((name) => ({ name, doctype: "DocType" })),
|
|
81
|
+
Workspace: workspaces,
|
|
82
|
+
"Notification Log": [],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Recents ride the SHARED desk store — the same key frappe-ui's DeskSidebar
|
|
87
|
+
* writes — so an empty query has something to show. Seeded once, web only.
|
|
88
|
+
*/
|
|
89
|
+
(() => {
|
|
90
|
+
if (typeof window === "undefined") return;
|
|
91
|
+
const w = window as unknown as Record<string, unknown>;
|
|
92
|
+
if (w.__commandPaletteRecentsSeeded) return;
|
|
93
|
+
w.__commandPaletteRecentsSeeded = true;
|
|
94
|
+
void storage.setItem(
|
|
95
|
+
"frappe-desk-recent",
|
|
96
|
+
JSON.stringify([
|
|
97
|
+
{ label: "SO-0042 · Sales Order", path: "/backoffice/sales-order/SO-0042" },
|
|
98
|
+
{ label: "TD-0007 · ToDo", path: "/backoffice/todo/TD-0007" },
|
|
99
|
+
{ label: "Selling", path: "/backoffice/selling" },
|
|
100
|
+
]),
|
|
101
|
+
);
|
|
102
|
+
})();
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Document hits need frappe.utils.global_search.search; everything else on
|
|
106
|
+
* the mock host answers empty (and the session probe answers null, so the
|
|
107
|
+
* inert host never mints a truthy user).
|
|
108
|
+
*/
|
|
109
|
+
(() => {
|
|
110
|
+
if (typeof window === "undefined") return;
|
|
111
|
+
const w = window as unknown as Record<string, unknown>;
|
|
112
|
+
if (w.__commandPaletteMockFetch) return;
|
|
113
|
+
w.__commandPaletteMockFetch = true;
|
|
114
|
+
const realFetch = window.fetch.bind(window);
|
|
115
|
+
const json = (body: unknown) =>
|
|
116
|
+
new Response(JSON.stringify(body), {
|
|
117
|
+
status: 200,
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
});
|
|
120
|
+
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
121
|
+
const urlStr =
|
|
122
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
123
|
+
if (!urlStr.startsWith(MOCK_HOST)) return realFetch(input as RequestInfo, init);
|
|
124
|
+
const { pathname } = new URL(urlStr);
|
|
125
|
+
if (pathname.endsWith("frappe.utils.global_search.search")) {
|
|
126
|
+
return json({
|
|
127
|
+
message: [
|
|
128
|
+
{
|
|
129
|
+
doctype: "ToDo",
|
|
130
|
+
name: "TD-0007",
|
|
131
|
+
content: "Description: pay the rent ||| Status: Open",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
doctype: "Sales Order",
|
|
135
|
+
name: "SO-0042",
|
|
136
|
+
content: "Customer: ACME Corp ||| Status: To Deliver",
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (pathname.endsWith("frappe.auth.get_logged_user")) return json({ message: null });
|
|
142
|
+
return json({ data: [], message: [] });
|
|
143
|
+
};
|
|
144
|
+
})();
|
|
145
|
+
|
|
146
|
+
function Host({ children }: { children: ReactNode }) {
|
|
147
|
+
return (
|
|
148
|
+
<BackofficeProvider
|
|
149
|
+
frappe={{ baseURL: MOCK_HOST, fixtures: makeFixtures() }}
|
|
150
|
+
navigation={createMemoryNavigator("/backoffice")}
|
|
151
|
+
>
|
|
152
|
+
<YStack flex={1} height={620} padding="$4" gap="$3" backgroundColor="$color2">
|
|
153
|
+
{children}
|
|
154
|
+
</YStack>
|
|
155
|
+
</BackofficeProvider>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Open on an empty query: the Recents section from the shared desk store.
|
|
161
|
+
* Type to fill Actions / Navigation / Documents; Escape closes.
|
|
162
|
+
*/
|
|
163
|
+
export const Overlay = () => {
|
|
164
|
+
const [open, setOpen] = useState(true);
|
|
165
|
+
return (
|
|
166
|
+
<Host>
|
|
167
|
+
<Text>Page behind the palette.</Text>
|
|
168
|
+
<Button onPress={() => setOpen(true)}>Open palette</Button>
|
|
169
|
+
<CommandPalette open={open} onOpenChange={setOpen} />
|
|
170
|
+
</Host>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
Overlay.storyName = "Main";
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Closed: the palette owns no trigger of its own (the navbar awesomebar and
|
|
177
|
+
* the Mod+K shortcut open it), so the closed state is the host page with
|
|
178
|
+
* nothing painted over it.
|
|
179
|
+
*/
|
|
180
|
+
export const Closed = () => {
|
|
181
|
+
const [open, setOpen] = useState(false);
|
|
182
|
+
return (
|
|
183
|
+
<Host>
|
|
184
|
+
<Text>Nothing is painted over the page while the palette is closed.</Text>
|
|
185
|
+
<Button onPress={() => setOpen(true)}>Open palette</Button>
|
|
186
|
+
<CommandPalette open={open} onOpenChange={setOpen} />
|
|
187
|
+
</Host>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The compact container. The palette picks its container from the VIEWPORT,
|
|
193
|
+
* not a prop (useLayoutSizeClass): below the medium breakpoint — and on
|
|
194
|
+
* native always — the same section model renders as a full-height
|
|
195
|
+
* SheetModal instead of the centered overlay. Narrow the preview pane (or
|
|
196
|
+
* pick a phone viewport) to see this arm switch containers; it is the same
|
|
197
|
+
* story otherwise, which is the point.
|
|
198
|
+
*/
|
|
199
|
+
export const CompactSheet = () => {
|
|
200
|
+
const [open, setOpen] = useState(true);
|
|
201
|
+
return (
|
|
202
|
+
<Host>
|
|
203
|
+
<Text>Narrow the preview below the medium breakpoint to get the sheet container.</Text>
|
|
204
|
+
<CommandPalette open={open} onOpenChange={setOpen} />
|
|
205
|
+
</Host>
|
|
206
|
+
);
|
|
207
|
+
};
|
package/src/desk/Desk.spec.tsx
CHANGED
|
@@ -285,3 +285,47 @@ describe("two Desks on one page (DK-7)", () => {
|
|
|
285
285
|
expect(screen.getByTestId("scope-b").textContent).toBe('[["company","=","B"]]');
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Story-shape arms (MPO-189) — the narrowings Desk.stories.tsx demonstrates,
|
|
291
|
+
// pinned here so a chrome default can never drift silently.
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
describe("<Desk /> chrome narrowings", () => {
|
|
295
|
+
it('chrome="bare" keeps the banner and forces the sidebar capability off', () => {
|
|
296
|
+
renderWithProviders(
|
|
297
|
+
<Desk frappe={frappe()} doctype="ToDo" chrome="bare" surfaces={{ list: SurfaceProbe }} />,
|
|
298
|
+
);
|
|
299
|
+
// Banner present…
|
|
300
|
+
expect(screen.getByTestId("backoffice-shell")).toBeTruthy();
|
|
301
|
+
expect(screen.getByTestId("backoffice-awesomebar")).toBeTruthy();
|
|
302
|
+
// …rail gone: bare merges `sidebar: false` into capabilities, so nothing
|
|
303
|
+
// downstream can put it back.
|
|
304
|
+
expect(screen.queryByTestId("backoffice-sidebar-rail")).toBeNull();
|
|
305
|
+
expect(screen.queryByTestId("backoffice-sidebar-toggle")).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("`embedded` is sugar for chrome='content' (no banner, no rail)", () => {
|
|
309
|
+
renderWithProviders(
|
|
310
|
+
<Desk frappe={frappe()} workspace="Tools" embedded surfaces={{ workspace: SurfaceProbe }} />,
|
|
311
|
+
);
|
|
312
|
+
expect(screen.queryByTestId("backoffice-shell")).toBeNull();
|
|
313
|
+
expect(screen.getByTestId("probe-workspace").textContent).toContain('"Tools"');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("a view pin enters on that view rather than the list", () => {
|
|
317
|
+
renderWithProviders(
|
|
318
|
+
<Desk frappe={frappe()} doctype="ToDo" view="kanban" surfaces={{ view: SurfaceProbe }} />,
|
|
319
|
+
);
|
|
320
|
+
const probe = screen.getByTestId("probe-view");
|
|
321
|
+
expect(probe.textContent).toContain('"kanban"');
|
|
322
|
+
expect(probe.textContent).toContain('"ToDo"');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('name="new" enters create mode', () => {
|
|
326
|
+
renderWithProviders(
|
|
327
|
+
<Desk frappe={frappe()} doctype="ToDo" name="new" surfaces={{ new: SurfaceProbe }} />,
|
|
328
|
+
);
|
|
329
|
+
expect(screen.getByTestId("probe-new").textContent).toContain('"ToDo"');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// OWNER: backoffice screens workstream (MPO-189).
|
|
2
|
+
// <Desk /> stories: the whole desk as one component. One required prop, and
|
|
3
|
+
// that prop is nothing — `<Desk />` renders the full desk, and every other
|
|
4
|
+
// arm here is a NARROWING of that: a pin, a chrome mode, a fence, a surface
|
|
5
|
+
// override.
|
|
6
|
+
//
|
|
7
|
+
// Everything rides fixtures. Doctype meta comes from the fixture provider's
|
|
8
|
+
// "DocType" collection, rows and Workspace docs from the same
|
|
9
|
+
// InMemoryFixtureProvider, and a fetch mock scoped to this story's mock host
|
|
10
|
+
// keeps the navbar's session probe and the desk's catalog reads off the
|
|
11
|
+
// network. Navigation is internal: with no `navigation` prop Desk seeds its
|
|
12
|
+
// own MemoryNavigator from the pins, so the host URL never moves.
|
|
13
|
+
import { Text, YStack } from "@multiplatform.one/components";
|
|
14
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
import { Desk } from "./Desk";
|
|
17
|
+
import type { DeskSurfaceProps } from "./DeskSurface";
|
|
18
|
+
|
|
19
|
+
const meta = {
|
|
20
|
+
title: "Screens/Backoffice/Desk",
|
|
21
|
+
component: Desk,
|
|
22
|
+
tags: ["!test"],
|
|
23
|
+
parameters: {
|
|
24
|
+
status: { type: "stable" },
|
|
25
|
+
layout: "fullscreen",
|
|
26
|
+
docs: {
|
|
27
|
+
description: {
|
|
28
|
+
component:
|
|
29
|
+
"The embeddable desk: navbar and workspace rail, the resolved surface underneath, and one memory navigator when the host supplies no router. Pins (doctype / name / view / workspace / report / path) set the entry surface and, for a doctype pin, the reachability fence; `chrome` picks full banner + rail, banner only, or no chrome at all; `surfaces` replaces a whole screen per kind or per doctype; `scope` filters are ANDed into every doctype-homed query and cannot be cleared from the filter chrome.",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export default meta;
|
|
35
|
+
|
|
36
|
+
const MOCK_HOST = "https://backoffice-desk.mock.test";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Fixtures
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const TASK_DOCTYPE = "QaDeskWorkItem";
|
|
43
|
+
const FIRST_TASK = "QDW-0001";
|
|
44
|
+
|
|
45
|
+
const taskMeta = {
|
|
46
|
+
name: TASK_DOCTYPE,
|
|
47
|
+
doctype: "DocType",
|
|
48
|
+
module: "QA",
|
|
49
|
+
title_field: "subject",
|
|
50
|
+
fields: [
|
|
51
|
+
{ fieldname: "subject", fieldtype: "Data", label: "Subject", in_list_view: 1, reqd: 1 },
|
|
52
|
+
{
|
|
53
|
+
fieldname: "status",
|
|
54
|
+
fieldtype: "Select",
|
|
55
|
+
label: "Status",
|
|
56
|
+
options: "Open\nWorking\nDone",
|
|
57
|
+
in_list_view: 1,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
fieldname: "priority",
|
|
61
|
+
fieldtype: "Select",
|
|
62
|
+
label: "Priority",
|
|
63
|
+
options: "High\nMedium\nLow",
|
|
64
|
+
in_list_view: 1,
|
|
65
|
+
},
|
|
66
|
+
{ fieldname: "company", fieldtype: "Data", label: "Company", in_list_view: 1 },
|
|
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 companies = ["Acme", "Globex"];
|
|
75
|
+
const owners = ["ana", "bo", "cyrus", "dee"];
|
|
76
|
+
|
|
77
|
+
const taskDocs = Array.from({ length: 14 }, (_, i) => ({
|
|
78
|
+
name: `QDW-${String(i + 1).padStart(4, "0")}`,
|
|
79
|
+
doctype: TASK_DOCTYPE,
|
|
80
|
+
subject: `Work item ${i + 1}: ${["triage inbox", "ship release", "write docs", "fix flaky test"][i % 4]}`,
|
|
81
|
+
status: statuses[i % statuses.length],
|
|
82
|
+
priority: priorities[i % priorities.length],
|
|
83
|
+
company: companies[i % companies.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: "Work Items", col: 3 } },
|
|
107
|
+
{
|
|
108
|
+
id: "p1",
|
|
109
|
+
type: "paragraph",
|
|
110
|
+
data: { text: "This whole desk is served from fixtures.<br>", col: 12 },
|
|
111
|
+
},
|
|
112
|
+
]),
|
|
113
|
+
shortcuts: [{ name: "sc1", type: "DocType", link_to: TASK_DOCTYPE, label: "Work Items" }],
|
|
114
|
+
links: [],
|
|
115
|
+
charts: [],
|
|
116
|
+
number_cards: [],
|
|
117
|
+
quick_lists: [],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "Users",
|
|
121
|
+
doctype: "Workspace",
|
|
122
|
+
label: "Users",
|
|
123
|
+
title: "Users",
|
|
124
|
+
icon: "users",
|
|
125
|
+
module: "Core",
|
|
126
|
+
parent_page: "",
|
|
127
|
+
public: 1,
|
|
128
|
+
for_user: "",
|
|
129
|
+
sequence_id: 2,
|
|
130
|
+
is_hidden: 0,
|
|
131
|
+
content: JSON.stringify([]),
|
|
132
|
+
shortcuts: [],
|
|
133
|
+
links: [],
|
|
134
|
+
charts: [],
|
|
135
|
+
number_cards: [],
|
|
136
|
+
quick_lists: [],
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const makeFixtures = () =>
|
|
141
|
+
new InMemoryFixtureProvider({
|
|
142
|
+
DocType: [taskMeta],
|
|
143
|
+
Workspace: workspaceDocs,
|
|
144
|
+
"Notification Log": [],
|
|
145
|
+
[TASK_DOCTYPE]: taskDocs,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const frappe = () => ({ baseURL: MOCK_HOST, fixtures: makeFixtures() });
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Quiet fetch mock for the mock host (navbar session probe, catalog reads),
|
|
152
|
+
* so no arm ever hits the network. The session probe must answer null: the
|
|
153
|
+
* generic `message: []` reads as a truthy user docname and crashes the
|
|
154
|
+
* avatar's initials (BackofficeShell.stories precedent). Web only.
|
|
155
|
+
*/
|
|
156
|
+
(() => {
|
|
157
|
+
if (typeof window === "undefined") return;
|
|
158
|
+
const w = window as unknown as Record<string, unknown>;
|
|
159
|
+
if (w.__deskStoriesMockFetch) return;
|
|
160
|
+
w.__deskStoriesMockFetch = true;
|
|
161
|
+
const realFetch = window.fetch.bind(window);
|
|
162
|
+
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
163
|
+
const urlStr =
|
|
164
|
+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
165
|
+
if (!urlStr.startsWith(MOCK_HOST)) return realFetch(input as RequestInfo, init);
|
|
166
|
+
const body =
|
|
167
|
+
new URL(urlStr).pathname === "/api/method/frappe.auth.get_logged_user"
|
|
168
|
+
? { message: null }
|
|
169
|
+
: { data: [], message: [] };
|
|
170
|
+
return new Response(JSON.stringify(body), {
|
|
171
|
+
status: 200,
|
|
172
|
+
headers: { "Content-Type": "application/json" },
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
})();
|
|
176
|
+
|
|
177
|
+
function Frame({ children }: { children: ReactNode }) {
|
|
178
|
+
return (
|
|
179
|
+
<YStack height={680} minHeight={0}>
|
|
180
|
+
{children}
|
|
181
|
+
</YStack>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Arms
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* No required props: full chrome (navbar + workspace rail) and the home
|
|
191
|
+
* surface, which lands on the default workspace.
|
|
192
|
+
*/
|
|
193
|
+
export const FullDesk = () => (
|
|
194
|
+
<Frame>
|
|
195
|
+
<Desk frappe={frappe()} />
|
|
196
|
+
</Frame>
|
|
197
|
+
);
|
|
198
|
+
FullDesk.storyName = "Main";
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A doctype pin. It sets the entry surface (the list) AND the reachability
|
|
202
|
+
* fence, and it flips the chrome default to "content" — the embedded shape a
|
|
203
|
+
* host drops into its own page.
|
|
204
|
+
*/
|
|
205
|
+
export const PinnedList = () => (
|
|
206
|
+
<Frame>
|
|
207
|
+
<Desk frappe={frappe()} doctype={TASK_DOCTYPE} />
|
|
208
|
+
</Frame>
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
/** doctype + name: the document form, still fenced to that one doctype. */
|
|
212
|
+
export const PinnedForm = () => (
|
|
213
|
+
<Frame>
|
|
214
|
+
<Desk frappe={frappe()} doctype={TASK_DOCTYPE} name={FIRST_TASK} />
|
|
215
|
+
</Frame>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
/** doctype + name="new": create mode. */
|
|
219
|
+
export const PinnedNewDocument = () => (
|
|
220
|
+
<Frame>
|
|
221
|
+
<Desk frappe={frappe()} doctype={TASK_DOCTYPE} name="new" />
|
|
222
|
+
</Frame>
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
/** doctype + view: an alternate view as the entry surface. */
|
|
226
|
+
export const PinnedKanban = () => (
|
|
227
|
+
<Frame>
|
|
228
|
+
<Desk frappe={frappe()} doctype={TASK_DOCTYPE} view="kanban" />
|
|
229
|
+
</Frame>
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
/** A workspace pin: one page, no rail. */
|
|
233
|
+
export const PinnedWorkspace = () => (
|
|
234
|
+
<Frame>
|
|
235
|
+
<Desk frappe={frappe()} workspace="Tools" />
|
|
236
|
+
</Frame>
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* chrome="bare": the banner without the workspace rail. The sidebar
|
|
241
|
+
* capability is forced off with it, so nothing can put the rail back.
|
|
242
|
+
*/
|
|
243
|
+
export const BareChrome = () => (
|
|
244
|
+
<Frame>
|
|
245
|
+
<Desk frappe={frappe()} doctype={TASK_DOCTYPE} chrome="bare" />
|
|
246
|
+
</Frame>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
/** chrome="full" over a pin: the pin picks the surface, the host picks the chrome. */
|
|
250
|
+
export const PinnedWithFullChrome = () => (
|
|
251
|
+
<Frame>
|
|
252
|
+
<Desk frappe={frappe()} doctype={TASK_DOCTYPE} chrome="full" />
|
|
253
|
+
</Frame>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
/** `embedded` is sugar for chrome="content": the surface and nothing else. */
|
|
257
|
+
export const Embedded = () => (
|
|
258
|
+
<Frame>
|
|
259
|
+
<Desk frappe={frappe()} workspace="Tools" embedded />
|
|
260
|
+
</Frame>
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `scope` is the tenant seam: ANDed into every doctype-homed query, invisible
|
|
265
|
+
* to the filter chrome and not removable by the user. Here the desk can only
|
|
266
|
+
* ever see Acme rows, on the list and on every alternate view.
|
|
267
|
+
*/
|
|
268
|
+
export const ScopedToOneTenant = () => (
|
|
269
|
+
<Frame>
|
|
270
|
+
<Desk frappe={frappe()} doctype={TASK_DOCTYPE} scope={[["company", "=", "Acme"]]} />
|
|
271
|
+
</Frame>
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
function HostSurface({ target }: DeskSurfaceProps) {
|
|
275
|
+
return (
|
|
276
|
+
<YStack
|
|
277
|
+
flex={1}
|
|
278
|
+
padding="$4"
|
|
279
|
+
gap="$2"
|
|
280
|
+
borderWidth={1}
|
|
281
|
+
borderColor="$borderColor"
|
|
282
|
+
borderRadius="$3"
|
|
283
|
+
backgroundColor="$color2"
|
|
284
|
+
>
|
|
285
|
+
<Text>{`host-rendered surface for kind "${target.kind}"`}</Text>
|
|
286
|
+
<Text>{JSON.stringify(target)}</Text>
|
|
287
|
+
</YStack>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `surfaces` replaces a whole screen. Overrides are per-Desk by
|
|
293
|
+
* construction, so two desks on one page never see each other's.
|
|
294
|
+
*/
|
|
295
|
+
export const HostSurfaceOverride = () => (
|
|
296
|
+
<Frame>
|
|
297
|
+
<Desk frappe={frappe()} doctype={TASK_DOCTYPE} surfaces={{ list: HostSurface }} />
|
|
298
|
+
</Frame>
|
|
299
|
+
);
|