@agent-native/dispatch 0.40.30-nightly-20261021064625 → 0.40.30

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.
Files changed (117) hide show
  1. package/CHANGELOG.md +7 -47
  2. package/dist/actions/connect-external-agent.d.ts.map +1 -1
  3. package/dist/actions/connect-external-agent.js +12 -13
  4. package/dist/actions/connect-external-agent.js.map +1 -1
  5. package/dist/actions/create-workspace-resource.d.ts +13 -13
  6. package/dist/actions/delete-destination.d.ts +12 -12
  7. package/dist/actions/delete-workspace-resource.d.ts +13 -13
  8. package/dist/actions/import-agent.d.ts +13 -13
  9. package/dist/actions/index.d.ts.map +1 -1
  10. package/dist/actions/index.js +8 -0
  11. package/dist/actions/index.js.map +1 -1
  12. package/dist/actions/inspect-connect-agent-card.d.ts +5 -0
  13. package/dist/actions/inspect-connect-agent-card.d.ts.map +1 -0
  14. package/dist/actions/inspect-connect-agent-card.js +15 -0
  15. package/dist/actions/inspect-connect-agent-card.js.map +1 -0
  16. package/dist/actions/install-curated-community-app.d.ts +36 -0
  17. package/dist/actions/install-curated-community-app.d.ts.map +1 -0
  18. package/dist/actions/install-curated-community-app.js +26 -0
  19. package/dist/actions/install-curated-community-app.js.map +1 -0
  20. package/dist/actions/list-connect-marketplace-apps.d.ts +3 -0
  21. package/dist/actions/list-connect-marketplace-apps.d.ts.map +1 -0
  22. package/dist/actions/list-connect-marketplace-apps.js +15 -0
  23. package/dist/actions/list-connect-marketplace-apps.js.map +1 -0
  24. package/dist/actions/list-connected-agents.d.ts +2 -0
  25. package/dist/actions/list-curated-community-apps.d.ts +3 -0
  26. package/dist/actions/list-curated-community-apps.d.ts.map +1 -0
  27. package/dist/actions/list-curated-community-apps.js +10 -0
  28. package/dist/actions/list-curated-community-apps.js.map +1 -0
  29. package/dist/actions/provider-api-register.d.ts +14 -14
  30. package/dist/actions/remix-workspace-template.d.ts +6 -0
  31. package/dist/actions/update-workspace-app-metadata.d.ts +1 -0
  32. package/dist/actions/update-workspace-app-metadata.js +26 -5
  33. package/dist/actions/update-workspace-app-metadata.js.map +1 -1
  34. package/dist/components/community-apps-section.d.ts +2 -0
  35. package/dist/components/community-apps-section.d.ts.map +1 -0
  36. package/dist/components/community-apps-section.js +90 -0
  37. package/dist/components/community-apps-section.js.map +1 -0
  38. package/dist/components/workspace-app-card.d.ts.map +1 -1
  39. package/dist/components/workspace-app-card.js +63 -9
  40. package/dist/components/workspace-app-card.js.map +1 -1
  41. package/dist/lib/connect-apps.d.ts +1 -2
  42. package/dist/lib/connect-apps.d.ts.map +1 -1
  43. package/dist/lib/connect-apps.js +41 -65
  44. package/dist/lib/connect-apps.js.map +1 -1
  45. package/dist/lib/workspace-apps.d.ts +1 -0
  46. package/dist/lib/workspace-apps.d.ts.map +1 -1
  47. package/dist/lib/workspace-apps.js.map +1 -1
  48. package/dist/routes/pages/apps.d.ts.map +1 -1
  49. package/dist/routes/pages/apps.js +2 -1
  50. package/dist/routes/pages/apps.js.map +1 -1
  51. package/dist/routes/pages/connect.d.ts.map +1 -1
  52. package/dist/routes/pages/connect.js +5 -7
  53. package/dist/routes/pages/connect.js.map +1 -1
  54. package/dist/server/index.d.ts +2 -0
  55. package/dist/server/index.d.ts.map +1 -1
  56. package/dist/server/index.js +2 -0
  57. package/dist/server/index.js.map +1 -1
  58. package/dist/server/lib/app-creation-store.d.ts +11 -1
  59. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  60. package/dist/server/lib/app-creation-store.js +258 -39
  61. package/dist/server/lib/app-creation-store.js.map +1 -1
  62. package/dist/server/lib/community-apps.d.ts +45 -0
  63. package/dist/server/lib/community-apps.d.ts.map +1 -0
  64. package/dist/server/lib/community-apps.js +267 -0
  65. package/dist/server/lib/community-apps.js.map +1 -0
  66. package/dist/server/lib/community-source.d.ts +19 -0
  67. package/dist/server/lib/community-source.d.ts.map +1 -0
  68. package/dist/server/lib/community-source.js +118 -0
  69. package/dist/server/lib/community-source.js.map +1 -0
  70. package/dist/server/lib/connect-apps.d.ts +4 -0
  71. package/dist/server/lib/connect-apps.d.ts.map +1 -0
  72. package/dist/server/lib/connect-apps.js +99 -0
  73. package/dist/server/lib/connect-apps.js.map +1 -0
  74. package/dist/server/lib/org-apps-directory-cache.d.ts +20 -0
  75. package/dist/server/lib/org-apps-directory-cache.d.ts.map +1 -0
  76. package/dist/server/lib/org-apps-directory-cache.js +44 -0
  77. package/dist/server/lib/org-apps-directory-cache.js.map +1 -0
  78. package/dist/server/plugins/integrations.js +3 -3
  79. package/dist/server/plugins/integrations.js.map +1 -1
  80. package/dist/shared/labs.d.ts +2 -0
  81. package/dist/shared/labs.d.ts.map +1 -0
  82. package/dist/shared/labs.js +8 -0
  83. package/dist/shared/labs.js.map +1 -0
  84. package/package.json +5 -4
  85. package/src/actions/connect-external-agent.spec.ts +101 -3
  86. package/src/actions/connect-external-agent.ts +12 -11
  87. package/src/actions/index.spec.ts +13 -0
  88. package/src/actions/index.ts +8 -0
  89. package/src/actions/inspect-connect-agent-card.ts +17 -0
  90. package/src/actions/install-curated-community-app.ts +32 -0
  91. package/src/actions/list-connect-marketplace-apps.ts +17 -0
  92. package/src/actions/list-curated-community-apps.ts +12 -0
  93. package/src/actions/update-workspace-app-metadata.spec.ts +25 -0
  94. package/src/actions/update-workspace-app-metadata.ts +48 -24
  95. package/src/components/community-apps-section.tsx +224 -0
  96. package/src/components/workspace-app-card.spec.tsx +226 -1
  97. package/src/components/workspace-app-card.tsx +115 -12
  98. package/src/lib/connect-apps.spec.ts +118 -66
  99. package/src/lib/connect-apps.ts +47 -82
  100. package/src/lib/workspace-apps.ts +1 -0
  101. package/src/routes/pages/apps.spec.tsx +201 -1
  102. package/src/routes/pages/apps.tsx +3 -0
  103. package/src/routes/pages/connect.spec.tsx +53 -6
  104. package/src/routes/pages/connect.tsx +14 -11
  105. package/src/server/index.ts +9 -0
  106. package/src/server/lib/app-creation-store.spec.ts +539 -11
  107. package/src/server/lib/app-creation-store.ts +332 -41
  108. package/src/server/lib/community-apps.spec.ts +367 -0
  109. package/src/server/lib/community-apps.ts +372 -0
  110. package/src/server/lib/community-source.spec.ts +151 -0
  111. package/src/server/lib/community-source.ts +186 -0
  112. package/src/server/lib/connect-apps.ts +149 -0
  113. package/src/server/lib/org-apps-directory-cache.spec.ts +71 -0
  114. package/src/server/lib/org-apps-directory-cache.ts +66 -0
  115. package/src/server/plugins/integrations.spec.ts +60 -0
  116. package/src/server/plugins/integrations.ts +3 -3
  117. package/src/shared/labs.ts +9 -0
@@ -1,33 +1,57 @@
1
1
  import { defineAction } from "@agent-native/core/action";
2
- import { getWorkspaceAppIdValidationError } from "@agent-native/core/shared";
2
+ import {
3
+ getWorkspaceAppIdValidationError,
4
+ parseAgentProfileMetadata,
5
+ } from "@agent-native/core/shared";
3
6
  import { z } from "zod";
4
7
 
5
8
  import { updateWorkspaceAppMetadata } from "../server/lib/app-creation-store.js";
6
9
 
7
10
  export default defineAction({
8
11
  description:
9
- "Update the human-editable display name and description Dispatch uses for a workspace app. Workspace members can edit these shared details, which are also used as connected-agent/A2A context.",
10
- schema: z.object({
11
- appId: z
12
- .string()
13
- .max(64)
14
- .refine((appId) => !getWorkspaceAppIdValidationError(appId), {
15
- message:
16
- "Use a non-reserved app id with lowercase letters, numbers, and hyphens.",
17
- })
18
- .describe("Workspace app id, matching the apps/<id> folder"),
19
- name: z
20
- .string()
21
- .max(120)
22
- .optional()
23
- .nullable()
24
- .describe("Human-readable app name"),
25
- description: z
26
- .string()
27
- .max(500)
28
- .optional()
29
- .nullable()
30
- .describe("Human-readable app description"),
31
- }),
12
+ "Update a workspace app's display name, description, and when-to-use profile. Only organization owners and admins can edit these shared details.",
13
+ schema: z
14
+ .object({
15
+ appId: z
16
+ .string()
17
+ .max(64)
18
+ .refine((appId) => !getWorkspaceAppIdValidationError(appId), {
19
+ message:
20
+ "Use a non-reserved app id with lowercase letters, numbers, and hyphens.",
21
+ })
22
+ .describe("Workspace app id, matching the apps/<id> folder"),
23
+ name: z
24
+ .string()
25
+ .max(120)
26
+ .optional()
27
+ .nullable()
28
+ .describe("Human-readable app name"),
29
+ description: z
30
+ .string()
31
+ .max(1000)
32
+ .optional()
33
+ .nullable()
34
+ .describe("Human-readable app description, up to 1,000 characters"),
35
+ whenToUse: z
36
+ .array(z.string())
37
+ .max(6)
38
+ .optional()
39
+ .nullable()
40
+ .describe("Example requests that indicate when this app is useful"),
41
+ })
42
+ .superRefine(({ description, whenToUse }, context) => {
43
+ if (description == null && whenToUse == null) return;
44
+ const profile = parseAgentProfileMetadata({
45
+ ...(description != null ? { description } : {}),
46
+ ...(whenToUse != null ? { whenToUse } : {}),
47
+ });
48
+ if (!profile) {
49
+ context.addIssue({
50
+ code: "custom",
51
+ message:
52
+ "Use a description up to 1,000 characters and up to 6 non-empty examples, each no longer than 140 characters.",
53
+ });
54
+ }
55
+ }),
32
56
  run: async (args) => updateWorkspaceAppMetadata(args),
33
57
  });
@@ -0,0 +1,224 @@
1
+ import { sendToAgentChat } from "@agent-native/core/client/agent-chat";
2
+ import {
3
+ useActionMutation,
4
+ useActionQuery,
5
+ } from "@agent-native/core/client/hooks";
6
+ import { useT } from "@agent-native/core/client/i18n";
7
+ import { useLab } from "@agent-native/core/client/labs";
8
+ import {
9
+ IconArrowUpRight,
10
+ IconCircleCheck,
11
+ IconPlus,
12
+ } from "@tabler/icons-react";
13
+ import { useState } from "react";
14
+ import { toast } from "sonner";
15
+
16
+ import { COMMUNITY_APPS_LAB } from "../shared/labs.js";
17
+ import { ActionQueryError } from "./action-query-error";
18
+ import { AppIcon } from "./app-icon";
19
+ import { AppList, AppListRow } from "./app-list-row";
20
+ import { Badge } from "./ui/badge";
21
+ import { Button } from "./ui/button";
22
+ import { Skeleton } from "./ui/skeleton";
23
+ import { Spinner } from "./ui/spinner";
24
+
25
+ interface CommunityAppSource {
26
+ id: string;
27
+ name: string;
28
+ description?: string;
29
+ appId: string;
30
+ repo: string;
31
+ ref: string;
32
+ license: string;
33
+ provenance: string;
34
+ templatePath: string;
35
+ installed: boolean;
36
+ pending: boolean;
37
+ installable: boolean;
38
+ installUnavailableReason?:
39
+ | "workspace-required"
40
+ | "app-id-conflict"
41
+ | "builder-install-unverified";
42
+ }
43
+
44
+ function pinnedSourceUrl(source: CommunityAppSource): string {
45
+ return (
46
+ "https://github.com/" +
47
+ source.repo +
48
+ "/tree/" +
49
+ source.ref +
50
+ "/" +
51
+ source.templatePath
52
+ );
53
+ }
54
+
55
+ export function CommunityAppsSection() {
56
+ const t = useT();
57
+ const enabled = useLab(COMMUNITY_APPS_LAB);
58
+ const [installingId, setInstallingId] = useState<string | null>(null);
59
+ const catalogQuery = useActionQuery<CommunityAppSource[]>(
60
+ "list-curated-community-apps" as never,
61
+ {},
62
+ { enabled },
63
+ );
64
+ const workspaceAppsQuery = useActionQuery("list-workspace-apps", {
65
+ includeAgentCards: false,
66
+ includeArchived: true,
67
+ });
68
+ const install = useActionMutation("install-curated-community-app", {
69
+ onSuccess: (result) => {
70
+ const mode = (result as { mode?: string } | null)?.mode;
71
+ const message = (result as { message?: string } | null)?.message;
72
+ if (mode === "builder") {
73
+ toast.success(t("dispatch.pages.communityAppsInstallStarted"));
74
+ } else if (mode === "pending") {
75
+ toast.info(t("dispatch.pages.communityAppsPending"));
76
+ } else if (mode === "already-installed") {
77
+ toast.success(t("dispatch.pages.communityAppsAlreadyInstalled"));
78
+ } else if (mode === "builder-unavailable" || mode === "app-id-taken") {
79
+ toast.error(message || t("dispatch.pages.communityAppsInstallFailed"));
80
+ } else if (mode === "local-agent") {
81
+ sendToAgentChat({
82
+ message:
83
+ (result as { prompt?: string } | null)?.prompt ||
84
+ t("dispatch.pages.communityAppsLocalRequestReady"),
85
+ attachments: (
86
+ result as {
87
+ attachments?: Array<{
88
+ type: "file";
89
+ name: string;
90
+ contentType: string;
91
+ text: string;
92
+ }>;
93
+ } | null
94
+ )?.attachments,
95
+ submit: true,
96
+ type: "code",
97
+ newTab: true,
98
+ reuseEmptyTab: true,
99
+ });
100
+ toast.info(t("dispatch.pages.communityAppsLocalRequestReady"));
101
+ } else {
102
+ toast.error(t("dispatch.pages.communityAppsInstallFailed"));
103
+ }
104
+ setInstallingId(null);
105
+ void catalogQuery.refetch();
106
+ void workspaceAppsQuery.refetch();
107
+ },
108
+ onError: () => {
109
+ setInstallingId(null);
110
+ toast.error(t("dispatch.pages.communityAppsInstallFailed"));
111
+ },
112
+ });
113
+
114
+ if (!enabled) return null;
115
+
116
+ return (
117
+ <section className="space-y-3 border-t pt-6">
118
+ <h2 className="text-sm font-semibold text-foreground">
119
+ {t("dispatch.pages.communityApps")}
120
+ </h2>
121
+ {catalogQuery.isError ? (
122
+ <ActionQueryError
123
+ error={catalogQuery.error}
124
+ onRetry={() => void catalogQuery.refetch()}
125
+ />
126
+ ) : catalogQuery.isLoading ? (
127
+ <AppList className="divide-y rounded-xl border">
128
+ <div className="flex items-center gap-3 px-4 py-3.5">
129
+ <Skeleton className="size-8 rounded-lg" />
130
+ <Skeleton className="h-4 w-40" />
131
+ </div>
132
+ </AppList>
133
+ ) : catalogQuery.data?.length ? (
134
+ <AppList className="divide-y rounded-xl border">
135
+ {catalogQuery.data.map((source) => (
136
+ <AppListRow key={source.id} className="border-b-0">
137
+ <AppIcon id={source.id} name={source.name} size="sm" />
138
+ <div className="min-w-0 flex-1">
139
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
140
+ <h3 className="truncate text-sm font-semibold text-foreground">
141
+ {source.name}
142
+ </h3>
143
+ {source.installed ? (
144
+ <Badge variant="secondary" className="gap-1">
145
+ <IconCircleCheck size={13} />
146
+ {t("dispatch.pages.communityAppsInstalled")}
147
+ </Badge>
148
+ ) : source.pending ? (
149
+ <Badge variant="secondary">
150
+ {t("dispatch.pages.communityAppsPending")}
151
+ </Badge>
152
+ ) : null}
153
+ </div>
154
+ {source.description ? (
155
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
156
+ {source.description}
157
+ </p>
158
+ ) : null}
159
+ <div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
160
+ <span>
161
+ {t("dispatch.pages.communityAppsLicense")}: {source.license}
162
+ </span>
163
+ <a
164
+ href={pinnedSourceUrl(source)}
165
+ target="_blank"
166
+ rel="noreferrer"
167
+ className="inline-flex items-center gap-1 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
168
+ >
169
+ {t("dispatch.pages.communityAppsTemplateSource")}
170
+ <IconArrowUpRight size={12} aria-hidden="true" />
171
+ </a>
172
+ </div>
173
+ </div>
174
+ {source.installable ? (
175
+ <Button
176
+ type="button"
177
+ size="sm"
178
+ variant="outline"
179
+ disabled={install.isPending}
180
+ onClick={() => {
181
+ setInstallingId(source.id);
182
+ install.mutate({ sourceId: source.id });
183
+ }}
184
+ >
185
+ {install.isPending && installingId === source.id ? (
186
+ <Spinner className="mr-1.5" />
187
+ ) : (
188
+ <IconPlus size={14} className="mr-1.5" />
189
+ )}
190
+ {install.isPending && installingId === source.id
191
+ ? t("dispatch.pages.communityAppsInstalling")
192
+ : t("dispatch.pages.communityAppsInstall")}
193
+ </Button>
194
+ ) : !source.installed &&
195
+ !source.pending &&
196
+ source.installUnavailableReason === "workspace-required" ? (
197
+ <span className="max-w-48 text-right text-xs text-muted-foreground">
198
+ {t("dispatch.pages.communityAppsWorkspaceRequired")}
199
+ </span>
200
+ ) : !source.installed &&
201
+ !source.pending &&
202
+ source.installUnavailableReason === "app-id-conflict" ? (
203
+ <span className="max-w-48 text-right text-xs text-muted-foreground">
204
+ {t("dispatch.pages.communityAppsIdConflict")}
205
+ </span>
206
+ ) : !source.installed &&
207
+ !source.pending &&
208
+ source.installUnavailableReason ===
209
+ "builder-install-unverified" ? (
210
+ <span className="max-w-48 text-right text-xs text-muted-foreground">
211
+ {t("dispatch.pages.communityAppsBuilderInstallUnavailable")}
212
+ </span>
213
+ ) : null}
214
+ </AppListRow>
215
+ ))}
216
+ </AppList>
217
+ ) : (
218
+ <p className="text-sm text-muted-foreground">
219
+ {t("dispatch.pages.communityAppsEmpty")}
220
+ </p>
221
+ )}
222
+ </section>
223
+ );
224
+ }
@@ -34,7 +34,13 @@ vi.mock("../../../core/dist/client/i18n.js", () => ({
34
34
  }));
35
35
 
36
36
  vi.mock("@agent-native/core/client/hooks", () => ({
37
- useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
37
+ useActionMutation: (action: string) => ({
38
+ mutate:
39
+ action === "update-workspace-app-metadata"
40
+ ? metadataMutationState.mutate
41
+ : vi.fn(),
42
+ isPending: false,
43
+ }),
38
44
  useActionQuery: () => ({
39
45
  data: { resources: [], counts: {} },
40
46
  isLoading: false,
@@ -53,6 +59,8 @@ vi.mock("@agent-native/core/client/i18n", () => ({
53
59
  }));
54
60
 
55
61
  const frameState = vi.hoisted(() => ({ inBuilderFrame: false }));
62
+ const orgRoleState = vi.hoisted(() => ({ role: "owner" as string }));
63
+ const metadataMutationState = vi.hoisted(() => ({ mutate: vi.fn() }));
56
64
 
57
65
  function LocationProbe() {
58
66
  const location = useLocation();
@@ -64,6 +72,24 @@ vi.mock("@agent-native/core/client/host", () => ({
64
72
  isInBuilderFrame: () => frameState.inBuilderFrame,
65
73
  }));
66
74
 
75
+ vi.mock("@agent-native/core/client/org", () => ({
76
+ useOrgRole: () => ({
77
+ org: { orgId: "org-123" },
78
+ role: orgRoleState.role,
79
+ isLoading: false,
80
+ error: null,
81
+ }),
82
+ }));
83
+
84
+ function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
85
+ const setter = Object.getOwnPropertyDescriptor(
86
+ HTMLTextAreaElement.prototype,
87
+ "value",
88
+ )?.set;
89
+ setter?.call(textarea, value);
90
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
91
+ }
92
+
67
93
  describe("WorkspaceAppCard", () => {
68
94
  let container: HTMLDivElement;
69
95
  let root: Root;
@@ -71,6 +97,8 @@ describe("WorkspaceAppCard", () => {
71
97
 
72
98
  beforeEach(() => {
73
99
  frameState.inBuilderFrame = false;
100
+ orgRoleState.role = "owner";
101
+ metadataMutationState.mutate.mockClear();
74
102
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
75
103
  container = document.createElement("div");
76
104
  document.body.appendChild(container);
@@ -90,6 +118,203 @@ describe("WorkspaceAppCard", () => {
90
118
  vi.unstubAllGlobals();
91
119
  });
92
120
 
121
+ it("edits the app's when-to-use examples with the existing details form", async () => {
122
+ await act(async () => {
123
+ root.render(
124
+ <MemoryRouter>
125
+ <TooltipProvider>
126
+ <WorkspaceAppCard
127
+ app={{
128
+ id: "analytics",
129
+ name: "Analytics",
130
+ path: "/analytics",
131
+ status: "ready",
132
+ whenToUse: [
133
+ "Explain this week's activation trend",
134
+ "Compare signup sources",
135
+ ],
136
+ }}
137
+ />
138
+ </TooltipProvider>
139
+ </MemoryRouter>,
140
+ );
141
+ });
142
+
143
+ const settingsButton = container.querySelector<HTMLButtonElement>(
144
+ 'button[aria-label="Settings for Analytics"]',
145
+ );
146
+ await act(async () => {
147
+ settingsButton?.dispatchEvent(
148
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
149
+ );
150
+ });
151
+ const editItem = Array.from(
152
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
153
+ ).find((item) => item.textContent?.includes("Edit details"));
154
+ expect(editItem).not.toBeUndefined();
155
+
156
+ await act(async () => editItem?.click());
157
+
158
+ const whenToUse = document.querySelector<HTMLTextAreaElement>(
159
+ "#app-when-to-use-analytics",
160
+ );
161
+ expect(whenToUse?.value).toBe(
162
+ "Explain this week's activation trend\nCompare signup sources",
163
+ );
164
+ expect(document.body.textContent).toContain("When to use");
165
+ });
166
+
167
+ it("validates profile examples and normalizes description line breaks", async () => {
168
+ await act(async () => {
169
+ root.render(
170
+ <MemoryRouter>
171
+ <TooltipProvider>
172
+ <WorkspaceAppCard
173
+ app={{
174
+ id: "analytics",
175
+ name: "Analytics",
176
+ path: "/analytics",
177
+ status: "ready",
178
+ whenToUse: ["Compare signup sources"],
179
+ }}
180
+ />
181
+ </TooltipProvider>
182
+ </MemoryRouter>,
183
+ );
184
+ });
185
+
186
+ const settingsButton = container.querySelector<HTMLButtonElement>(
187
+ 'button[aria-label="Settings for Analytics"]',
188
+ );
189
+ await act(async () => {
190
+ settingsButton?.dispatchEvent(
191
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
192
+ );
193
+ });
194
+ const editItem = Array.from(
195
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
196
+ ).find((item) => item.textContent?.includes("Edit details"));
197
+ await act(async () => editItem?.click());
198
+
199
+ const examples = document.querySelector<HTMLTextAreaElement>(
200
+ "#app-when-to-use-analytics",
201
+ );
202
+ const description = document.querySelector<HTMLTextAreaElement>(
203
+ "#app-description-analytics",
204
+ );
205
+ const save = Array.from(
206
+ document.querySelectorAll<HTMLButtonElement>('button[type="submit"]'),
207
+ ).find((button) => button.textContent?.includes("Save"));
208
+ expect(examples).not.toBeNull();
209
+ expect(description).not.toBeNull();
210
+ expect(save).not.toBeNull();
211
+
212
+ await act(async () => {
213
+ setTextareaValue(
214
+ examples!,
215
+ Array.from({ length: 7 }, (_, i) => `Example ${i + 1}`).join("\n"),
216
+ );
217
+ });
218
+ expect(save?.disabled).toBe(true);
219
+
220
+ await act(async () => {
221
+ setTextareaValue(examples!, `${"x".repeat(141)}\nValid example`);
222
+ });
223
+ expect(save?.disabled).toBe(true);
224
+
225
+ await act(async () => {
226
+ setTextareaValue(examples!, "Safe\u0001 example");
227
+ setTextareaValue(description!, "First line\nSecond line");
228
+ });
229
+ expect(examples?.value).toBe("Safe example");
230
+ expect(description?.value).toBe("First line Second line");
231
+ expect(save?.disabled).toBe(false);
232
+ });
233
+
234
+ it("submits only the profile field that changed", async () => {
235
+ await act(async () => {
236
+ root.render(
237
+ <MemoryRouter>
238
+ <TooltipProvider>
239
+ <WorkspaceAppCard
240
+ app={{
241
+ id: "analytics",
242
+ name: "Analytics",
243
+ description: "Original description",
244
+ path: "/analytics",
245
+ status: "ready",
246
+ whenToUse: ["Compare signup sources"],
247
+ }}
248
+ />
249
+ </TooltipProvider>
250
+ </MemoryRouter>,
251
+ );
252
+ });
253
+
254
+ const settingsButton = container.querySelector<HTMLButtonElement>(
255
+ 'button[aria-label="Settings for Analytics"]',
256
+ );
257
+ await act(async () => {
258
+ settingsButton?.dispatchEvent(
259
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
260
+ );
261
+ });
262
+ const editItem = Array.from(
263
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
264
+ ).find((item) => item.textContent?.includes("Edit details"));
265
+ await act(async () => editItem?.click());
266
+
267
+ const examples = document.querySelector<HTMLTextAreaElement>(
268
+ "#app-when-to-use-analytics",
269
+ );
270
+ const form = examples?.closest("form");
271
+ await act(async () => {
272
+ setTextareaValue(examples!, "Track acquisition by channel");
273
+ form?.dispatchEvent(
274
+ new Event("submit", { bubbles: true, cancelable: true }),
275
+ );
276
+ });
277
+
278
+ expect(metadataMutationState.mutate).toHaveBeenCalledWith({
279
+ appId: "analytics",
280
+ whenToUse: ["Track acquisition by channel"],
281
+ });
282
+ });
283
+
284
+ it("hides Edit details from members", async () => {
285
+ orgRoleState.role = "member";
286
+ await act(async () => {
287
+ root.render(
288
+ <MemoryRouter>
289
+ <TooltipProvider>
290
+ <WorkspaceAppCard
291
+ app={{
292
+ id: "analytics",
293
+ name: "Analytics",
294
+ path: "/analytics",
295
+ status: "ready",
296
+ }}
297
+ />
298
+ </TooltipProvider>
299
+ </MemoryRouter>,
300
+ );
301
+ });
302
+
303
+ const settingsButton = container.querySelector<HTMLButtonElement>(
304
+ 'button[aria-label="Settings for Analytics"]',
305
+ );
306
+ await act(async () => {
307
+ settingsButton?.dispatchEvent(
308
+ new MouseEvent("pointerdown", { bubbles: true, button: 0 }),
309
+ );
310
+ });
311
+ expect(
312
+ Array.from(
313
+ document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
314
+ ).some((item) => item.textContent?.includes("Edit details")),
315
+ ).toBe(false);
316
+ });
317
+
93
318
  it("opens ready apps inline and keeps new-tab opening in the chevron menu", async () => {
94
319
  await act(async () => {
95
320
  root.render(