@xenide-io/the-old-ui-theme 0.7.0 → 0.9.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/README.md +9 -10
- package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
- package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
- package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
- package/dist/chunk-2EO5VCXP.mjs +3912 -0
- package/dist/chunk-APKRZQRO.mjs +251 -0
- package/dist/chunk-BS7BZI5W.mjs +19 -0
- package/dist/chunk-IULORI3A.mjs +8 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +997 -576
- package/dist/index.mjs +49 -45
- package/dist/suite.d.mts +157 -55
- package/dist/suite.d.ts +157 -55
- package/dist/suite.js +4249 -801
- package/dist/suite.mjs +2578 -594
- package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
- package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
- package/dist/theme-boot.d.mts +2 -0
- package/dist/theme-boot.d.ts +2 -0
- package/dist/theme-boot.js +44 -0
- package/dist/theme-boot.mjs +8 -0
- package/dist/ui.d.mts +5 -29
- package/dist/ui.d.ts +5 -29
- package/dist/ui.js +647 -352
- package/dist/ui.mjs +10 -12
- package/docs/README.md +34 -0
- package/docs/ai-quick-reference.md +56 -0
- package/docs/component-library-improvement-plan.md +751 -0
- package/docs/component-reference.md +81 -0
- package/docs/components.md +48 -0
- package/docs/demos.md +19 -0
- package/docs/installation.md +117 -0
- package/docs/suite-audit.md +34 -0
- package/docs/theme-token-reference.md +67 -0
- package/docs/themes.md +164 -0
- package/package.json +22 -21
- package/public/fonts/OpenRunde-Bold.woff2 +0 -0
- package/public/fonts/OpenRunde-Medium.woff2 +0 -0
- package/public/fonts/OpenRunde-Regular.woff2 +0 -0
- package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
- package/scripts/package-smoke.mjs +43 -0
- package/src/app/globals.css +2 -1
- package/src/components/ui/Accordion.tsx +20 -6
- package/src/components/ui/Avatar.tsx +5 -3
- package/src/components/ui/Calendar.tsx +187 -63
- package/src/components/ui/Card.test.tsx +38 -0
- package/src/components/ui/Card.tsx +77 -14
- package/src/components/ui/CollapsibleSection.tsx +12 -4
- package/src/components/ui/CommandPalette.test.tsx +91 -4
- package/src/components/ui/CommandPalette.tsx +138 -128
- package/src/components/ui/EmptyState.tsx +2 -2
- package/src/components/ui/FormField.tsx +1 -1
- package/src/components/ui/Input.tsx +42 -18
- package/src/components/ui/Kbd.tsx +18 -3
- package/src/components/ui/Modal.tsx +3 -1
- package/src/components/ui/Progress.tsx +14 -5
- package/src/components/ui/SegmentedControl.tsx +2 -2
- package/src/components/ui/SettingsLayout.tsx +1 -1
- package/src/components/ui/Stat.tsx +3 -2
- package/src/components/ui/Table.tsx +8 -3
- package/src/components/ui/Tooltip.test.tsx +70 -1
- package/src/components/ui/Tooltip.tsx +53 -29
- package/src/components/ui/Typography.tsx +103 -49
- package/src/components/ui/avatar-stat.test.tsx +23 -0
- package/src/components/ui/calendar.test.tsx +88 -0
- package/src/components/ui/disclosure.test.tsx +39 -0
- package/src/components/ui/index.ts +44 -6
- package/src/components/ui/progress.test.tsx +15 -0
- package/src/components/ui/typography.test.tsx +26 -1
- package/src/styles/suite-skin.css +8 -2
- package/src/styles/themes.css +184 -68
- package/src/suite/components/ai-message-blocknote.tsx +13 -2
- package/src/suite/components/ai-panel.tsx +41 -10
- package/src/suite/components/app-switcher.test.tsx +21 -6
- package/src/suite/components/app-switcher.tsx +7 -11
- package/src/suite/components/suite-app-layout.tsx +12 -3
- package/src/suite/components/suite-bottom-nav.tsx +0 -1
- package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
- package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
- package/src/suite/components/suite-integration-picker.test.tsx +224 -0
- package/src/suite/components/suite-integration-picker.tsx +939 -0
- package/src/suite/components/suite-integration-rules.test.tsx +157 -0
- package/src/suite/components/suite-integration-rules.tsx +471 -0
- package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
- package/src/suite/components/suite-mobile-drawer.tsx +65 -98
- package/src/suite/components/suite-notification-bell.tsx +29 -4
- package/src/suite/components/suite-sidebar.tsx +25 -25
- package/src/suite/components/suite-skeleton.tsx +2 -2
- package/src/suite/components/suite-user-menu.tsx +9 -8
- package/src/suite/components/theme-provider.test.tsx +87 -3
- package/src/suite/components/theme-provider.tsx +144 -23
- package/src/suite/components/today-ui.tsx +79 -68
- package/src/suite/components/workspace-switcher.test.tsx +56 -0
- package/src/suite/components/workspace-switcher.tsx +3 -3
- package/src/suite/icons/app-accents.ts +12 -12
- package/src/suite/icons/glyphs.ts +6 -12
- package/src/suite/index.ts +27 -0
- package/src/suite/lib/apps.test.ts +9 -1
- package/src/suite/lib/apps.ts +14 -8
- package/src/suite/lib/cookies.ts +53 -0
- package/src/suite/lib/theme-boot.ts +32 -0
- package/src/suite/lib/theme-cookie.ts +7 -0
- package/src/suite/lib/use-sidebar-width.ts +5 -51
- package/dist/chunk-KDR6HI6F.mjs +0 -1075
- package/dist/chunk-NOI42JNZ.mjs +0 -151
- package/dist/chunk-VXISSXTB.mjs +0 -2564
- package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
- package/src/suite/lib/use-lock-mobile-page-zoom.ts +0 -51
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type FormEvent,
|
|
9
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import {
|
|
13
|
+
EvPlug as Plug,
|
|
14
|
+
Filter,
|
|
15
|
+
NavArrowDown,
|
|
16
|
+
NavArrowUp,
|
|
17
|
+
Search,
|
|
18
|
+
Xmark,
|
|
19
|
+
} from "iconoir-react";
|
|
20
|
+
import { Button, Input, Modal } from "@/components/ui";
|
|
21
|
+
import { SUITE_APPS, SUITE_APP_MAP, type SuiteAppSlug } from "@/suite/lib/apps";
|
|
22
|
+
import { SuiteAppIcon } from "@/suite/icons";
|
|
23
|
+
|
|
24
|
+
export interface SuiteIntegrationPickerItem {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
description: string;
|
|
28
|
+
icon?: ReactNode;
|
|
29
|
+
categories?: string[];
|
|
30
|
+
authSchemes?: string[];
|
|
31
|
+
connecting?: boolean;
|
|
32
|
+
unavailableReason?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SuiteIntegrationAuthField {
|
|
36
|
+
name: string;
|
|
37
|
+
label: string;
|
|
38
|
+
type?: "password" | "text" | "url";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SuiteIntegrationAuthDetails {
|
|
42
|
+
fields: SuiteIntegrationAuthField[];
|
|
43
|
+
auth_scheme: string | null;
|
|
44
|
+
managed: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SuiteIntegrationAuthSelection {
|
|
48
|
+
auth_scheme: string;
|
|
49
|
+
auth_fields?: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SuiteIntegrationPickerProps {
|
|
53
|
+
open: boolean;
|
|
54
|
+
items: SuiteIntegrationPickerItem[];
|
|
55
|
+
connectedItems?: SuiteIntegrationPickerItem[];
|
|
56
|
+
loading?: boolean;
|
|
57
|
+
catalogLoading?: boolean;
|
|
58
|
+
loadingMore?: boolean;
|
|
59
|
+
hasMore?: boolean;
|
|
60
|
+
onEndReached?: () => void;
|
|
61
|
+
search?: string;
|
|
62
|
+
onSearchChange?: (value: string) => void;
|
|
63
|
+
category?: string;
|
|
64
|
+
categories?: string[];
|
|
65
|
+
onCategoryChange?: (value: string) => void;
|
|
66
|
+
onClose: () => void;
|
|
67
|
+
onConnect: (
|
|
68
|
+
item: SuiteIntegrationPickerItem,
|
|
69
|
+
auth?: SuiteIntegrationAuthSelection,
|
|
70
|
+
) => void | Promise<void>;
|
|
71
|
+
onFetchAuthFields?: (
|
|
72
|
+
item: SuiteIntegrationPickerItem,
|
|
73
|
+
authScheme: string,
|
|
74
|
+
) => Promise<SuiteIntegrationAuthDetails>;
|
|
75
|
+
onDisconnect?: (item: SuiteIntegrationPickerItem) => void;
|
|
76
|
+
title?: string;
|
|
77
|
+
description?: string;
|
|
78
|
+
dataTest?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DESCRIPTION_LIMIT = 90;
|
|
82
|
+
|
|
83
|
+
function IntegrationSkeletonCard() {
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className="flex min-h-36 min-w-0 animate-pulse flex-col justify-between rounded-xl border border-ph-border bg-ph-surface p-4"
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
>
|
|
89
|
+
<div className="flex items-start gap-3">
|
|
90
|
+
<div className="h-10 w-10 shrink-0 rounded-lg bg-ph-muted" />
|
|
91
|
+
<div className="min-w-0 flex-1 space-y-2 pt-1">
|
|
92
|
+
<div className="h-4 w-3/5 rounded bg-ph-muted" />
|
|
93
|
+
<div className="h-3 w-full rounded bg-ph-muted" />
|
|
94
|
+
<div className="h-3 w-4/5 rounded bg-ph-muted" />
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="mt-3 h-9 w-20 rounded bg-ph-muted" />
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const SKELETON_COUNT = 6;
|
|
103
|
+
|
|
104
|
+
const AUTH_SCHEME_LABELS: Record<string, string> = {
|
|
105
|
+
API_KEY: "API key",
|
|
106
|
+
BASIC_AUTH: "Basic auth",
|
|
107
|
+
BEARER_TOKEN: "Bearer token",
|
|
108
|
+
OAUTH2: "OAuth 2.0",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function authSchemeLabel(scheme: string) {
|
|
112
|
+
const normalised = scheme.trim().toUpperCase();
|
|
113
|
+
return (
|
|
114
|
+
AUTH_SCHEME_LABELS[normalised] ??
|
|
115
|
+
normalised
|
|
116
|
+
.replace(/[-_]/g, " ")
|
|
117
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function authSchemesFor(item: SuiteIntegrationPickerItem) {
|
|
122
|
+
return Array.from(
|
|
123
|
+
new Set(
|
|
124
|
+
(item.authSchemes ?? [])
|
|
125
|
+
.map((scheme) => scheme.trim().toUpperCase())
|
|
126
|
+
.filter(Boolean),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Which ShellStack apps put a connected integration to work, per the deep
|
|
133
|
+
* integration plan: calendar/mail feed TurtleTime time entries and Tides
|
|
134
|
+
* tasks, code/chat/ticketing feed Tides tasks and Kraken runbooks, docs and
|
|
135
|
+
* knowledge sources feed Kraken, CRM/finance feed the Portal. Unmapped
|
|
136
|
+
* categories default to Kraken's authorised-source search.
|
|
137
|
+
*/
|
|
138
|
+
const CATEGORY_APP_AFFINITY: Record<string, readonly SuiteAppSlug[]> = {
|
|
139
|
+
calendar: ["turtletime", "tides"],
|
|
140
|
+
email: ["turtletime", "tides"],
|
|
141
|
+
code: ["tides", "kraken"],
|
|
142
|
+
chat: ["tides", "kraken"],
|
|
143
|
+
documents: ["kraken"],
|
|
144
|
+
notes: ["kraken"],
|
|
145
|
+
knowledge: ["kraken"],
|
|
146
|
+
file_storage: ["kraken"],
|
|
147
|
+
crm: ["shellstack", "tides"],
|
|
148
|
+
finance: ["shellstack", "tides"],
|
|
149
|
+
payment: ["shellstack", "tides"],
|
|
150
|
+
monitoring: ["tides", "kraken"],
|
|
151
|
+
design: ["tides", "kraken"],
|
|
152
|
+
support: ["tides", "shellstack"],
|
|
153
|
+
ticketing: ["tides", "kraken"],
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const DEFAULT_APP_AFFINITY: readonly SuiteAppSlug[] = ["tides", "kraken"];
|
|
157
|
+
|
|
158
|
+
export function suiteAppsForIntegration(
|
|
159
|
+
categories: readonly string[] | undefined,
|
|
160
|
+
): SuiteAppSlug[] {
|
|
161
|
+
const apps = new Set<SuiteAppSlug>();
|
|
162
|
+
for (const category of categories ?? []) {
|
|
163
|
+
for (const app of CATEGORY_APP_AFFINITY[category.trim().toLowerCase()] ??
|
|
164
|
+
[]) {
|
|
165
|
+
apps.add(app);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return apps.size > 0
|
|
169
|
+
? SUITE_APPS.filter((app) => apps.has(app.slug)).map((app) => app.slug)
|
|
170
|
+
: [...DEFAULT_APP_AFFINITY];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function SuiteIntegrationPicker({
|
|
174
|
+
open,
|
|
175
|
+
items,
|
|
176
|
+
connectedItems = [],
|
|
177
|
+
loading = false,
|
|
178
|
+
catalogLoading = false,
|
|
179
|
+
loadingMore = false,
|
|
180
|
+
hasMore = false,
|
|
181
|
+
onEndReached,
|
|
182
|
+
search: controlledSearch,
|
|
183
|
+
onSearchChange,
|
|
184
|
+
category: controlledCategory,
|
|
185
|
+
categories: controlledCategories,
|
|
186
|
+
onCategoryChange,
|
|
187
|
+
onClose,
|
|
188
|
+
onConnect,
|
|
189
|
+
onFetchAuthFields,
|
|
190
|
+
onDisconnect,
|
|
191
|
+
title = "Integrate an app",
|
|
192
|
+
description = "Connect an app to this workspace securely.",
|
|
193
|
+
dataTest = "suite-integration-picker",
|
|
194
|
+
}: SuiteIntegrationPickerProps) {
|
|
195
|
+
const [query, setQuery] = useState("");
|
|
196
|
+
const [category, setCategory] = useState("all");
|
|
197
|
+
const [categoryQuery, setCategoryQuery] = useState("");
|
|
198
|
+
const [categoryMenuOpen, setCategoryMenuOpen] = useState(false);
|
|
199
|
+
const [expanded, setExpanded] = useState<string | null>(null);
|
|
200
|
+
const categoryMenuRef = useRef<HTMLDivElement>(null);
|
|
201
|
+
const categoryTriggerRef = useRef<HTMLButtonElement>(null);
|
|
202
|
+
const categorySearchRef = useRef<HTMLInputElement>(null);
|
|
203
|
+
const categoryOptionRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
204
|
+
const pendingCategoryFocus = useRef<"trigger" | "search" | string | null>(
|
|
205
|
+
null,
|
|
206
|
+
);
|
|
207
|
+
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
|
208
|
+
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
|
209
|
+
const loadMoreInFlightRef = useRef(false);
|
|
210
|
+
const authRequestRef = useRef(0);
|
|
211
|
+
const [methodItem, setMethodItem] =
|
|
212
|
+
useState<SuiteIntegrationPickerItem | null>(null);
|
|
213
|
+
const [authItem, setAuthItem] = useState<{
|
|
214
|
+
item: SuiteIntegrationPickerItem;
|
|
215
|
+
scheme: string;
|
|
216
|
+
fields: SuiteIntegrationAuthField[];
|
|
217
|
+
} | null>(null);
|
|
218
|
+
const [authValues, setAuthValues] = useState<Record<string, string>>({});
|
|
219
|
+
const [authLoading, setAuthLoading] = useState(false);
|
|
220
|
+
const [authSubmitting, setAuthSubmitting] = useState(false);
|
|
221
|
+
const [authError, setAuthError] = useState<string | null>(null);
|
|
222
|
+
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
223
|
+
const [itemToDisconnect, setItemToDisconnect] =
|
|
224
|
+
useState<SuiteIntegrationPickerItem | null>(null);
|
|
225
|
+
const [disconnecting, setDisconnecting] = useState(false);
|
|
226
|
+
const [disconnectError, setDisconnectError] = useState<string | null>(null);
|
|
227
|
+
const itemCategories = useMemo(
|
|
228
|
+
() =>
|
|
229
|
+
Array.from(
|
|
230
|
+
new Set(items.flatMap((item) => item.categories ?? [])),
|
|
231
|
+
).sort(),
|
|
232
|
+
[items],
|
|
233
|
+
);
|
|
234
|
+
const categories = controlledCategories ?? itemCategories;
|
|
235
|
+
const activeSearch = controlledSearch ?? query;
|
|
236
|
+
const activeCategory = controlledCategory ?? category;
|
|
237
|
+
const categoryLabel = (value: string) =>
|
|
238
|
+
value
|
|
239
|
+
.replace(/[-_]/g, " ")
|
|
240
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
241
|
+
const filtered =
|
|
242
|
+
onSearchChange || onCategoryChange
|
|
243
|
+
? items
|
|
244
|
+
: items.filter((item) => {
|
|
245
|
+
return (
|
|
246
|
+
item.label
|
|
247
|
+
.toLowerCase()
|
|
248
|
+
.includes(activeSearch.trim().toLowerCase()) &&
|
|
249
|
+
(activeCategory === "all" ||
|
|
250
|
+
(item.categories ?? []).includes(activeCategory))
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
const visibleCategories = categories.filter((value) =>
|
|
254
|
+
categoryLabel(value)
|
|
255
|
+
.toLowerCase()
|
|
256
|
+
.includes(categoryQuery.trim().toLowerCase()),
|
|
257
|
+
);
|
|
258
|
+
const categoryValues = ["all", ...visibleCategories];
|
|
259
|
+
const focusableCategory = categoryValues.includes(activeCategory)
|
|
260
|
+
? activeCategory
|
|
261
|
+
: "all";
|
|
262
|
+
|
|
263
|
+
function focusCategory(value: "trigger" | "search" | string) {
|
|
264
|
+
pendingCategoryFocus.current = value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function openCategoryMenu(focus: "search" | string | null = null) {
|
|
268
|
+
setCategoryQuery("");
|
|
269
|
+
if (focus) focusCategory(focus);
|
|
270
|
+
setCategoryMenuOpen(true);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function closeCategoryMenu(restoreFocus = false) {
|
|
274
|
+
if (restoreFocus) focusCategory("trigger");
|
|
275
|
+
setCategoryMenuOpen(false);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function selectCategory(value: string) {
|
|
279
|
+
setCategory(value);
|
|
280
|
+
onCategoryChange?.(value);
|
|
281
|
+
closeCategoryMenu(true);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function moveCategoryFocus(
|
|
285
|
+
event: ReactKeyboardEvent<HTMLButtonElement>,
|
|
286
|
+
value: string,
|
|
287
|
+
) {
|
|
288
|
+
const index = categoryValues.indexOf(value);
|
|
289
|
+
let nextIndex: number | null = null;
|
|
290
|
+
if (event.key === "ArrowDown") nextIndex = (index + 1) % categoryValues.length;
|
|
291
|
+
else if (event.key === "ArrowUp")
|
|
292
|
+
nextIndex = (index - 1 + categoryValues.length) % categoryValues.length;
|
|
293
|
+
else if (event.key === "Home") nextIndex = 0;
|
|
294
|
+
else if (event.key === "End") nextIndex = categoryValues.length - 1;
|
|
295
|
+
|
|
296
|
+
if (nextIndex === null) return;
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
categoryOptionRefs.current[categoryValues[nextIndex]]?.focus();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function handleCategoryTriggerKeyDown(
|
|
302
|
+
event: ReactKeyboardEvent<HTMLButtonElement>,
|
|
303
|
+
) {
|
|
304
|
+
if (event.key === "ArrowDown") {
|
|
305
|
+
event.preventDefault();
|
|
306
|
+
openCategoryMenu(activeCategory === "all" ? "all" : activeCategory);
|
|
307
|
+
} else if (event.key === "ArrowUp") {
|
|
308
|
+
event.preventDefault();
|
|
309
|
+
openCategoryMenu(categoryValues.at(-1) ?? "all");
|
|
310
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
311
|
+
event.preventDefault();
|
|
312
|
+
openCategoryMenu("search");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function resetAuthForm() {
|
|
317
|
+
authRequestRef.current += 1;
|
|
318
|
+
setAuthItem(null);
|
|
319
|
+
setAuthValues({});
|
|
320
|
+
setAuthLoading(false);
|
|
321
|
+
setAuthError(null);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function closeAuthForm() {
|
|
325
|
+
if (authSubmitting) return;
|
|
326
|
+
resetAuthForm();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function prepareConnection(
|
|
330
|
+
item: SuiteIntegrationPickerItem,
|
|
331
|
+
scheme: string,
|
|
332
|
+
) {
|
|
333
|
+
setMethodItem(null);
|
|
334
|
+
setAuthError(null);
|
|
335
|
+
setConnectionError(null);
|
|
336
|
+
setAuthValues({});
|
|
337
|
+
|
|
338
|
+
if (!onFetchAuthFields) {
|
|
339
|
+
try {
|
|
340
|
+
await onConnect(item, { auth_scheme: scheme });
|
|
341
|
+
} catch (cause) {
|
|
342
|
+
setConnectionError(
|
|
343
|
+
cause instanceof Error ? cause.message : "Could not connect the app.",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const requestId = authRequestRef.current + 1;
|
|
350
|
+
authRequestRef.current = requestId;
|
|
351
|
+
setAuthItem({ item, scheme, fields: [] });
|
|
352
|
+
setAuthLoading(true);
|
|
353
|
+
try {
|
|
354
|
+
const details = await onFetchAuthFields(item, scheme);
|
|
355
|
+
if (requestId !== authRequestRef.current) return;
|
|
356
|
+
const resolvedScheme = details.auth_scheme ?? scheme;
|
|
357
|
+
if (details.managed) {
|
|
358
|
+
setAuthItem(null);
|
|
359
|
+
await onConnect(item, { auth_scheme: resolvedScheme });
|
|
360
|
+
} else if (details.fields.length === 0) {
|
|
361
|
+
setAuthItem(null);
|
|
362
|
+
await onConnect(item, {
|
|
363
|
+
auth_scheme: resolvedScheme,
|
|
364
|
+
auth_fields: {},
|
|
365
|
+
});
|
|
366
|
+
} else {
|
|
367
|
+
setAuthItem({ item, scheme: resolvedScheme, fields: details.fields });
|
|
368
|
+
}
|
|
369
|
+
} catch (cause) {
|
|
370
|
+
if (requestId === authRequestRef.current) {
|
|
371
|
+
const message =
|
|
372
|
+
cause instanceof Error
|
|
373
|
+
? cause.message
|
|
374
|
+
: "Could not load authentication details.";
|
|
375
|
+
setAuthError(message);
|
|
376
|
+
setConnectionError(message);
|
|
377
|
+
}
|
|
378
|
+
} finally {
|
|
379
|
+
if (requestId === authRequestRef.current) setAuthLoading(false);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function connectItem(item: SuiteIntegrationPickerItem) {
|
|
384
|
+
setConnectionError(null);
|
|
385
|
+
const schemes = authSchemesFor(item);
|
|
386
|
+
if (schemes.length > 1) {
|
|
387
|
+
setMethodItem(item);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (schemes.length === 1) {
|
|
391
|
+
void prepareConnection(item, schemes[0]);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
void (async () => {
|
|
395
|
+
try {
|
|
396
|
+
await onConnect(item);
|
|
397
|
+
} catch (cause) {
|
|
398
|
+
setConnectionError(
|
|
399
|
+
cause instanceof Error ? cause.message : "Could not connect the app.",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function requestDisconnect(item: SuiteIntegrationPickerItem) {
|
|
406
|
+
setDisconnectError(null);
|
|
407
|
+
setItemToDisconnect(item);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function disconnectItem() {
|
|
411
|
+
if (!onDisconnect || !itemToDisconnect) return;
|
|
412
|
+
setDisconnecting(true);
|
|
413
|
+
setDisconnectError(null);
|
|
414
|
+
try {
|
|
415
|
+
await onDisconnect(itemToDisconnect);
|
|
416
|
+
setItemToDisconnect(null);
|
|
417
|
+
} catch (cause) {
|
|
418
|
+
setDisconnectError(
|
|
419
|
+
cause instanceof Error ? cause.message : "Could not disconnect the app.",
|
|
420
|
+
);
|
|
421
|
+
} finally {
|
|
422
|
+
setDisconnecting(false);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function submitAuthForm(event: FormEvent<HTMLFormElement>) {
|
|
427
|
+
event.preventDefault();
|
|
428
|
+
if (!authItem) return;
|
|
429
|
+
setAuthSubmitting(true);
|
|
430
|
+
setAuthError(null);
|
|
431
|
+
try {
|
|
432
|
+
await onConnect(authItem.item, {
|
|
433
|
+
auth_scheme: authItem.scheme,
|
|
434
|
+
auth_fields: authValues,
|
|
435
|
+
});
|
|
436
|
+
resetAuthForm();
|
|
437
|
+
} catch (cause) {
|
|
438
|
+
setAuthError(
|
|
439
|
+
cause instanceof Error ? cause.message : "Could not connect the app.",
|
|
440
|
+
);
|
|
441
|
+
} finally {
|
|
442
|
+
setAuthSubmitting(false);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (!categoryMenuOpen) return;
|
|
448
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
449
|
+
if (!categoryMenuRef.current?.contains(event.target as Node)) {
|
|
450
|
+
closeCategoryMenu();
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
454
|
+
if (event.key === "Escape") closeCategoryMenu(true);
|
|
455
|
+
};
|
|
456
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
457
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
458
|
+
return () => {
|
|
459
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
460
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
461
|
+
};
|
|
462
|
+
// closeCategoryMenu only updates stable React setters and refs.
|
|
463
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
464
|
+
}, [categoryMenuOpen]);
|
|
465
|
+
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
const target = pendingCategoryFocus.current;
|
|
468
|
+
if (!target) return;
|
|
469
|
+
pendingCategoryFocus.current = null;
|
|
470
|
+
|
|
471
|
+
if (target === "trigger") categoryTriggerRef.current?.focus();
|
|
472
|
+
else if (target === "search") categorySearchRef.current?.focus();
|
|
473
|
+
else categoryOptionRefs.current[target]?.focus();
|
|
474
|
+
}, [activeCategory, categoryMenuOpen, categoryQuery, visibleCategories]);
|
|
475
|
+
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
if (!loadingMore) loadMoreInFlightRef.current = false;
|
|
478
|
+
}, [loadingMore]);
|
|
479
|
+
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
const root = scrollViewportRef.current;
|
|
482
|
+
const sentinel = loadMoreSentinelRef.current;
|
|
483
|
+
if (
|
|
484
|
+
!open ||
|
|
485
|
+
!root ||
|
|
486
|
+
!sentinel ||
|
|
487
|
+
!hasMore ||
|
|
488
|
+
!onEndReached ||
|
|
489
|
+
loading ||
|
|
490
|
+
catalogLoading ||
|
|
491
|
+
loadingMore
|
|
492
|
+
) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const observer = new IntersectionObserver(
|
|
497
|
+
([entry]) => {
|
|
498
|
+
if (!entry.isIntersecting || loadMoreInFlightRef.current) return;
|
|
499
|
+
loadMoreInFlightRef.current = true;
|
|
500
|
+
observer.unobserve(entry.target);
|
|
501
|
+
onEndReached();
|
|
502
|
+
},
|
|
503
|
+
{ root, rootMargin: "0px 0px 80px", threshold: 0 },
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
observer.observe(sentinel);
|
|
507
|
+
return () => observer.disconnect();
|
|
508
|
+
}, [catalogLoading, hasMore, loading, loadingMore, onEndReached, open]);
|
|
509
|
+
|
|
510
|
+
return (
|
|
511
|
+
<>
|
|
512
|
+
<Modal
|
|
513
|
+
open={open}
|
|
514
|
+
onClose={onClose}
|
|
515
|
+
title={title}
|
|
516
|
+
description={description}
|
|
517
|
+
size="xl"
|
|
518
|
+
dataTest={dataTest}
|
|
519
|
+
bodyClassName="!overflow-hidden"
|
|
520
|
+
>
|
|
521
|
+
<div className="space-y-4">
|
|
522
|
+
{connectionError ? (
|
|
523
|
+
<p className="text-sm text-ph-danger" role="alert">
|
|
524
|
+
{connectionError}
|
|
525
|
+
</p>
|
|
526
|
+
) : null}
|
|
527
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
528
|
+
<div className="relative">
|
|
529
|
+
<Search
|
|
530
|
+
className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-ph-subtle"
|
|
531
|
+
aria-hidden
|
|
532
|
+
/>
|
|
533
|
+
<Input
|
|
534
|
+
value={activeSearch}
|
|
535
|
+
onChange={(event) => {
|
|
536
|
+
setQuery(event.target.value);
|
|
537
|
+
onSearchChange?.(event.target.value);
|
|
538
|
+
}}
|
|
539
|
+
placeholder="Search apps..."
|
|
540
|
+
aria-label="Search apps"
|
|
541
|
+
className="pl-10"
|
|
542
|
+
/>
|
|
543
|
+
</div>
|
|
544
|
+
<div ref={categoryMenuRef} className="relative">
|
|
545
|
+
<button
|
|
546
|
+
ref={categoryTriggerRef}
|
|
547
|
+
id={`${dataTest}-category`}
|
|
548
|
+
type="button"
|
|
549
|
+
className="ph-input flex w-full items-center justify-between text-left"
|
|
550
|
+
aria-haspopup="listbox"
|
|
551
|
+
aria-expanded={categoryMenuOpen}
|
|
552
|
+
aria-controls={
|
|
553
|
+
categoryMenuOpen ? `${dataTest}-category-options` : undefined
|
|
554
|
+
}
|
|
555
|
+
aria-label="Filter by category"
|
|
556
|
+
onClick={() => {
|
|
557
|
+
if (categoryMenuOpen) closeCategoryMenu();
|
|
558
|
+
else openCategoryMenu();
|
|
559
|
+
}}
|
|
560
|
+
onKeyDown={handleCategoryTriggerKeyDown}
|
|
561
|
+
>
|
|
562
|
+
<span className="flex items-center gap-2">
|
|
563
|
+
<Filter
|
|
564
|
+
className="h-4 w-4 shrink-0 text-ph-subtle"
|
|
565
|
+
aria-hidden
|
|
566
|
+
/>
|
|
567
|
+
<span>
|
|
568
|
+
{activeCategory === "all"
|
|
569
|
+
? "All apps"
|
|
570
|
+
: categoryLabel(activeCategory)}
|
|
571
|
+
</span>
|
|
572
|
+
</span>
|
|
573
|
+
<NavArrowDown
|
|
574
|
+
className={`h-4 w-4 transition-transform ${categoryMenuOpen ? "rotate-180" : ""}`}
|
|
575
|
+
aria-hidden
|
|
576
|
+
/>
|
|
577
|
+
</button>
|
|
578
|
+
{categoryMenuOpen ? (
|
|
579
|
+
<div
|
|
580
|
+
className="absolute z-20 mt-2 max-h-[min(22rem,calc(100dvh-12rem))] w-full max-w-full overflow-hidden rounded-lg border border-ph-border bg-ph-surface p-2 shadow-lg"
|
|
581
|
+
>
|
|
582
|
+
<Input
|
|
583
|
+
ref={categorySearchRef}
|
|
584
|
+
value={categoryQuery}
|
|
585
|
+
onChange={(event) => setCategoryQuery(event.target.value)}
|
|
586
|
+
placeholder="Search categories..."
|
|
587
|
+
aria-label="Search categories"
|
|
588
|
+
onKeyDown={(event) => {
|
|
589
|
+
if (event.key === "ArrowDown") {
|
|
590
|
+
event.preventDefault();
|
|
591
|
+
categoryOptionRefs.current[categoryValues[0]]?.focus();
|
|
592
|
+
} else if (event.key === "Escape") {
|
|
593
|
+
event.preventDefault();
|
|
594
|
+
closeCategoryMenu(true);
|
|
595
|
+
}
|
|
596
|
+
}}
|
|
597
|
+
/>
|
|
598
|
+
<div
|
|
599
|
+
id={`${dataTest}-category-options`}
|
|
600
|
+
role="listbox"
|
|
601
|
+
aria-label="Integration categories"
|
|
602
|
+
className="mt-2 max-h-52 overflow-y-auto"
|
|
603
|
+
data-test="integration-category-options"
|
|
604
|
+
>
|
|
605
|
+
{categoryValues.map((value) => (
|
|
606
|
+
<button
|
|
607
|
+
key={value}
|
|
608
|
+
type="button"
|
|
609
|
+
role="option"
|
|
610
|
+
aria-selected={activeCategory === value}
|
|
611
|
+
tabIndex={value === focusableCategory ? 0 : -1}
|
|
612
|
+
ref={(element) => {
|
|
613
|
+
categoryOptionRefs.current[value] = element;
|
|
614
|
+
}}
|
|
615
|
+
className={`flex w-full min-w-0 items-center rounded-md px-3 py-2 text-left text-sm break-words ${activeCategory === value ? "bg-ph-brand text-[var(--ph-on-accent)]" : "text-ph-ink hover:bg-ph-muted"}`}
|
|
616
|
+
onClick={() => selectCategory(value)}
|
|
617
|
+
onKeyDown={(event) => {
|
|
618
|
+
if (event.key === "Escape") {
|
|
619
|
+
event.preventDefault();
|
|
620
|
+
closeCategoryMenu(true);
|
|
621
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
622
|
+
event.preventDefault();
|
|
623
|
+
selectCategory(value);
|
|
624
|
+
} else moveCategoryFocus(event, value);
|
|
625
|
+
}}
|
|
626
|
+
>
|
|
627
|
+
{value === "all" ? "All apps" : categoryLabel(value)}
|
|
628
|
+
</button>
|
|
629
|
+
))}
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
) : null}
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<div
|
|
637
|
+
ref={scrollViewportRef}
|
|
638
|
+
className="min-h-0 max-h-[min(60vh,32rem)] w-full overflow-y-auto pr-1"
|
|
639
|
+
>
|
|
640
|
+
{loading || catalogLoading ? (
|
|
641
|
+
<div
|
|
642
|
+
className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
|
643
|
+
aria-label="Loading apps"
|
|
644
|
+
>
|
|
645
|
+
{Array.from({ length: SKELETON_COUNT }, (_, index) => (
|
|
646
|
+
<IntegrationSkeletonCard key={index} />
|
|
647
|
+
))}
|
|
648
|
+
</div>
|
|
649
|
+
) : (
|
|
650
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
651
|
+
{filtered.map((item) => {
|
|
652
|
+
const isExpanded = expanded === item.id;
|
|
653
|
+
const longDescription =
|
|
654
|
+
item.description.length > DESCRIPTION_LIMIT;
|
|
655
|
+
const text =
|
|
656
|
+
isExpanded || !longDescription
|
|
657
|
+
? item.description
|
|
658
|
+
: `${item.description.slice(0, DESCRIPTION_LIMIT).trimEnd()}...`;
|
|
659
|
+
return (
|
|
660
|
+
<div
|
|
661
|
+
key={item.id}
|
|
662
|
+
className="flex min-h-36 min-w-0 flex-col justify-between rounded-xl border border-ph-border bg-ph-surface p-4"
|
|
663
|
+
>
|
|
664
|
+
<button
|
|
665
|
+
type="button"
|
|
666
|
+
className="min-w-0 text-left"
|
|
667
|
+
onClick={() =>
|
|
668
|
+
longDescription &&
|
|
669
|
+
setExpanded(isExpanded ? null : item.id)
|
|
670
|
+
}
|
|
671
|
+
aria-expanded={isExpanded}
|
|
672
|
+
>
|
|
673
|
+
<span className="flex items-start gap-3">
|
|
674
|
+
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ph-muted text-ph-ink">
|
|
675
|
+
{item.icon ?? (
|
|
676
|
+
<Plug className="h-5 w-5" aria-hidden />
|
|
677
|
+
)}
|
|
678
|
+
</span>
|
|
679
|
+
<span className="min-w-0">
|
|
680
|
+
<span className="block truncate font-medium text-ph-ink">
|
|
681
|
+
{item.label}
|
|
682
|
+
</span>
|
|
683
|
+
<span className="mt-1 block text-xs text-ph-subtle">
|
|
684
|
+
{text}
|
|
685
|
+
</span>
|
|
686
|
+
{longDescription ? (
|
|
687
|
+
<span className="mt-1 block text-xs font-medium text-ph-brand">
|
|
688
|
+
{isExpanded ? "See less" : "See more"}{" "}
|
|
689
|
+
{isExpanded ? (
|
|
690
|
+
<NavArrowUp className="inline h-3 w-3" />
|
|
691
|
+
) : (
|
|
692
|
+
<NavArrowDown className="inline h-3 w-3" />
|
|
693
|
+
)}
|
|
694
|
+
</span>
|
|
695
|
+
) : null}
|
|
696
|
+
</span>
|
|
697
|
+
</span>
|
|
698
|
+
</button>
|
|
699
|
+
{suiteAppsForIntegration(item.categories).length > 0 ? (
|
|
700
|
+
<div
|
|
701
|
+
className="mt-3 flex flex-wrap items-center gap-1.5"
|
|
702
|
+
aria-label="Works with ShellStack apps"
|
|
703
|
+
>
|
|
704
|
+
{suiteAppsForIntegration(item.categories).map(
|
|
705
|
+
(app) => (
|
|
706
|
+
<span
|
|
707
|
+
key={app}
|
|
708
|
+
data-test={`${dataTest}-${item.id}-app-${app}`}
|
|
709
|
+
>
|
|
710
|
+
<SuiteAppIcon
|
|
711
|
+
app={app}
|
|
712
|
+
size={16}
|
|
713
|
+
label={`Works with ${SUITE_APP_MAP[app].name}`}
|
|
714
|
+
className="rounded-[4px]"
|
|
715
|
+
/>
|
|
716
|
+
</span>
|
|
717
|
+
),
|
|
718
|
+
)}
|
|
719
|
+
</div>
|
|
720
|
+
) : null}
|
|
721
|
+
{authSchemesFor(item).length > 0 ? (
|
|
722
|
+
<div
|
|
723
|
+
className="mt-3 flex flex-wrap gap-1.5"
|
|
724
|
+
aria-label="Connection methods"
|
|
725
|
+
>
|
|
726
|
+
{authSchemesFor(item).map((scheme) => (
|
|
727
|
+
<span
|
|
728
|
+
key={scheme}
|
|
729
|
+
className="rounded-full border border-ph-border bg-ph-muted px-2 py-1 text-[0.6875rem] font-medium text-ph-subtle"
|
|
730
|
+
data-test={`${dataTest}-${item.id}-auth-${scheme.toLowerCase()}`}
|
|
731
|
+
>
|
|
732
|
+
{authSchemeLabel(scheme)}
|
|
733
|
+
</span>
|
|
734
|
+
))}
|
|
735
|
+
</div>
|
|
736
|
+
) : null}
|
|
737
|
+
<Button
|
|
738
|
+
type="button"
|
|
739
|
+
variant="primary"
|
|
740
|
+
className="mt-3 self-start"
|
|
741
|
+
disabled={
|
|
742
|
+
Boolean(item.unavailableReason) || item.connecting
|
|
743
|
+
}
|
|
744
|
+
onClick={() => connectItem(item)}
|
|
745
|
+
>
|
|
746
|
+
<span className="flex items-center gap-1.5">
|
|
747
|
+
<Plug className="h-3.5 w-3.5" aria-hidden />
|
|
748
|
+
{item.connecting
|
|
749
|
+
? "Connecting..."
|
|
750
|
+
: (item.unavailableReason ?? "Connect")}
|
|
751
|
+
</span>
|
|
752
|
+
</Button>
|
|
753
|
+
</div>
|
|
754
|
+
);
|
|
755
|
+
})}
|
|
756
|
+
{loadingMore
|
|
757
|
+
? Array.from({ length: 3 }, (_, index) => (
|
|
758
|
+
<IntegrationSkeletonCard key={`loading-${index}`} />
|
|
759
|
+
))
|
|
760
|
+
: null}
|
|
761
|
+
</div>
|
|
762
|
+
)}
|
|
763
|
+
<div ref={loadMoreSentinelRef} className="h-1" aria-hidden="true" />
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
{connectedItems.length > 0 && onDisconnect ? (
|
|
767
|
+
<div className="space-y-2 border-t border-ph-border pt-4">
|
|
768
|
+
<h3 className="font-medium text-ph-ink">Connected apps</h3>
|
|
769
|
+
{connectedItems.map((item) => (
|
|
770
|
+
<div
|
|
771
|
+
key={item.id}
|
|
772
|
+
className="flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted p-3"
|
|
773
|
+
>
|
|
774
|
+
<span className="flex min-w-0 items-center gap-2 text-sm text-ph-ink">
|
|
775
|
+
{item.icon}
|
|
776
|
+
<span className="truncate">{item.label}</span>
|
|
777
|
+
</span>
|
|
778
|
+
<Button
|
|
779
|
+
type="button"
|
|
780
|
+
variant="danger"
|
|
781
|
+
onClick={() => requestDisconnect(item)}
|
|
782
|
+
>
|
|
783
|
+
<span className="flex items-center gap-1.5">
|
|
784
|
+
<Xmark className="h-4 w-4" aria-hidden />
|
|
785
|
+
Disconnect
|
|
786
|
+
</span>
|
|
787
|
+
</Button>
|
|
788
|
+
</div>
|
|
789
|
+
))}
|
|
790
|
+
</div>
|
|
791
|
+
) : null}
|
|
792
|
+
</div>
|
|
793
|
+
</Modal>
|
|
794
|
+
|
|
795
|
+
<Modal
|
|
796
|
+
open={itemToDisconnect !== null}
|
|
797
|
+
onClose={() => {
|
|
798
|
+
if (!disconnecting) setItemToDisconnect(null);
|
|
799
|
+
}}
|
|
800
|
+
title="Disconnect app"
|
|
801
|
+
description="This will stop syncing data from this app."
|
|
802
|
+
size="sm"
|
|
803
|
+
zIndex={220}
|
|
804
|
+
dataTest={`${dataTest}-disconnect-confirmation`}
|
|
805
|
+
footer={
|
|
806
|
+
<div className="flex justify-end gap-2">
|
|
807
|
+
<Button
|
|
808
|
+
type="button"
|
|
809
|
+
variant="tertiary"
|
|
810
|
+
onClick={() => setItemToDisconnect(null)}
|
|
811
|
+
disabled={disconnecting}
|
|
812
|
+
>
|
|
813
|
+
Cancel
|
|
814
|
+
</Button>
|
|
815
|
+
<Button
|
|
816
|
+
type="button"
|
|
817
|
+
variant="danger"
|
|
818
|
+
onClick={() => void disconnectItem()}
|
|
819
|
+
disabled={disconnecting}
|
|
820
|
+
data-test={`${dataTest}-disconnect-confirm`}
|
|
821
|
+
>
|
|
822
|
+
{disconnecting ? "Disconnecting..." : "Disconnect app"}
|
|
823
|
+
</Button>
|
|
824
|
+
</div>
|
|
825
|
+
}
|
|
826
|
+
>
|
|
827
|
+
{disconnectError ? (
|
|
828
|
+
<p className="text-sm text-ph-danger" role="alert">
|
|
829
|
+
{disconnectError}
|
|
830
|
+
</p>
|
|
831
|
+
) : null}
|
|
832
|
+
<p className="text-sm text-ph-ink">
|
|
833
|
+
Disconnect {itemToDisconnect?.label}? You can reconnect it later.
|
|
834
|
+
</p>
|
|
835
|
+
</Modal>
|
|
836
|
+
|
|
837
|
+
<Modal
|
|
838
|
+
open={Boolean(methodItem)}
|
|
839
|
+
onClose={() => setMethodItem(null)}
|
|
840
|
+
title={
|
|
841
|
+
methodItem
|
|
842
|
+
? `Connect ${methodItem.label}`
|
|
843
|
+
: "Choose connection method"
|
|
844
|
+
}
|
|
845
|
+
description="Choose how you want to connect this app."
|
|
846
|
+
size="sm"
|
|
847
|
+
zIndex={200}
|
|
848
|
+
dataTest={`${dataTest}-auth-method`}
|
|
849
|
+
>
|
|
850
|
+
{methodItem ? (
|
|
851
|
+
<div className="space-y-2">
|
|
852
|
+
{authSchemesFor(methodItem).map((scheme) => (
|
|
853
|
+
<button
|
|
854
|
+
key={scheme}
|
|
855
|
+
type="button"
|
|
856
|
+
className="flex w-full items-center justify-between rounded-lg border border-ph-border bg-ph-surface px-3 py-3 text-left text-sm text-ph-ink transition hover:border-ph-brand hover:bg-ph-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ph-brand"
|
|
857
|
+
onClick={() => void prepareConnection(methodItem, scheme)}
|
|
858
|
+
data-test={`${dataTest}-choose-${scheme.toLowerCase()}`}
|
|
859
|
+
>
|
|
860
|
+
<span className="font-medium">{authSchemeLabel(scheme)}</span>
|
|
861
|
+
<span className="text-xs text-ph-subtle">Select</span>
|
|
862
|
+
</button>
|
|
863
|
+
))}
|
|
864
|
+
</div>
|
|
865
|
+
) : null}
|
|
866
|
+
</Modal>
|
|
867
|
+
|
|
868
|
+
<Modal
|
|
869
|
+
open={Boolean(authItem)}
|
|
870
|
+
onClose={closeAuthForm}
|
|
871
|
+
title={
|
|
872
|
+
authItem ? `${authItem.item.label} authentication` : "Authentication"
|
|
873
|
+
}
|
|
874
|
+
description="Enter the credentials required by Composio. They are sent securely to the ShellStack API."
|
|
875
|
+
size="md"
|
|
876
|
+
zIndex={210}
|
|
877
|
+
dataTest={`${dataTest}-auth-form`}
|
|
878
|
+
>
|
|
879
|
+
{authLoading ? (
|
|
880
|
+
<p className="text-sm text-ph-subtle">
|
|
881
|
+
Loading authentication fields...
|
|
882
|
+
</p>
|
|
883
|
+
) : authItem ? (
|
|
884
|
+
<form
|
|
885
|
+
className="space-y-4"
|
|
886
|
+
onSubmit={(event) => void submitAuthForm(event)}
|
|
887
|
+
>
|
|
888
|
+
{authError ? (
|
|
889
|
+
<p className="text-sm text-ph-danger" role="alert">
|
|
890
|
+
{authError}
|
|
891
|
+
</p>
|
|
892
|
+
) : null}
|
|
893
|
+
{authItem.fields.length === 0 && authError ? (
|
|
894
|
+
<Button
|
|
895
|
+
type="button"
|
|
896
|
+
variant="tertiary"
|
|
897
|
+
onClick={() =>
|
|
898
|
+
void prepareConnection(authItem.item, authItem.scheme)
|
|
899
|
+
}
|
|
900
|
+
>
|
|
901
|
+
Try again
|
|
902
|
+
</Button>
|
|
903
|
+
) : null}
|
|
904
|
+
{authItem.fields.map((field) => (
|
|
905
|
+
<Input
|
|
906
|
+
key={field.name}
|
|
907
|
+
label={field.label}
|
|
908
|
+
type={field.type ?? "password"}
|
|
909
|
+
required
|
|
910
|
+
value={authValues[field.name] ?? ""}
|
|
911
|
+
onChange={(event) =>
|
|
912
|
+
setAuthValues((current) => ({
|
|
913
|
+
...current,
|
|
914
|
+
[field.name]: event.target.value,
|
|
915
|
+
}))
|
|
916
|
+
}
|
|
917
|
+
autoComplete="off"
|
|
918
|
+
data-test={`${dataTest}-auth-field-${field.name}`}
|
|
919
|
+
/>
|
|
920
|
+
))}
|
|
921
|
+
<div className="flex justify-end gap-2 border-t border-ph-border pt-4">
|
|
922
|
+
<Button
|
|
923
|
+
type="button"
|
|
924
|
+
variant="tertiary"
|
|
925
|
+
onClick={closeAuthForm}
|
|
926
|
+
disabled={authSubmitting}
|
|
927
|
+
>
|
|
928
|
+
Cancel
|
|
929
|
+
</Button>
|
|
930
|
+
<Button type="submit" variant="primary" disabled={authSubmitting}>
|
|
931
|
+
{authSubmitting ? "Connecting..." : "Connect"}
|
|
932
|
+
</Button>
|
|
933
|
+
</div>
|
|
934
|
+
</form>
|
|
935
|
+
) : null}
|
|
936
|
+
</Modal>
|
|
937
|
+
</>
|
|
938
|
+
);
|
|
939
|
+
}
|