"use client"; import type { CSSProperties, KeyboardEvent, ReactNode } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Accordion, ActionIcon, Alert, Anchor, Avatar, Badge, Button, Checkbox, Code, ColorSwatch, CopyButton, Fieldset, Group, Paper, PasswordInput, Popover, Progress, Select, SimpleGrid, Slider, Stack, Switch, Tabs, Text, TextInput, ThemeIcon, Timeline, Title, UnstyledButton, useMantineColorScheme, } from "@mantine/core"; import { IconAdjustments, IconAlertCircle, IconApps, IconArrowLeft, IconArrowRight, IconBrandDocker, IconBorderRadius, IconCheck, IconCode, IconCopy, IconDatabase, IconDeviceMobile, IconExternalLink, IconInfoCircle, IconKey, IconLanguage, IconLayoutColumns, IconLayoutDashboard, IconLayoutSidebar, IconPalette, IconPlugConnected, IconRefresh, IconRobot, IconSearch, IconServer, IconSparkles, IconSunMoon, IconX, } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import type { RouterOutputs } from "@homarr/api"; import { revalidatePathActionAsync } from "@homarr/common/client"; import type { ColorScheme, IntegrationKind, IntegrationSecretKind, UrlTemplateMode, WidgetKind, } from "@homarr/definitions"; import { buildAppUrl, buildIntegrationUrl, getAllSecretKindOptions, getDefaultWidgetConfig, getIntegrationApiKeyUrl, getIntegrationDefaultUrl, getIntegrationDocumentationUrl, getIntegrationName, getWidgetKindsForIntegration, generalWidgets, } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications"; import type { SupportedLanguage } from "@homarr/translation"; import { useCurrentLocale, useI18n } from "@homarr/translation/client"; import type { CornerStyle } from "@homarr/ui"; import { BoardColorInput, ColorSchemeCombobox, CornerStylePicker, cornerStyleValues, IntegrationAvatar, LanguageCombobox, Link, } from "@homarr/ui"; import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid"; import type { OnboardingStudioProps } from "./types"; import { getBoardValidationErrors } from "./board-validation"; import { OnboardingBackdrop } from "./onboarding-backdrop"; import { OnboardingWordmark } from "./onboarding-wordmark"; import { OnboardingFloatingControl, ServiceUrlTemplate } from "./service-url-template"; import { useOnboardingSounds } from "./use-onboarding-sounds"; import { normalizeServiceUrl, resolveDiscoveredAppUrl, resolveIntegrationDraftUrl, takeNewSourceIds, } from "./discovery-selection"; import classes from "./onboarding-studio.module.css"; type StudioSection = "essentials" | "discover" | "connect" | "board" | "extend" | "review"; type LayoutPreset = "balanced" | "wide" | "focused"; const getLayoutPresetForColumnCount = (columnCount: number): LayoutPreset => columnCount <= 8 ? "focused" : columnCount <= 10 ? "balanced" : "wide"; interface IntegrationDraft { id: string; sourceId?: string; kind: IntegrationKind; name: string; url: string; urlOverridden: boolean; source: "manual" | "docker"; secretOption: number; secrets: { kind: IntegrationSecretKind; value: string }[]; } const isIntegrationDraftComplete = (draft: IntegrationDraft) => draft.url.trim().length > 0 && draft.secrets.every((secret) => secret.value.trim().length > 0); type DockerDiscoveryData = RouterOutputs["onboard"]["discoverDockerServices"]; type RuntimeCapabilitiesQuery = Omit< ReturnType, "data" > & { data: RouterOutputs["onboard"]["detectRuntimeCapabilities"] | undefined; }; const sectionDefinitions = [ { id: "essentials", icon: IconAdjustments }, { id: "discover", icon: IconSearch }, { id: "connect", icon: IconPlugConnected }, { id: "board", icon: IconLayoutDashboard }, { id: "extend", icon: IconSparkles }, { id: "review", icon: IconCheck }, ] as const; const initialPrimaryColor = "#fa5252"; const initialSecondaryColor = "#fd7e14"; const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = []; const emptyDiscoveredApps: DockerDiscoveryData["apps"] = []; export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingStudioProps) => { const t = useI18n("init.studio"); const tCommon = useI18n("common.action"); const currentLocale = useCurrentLocale(); const { colorScheme, setColorScheme } = useMantineColorScheme(); const sounds = useOnboardingSounds(); const [activeSection, setActiveSection] = useState("essentials"); const [incompleteIntegrationConfirmationOpened, setIncompleteIntegrationConfirmationOpened] = useState(false); const [selectedLocale, setSelectedLocale] = useState(currentLocale); const [serverOrigin, setServerOrigin] = useState(environment.serverOrigin); const [urlMode, setUrlMode] = useState("hostPort"); const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [selectedKinds, setSelectedKinds] = useState([]); const [selectedIntegrationSourceIds, setSelectedIntegrationSourceIds] = useState([]); const [drafts, setDrafts] = useState([]); const [selectedAppIds, setSelectedAppIds] = useState([]); const [appUrlOverrides, setAppUrlOverrides] = useState>({}); const [appErrorSourceId, setAppErrorSourceId] = useState(null); const initialBoard = environment.initialBoard ?? (environment.availableBoards.length === 1 ? environment.availableBoards[0] : null); const [selectedBoardId, setSelectedBoardId] = useState(initialBoard?.id ?? null); const [boardName, setBoardName] = useState(initialBoard?.name ?? "dashboard"); const [primaryColor, setPrimaryColor] = useState(initialPrimaryColor); const [secondaryColor, setSecondaryColor] = useState(initialSecondaryColor); const [itemRadius, setItemRadius] = useState("lg"); const [columnCount, setColumnCount] = useState(10); const [leftSidebar, setLeftSidebar] = useState(false); const [rightSidebar, setRightSidebar] = useState(false); const [boardValidationAttempted, setBoardValidationAttempted] = useState(false); const [isApplying, setIsApplying] = useState(false); const [applyProgress, setApplyProgress] = useState(0); const [applyMessage, setApplyMessage] = useState(""); const [appError, setAppError] = useState(null); const sectionHeadingRef = useRef(null); const sectionButtonRefs = useRef<(HTMLButtonElement | null)[]>([]); const initialSection = useRef(true); const focusSectionHeading = useRef(true); const seenIntegrationSourceIds = useRef(new Set()); const seenAppSourceIds = useRef(new Set()); const docker = clientApi.onboard.discoverDockerServices.useQuery(undefined, { enabled: environment.dockerConfigured, retry: false, refetchOnWindowFocus: false, }); const runtimeCapabilities = clientApi.onboard.detectRuntimeCapabilities.useQuery(undefined, { retry: false, refetchOnWindowFocus: false, }); const complete = clientApi.onboard.completeSetup.useMutation({ onError(error) { showErrorNotification({ title: t("review.errorTitle"), message: error.message }); }, }); const dockerData = docker.data; const discoveredIntegrations = dockerData?.integrations ?? emptyDiscoveredIntegrations; const discoveredApps = dockerData?.apps ?? emptyDiscoveredApps; const discoveredAppUrls = useMemo( () => Object.fromEntries( discoveredApps.map((app) => [ app.sourceId, resolveDiscoveredAppUrl( appUrlOverrides[app.sourceId], app.suggestedUrl, serverOrigin ? buildAppUrl( app.detectedType ?? app.containerName, serverOrigin, urlMode, app.publishedPort ?? undefined, ) : null, ), ]), ), [appUrlOverrides, discoveredApps, serverOrigin, urlMode], ); const detectedKinds = useMemo( () => new Set(discoveredIntegrations.map((integration) => integration.kind)), [discoveredIntegrations], ); useEffect(() => { if (!docker.data) return; const newIntegrationSourceIds = takeNewSourceIds( discoveredIntegrations.map((integration) => integration.sourceId), seenIntegrationSourceIds.current, ); if (newIntegrationSourceIds.length > 0) { setSelectedIntegrationSourceIds((current) => [...new Set([...current, ...newIntegrationSourceIds])]); } const newAppSourceIds = takeNewSourceIds( discoveredApps.map((application) => application.sourceId), seenAppSourceIds.current, ); if (newAppSourceIds.length > 0) { setSelectedAppIds((current) => [...new Set([...current, ...newAppSourceIds])]); } }, [docker.data, discoveredApps, discoveredIntegrations]); useEffect(() => { setDrafts((current) => { const next: IntegrationDraft[] = []; const existing = new Map(current.map((draft) => [draft.id, draft])); for (const discovered of discoveredIntegrations) { if (!selectedIntegrationSourceIds.includes(discovered.sourceId)) continue; const id = discovered.sourceId; const existingDraft = existing.get(id); const generatedUrl = resolveIntegrationDraftUrl({ currentUrl: existingDraft?.url ?? "", overridden: existingDraft?.urlOverridden ?? false, serverUrl: serverOrigin ? buildIntegrationUrl(discovered.kind, serverOrigin, urlMode, discovered.publishedPort ?? undefined) : null, fallbackUrl: discovered.suggestedUrl, }); next.push( existingDraft ? { ...existingDraft, url: generatedUrl } : createDraft({ id, sourceId: discovered.sourceId, kind: discovered.kind, name: discovered.containerName, url: generatedUrl, source: "docker", }), ); } for (const kind of selectedKinds) { const id = `manual:${kind}`; const existingDraft = existing.get(id); const generatedUrl = resolveIntegrationDraftUrl({ currentUrl: existingDraft?.url ?? "", overridden: existingDraft?.urlOverridden ?? false, serverUrl: serverOrigin ? buildIntegrationUrl(kind, serverOrigin, urlMode) : null, fallbackUrl: getIntegrationDefaultUrl(kind) ?? null, }); next.push( existingDraft ? { ...existingDraft, url: generatedUrl } : createDraft({ id, sourceId: id, kind, name: getIntegrationName(kind), url: generatedUrl ?? "", source: "manual", }), ); } return next; }); }, [discoveredIntegrations, selectedIntegrationSourceIds, selectedKinds, serverOrigin, urlMode]); useEffect(() => { if (initialSection.current) { initialSection.current = false; return; } if (!focusSectionHeading.current) { focusSectionHeading.current = true; return; } sectionHeadingRef.current?.focus(); }, [activeSection]); const activeIndex = sectionDefinitions.findIndex((section) => section.id === activeSection); const move = (direction: -1 | 1) => { const next = sectionDefinitions[activeIndex + direction]; if (next) selectSection(next.id); }; const selectSection = (section: StudioSection) => { if (section !== activeSection) sounds.swoosh(); setActiveSection(section); }; const handleSectionKeyDown = (event: KeyboardEvent, index: number) => { const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? sectionDefinitions.length - 1 : event.key === "ArrowRight" || event.key === "ArrowDown" ? (index + 1) % sectionDefinitions.length : event.key === "ArrowLeft" || event.key === "ArrowUp" ? (index - 1 + sectionDefinitions.length) % sectionDefinitions.length : null; if (nextIndex === null) return; event.preventDefault(); const nextSection = sectionDefinitions[nextIndex]; if (!nextSection) return; focusSectionHeading.current = false; selectSection(nextSection.id); sectionButtonRefs.current[nextIndex]?.focus(); }; const updateDraft = (id: string, patch: Partial) => { setDrafts((current) => current.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft))); }; const selectSecretOption = (draft: IntegrationDraft, option: number) => { const secretKinds = getAllSecretKindOptions(draft.kind)[option] ?? []; updateDraft(draft.id, { secretOption: option, secrets: secretKinds.map((kind) => ({ kind, value: draft.secrets.find((secret) => secret.kind === kind)?.value ?? "", })), }); }; const applySetupAsync = async () => { if (isApplying) return; setIsApplying(true); setAppError(null); setAppErrorSourceId(null); setApplyProgress(5); setBoardValidationAttempted(true); try { if (!selectedBoardId && environment.availableBoards.length > 0) { setActiveSection("board"); throw new Error(t("board.targetRequired")); } if (!boardName.trim()) { setActiveSection("board"); throw new Error(t("board.nameDescription")); } const draftsToApply = drafts.filter(isIntegrationDraftComplete); const appsToCreate = discoveredApps.filter((app) => selectedAppIds.includes(app.sourceId)); const appWithoutAddress = appsToCreate.find((app) => (discoveredAppUrls[app.sourceId] ?? "").trim().length === 0); if (appWithoutAddress) { const message = t("connect.appAddressRequired", { name: appWithoutAddress.containerName }); setAppError(message); setAppErrorSourceId(appWithoutAddress.sourceId); setActiveSection("connect"); throw new Error(message); } setApplyMessage(t("review.progress.applying")); setApplyProgress(20); await new Promise((resolve) => setTimeout(resolve, 300)); const selectedSourceIds = new Set(draftsToApply.flatMap((draft) => (draft.sourceId ? [draft.sourceId] : []))); const completion = await complete.mutateAsync({ server: { defaultLocale: selectedLocale, defaultColorScheme: colorScheme as ColorScheme, analyticsEnabled, }, board: { id: selectedBoardId ?? undefined, name: boardName, primaryColor, secondaryColor, itemRadius, layoutPreset: getLayoutPresetForColumnCount(columnCount), columnCount, leftSidebar, rightSidebar, }, integrations: draftsToApply.map((draft) => { const discovered = draft.sourceId ? discoveredIntegrations.find((integration) => integration.sourceId === draft.sourceId) : undefined; return { sourceId: draft.sourceId ?? draft.id, name: draft.name, url: normalizeServiceUrl(draft.url) ?? draft.url, kind: draft.kind, secrets: draft.secrets, iconUrl: discovered?.iconUrl ?? null, description: discovered?.description ?? null, pingUrl: discovered?.pingUrl ?? null, }; }), apps: appsToCreate.flatMap((app) => { const enteredAddress = (discoveredAppUrls[app.sourceId] ?? "").trim(); const href = normalizeServiceUrl(enteredAddress) ?? enteredAddress; return href ? [ { sourceId: app.sourceId, name: app.containerName, href, pingUrl: app.pingUrl ?? null, iconUrl: app.iconUrl, description: app.description ?? null, }, ] : []; }), selectedIntegrationIds: [], selectedAppIds: [], selectedDockerSourceIds: [ ...new Set([ ...discoveredIntegrations .filter((integration) => integration.source === "label" && selectedSourceIds.has(integration.sourceId)) .map((integration) => integration.sourceId), ...appsToCreate .filter((application) => application.source === "label") .map((application) => application.sourceId), ]), ], selectedWidgetKinds: [ ...new Set( [ ...generalWidgets, ...discoveredIntegrations.filter((integration) => selectedSourceIds.has(integration.sourceId)), ...appsToCreate, ].flatMap((selection) => typeof selection === "string" ? [selection] : selection.widgetKind ? [selection.widgetKind] : [], ), ), ], }); const dockerWarningCount = completion.docker.missingSourceIds.length + completion.docker.ignoredSourceIds.length + completion.docker.skippedWidgets.length; if (dockerWarningCount > 0) { sounds.warning(); showWarningNotification({ title: t("review.dockerWarningTitle"), message: t("review.dockerWarningDescription", { count: dockerWarningCount }), }); } else sounds.success(); setApplyMessage(t("review.progress.done")); setApplyProgress(100); await revalidatePathActionAsync("/init"); } catch { sounds.error(); setApplyProgress(0); setIsApplying(false); } }; const sectionProps: StudioSectionProps = { headingRef: sectionHeadingRef, environment, assistantConfiguration, serverOrigin, setServerOrigin, urlMode, setUrlMode, analyticsEnabled, setAnalyticsEnabled: (value) => { sounds.toggle(value); setAnalyticsEnabled(value); }, selectedLocale, setSelectedLocale: (value) => { sounds.click(); setSelectedLocale(value); }, colorScheme: colorScheme as ColorScheme, setColorScheme, docker, dockerData, runtimeCapabilities, selectedKinds, setSelectedKinds: (value) => { sounds.click(); setSelectedKinds(value); }, discoveredIntegrations, selectedIntegrationSourceIds, setSelectedIntegrationSourceIds: (value) => { sounds.click(); setSelectedIntegrationSourceIds(value); }, detectedKinds, drafts, updateDraft, selectSecretOption, discoveredApps, discoveredAppUrls, setDiscoveredAppUrl: (sourceId, url) => { setAppUrlOverrides((current) => ({ ...current, [sourceId]: url })); if (appErrorSourceId === sourceId) { setAppErrorSourceId(null); setAppError(null); } }, selectedAppIds, setSelectedAppIds: (value) => { sounds.click(); setSelectedAppIds(value); }, boardName, setBoardName, selectedBoardId, boardValidationAttempted, setSelectedBoardId: (value) => { sounds.click(); setSelectedBoardId(value); }, primaryColor, setPrimaryColor, secondaryColor, setSecondaryColor, itemRadius, setItemRadius, columnCount, setColumnCount, leftSidebar, setLeftSidebar: (value) => { sounds.toggle(value); setLeftSidebar(value); }, rightSidebar, setRightSidebar: (value) => { sounds.toggle(value); setRightSidebar(value); }, applyProgress, applyMessage, appError, appErrorSourceId, }; const hasIncompleteIntegrations = drafts.some((draft) => !isIntegrationDraftComplete(draft)); const selectedAppWithoutAddress = discoveredApps.find( (app) => selectedAppIds.includes(app.sourceId) && (discoveredAppUrls[app.sourceId] ?? "").trim().length === 0, ); const continueDisabled = isApplying || (activeSection === "essentials" && serverOrigin.trim().length === 0); const continueOnboarding = () => { if (continueDisabled) return; if (activeSection === "review") { void applySetupAsync(); } else if (activeSection === "connect") { if (selectedAppWithoutAddress) { setAppError(t("connect.appAddressRequired", { name: selectedAppWithoutAddress.containerName })); setAppErrorSourceId(selectedAppWithoutAddress.sourceId); return; } setAppError(null); setAppErrorSourceId(null); if (hasIncompleteIntegrations) { setIncompleteIntegrationConfirmationOpened(true); } else { move(1); } } else { move(1); } }; return (
{activeSection === "review" ? ( ) : ( {activeSection === "connect" && hasIncompleteIntegrations ? ( setIncompleteIntegrationConfirmationOpened(false)} transitionProps={{ duration: 0 }} position="top-end" width={320} shadow="md" withArrow trapFocus > {t("connect.incompleteConfirmation")} ) : ( )} )}
); }; interface StudioSectionProps { headingRef: React.RefObject; environment: OnboardingStudioProps["environment"]; assistantConfiguration: ReactNode; serverOrigin: string; setServerOrigin: (value: string) => void; urlMode: UrlTemplateMode; setUrlMode: (value: UrlTemplateMode) => void; analyticsEnabled: boolean; setAnalyticsEnabled: (value: boolean) => void; selectedLocale: SupportedLanguage; setSelectedLocale: (value: SupportedLanguage) => void; colorScheme: ColorScheme; setColorScheme: (value: ColorScheme) => void; docker: ReturnType; dockerData: DockerDiscoveryData | undefined; runtimeCapabilities: RuntimeCapabilitiesQuery; selectedKinds: IntegrationKind[]; setSelectedKinds: (value: IntegrationKind[]) => void; discoveredIntegrations: DockerDiscoveryData["integrations"]; selectedIntegrationSourceIds: string[]; setSelectedIntegrationSourceIds: (value: string[]) => void; detectedKinds: Set; drafts: IntegrationDraft[]; updateDraft: (id: string, patch: Partial) => void; selectSecretOption: (draft: IntegrationDraft, option: number) => void; discoveredApps: DockerDiscoveryData["apps"]; discoveredAppUrls: Record; setDiscoveredAppUrl: (sourceId: string, url: string) => void; selectedAppIds: string[]; setSelectedAppIds: (value: string[]) => void; boardName: string; setBoardName: (value: string) => void; selectedBoardId: string | null; boardValidationAttempted: boolean; setSelectedBoardId: (value: string | null) => void; primaryColor: string; setPrimaryColor: (value: string) => void; secondaryColor: string; setSecondaryColor: (value: string) => void; itemRadius: CornerStyle; setItemRadius: (value: CornerStyle) => void; columnCount: number; setColumnCount: (value: number) => void; leftSidebar: boolean; setLeftSidebar: (value: boolean) => void; rightSidebar: boolean; setRightSidebar: (value: boolean) => void; applyProgress: number; applyMessage: string; appError: string | null; appErrorSourceId: string | null; } const StudioSectionContent = ({ section, ...props }: StudioSectionProps & { section: StudioSection }) => { switch (section) { case "essentials": return ; case "discover": return ; case "connect": return ; case "board": return ; case "extend": return ; case "review": return ; } }; const SectionHeading = ({ title, description, headingRef, }: { title: string; description: string; headingRef: React.RefObject; }) => ( {title} {description} ); const Essentials = (props: StudioSectionProps) => { const t = useI18n("init.studio.essentials"); const [isHydrated, setIsHydrated] = useState(false); useEffect(() => setIsHydrated(true), []); return ( } my="xs"> {t("serverOriginHelp")} {props.serverOrigin ? ( {buildIntegrationUrl("sonarr", props.serverOrigin, props.urlMode)} ) : null} props.setAnalyticsEnabled(event.currentTarget.checked)} label={t("analytics")} description={t("analyticsDescription")} /> ); }; const Discovery = (props: StudioSectionProps) => { const t = useI18n("init.studio.discover"); const dockerStatus = !props.environment.dockerConfigured ? "disabled" : props.docker.isPending ? "checking" : props.dockerData?.status === "success" ? "available" : props.dockerData?.status === "empty" ? "empty" : props.dockerData?.status === "partial" ? "partial" : "unavailable"; const kubernetesStatus: CapabilityStatus = !props.environment.kubernetesConfigured ? "disabled" : props.runtimeCapabilities.isPending ? "checking" : props.runtimeCapabilities.data?.kubernetes.status === "available" ? "available" : "unavailable"; const workshopStatus: CapabilityStatus = props.runtimeCapabilities.isPending ? "checking" : props.runtimeCapabilities.data?.workshop.status === "available" ? "available" : "unavailable"; const kubernetesVersion = props.runtimeCapabilities.data?.kubernetes.status === "available" ? props.runtimeCapabilities.data.kubernetes.detail : undefined; const runtimeRetryAction = (label: string) => ( void props.runtimeCapabilities.refetch()} loading={props.runtimeCapabilities.isFetching} > ); return ( {props.docker.error || props.dockerData?.status === "partial" || props.dockerData?.status === "unavailable" ? ( {t("partialDescription")} {props.dockerData?.hosts .filter((host) => host.status === "unavailable") .map((host) => ( {host.host}: {" "} {host.reason} ))} ) : null} ); }; type CapabilityStatus = "checking" | "available" | "partial" | "empty" | "disabled" | "unavailable"; const CapabilityRow = ({ icon: Icon, title, status, detail, action, }: { icon: typeof IconServer; title: string; status: CapabilityStatus; detail: string; action?: ReactNode; }) => { const t = useI18n("init.studio.discover.status"); const color = status === "available" ? "green" : status === "checking" ? "blue" : status === "partial" || status === "empty" ? "yellow" : "gray"; return ( {title} {detail} {t(status)} {action} ); }; const Connections = (props: StudioSectionProps) => { const t = useI18n("init.studio.connect"); return ( {props.discoveredIntegrations.length > 0 ? (
{props.discoveredIntegrations.map((integration) => ( {integration.containerName} {getIntegrationName(integration.kind)} ))}
) : null} {props.discoveredApps.length > 0 ? (
{props.discoveredApps.map((app) => { const selected = props.selectedAppIds.includes(app.sourceId); return (
props.setSelectedAppIds( selected ? props.selectedAppIds.filter((id) => id !== app.sourceId) : [...props.selectedAppIds, app.sourceId], ) } p="sm" radius="md" > {app.containerName.at(0)} {app.containerName} {selected ? ( props.setDiscoveredAppUrl(app.sourceId, event.currentTarget.value)} error={props.appErrorSourceId === app.sourceId ? props.appError : undefined} placeholder="service.local:8080" required /> ) : null}
); })}
) : null} {props.appError ? ( {props.appError} ) : null}
{props.drafts.length > 0 ? ( draft.id)} > {props.drafts.map((draft) => ( props.updateDraft(draft.id, patch)} selectSecretOption={(option) => props.selectSecretOption(draft, option)} /> ))} ) : ( }> {t("empty")} )}
); }; const IntegrationEditor = ({ draft, update, selectSecretOption, }: { draft: IntegrationDraft; update: (patch: Partial) => void; selectSecretOption: (option: number) => void; }) => { const t = useI18n("init.studio.connect"); const tCommon = useI18n("common.field"); const sounds = useOnboardingSounds(); const options = getAllSecretKindOptions(draft.kind); const apiKeyUrl = getIntegrationApiKeyUrl(draft.url, draft.kind); const documentationUrl = getIntegrationDocumentationUrl(draft.kind); const needsConfiguration = !isIntegrationDraftComplete(draft); const testConnection = clientApi.onboard.testIntegration.useMutation({ onSuccess(result) { if (result.success) { sounds.success(); showSuccessNotification({ title: t("testSuccess"), message: t("testSuccessDescription") }); } else { sounds.error(); showErrorNotification({ title: t("testError"), message: t("testErrorDescription") }); } }, onError() { sounds.error(); showErrorNotification({ title: t("testError"), message: t("testErrorDescription") }); }, }); const updateAndResetTest = (patch: Partial) => { testConnection.reset(); update(patch); }; const testFailed = testConnection.data?.success === false || testConnection.isError; const testSucceeded = testConnection.data?.success === true; const status = needsConfiguration ? { color: "orange", label: t("needsConfiguration"), icon: } : testSucceeded ? { color: "green", label: t("tested"), icon: } : testFailed ? { color: "red", label: t("testFailed"), icon: } : { color: "gray", label: t("testConnection"), icon: }; return ( } aria-label={`${draft.name}: ${status.label}`} > {draft.name} {getIntegrationName(draft.kind)} ยท {draft.source === "docker" ? t("dockerSource") : t("manualSource")} {status.icon} updateAndResetTest({ name: event.currentTarget.value })} /> updateAndResetTest({ url: event.currentTarget.value, urlOverridden: true })} /> {options.length > 1 ? ( { testConnection.reset(); selectSecretOption(Number(value)); }} options={options.map((secretKinds, index) => ({ value: String(index), label: secretKinds.length === 0 ? t("noCredentials") : secretKinds.map(formatSecretKind).join(" + "), }))} /> ) : null} {draft.secrets.map((secret, index) => ( updateAndResetTest({ secrets: draft.secrets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.currentTarget.value } : item, ), }) } /> ))} {draft.secrets.length === 0 ? ( {t("noCredentialsDescription")} ) : null} {apiKeyUrl ? ( {t("getApiKey")} ) : null} {documentationUrl ? ( {t("openGuide")} ) : ( )} ); }; const formatSecretKind = (kind: IntegrationSecretKind) => kind.replace(/([A-Z])/g, " $1").replace(/^./, (letter) => letter.toUpperCase()); const BoardBuilder = (props: StudioSectionProps) => { const t = useI18n("init.studio.board"); const sounds = useOnboardingSounds(); const validationErrors = getBoardValidationErrors({ attempted: props.boardValidationAttempted, hasExistingBoards: props.environment.availableBoards.length > 0, selectedBoardId: props.selectedBoardId, boardName: props.boardName, }); const previewWidgetKinds = getPreviewWidgetKinds(props); const previewAppCount = getPreviewAppCount(props); const colorsCustomized = props.primaryColor.toLowerCase() !== initialPrimaryColor || props.secondaryColor.toLowerCase() !== initialSecondaryColor; return ( {props.environment.availableBoards.length > 1 ? (