fix: repair stale custom widget options
This commit is contained in:
1 parent
03ebb8c2db
commit
c2cf25b4fb
5 files changed
+81
-10
No files matched your search
@@ -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<string, unknown> = {},
|
||||
) {
|
||||
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: "<Text>{options.limit}</Text>",
|
||||
};
|
||||
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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
|
||||
@@ -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: "<Text>{options.limit}</Text>",
|
||||
}).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({
|
||||
|
||||
@@ -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<string, CustomWidgetOption>) : 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user