fix: preserve widget drafts and connection groups
This commit is contained in:
1 parent
48042a08ff
commit
29c94be821
12 files changed
+258
-79
No files matched your search
+46
-19
@@ -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"),
|
||||
};
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+6
-1
@@ -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">
|
||||
|
||||
+75
-44
@@ -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>
|
||||
|
||||
+12
-2
@@ -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,
|
||||
|
||||
+12
-3
@@ -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);
|
||||
}}
|
||||
|
||||
+13
-2
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in new issue
Block a user