fix(custom-widgets): finish v2 hardening
This commit is contained in:
97 files changed
+2833
-12393
No files matched your search
@@ -95,7 +95,7 @@ describe("CopyAiPromptButton", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("copies the complete offline bundle and reports its exact character count", async () => {
|
||||
test("copies the complete embedded skill and reports its exact character count", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
@@ -120,10 +120,26 @@ describe("CopyAiPromptButton", () => {
|
||||
|
||||
const prompt = writeText.mock.calls[0]?.[0] as string;
|
||||
expect(prompt.startsWith("Please create this Homarr Custom JSX v2 widget:\n\nCreate a Pokédex")).toBe(true);
|
||||
expect(prompt).toContain("# Homarr Custom Widget offline authoring bundle");
|
||||
expect(prompt).toContain("--- BEGIN SKILL FILE: SKILL.md ---");
|
||||
expect(prompt).toContain("--- BEGIN SKILL FILE: references/widget-schema.json ---");
|
||||
expect(prompt).toContain("--- BEGIN CANONICAL COMPONENT CATALOG ---");
|
||||
expect(prompt).toContain("--- BEGIN CANONICAL RUNTIME EXAMPLES ---");
|
||||
expect(prompt.length).toBeGreaterThan(500_000);
|
||||
expect(prompt.length).toBeLessThanOrEqual(1_700_000);
|
||||
expect(prompt.endsWith(`${CUSTOM_WIDGET_OFFLINE_BUNDLE_SENTINEL}\nCharacters: ${prompt.length}`)).toBe(true);
|
||||
expect(showSuccessNotification).toHaveBeenCalledWith({
|
||||
title: "action.copyAiPrompt",
|
||||
message: `notification.aiPromptCopiedWithCount:${prompt.length}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("places the AI authoring card before General information", async () => {
|
||||
const source = await readFile(
|
||||
resolve(process.cwd(), "apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source.indexOf("<CustomWidgetAiCard")).toBeLessThan(source.indexOf('<EditorSection id="general"'));
|
||||
});
|
||||
});
|
||||
+20
-1
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
|
||||
import { getChangedSecrets, getCustomWidgetPreviewOptionIssues, loadPreviewQueries } from "./_custom-widget-form-utils";
|
||||
|
||||
const previewQuery = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -64,4 +64,23 @@ describe("Custom Widget workbench preview options", () => {
|
||||
{ path: "configuration.environmentId", message: "Expected number" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes only changed secrets for save and preview", () => {
|
||||
expect(
|
||||
getChangedSecrets({
|
||||
name: "Widget",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
sources: "[]",
|
||||
requests: "[]",
|
||||
optionsSchema: "{}",
|
||||
defaultOptions: "{}",
|
||||
template: "<Text />",
|
||||
secrets: [
|
||||
{ sourceId: "default", kind: "apiKey", value: "", hasValue: true },
|
||||
{ sourceId: "other", kind: "password", value: "replacement" },
|
||||
],
|
||||
}),
|
||||
).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,16 @@ export function buildDefinition(values: CustomWidgetFormValues) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getChangedSecrets(values: CustomWidgetFormValues) {
|
||||
return values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
sourceId,
|
||||
kind: kind as "apiKey" | "username" | "password",
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
|
||||
form.setValues({
|
||||
...form.values,
|
||||
|
||||
@@ -165,6 +165,14 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
</nav>
|
||||
<div className={classes.workbench} data-mobile-pane={mobilePane}>
|
||||
<Stack gap="lg" className={classes.configuration}>
|
||||
<CustomWidgetAiCard
|
||||
candidate={candidate.success ? candidate.data : null}
|
||||
request={request}
|
||||
onRequestChange={setRequest}
|
||||
documentationUrl={documentationUrl}
|
||||
onDocumentationUrlChange={setDocumentationUrl}
|
||||
onPaste={() => void pasteAiResponse()}
|
||||
/>
|
||||
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
|
||||
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
|
||||
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
|
||||
@@ -224,14 +232,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
maxLength={50_000}
|
||||
/>
|
||||
</EditorSection>
|
||||
<CustomWidgetAiCard
|
||||
candidate={candidate.success ? candidate.data : null}
|
||||
request={request}
|
||||
onRequestChange={setRequest}
|
||||
documentationUrl={documentationUrl}
|
||||
onDocumentationUrlChange={setDocumentationUrl}
|
||||
onPaste={() => void pasteAiResponse()}
|
||||
/>
|
||||
<Paper p="md" className={classes.mobileSaveBar} shadow="sm">
|
||||
<SaveActions
|
||||
dirty={form.isDirty()}
|
||||
|
||||
+3
-14
@@ -18,6 +18,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
applyDefinition,
|
||||
buildDefinition,
|
||||
getChangedSecrets,
|
||||
getCustomWidgetPreviewOptionIssues,
|
||||
isRecord,
|
||||
loadPreviewQueries,
|
||||
@@ -63,13 +64,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const changedSecrets = values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
sourceId,
|
||||
kind: kind as "apiKey" | "username" | "password",
|
||||
value,
|
||||
}));
|
||||
const changedSecrets = getChangedSecrets(values);
|
||||
try {
|
||||
if (input.mode === "create") {
|
||||
const result = await createMutation.mutateAsync({ ...definition.data, secrets: changedSecrets });
|
||||
@@ -135,13 +130,7 @@ export function useCustomWidgetFormActions(input: FormActionsInput) {
|
||||
definition: input.candidate.data,
|
||||
definitionId: input.definitionId,
|
||||
options: input.optionsSnapshot,
|
||||
secrets: input.form.values.secrets
|
||||
.filter((secret) => secret.value.trim())
|
||||
.map(({ sourceId, kind, value }) => ({
|
||||
sourceId,
|
||||
kind: kind as "apiKey" | "username" | "password",
|
||||
value,
|
||||
})),
|
||||
secrets: getChangedSecrets(input.form.values),
|
||||
});
|
||||
const snapshot = await loadPreviewQueries(input.candidate.data, created.previewSession.id, input.optionsSnapshot);
|
||||
const failed = Object.values(snapshot.status).filter((status) => isRecord(status) && status.ok === false).length;
|
||||
|
||||
@@ -13,5 +13,6 @@ export function GET() {
|
||||
"homarr://custom-widgets/components",
|
||||
"homarr://custom-widgets/skill",
|
||||
],
|
||||
httpResources: ["/api/custom-widgets/schema", "/api/custom-widgets/components", "/api/custom-widgets/skill"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(getCustomWidgetJsonSchema());
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-pro
|
||||
import {
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetSkill,
|
||||
getCustomWidgetOfflineBundle,
|
||||
} from "@homarr/custom-widgets/authoring-resources";
|
||||
import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
import { db } from "@homarr/db";
|
||||
@@ -252,8 +252,7 @@ const mcpHandler = createMcpHandler(
|
||||
if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema());
|
||||
else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog());
|
||||
else if (uri === "homarr://custom-widgets/skill") {
|
||||
const skill = getCustomWidgetSkill();
|
||||
text = `${skill.skillMd}\n\nInstall from skills.sh: ${skill.skillsShUrl}\nSource: ${skill.sourceUrl}\nCommand: ${skill.installCommand}\n`;
|
||||
text = getCustomWidgetOfflineBundle();
|
||||
mimeType = "text/markdown";
|
||||
} else if (uri.startsWith("homarr://custom-widgets/components/")) {
|
||||
const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Code,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import type { WorkshopReport, WorkshopSubmissionDetail, WorkshopUser } from "@homarr/workshop";
|
||||
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -49,7 +52,7 @@ function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function WorkshopBrowser({ onInstall }: { onInstall?(submission: WorkshopSubmissionDetail): Promise<void> }) {
|
||||
export function WorkshopBrowser({ onInstall }: { onInstall?(widget: HomarrCustomWidgetV2): Promise<void> }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -85,6 +88,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
() => (detail.data ? validateWorkshopWidget(detail.data.content) : null),
|
||||
[detail.data],
|
||||
);
|
||||
const detailCompatible = detailValidation?.success === true && detail.data?.widgetSchema === CUSTOM_WIDGET_SCHEMA;
|
||||
const signIn = () => {
|
||||
setLoginPending(true);
|
||||
setLoginError(null);
|
||||
@@ -99,7 +103,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
.finally(() => setLoginPending(false));
|
||||
};
|
||||
const install = useMutation({
|
||||
mutationFn: async (submission: WorkshopSubmissionDetail) => onInstall?.(submission),
|
||||
mutationFn: async (widget: HomarrCustomWidgetV2) => onInstall?.(widget),
|
||||
onSuccess: () => setSelectedId(null),
|
||||
});
|
||||
const vote = useMutation({
|
||||
@@ -167,6 +171,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
<TextInput
|
||||
label={t("search")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={list.isFetching ? <Loader size={16} /> : undefined}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
@@ -194,7 +199,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
<Skeleton h={280} />
|
||||
</SimpleGrid>
|
||||
) : list.isError ? (
|
||||
<Alert color="red">{t("unavailable")}</Alert>
|
||||
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
|
||||
) : list.data?.items.length ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{list.data.items.map((item) => (
|
||||
@@ -223,6 +228,9 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color={item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "green" : "red"} variant="light">
|
||||
{item.widgetSchema}
|
||||
</Badge>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
@@ -254,7 +262,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
{detail.isPending ? (
|
||||
<Skeleton h={400} />
|
||||
) : detail.isError || !detail.data ? (
|
||||
<Alert color="red">{t("loadError")}</Alert>
|
||||
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
|
||||
) : (
|
||||
<Stack>
|
||||
<Text>{detail.data.description}</Text>
|
||||
@@ -273,12 +281,17 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<CapabilitySummary content={detail.data.content} />
|
||||
{detailValidation?.success && <CapabilitySummary widget={detailValidation.data} />}
|
||||
{detailValidation && !detailValidation.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("installErrorDescription")} {detailValidation.error}
|
||||
</Alert>
|
||||
)}
|
||||
{detailValidation?.success && !detailCompatible && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("installErrorDescription")}
|
||||
</Alert>
|
||||
)}
|
||||
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
|
||||
{detail.data.content}
|
||||
</Code>
|
||||
@@ -294,7 +307,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconThumbUp size={16} />}
|
||||
disabled={!user}
|
||||
loading={vote.isPending && vote.variables?.value === 1}
|
||||
disabled={!user || vote.isPending}
|
||||
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
|
||||
>
|
||||
{detail.data.upvotes}
|
||||
@@ -302,7 +316,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconThumbDown size={16} />}
|
||||
disabled={!user}
|
||||
loading={vote.isPending && vote.variables?.value === -1}
|
||||
disabled={!user || vote.isPending}
|
||||
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
|
||||
>
|
||||
{detail.data.downvotes}
|
||||
@@ -320,8 +335,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
{onInstall && (
|
||||
<Button
|
||||
loading={install.isPending}
|
||||
disabled={!detailValidation?.success}
|
||||
onClick={() => install.mutate(detail.data)}
|
||||
disabled={!detailCompatible}
|
||||
onClick={() => detailValidation?.success && install.mutate(detailValidation.data)}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
@@ -332,7 +347,7 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
{t("signInHint")}
|
||||
</Text>
|
||||
)}
|
||||
{vote.error && <Alert color="red">{t("voteError")}</Alert>}
|
||||
{vote.error && <Alert color="red">{vote.error.message || t("voteError")}</Alert>}
|
||||
{install.error && <Alert color="red">{install.error.message}</Alert>}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -367,10 +382,8 @@ export function WorkshopBrowser({ onInstall }: { onInstall?(submission: Workshop
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilitySummary({ content }: { content: string }) {
|
||||
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const parsed = validateWorkshopWidget(content);
|
||||
if (!parsed.success) return null;
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Card withBorder p="sm">
|
||||
@@ -378,7 +391,7 @@ function CapabilitySummary({ content }: { content: string }) {
|
||||
{t("sources")}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{parsed.data.sources.map((source) => (
|
||||
{widget.sources.map((source) => (
|
||||
<Box key={source.id}>
|
||||
<Text size="sm" fw={500}>
|
||||
{source.name}
|
||||
@@ -402,13 +415,13 @@ function CapabilitySummary({ content }: { content: string }) {
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("requests")}
|
||||
</Text>
|
||||
{parsed.data.requests.length === 0 ? (
|
||||
{widget.requests.length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("noRequests")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{parsed.data.requests.map((request) => (
|
||||
{widget.requests.map((request) => (
|
||||
<Group key={request.id} justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{request.id}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { WorkshopClient, WORKSHOP_API_URL } from "@homarr/workshop";
|
||||
|
||||
export function createWorkshopClient() {
|
||||
const runtimeUrl =
|
||||
typeof document === "undefined"
|
||||
? undefined
|
||||
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
|
||||
let browserClient: WorkshopClient | undefined;
|
||||
|
||||
return new WorkshopClient(runtimeUrl || WORKSHOP_API_URL);
|
||||
export function createWorkshopClient() {
|
||||
if (typeof document === "undefined") return new WorkshopClient(WORKSHOP_API_URL);
|
||||
if (browserClient) return browserClient;
|
||||
|
||||
const runtimeUrl = document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
|
||||
|
||||
browserClient = new WorkshopClient(runtimeUrl || WORKSHOP_API_URL);
|
||||
return browserClient;
|
||||
}
|
||||
@@ -5,9 +5,7 @@ import { Button, Modal } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
@@ -25,23 +23,9 @@ export function WorkshopInstallButton() {
|
||||
</Button>
|
||||
<Modal opened={opened} onClose={controls.close} title={t("installDialog")} size="90%">
|
||||
<WorkshopBrowser
|
||||
onInstall={async (submission) => {
|
||||
try {
|
||||
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
|
||||
if (!parsed.success) throw new Error(t("installErrorDescription"));
|
||||
setPendingWidget(parsed.data);
|
||||
reviewControls.open();
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("installError"),
|
||||
message:
|
||||
error instanceof SyntaxError
|
||||
? t("installErrorDescription")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: t("installErrorDescription"),
|
||||
});
|
||||
}
|
||||
onInstall={async (widget) => {
|
||||
setPendingWidget(widget);
|
||||
reviewControls.open();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -4,38 +4,19 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import { customWidgetImportSchema } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WorkshopSubmissionDetail } from "@homarr/workshop";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopInstaller() {
|
||||
const t = useScopedI18n("workshop");
|
||||
const router = useRouter();
|
||||
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, reviewControls] = useDisclosure(false);
|
||||
|
||||
const install = async (submission: WorkshopSubmissionDetail) => {
|
||||
try {
|
||||
const parsed = customWidgetImportSchema.safeParse(JSON.parse(submission.content) as unknown);
|
||||
if (!parsed.success) throw new Error(t("installErrorDescription"));
|
||||
setPendingWidget(parsed.data);
|
||||
reviewControls.open();
|
||||
} catch (error) {
|
||||
showErrorNotification({
|
||||
title: t("installError"),
|
||||
message:
|
||||
error instanceof SyntaxError
|
||||
? t("installErrorDescription")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: t("installErrorDescription"),
|
||||
});
|
||||
}
|
||||
const install = async (widget: HomarrCustomWidgetV2) => {
|
||||
setPendingWidget(widget);
|
||||
reviewControls.open();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user