feat(custom-widgets): complete JSX v2

This commit is contained in:
Thomas Camlong committed 2026-07-18 15:33:25 +02:00
1 parent c0f1d6624c
commit eae48f50fd
263 files changed
+65503 -17816

No files matched your search

@@ -4,11 +4,13 @@ import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
export type EditorDiagnosticCode =
| "ast"
| "dynamicRequestId"
| "dynamicStateBinding"
| "inlineRequestProps"
| "invalidRequestManifest"
| "legacyNetworkProps"
| "missingRequestId"
| "templateEmpty"
| "templateTooLong"
| "unknownStateBinding"
| "unknownRequest";
export interface EditorDiagnostic {
@@ -21,16 +23,17 @@ export interface EditorDiagnostic {
}
export interface AnalyzeJsxTemplateOptions {
apiVersion?: 1 | 2;
requestIds?: string[];
stateKeys?: string[];
}
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
const stateBindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
const lineAt = (source: string, index: number) => source.slice(0, index).split("\n").length;
export const CUSTOM_JSX_TEMPLATE_LIMIT = CUSTOM_JSX_LIMITS.templateLength;
export function analyzeJsxTemplate(
template: string,
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
{ requestIds = [], stateKeys = [] }: AnalyzeJsxTemplateOptions = {},
): EditorDiagnostic[] {
const diagnostics: EditorDiagnostic[] = [];
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
@@ -46,13 +49,12 @@ export function analyzeJsxTemplate(
value: entry.message,
})),
);
if (apiVersion !== 2) return diagnostics;
const knownIds = new Set(requestIds);
for (const match of template.matchAll(networkComponentPattern)) {
const props = match[2] ?? "";
const line = lineAt(template, match.index);
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
diagnostics.push({ code: "inlineRequestProps", severity: "error", line, index: match.index });
}
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
if (staticRequest?.[1] && !knownIds.has(staticRequest[1])) {
@@ -69,6 +71,26 @@ export function analyzeJsxTemplate(
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
}
}
const knownState = new Set(stateKeys);
for (const match of template.matchAll(stateBindingPattern)) {
const stateName = match[1] ?? match[2];
if (!stateName) {
diagnostics.push({
code: "dynamicStateBinding",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
});
} else if (!knownState.has(stateName)) {
diagnostics.push({
code: "unknownStateBinding",
severity: "error",
line: lineAt(template, match.index),
index: match.index,
value: stateName,
});
}
}
return diagnostics;
}
@@ -1,105 +1,39 @@
import { z } from "zod/v4";
import { buildDisplayFormValues } from "../core";
import { CUSTOM_WIDGET_STARTER } from "../core";
const requiredScalarFields: Record<string, string[]> = {
singleValue: ["jsonPath"],
table: ["tablePath"],
raw: ["rawJsonPath"],
actionButton: ["buttonLabel"],
customJsx: ["template"],
};
const requiredCollectionFields: Record<string, { collection: string; fields: string[] }> = {
keyValue: { collection: "mappings", fields: ["label", "jsonPath"] },
table: { collection: "columns", fields: ["header", "jsonPath"] },
statGrid: { collection: "statGridItems", fields: ["label", "jsonPath"] },
progressBars: { collection: "progressBars", fields: ["label", "valuePath"] },
statusIndicator: { collection: "statusItems", fields: ["label", "jsonPath", "goodValues"] },
countGrid: { collection: "countGridItems", fields: ["label", "jsonPath"] },
};
const jsonObjectString = z.string().superRefine((value, ctx) => {
try {
const parsed = JSON.parse(value) as unknown;
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
} catch {
ctx.addIssue({ code: "custom", message: "Must be a JSON object" });
}
});
const requiredString = z.string();
export const customWidgetFormSchema = z
.object({
name: z.string().trim().min(1).max(128),
description: requiredString,
iconUrl: requiredString,
url: z.string().trim().min(1),
authType: requiredString,
headerName: requiredString,
method: requiredString,
requestBody: requiredString,
displayType: requiredString,
jsonPath: requiredString,
label: requiredString,
unit: requiredString,
valueSize: requiredString,
labelPosition: requiredString,
mappings: z.array(z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString })),
kvLayout: requiredString,
kvColumns: z.number(),
tablePath: requiredString,
columns: z.array(z.object({ header: requiredString, jsonPath: requiredString })),
striped: z.boolean(),
compact: z.boolean(),
statGridItems: z.array(
z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString, color: requiredString }),
),
statGridColumns: z.number(),
cardStyle: requiredString,
progressBars: z.array(
z.object({
label: requiredString,
valuePath: requiredString,
maxPath: requiredString,
unit: requiredString,
color: requiredString,
}),
),
showPercentage: z.boolean(),
barSize: requiredString,
statusItems: z.array(z.object({ label: requiredString, jsonPath: requiredString, goodValues: requiredString })),
statusLayout: requiredString,
dotSize: requiredString,
countGridItems: z.array(z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString })),
countGridColumns: z.number(),
countValueSize: requiredString,
rawJsonPath: requiredString,
rawMaxHeight: z.number(),
buttonLabel: requiredString,
buttonColor: requiredString,
confirmText: requiredString,
successMessage: requiredString,
template: requiredString,
jsxApiVersion: requiredString,
networkScope: requiredString,
requestManifest: requiredString,
secrets: z.array(z.object({ kind: requiredString, value: requiredString, hasValue: z.boolean().optional() })),
})
.superRefine((data, ctx) => {
for (const field of requiredScalarFields[data.displayType] ?? []) {
if (!data[field as keyof typeof data]) addRequiredIssue(ctx, [field], data[field as keyof typeof data]);
}
const collection = requiredCollectionFields[data.displayType];
if (collection) {
const entries = data[collection.collection as keyof typeof data];
if (Array.isArray(entries))
entries.forEach((entry, index) => {
for (const field of collection.fields) {
const value = (entry as Record<string, unknown>)[field];
if (!value) addRequiredIssue(ctx, [collection.collection, index, field], value);
}
});
}
if (data.displayType === "customJsx" && data.jsxApiVersion === "2" && !isJsonArray(data.requestManifest)) {
ctx.addIssue({
code: "custom",
message: "invalidRequestManifest",
input: data.requestManifest,
path: ["requestManifest"],
});
}
});
const jsonArrayString = z.string().superRefine((value, ctx) => {
try {
if (!Array.isArray(JSON.parse(value))) throw new Error();
} catch {
ctx.addIssue({ code: "custom", message: "Must be a JSON array" });
}
});
export const customWidgetFormSchema = z.object({
name: z.string().trim().min(1).max(128),
description: z.string().max(512),
iconUrl: z.union([z.literal(""), z.string().url()]),
sources: jsonArrayString,
requests: jsonArrayString,
optionsSchema: jsonObjectString,
defaultOptions: jsonObjectString,
stateSchema: jsonObjectString,
defaultState: jsonObjectString,
template: z.string().min(1).max(50_000),
secrets: z.array(
z.object({ sourceId: z.string(), kind: z.string(), value: z.string(), hasValue: z.boolean().optional() }),
),
});
export type CustomWidgetFormValues = z.infer<typeof customWidgetFormSchema>;
@@ -116,29 +50,16 @@ export const CUSTOM_WIDGET_AUTH_SECRET_FIELDS: Record<
apiKeyQuery: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }],
};
export const CUSTOM_WIDGET_AUTH_USES_HEADER_NAME: Record<string, boolean> = { apiKeyHeader: true, apiKeyQuery: true };
export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
name: "",
description: "",
iconUrl: "",
url: "",
authType: "none",
headerName: "",
method: "GET",
requestBody: "",
...buildDisplayFormValues("singleValue", {}),
name: CUSTOM_WIDGET_STARTER.name,
description: CUSTOM_WIDGET_STARTER.description ?? "",
iconUrl: CUSTOM_WIDGET_STARTER.iconUrl ?? "",
sources: JSON.stringify(CUSTOM_WIDGET_STARTER.sources, null, 2),
requests: JSON.stringify(CUSTOM_WIDGET_STARTER.requests, null, 2),
optionsSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.optionsSchema, null, 2),
defaultOptions: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultOptions, null, 2),
stateSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.stateSchema ?? {}, null, 2),
defaultState: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultState ?? {}, null, 2),
template: CUSTOM_WIDGET_STARTER.template,
secrets: [],
};
function addRequiredIssue(ctx: z.core.$RefinementCtx, path: PropertyKey[], input: unknown): void {
ctx.addIssue({ code: "too_small", minimum: 1, origin: "string", inclusive: true, input, path });
}
function isJsonArray(value: string): boolean {
try {
return Array.isArray(JSON.parse(value));
} catch {
return false;
}
}
@@ -49,9 +49,9 @@ export function ImportReviewDialog({ opened, review, pending, messages, onClose,
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<ImportFact label={messages.name} value={review.name} />
<ImportFact label={messages.origin} value={review.origin} />
<ImportFact label={messages.authentication} value={review.authType} />
<ImportFact label={messages.networkScope} value={review.networkScope} />
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
</SimpleGrid>
<div>
<Text size="sm" fw={600} mb={6}>
@@ -9,9 +9,9 @@ export * from "./preview-request-panel";
export * from "./preview-diagnostics-panel";
export * from "./preview-header";
export * from "./preview-utils";
export * from "./request-references";
export {
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
customWidgetFormSchema,
} from "./form-schema";
@@ -10,9 +10,9 @@ export const redactPreviewUrl = (value: string): string => {
}
};
export const getPreviewNamedRequests = (displayConfig: Record<string, unknown>): PreviewNamedRequest[] => {
if (!Array.isArray(displayConfig.requests)) return [];
return displayConfig.requests.filter(
export const getPreviewNamedRequests = (definition: Record<string, unknown>): PreviewNamedRequest[] => {
if (!Array.isArray(definition.requests)) return [];
return definition.requests.filter(
(request): request is PreviewNamedRequest =>
request !== null &&
typeof request === "object" &&
@@ -27,11 +27,47 @@ export const getPreviewNamedRequests = (displayConfig: Record<string, unknown>):
);
};
export const getPreviewLegacyMethods = (template: string): string[] => {
const methods = new Set<string>();
for (const match of template.matchAll(/<(?:SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu)) {
const method = match[1]?.match(/\bmethod\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/iu)?.[1];
methods.add(method?.toUpperCase() ?? (match[0].startsWith("<SubFetch") ? "GET" : "POST"));
export type CustomWidgetAccessibilityIssue = "imageAlt" | "actionIconLabel" | "inputLabel";
export function analyzeCustomWidgetAccessibility(template: string): CustomWidgetAccessibilityIssue[] {
const issues = new Set<CustomWidgetAccessibilityIssue>();
const elements = template.matchAll(/<([A-Z][A-Za-z0-9.]*)\b([^>]*)>/gu);
const inputComponents = new Set([
"Autocomplete",
"Checkbox",
"ColorInput",
"DateInput",
"FileInput",
"JsonInput",
"MultiSelect",
"NumberInput",
"PasswordInput",
"Radio",
"Select",
"Slider",
"Switch",
"TagsInput",
"TextInput",
"Textarea",
"TimeInput",
]);
for (const match of elements) {
const component = match[1] ?? "";
const props = match[2] ?? "";
if (component === "Image" && !hasProp(props, "alt")) issues.add("imageAlt");
if (component === "ActionIcon" && !hasAnyProp(props, ["aria-label", "title"])) issues.add("actionIconLabel");
if (inputComponents.has(component) && !hasAnyProp(props, ["label", "aria-label", "aria-labelledby"])) {
issues.add("inputLabel");
}
}
return [...methods];
};
return [...issues];
}
function hasAnyProp(props: string, names: string[]) {
return names.some((name) => hasProp(props, name));
}
function hasProp(props: string, name: string) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
return new RegExp(`(?:^|\\s)${escapedName}\\s*=`, "u").test(props);
}
@@ -0,0 +1,52 @@
import { customWidgetDefinitionSchema } from "../core/custom-jsx-schema";
import { customWidgetIdentifierSchema } from "../core/request-schema";
import type { HomarrCustomWidgetV2 } from "../core/custom-jsx-schema";
export function renameCustomWidgetRequest(
definition: HomarrCustomWidgetV2,
currentId: string,
nextId: string,
): HomarrCustomWidgetV2 {
const parsedId = customWidgetIdentifierSchema.safeParse(nextId);
if (!parsedId.success) throw new Error(parsedId.error.issues[0]?.message ?? "Invalid request ID");
if (!definition.requests.some((request) => request.id === currentId))
throw new Error(`Unknown request '${currentId}'`);
if (currentId !== nextId && definition.requests.some((request) => request.id === nextId)) {
throw new Error(`Request '${nextId}' already exists`);
}
const candidate = {
...definition,
requests: definition.requests.map((request) => ({
...request,
id: request.id === currentId ? nextId : request.id,
invalidates: request.invalidates?.map((id) => (id === currentId ? nextId : id)),
})),
optionsSchema: replaceDynamicOptionRequest(definition.optionsSchema, currentId, nextId),
template: replaceTemplateRequestReferences(definition.template, currentId, nextId),
};
return customWidgetDefinitionSchema.parse(candidate);
}
function replaceDynamicOptionRequest<T>(value: T, currentId: string, nextId: string): T {
if (Array.isArray(value)) return value.map((entry) => replaceDynamicOptionRequest(entry, currentId, nextId)) as T;
if (value === null || typeof value !== "object") return value;
const record = value as Record<string, unknown>;
const replaced = Object.fromEntries(
Object.entries(record).map(([key, child]) => [key, replaceDynamicOptionRequest(child, currentId, nextId)]),
);
if (record.requestId === currentId && "valuePath" in record && "labelPath" in record) replaced.requestId = nextId;
return replaced as T;
}
function replaceTemplateRequestReferences(template: string, currentId: string, nextId: string) {
const escaped = escapeRegExp(currentId);
return template
.replace(new RegExp(`(requestId\\s*=\\s*["'])${escaped}(["'])`, "gu"), `$1${nextId}$2`)
.replace(new RegExp(`((?:data|status)\\s*\\[\\s*["'])${escaped}(["']\\s*\\])`, "gu"), `$1${nextId}$2`)
.replace(new RegExp(`((?:data|status)\\.)${escaped}\\b`, "gu"), `$1${nextId}`);
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}