@multiplatform.one/backoffice 7.2.1 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/config/BackofficeProvider.cjs +4 -10
- package/dist/cjs/config/BackofficeProvider.native.js +9 -29
- package/dist/cjs/config/BackofficeProvider.native.js.map +1 -1
- package/dist/cjs/desk/Desk.cjs +187 -0
- package/dist/cjs/desk/Desk.native.js +214 -0
- package/dist/cjs/desk/Desk.native.js.map +1 -0
- package/dist/cjs/desk/DeskSurface.cjs +123 -0
- package/dist/cjs/desk/DeskSurface.native.js +130 -0
- package/dist/cjs/desk/DeskSurface.native.js.map +1 -0
- package/dist/cjs/desk/resolveDeskPath.cjs +118 -0
- package/dist/cjs/desk/resolveDeskPath.native.js +123 -0
- package/dist/cjs/desk/resolveDeskPath.native.js.map +1 -0
- package/dist/cjs/desk/scope.cjs +54 -0
- package/dist/cjs/desk/scope.native.js +61 -0
- package/dist/cjs/desk/scope.native.js.map +1 -0
- package/dist/cjs/desk/target.cjs +174 -0
- package/dist/cjs/desk/target.native.js +178 -0
- package/dist/cjs/desk/target.native.js.map +1 -0
- package/dist/cjs/index.cjs +13 -0
- package/dist/cjs/index.native.js +13 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/list/DoctypeListScreen.cjs +3 -1
- package/dist/cjs/list/DoctypeListScreen.native.js +3 -1
- package/dist/cjs/list/DoctypeListScreen.native.js.map +1 -1
- package/dist/cjs/report/DoctypeReportView.cjs +3 -1
- package/dist/cjs/report/DoctypeReportView.native.js +4 -2
- package/dist/cjs/report/DoctypeReportView.native.js.map +1 -1
- package/dist/cjs/views/CalendarView.cjs +5 -0
- package/dist/cjs/views/CalendarView.native.js +5 -0
- package/dist/cjs/views/CalendarView.native.js.map +1 -1
- package/dist/cjs/views/DashboardView.cjs +5 -3
- package/dist/cjs/views/DashboardView.native.js +5 -3
- package/dist/cjs/views/DashboardView.native.js.map +1 -1
- package/dist/cjs/views/ImageView.cjs +5 -0
- package/dist/cjs/views/ImageView.native.js +5 -0
- package/dist/cjs/views/ImageView.native.js.map +1 -1
- package/dist/cjs/views/KanbanView.cjs +5 -2
- package/dist/cjs/views/KanbanView.native.js +5 -2
- package/dist/cjs/views/KanbanView.native.js.map +1 -1
- package/dist/cjs/views/TreeView.cjs +5 -0
- package/dist/cjs/views/TreeView.native.js +5 -0
- package/dist/cjs/views/TreeView.native.js.map +1 -1
- package/dist/esm/config/BackofficeProvider.mjs +5 -11
- package/dist/esm/config/BackofficeProvider.mjs.map +1 -1
- package/dist/esm/config/BackofficeProvider.native.js +10 -30
- package/dist/esm/config/BackofficeProvider.native.js.map +1 -1
- package/dist/esm/desk/Desk.mjs +162 -0
- package/dist/esm/desk/Desk.mjs.map +1 -0
- package/dist/esm/desk/Desk.native.js +186 -0
- package/dist/esm/desk/Desk.native.js.map +1 -0
- package/dist/esm/desk/DeskSurface.mjs +97 -0
- package/dist/esm/desk/DeskSurface.mjs.map +1 -0
- package/dist/esm/desk/DeskSurface.native.js +101 -0
- package/dist/esm/desk/DeskSurface.native.js.map +1 -0
- package/dist/esm/desk/resolveDeskPath.mjs +93 -0
- package/dist/esm/desk/resolveDeskPath.mjs.map +1 -0
- package/dist/esm/desk/resolveDeskPath.native.js +95 -0
- package/dist/esm/desk/resolveDeskPath.native.js.map +1 -0
- package/dist/esm/desk/scope.mjs +27 -0
- package/dist/esm/desk/scope.mjs.map +1 -0
- package/dist/esm/desk/scope.native.js +31 -0
- package/dist/esm/desk/scope.native.js.map +1 -0
- package/dist/esm/desk/target.mjs +147 -0
- package/dist/esm/desk/target.mjs.map +1 -0
- package/dist/esm/desk/target.native.js +148 -0
- package/dist/esm/desk/target.native.js.map +1 -0
- package/dist/esm/index.mjs +6 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +6 -1
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/list/DoctypeListScreen.mjs +3 -1
- package/dist/esm/list/DoctypeListScreen.mjs.map +1 -1
- package/dist/esm/list/DoctypeListScreen.native.js +3 -1
- package/dist/esm/list/DoctypeListScreen.native.js.map +1 -1
- package/dist/esm/report/DoctypeReportView.mjs +3 -1
- package/dist/esm/report/DoctypeReportView.mjs.map +1 -1
- package/dist/esm/report/DoctypeReportView.native.js +4 -2
- package/dist/esm/report/DoctypeReportView.native.js.map +1 -1
- package/dist/esm/views/CalendarView.mjs +5 -0
- package/dist/esm/views/CalendarView.mjs.map +1 -1
- package/dist/esm/views/CalendarView.native.js +5 -0
- package/dist/esm/views/CalendarView.native.js.map +1 -1
- package/dist/esm/views/DashboardView.mjs +5 -3
- package/dist/esm/views/DashboardView.mjs.map +1 -1
- package/dist/esm/views/DashboardView.native.js +5 -3
- package/dist/esm/views/DashboardView.native.js.map +1 -1
- package/dist/esm/views/ImageView.mjs +5 -0
- package/dist/esm/views/ImageView.mjs.map +1 -1
- package/dist/esm/views/ImageView.native.js +5 -0
- package/dist/esm/views/ImageView.native.js.map +1 -1
- package/dist/esm/views/KanbanView.mjs +5 -2
- package/dist/esm/views/KanbanView.mjs.map +1 -1
- package/dist/esm/views/KanbanView.native.js +5 -2
- package/dist/esm/views/KanbanView.native.js.map +1 -1
- package/dist/esm/views/TreeView.mjs +5 -0
- package/dist/esm/views/TreeView.mjs.map +1 -1
- package/dist/esm/views/TreeView.native.js +5 -0
- package/dist/esm/views/TreeView.native.js.map +1 -1
- package/dist/jsx/config/BackofficeProvider.mjs +5 -11
- package/dist/jsx/config/BackofficeProvider.mjs.map +1 -1
- package/dist/jsx/config/BackofficeProvider.native.js +9 -29
- package/dist/jsx/config/BackofficeProvider.native.js.map +1 -1
- package/dist/jsx/desk/Desk.mjs +162 -0
- package/dist/jsx/desk/Desk.mjs.map +1 -0
- package/dist/jsx/desk/Desk.native.js +214 -0
- package/dist/jsx/desk/Desk.native.js.map +1 -0
- package/dist/jsx/desk/DeskSurface.mjs +97 -0
- package/dist/jsx/desk/DeskSurface.mjs.map +1 -0
- package/dist/jsx/desk/DeskSurface.native.js +130 -0
- package/dist/jsx/desk/DeskSurface.native.js.map +1 -0
- package/dist/jsx/desk/resolveDeskPath.mjs +93 -0
- package/dist/jsx/desk/resolveDeskPath.mjs.map +1 -0
- package/dist/jsx/desk/resolveDeskPath.native.js +123 -0
- package/dist/jsx/desk/resolveDeskPath.native.js.map +1 -0
- package/dist/jsx/desk/scope.mjs +27 -0
- package/dist/jsx/desk/scope.mjs.map +1 -0
- package/dist/jsx/desk/scope.native.js +61 -0
- package/dist/jsx/desk/scope.native.js.map +1 -0
- package/dist/jsx/desk/target.mjs +147 -0
- package/dist/jsx/desk/target.mjs.map +1 -0
- package/dist/jsx/desk/target.native.js +178 -0
- package/dist/jsx/desk/target.native.js.map +1 -0
- package/dist/jsx/index.js +6 -1
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +6 -1
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +13 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/list/DoctypeListScreen.mjs +3 -1
- package/dist/jsx/list/DoctypeListScreen.mjs.map +1 -1
- package/dist/jsx/list/DoctypeListScreen.native.js +3 -1
- package/dist/jsx/list/DoctypeListScreen.native.js.map +1 -1
- package/dist/jsx/report/DoctypeReportView.mjs +3 -1
- package/dist/jsx/report/DoctypeReportView.mjs.map +1 -1
- package/dist/jsx/report/DoctypeReportView.native.js +4 -2
- package/dist/jsx/report/DoctypeReportView.native.js.map +1 -1
- package/dist/jsx/views/CalendarView.mjs +5 -0
- package/dist/jsx/views/CalendarView.mjs.map +1 -1
- package/dist/jsx/views/CalendarView.native.js +5 -0
- package/dist/jsx/views/CalendarView.native.js.map +1 -1
- package/dist/jsx/views/DashboardView.mjs +5 -3
- package/dist/jsx/views/DashboardView.mjs.map +1 -1
- package/dist/jsx/views/DashboardView.native.js +5 -3
- package/dist/jsx/views/DashboardView.native.js.map +1 -1
- package/dist/jsx/views/ImageView.mjs +5 -0
- package/dist/jsx/views/ImageView.mjs.map +1 -1
- package/dist/jsx/views/ImageView.native.js +5 -0
- package/dist/jsx/views/ImageView.native.js.map +1 -1
- package/dist/jsx/views/KanbanView.mjs +5 -2
- package/dist/jsx/views/KanbanView.mjs.map +1 -1
- package/dist/jsx/views/KanbanView.native.js +5 -2
- package/dist/jsx/views/KanbanView.native.js.map +1 -1
- package/dist/jsx/views/TreeView.mjs +5 -0
- package/dist/jsx/views/TreeView.mjs.map +1 -1
- package/dist/jsx/views/TreeView.native.js +5 -0
- package/dist/jsx/views/TreeView.native.js.map +1 -1
- package/package.json +12 -12
- package/src/config/BackofficeProvider.tsx +11 -18
- package/src/desk/Desk.spec.tsx +287 -0
- package/src/desk/Desk.tsx +308 -0
- package/src/desk/DeskSurface.tsx +132 -0
- package/src/desk/doctrine.spec.ts +48 -0
- package/src/desk/resolveDeskPath.spec.ts +174 -0
- package/src/desk/resolveDeskPath.ts +111 -0
- package/src/desk/scope.tsx +52 -0
- package/src/desk/target.ts +169 -0
- package/src/index.ts +17 -0
- package/src/list/DoctypeListScreen.tsx +4 -1
- package/src/report/DoctypeReportView.tsx +6 -2
- package/src/views/CalendarView.tsx +3 -0
- package/src/views/DashboardView.tsx +5 -3
- package/src/views/ImageView.tsx +3 -0
- package/src/views/KanbanView.tsx +5 -1
- package/src/views/TreeView.tsx +3 -0
- package/types/config/BackofficeProvider.d.ts +6 -4
- package/types/config/BackofficeProvider.d.ts.map +1 -1
- package/types/desk/Desk.d.ts +91 -0
- package/types/desk/Desk.d.ts.map +1 -0
- package/types/desk/DeskSurface.d.ts +24 -0
- package/types/desk/DeskSurface.d.ts.map +1 -0
- package/types/desk/resolveDeskPath.d.ts +60 -0
- package/types/desk/resolveDeskPath.d.ts.map +1 -0
- package/types/desk/scope.d.ts +31 -0
- package/types/desk/scope.d.ts.map +1 -0
- package/types/desk/target.d.ts +57 -0
- package/types/desk/target.d.ts.map +1 -0
- package/types/index.d.ts +10 -0
- package/types/index.d.ts.map +1 -1
- package/types/list/DoctypeListScreen.d.ts.map +1 -1
- package/types/report/DoctypeReportView.d.ts.map +1 -1
- package/types/views/CalendarView.d.ts.map +1 -1
- package/types/views/DashboardView.d.ts.map +1 -1
- package/types/views/ImageView.d.ts.map +1 -1
- package/types/views/KanbanView.d.ts.map +1 -1
- package/types/views/TreeView.d.ts.map +1 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeskSurface — the target→surface switch plus the surface override
|
|
3
|
+
* registry (MPO-14 / gaps GL-1 assembly half + GL-6, spec 09 §3, §5.1).
|
|
4
|
+
*
|
|
5
|
+
* A pure dispatch: every branch renders an EXISTING wired component; no
|
|
6
|
+
* fetch, no filter construction, no visual values (DK-2/DK-3). Overrides
|
|
7
|
+
* ride props (per-Desk by construction, DK-7): per-doctype first, then
|
|
8
|
+
* per-kind, then the built-in.
|
|
9
|
+
*/
|
|
10
|
+
import { Spinner, YStack } from "@multiplatform.one/components";
|
|
11
|
+
import { useMemo, type ComponentType } from "react";
|
|
12
|
+
import { setCreatePrefill } from "../form/createPrefill";
|
|
13
|
+
import { DocFormScreen } from "../form/DocFormScreen";
|
|
14
|
+
import { DoctypeListScreen } from "../list/DoctypeListScreen";
|
|
15
|
+
import type { BackofficeView } from "../navigation/paths";
|
|
16
|
+
import { QueryReportScreen } from "../report/QueryReportScreen";
|
|
17
|
+
import type { FrappeFilters } from "../shared/types";
|
|
18
|
+
import { HomeRedirect } from "../shell/HomeRedirect";
|
|
19
|
+
import { NotFoundScreen } from "../shell/NotFoundScreen";
|
|
20
|
+
import { DoctypeViewScreen } from "../views/DoctypeViewScreen";
|
|
21
|
+
import { WorkspaceScreen } from "../workspace/WorkspaceScreen";
|
|
22
|
+
import type { DeskTarget } from "./target";
|
|
23
|
+
|
|
24
|
+
export type DeskSurfaceKind = DeskTarget["kind"];
|
|
25
|
+
|
|
26
|
+
/** The whole contract an override receives — thin enough to wrap the built-in. */
|
|
27
|
+
export interface DeskSurfaceProps {
|
|
28
|
+
target: DeskTarget;
|
|
29
|
+
readOnly?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A component per kind, or per doctype under that kind. */
|
|
33
|
+
export type DeskSurfaceOverride =
|
|
34
|
+
| ComponentType<DeskSurfaceProps>
|
|
35
|
+
| Record<string, ComponentType<DeskSurfaceProps>>;
|
|
36
|
+
|
|
37
|
+
export type DeskSurfaceOverrides = Partial<Record<DeskSurfaceKind, DeskSurfaceOverride>>;
|
|
38
|
+
|
|
39
|
+
function resolveOverride(
|
|
40
|
+
overrides: DeskSurfaceOverrides | undefined,
|
|
41
|
+
target: DeskTarget,
|
|
42
|
+
): ComponentType<DeskSurfaceProps> | undefined {
|
|
43
|
+
const entry = overrides?.[target.kind];
|
|
44
|
+
if (!entry) return undefined;
|
|
45
|
+
if (typeof entry === "function") return entry;
|
|
46
|
+
const doctype = "doctype" in target ? target.doctype : undefined;
|
|
47
|
+
return doctype ? entry[doctype] : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Create mode, `?d=`/pin defaults routed through the ONE prefill stash. */
|
|
51
|
+
function NewDocSurface({
|
|
52
|
+
doctype,
|
|
53
|
+
defaults,
|
|
54
|
+
readOnly,
|
|
55
|
+
}: {
|
|
56
|
+
doctype: string;
|
|
57
|
+
defaults?: Record<string, unknown>;
|
|
58
|
+
readOnly?: boolean;
|
|
59
|
+
}) {
|
|
60
|
+
const defaultsKey = JSON.stringify(defaults ?? null);
|
|
61
|
+
// Stash in a memo (not an effect) so the values land before
|
|
62
|
+
// DocFormScreen's create-mode seeding runs — same contract as the old
|
|
63
|
+
// host route screen this replaces.
|
|
64
|
+
useMemo(() => {
|
|
65
|
+
if (defaults && Object.keys(defaults).length > 0) setCreatePrefill(doctype, defaults);
|
|
66
|
+
// oxlint-disable-next-line exhaustive-deps
|
|
67
|
+
}, [doctype, defaultsKey]);
|
|
68
|
+
// Keyed on defaults: same-doctype navigation with different `?d=` must
|
|
69
|
+
// remount to re-seed.
|
|
70
|
+
return <DocFormScreen key={defaultsKey} doctype={doctype} name="new" readOnly={readOnly} />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DeskSurfaceHostProps extends DeskSurfaceProps {
|
|
74
|
+
overrides?: DeskSurfaceOverrides;
|
|
75
|
+
/** Seed for the list surface's user-editable filter channel. */
|
|
76
|
+
initialFilters?: FrappeFilters;
|
|
77
|
+
/** View-switcher allowlist forwarded to the list surface. */
|
|
78
|
+
views?: readonly BackofficeView[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function DeskSurface({
|
|
82
|
+
target,
|
|
83
|
+
overrides,
|
|
84
|
+
readOnly,
|
|
85
|
+
initialFilters,
|
|
86
|
+
views,
|
|
87
|
+
}: DeskSurfaceHostProps) {
|
|
88
|
+
const Override = resolveOverride(overrides, target);
|
|
89
|
+
if (Override) return <Override target={target} readOnly={readOnly} />;
|
|
90
|
+
|
|
91
|
+
switch (target.kind) {
|
|
92
|
+
case "home":
|
|
93
|
+
return <HomeRedirect />;
|
|
94
|
+
case "workspace":
|
|
95
|
+
return <WorkspaceScreen workspace={target.workspace} />;
|
|
96
|
+
case "list":
|
|
97
|
+
return (
|
|
98
|
+
<DoctypeListScreen
|
|
99
|
+
doctype={target.doctype}
|
|
100
|
+
filtersParam={target.filtersParam}
|
|
101
|
+
sortParam={target.sortParam}
|
|
102
|
+
initialFilters={initialFilters}
|
|
103
|
+
views={views}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
case "view":
|
|
107
|
+
return <DoctypeViewScreen doctype={target.doctype} view={target.view} />;
|
|
108
|
+
case "new":
|
|
109
|
+
return (
|
|
110
|
+
<NewDocSurface doctype={target.doctype} defaults={target.defaults} readOnly={readOnly} />
|
|
111
|
+
);
|
|
112
|
+
case "form":
|
|
113
|
+
// MPO-15: DocFormScreen is the strong form today. When the form-engine
|
|
114
|
+
// seam collapses (formLayout + useFormEngine hoisted into frappe-ui,
|
|
115
|
+
// gap GL-3), the exported FrappeForm becomes this branch's renderer —
|
|
116
|
+
// swap here, nowhere else.
|
|
117
|
+
return <DocFormScreen doctype={target.doctype} name={target.name} readOnly={readOnly} />;
|
|
118
|
+
case "report":
|
|
119
|
+
return <QueryReportScreen reportName={target.reportName} />;
|
|
120
|
+
case "not-found":
|
|
121
|
+
return <NotFoundScreen />;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The resolving state — same presentation as the slug route's spinner. */
|
|
126
|
+
export function DeskSurfaceLoading() {
|
|
127
|
+
return (
|
|
128
|
+
<YStack flex={1} alignItems="center" justifyContent="center">
|
|
129
|
+
<Spinner size="large" />
|
|
130
|
+
</YStack>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The doctrine greps (spec 09 §9.2), shipped as spec tests — a rule nobody
|
|
3
|
+
* runs is a wish.
|
|
4
|
+
*
|
|
5
|
+
* DK-2: Desk contains no data wiring. Every byte it displays is fetched by
|
|
6
|
+
* a component it renders or an exported hook.
|
|
7
|
+
* DK-3: Desk contains no visual values — no color literal, no pixel literal.
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
const DESK_DIR = join(__dirname);
|
|
14
|
+
|
|
15
|
+
function deskSources(extensions: readonly string[]): { file: string; source: string }[] {
|
|
16
|
+
return readdirSync(DESK_DIR)
|
|
17
|
+
.filter(
|
|
18
|
+
(file) =>
|
|
19
|
+
extensions.some((extension) => file.endsWith(extension)) &&
|
|
20
|
+
!file.includes(".spec.") &&
|
|
21
|
+
!file.includes(".stories."),
|
|
22
|
+
)
|
|
23
|
+
.map((file) => ({ file, source: readFileSync(join(DESK_DIR, file), "utf8") }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Match lines, reporting file:line so a failure names its violation. */
|
|
27
|
+
function violations(sources: { file: string; source: string }[], pattern: RegExp): string[] {
|
|
28
|
+
const found: string[] = [];
|
|
29
|
+
for (const { file, source } of sources) {
|
|
30
|
+
source.split("\n").forEach((line, index) => {
|
|
31
|
+
if (pattern.test(line)) found.push(`${file}:${index + 1}: ${line.trim()}`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("desk doctrine (spec 09 §9.2)", () => {
|
|
38
|
+
it("DK-2: no data wiring anywhere in src/desk", () => {
|
|
39
|
+
const pattern =
|
|
40
|
+
/HttpClient|useFrappeCollection|useLiveQuery|\bfetch\(|axios|\/api\/(resource|method)/;
|
|
41
|
+
expect(violations(deskSources([".ts", ".tsx"]), pattern)).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("DK-3: no color or pixel literals anywhere in src/desk", () => {
|
|
45
|
+
const pattern = /#[0-9a-fA-F]{3,8}\b|rgba?\(|\b(width|height|padding|margin|fontSize)=\{[0-9]/;
|
|
46
|
+
expect(violations(deskSources([".tsx"]), pattern)).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DM-1 correctness proof (spec 09 §4.3): for every builder in
|
|
3
|
+
* `createBackofficePaths`, `resolveDeskPath(paths.x(...))` returns the
|
|
4
|
+
* address that built it — both halves are pure, so the round-trip is the
|
|
5
|
+
* whole contract.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { parseBackofficeHref } from "../navigation/navigator";
|
|
9
|
+
import { BACKOFFICE_VIEWS, createBackofficePaths } from "../navigation/paths";
|
|
10
|
+
import { resolveDeskPath, type DeskAddress } from "./resolveDeskPath";
|
|
11
|
+
|
|
12
|
+
const BASE_PATHS = ["/backoffice", "/admin", "/", "/nested/desk"] as const;
|
|
13
|
+
|
|
14
|
+
/** Split a built href the way the navigator does, then resolve it. */
|
|
15
|
+
function resolve(href: string, basePath: string): DeskAddress {
|
|
16
|
+
const { path, params } = parseBackofficeHref(href);
|
|
17
|
+
return resolveDeskPath(path, basePath, params);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe.each(BASE_PATHS)("resolveDeskPath round-trips createBackofficePaths at %s", (basePath) => {
|
|
21
|
+
const paths = createBackofficePaths(basePath);
|
|
22
|
+
|
|
23
|
+
it("home() → home", () => {
|
|
24
|
+
expect(resolve(paths.home(), basePath)).toEqual({ kind: "home" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("workspace() → slug (ambiguity is data, resolved downstream)", () => {
|
|
28
|
+
expect(resolve(paths.workspace("Accounting"), basePath)).toEqual({
|
|
29
|
+
kind: "slug",
|
|
30
|
+
slug: "accounting",
|
|
31
|
+
filtersParam: undefined,
|
|
32
|
+
sortParam: undefined,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("list() → slug", () => {
|
|
37
|
+
expect(resolve(paths.list("Sales Order"), basePath)).toMatchObject({
|
|
38
|
+
kind: "slug",
|
|
39
|
+
slug: "sales-order",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it.each(BACKOFFICE_VIEWS.filter((view) => view !== "list"))("view(%s) → slug-view", (view) => {
|
|
44
|
+
expect(resolve(paths.view("Sales Order", view), basePath)).toEqual({
|
|
45
|
+
kind: "slug-view",
|
|
46
|
+
slug: "sales-order",
|
|
47
|
+
view,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("view('list') → the list surface, exactly like list()", () => {
|
|
52
|
+
expect(resolve(paths.view("Sales Order", "list"), basePath)).toMatchObject({
|
|
53
|
+
kind: "slug",
|
|
54
|
+
slug: "sales-order",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("newDoc() → slug-new, without and with ?d= defaults", () => {
|
|
59
|
+
expect(resolve(paths.newDoc("Sales Order"), basePath)).toEqual({
|
|
60
|
+
kind: "slug-new",
|
|
61
|
+
slug: "sales-order",
|
|
62
|
+
defaults: undefined,
|
|
63
|
+
});
|
|
64
|
+
const defaults = { customer: "ACME & Sons", qty: 2 };
|
|
65
|
+
expect(resolve(paths.newDoc("Sales Order", defaults), basePath)).toEqual({
|
|
66
|
+
kind: "slug-new",
|
|
67
|
+
slug: "sales-order",
|
|
68
|
+
defaults,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("form() → slug-name, decoding encoded document names", () => {
|
|
73
|
+
expect(resolve(paths.form("Sales Order", "SO-0001"), basePath)).toEqual({
|
|
74
|
+
kind: "slug-name",
|
|
75
|
+
slug: "sales-order",
|
|
76
|
+
name: "SO-0001",
|
|
77
|
+
});
|
|
78
|
+
expect(resolve(paths.form("File", "a b/c#1"), basePath)).toEqual({
|
|
79
|
+
kind: "slug-name",
|
|
80
|
+
slug: "file",
|
|
81
|
+
name: "a b/c#1",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("a document literally named 'new' still resolves as create mode (desk parity)", () => {
|
|
86
|
+
expect(resolve(paths.form("Task", "new"), basePath)).toMatchObject({ kind: "slug-new" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("queryReport() → report, decoding encoded report names", () => {
|
|
90
|
+
expect(resolve(paths.queryReport("Accounts Receivable"), basePath)).toEqual({
|
|
91
|
+
kind: "report",
|
|
92
|
+
reportName: "Accounts Receivable",
|
|
93
|
+
filtersParam: undefined,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("resolveDeskPath search params", () => {
|
|
99
|
+
const paths = createBackofficePaths("/backoffice");
|
|
100
|
+
|
|
101
|
+
it("carries ?f=/?sort= on the list address", () => {
|
|
102
|
+
const address = resolveDeskPath("/backoffice/todo", "/backoffice", {
|
|
103
|
+
f: '[["status","=","Open"]]',
|
|
104
|
+
sort: "modified.desc",
|
|
105
|
+
});
|
|
106
|
+
expect(address).toEqual({
|
|
107
|
+
kind: "slug",
|
|
108
|
+
slug: "todo",
|
|
109
|
+
filtersParam: '[["status","=","Open"]]',
|
|
110
|
+
sortParam: "modified.desc",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("carries ?board=/?field=/?f= on the view address", () => {
|
|
115
|
+
const address = resolveDeskPath(`${paths.view("ToDo", "kanban")}`, "/backoffice", {
|
|
116
|
+
board: "My Board",
|
|
117
|
+
field: "date",
|
|
118
|
+
});
|
|
119
|
+
expect(address).toEqual({
|
|
120
|
+
kind: "slug-view",
|
|
121
|
+
slug: "todo",
|
|
122
|
+
view: "kanban",
|
|
123
|
+
params: { board: "My Board", field: "date", filtersParam: undefined },
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("carries ?filters= on the report address", () => {
|
|
128
|
+
const address = resolveDeskPath(
|
|
129
|
+
"/backoffice/query-report/Accounts%20Receivable",
|
|
130
|
+
"/backoffice",
|
|
131
|
+
{
|
|
132
|
+
filters: '{"company":"ACME"}',
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
expect(address).toEqual({
|
|
136
|
+
kind: "report",
|
|
137
|
+
reportName: "Accounts Receivable",
|
|
138
|
+
filtersParam: '{"company":"ACME"}',
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("resolveDeskPath rejections", () => {
|
|
144
|
+
it("paths outside basePath are not-found", () => {
|
|
145
|
+
expect(resolveDeskPath("/elsewhere/todo", "/backoffice")).toEqual({
|
|
146
|
+
kind: "not-found",
|
|
147
|
+
path: "/elsewhere/todo",
|
|
148
|
+
});
|
|
149
|
+
// Prefix must match on a segment boundary.
|
|
150
|
+
expect(resolveDeskPath("/backofficex/todo", "/backoffice")).toMatchObject({
|
|
151
|
+
kind: "not-found",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("malformed shapes are not-found", () => {
|
|
156
|
+
expect(resolveDeskPath("/backoffice/todo/view/nope", "/backoffice")).toMatchObject({
|
|
157
|
+
kind: "not-found",
|
|
158
|
+
});
|
|
159
|
+
expect(resolveDeskPath("/backoffice/todo/view/kanban/extra", "/backoffice")).toMatchObject({
|
|
160
|
+
kind: "not-found",
|
|
161
|
+
});
|
|
162
|
+
expect(resolveDeskPath("/backoffice/query-report", "/backoffice")).toMatchObject({
|
|
163
|
+
kind: "not-found",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("trailing slashes normalize", () => {
|
|
168
|
+
expect(resolveDeskPath("/backoffice/", "/backoffice")).toEqual({ kind: "home" });
|
|
169
|
+
expect(resolveDeskPath("/backoffice/todo/", "/backoffice")).toMatchObject({
|
|
170
|
+
kind: "slug",
|
|
171
|
+
slug: "todo",
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveDeskPath — the pure path→surface resolver (MPO-14 / gap GL-1,
|
|
3
|
+
* spec 09 §DM-1, §4.3).
|
|
4
|
+
*
|
|
5
|
+
* One inverse for every builder in `createBackofficePaths`, so dispatch
|
|
6
|
+
* lives in the package once instead of being re-derived per host route
|
|
7
|
+
* file. Pure: no network, no React. The slug half of an address stays
|
|
8
|
+
* AMBIGUOUS here (workspace vs doctype is data, resolved by
|
|
9
|
+
* `useSlugTarget`); `useDeskTarget` composes the two.
|
|
10
|
+
*/
|
|
11
|
+
import type { BackofficeParams } from "../navigation/navigator";
|
|
12
|
+
import {
|
|
13
|
+
BACKOFFICE_BASE,
|
|
14
|
+
isBackofficeView,
|
|
15
|
+
parseDocDefaults,
|
|
16
|
+
type BackofficeView,
|
|
17
|
+
} from "../navigation/paths";
|
|
18
|
+
|
|
19
|
+
/** View search params the view surface reads (board/field/f — spec §2.2). */
|
|
20
|
+
export interface DeskViewParams {
|
|
21
|
+
/** `?board=` kanban board selection. */
|
|
22
|
+
board?: string;
|
|
23
|
+
/** `?field=` calendar date field. */
|
|
24
|
+
field?: string;
|
|
25
|
+
/** `?f=` report base filters (raw urlencoded JSON). */
|
|
26
|
+
filtersParam?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A resolved desk address: the shape of the URL, before slug disambiguation.
|
|
31
|
+
* `slug` kinds still need the runtime workspace/doctype index.
|
|
32
|
+
*/
|
|
33
|
+
export type DeskAddress =
|
|
34
|
+
| { kind: "home" }
|
|
35
|
+
| { kind: "slug"; slug: string; filtersParam?: string; sortParam?: string }
|
|
36
|
+
| {
|
|
37
|
+
kind: "slug-view";
|
|
38
|
+
slug: string;
|
|
39
|
+
view: Exclude<BackofficeView, "list">;
|
|
40
|
+
params?: DeskViewParams;
|
|
41
|
+
}
|
|
42
|
+
| { kind: "slug-new"; slug: string; defaults?: Record<string, unknown> }
|
|
43
|
+
| { kind: "slug-name"; slug: string; name: string }
|
|
44
|
+
| { kind: "report"; reportName: string; filtersParam?: string }
|
|
45
|
+
| { kind: "not-found"; path: string };
|
|
46
|
+
|
|
47
|
+
function first(value: string | string[] | undefined): string | undefined {
|
|
48
|
+
return typeof value === "string" ? value : value?.[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decode(segment: string): string {
|
|
52
|
+
try {
|
|
53
|
+
return decodeURIComponent(segment);
|
|
54
|
+
} catch {
|
|
55
|
+
return segment; // malformed escape — keep the raw segment
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve `path` (no query string; query lives in `params`, One-router
|
|
61
|
+
* semantics) against the six route shapes `createBackofficePaths` writes.
|
|
62
|
+
* Paths outside `basePath` resolve to `not-found`.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveDeskPath(
|
|
65
|
+
path: string,
|
|
66
|
+
basePath: string = BACKOFFICE_BASE,
|
|
67
|
+
params: BackofficeParams = {},
|
|
68
|
+
): DeskAddress {
|
|
69
|
+
const base = basePath.replace(/\/+$/, "") || "/";
|
|
70
|
+
const clean = path.replace(/\/+$/, "") || "/";
|
|
71
|
+
let rest: string;
|
|
72
|
+
if (base === "/") rest = clean;
|
|
73
|
+
else if (clean === base) rest = "/";
|
|
74
|
+
else if (clean.startsWith(`${base}/`)) rest = clean.slice(base.length);
|
|
75
|
+
else return { kind: "not-found", path };
|
|
76
|
+
|
|
77
|
+
const segments = rest.split("/").filter(Boolean).map(decode);
|
|
78
|
+
if (segments.length === 0) return { kind: "home" };
|
|
79
|
+
|
|
80
|
+
if (segments[0] === "query-report") {
|
|
81
|
+
if (segments.length === 2 && segments[1]) {
|
|
82
|
+
return { kind: "report", reportName: segments[1], filtersParam: first(params.filters) };
|
|
83
|
+
}
|
|
84
|
+
return { kind: "not-found", path };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const slug = segments[0];
|
|
88
|
+
if (segments.length === 1) {
|
|
89
|
+
return { kind: "slug", slug, filtersParam: first(params.f), sortParam: first(params.sort) };
|
|
90
|
+
}
|
|
91
|
+
if (segments.length === 2 && segments[1] === "new") {
|
|
92
|
+
return { kind: "slug-new", slug, defaults: parseDocDefaults(first(params.d)) };
|
|
93
|
+
}
|
|
94
|
+
if (segments.length === 3 && segments[1] === "view" && isBackofficeView(segments[2])) {
|
|
95
|
+
const view = segments[2];
|
|
96
|
+
// /view/list is the list surface (DoctypeViewScreen bounces it too).
|
|
97
|
+
if (view === "list") {
|
|
98
|
+
return { kind: "slug", slug, filtersParam: first(params.f), sortParam: first(params.sort) };
|
|
99
|
+
}
|
|
100
|
+
const board = first(params.board);
|
|
101
|
+
const field = first(params.field);
|
|
102
|
+
const filtersParam = first(params.f);
|
|
103
|
+
const viewParams: DeskViewParams | undefined =
|
|
104
|
+
board || field || filtersParam ? { board, field, filtersParam } : undefined;
|
|
105
|
+
return { kind: "slug-view", slug, view, ...(viewParams && { params: viewParams }) };
|
|
106
|
+
}
|
|
107
|
+
if (segments.length === 2) {
|
|
108
|
+
return { kind: "slug-name", slug, name: segments[1] };
|
|
109
|
+
}
|
|
110
|
+
return { kind: "not-found", path };
|
|
111
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desk scope — the tenant seam (MPO-14 / gap GL-5, spec 09 §DM-4).
|
|
3
|
+
*
|
|
4
|
+
* `scope` filters are ALWAYS ANDed into every doctype-homed query, invisible
|
|
5
|
+
* to the filter chrome and not removable by the user. Before this context
|
|
6
|
+
* existed the seam reached exactly one surface (DoctypeListScreen's
|
|
7
|
+
* `fixedFilters`), so a scoped desk leaked rows the moment the user switched
|
|
8
|
+
* to kanban. Surfaces read the context themselves; hosts set it once via
|
|
9
|
+
* `<Desk scope={…} />` (or `DeskScopeProvider` directly).
|
|
10
|
+
*
|
|
11
|
+
* Scope applies to doctype-homed surfaces (list, kanban, calendar, tree,
|
|
12
|
+
* image, dashboard, report view). Query/Script reports run server-side SQL
|
|
13
|
+
* and the awesomebar searches across doctypes, so field-tuple scope cannot
|
|
14
|
+
* soundly AND into either; restrict those with `capabilities` instead.
|
|
15
|
+
*/
|
|
16
|
+
import type { ReactNode } from "react";
|
|
17
|
+
import { createContext, useContext, useMemo } from "react";
|
|
18
|
+
import type { FrappeFilters } from "../shared/types";
|
|
19
|
+
|
|
20
|
+
const EMPTY_SCOPE: FrappeFilters = [];
|
|
21
|
+
|
|
22
|
+
const DeskScopeContext = createContext<FrappeFilters>(EMPTY_SCOPE);
|
|
23
|
+
|
|
24
|
+
export interface DeskScopeProviderProps {
|
|
25
|
+
/** Filter tuples ANDed into every doctype-homed query in the subtree. */
|
|
26
|
+
scope?: FrappeFilters;
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function DeskScopeProvider({ scope, children }: DeskScopeProviderProps) {
|
|
31
|
+
// Value-keyed so unstable array literals from the host don't churn readers.
|
|
32
|
+
const scopeKey = JSON.stringify(scope ?? EMPTY_SCOPE);
|
|
33
|
+
const value = useMemo(() => (JSON.parse(scopeKey) as FrappeFilters) ?? EMPTY_SCOPE, [scopeKey]);
|
|
34
|
+
return <DeskScopeContext.Provider value={value}>{children}</DeskScopeContext.Provider>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The ambient desk scope; `[]` outside any DeskScopeProvider. */
|
|
38
|
+
export function useDeskScope(): FrappeFilters {
|
|
39
|
+
return useContext(DeskScopeContext);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* AND the ambient scope into `filters`. Returns `undefined` when there is
|
|
44
|
+
* nothing to apply, so callers can spread-conditionally into leaf props.
|
|
45
|
+
*/
|
|
46
|
+
export function useScopedFilters(filters?: FrappeFilters): FrappeFilters | undefined {
|
|
47
|
+
const scope = useDeskScope();
|
|
48
|
+
return useMemo(() => {
|
|
49
|
+
if (scope.length === 0 && (!filters || filters.length === 0)) return undefined;
|
|
50
|
+
return [...scope, ...(filters ?? [])];
|
|
51
|
+
}, [scope, filters]);
|
|
52
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeskTarget — the resolved surface (spec 09 §2.2) — and useDeskTarget, the
|
|
3
|
+
* hook that composes the pure resolver with the runtime slug index.
|
|
4
|
+
*
|
|
5
|
+
* Precedence (spec §2.3): `path` beats the pins, the pins beat the navigator
|
|
6
|
+
* location, the navigator location beats home. A pin plus a live navigator
|
|
7
|
+
* is legal: the pin is the ENTRY, and once the location moves the navigator
|
|
8
|
+
* drives, still fenced by whatever scope the pin implied.
|
|
9
|
+
*
|
|
10
|
+
* Desk writes no fetch: the only read here is `useSlugTarget`, the exported
|
|
11
|
+
* once-per-session slug index (DK-2 holding at the one place it was most at
|
|
12
|
+
* risk).
|
|
13
|
+
*/
|
|
14
|
+
import { useMemo, useRef } from "react";
|
|
15
|
+
import { useBackofficeConfig, useBackofficeLocation } from "../config/BackofficeProvider";
|
|
16
|
+
import { parseBackofficeHref } from "../navigation/navigator";
|
|
17
|
+
import { slugToDisplayName, type BackofficeView } from "../navigation/paths";
|
|
18
|
+
import { useSlugTarget } from "../shared/useSlugTarget";
|
|
19
|
+
import { resolveDeskPath, type DeskAddress, type DeskViewParams } from "./resolveDeskPath";
|
|
20
|
+
|
|
21
|
+
export type DeskTarget =
|
|
22
|
+
| { kind: "home" }
|
|
23
|
+
| { kind: "workspace"; workspace: string }
|
|
24
|
+
| { kind: "list"; doctype: string; filtersParam?: string; sortParam?: string }
|
|
25
|
+
| {
|
|
26
|
+
kind: "view";
|
|
27
|
+
doctype: string;
|
|
28
|
+
view: Exclude<BackofficeView, "list">;
|
|
29
|
+
params?: DeskViewParams;
|
|
30
|
+
}
|
|
31
|
+
| { kind: "new"; doctype: string; defaults?: Record<string, unknown> }
|
|
32
|
+
| { kind: "form"; doctype: string; name: string }
|
|
33
|
+
| { kind: "report"; reportName: string; filtersParam?: string }
|
|
34
|
+
| { kind: "not-found"; slug?: string };
|
|
35
|
+
|
|
36
|
+
/** The surface pins a host sets on `<Desk />` (spec §2, "what it shows"). */
|
|
37
|
+
export interface DeskPins {
|
|
38
|
+
doctype?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
view?: BackofficeView;
|
|
41
|
+
workspace?: string;
|
|
42
|
+
report?: string;
|
|
43
|
+
path?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasDeskPin(pins: DeskPins): boolean {
|
|
47
|
+
return Boolean(pins.doctype || pins.workspace || pins.report || pins.path);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pins → target, directly: pins carry proper-cased names, no slug hop. */
|
|
51
|
+
function pinnedTarget(pins: DeskPins): DeskTarget | undefined {
|
|
52
|
+
if (pins.doctype) {
|
|
53
|
+
if (pins.name === "new") return { kind: "new", doctype: pins.doctype };
|
|
54
|
+
if (pins.name) return { kind: "form", doctype: pins.doctype, name: pins.name };
|
|
55
|
+
if (pins.view && pins.view !== "list") {
|
|
56
|
+
return { kind: "view", doctype: pins.doctype, view: pins.view };
|
|
57
|
+
}
|
|
58
|
+
return { kind: "list", doctype: pins.doctype };
|
|
59
|
+
}
|
|
60
|
+
if (pins.workspace) return { kind: "workspace", workspace: pins.workspace };
|
|
61
|
+
if (pins.report) return { kind: "report", reportName: pins.report };
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Address + resolved slug → target. Ambiguous slugs stay `undefined` while
|
|
66
|
+
* the index loads (the caller renders a spinner, exactly like SlugScreen). */
|
|
67
|
+
function addressToTarget(
|
|
68
|
+
address: DeskAddress,
|
|
69
|
+
slugKind: "workspace" | "doctype" | "loading" | "not-found",
|
|
70
|
+
slugName: string | undefined,
|
|
71
|
+
): DeskTarget | undefined {
|
|
72
|
+
switch (address.kind) {
|
|
73
|
+
case "home":
|
|
74
|
+
return { kind: "home" };
|
|
75
|
+
case "report":
|
|
76
|
+
return { kind: "report", reportName: address.reportName, filtersParam: address.filtersParam };
|
|
77
|
+
case "not-found":
|
|
78
|
+
return { kind: "not-found" };
|
|
79
|
+
default:
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
if (slugKind === "loading") return undefined;
|
|
83
|
+
if (slugKind === "not-found" || !slugName) return { kind: "not-found", slug: address.slug };
|
|
84
|
+
if (slugKind === "workspace") {
|
|
85
|
+
// Only the bare slug route can be a workspace page; /new, /view/x and
|
|
86
|
+
// /[name] under a workspace slug are dead addresses.
|
|
87
|
+
return address.kind === "slug"
|
|
88
|
+
? { kind: "workspace", workspace: slugName }
|
|
89
|
+
: { kind: "not-found", slug: address.slug };
|
|
90
|
+
}
|
|
91
|
+
switch (address.kind) {
|
|
92
|
+
case "slug":
|
|
93
|
+
return {
|
|
94
|
+
kind: "list",
|
|
95
|
+
doctype: slugName,
|
|
96
|
+
filtersParam: address.filtersParam,
|
|
97
|
+
sortParam: address.sortParam,
|
|
98
|
+
};
|
|
99
|
+
case "slug-view":
|
|
100
|
+
return { kind: "view", doctype: slugName, view: address.view, params: address.params };
|
|
101
|
+
case "slug-new":
|
|
102
|
+
return { kind: "new", doctype: slugName, defaults: address.defaults };
|
|
103
|
+
case "slug-name":
|
|
104
|
+
return { kind: "form", doctype: slugName, name: address.name };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Pure, best-effort address→target for pre-navigation interception
|
|
110
|
+
* (`onNavigate`): the slug degrades to a title-cased doctype guess, the same
|
|
111
|
+
* degrade `useSlugTarget` applies when the index is unavailable.
|
|
112
|
+
*/
|
|
113
|
+
export function guessDeskTarget(path: string, basePath: string): DeskTarget {
|
|
114
|
+
const { path: cleanPath, params } = parseBackofficeHref(path);
|
|
115
|
+
const address = resolveDeskPath(cleanPath, basePath, params);
|
|
116
|
+
const guess = "slug" in address ? slugToDisplayName(address.slug) : undefined;
|
|
117
|
+
return addressToTarget(address, guess ? "doctype" : "not-found", guess) ?? { kind: "not-found" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The desk's current target. `undefined` while an ambiguous slug is still
|
|
122
|
+
* resolving against the once-per-session index. Must be called under
|
|
123
|
+
* BackofficeProvider (it reads the navigator location and the frappe
|
|
124
|
+
* connection from context).
|
|
125
|
+
*/
|
|
126
|
+
export function useDeskTarget(pins: DeskPins): DeskTarget | undefined {
|
|
127
|
+
const { basePath } = useBackofficeConfig();
|
|
128
|
+
const location = useBackofficeLocation();
|
|
129
|
+
|
|
130
|
+
// The pins (and the `path` prop) are the ENTRY: they win until the
|
|
131
|
+
// location moves, then the navigator drives (spec §2.3). Location
|
|
132
|
+
// snapshots have stable identity until they change, so reference
|
|
133
|
+
// equality against the mount-time snapshot is the "has it moved" test.
|
|
134
|
+
const initialLocation = useRef(location);
|
|
135
|
+
const entryActive = location === initialLocation.current;
|
|
136
|
+
|
|
137
|
+
const address = useMemo<DeskAddress | undefined>(() => {
|
|
138
|
+
if (entryActive && pins.path) {
|
|
139
|
+
const prefix = basePath === "/" ? "" : basePath.replace(/\/+$/, "");
|
|
140
|
+
const { path, params } = parseBackofficeHref(`${prefix}${pins.path}`);
|
|
141
|
+
return resolveDeskPath(path, basePath, params);
|
|
142
|
+
}
|
|
143
|
+
if (entryActive && hasDeskPin(pins)) return undefined; // pinned target, no address
|
|
144
|
+
return resolveDeskPath(location.path, basePath, location.params);
|
|
145
|
+
}, [entryActive, pins, basePath, location]);
|
|
146
|
+
|
|
147
|
+
const pinned = entryActive && !pins.path ? pinnedTarget(pins) : undefined;
|
|
148
|
+
|
|
149
|
+
const slug = !pinned && address && "slug" in address ? address.slug : undefined;
|
|
150
|
+
const slugTarget = useSlugTarget(slug);
|
|
151
|
+
|
|
152
|
+
return useMemo(() => {
|
|
153
|
+
if (pinned) return pinned;
|
|
154
|
+
if (!address) return undefined;
|
|
155
|
+
const slugKind =
|
|
156
|
+
slugTarget.status === "loading"
|
|
157
|
+
? "loading"
|
|
158
|
+
: slugTarget.status === "not-found"
|
|
159
|
+
? "not-found"
|
|
160
|
+
: slugTarget.kind;
|
|
161
|
+
const slugName =
|
|
162
|
+
slugTarget.status === "resolved"
|
|
163
|
+
? slugTarget.kind === "workspace"
|
|
164
|
+
? slugTarget.workspace
|
|
165
|
+
: slugTarget.doctype
|
|
166
|
+
: undefined;
|
|
167
|
+
return addressToTarget(address, slugKind, slugName);
|
|
168
|
+
}, [pinned, address, slugTarget]);
|
|
169
|
+
}
|