fix(custom-widgets): address v2 merge review (#6446)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-23 01:55:15 +02:00
1 parent 6aceb2fd1f
commit df967751f6
123 files changed
+8632 -1477

No files matched your search

+25 -15
View File
@@ -6,8 +6,10 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
@@ -26,8 +28,9 @@ export default function CustomApiWidget({
{ itemId: itemId ?? "" },
{
enabled: Boolean(itemId) && Boolean(definitionId),
refetchInterval: (currentQuery) => (isTerminalDefinitionError(currentQuery.state.error) ? false : intervalMs),
retry: (failureCount, error) => !isTerminalDefinitionError(error) && failureCount < 3,
refetchInterval: (currentQuery) =>
isTerminalCustomWidgetDefinitionError(currentQuery.state.error) ? false : intervalMs,
retry: (failureCount, error) => !isTerminalCustomWidgetDefinitionError(error) && failureCount < 3,
},
);
@@ -43,6 +46,7 @@ export default function CustomApiWidget({
);
if (query.error) {
const errorCode = query.error.data?.code;
const migrationRequired = isLegacyCustomWidgetMigrationError(query.error);
if (isEditMode && errorCode === "NOT_FOUND") {
return <Unavailable message={t("editModePending")} />;
}
@@ -50,14 +54,18 @@ export default function CustomApiWidget({
return (
<Unavailable
message={
isUnavailable
? t("definitionNotFound")
: errorCode === "PRECONDITION_FAILED"
? t("configurationNeedsRepair")
: tCustomJsx("requestFailed")
migrationRequired
? t("migrationRequired")
: isUnavailable
? t("definitionNotFound")
: errorCode === "PRECONDITION_FAILED"
? t("configurationNeedsRepair")
: tCustomJsx("requestFailed")
}
danger={!isUnavailable}
danger={!isUnavailable && !migrationRequired}
removeLabel={isUnavailable ? t("removeFromBoard") : undefined}
actionLabel={migrationRequired ? t("manageMigration") : undefined}
actionHref={migrationRequired ? "/manage/custom-widgets" : undefined}
onRemove={removeItem}
/>
);
@@ -76,22 +84,19 @@ export default function CustomApiWidget({
);
}
function isTerminalDefinitionError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("data" in error)) return false;
const data = error.data;
if (!data || typeof data !== "object" || !("code" in data)) return false;
return data.code === "NOT_FOUND" || data.code === "FORBIDDEN" || data.code === "PRECONDITION_FAILED";
}
function Unavailable({
message,
danger = false,
removeLabel,
actionLabel,
actionHref,
onRemove,
}: {
message: string;
danger?: boolean;
removeLabel?: string;
actionLabel?: string;
actionHref?: string;
onRemove?: () => void;
}) {
return (
@@ -101,6 +106,11 @@ function Unavailable({
<Text c="dimmed" size="sm" ta="center">
{message}
</Text>
{actionLabel && actionHref && (
<Button component={Link} href={actionHref} size="compact-sm" color="yellow" variant="light">
{actionLabel}
</Button>
)}
{removeLabel && onRemove && (
<Button size="compact-sm" color="red" variant="light" onClick={onRemove}>
{removeLabel}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { isLegacyCustomWidgetMigrationError, isTerminalCustomWidgetDefinitionError } from "./migration-state";
describe("custom widget migration state", () => {
it("recognizes the preserved legacy definition error and stops retries", () => {
const error = {
message: "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED",
data: { code: "PRECONDITION_FAILED" },
};
expect(isLegacyCustomWidgetMigrationError(error)).toBe(true);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
});
it("does not label an invalid v2 configuration as a legacy migration", () => {
const error = {
message: "Custom widget configuration needs repair",
data: { code: "PRECONDITION_FAILED" },
};
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(true);
});
it("allows transient request failures to retry", () => {
const error = { message: "Request failed", data: { code: "INTERNAL_SERVER_ERROR" } };
expect(isLegacyCustomWidgetMigrationError(error)).toBe(false);
expect(isTerminalCustomWidgetDefinitionError(error)).toBe(false);
});
});
@@ -0,0 +1,13 @@
const LEGACY_MIGRATION_ERROR = "LEGACY_CUSTOM_WIDGET_MIGRATION_REQUIRED";
const TERMINAL_DEFINITION_CODES = new Set(["NOT_FOUND", "FORBIDDEN", "PRECONDITION_FAILED"]);
export function isLegacyCustomWidgetMigrationError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "message" in error && error.message === LEGACY_MIGRATION_ERROR);
}
export function isTerminalCustomWidgetDefinitionError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("data" in error)) return false;
const data = error.data;
if (!data || typeof data !== "object" || !("code" in data) || typeof data.code !== "string") return false;
return TERMINAL_DEFINITION_CODES.has(data.code);
}