@agent-native/dispatch 0.29.1 → 0.29.3
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/actions/list-workspace-apps.d.ts +1 -0
- package/dist/actions/list-workspace-apps.js +3 -0
- package/dist/actions/list-workspace-apps.js.map +1 -1
- package/dist/components/layout/Layout.js +1 -1
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/workspace-app-host.d.ts +4 -2
- package/dist/components/workspace-app-host.d.ts.map +1 -1
- package/dist/components/workspace-app-host.js +54 -10
- package/dist/components/workspace-app-host.js.map +1 -1
- package/dist/lib/workspace-apps.d.ts +2 -0
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js +23 -0
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/lib/workspace-apps.test.js +60 -2
- package/dist/lib/workspace-apps.test.js.map +1 -1
- package/dist/routes/pages/$appId.d.ts.map +1 -1
- package/dist/routes/pages/$appId.js +15 -6
- package/dist/routes/pages/$appId.js.map +1 -1
- package/dist/routes/pages/apps.$appId.d.ts +1 -0
- package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
- package/dist/routes/pages/apps.$appId.js +5 -1
- package/dist/routes/pages/apps.$appId.js.map +1 -1
- package/package.json +2 -2
- package/src/actions/list-workspace-apps.ts +3 -0
- package/src/components/layout/Layout.tsx +1 -1
- package/src/components/workspace-app-host.spec.tsx +222 -38
- package/src/components/workspace-app-host.tsx +89 -7
- package/src/lib/workspace-apps.test.ts +70 -1
- package/src/lib/workspace-apps.ts +24 -0
- package/src/routes/pages/$appId.spec.tsx +115 -0
- package/src/routes/pages/$appId.tsx +28 -5
- package/src/routes/pages/apps.$appId.spec.tsx +66 -0
- package/src/routes/pages/apps.$appId.tsx +11 -1
|
@@ -31,6 +31,8 @@ import { Link } from "react-router";
|
|
|
31
31
|
import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery";
|
|
32
32
|
import {
|
|
33
33
|
mergeChatFirstWorkspaceApps,
|
|
34
|
+
navigateToWorkspaceApp,
|
|
35
|
+
shouldOpenWorkspaceAppInTopWindow,
|
|
34
36
|
workspaceAppDirectHref,
|
|
35
37
|
workspaceAppEmbedTarget,
|
|
36
38
|
workspaceAppHref,
|
|
@@ -244,6 +246,7 @@ export interface WorkspaceAppFrameApp {
|
|
|
244
246
|
|
|
245
247
|
interface WorkspaceAppFrameProps {
|
|
246
248
|
app: WorkspaceAppFrameApp;
|
|
249
|
+
navigateToTopWindow?: (href: string) => boolean | void;
|
|
247
250
|
/** Chat-first app tabs use their own route while standalone hosts use app metadata. */
|
|
248
251
|
embedPath?: string;
|
|
249
252
|
/** Chat-first app surfaces own the parent chat rail around the iframe. */
|
|
@@ -253,6 +256,7 @@ interface WorkspaceAppFrameProps {
|
|
|
253
256
|
|
|
254
257
|
export function WorkspaceAppFrame({
|
|
255
258
|
app,
|
|
259
|
+
navigateToTopWindow = navigateToWorkspaceApp,
|
|
256
260
|
embedPath,
|
|
257
261
|
chatSidebar = false,
|
|
258
262
|
copy = defaultChatFirstCopy,
|
|
@@ -269,6 +273,8 @@ export function WorkspaceAppFrame({
|
|
|
269
273
|
const [embedError, setEmbedError] = useState<Error | null>(null);
|
|
270
274
|
const [isDirectFallback, setIsDirectFallback] = useState(false);
|
|
271
275
|
const [embedAttempt, setEmbedAttempt] = useState(0);
|
|
276
|
+
const [topWindowNavigationFailed, setTopWindowNavigationFailed] =
|
|
277
|
+
useState(false);
|
|
272
278
|
const embedFrameRef = useRef<HTMLIFrameElement>(null);
|
|
273
279
|
const postThemeToFrame = useCallback(() => {
|
|
274
280
|
embedFrameRef.current?.contentWindow?.postMessage(
|
|
@@ -299,6 +305,21 @@ export function WorkspaceAppFrame({
|
|
|
299
305
|
path: app.path ?? "",
|
|
300
306
|
url: app.url,
|
|
301
307
|
});
|
|
308
|
+
const topWindowHref = useMemo(() => {
|
|
309
|
+
if (embedPath !== undefined) {
|
|
310
|
+
return workspaceAppDirectHref(
|
|
311
|
+
{ path: app.path, url: app.url },
|
|
312
|
+
embedPath,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const target = workspaceAppEmbedTarget({
|
|
317
|
+
path: app.path ?? "",
|
|
318
|
+
url: app.url,
|
|
319
|
+
});
|
|
320
|
+
return target.url ?? target.path ?? null;
|
|
321
|
+
}, [app.path, app.url, embedPath]);
|
|
322
|
+
const openInTopWindow = shouldOpenWorkspaceAppInTopWindow();
|
|
302
323
|
const embedInput = useMemo<EmbedSessionInput | null>(() => {
|
|
303
324
|
if (embedPath !== undefined) {
|
|
304
325
|
return buildChatFirstEmbedSessionInput(app.id, embedPath);
|
|
@@ -312,7 +333,26 @@ export function WorkspaceAppFrame({
|
|
|
312
333
|
}, [app.id, app.path, app.url, appHref, embedPath]);
|
|
313
334
|
|
|
314
335
|
useEffect(() => {
|
|
315
|
-
if (!
|
|
336
|
+
if (!openInTopWindow) {
|
|
337
|
+
setTopWindowNavigationFailed(false);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (!topWindowHref) {
|
|
341
|
+
setTopWindowNavigationFailed(true);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let didNavigate = false;
|
|
346
|
+
try {
|
|
347
|
+
didNavigate = navigateToTopWindow(topWindowHref) !== false;
|
|
348
|
+
} catch {
|
|
349
|
+
didNavigate = false;
|
|
350
|
+
}
|
|
351
|
+
setTopWindowNavigationFailed(!didNavigate);
|
|
352
|
+
}, [navigateToTopWindow, openInTopWindow, topWindowHref]);
|
|
353
|
+
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (!embedInput || (openInTopWindow && !topWindowNavigationFailed)) return;
|
|
316
356
|
let cancelled = false;
|
|
317
357
|
setEmbedUrl(null);
|
|
318
358
|
setEmbedError(null);
|
|
@@ -359,6 +399,8 @@ export function WorkspaceAppFrame({
|
|
|
359
399
|
embedInput,
|
|
360
400
|
embedPath,
|
|
361
401
|
embedAttempt,
|
|
402
|
+
openInTopWindow,
|
|
403
|
+
topWindowNavigationFailed,
|
|
362
404
|
workspaceSsoEnabled,
|
|
363
405
|
]);
|
|
364
406
|
|
|
@@ -424,25 +466,62 @@ export function WorkspaceAppFrame({
|
|
|
424
466
|
);
|
|
425
467
|
}
|
|
426
468
|
|
|
427
|
-
export function WorkspaceAppHost({
|
|
469
|
+
export function WorkspaceAppHost({
|
|
470
|
+
appId,
|
|
471
|
+
navigateToTopWindow = navigateToWorkspaceApp,
|
|
472
|
+
}: {
|
|
473
|
+
appId?: string;
|
|
474
|
+
navigateToTopWindow?: (href: string) => boolean | void;
|
|
475
|
+
}) {
|
|
428
476
|
const t = useT();
|
|
429
477
|
const workspaceAppsQuery = useActionQuery<WorkspaceAppSummary[]>(
|
|
430
478
|
"list-workspace-apps",
|
|
431
|
-
{ includeAgentCards: false },
|
|
479
|
+
{ includeAgentCards: false, includeArchived: true },
|
|
480
|
+
);
|
|
481
|
+
const workspaceApps = useMemo(
|
|
482
|
+
() => mergeChatFirstWorkspaceApps(workspaceAppsQuery.data),
|
|
483
|
+
[workspaceAppsQuery.data],
|
|
484
|
+
);
|
|
485
|
+
const visibleWorkspaceApps = useMemo(
|
|
486
|
+
() => workspaceApps.filter((item) => !item.archived),
|
|
487
|
+
[workspaceApps],
|
|
488
|
+
);
|
|
489
|
+
const workspaceAppIds = useMemo(
|
|
490
|
+
() => new Set(workspaceApps.map((item) => item.id.trim().toLowerCase())),
|
|
491
|
+
[workspaceApps],
|
|
492
|
+
);
|
|
493
|
+
const workspaceApp = useMemo(
|
|
494
|
+
() =>
|
|
495
|
+
visibleWorkspaceApps.find(
|
|
496
|
+
(item) => item.id.trim().toLowerCase() === appId?.trim().toLowerCase(),
|
|
497
|
+
) ?? null,
|
|
498
|
+
[appId, visibleWorkspaceApps],
|
|
432
499
|
);
|
|
433
500
|
const grantedAppsQuery = useActionQuery<GrantedWorkspaceAppsResult>(
|
|
434
501
|
"list_apps",
|
|
435
502
|
{},
|
|
503
|
+
{
|
|
504
|
+
// Mounted workspace apps are already fully described by the workspace
|
|
505
|
+
// registry. Defer the broader MCP grant/discovery scan until that
|
|
506
|
+
// lookup misses; it is only needed for externally granted apps.
|
|
507
|
+
enabled: !workspaceAppsQuery.isLoading && !workspaceApp,
|
|
508
|
+
},
|
|
436
509
|
);
|
|
437
510
|
const apps = useMemo(() => {
|
|
438
511
|
const merged = new Map<string, WorkspaceAppSummary>();
|
|
439
512
|
|
|
440
|
-
for (const app of
|
|
513
|
+
for (const app of visibleWorkspaceApps) {
|
|
441
514
|
merged.set(app.id.trim().toLowerCase(), app);
|
|
442
515
|
}
|
|
443
516
|
for (const app of grantedAppsQuery.data?.apps ?? []) {
|
|
444
517
|
const id = app.id.trim();
|
|
445
|
-
if (
|
|
518
|
+
if (
|
|
519
|
+
!id ||
|
|
520
|
+
workspaceAppIds.has(id.toLowerCase()) ||
|
|
521
|
+
merged.has(id.toLowerCase())
|
|
522
|
+
) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
446
525
|
merged.set(id.toLowerCase(), {
|
|
447
526
|
id,
|
|
448
527
|
name: app.name.trim() || id,
|
|
@@ -453,7 +532,7 @@ export function WorkspaceAppHost({ appId }: { appId?: string }) {
|
|
|
453
532
|
}
|
|
454
533
|
|
|
455
534
|
return [...merged.values()];
|
|
456
|
-
}, [grantedAppsQuery.data?.apps,
|
|
535
|
+
}, [grantedAppsQuery.data?.apps, visibleWorkspaceApps, workspaceAppIds]);
|
|
457
536
|
const app = useMemo(
|
|
458
537
|
() =>
|
|
459
538
|
apps.find(
|
|
@@ -575,7 +654,10 @@ export function WorkspaceAppHost({ appId }: { appId?: string }) {
|
|
|
575
654
|
className="flex h-full min-h-0 flex-col bg-background"
|
|
576
655
|
>
|
|
577
656
|
<div className="min-h-0 flex-1 bg-muted/20">
|
|
578
|
-
<WorkspaceAppFrame
|
|
657
|
+
<WorkspaceAppFrame
|
|
658
|
+
app={app}
|
|
659
|
+
navigateToTopWindow={navigateToTopWindow}
|
|
660
|
+
/>
|
|
579
661
|
</div>
|
|
580
662
|
</div>
|
|
581
663
|
);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
3
|
|
|
3
4
|
import {
|
|
5
|
+
navigateToWorkspaceApp,
|
|
4
6
|
workspaceAppDirectHref,
|
|
5
7
|
workspaceAppEmbedTarget,
|
|
6
8
|
} from "./workspace-apps";
|
|
@@ -62,3 +64,70 @@ describe("workspaceAppDirectHref", () => {
|
|
|
62
64
|
);
|
|
63
65
|
});
|
|
64
66
|
});
|
|
67
|
+
|
|
68
|
+
describe("navigateToWorkspaceApp", () => {
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
Object.defineProperty(window, "parent", {
|
|
71
|
+
configurable: true,
|
|
72
|
+
value: window,
|
|
73
|
+
});
|
|
74
|
+
Object.defineProperty(window, "top", {
|
|
75
|
+
configurable: true,
|
|
76
|
+
value: window,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("uses the top window when Dispatch is embedded", () => {
|
|
81
|
+
const topWindow = { location: { href: "" } } as unknown as Window;
|
|
82
|
+
Object.defineProperty(window, "parent", {
|
|
83
|
+
configurable: true,
|
|
84
|
+
value: {},
|
|
85
|
+
});
|
|
86
|
+
Object.defineProperty(window, "top", {
|
|
87
|
+
configurable: true,
|
|
88
|
+
value: topWindow,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
navigateToWorkspaceApp("/mail");
|
|
92
|
+
|
|
93
|
+
expect(topWindow.location.href).toBe(
|
|
94
|
+
new URL("/mail", window.location.href).href,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reports when the embedded top window rejects navigation", () => {
|
|
99
|
+
const location = {} as Location;
|
|
100
|
+
Object.defineProperty(location, "href", {
|
|
101
|
+
configurable: true,
|
|
102
|
+
set: () => {
|
|
103
|
+
throw new Error("Top navigation is blocked");
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const topWindow = { location } as unknown as Window;
|
|
107
|
+
Object.defineProperty(window, "parent", {
|
|
108
|
+
configurable: true,
|
|
109
|
+
value: {},
|
|
110
|
+
});
|
|
111
|
+
Object.defineProperty(window, "top", {
|
|
112
|
+
configurable: true,
|
|
113
|
+
value: topWindow,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(navigateToWorkspaceApp("/mail")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rejects non-http navigation targets", () => {
|
|
120
|
+
const topWindow = { location: { href: "" } } as unknown as Window;
|
|
121
|
+
Object.defineProperty(window, "parent", {
|
|
122
|
+
configurable: true,
|
|
123
|
+
value: {},
|
|
124
|
+
});
|
|
125
|
+
Object.defineProperty(window, "top", {
|
|
126
|
+
configurable: true,
|
|
127
|
+
value: topWindow,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(navigateToWorkspaceApp("javascript:alert(1)")).toBe(false);
|
|
131
|
+
expect(topWindow.location.href).toBe("");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CHAT_FIRST_DEFAULT_APP_IDS } from "@agent-native/core/client/chat-first";
|
|
2
|
+
import { isInBuilderFrame } from "@agent-native/core/client/host";
|
|
2
3
|
import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
|
|
3
4
|
|
|
4
5
|
import { CANONICAL_WORKSPACE_SSO_APP_ORIGINS } from "../shared/workspace-sso";
|
|
@@ -158,6 +159,29 @@ export function isPendingBuilderHref(app: WorkspaceAppSummary): boolean {
|
|
|
158
159
|
return app.status === "pending" && !!app.builderUrl;
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
export function shouldOpenWorkspaceAppInTopWindow(): boolean {
|
|
163
|
+
if (typeof window === "undefined") return false;
|
|
164
|
+
return isInBuilderFrame() || window.parent !== window;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function navigateToWorkspaceApp(href: string): boolean {
|
|
168
|
+
if (typeof window === "undefined") return false;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const targetUrl = new URL(href, window.location.href);
|
|
172
|
+
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
const targetWindow =
|
|
176
|
+
shouldOpenWorkspaceAppInTopWindow() && window.top ? window.top : window;
|
|
177
|
+
targetWindow.location.href = targetUrl.href;
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
// coercion-ok: a blocked top-window assignment is an expected fallback signal.
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
161
185
|
/**
|
|
162
186
|
* Keep the chat-first rail useful before a workspace manifest is populated.
|
|
163
187
|
* Mounted workspace rows still win, so custom names and routes remain the
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const routeState = vi.hoisted(() => ({
|
|
7
|
+
navigateToWorkspaceApp: vi.fn(() => false),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@agent-native/core/client/api-path", () => ({
|
|
11
|
+
appPath: (path: string) => path,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@agent-native/core/client/hooks", () => ({
|
|
15
|
+
useActionQuery: () => ({
|
|
16
|
+
data: [
|
|
17
|
+
{
|
|
18
|
+
id: "mail",
|
|
19
|
+
name: "Mail",
|
|
20
|
+
path: "/mail",
|
|
21
|
+
url: null,
|
|
22
|
+
status: "ready",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
isError: false,
|
|
26
|
+
isLoading: false,
|
|
27
|
+
refetch: vi.fn(),
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("@agent-native/core/client/i18n", () => ({
|
|
32
|
+
useT: () => (key: string) => key,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("@agent-native/core/shared/builder-link-tracking", () => ({
|
|
36
|
+
withBuilderUtmTrackingParams: (href: string) => href,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock("react-router", () => ({
|
|
40
|
+
Link: ({ children }: { children: React.ReactNode }) => <a>{children}</a>,
|
|
41
|
+
Navigate: () => null,
|
|
42
|
+
redirect: (target: string) => target,
|
|
43
|
+
useParams: () => ({ appId: "mail" }),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock("../../components/action-query-error", () => ({
|
|
47
|
+
ActionQueryError: ({ onRetry }: { onRetry: () => unknown }) => (
|
|
48
|
+
<button data-navigation-retry onClick={onRetry}>
|
|
49
|
+
retry
|
|
50
|
+
</button>
|
|
51
|
+
),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock("../../components/dispatch-shell", () => ({
|
|
55
|
+
DispatchShell: ({ children }: { children: React.ReactNode }) => (
|
|
56
|
+
<div data-dispatch-shell>{children}</div>
|
|
57
|
+
),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock("../../lib/catch-all-target", () => ({
|
|
61
|
+
resolveServerCatchAllTarget: vi.fn(),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
vi.mock("../../lib/workspace-apps", () => ({
|
|
65
|
+
navigateToWorkspaceApp: routeState.navigateToWorkspaceApp,
|
|
66
|
+
workspaceAppHref: (app: { path?: string | null }) => app.path ?? null,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
vi.mock("../../components/ui/badge", () => ({ Badge: () => null }));
|
|
70
|
+
vi.mock("../../components/ui/button", () => ({
|
|
71
|
+
Button: ({ children }: { children: React.ReactNode }) => (
|
|
72
|
+
<button>{children}</button>
|
|
73
|
+
),
|
|
74
|
+
}));
|
|
75
|
+
vi.mock("../../components/ui/spinner", () => ({ Spinner: () => null }));
|
|
76
|
+
|
|
77
|
+
import WorkspaceAppCatchAllRoute from "./$appId";
|
|
78
|
+
|
|
79
|
+
describe("WorkspaceAppCatchAllRoute", () => {
|
|
80
|
+
let container: HTMLDivElement;
|
|
81
|
+
let root: Root;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
85
|
+
routeState.navigateToWorkspaceApp.mockClear();
|
|
86
|
+
container = document.createElement("div");
|
|
87
|
+
document.body.appendChild(container);
|
|
88
|
+
root = createRoot(container);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
act(() => root.unmount());
|
|
93
|
+
container.remove();
|
|
94
|
+
vi.unstubAllGlobals();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("shows a retryable error when navigation cannot reach the target", async () => {
|
|
98
|
+
await act(async () => {
|
|
99
|
+
root.render(<WorkspaceAppCatchAllRoute />);
|
|
100
|
+
await Promise.resolve();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(routeState.navigateToWorkspaceApp).toHaveBeenCalledWith("/mail");
|
|
104
|
+
expect(container.querySelector("[data-navigation-retry]")).not.toBeNull();
|
|
105
|
+
|
|
106
|
+
await act(async () => {
|
|
107
|
+
container
|
|
108
|
+
.querySelector<HTMLButtonElement>("[data-navigation-retry]")
|
|
109
|
+
?.click();
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(routeState.navigateToWorkspaceApp).toHaveBeenCalledTimes(2);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
IconArrowUpRight,
|
|
8
8
|
IconClockHour4,
|
|
9
9
|
} from "@tabler/icons-react";
|
|
10
|
-
import { useEffect, useMemo } from "react";
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
11
|
import {
|
|
12
12
|
Link,
|
|
13
13
|
Navigate,
|
|
@@ -24,6 +24,7 @@ import { Button } from "../../components/ui/button";
|
|
|
24
24
|
import { Spinner } from "../../components/ui/spinner";
|
|
25
25
|
import { resolveServerCatchAllTarget } from "../../lib/catch-all-target";
|
|
26
26
|
import {
|
|
27
|
+
navigateToWorkspaceApp,
|
|
27
28
|
workspaceAppHref,
|
|
28
29
|
type WorkspaceAppSummary,
|
|
29
30
|
} from "../../lib/workspace-apps";
|
|
@@ -111,12 +112,18 @@ export default function WorkspaceAppCatchAllRoute() {
|
|
|
111
112
|
);
|
|
112
113
|
const href = app ? workspaceAppHref(app) : null;
|
|
113
114
|
const isSelfReference = appId === "dispatch";
|
|
115
|
+
const hasApp = app !== null;
|
|
116
|
+
const appIsPending = app?.status === "pending";
|
|
117
|
+
const [navigationFailed, setNavigationFailed] = useState(false);
|
|
114
118
|
|
|
115
119
|
useEffect(() => {
|
|
116
120
|
if (isSelfReference) return;
|
|
117
|
-
if (!
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
if (!hasApp || appIsPending || !href) {
|
|
122
|
+
setNavigationFailed(false);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
setNavigationFailed(!navigateToWorkspaceApp(href));
|
|
126
|
+
}, [appIsPending, hasApp, href, isSelfReference]);
|
|
120
127
|
|
|
121
128
|
if (isSelfReference) {
|
|
122
129
|
return <Navigate to={appPath("/overview")} replace />;
|
|
@@ -136,7 +143,23 @@ export default function WorkspaceAppCatchAllRoute() {
|
|
|
136
143
|
);
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
if (
|
|
146
|
+
if (navigationFailed && href) {
|
|
147
|
+
return (
|
|
148
|
+
<DispatchShell
|
|
149
|
+
title={t("dispatch.pages.dataLoadFailed")}
|
|
150
|
+
description={t("dispatch.pages.pageNotFoundDescription")}
|
|
151
|
+
>
|
|
152
|
+
<ActionQueryError
|
|
153
|
+
onRetry={() => setNavigationFailed(!navigateToWorkspaceApp(href))}
|
|
154
|
+
/>
|
|
155
|
+
</DispatchShell>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
(isLoading && !app) ||
|
|
161
|
+
(app && app.status !== "pending" && href && !navigationFailed)
|
|
162
|
+
) {
|
|
140
163
|
return (
|
|
141
164
|
<div className="flex h-screen w-full items-center justify-center">
|
|
142
165
|
<Spinner className="size-8" />
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const routeState = vi.hoisted(() => ({
|
|
7
|
+
appId: "mail",
|
|
8
|
+
hostProps: null as {
|
|
9
|
+
appId?: string;
|
|
10
|
+
navigateToTopWindow?: (href: string) => boolean | void;
|
|
11
|
+
} | null,
|
|
12
|
+
navigateToWorkspaceApp: vi.fn(() => true),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("react-router", () => ({
|
|
16
|
+
useParams: () => ({ appId: routeState.appId }),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("../../components/workspace-app-host", () => ({
|
|
20
|
+
WorkspaceAppHost: (props: typeof routeState.hostProps) => {
|
|
21
|
+
routeState.hostProps = props;
|
|
22
|
+
return <div data-workspace-app-id={props?.appId} />;
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("../../lib/workspace-apps", () => ({
|
|
27
|
+
navigateToWorkspaceApp: routeState.navigateToWorkspaceApp,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import WorkspaceAppRoute from "./apps.$appId";
|
|
31
|
+
|
|
32
|
+
describe("WorkspaceAppRoute", () => {
|
|
33
|
+
let container: HTMLDivElement;
|
|
34
|
+
let root: Root;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
38
|
+
routeState.appId = "mail";
|
|
39
|
+
routeState.hostProps = null;
|
|
40
|
+
routeState.navigateToWorkspaceApp.mockClear();
|
|
41
|
+
container = document.createElement("div");
|
|
42
|
+
document.body.appendChild(container);
|
|
43
|
+
root = createRoot(container);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
act(() => root.unmount());
|
|
48
|
+
container.remove();
|
|
49
|
+
vi.unstubAllGlobals();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("passes the route app id and top-window helper to the workspace host", async () => {
|
|
53
|
+
await act(async () => {
|
|
54
|
+
root.render(<WorkspaceAppRoute />);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(routeState.hostProps?.appId).toBe("mail");
|
|
58
|
+
expect(routeState.hostProps?.navigateToTopWindow).toEqual(
|
|
59
|
+
expect.any(Function),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
routeState.hostProps?.navigateToTopWindow?.("/mail");
|
|
63
|
+
|
|
64
|
+
expect(routeState.navigateToWorkspaceApp).toHaveBeenCalledWith("/mail");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useParams } from "react-router";
|
|
2
2
|
|
|
3
3
|
import { WorkspaceAppHost } from "../../components/workspace-app-host";
|
|
4
|
+
import { navigateToWorkspaceApp } from "../../lib/workspace-apps";
|
|
5
|
+
|
|
6
|
+
export function navigateWorkspaceAppFromRoute(href: string): boolean {
|
|
7
|
+
return navigateToWorkspaceApp(href);
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
export function meta() {
|
|
6
11
|
return [{ title: "Workspace app - Dispatch" }];
|
|
@@ -8,5 +13,10 @@ export function meta() {
|
|
|
8
13
|
|
|
9
14
|
export default function WorkspaceAppRoute() {
|
|
10
15
|
const { appId } = useParams();
|
|
11
|
-
return
|
|
16
|
+
return (
|
|
17
|
+
<WorkspaceAppHost
|
|
18
|
+
appId={appId}
|
|
19
|
+
navigateToTopWindow={navigateWorkspaceAppFromRoute}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
12
22
|
}
|