@agent-native/dispatch 0.31.3 → 0.31.5

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.
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
3
  import React, { act } from "react";
3
4
  import { createRoot, type Root } from "react-dom/client";
4
5
  import { MemoryRouter } from "react-router";
@@ -7,13 +8,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
8
  import { TooltipProvider } from "./ui/tooltip";
8
9
  import { WorkspaceAppCard } from "./workspace-app-card";
9
10
 
10
- vi.mock("@agent-native/core/client/sharing", () => ({
11
- ShareButton: ({ resourceTitle }: { resourceTitle?: string }) =>
12
- React.createElement(
13
- "button",
14
- { type: "button", "aria-label": `Share ${resourceTitle ?? "app"}` },
15
- "Share",
16
- ),
11
+ vi.mock("../../../core/dist/client/use-action.js", () => ({
12
+ useActionMutation: () => ({
13
+ mutate: vi.fn(),
14
+ isPending: false,
15
+ }),
16
+ useActionQuery: () => ({
17
+ data: {
18
+ ownerEmail: "owner@example.com",
19
+ orgId: null,
20
+ visibility: "private",
21
+ role: "owner",
22
+ shares: [],
23
+ },
24
+ isLoading: false,
25
+ isError: false,
26
+ error: null,
27
+ refetch: vi.fn(async () => undefined),
28
+ }),
29
+ }));
30
+
31
+ vi.mock("../../../core/dist/client/i18n.js", () => ({
32
+ useT: () => (key: string, values?: Record<string, unknown>) =>
33
+ String(values?.defaultValue ?? key),
17
34
  }));
18
35
 
19
36
  vi.mock("@agent-native/core/client/hooks", () => ({
@@ -44,6 +61,7 @@ vi.mock("@agent-native/core/client/host", () => ({
44
61
  describe("WorkspaceAppCard", () => {
45
62
  let container: HTMLDivElement;
46
63
  let root: Root;
64
+ let queryClient: QueryClient;
47
65
 
48
66
  beforeEach(() => {
49
67
  frameState.inBuilderFrame = false;
@@ -51,10 +69,17 @@ describe("WorkspaceAppCard", () => {
51
69
  container = document.createElement("div");
52
70
  document.body.appendChild(container);
53
71
  root = createRoot(container);
72
+ queryClient = new QueryClient({
73
+ defaultOptions: {
74
+ queries: { retry: false },
75
+ mutations: { retry: false },
76
+ },
77
+ });
54
78
  });
55
79
 
56
80
  afterEach(() => {
57
81
  act(() => root.unmount());
82
+ queryClient.clear();
58
83
  container.remove();
59
84
  vi.unstubAllGlobals();
60
85
  });
@@ -193,6 +218,77 @@ describe("WorkspaceAppCard", () => {
193
218
  }
194
219
  });
195
220
 
221
+ it("opens Share from the app settings menu instead of the card actions", async () => {
222
+ const animationFrameCallbacks: FrameRequestCallback[] = [];
223
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
224
+ animationFrameCallbacks.push(callback);
225
+ return animationFrameCallbacks.length;
226
+ });
227
+
228
+ await act(async () => {
229
+ root.render(
230
+ <QueryClientProvider client={queryClient}>
231
+ <MemoryRouter>
232
+ <TooltipProvider>
233
+ <WorkspaceAppCard
234
+ app={{
235
+ id: "analytics",
236
+ name: "Analytics",
237
+ path: "/analytics",
238
+ status: "ready",
239
+ }}
240
+ />
241
+ </TooltipProvider>
242
+ </MemoryRouter>
243
+ </QueryClientProvider>,
244
+ );
245
+ });
246
+
247
+ expect(container.querySelector('button[aria-label="Share"]')).toBeNull();
248
+
249
+ const settingsButton = container.querySelector<HTMLButtonElement>(
250
+ 'button[aria-label="Settings for Analytics"]',
251
+ );
252
+ expect(settingsButton).not.toBeNull();
253
+
254
+ await act(async () => {
255
+ settingsButton?.dispatchEvent(
256
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
257
+ );
258
+ });
259
+
260
+ const shareItem = Array.from(
261
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
262
+ ).find((item) => item.textContent?.trim() === "Share");
263
+ expect(shareItem).not.toBeUndefined();
264
+
265
+ await act(async () => shareItem?.click());
266
+
267
+ expect(container.querySelector('button[aria-label="Share"]')).toBeNull();
268
+ expect(document.querySelector('[role="menu"]')).toBeNull();
269
+ expect(animationFrameCallbacks).toHaveLength(1);
270
+
271
+ await act(async () => animationFrameCallbacks.shift()?.(0));
272
+
273
+ expect(
274
+ container.querySelector('button[aria-label="Share"]'),
275
+ ).not.toBeNull();
276
+ expect(
277
+ document.body.querySelector("[data-agent-native-share-overlay]"),
278
+ ).not.toBeNull();
279
+
280
+ const shareButton = container.querySelector<HTMLButtonElement>(
281
+ 'button[aria-label="Share"]',
282
+ );
283
+ expect(shareButton).not.toBeNull();
284
+
285
+ await act(async () => shareButton?.click());
286
+ expect(animationFrameCallbacks).toHaveLength(1);
287
+
288
+ await act(async () => animationFrameCallbacks.shift()?.(0));
289
+ expect(document.activeElement).toBe(settingsButton);
290
+ });
291
+
196
292
  it("keeps pinning in the app open menu", async () => {
197
293
  const onTogglePinned = vi.fn();
198
294
  await act(async () => {
@@ -270,7 +366,7 @@ describe("WorkspaceAppCard", () => {
270
366
  expect(settingsMenu?.className).toContain("w-48");
271
367
  expect(settingsMenu?.className).toContain("bg-popover");
272
368
  expect(settingsMenu?.className).toContain("shadow-md");
273
- expect(document.querySelectorAll('[role="menuitem"] svg').length).toBe(4);
369
+ expect(document.querySelectorAll('[role="menuitem"] svg').length).toBe(5);
274
370
 
275
371
  await act(async () => resourcesItem?.click());
276
372
 
@@ -15,13 +15,14 @@ import {
15
15
  IconFileText,
16
16
  IconKey,
17
17
  IconPin,
18
+ IconShare3,
18
19
  IconSettings,
19
20
  IconTrash,
20
21
  IconUser,
21
22
  IconUsersGroup,
22
23
  IconWorld,
23
24
  } from "@tabler/icons-react";
24
- import { useEffect, useState, type FormEvent } from "react";
25
+ import { useEffect, useRef, useState, type FormEvent } from "react";
25
26
  import { useNavigate } from "react-router";
26
27
  import { toast } from "sonner";
27
28
 
@@ -67,6 +68,23 @@ import { AppResourceEffectiveStack } from "./workspace-resource-effective-stack"
67
68
  const APP_CARD_ACTION_CLASS =
68
69
  "size-7 rounded-md p-0 text-muted-foreground transition-[background-color,color] hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-foreground";
69
70
 
71
+ function deferWorkspaceAppOverlayOpen(
72
+ event: { preventDefault: () => void },
73
+ closeMenu: () => void,
74
+ openOverlay: () => void,
75
+ ): void {
76
+ event.preventDefault();
77
+ closeMenu();
78
+ if (
79
+ typeof window !== "undefined" &&
80
+ typeof window.requestAnimationFrame === "function"
81
+ ) {
82
+ window.requestAnimationFrame(() => openOverlay());
83
+ } else {
84
+ setTimeout(openOverlay, 0);
85
+ }
86
+ }
87
+
70
88
  export function WorkspaceAppCard({
71
89
  app,
72
90
  className,
@@ -211,17 +229,6 @@ export function WorkspaceAppCard({
211
229
  pendingOpenLabel={pendingOpenLabel}
212
230
  onTogglePinned={onTogglePinned}
213
231
  />
214
- {!isPending ? (
215
- <div className="pointer-events-auto">
216
- <ShareButton
217
- resourceType="workspace-app"
218
- resourceId={app.id}
219
- resourceTitle={app.name}
220
- trigger="icon"
221
- triggerClassName={APP_CARD_ACTION_CLASS}
222
- />
223
- </div>
224
- ) : null}
225
232
  <WorkspaceAppSettings
226
233
  app={app}
227
234
  isArchived={isArchived}
@@ -236,6 +243,7 @@ export function WorkspaceAppCard({
236
243
  createdBy: t("agents.dashboardMetadataCreatedBy"),
237
244
  owner: t("dispatch.pages.appMetadataOwner"),
238
245
  teams: t("dispatch.pages.appMetadataTeams"),
246
+ share: t("share.share", { defaultValue: "Share" }),
239
247
  }}
240
248
  />
241
249
  </div>
@@ -376,6 +384,7 @@ type WorkspaceAppMetadataLabels = {
376
384
  createdBy: string;
377
385
  owner: string;
378
386
  teams: string;
387
+ share: string;
379
388
  };
380
389
 
381
390
  function WorkspaceAppSettings({
@@ -401,66 +410,112 @@ function WorkspaceAppSettings({
401
410
  onRemovePending: () => void;
402
411
  labels: WorkspaceAppMetadataLabels;
403
412
  }) {
413
+ const [shareOpen, setShareOpen] = useState(false);
414
+ const [settingsOpen, setSettingsOpen] = useState(false);
415
+ const settingsTriggerRef = useRef<HTMLButtonElement>(null);
416
+
417
+ const handleShareOpenChange = (open: boolean) => {
418
+ setShareOpen(open);
419
+ if (open) return;
420
+
421
+ const restoreSettingsFocus = () => settingsTriggerRef.current?.focus();
422
+ if (
423
+ typeof window !== "undefined" &&
424
+ typeof window.requestAnimationFrame === "function"
425
+ ) {
426
+ window.requestAnimationFrame(restoreSettingsFocus);
427
+ } else {
428
+ setTimeout(restoreSettingsFocus, 0);
429
+ }
430
+ };
431
+
404
432
  return (
405
- <DropdownMenu>
406
- <Tooltip>
407
- <TooltipTrigger asChild>
408
- <DropdownMenuTrigger asChild>
409
- <Button
410
- type="button"
411
- variant="ghost"
412
- size="sm"
413
- aria-label={`Settings for ${app.name}`}
414
- className={APP_CARD_ACTION_CLASS}
433
+ <>
434
+ <DropdownMenu open={settingsOpen} onOpenChange={setSettingsOpen}>
435
+ <Tooltip>
436
+ <TooltipTrigger asChild>
437
+ <DropdownMenuTrigger asChild>
438
+ <Button
439
+ type="button"
440
+ variant="ghost"
441
+ size="sm"
442
+ ref={settingsTriggerRef}
443
+ aria-label={`Settings for ${app.name}`}
444
+ className={APP_CARD_ACTION_CLASS}
445
+ >
446
+ <IconSettings size={15} />
447
+ </Button>
448
+ </DropdownMenuTrigger>
449
+ </TooltipTrigger>
450
+ <TooltipContent>App settings</TooltipContent>
451
+ </Tooltip>
452
+ <DropdownMenuContent
453
+ align="end"
454
+ className={cn(APP_ACTION_MENU_CONTENT_CLASS, "min-w-max")}
455
+ >
456
+ <DropdownMenuItem onSelect={onEdit}>
457
+ <IconEdit size={14} aria-hidden="true" />
458
+ Edit details
459
+ </DropdownMenuItem>
460
+ {!isPending ? (
461
+ <DropdownMenuItem
462
+ onSelect={(event) =>
463
+ deferWorkspaceAppOverlayOpen(
464
+ event,
465
+ () => setSettingsOpen(false),
466
+ () => setShareOpen(true),
467
+ )
468
+ }
415
469
  >
416
- <IconSettings size={15} />
417
- </Button>
418
- </DropdownMenuTrigger>
419
- </TooltipTrigger>
420
- <TooltipContent>App settings</TooltipContent>
421
- </Tooltip>
422
- <DropdownMenuContent
423
- align="end"
424
- className={cn(APP_ACTION_MENU_CONTENT_CLASS, "min-w-max")}
425
- >
426
- <DropdownMenuItem onSelect={onEdit}>
427
- <IconEdit size={14} aria-hidden="true" />
428
- Edit details
429
- </DropdownMenuItem>
430
- {!isPending && !isArchived ? (
431
- <>
432
- <DropdownMenuItem onSelect={onKeys}>
433
- <IconKey size={14} aria-hidden="true" />
434
- Manage keys
470
+ <IconShare3 size={14} aria-hidden="true" />
471
+ {labels.share}
435
472
  </DropdownMenuItem>
436
- <DropdownMenuItem onSelect={onResources}>
437
- <IconFileText size={14} aria-hidden="true" />
438
- Agent resources
473
+ ) : null}
474
+ {!isPending && !isArchived ? (
475
+ <>
476
+ <DropdownMenuItem onSelect={onKeys}>
477
+ <IconKey size={14} aria-hidden="true" />
478
+ Manage keys
479
+ </DropdownMenuItem>
480
+ <DropdownMenuItem onSelect={onResources}>
481
+ <IconFileText size={14} aria-hidden="true" />
482
+ Agent resources
483
+ </DropdownMenuItem>
484
+ </>
485
+ ) : null}
486
+ {isPending ? (
487
+ <DropdownMenuItem
488
+ onSelect={onRemovePending}
489
+ className="text-destructive focus:text-destructive"
490
+ >
491
+ <IconTrash size={14} aria-hidden="true" />
492
+ Remove from list
439
493
  </DropdownMenuItem>
440
- </>
441
- ) : null}
442
- {isPending ? (
443
- <DropdownMenuItem
444
- onSelect={onRemovePending}
445
- className="text-destructive focus:text-destructive"
446
- >
447
- <IconTrash size={14} aria-hidden="true" />
448
- Remove from list
449
- </DropdownMenuItem>
450
- ) : isArchived ? (
451
- <DropdownMenuItem onSelect={onUnarchive}>
452
- <IconEye size={14} aria-hidden="true" />
453
- Restore to list
454
- </DropdownMenuItem>
455
- ) : (
456
- <DropdownMenuItem onSelect={onArchive}>
457
- <IconEyeOff size={14} aria-hidden="true" />
458
- Hide from list
459
- </DropdownMenuItem>
460
- )}
461
- <WorkspaceAppMetadata app={app} labels={labels} />
462
- </DropdownMenuContent>
463
- </DropdownMenu>
494
+ ) : isArchived ? (
495
+ <DropdownMenuItem onSelect={onUnarchive}>
496
+ <IconEye size={14} aria-hidden="true" />
497
+ Restore to list
498
+ </DropdownMenuItem>
499
+ ) : (
500
+ <DropdownMenuItem onSelect={onArchive}>
501
+ <IconEyeOff size={14} aria-hidden="true" />
502
+ Hide from list
503
+ </DropdownMenuItem>
504
+ )}
505
+ <WorkspaceAppMetadata app={app} labels={labels} />
506
+ </DropdownMenuContent>
507
+ </DropdownMenu>
508
+ {!isPending && shareOpen ? (
509
+ <ShareButton
510
+ resourceType="workspace-app"
511
+ resourceId={app.id}
512
+ resourceTitle={app.name}
513
+ defaultOpen
514
+ onOpenChange={handleShareOpenChange}
515
+ triggerClassName="sr-only"
516
+ />
517
+ ) : null}
518
+ </>
464
519
  );
465
520
  }
466
521
 
@@ -1,4 +1,4 @@
1
- import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
1
+ import { useActionQuery } from "@agent-native/core/client/hooks";
2
2
  import { useT } from "@agent-native/core/client/i18n";
3
3
  import { IconAlertTriangle, IconArrowLeft } from "@tabler/icons-react";
4
4
  import { useQueries, useQuery } from "@tanstack/react-query";
@@ -10,7 +10,6 @@ import {
10
10
  callAppAction,
11
11
  fetchAppEmailCatalog,
12
12
  type AppEmailCatalog,
13
- type AppTransactionalEmail,
14
13
  type LocalTransactionalEmailCatalog,
15
14
  } from "../../client/transactional-emails";
16
15
  import { ActionQueryError } from "../../components/action-query-error";
@@ -1,4 +1,4 @@
1
- import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
1
+ import { useActionQuery } from "@agent-native/core/client/hooks";
2
2
  import { useT } from "@agent-native/core/client/i18n";
3
3
  import {
4
4
  IconAlertTriangle,
@@ -31,10 +31,7 @@ import {
31
31
  DISPATCH_WORKSPACE_SSO_FLAG,
32
32
  isWorkspaceSsoAppUrl,
33
33
  } from "../../shared/workspace-sso.js";
34
- import {
35
- listWorkspaceApps,
36
- type WorkspaceAppSummary,
37
- } from "./app-creation-store.js";
34
+ import { listWorkspaceApps } from "./app-creation-store.js";
38
35
  import {
39
36
  getDispatchMcpAppAccessSettings,
40
37
  isAppAllowedByMcpAccess,