fix: preserve widget drafts and connection groups

This commit is contained in:
ajnart committed 2026-09-12 15:50:36 +02:00
1 parent 48042a08ff
commit 29c94be821
12 files changed
+258 -79

No files matched your search

@@ -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<string, string>;
onChange(bindings: Record<string, string>): void;
onChange: Dispatch<SetStateAction<Record<string, string>>>;
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"),
};
@@ -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<string, unknown> = connection?.configuration ?? {};
const tls: Record<string, unknown> = isRecord(configuration.tls) ? configuration.tls : {};
let initialAuth: Authentication = "none";
if (typeof configuration.auth === "string" && authKinds.includes(configuration.auth))
initialAuth = configuration.auth as Authentication;
@@ -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<string, string>;
onChange(bindings: Record<string, string>): void;
onChange: Dispatch<SetStateAction<Record<string, string>>>;
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) => (
<Group key={bindingName} gap="xs">
@@ -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<WorkspaceTab>(() => {
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({
<Button
variant="default"
loading={save.isPending}
disabled={!document.name.trim() || previewMutation.isPending || saveBindings.isPending}
disabled={!document.name.trim() || busy}
onClick={() => void saveDraft()}
>
{t("saveDraft")}
</Button>
<Button onClick={() => { setTab("installation"); setPane("edit"); }}>
<Button onClick={() => { selectTab("installation"); setPane("edit"); }}>
{t("installation")}
</Button>
</Group>
@@ -153,7 +167,7 @@ export function PackageWorkspace({
<Alert color="yellow">
<Group justify="space-between">
<Text size="sm">{t("connectionsUnsaved")}</Text>
<Button size="xs" loading={saveBindings.isPending || save.isPending} onClick={saveLocalBindings}>
<Button size="xs" loading={saveBindings.isPending || save.isPending} disabled={busy} onClick={saveLocalBindings}>
{t("saveBindings")}
</Button>
</Group>
@@ -218,7 +232,7 @@ export function PackageWorkspace({
preview={<PackagePreview state={workspace} />}
>
<Stack className={classes.authoring}>
<Tabs value={tab} onChange={(value) => { if (value) setTab(value); }} keepMounted={false}>
<Tabs value={tab} onChange={selectTab}>
<Tabs.List>
<Tabs.Tab value="source">{t("code")}</Tabs.Tab>
<Tabs.Tab value="connections">{t("connections")}</Tabs.Tab>
@@ -226,51 +240,68 @@ export function PackageWorkspace({
<Tabs.Tab value="installation">{t("installation")}</Tabs.Tab>
<Tabs.Tab value="workshop">{t("workshopTab")}</Tabs.Tab>
</Tabs.List>
<PackageSourceEditor state={workspace} existing={Boolean(workspace.installationId)} />
{visitedTabs.has("source") && (
<PackageSourceEditor state={workspace} existing={Boolean(workspace.installationId)} />
)}
<Tabs.Panel value="connections" pt="md">
<PackageConnections
requirements={parsed.success ? parsed.data.connections : {}}
bindings={bindings}
onChange={setBindings}
onSave={saveLocalBindings}
saving={saveBindings.isPending}
onConnectionSaved={workspace.connectionChanged}
/>
{visitedTabs.has("connections") && (
<PackageConnections
requirements={parsed.success ? parsed.data.connections : {}}
bindings={bindings}
onChange={setBindings}
onPendingChange={workspace.connectionPreparationChanged}
onSave={saveLocalBindings}
saving={busy}
onConnectionSaved={workspace.connectionChanged}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="options" pt="md">
<Stack>
<Text size="sm" c="dimmed">{t("optionsDescription")}</Text>
{parsed.success && <PackageOptionFields schema={parsed.data.options} value={effectiveOptions} onChange={setOptions} />}
{parsed.success && Object.keys(parsed.data.options).length === 0 && <Alert>{t("noOptions")}</Alert>}
<Button variant="subtle" onClick={() => setOptions({})}>{t("resetOptions")}</Button>
</Stack>
{visitedTabs.has("options") && (
<Stack>
<Text size="sm" c="dimmed">{t("optionsDescription")}</Text>
{parsed.success && <PackageOptionFields schema={parsed.data.options} value={effectiveOptions} onChange={setOptions} />}
{parsed.success && Object.keys(parsed.data.options).length === 0 && <Alert>{t("noOptions")}</Alert>}
<Button variant="subtle" onClick={() => setOptions({})}>{t("resetOptions")}</Button>
</Stack>
)}
</Tabs.Panel>
<Tabs.Panel value="installation" pt="md">
<PackageOperations
installation={current}
initialBoardId={initialBoardId}
source={parsed.success ? parsed.data : undefined}
options={effectiveOptions}
dirty={editor.dirty}
trusted={trusted}
connectionsDirty={workspace.bindingsDirty}
onTrustChange={workspace.setTrusted}
onSave={() => void saveDraft()}
saving={save.isPending}
onError={setError}
onMessage={setMessage}
/>
{visitedTabs.has("installation") && (
<PackageOperations
installation={current}
initialBoardId={initialBoardId}
source={parsed.success ? parsed.data : undefined}
options={effectiveOptions}
dirty={editor.dirty}
trusted={trusted}
connectionsDirty={workspace.bindingsDirty || busy}
onTrustChange={workspace.setTrusted}
onSave={() => void saveDraft()}
saving={busy}
onError={setError}
onMessage={setMessage}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="workshop" pt="md">
<Stack>
<Text size="sm" c="dimmed">{t("workshopDescription")}</Text>
{current ? (
<>
<PackageWorkshop id={current.id} dirty={editor.dirty || workspace.bindingsDirty} />
<PackageTransfer id={current.id} name={current.name} active={Boolean(current.activeArtifactId)} dirty={editor.dirty || workspace.bindingsDirty} onError={setError} />
</>
) : <Button onClick={() => void saveDraft()} loading={save.isPending}>{t("saveDraft")}</Button>}
</Stack>
{visitedTabs.has("workshop") && (
<Stack>
<Text size="sm" c="dimmed">{t("workshopDescription")}</Text>
{current ? (
<>
<PackageWorkshop id={current.id} dirty={editor.dirty || workspace.bindingsDirty || busy} />
<PackageTransfer
id={current.id}
name={current.name}
active={Boolean(current.activeArtifactId)}
dirty={editor.dirty || workspace.bindingsDirty || busy}
onError={setError}
/>
</>
) : <Button onClick={() => void saveDraft()} loading={busy}>{t("saveDraft")}</Button>}
</Stack>
)}
</Tabs.Panel>
</Tabs>
</Stack>
@@ -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,
@@ -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<CollectionConfiguration>({});
const [selected, setSelected] = useState(initialData.entries.map((entry) => entry.id));
const [boardId, setBoardId] = useState<string | null>(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
<Group>
<Button
loading={progress.busy}
disabled={!board || !trusted || !selected.length || missing.length > 0 || saveBindings.isPending}
disabled={!board || !trusted || !selected.length || missing.length > 0 || busy}
onClick={() => {
if (board) void progress.run(board, selected, bindings, configuration);
}}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useState } from "react";
import { Accordion, Alert, Button, Paper, Select, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
@@ -20,7 +20,15 @@ export function WorkshopPackageReview({ submissionId }: { submissionId: string }
{ enabled: Boolean(releaseId) },
);
const [bindings, setBindings] = useState<Record<string, string>>({});
const [pendingConnections, setPendingConnections] = useState(0);
const connectionPreparationChanged = useCallback((pending: boolean) => {
setPendingConnections((current) => {
if (pending) return current + 1;
return current - 1;
});
}, []);
const install = clientApi.customWidget.package.installWorkshop.useMutation();
const busy = install.isPending || pendingConnections > 0;
return (
<Paper withBorder p="md">
<Stack>
@@ -33,6 +41,7 @@ export function WorkshopPackageReview({ submissionId }: { submissionId: string }
label: `${entry.version} · ${entry.created}`,
}))}
value={releaseId || null}
disabled={busy}
onChange={(value) => {
setSelected(value);
setBindings({});
@@ -76,13 +85,15 @@ export function WorkshopPackageReview({ submissionId }: { submissionId: string }
requirements={release.data.source.connections}
bindings={bindings}
onChange={setBindings}
saving={false}
onPendingChange={connectionPreparationChanged}
saving={busy}
/>
<Text size="sm" c="dimmed">
{t("installDisabled")}
</Text>
<Button
loading={install.isPending}
disabled={busy}
onClick={() =>
install.mutate(
{ releaseId, bindings },
@@ -1,4 +1,8 @@
import { getWidgetConnectionBindingNames, isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import {
getWidgetConnectionBindingNames,
isWidgetConnectionCompatible,
mergeWidgetConnectionBindings,
} from "@homarr/custom-widgets/package";
import { TRPCError } from "@trpc/server";
import { parse, stringify } from "superjson";
import { z } from "zod/v4";
@@ -58,7 +62,11 @@ export async function prepareWidgetMigration(
const issues = validateCustomWidgetOptions(source.options, configuration);
if (issues.length)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Placement ${placement.id}: ${issues[0]?.message}` });
const mergedBindings = { ...bindings, ...placement.packageOptions.connectionBindings };
const mergedBindings = mergeWidgetConnectionBindings(
source.connections,
bindings,
placement.packageOptions.connectionBindings,
);
for (const [name, requirement] of Object.entries(source.connections)) {
const names = getWidgetConnectionBindingNames(name, requirement, mergedBindings);
if (!names.length && !requirement.optional)
@@ -6,7 +6,11 @@ import { isRecord } from "@homarr/common";
import { eq } from "@homarr/db";
import { boards, customWidgetArtifacts, customWidgetInstallations, items } from "@homarr/db/schema";
import { getCustomWidgetDefaultOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core";
import { customWidgetArtifactSchema, customWidgetPackageSchema } from "@homarr/custom-widgets/package";
import {
customWidgetArtifactSchema,
customWidgetPackageSchema,
mergeWidgetConnectionBindings,
} from "@homarr/custom-widgets/package";
import type { CustomWidgetArtifact, CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { throwIfActionForbiddenAsync } from "../../board/board-access";
@@ -105,7 +109,11 @@ export async function resolvePackagePlacement(ctx: PackageContext, itemId: strin
const issues = validateCustomWidgetOptions(source.options, configuration);
if (issues.length > 0)
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Widget configuration needs repair" });
const bindings = { ...parseBindings(installation.bindings), ...options.connectionBindings };
const bindings = mergeWidgetConnectionBindings(
source.connections,
parseBindings(installation.bindings),
options.connectionBindings,
);
return { item, installation, artifact, source, configuration, bindings };
}
@@ -115,6 +115,24 @@ export function getWidgetConnectionBindingNames(
);
}
/** A placement selects an entire group; inherited members must not leak into its selection. */
export function mergeWidgetConnectionBindings(
requirements: Record<string, { multiple?: boolean }>,
defaults: Record<string, string>,
overrides: Record<string, string>,
) {
const bindings = { ...defaults };
for (const [name, requirement] of Object.entries(requirements)) {
if (!requirement.multiple) continue;
const belongsToGroup = (key: string) => key === name || key.startsWith(`${name}:`);
if (!Object.keys(overrides).some(belongsToGroup)) continue;
for (const key of Object.keys(bindings).filter(belongsToGroup)) delete bindings[key];
}
const merged = { ...bindings, ...overrides };
// An explicit empty group is stored as an empty base binding to distinguish it from inheritance.
return Object.fromEntries(Object.entries(merged).filter(([, id]) => Boolean(id)));
}
export function getWidgetConnectionRequirement<T extends { multiple?: boolean }>(
requirements: Record<string, T>,
name: string,
+1
View File
@@ -5415,6 +5415,7 @@
"advancedOptions": "Advanced options",
"invalidJson": "Invalid JSON",
"connectionDefault": "Use installation connection ({name})",
"useInstallationConnections": "Use installation connections",
"connectionUnbound": "Choose a connection",
"connectionMissing": "This required connection is not bound.",
"connectionUnavailable": "Saved connection is unavailable",
@@ -1,11 +1,15 @@
"use client";
import { Alert, Select, Stack } from "@mantine/core";
import { Alert, Button, MultiSelect, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { isRecord } from "@homarr/common";
import { isWidgetConnectionCompatible } from "@homarr/custom-widgets/package";
import {
getWidgetConnectionBindingNames,
isWidgetConnectionCompatible,
mergeWidgetConnectionBindings,
} from "@homarr/custom-widgets/package";
import type { CustomWidgetPackage } from "@homarr/custom-widgets/package";
import { useI18n } from "@homarr/translation/client";
@@ -27,6 +31,7 @@ export function PackageConnectionBindings({
enabled: isAdmin && Object.keys(connections).length > 0,
});
if (!isAdmin) return null;
const effectiveBindings = mergeWidgetConnectionBindings(connections, installationBindings, value);
return (
<Stack gap="sm">
{local.isError && <Alert color="red">{t("connectionsError")}</Alert>}
@@ -34,6 +39,52 @@ export function PackageConnectionBindings({
const candidates = (local.data ?? []).filter(
(row) => isRecord(row.configuration) && isWidgetConnectionCompatible(connection, row.configuration),
);
if (connection.multiple) {
const names = getWidgetConnectionBindingNames(name, connection, effectiveBindings);
const selected = [...new Set(names.map((key) => effectiveBindings[key]!))];
const choices = candidates.map((row) => ({ value: row.id, label: row.name }));
for (const id of selected) {
if (!choices.some((choice) => choice.value === id))
choices.push({ value: id, label: t("connectionUnavailable") });
}
const overrideKeys = Object.keys(value).filter((key) => key === name || key.startsWith(`${name}:`));
const withoutOverride = () => {
const bindings = { ...value };
for (const key of overrideKeys) delete bindings[key];
return bindings;
};
let error: string | undefined;
if (local.data && !connection.optional && !selected.length) error = t("connectionMissing");
if (local.data && selected.some((id) => !candidates.some((row) => row.id === id)))
error = t("connectionUnavailable");
return (
<Stack key={name} gap="xs">
<MultiSelect
label={connection.label}
description={connection.description}
required={!connection.optional}
placeholder={t("connectionUnbound")}
clearable
searchable
data={choices}
value={selected}
error={error}
disabled={local.isLoading}
onChange={(ids) => {
const bindings = withoutOverride();
bindings[name] = ids[0] ?? "";
for (const id of ids.slice(1)) bindings[`${name}:${id}`] = id;
onChange(bindings);
}}
/>
{overrideKeys.length > 0 && (
<Button variant="subtle" size="compact-xs" onClick={() => onChange(withoutOverride())}>
{t("useInstallationConnections")}
</Button>
)}
</Stack>
);
}
const defaults = candidates.find((row) => row.id === installationBindings[name]);
const selected = value[name];
const choices = candidates.map((row) => ({ value: row.id, label: row.name }));