@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/backoffice",
3
- "version": "7.7.0",
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.0",
76
- "@multiplatform.one/forms": "7.7.0",
77
- "@multiplatform.one/frappe": "7.7.0",
78
- "@multiplatform.one/frappe-ui": "7.7.0",
79
- "@multiplatform.one/i18n": "7.7.0",
80
- "@multiplatform.one/platform": "7.7.0",
81
- "@multiplatform.one/store": "7.7.0",
82
- "@multiplatform.one/table": "7.7.0",
83
- "@multiplatform.one/theme": "7.7.0"
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.0",
98
- "@multiplatform.one/test-utils": "7.7.0"
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
+ };
@@ -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
+ );