diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-binding-picker.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-binding-picker.tsx index e3e6fe252..37f5cb581 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-binding-picker.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-binding-picker.tsx @@ -1,6 +1,7 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; import { Alert, MultiSelect, Select, Stack } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; @@ -9,22 +10,39 @@ import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@ import type { CustomWidgetPackage } from "@homarr/custom-widgets/package"; import { useI18n } from "@homarr/translation/client"; -export function PackageBindingPicker({ name, requirement, connections, integrations, bindings, onChange }: { +export function PackageBindingPicker({ + name, + requirement, + connections, + integrations, + bindings, + onChange, + onPendingChange, + disabled, +}: { name: string; requirement: CustomWidgetPackage["connections"][string]; connections: RouterOutputs["customWidget"]["package"]["connections"]; integrations: RouterOutputs["integration"]["all"]; bindings: Record; - onChange(bindings: Record): void; + onChange: Dispatch>>; + onPendingChange(pending: boolean): void; + disabled: boolean; }) { const t = useI18n("customWidget.package"); const utils = clientApi.useUtils(); const saveConnection = clientApi.customWidget.package.saveConnection.useMutation(); const [error, setError] = useState(""); const [pending, setPending] = useState(false); - const currentBindings = useRef(bindings); - currentBindings.current = bindings; - const ids = getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]!); + const preparing = useRef(false); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const ids = [...new Set(getWidgetConnectionBindingNames(name, requirement, bindings).map((key) => bindings[key]!))]; const compatible = connections.filter((connection) => isWidgetConnectionCompatible(requirement, connection.configuration)); const choices = compatible.map((connection) => ({ value: connection.id, label: connection.name })); if (requirement.kind === "integration") { @@ -40,8 +58,10 @@ export function PackageBindingPicker({ name, requirement, connections, integrati choices.push({ value: id, label: connections.find((connection) => connection.id === id)?.name ?? t("unavailableConnection") }); } const change = async (values: string[]) => { - if (pending) return; + if (preparing.current || disabled) return; + preparing.current = true; setPending(true); + onPendingChange(true); setError(""); try { const nextIds: string[] = []; @@ -59,19 +79,26 @@ export function PackageBindingPicker({ name, requirement, connections, integrati }); nextIds.push(result.id); } - await utils.customWidget.package.connections.invalidate(); - const next = { ...currentBindings.current }; - for (const key of getWidgetConnectionBindingNames(name, requirement, next)) delete next[key]; - for (const [index, id] of nextIds.entries()) { - let key = name; - if (index > 0) key = `${name}:${id}`; - next[key] = id; - } - onChange(next); + if (!mounted.current) return; + onChange((current) => { + const next = { ...current }; + for (const key of getWidgetConnectionBindingNames(name, requirement, next)) delete next[key]; + for (const [index, id] of nextIds.entries()) { + let key = name; + if (index > 0) key = `${name}:${id}`; + next[key] = id; + } + return next; + }); } catch (cause) { - setError(cause instanceof Error ? cause.message : String(cause)); + if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); } finally { - setPending(false); + // Refresh the list independently; a failed refetch must not discard a selection. + if (values.some((value) => value.startsWith("integration:"))) + void utils.customWidget.package.connections.invalidate(); + preparing.current = false; + if (mounted.current) setPending(false); + onPendingChange(false); } }; const common = { @@ -81,7 +108,7 @@ export function PackageBindingPicker({ name, requirement, connections, integrati required: !requirement.optional, searchable: true, clearable: true, - disabled: pending, + disabled: pending || disabled, data: choices, nothingFoundMessage: t("noCompatibleConnections"), }; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-connection-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-connection-editor.tsx index 8f2008dad..0177a8dcf 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-connection-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-connection-editor.tsx @@ -26,8 +26,8 @@ export function ConnectionEditor({ onSaved, connection }: { onSaved(): void; con const t = useI18n("customWidget.package"); const integrations = clientApi.integration.all.useQuery(); const save = clientApi.customWidget.package.saveConnection.useMutation(); - const configuration = isRecord(connection?.configuration) ? connection.configuration : {}; - const tls = isRecord(configuration.tls) ? configuration.tls : {}; + const configuration: Record = connection?.configuration ?? {}; + const tls: Record = isRecord(configuration.tls) ? configuration.tls : {}; let initialAuth: Authentication = "none"; if (typeof configuration.auth === "string" && authKinds.includes(configuration.auth)) initialAuth = configuration.auth as Authentication; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-connections.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-connections.tsx index 2621ef545..a795bbb9b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-connections.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-connections.tsx @@ -5,6 +5,7 @@ import { PackageConnectionDiagnostic } from "./_package-connection-diagnostic"; import { PackageBindingPicker } from "./_package-binding-picker"; import type { RouterOutputs } from "@homarr/api"; import { useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; @@ -17,13 +18,15 @@ export function PackageConnections({ requirements, bindings, onChange, + onPendingChange, onSave, saving, onConnectionSaved, }: { requirements: CustomWidgetPackage["connections"]; bindings: Record; - onChange(bindings: Record): void; + onChange: Dispatch>>; + onPendingChange(pending: boolean): void; onSave?(): void; saving: boolean; onConnectionSaved?(): void; @@ -55,6 +58,8 @@ export function PackageConnections({ integrations={integrations.data ?? []} bindings={bindings} onChange={onChange} + onPendingChange={onPendingChange} + disabled={saving} /> {getWidgetConnectionBindingNames(name, requirement, bindings).map((bindingName) => ( diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-workspace.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-workspace.tsx index 031eb8800..bdea4ae56 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-workspace.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_package-workspace.tsx @@ -31,6 +31,11 @@ import { PackageTransfer } from "./_package-transfer"; import { usePackageWorkspace } from "./_use-package-workspace"; import type { Installation } from "./_use-package-workspace"; import classes from "./_package-workspace.module.css"; + +const workspaceTabs = ["source", "connections", "options", "installation", "workshop"] as const; +type WorkspaceTab = (typeof workspaceTabs)[number]; +const isWorkspaceTab = (value: string | null): value is WorkspaceTab => workspaceTabs.some((tab) => tab === value); + export function PackageWorkspace({ installation, userId, @@ -48,7 +53,17 @@ export function PackageWorkspace({ }) { const t = useI18n("customWidget.package"); const searchParams = useSearchParams(); - const [tab, setTab] = useState(searchParams.get("tab") ?? "source"); + const [tab, setTab] = useState(() => { + const requested = searchParams.get("tab"); + if (isWorkspaceTab(requested)) return requested; + return "source"; + }); + const [visitedTabs, setVisitedTabs] = useState(() => new Set([tab])); + const selectTab = (value: string | null) => { + if (!isWorkspaceTab(value)) return; + setVisitedTabs((current) => new Set(current).add(value)); + setTab(value); + }; const workspace = usePackageWorkspace( installation, userId, @@ -74,7 +89,6 @@ export function PackageWorkspace({ parsed, effectiveOptions, save, - previewMutation, saveBindings, busy, saveDraft, @@ -120,12 +134,12 @@ export function PackageWorkspace({ - @@ -153,7 +167,7 @@ export function PackageWorkspace({ {t("connectionsUnsaved")} - @@ -218,7 +232,7 @@ export function PackageWorkspace({ preview={} > - { if (value) setTab(value); }} keepMounted={false}> + {t("code")} {t("connections")} @@ -226,51 +240,68 @@ export function PackageWorkspace({ {t("installation")} {t("workshopTab")} - + {visitedTabs.has("source") && ( + + )} - + {visitedTabs.has("connections") && ( + + )} - - {t("optionsDescription")} - {parsed.success && } - {parsed.success && Object.keys(parsed.data.options).length === 0 && {t("noOptions")}} - - + {visitedTabs.has("options") && ( + + {t("optionsDescription")} + {parsed.success && } + {parsed.success && Object.keys(parsed.data.options).length === 0 && {t("noOptions")}} + + + )} - void saveDraft()} - saving={save.isPending} - onError={setError} - onMessage={setMessage} - /> + {visitedTabs.has("installation") && ( + void saveDraft()} + saving={busy} + onError={setError} + onMessage={setMessage} + /> + )} - - {t("workshopDescription")} - {current ? ( - <> - - - - ) : } - + {visitedTabs.has("workshop") && ( + + {t("workshopDescription")} + {current ? ( + <> + + + + ) : } + + )} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_use-package-workspace.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_use-package-workspace.ts index 13fc1c352..59a9ad604 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_use-package-workspace.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/_use-package-workspace.ts @@ -27,7 +27,14 @@ export function usePackageWorkspace( }); const [installationId, setInstallationId] = useState(installation?.id); const [bindings, setBindings] = useState(installation?.bindings ?? initialBindings ?? {}); - const [savedBindings, setSavedBindings] = useState(bindings); + const [savedBindings, setSavedBindings] = useState(installation?.bindings ?? {}); + const [pendingConnections, setPendingConnections] = useState(0); + const connectionPreparationChanged = useCallback((pending: boolean) => { + setPendingConnections((current) => { + if (pending) return current + 1; + return current - 1; + }); + }, []); const bindingsDirty = JSON.stringify(Object.entries(bindings).toSorted()) !== JSON.stringify(Object.entries(savedBindings).toSorted()); const editor = usePackageDocument(initial, userId, installationId, bindingsDirty); @@ -69,7 +76,7 @@ export function usePackageWorkspace( const discard = clientApi.customWidget.package.discardPreview.useMutation(); const saveBindings = clientApi.customWidget.package.setBindings.useMutation(); const discardPreview = discard.mutate; - const busy = save.isPending || saveBindings.isPending || previewMutation.isPending; + const busy = save.isPending || saveBindings.isPending || previewMutation.isPending || pendingConnections > 0; const resetExecution = useCallback(() => { generation.current += 1; if (previewId.current) setStale(true); @@ -98,6 +105,7 @@ export function usePackageWorkspace( ); const saveDraft = async () => { + if (busy) return; setError(""); setMessage(""); const submitted = document; @@ -209,6 +217,7 @@ export function usePackageWorkspace( if (parsed.success && parsed.data.manifest.entrypoints.configuration) surfaceChoices.push({ value: "configuration", label: t("configuration") }); const saveLocalBindings = () => { + if (busy) return; if (!installationId) { void saveDraft(); return; @@ -265,6 +274,7 @@ export function usePackageWorkspace( setMessage, bindings, bindingsDirty, + connectionPreparationChanged, setBindings, options, setOptions, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/collections/_collection-setup.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/collections/_collection-setup.tsx index 3e85b410c..f3b7a90fa 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/collections/_collection-setup.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/packages/collections/_collection-setup.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { Accordion, Alert, Badge, Button, Checkbox, Group, Paper, Select, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; @@ -19,6 +19,13 @@ export function PackageCollectionSetup({ initialData }: { initialData: WidgetCol const boards = clientApi.board.getAllBoards.useQuery(); const saveBindings = clientApi.customWidget.package.setCollectionBindings.useMutation(); const [bindings, setBindings] = useState(initialData.bindings); + const [pendingConnections, setPendingConnections] = useState(0); + const connectionPreparationChanged = useCallback((pending: boolean) => { + setPendingConnections((current) => { + if (pending) return current + 1; + return current - 1; + }); + }, []); const [configuration, setConfiguration] = useState({}); const [selected, setSelected] = useState(initialData.entries.map((entry) => entry.id)); const [boardId, setBoardId] = useState(null); @@ -33,7 +40,7 @@ export function PackageCollectionSetup({ initialData }: { initialData: WidgetCol }; const [error, setError] = useState(""); const progress = useCollectionPlacement(collection); - const busy = progress.busy || saveBindings.isPending; + const busy = progress.busy || saveBindings.isPending || pendingConnections > 0; const board = boards.data?.find((candidate) => candidate.id === boardId); const requiredSlots = new Set( collection.entries @@ -46,6 +53,7 @@ export function PackageCollectionSetup({ initialData }: { initialData: WidgetCol ); const missing = Object.entries(collection.connections).filter(([name]) => requiredSlots.has(name) && !bindings[name]); const save = () => { + if (busy) return; setError(""); saveBindings.mutate( { importId: collection.importId, bindings }, @@ -86,6 +94,7 @@ export function PackageCollectionSetup({ initialData }: { initialData: WidgetCol requirements={collection.connections} bindings={bindings} onChange={setBindings} + onPendingChange={connectionPreparationChanged} onSave={save} saving={busy} onConnectionSaved={() => setTrusted(false)} @@ -177,7 +186,7 @@ export function PackageCollectionSetup({ initialData }: { initialData: WidgetCol + )} + + ); + } const defaults = candidates.find((row) => row.id === installationBindings[name]); const selected = value[name]; const choices = candidates.map((row) => ({ value: row.id, label: row.name }));