From c2cf25b4fb7703ccdcfa0524db645b62aebb2dbd Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 09:17:32 +0200 Subject: [PATCH] fix: repair stale custom widget options --- .../router/test/widgets/custom-api-v2.spec.ts | 24 +++++++++++++++--- packages/api/src/router/widgets/custom-api.ts | 6 ++++- packages/custom-widgets/src/core/options.ts | 12 +++++++++ .../custom-widgets/src/test/options.spec.ts | 24 ++++++++++++++++++ ...dget-custom-widget-configuration-input.tsx | 25 ++++++++++++++----- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/packages/api/src/router/test/widgets/custom-api-v2.spec.ts b/packages/api/src/router/test/widgets/custom-api-v2.spec.ts index a5e6f3c50..8bfc75319 100644 --- a/packages/api/src/router/test/widgets/custom-api-v2.spec.ts +++ b/packages/api/src/router/test/widgets/custom-api-v2.spec.ts @@ -84,7 +84,11 @@ const createSession = (userId: string): Session => ({ expires: new Date(Date.now() + 60_000).toISOString(), }); -async function setup(isPublic = true) { +async function setup( + isPublic = true, + customDefinition: HomarrCustomWidgetV2 = definition, + configuration: Record = {}, +) { const db = createDb(); const ownerId = createId(); const boardId = createId(); @@ -94,14 +98,14 @@ async function setup(isPublic = true) { await db.insert(boards).values({ id: boardId, name: createId(), creatorId: ownerId, isPublic }); await db.insert(customWidgetDefinitions).values({ id: definitionId, - ...serializeCustomWidgetDefinition(definition), + ...serializeCustomWidgetDefinition(customDefinition), creatorId: ownerId, }); await db.insert(items).values({ id: itemId, boardId, kind: "customApi", - options: stringifySuperJson({ definitionId, configuration: {}, configurationVersion: 1 }), + options: stringifySuperJson({ definitionId, configuration, configurationVersion: 1 }), }); return { db, ownerId, boardId, itemId, definitionId }; } @@ -122,6 +126,20 @@ describe("Custom JSX v2 board router", () => { expect(mocks.executeRequest).toHaveBeenCalledOnce(); }); + test("uses current defaults when an updated option no longer accepts the stored value", async () => { + const updatedDefinition: HomarrCustomWidgetV2 = { + ...definition, + options: { + limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 }, + }, + template: "{options.limit}", + }; + const { db, itemId } = await setup(true, updatedDefinition, { limit: "5", removed: true }); + const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null }); + + await expect(caller.getData({ itemId })).resolves.toMatchObject({ options: { limit: 5 } }); + }); + test("hides private boards and disabled definitions before executing a request", async () => { const privateSetup = await setup(false); const anonymous = customApiRouter.createCaller({ diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index ac8462a73..d77125f90 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -9,6 +9,7 @@ import type { BoardPermission } from "@homarr/definitions"; import { getCustomWidgetConfirmation, getCustomWidgetDefaultOptions, + normalizeCustomWidgetOptions, validateCustomWidgetOptions, } from "@homarr/custom-widgets/core"; import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core"; @@ -97,7 +98,10 @@ async function resolvePlacedDefinitionAsync(ctx: RouterContext, itemId: string) if (!stored.enabled) throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" }); const definition = parseStoredCustomWidgetDefinition(stored); - const configuration = { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration }; + const configuration = + itemOptions.configurationVersion === stored.updatedAt.getTime() + ? { ...getCustomWidgetDefaultOptions(definition.options), ...itemOptions.configuration } + : normalizeCustomWidgetOptions(definition.options, itemOptions.configuration); const issues = validateCustomWidgetOptions(definition.options, configuration); if (issues.length > 0) { throw new TRPCError({ diff --git a/packages/custom-widgets/src/core/options.ts b/packages/custom-widgets/src/core/options.ts index b6ca003b0..0cc292cba 100644 --- a/packages/custom-widgets/src/core/options.ts +++ b/packages/custom-widgets/src/core/options.ts @@ -12,6 +12,18 @@ export function getCustomWidgetDefaultOptions( return Object.fromEntries(Object.entries(options).map(([name, option]) => [name, option.default])); } +export function normalizeCustomWidgetOptions( + options: CustomWidgetOptions, + value: Record, +): Record { + return Object.fromEntries( + Object.entries(options).map(([name, option]) => [ + name, + getCustomWidgetOptionValueIssue(option, value[name]) ? option.default : value[name], + ]), + ); +} + export function validateCustomWidgetOptions( options: CustomWidgetOptions, value: Record, diff --git a/packages/custom-widgets/src/test/options.spec.ts b/packages/custom-widgets/src/test/options.spec.ts index bd3eac0ef..078cfc62a 100644 --- a/packages/custom-widgets/src/test/options.spec.ts +++ b/packages/custom-widgets/src/test/options.spec.ts @@ -5,6 +5,7 @@ import { customWidgetOptionControls, customWidgetOptionSchema, getCustomWidgetDefaultOptions, + normalizeCustomWidgetOptions, validateCustomWidgetOptions, } from "../core"; @@ -69,6 +70,29 @@ describe("lean Custom Widget options", () => { expect(validateCustomWidgetOptions(options, configuration)).toEqual([]); }); + it("replaces stale values with current defaults", () => { + const options = customWidgetDefinitionSchema.parse({ + $schema: "homarr-custom-widget-v2", + name: "Updated options", + sources: {}, + requests: {}, + options: { + limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 }, + label: { label: "Label", control: "text", default: "Default" }, + }, + template: "{options.limit}", + }).options; + + const normalized = normalizeCustomWidgetOptions(options, { + limit: "5", + label: "Custom", + removed: true, + }); + + expect(normalized).toEqual({ limit: 5, label: "Custom" }); + expect(validateCustomWidgetOptions(options, normalized)).toEqual([]); + }); + it("rejects duplicate static choice values", () => { expect( customWidgetOptionSchema.safeParse({ diff --git a/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx b/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx index 052563fee..619d8e0bc 100644 --- a/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx +++ b/packages/widgets/src/_inputs/widget-custom-widget-configuration-input.tsx @@ -21,7 +21,7 @@ import { IconAlertTriangle } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useOptionalBoard } from "@homarr/boards/context"; import type { CustomWidgetOption } from "@homarr/custom-widgets/core"; -import { validateCustomWidgetOptions } from "@homarr/custom-widgets/core"; +import { normalizeCustomWidgetOptions, validateCustomWidgetOptions } from "@homarr/custom-widgets/core"; import { CustomWidgetCodeEditor } from "@homarr/custom-widgets/workbench"; import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench"; import { IconPicker } from "@homarr/forms-collection"; @@ -45,10 +45,23 @@ export const WidgetCustomWidgetConfigurationInput = ({ ); const definition = available.data?.find((candidate) => candidate.id === definitionId); const options = isRecord(definition?.options) ? (definition.options as Record) : null; - const issues = useMemo( - () => (options ? validateCustomWidgetOptions(options, configuration) : []), - [configuration, options], + const definitionVersion = definition?.updatedAt instanceof Date ? definition.updatedAt.getTime() : undefined; + const configurationVersion = form.values.options.configurationVersion; + const needsDefinitionRepair = definitionVersion !== undefined && configurationVersion !== definitionVersion; + const effectiveConfiguration = useMemo( + () => (options && needsDefinitionRepair ? normalizeCustomWidgetOptions(options, configuration) : configuration), + [configuration, needsDefinitionRepair, options], ); + const issues = useMemo( + () => (options ? validateCustomWidgetOptions(options, effectiveConfiguration) : []), + [effectiveConfiguration, options], + ); + + useEffect(() => { + if (!needsDefinitionRepair || definitionVersion === undefined) return; + form.setFieldValue(`options.${property}`, effectiveConfiguration); + form.setFieldValue("options.configurationVersion", definitionVersion); + }, [definitionVersion, effectiveConfiguration, form, needsDefinitionRepair, property]); useEffect(() => { const path = `options.${property}`; @@ -72,7 +85,7 @@ export const WidgetCustomWidgetConfigurationInput = ({ key={`${definitionId}:${name}`} option={option} path={`options.${property}.${name}`} - configuration={configuration} + configuration={effectiveConfiguration} definitionId={definitionId} /> ))} @@ -87,7 +100,7 @@ export const WidgetCustomWidgetConfigurationInput = ({ key={`${definitionId}:${name}`} option={option} path={`options.${property}.${name}`} - configuration={configuration} + configuration={effectiveConfiguration} definitionId={definitionId} /> ))}