Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7749458c31 | ||
|
|
69788124c1 | ||
|
|
9c31cdce37 |
No files matched your search
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { Alert, Button, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface FormErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
interface FormErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export class FormErrorBoundary extends Component<FormErrorBoundaryProps, FormErrorBoundaryState> {
|
||||
public state: FormErrorBoundaryState = { error: null };
|
||||
|
||||
public static getDerivedStateFromError(error: Error): FormErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, _info: ErrorInfo) {
|
||||
console.error("Custom widget form error:", error);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.error) {
|
||||
return <FormErrorFallback error={this.state.error} reset={() => this.setState({ error: null })} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function FormErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("editor.errorBoundary.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{error.message}
|
||||
</Text>
|
||||
<Button size="xs" variant="light" color="red" onClick={reset}>
|
||||
{t("editor.errorBoundary.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { CustomWidgetBetaBanner } from "../../_beta-banner";
|
||||
import { CustomWidgetForm } from "../../_custom-widget-form";
|
||||
import { FormErrorBoundary } from "../../_form-error-boundary";
|
||||
|
||||
const authTypeExpectedSecrets: Record<string, string[]> = {
|
||||
bearer: ["apiKey"],
|
||||
@@ -52,22 +53,24 @@ export default async function EditCustomWidgetPage(props: EditCustomWidgetPagePr
|
||||
<Stack>
|
||||
<Title>{definition.name}</Title>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetForm
|
||||
mode="edit"
|
||||
definitionId={params.id}
|
||||
initialValues={{
|
||||
name: definition.name,
|
||||
description: definition.description ?? "",
|
||||
iconUrl: definition.iconUrl ?? "",
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName ?? "",
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody ?? "",
|
||||
...displayValues,
|
||||
secrets: buildInitialSecrets(definition.authType, definition.secrets),
|
||||
}}
|
||||
/>
|
||||
<FormErrorBoundary>
|
||||
<CustomWidgetForm
|
||||
mode="edit"
|
||||
definitionId={params.id}
|
||||
initialValues={{
|
||||
name: definition.name,
|
||||
description: definition.description ?? "",
|
||||
iconUrl: definition.iconUrl ?? "",
|
||||
url: definition.url,
|
||||
authType: definition.authType,
|
||||
headerName: definition.headerName ?? "",
|
||||
method: definition.method,
|
||||
requestBody: definition.requestBody ?? "",
|
||||
...displayValues,
|
||||
secrets: buildInitialSecrets(definition.authType, definition.secrets),
|
||||
}}
|
||||
/>
|
||||
</FormErrorBoundary>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { CustomWidgetBetaBanner } from "../_beta-banner";
|
||||
import { CustomWidgetForm } from "../_custom-widget-form";
|
||||
import { FormErrorBoundary } from "../_form-error-boundary";
|
||||
|
||||
export default async function NewCustomWidgetPage() {
|
||||
const session = await auth();
|
||||
@@ -28,7 +29,9 @@ export default async function NewCustomWidgetPage() {
|
||||
</Text>
|
||||
</div>
|
||||
<CustomWidgetBetaBanner />
|
||||
<CustomWidgetForm mode="create" />
|
||||
<FormErrorBoundary>
|
||||
<CustomWidgetForm mode="create" />
|
||||
</FormErrorBoundary>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -80,4 +80,83 @@ describe("Custom JSX custom widgets", () => {
|
||||
await mockApi.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("newly added custom widget does not show definition not found before reload", async () => {
|
||||
const mockApi = await startMockApiContainerAsync({
|
||||
title: "Board Widget",
|
||||
status: "running",
|
||||
value: 7,
|
||||
});
|
||||
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
await seedAdminUserAsync(db, adminCredentials);
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "credentials",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
},
|
||||
}).start();
|
||||
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await page.waitForURL(baseUrl, { timeout: 15_000 });
|
||||
|
||||
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
|
||||
await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 });
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("Board Widget");
|
||||
await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`);
|
||||
await page.getByRole("button", { name: /Custom JSX/u }).click();
|
||||
await page.getByRole("combobox", { name: "Network scope" }).click();
|
||||
await page.getByRole("option", { name: "Private networks" }).click();
|
||||
await page.getByLabel("JSX Template").fill(initialTemplate);
|
||||
await page.getByRole("button", { name: "Create" }).first().click();
|
||||
await expect(page.getByText(/created successfully/u)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.goto(baseUrl);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /edit mode|Edit mode|pencil/iu })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /Add widget|Add item/iu })
|
||||
.first()
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: /Custom API|Custom Widget/iu })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const addDialog = page.getByRole("dialog").last();
|
||||
await addDialog.getByText("Board Widget").first().click();
|
||||
|
||||
await expect(page.getByText("Widget definition not found")).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: /edit mode|Edit mode|pencil/iu })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.getByText("Board Widget")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("Widget definition not found")).not.toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await mockApi.stop();
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -253,6 +253,12 @@ export function buildDisplayConfigFromFormValues(values: CustomWidgetDisplayForm
|
||||
}
|
||||
|
||||
function parseRequestManifest(value: string) {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return customJsxRequestSchema.array().parse(parsed);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const result = customJsxRequestSchema.array().safeParse(parsed);
|
||||
return result.success ? result.data : [];
|
||||
}
|
||||
@@ -62,4 +62,16 @@ describe("custom-widget display form contracts", () => {
|
||||
requests: [{ id: "status", kind: "query", method: "GET" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not throw on invalid request manifest JSON while serializing v2 form state", () => {
|
||||
const values = requiredFormValues("customJsx");
|
||||
values.jsxApiVersion = "2";
|
||||
|
||||
for (const invalidManifest of ["[invalid", "{not an array}", "", '{"id":1}', "null"]) {
|
||||
values.requestManifest = invalidManifest;
|
||||
expect(() => buildDisplayConfigFromFormValues(values)).not.toThrow();
|
||||
const config = buildDisplayConfigFromFormValues(values) as { requests?: unknown };
|
||||
expect(config.requests).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -803,6 +803,10 @@
|
||||
"editor": {
|
||||
"saveBlocked": "Resolve the editor errors before saving.",
|
||||
"saveReady": "The configuration is ready to save.",
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
"retry": "Try again"
|
||||
},
|
||||
"language": {
|
||||
"jsx": "JSX",
|
||||
"json": "JSON"
|
||||
@@ -4084,6 +4088,7 @@
|
||||
},
|
||||
"noDefinition": "No definition selected",
|
||||
"definitionNotFound": "Widget definition not found. It may have been deleted.",
|
||||
"editModePending": "Save the board to load this widget.",
|
||||
"noDisplayOutput": "No display output",
|
||||
"openJson": "Open in browser JSON viewer",
|
||||
"execute": "Execute",
|
||||
|
||||
@@ -129,7 +129,7 @@ function CustomApiWidgetInner({
|
||||
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
|
||||
{ itemId: itemId ?? "" },
|
||||
{
|
||||
enabled: Boolean(itemId),
|
||||
enabled: Boolean(itemId) && !isEditMode,
|
||||
refetchInterval: (query) => {
|
||||
const result = query.state.data as Record<string, unknown> | undefined;
|
||||
if (
|
||||
@@ -170,7 +170,18 @@ function CustomApiWidgetInner({
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
if (!data) {
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("editModePending")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const widgetData = data as Record<string, unknown>;
|
||||
const dataType = widgetData.type as string | undefined;
|
||||
|
||||
Reference in new issue
Block a user