@agent-native/dispatch 0.29.0 → 0.29.2
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/provider-api-register.d.ts +12 -12
- package/dist/components/workspace-app-card.js +2 -2
- package/dist/components/workspace-app-card.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 +37 -5
- package/dist/components/workspace-app-host.js.map +1 -1
- package/dist/components/workspace-app-search.js +1 -1
- package/dist/components/workspace-app-search.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/components/dispatch-control-plane.spec.tsx +3 -0
- package/src/components/workspace-app-card.spec.tsx +2 -1
- package/src/components/workspace-app-card.tsx +2 -2
- package/src/components/workspace-app-host.spec.tsx +118 -0
- package/src/components/workspace-app-host.tsx +54 -3
- package/src/components/workspace-app-search.tsx +1 -1
- 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
|
@@ -25,6 +25,7 @@ const clientState = vi.hoisted(() => {
|
|
|
25
25
|
legacyMutateAsync,
|
|
26
26
|
legacyMutateError: null as Error | null,
|
|
27
27
|
legacyErrorMutateAsync,
|
|
28
|
+
inBuilderFrame: false,
|
|
28
29
|
frameLoadHandler: null as (() => void) | null,
|
|
29
30
|
suppressFrameLoad: false,
|
|
30
31
|
theme: "dark" as "dark" | "light",
|
|
@@ -81,6 +82,10 @@ vi.mock("@agent-native/core/client/feature-flags", () => ({
|
|
|
81
82
|
useFeatureFlag: () => clientState.workspaceSsoEnabled,
|
|
82
83
|
}));
|
|
83
84
|
|
|
85
|
+
vi.mock("@agent-native/core/client/host", () => ({
|
|
86
|
+
isInBuilderFrame: () => clientState.inBuilderFrame,
|
|
87
|
+
}));
|
|
88
|
+
|
|
84
89
|
vi.mock("@agent-native/core/client/hooks", () => ({
|
|
85
90
|
useActionMutation: (name: string) => {
|
|
86
91
|
clientState.actionNames.push(name);
|
|
@@ -156,6 +161,7 @@ describe("WorkspaceAppKeepAlive", () => {
|
|
|
156
161
|
clientState.legacyMutateAsync.mockClear();
|
|
157
162
|
clientState.legacyMutateError = null;
|
|
158
163
|
clientState.legacyErrorMutateAsync.mockReset();
|
|
164
|
+
clientState.inBuilderFrame = false;
|
|
159
165
|
clientState.frameLoadHandler = null;
|
|
160
166
|
clientState.suppressFrameLoad = false;
|
|
161
167
|
clientState.workspaceSsoMutateAsync.mockClear();
|
|
@@ -166,6 +172,14 @@ describe("WorkspaceAppKeepAlive", () => {
|
|
|
166
172
|
afterEach(() => {
|
|
167
173
|
act(() => root.unmount());
|
|
168
174
|
container.remove();
|
|
175
|
+
Object.defineProperty(window, "parent", {
|
|
176
|
+
configurable: true,
|
|
177
|
+
value: window,
|
|
178
|
+
});
|
|
179
|
+
Object.defineProperty(window, "top", {
|
|
180
|
+
configurable: true,
|
|
181
|
+
value: window,
|
|
182
|
+
});
|
|
169
183
|
vi.unstubAllGlobals();
|
|
170
184
|
});
|
|
171
185
|
|
|
@@ -354,6 +368,110 @@ describe("WorkspaceAppKeepAlive", () => {
|
|
|
354
368
|
).toBe("Workspace SSO is temporarily unavailable");
|
|
355
369
|
});
|
|
356
370
|
|
|
371
|
+
it("opens the app in the top window when Dispatch is inside an iframe", async () => {
|
|
372
|
+
const topWindow = { location: { href: "" } } as unknown as Window;
|
|
373
|
+
const expectedUrl = new URL("/mail", window.location.href).href;
|
|
374
|
+
Object.defineProperty(window, "parent", {
|
|
375
|
+
configurable: true,
|
|
376
|
+
value: {},
|
|
377
|
+
});
|
|
378
|
+
Object.defineProperty(window, "top", {
|
|
379
|
+
configurable: true,
|
|
380
|
+
value: topWindow,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await act(async () => {
|
|
384
|
+
root.render(
|
|
385
|
+
<WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
|
|
386
|
+
);
|
|
387
|
+
await Promise.resolve();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(topWindow.location.href).toBe(expectedUrl);
|
|
391
|
+
expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
|
|
392
|
+
expect(container.querySelector("iframe")).toBeNull();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("falls back to the embedded app when top-window navigation is blocked", async () => {
|
|
396
|
+
const navigateToTopWindow = vi.fn(() => false);
|
|
397
|
+
Object.defineProperty(window, "parent", {
|
|
398
|
+
configurable: true,
|
|
399
|
+
value: {},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
await act(async () => {
|
|
403
|
+
root.render(
|
|
404
|
+
<WorkspaceAppFrame
|
|
405
|
+
app={{ id: "mail", name: "Mail", path: "/mail" }}
|
|
406
|
+
navigateToTopWindow={navigateToTopWindow}
|
|
407
|
+
/>,
|
|
408
|
+
);
|
|
409
|
+
await Promise.resolve();
|
|
410
|
+
await Promise.resolve();
|
|
411
|
+
await Promise.resolve();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(navigateToTopWindow).toHaveBeenCalledWith("/mail");
|
|
415
|
+
expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
|
|
416
|
+
app: "mail",
|
|
417
|
+
path: "/mail",
|
|
418
|
+
chrome: "minimal",
|
|
419
|
+
});
|
|
420
|
+
expect(container.querySelector("iframe")).not.toBeNull();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("preserves a chat-first deep route in the top window when embedded", async () => {
|
|
424
|
+
const topWindow = { location: { href: "" } } as unknown as Window;
|
|
425
|
+
const embedPath = "/emails?status=failed#latest";
|
|
426
|
+
const expectedUrl = new URL(
|
|
427
|
+
"/mail/emails?status=failed#latest",
|
|
428
|
+
window.location.href,
|
|
429
|
+
).href;
|
|
430
|
+
Object.defineProperty(window, "parent", {
|
|
431
|
+
configurable: true,
|
|
432
|
+
value: {},
|
|
433
|
+
});
|
|
434
|
+
Object.defineProperty(window, "top", {
|
|
435
|
+
configurable: true,
|
|
436
|
+
value: topWindow,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await act(async () => {
|
|
440
|
+
root.render(
|
|
441
|
+
<WorkspaceAppFrame
|
|
442
|
+
app={{ id: "mail", name: "Mail", path: "/mail" }}
|
|
443
|
+
embedPath={embedPath}
|
|
444
|
+
/>,
|
|
445
|
+
);
|
|
446
|
+
await Promise.resolve();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(topWindow.location.href).toBe(expectedUrl);
|
|
450
|
+
expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
|
|
451
|
+
expect(container.querySelector("iframe")).toBeNull();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("opens the app in the top window for a Builder webview", async () => {
|
|
455
|
+
const topWindow = { location: { href: "" } } as unknown as Window;
|
|
456
|
+
const expectedUrl = new URL("/mail", window.location.href).href;
|
|
457
|
+
clientState.inBuilderFrame = true;
|
|
458
|
+
Object.defineProperty(window, "top", {
|
|
459
|
+
configurable: true,
|
|
460
|
+
value: topWindow,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
await act(async () => {
|
|
464
|
+
root.render(
|
|
465
|
+
<WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
|
|
466
|
+
);
|
|
467
|
+
await Promise.resolve();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
expect(topWindow.location.href).toBe(expectedUrl);
|
|
471
|
+
expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
|
|
472
|
+
expect(container.querySelector("iframe")).toBeNull();
|
|
473
|
+
});
|
|
474
|
+
|
|
357
475
|
it("evicts the oldest inactive app after reaching the keep-alive limit", async () => {
|
|
358
476
|
await act(async () => {
|
|
359
477
|
root.render(<WorkspaceAppKeepAlive activeAppId="mail" />);
|
|
@@ -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,7 +466,13 @@ 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",
|
|
@@ -575,7 +623,10 @@ export function WorkspaceAppHost({ appId }: { appId?: string }) {
|
|
|
575
623
|
className="flex h-full min-h-0 flex-col bg-background"
|
|
576
624
|
>
|
|
577
625
|
<div className="min-h-0 flex-1 bg-muted/20">
|
|
578
|
-
<WorkspaceAppFrame
|
|
626
|
+
<WorkspaceAppFrame
|
|
627
|
+
app={app}
|
|
628
|
+
navigateToTopWindow={navigateToTopWindow}
|
|
629
|
+
/>
|
|
579
630
|
</div>
|
|
580
631
|
</div>
|
|
581
632
|
);
|
|
@@ -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
|
}
|