feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)
This commit is contained in:
1 parent
0a0b3a12c9
commit
3b2fc2527e
181 files changed
+18472
-4364
No files matched your search
@@ -0,0 +1,168 @@
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { enabledCustomJsxComponents } from "./component-registry";
|
||||
|
||||
const PROMPT_HEADER = `You are configuring a Homarr custom widget. Ask a concise clarifying question only when the requested behavior is ambiguous.
|
||||
|
||||
For Custom JSX, return a Homarr authoring bundle with exactly two fenced code blocks:
|
||||
1. A \`json\` block containing the complete import object. Set \`displayConfig.template\` to \`"__HOMARR_TEMPLATE__"\`.
|
||||
2. A \`jsx\` block containing the readable, multiline template with no JSON escaping.
|
||||
|
||||
The user can paste both blocks together into Homarr. Homarr combines and validates them before import. For non-Custom-JSX display types, return only the complete JSON block.
|
||||
|
||||
## Rules
|
||||
- Use \`"$schema": "homarr-custom-widget-v3"\`.
|
||||
- \`displayType\` and \`displayConfig.type\` must match.
|
||||
- Prefer \`customJsx\` with \`jsxApiVersion: 2\` for custom layouts.
|
||||
- The base widget request must use GET. Mutations belong in named action requests.
|
||||
- JSX cannot call \`fetch\` or use event handlers. Use SubFetch, ActionButton, ToggleSwitch, and RefreshButton.
|
||||
- Version 2 network components reference a literal \`requestId\` and pass only declared \`params\`.
|
||||
- SubFetch can reference query requests only. ActionButton and ToggleSwitch can reference action requests only.
|
||||
- Prefer the SubFetch function-as-children API for fetched data. The callback receives \`(response, metadata)\` and may return any safe JSX tree. Use SubData only for very small path-based displays.
|
||||
- SubFetch owns loading, failure, retry, cancellation, and stale-response handling. The render callback runs only when response data is available.
|
||||
- Named request paths are same-origin paths beginning with \`/\`. Do not put credentials in a template or export.
|
||||
- Query requests use GET and view permission. POST, PUT, and PATCH actions default to modify. DELETE requires full permission and confirmation.
|
||||
- Use only components in the generated component reference below.
|
||||
- Template output must fit inside a dashboard widget and remain usable at narrow widths.
|
||||
- Return the complete import object, not a template fragment. Preserve unrelated fields from the current configuration.
|
||||
- Include stable keys for rendered collections, accessible labels for controls and images, useful empty states, and concise content that works in light and dark themes.
|
||||
- Prefer one clear information hierarchy over nested cards. Use ActionButton and ToggleSwitch only when the named action and required permission are declared.
|
||||
|
||||
## Named request shape
|
||||
\`\`\`ts
|
||||
interface CustomJsxRequest {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
pathTemplate: \`/\${string}\`;
|
||||
parameters: Record<string, "string" | "number" | "boolean">;
|
||||
bodyTemplate?: unknown;
|
||||
staticHeaders?: Record<string, string>;
|
||||
auth: "inherit" | "none";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
cacheTtlSeconds?: number;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Path parameters use \`{name}\`. JSON body parameters use \`{ "$param": "name" }\` and preserve the declared type.
|
||||
|
||||
## SubFetch rendering
|
||||
Use a render child when a named query needs its own layout:
|
||||
|
||||
\`\`\`jsx
|
||||
<SubFetch requestId="service-detail" params={{ id: data.serviceId }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>{detail.name}</Text>
|
||||
<Badge color={meta.ok ? "green" : "red"}>HTTP {meta.status}</Badge>
|
||||
</Group>
|
||||
{detail.metrics.map((metric) =>
|
||||
<Group key={metric.name} justify="space-between">
|
||||
<Text size="sm">{metric.name}</Text>
|
||||
<Text fw={600}>{metric.value}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
The callback is an interpreter-managed inline arrow function. Do not use native callbacks, event handlers, global state, or direct network access. Keep list keys stable and design for narrow and wide widget sizes.
|
||||
`;
|
||||
|
||||
const PROMPT_NO_RESPONSE = `No sample response is available. Leave this marker in your reasoning and ask the user for a representative response if its shape is required:
|
||||
|
||||
\`\`\`json
|
||||
PASTE_API_RESPONSE_HERE
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
function buildComponentReference() {
|
||||
const categories = new Map<string, string[]>();
|
||||
for (const component of enabledCustomJsxComponents) {
|
||||
const names = categories.get(component.category) ?? [];
|
||||
names.push(component.name);
|
||||
categories.set(component.category, names);
|
||||
}
|
||||
|
||||
return [...categories.entries()].map(([category, names]) => `- ${category}: ${names.join(", ")}`).join("\n");
|
||||
}
|
||||
|
||||
function buildExamples() {
|
||||
return customJsxExamples
|
||||
.map(
|
||||
(example) => `### ${example.title}
|
||||
${example.description}
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
displayType: "customJsx",
|
||||
displayConfig: {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
networkScope: "public",
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
requests: example.requests,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
\`\`\`
|
||||
\`\`\`jsx
|
||||
${example.template}
|
||||
\`\`\``,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildCustomWidgetAiPrompt(
|
||||
jsonSchema: unknown,
|
||||
rawResponse?: string | null,
|
||||
currentConfig?: Record<string, unknown> | null,
|
||||
) {
|
||||
const responseSection = rawResponse
|
||||
? `## API response\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
|
||||
: `## API response\n\n${PROMPT_NO_RESPONSE}`;
|
||||
const currentDisplayConfig = currentConfig?.displayConfig;
|
||||
const currentTemplate =
|
||||
currentDisplayConfig && typeof currentDisplayConfig === "object" && "template" in currentDisplayConfig
|
||||
? currentDisplayConfig.template
|
||||
: null;
|
||||
const readableCurrentConfig = currentConfig
|
||||
? {
|
||||
...currentConfig,
|
||||
...(currentTemplate && typeof currentTemplate === "string"
|
||||
? {
|
||||
displayConfig: {
|
||||
...(currentDisplayConfig as Record<string, unknown>),
|
||||
template: "__HOMARR_TEMPLATE__",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
const configSection = readableCurrentConfig
|
||||
? `## Current configuration\n\nUse this as the starting point and preserve unrelated settings.\n\n\`\`\`json\n${JSON.stringify(readableCurrentConfig, null, 2)}\n\`\`\`\n${typeof currentTemplate === "string" ? `\n\`\`\`jsx\n${currentTemplate}\n\`\`\`\n` : ""}`
|
||||
: "";
|
||||
|
||||
return `${PROMPT_HEADER}
|
||||
## Import schema
|
||||
The \`__HOMARR_TEMPLATE__\` value is an authoring transport placeholder. Homarr replaces it with the following JSX block before schema and AST validation.
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(jsonSchema, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
## Generated component reference
|
||||
${buildComponentReference()}
|
||||
|
||||
## Tested examples
|
||||
${buildExamples()}
|
||||
|
||||
${responseSection}
|
||||
${configSection}
|
||||
## Requested widget
|
||||
Describe what the widget should show and which interactions it needs:
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const singleValueDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("singleValue")
|
||||
.describe("Display type discriminator — must be 'singleValue' when displayType is singleValue"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the value from the API response (e.g. $.data.count, $.items[0].name, $.status). Use $ for the root object.",
|
||||
),
|
||||
label: z
|
||||
.string()
|
||||
.describe("Human-readable label shown alongside the extracted value (e.g. 'CPU Temperature', 'Total Users')"),
|
||||
unit: z
|
||||
.string()
|
||||
.describe("Unit suffix displayed after the value (e.g. '°C', '%', 'GB'). Leave empty string if no unit is needed."),
|
||||
valueSize: z
|
||||
.enum(["sm", "md", "lg", "xl"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Font size of the displayed value: 'sm' (small), 'md' (medium), 'lg' (large, default), 'xl' (extra large)",
|
||||
),
|
||||
labelPosition: z
|
||||
.enum(["above", "below"])
|
||||
.optional()
|
||||
.describe("Position of the label relative to the value: 'above' or 'below' (default: below)"),
|
||||
});
|
||||
|
||||
const keyValueMappingSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this key-value pair (e.g. 'CPU', 'Memory', 'Uptime')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract this value from the API response (e.g. $.cpu.percent, $.memory.used). Use $ for the root object.",
|
||||
),
|
||||
unit: z
|
||||
.string()
|
||||
.describe("Unit suffix for this value (e.g. '%', 'GB', 'ms'). Leave empty string if no unit is needed."),
|
||||
});
|
||||
|
||||
export const keyValueDisplayConfigSchema = z.object({
|
||||
type: z.literal("keyValue").describe("Display type discriminator — must be 'keyValue' when displayType is keyValue"),
|
||||
mappings: z
|
||||
.array(keyValueMappingSchema)
|
||||
.min(1)
|
||||
.describe("Array of label/jsonPath/unit mappings — each entry becomes one labeled value in the widget"),
|
||||
layout: z
|
||||
.enum(["list", "grid"])
|
||||
.optional()
|
||||
.describe("Layout style: 'list' (vertical stack, default) or 'grid' (multi-column grid)"),
|
||||
columns: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(3)
|
||||
.optional()
|
||||
.describe("Number of columns when layout is 'grid' (1–3, default: 2)"),
|
||||
});
|
||||
|
||||
const tableColumnSchema = z.object({
|
||||
header: z.string().min(1).describe("Column header text shown in the table (e.g. 'Name', 'Status', 'Size')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression relative to each row object to extract the cell value (e.g. $.name, $.status, $.size). Do not include the array path here — that goes in tablePath.",
|
||||
),
|
||||
});
|
||||
|
||||
export const tableDisplayConfigSchema = z.object({
|
||||
type: z.literal("table").describe("Display type discriminator — must be 'table' when displayType is table"),
|
||||
tablePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to the array of row objects in the API response (e.g. $.data.items, $.results, $.users[*])",
|
||||
),
|
||||
columns: z
|
||||
.array(tableColumnSchema)
|
||||
.min(1)
|
||||
.describe("Column definitions — each column has a header and a jsonPath relative to each row"),
|
||||
striped: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Whether to alternate row background colors for readability (default: true)"),
|
||||
compact: z.boolean().optional().describe("Whether to use compact row spacing (default: false)"),
|
||||
});
|
||||
|
||||
const statGridItemSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this stat card (e.g. 'Movies', 'Series', 'Episodes')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the stat value from the API response (e.g. $.library.movies, $.counts.series)",
|
||||
),
|
||||
unit: z.string().describe("Unit suffix for the stat (e.g. '', 'GB', '%'). Leave empty string if no unit is needed."),
|
||||
color: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Mantine color name for the stat card (e.g. 'blue', 'green', 'red', 'orange', 'violet'). Default: blue"),
|
||||
});
|
||||
|
||||
export const statGridDisplayConfigSchema = z.object({
|
||||
type: z.literal("statGrid").describe("Display type discriminator — must be 'statGrid' when displayType is statGrid"),
|
||||
items: z
|
||||
.array(statGridItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of stat cards — each item shows a labeled value in a colored card"),
|
||||
columns: z.number().int().min(1).max(4).optional().describe("Number of columns in the stat grid (1–4, default: 2)"),
|
||||
cardStyle: z
|
||||
.enum(["filled", "outline", "subtle"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Visual style of stat cards: 'filled' (solid background, default), 'outline' (border only), 'subtle' (light tint)",
|
||||
),
|
||||
});
|
||||
|
||||
const progressBarItemSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this progress bar (e.g. 'Disk Usage', 'Memory', 'Quota')"),
|
||||
valuePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the current value (numerator) from the API response (e.g. $.disk.used, $.memory.used_bytes)",
|
||||
),
|
||||
maxPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"JSONPath expression to extract the maximum value (denominator). If omitted, defaults to 100. Use for ratios like used/total (e.g. $.disk.total)",
|
||||
),
|
||||
unit: z
|
||||
.string()
|
||||
.describe("Unit suffix shown alongside values (e.g. 'GB', 'MB', '%'). Leave empty string if no unit is needed."),
|
||||
color: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Mantine color name for the progress bar fill (e.g. 'blue', 'green', 'red'). Default: blue"),
|
||||
});
|
||||
|
||||
export const progressBarsDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("progressBars")
|
||||
.describe("Display type discriminator — must be 'progressBars' when displayType is progressBars"),
|
||||
bars: z
|
||||
.array(progressBarItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of progress bar definitions — each bar shows value/max as a visual progress indicator"),
|
||||
showPercentage: z.boolean().optional().describe("Whether to display the percentage next to each bar (default: true)"),
|
||||
barSize: z
|
||||
.enum(["sm", "md", "lg"])
|
||||
.optional()
|
||||
.describe("Height of progress bars: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
|
||||
});
|
||||
|
||||
const statusIndicatorItemSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this status indicator (e.g. 'Web Server', 'Database', 'API')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the status value from the API response (e.g. $.services.web.status, $.health.database)",
|
||||
),
|
||||
goodValues: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe(
|
||||
"Array of string values considered 'healthy/good' — case-insensitive match (e.g. ['online', 'up', 'true', 'healthy', 'ok'])",
|
||||
),
|
||||
});
|
||||
|
||||
export const statusIndicatorDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("statusIndicator")
|
||||
.describe("Display type discriminator — must be 'statusIndicator' when displayType is statusIndicator"),
|
||||
items: z
|
||||
.array(statusIndicatorItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of status indicators — each shows a green dot if the value matches goodValues, red otherwise"),
|
||||
layout: z
|
||||
.enum(["list", "grid"])
|
||||
.optional()
|
||||
.describe("Layout style: 'list' (vertical, default) or 'grid' (multi-column)"),
|
||||
dotSize: z
|
||||
.enum(["sm", "md", "lg"])
|
||||
.optional()
|
||||
.describe("Size of status dots: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
|
||||
});
|
||||
|
||||
const countGridItemSchema = z.object({
|
||||
label: z.string().min(1).describe("Label for this count (e.g. 'Movies', 'Shows', 'Albums')"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the count value from the API response (e.g. $.counts.movies, $.stats.total)",
|
||||
),
|
||||
unit: z.string().describe("Unit suffix for the count (e.g. '', 'items'). Leave empty string if no unit is needed."),
|
||||
});
|
||||
|
||||
export const countGridDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("countGrid")
|
||||
.describe("Display type discriminator — must be 'countGrid' when displayType is countGrid"),
|
||||
items: z
|
||||
.array(countGridItemSchema)
|
||||
.min(1)
|
||||
.describe("Array of count items — each shows a number with a label in a simple grid (no colors)"),
|
||||
columns: z.number().int().min(2).max(4).optional().describe("Number of columns in the count grid (2–4, default: 2)"),
|
||||
valueSize: z
|
||||
.enum(["sm", "md", "lg"])
|
||||
.optional()
|
||||
.describe("Font size of count values: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
|
||||
});
|
||||
|
||||
export const rawDisplayConfigSchema = z.object({
|
||||
type: z.literal("raw").describe("Display type discriminator — must be 'raw' when displayType is raw"),
|
||||
jsonPath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"JSONPath expression to extract the data to display (e.g. $ for entire response, $.data, $.result). The extracted value is pretty-printed as JSON.",
|
||||
),
|
||||
maxHeight: z
|
||||
.number()
|
||||
.int()
|
||||
.min(50)
|
||||
.max(1000)
|
||||
.optional()
|
||||
.describe("Maximum height of the raw JSON display area in pixels (50–1000, default: 300)"),
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { categorySafeProps, commonSafeProps } from "./component-props";
|
||||
import type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentPackage,
|
||||
CustomJsxComponentSafety,
|
||||
} from "./component-types";
|
||||
export type * from "./component-types";
|
||||
|
||||
export interface CustomJsxComponentDescriptor {
|
||||
name: string;
|
||||
package: CustomJsxComponentPackage;
|
||||
category: CustomJsxComponentCategory;
|
||||
safety: CustomJsxComponentSafety;
|
||||
supportedProps: readonly string[];
|
||||
subcomponents: readonly string[];
|
||||
accessibilityRequirements: readonly string[];
|
||||
documentationUrl: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export const subcomponentsByName: Readonly<Record<string, readonly string[]>> = {
|
||||
Accordion: ["Accordion.Item", "Accordion.Control", "Accordion.Panel"],
|
||||
Avatar: ["Avatar.Group"],
|
||||
Card: ["Card.Section"],
|
||||
Chip: ["Chip.Group"],
|
||||
DataList: ["DataList.Item", "DataList.ItemLabel", "DataList.ItemValue"],
|
||||
EmptyState: ["EmptyState.Indicator", "EmptyState.Title", "EmptyState.Description", "EmptyState.Actions"],
|
||||
Grid: ["Grid.Col"],
|
||||
List: ["List.Item"],
|
||||
Menu: ["Menu.Target", "Menu.Dropdown", "Menu.Item", "Menu.Label", "Menu.Divider"],
|
||||
Progress: ["Progress.Section"],
|
||||
Table: ["Table.Thead", "Table.Tbody", "Table.Tfoot", "Table.Caption", "Table.Tr", "Table.Th", "Table.Td"],
|
||||
Tabs: ["Tabs.List", "Tabs.Tab", "Tabs.Panel"],
|
||||
Timeline: ["Timeline.Item"],
|
||||
};
|
||||
|
||||
export const accessibilityByCategory: Record<CustomJsxComponentCategory, readonly string[]> = {
|
||||
layout: ["Keep visual order consistent with reading order."],
|
||||
content: ["Provide meaningful text alternatives for non-text content."],
|
||||
feedback: ["Do not rely on color alone to communicate status."],
|
||||
navigation: ["Provide an accessible label for controls without visible text."],
|
||||
charts: ["Include a nearby text summary of the chart data."],
|
||||
dates: ["Include a textual date when the visual calendar carries meaning."],
|
||||
interaction: ["Provide a visible label or aria-label."],
|
||||
network: ["Action labels must describe their effect; destructive actions require confirmation."],
|
||||
blocked: [],
|
||||
};
|
||||
|
||||
const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
|
||||
"Tabs.List": ["grow"],
|
||||
ScrollArea: ["offsetScrollbars"],
|
||||
SubData: ["alt", "fit"],
|
||||
};
|
||||
|
||||
export function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
|
||||
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
|
||||
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
|
||||
const slug = (name.split(".")[0] ?? "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
return `https://mantine.dev/${section}/${slug}/`;
|
||||
}
|
||||
|
||||
export const describe = (
|
||||
packageName: CustomJsxComponentPackage,
|
||||
category: CustomJsxComponentCategory,
|
||||
safety: CustomJsxComponentSafety,
|
||||
names: readonly string[],
|
||||
reason?: string,
|
||||
): CustomJsxComponentDescriptor[] =>
|
||||
names.map((name) => ({
|
||||
name,
|
||||
package: packageName,
|
||||
category,
|
||||
safety,
|
||||
supportedProps:
|
||||
safety === "denied"
|
||||
? []
|
||||
: [
|
||||
...new Set<string>([
|
||||
...commonSafeProps,
|
||||
...categorySafeProps[category],
|
||||
...(componentSafeProps[name] ?? []),
|
||||
]),
|
||||
],
|
||||
subcomponents: subcomponentsByName[name] ?? [],
|
||||
accessibilityRequirements: accessibilityByCategory[category],
|
||||
documentationUrl: documentationUrl(packageName, name),
|
||||
reason,
|
||||
}));
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { CustomJsxComponentCategory } from "./component-types";
|
||||
|
||||
export const commonSafeProps = [
|
||||
"key",
|
||||
"id",
|
||||
"style",
|
||||
"title",
|
||||
"role",
|
||||
"aria-label",
|
||||
"aria-describedby",
|
||||
"aria-hidden",
|
||||
"tabIndex",
|
||||
"w",
|
||||
"h",
|
||||
"miw",
|
||||
"maw",
|
||||
"mih",
|
||||
"mah",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mr",
|
||||
"mb",
|
||||
"ml",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pr",
|
||||
"pb",
|
||||
"pl",
|
||||
"bg",
|
||||
"c",
|
||||
"opacity",
|
||||
"display",
|
||||
"hiddenFrom",
|
||||
"visibleFrom",
|
||||
"bd",
|
||||
"bdrs",
|
||||
"flex",
|
||||
"fz",
|
||||
"fw",
|
||||
"lh",
|
||||
"lts",
|
||||
"ta",
|
||||
"td",
|
||||
"tt",
|
||||
"radius",
|
||||
"shadow",
|
||||
"variant",
|
||||
"color",
|
||||
"size",
|
||||
"withBorder",
|
||||
] as const;
|
||||
|
||||
export const categorySafeProps: Record<CustomJsxComponentCategory, readonly string[]> = {
|
||||
layout: [
|
||||
"gap",
|
||||
"align",
|
||||
"justify",
|
||||
"wrap",
|
||||
"direction",
|
||||
"grow",
|
||||
"cols",
|
||||
"spacing",
|
||||
"verticalSpacing",
|
||||
"gutter",
|
||||
"span",
|
||||
"offset",
|
||||
"order",
|
||||
"type",
|
||||
"orientation",
|
||||
"withBorder",
|
||||
"shadow",
|
||||
"radius",
|
||||
],
|
||||
content: [
|
||||
"alt",
|
||||
"src",
|
||||
"href",
|
||||
"target",
|
||||
"rel",
|
||||
"size",
|
||||
"fw",
|
||||
"fs",
|
||||
"td",
|
||||
"tt",
|
||||
"ta",
|
||||
"lts",
|
||||
"lh",
|
||||
"lineClamp",
|
||||
"truncate",
|
||||
"inherit",
|
||||
"gradient",
|
||||
"variant",
|
||||
"color",
|
||||
"value",
|
||||
"prefix",
|
||||
"suffix",
|
||||
"decimalScale",
|
||||
"thousandSeparator",
|
||||
"striped",
|
||||
"highlightOnHover",
|
||||
"withTableBorder",
|
||||
"withColumnBorders",
|
||||
"type",
|
||||
"icon",
|
||||
"image",
|
||||
"order",
|
||||
"fit",
|
||||
"fallbackSrc",
|
||||
"loading",
|
||||
"referrerPolicy",
|
||||
"crossOrigin",
|
||||
"decoding",
|
||||
"draggable",
|
||||
"center",
|
||||
"spacing",
|
||||
"listStyleType",
|
||||
],
|
||||
feedback: ["color", "variant", "radius", "size", "value", "sections", "label", "animated", "striped"],
|
||||
navigation: [
|
||||
"value",
|
||||
"defaultValue",
|
||||
"active",
|
||||
"label",
|
||||
"description",
|
||||
"leftSection",
|
||||
"rightSection",
|
||||
"opened",
|
||||
"disabled",
|
||||
"multiple",
|
||||
"chevronPosition",
|
||||
"orientation",
|
||||
"keepMounted",
|
||||
"transitionDuration",
|
||||
],
|
||||
charts: [
|
||||
"data",
|
||||
"dataKey",
|
||||
"series",
|
||||
"type",
|
||||
"curveType",
|
||||
"color",
|
||||
"colors",
|
||||
"withLegend",
|
||||
"withTooltip",
|
||||
"withXAxis",
|
||||
"withYAxis",
|
||||
"withDots",
|
||||
"gridAxis",
|
||||
"unit",
|
||||
"valueFormatter",
|
||||
"strokeWidth",
|
||||
"fillOpacity",
|
||||
"connectNulls",
|
||||
"tooltipAnimationDuration",
|
||||
],
|
||||
dates: [
|
||||
"date",
|
||||
"defaultDate",
|
||||
"value",
|
||||
"minDate",
|
||||
"maxDate",
|
||||
"locale",
|
||||
"firstDayOfWeek",
|
||||
"weekendDays",
|
||||
"hideOutsideDates",
|
||||
"hideWeekdays",
|
||||
"showWeekNumbers",
|
||||
"numberOfColumns",
|
||||
"level",
|
||||
"size",
|
||||
],
|
||||
interaction: [
|
||||
"label",
|
||||
"value",
|
||||
"values",
|
||||
"data",
|
||||
"checked",
|
||||
"defaultChecked",
|
||||
"disabled",
|
||||
"readOnly",
|
||||
"color",
|
||||
"variant",
|
||||
"size",
|
||||
"radius",
|
||||
"leftSection",
|
||||
"rightSection",
|
||||
"loading",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"total",
|
||||
"page",
|
||||
"pageSize",
|
||||
"defaultTab",
|
||||
"title",
|
||||
"defaultOpen",
|
||||
"type",
|
||||
"fullWidth",
|
||||
],
|
||||
network: [
|
||||
"requestId",
|
||||
"params",
|
||||
"onParams",
|
||||
"offParams",
|
||||
"initialValue",
|
||||
"label",
|
||||
"loadingLabel",
|
||||
"successMessage",
|
||||
"errorMessage",
|
||||
"confirmMessage",
|
||||
"invalidate",
|
||||
"refreshInterval",
|
||||
"trigger",
|
||||
"render",
|
||||
"path",
|
||||
"as",
|
||||
"fallback",
|
||||
"icon",
|
||||
"color",
|
||||
"variant",
|
||||
"size",
|
||||
"radius",
|
||||
"disabled",
|
||||
],
|
||||
blocked: [],
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe } from "./component-descriptor";
|
||||
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
|
||||
|
||||
export const customJsxDeniedComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AppShell",
|
||||
"Affix",
|
||||
"ComboboxPopover",
|
||||
"Dialog",
|
||||
"Drawer",
|
||||
"FloatingWindow",
|
||||
"FocusTrap",
|
||||
"Menubar",
|
||||
"Modal",
|
||||
"ModalBase",
|
||||
"Portal",
|
||||
"Popover",
|
||||
"RemoveScroll",
|
||||
],
|
||||
"Escapes the widget layout or focus boundary",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AngleSlider",
|
||||
"Autocomplete",
|
||||
"Checkbox",
|
||||
"ColorInput",
|
||||
"ColorPicker",
|
||||
"Combobox",
|
||||
"FileButton",
|
||||
"FileInput",
|
||||
"Input",
|
||||
"InputBase",
|
||||
"JsonInput",
|
||||
"MaskInput",
|
||||
"MultiSelect",
|
||||
"NativeSelect",
|
||||
"NumberInput",
|
||||
"PasswordInput",
|
||||
"PillsInput",
|
||||
"PinInput",
|
||||
"Radio",
|
||||
"Select",
|
||||
"TagsInput",
|
||||
"Textarea",
|
||||
"TextInput",
|
||||
"TreeSelect",
|
||||
"UnstyledButton",
|
||||
],
|
||||
"Arbitrary form controls are not connected to the declarative action model",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
["OverflowList", "Scroller", "Splitter", "TableOfContents"],
|
||||
"Requires callback, document, or layout capabilities that are not exposed to templates",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/core",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"AccordionChevron",
|
||||
"ActionIconGroup",
|
||||
"ActionIconGroupSection",
|
||||
"AlphaSlider",
|
||||
"AppShellAside",
|
||||
"AppShellFooter",
|
||||
"AppShellHeader",
|
||||
"AppShellMain",
|
||||
"AppShellNavbar",
|
||||
"AppShellSection",
|
||||
"ButtonGroup",
|
||||
"ButtonGroupSection",
|
||||
"CheckIcon",
|
||||
"CheckboxCard",
|
||||
"CheckboxGroup",
|
||||
"CheckboxIndicator",
|
||||
"CloseIcon",
|
||||
"ColorSchemeScript",
|
||||
"ComboboxChevron",
|
||||
"ComboboxClearButton",
|
||||
"ComboboxDropdown",
|
||||
"ComboboxDropdownTarget",
|
||||
"ComboboxEmpty",
|
||||
"ComboboxEventsTarget",
|
||||
"ComboboxFooter",
|
||||
"ComboboxGroup",
|
||||
"ComboboxHeader",
|
||||
"ComboboxHiddenInput",
|
||||
"ComboboxOption",
|
||||
"ComboboxOptions",
|
||||
"ComboboxPopoverTarget",
|
||||
"ComboboxSearch",
|
||||
"ComboboxTarget",
|
||||
"DirectionProvider",
|
||||
"DrawerBody",
|
||||
"DrawerCloseButton",
|
||||
"DrawerContent",
|
||||
"DrawerHeader",
|
||||
"DrawerOverlay",
|
||||
"DrawerRoot",
|
||||
"DrawerStack",
|
||||
"DrawerTitle",
|
||||
"FlatTreeNode",
|
||||
"FloatingArrow",
|
||||
"FocusTrapInitialFocus",
|
||||
"HeadlessMantineProvider",
|
||||
"HoverCardGroup",
|
||||
"HueSlider",
|
||||
"InlineStyles",
|
||||
"InputClearButton",
|
||||
"InputClearSection",
|
||||
"InputDescription",
|
||||
"InputError",
|
||||
"InputLabel",
|
||||
"InputPlaceholder",
|
||||
"InputSuccess",
|
||||
"InputWrapper",
|
||||
"MantineProvider",
|
||||
"MantineThemeProvider",
|
||||
"MenuCheckboxGroup",
|
||||
"MenuCheckboxItem",
|
||||
"MenuContextMenu",
|
||||
"MenuRadioGroup",
|
||||
"MenuRadioItem",
|
||||
"MenuSearch",
|
||||
"MenuSub",
|
||||
"MenuSubDropdown",
|
||||
"MenuSubItem",
|
||||
"MenuSubTarget",
|
||||
"MenubarDropdown",
|
||||
"MenubarMenu",
|
||||
"MenubarTarget",
|
||||
"ModalBaseBody",
|
||||
"ModalBaseCloseButton",
|
||||
"ModalBaseContent",
|
||||
"ModalBaseHeader",
|
||||
"ModalBaseOverlay",
|
||||
"ModalBaseTitle",
|
||||
"ModalBody",
|
||||
"ModalCloseButton",
|
||||
"ModalContent",
|
||||
"ModalHeader",
|
||||
"ModalOverlay",
|
||||
"ModalRoot",
|
||||
"ModalStack",
|
||||
"ModalTitle",
|
||||
"NativeScrollArea",
|
||||
"OptionalPortal",
|
||||
"OptionsDropdown",
|
||||
"PaginationControl",
|
||||
"PaginationDots",
|
||||
"PaginationFirst",
|
||||
"PaginationItems",
|
||||
"PaginationLabel",
|
||||
"PaginationLast",
|
||||
"PaginationNext",
|
||||
"PaginationPrevious",
|
||||
"PaginationRoot",
|
||||
"PillGroup",
|
||||
"PillsInputField",
|
||||
"PopoverContextMenu",
|
||||
"PopoverDropdown",
|
||||
"PopoverTarget",
|
||||
"ProgressLabel",
|
||||
"ProgressRoot",
|
||||
"RadioCard",
|
||||
"RadioGroup",
|
||||
"RadioIcon",
|
||||
"RadioIndicator",
|
||||
"RangeSlider",
|
||||
"ScrollAreaAutosize",
|
||||
"SplitterPane",
|
||||
"StepperCompleted",
|
||||
"SwitchGroup",
|
||||
"TableScrollContainer",
|
||||
"TooltipFloating",
|
||||
"TooltipGroup",
|
||||
"TreeSelectOption",
|
||||
],
|
||||
"Internal, provider, compound-control, form, portal, or document-level export outside the safe declarative surface",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/charts",
|
||||
"blocked",
|
||||
"denied",
|
||||
["AreaGradient"],
|
||||
"Low-level chart implementation helper that is not a standalone declarative display component",
|
||||
),
|
||||
...describe(
|
||||
"@mantine/dates",
|
||||
"blocked",
|
||||
"denied",
|
||||
[
|
||||
"DatesProvider",
|
||||
"HiddenDatesInput",
|
||||
"TimeInput",
|
||||
"TimePicker",
|
||||
"PickerInputBase",
|
||||
"DateInput",
|
||||
"DateTimePicker",
|
||||
"InlineDateTimePicker",
|
||||
"YearPickerInput",
|
||||
"MonthPickerInput",
|
||||
"DatePickerInput",
|
||||
],
|
||||
"Input and provider components are outside the read-only template surface",
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The source of truth for the Custom JSX public component surface.
|
||||
*
|
||||
* Components marked `wrapped` require a runtime wrapper that removes unsafe props
|
||||
* or supplies inert/read-only behavior. `denied` entries are deliberately kept in
|
||||
* the registry so Mantine upgrades cannot silently expand the template surface.
|
||||
*/
|
||||
export const customJsxEnabledComponentRegistry: readonly CustomJsxComponentDescriptor[] = [
|
||||
...describe("@mantine/core", "layout", "allowed", [
|
||||
"Box",
|
||||
"Stack",
|
||||
"Group",
|
||||
"Flex",
|
||||
"Grid",
|
||||
"Grid.Col",
|
||||
"SimpleGrid",
|
||||
"Center",
|
||||
"Space",
|
||||
"Container",
|
||||
"AspectRatio",
|
||||
"ScrollArea",
|
||||
"Paper",
|
||||
"Card",
|
||||
"Card.Section",
|
||||
"Divider",
|
||||
"Fieldset",
|
||||
"VisuallyHidden",
|
||||
"Typography",
|
||||
"FloatingIndicator",
|
||||
]),
|
||||
...describe("@mantine/core", "content", "allowed", [
|
||||
"Text",
|
||||
"Title",
|
||||
"Code",
|
||||
"Highlight",
|
||||
"Mark",
|
||||
"Kbd",
|
||||
"Blockquote",
|
||||
"NumberFormatter",
|
||||
"Badge",
|
||||
"ThemeIcon",
|
||||
"ColorSwatch",
|
||||
"Table",
|
||||
"Table.Thead",
|
||||
"Table.Tbody",
|
||||
"Table.Tfoot",
|
||||
"Table.Caption",
|
||||
"Table.Tr",
|
||||
"Table.Th",
|
||||
"Table.Td",
|
||||
"List",
|
||||
"List.Item",
|
||||
"Timeline",
|
||||
"Timeline.Item",
|
||||
"Indicator",
|
||||
"Pill",
|
||||
"Image",
|
||||
"Avatar",
|
||||
"Avatar.Group",
|
||||
"BackgroundImage",
|
||||
"DataList",
|
||||
"DataList.Item",
|
||||
"DataList.ItemLabel",
|
||||
"DataList.ItemValue",
|
||||
"EmptyState",
|
||||
"EmptyState.Indicator",
|
||||
"EmptyState.Title",
|
||||
"EmptyState.Description",
|
||||
"EmptyState.Actions",
|
||||
"Marquee",
|
||||
"RollingNumber",
|
||||
]),
|
||||
...describe("@mantine/core", "feedback", "allowed", [
|
||||
"Alert",
|
||||
"Progress",
|
||||
"Progress.Section",
|
||||
"RingProgress",
|
||||
"SemiCircleProgress",
|
||||
"Skeleton",
|
||||
"Loader",
|
||||
]),
|
||||
...describe("@mantine/core", "feedback", "wrapped", ["Notification", "LoadingOverlay", "Overlay"]),
|
||||
...describe("@mantine/core", "navigation", "allowed", [
|
||||
"Accordion",
|
||||
"Accordion.Item",
|
||||
"Accordion.Control",
|
||||
"Accordion.Panel",
|
||||
"Tabs",
|
||||
"Tabs.List",
|
||||
"Tabs.Tab",
|
||||
"Tabs.Panel",
|
||||
"Stepper.Step",
|
||||
"HoverCard",
|
||||
"HoverCard.Target",
|
||||
"HoverCard.Dropdown",
|
||||
"Menu",
|
||||
"Menu.Target",
|
||||
"Menu.Dropdown",
|
||||
"Menu.Item",
|
||||
"Menu.Label",
|
||||
"Menu.Divider",
|
||||
"Tooltip",
|
||||
"Spoiler",
|
||||
"Collapse",
|
||||
"Transition",
|
||||
]),
|
||||
...describe("@mantine/core", "navigation", "wrapped", ["Anchor", "NavLink", "Breadcrumbs", "Stepper", "Tree"]),
|
||||
...describe("@mantine/core", "interaction", "wrapped", [
|
||||
"Button",
|
||||
"ActionIcon",
|
||||
"Burger",
|
||||
"CloseButton",
|
||||
"Chip",
|
||||
"Chip.Group",
|
||||
"CopyButton",
|
||||
"Pagination",
|
||||
"Rating",
|
||||
"SegmentedControl",
|
||||
"Slider",
|
||||
"Switch",
|
||||
]),
|
||||
...describe("@mantine/charts", "charts", "allowed", [
|
||||
"AreaChart",
|
||||
"BarChart",
|
||||
"BarsList",
|
||||
"BubbleChart",
|
||||
"ChartLegend",
|
||||
"ChartTooltip",
|
||||
"CompositeChart",
|
||||
"DonutChart",
|
||||
"FunnelChart",
|
||||
"Heatmap",
|
||||
"LineChart",
|
||||
"PieChart",
|
||||
"RadarChart",
|
||||
"RadialBarChart",
|
||||
"SankeyChart",
|
||||
"ScatterChart",
|
||||
"Sparkline",
|
||||
"Treemap",
|
||||
]),
|
||||
...describe("@mantine/dates", "dates", "wrapped", [
|
||||
"Calendar",
|
||||
"MiniCalendar",
|
||||
"DatePicker",
|
||||
"Day",
|
||||
"WeekdaysRow",
|
||||
"Month",
|
||||
"CalendarHeader",
|
||||
"YearPicker",
|
||||
"MonthPicker",
|
||||
"TimeGrid",
|
||||
"PickerControl",
|
||||
"YearsList",
|
||||
"MonthsList",
|
||||
"DecadeLevel",
|
||||
"YearLevel",
|
||||
"MonthLevel",
|
||||
"LevelsGroup",
|
||||
"DecadeLevelGroup",
|
||||
"YearLevelGroup",
|
||||
"MonthLevelGroup",
|
||||
]),
|
||||
...describe("@mantine/dates", "dates", "allowed", ["TimeValue"]),
|
||||
...describe("@homarr/widgets", "interaction", "allowed", [
|
||||
"PaginatedList",
|
||||
"TabsContainer",
|
||||
"TabPanel",
|
||||
"Collapsible",
|
||||
"StatBar",
|
||||
"TypeBadge",
|
||||
]),
|
||||
...describe("@homarr/widgets", "network", "wrapped", [
|
||||
"SubFetch",
|
||||
"SubData",
|
||||
"ActionButton",
|
||||
"ToggleSwitch",
|
||||
"RefreshButton",
|
||||
]),
|
||||
{
|
||||
name: "TablerIcon",
|
||||
package: "@homarr/widgets",
|
||||
category: "content",
|
||||
safety: "wrapped",
|
||||
supportedProps: ["name", "size", "color", "stroke"],
|
||||
subcomponents: [],
|
||||
accessibilityRequirements: accessibilityByCategory.content,
|
||||
documentationUrl: documentationUrl("@homarr/widgets", "TablerIcon"),
|
||||
},
|
||||
];
|
||||
import { accessibilityByCategory, describe, documentationUrl } from "./component-descriptor";
|
||||
import type { CustomJsxComponentDescriptor } from "./component-descriptor";
|
||||
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
CustomJsxComponentCategory,
|
||||
CustomJsxComponentDescriptor,
|
||||
CustomJsxComponentPackage,
|
||||
CustomJsxComponentSafety,
|
||||
} from "./component-descriptor";
|
||||
|
||||
import { customJsxDeniedComponentRegistry } from "./component-registry-denied";
|
||||
import { customJsxEnabledComponentRegistry } from "./component-registry-enabled";
|
||||
|
||||
export const customJsxComponentRegistry = [
|
||||
...customJsxEnabledComponentRegistry,
|
||||
...customJsxDeniedComponentRegistry,
|
||||
] as const;
|
||||
export const enabledCustomJsxComponents = customJsxEnabledComponentRegistry;
|
||||
export const customJsxComponentByName = new Map(customJsxComponentRegistry.map((entry) => [entry.name, entry]));
|
||||
export const customJsxSupportedPropsByName = new Map(
|
||||
customJsxComponentRegistry.map((entry) => [entry.name, new Set(entry.supportedProps)]),
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
export type CustomJsxComponentPackage = "@mantine/core" | "@mantine/charts" | "@mantine/dates" | "@homarr/widgets";
|
||||
export type CustomJsxComponentSafety = "allowed" | "wrapped" | "denied";
|
||||
export type CustomJsxComponentCategory =
|
||||
| "layout"
|
||||
| "content"
|
||||
| "feedback"
|
||||
| "navigation"
|
||||
| "charts"
|
||||
| "dates"
|
||||
| "interaction"
|
||||
| "network"
|
||||
| "blocked";
|
||||
@@ -0,0 +1,263 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
import { customWidgetMethods } from "./schema-types";
|
||||
|
||||
export const customJsxNetworkScopes = ["public", "private", "loopback"] as const;
|
||||
export type CustomJsxNetworkScope = (typeof customJsxNetworkScopes)[number];
|
||||
|
||||
export const customJsxRequestParameterTypes = ["string", "number", "boolean"] as const;
|
||||
export type CustomJsxRequestParameterType = (typeof customJsxRequestParameterTypes)[number];
|
||||
|
||||
const requestIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_-]*$/,
|
||||
"Request IDs must start with a letter and contain only lowercase letters, numbers, - or _",
|
||||
);
|
||||
|
||||
const requestParametersSchema = z
|
||||
.record(requestIdSchema, z.enum(customJsxRequestParameterTypes))
|
||||
.refine((parameters) => Object.keys(parameters).length <= 32, "A request can declare at most 32 parameters");
|
||||
|
||||
const reservedRequestHeaders = new Set([
|
||||
"authorization",
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"expect",
|
||||
"forwarded",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"via",
|
||||
]);
|
||||
|
||||
const staticHeadersSchema = z
|
||||
.record(z.string().min(1).max(128), z.string().max(8192))
|
||||
.refine((headers) => Object.keys(headers).length <= 32, "A request can declare at most 32 static headers")
|
||||
.refine(
|
||||
(headers) =>
|
||||
Object.keys(headers).every((name) => {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
!reservedRequestHeaders.has(normalized) &&
|
||||
!normalized.startsWith("proxy-") &&
|
||||
!normalized.startsWith("sec-") &&
|
||||
!normalized.startsWith("x-forwarded-")
|
||||
);
|
||||
}),
|
||||
"Static headers cannot override authentication, cookies, routing, forwarding, proxy, or hop-by-hop headers",
|
||||
);
|
||||
|
||||
const collectBodyParameterNames = (value: unknown, result = new Set<string>()): Set<string> => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectBodyParameterNames(entry, result));
|
||||
return result;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return result;
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$param" && typeof entries[0][1] === "string") {
|
||||
result.add(entries[0][1]);
|
||||
return result;
|
||||
}
|
||||
entries.forEach(([, entry]) => collectBodyParameterNames(entry, result));
|
||||
return result;
|
||||
};
|
||||
|
||||
export const customJsxRequestSchema = z
|
||||
.object({
|
||||
id: requestIdSchema,
|
||||
kind: z.enum(["query", "action"]),
|
||||
method: z.enum(customWidgetMethods),
|
||||
pathTemplate: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.startsWith("/")
|
||||
.refine((path) => !path.startsWith("//"), "Path templates must be same-origin paths")
|
||||
.refine((path) => !path.includes("\\"), "Path templates cannot contain backslashes")
|
||||
.refine((path) => !path.includes("#"), "Path templates cannot contain URL fragments"),
|
||||
parameters: requestParametersSchema,
|
||||
bodyTemplate: z.json().optional(),
|
||||
staticHeaders: staticHeadersSchema.optional(),
|
||||
auth: z.enum(["inherit", "none"]),
|
||||
minimumBoardPermission: z.enum(["view", "modify", "full"]),
|
||||
cacheTtlSeconds: z.number().int().min(0).max(3600).optional(),
|
||||
allowViewerExecution: z.literal(true).optional(),
|
||||
})
|
||||
.superRefine((request, ctx) => {
|
||||
if (request.kind === "query" && request.method !== "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["method"],
|
||||
message: "Query requests must use GET",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "action" && request.method === "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["method"],
|
||||
message: "Action requests must use POST, PUT, PATCH, or DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "query" && request.minimumBoardPermission !== "view") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["minimumBoardPermission"],
|
||||
message: "Query requests use view permission",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "DELETE" && request.minimumBoardPermission !== "full") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["minimumBoardPermission"],
|
||||
message: "DELETE actions require full board permission",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.kind === "action" &&
|
||||
request.method !== "DELETE" &&
|
||||
request.minimumBoardPermission === "view" &&
|
||||
request.allowViewerExecution !== true
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["allowViewerExecution"],
|
||||
message: "Viewer actions require explicit allowViewerExecution approval",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "query" && request.bodyTemplate !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["bodyTemplate"],
|
||||
message: "GET query requests cannot define a request body",
|
||||
});
|
||||
}
|
||||
|
||||
if (request.kind === "action" && request.cacheTtlSeconds !== undefined && request.cacheTtlSeconds !== 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cacheTtlSeconds"],
|
||||
message: "Actions cannot be cached",
|
||||
});
|
||||
}
|
||||
|
||||
const declaredParameters = new Set(Object.keys(request.parameters));
|
||||
const pathParameters = [...request.pathTemplate.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1] ?? "");
|
||||
for (const parameter of [...pathParameters, ...collectBodyParameterNames(request.bodyTemplate)]) {
|
||||
if (!declaredParameters.has(parameter)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [request.bodyTemplate === undefined ? "pathTemplate" : "parameters"],
|
||||
message: `Placeholder '${parameter}' is not declared in request parameters`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomJsxRequest = z.infer<typeof customJsxRequestSchema>;
|
||||
|
||||
const customJsxTemplateSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(50000)
|
||||
.superRefine((template, ctx) => {
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
if (diagnostic.severity === "error") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: `${diagnostic.message} (line ${diagnostic.line}, column ${diagnostic.column})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.describe(
|
||||
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct fetch and XMLHttpRequest access are unavailable; v2 templates reference named requests.",
|
||||
);
|
||||
|
||||
export const customJsxDisplayConfigV1Schema = z.object({
|
||||
type: z
|
||||
.literal("customJsx")
|
||||
.describe("Display type discriminator — must be 'customJsx' when displayType is customJsx"),
|
||||
template: customJsxTemplateSchema,
|
||||
jsxApiVersion: z.never().optional(),
|
||||
networkScope: z.never().optional(),
|
||||
requests: z.never().optional(),
|
||||
});
|
||||
|
||||
export const customJsxDisplayConfigV2Schema = z
|
||||
.object({
|
||||
type: z.literal("customJsx"),
|
||||
jsxApiVersion: z.literal(2),
|
||||
template: customJsxTemplateSchema,
|
||||
networkScope: z.enum(customJsxNetworkScopes),
|
||||
requests: z.array(customJsxRequestSchema).max(64),
|
||||
})
|
||||
.superRefine((config, ctx) => {
|
||||
const requestsById = new Map<string, CustomJsxRequest>();
|
||||
for (const [index, request] of config.requests.entries()) {
|
||||
if (requestsById.has(request.id)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["requests", index, "id"],
|
||||
message: `Duplicate request ID: ${request.id}`,
|
||||
});
|
||||
}
|
||||
requestsById.set(request.id, request);
|
||||
}
|
||||
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/g;
|
||||
for (const match of config.template.matchAll(networkComponentPattern)) {
|
||||
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
|
||||
const attributes = match[2] ?? "";
|
||||
const idMatch = attributes.match(/\brequestId\s*=\s*(?:"([^"]+)"|'([^']+)')/);
|
||||
const requestId = idMatch?.[1] ?? idMatch?.[2];
|
||||
if (!requestId) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} must use a literal requestId`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const request = requestsById.get(requestId);
|
||||
const expectedKind = component === "SubFetch" ? "query" : "action";
|
||||
if (!request) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} references unknown request '${requestId}'`,
|
||||
});
|
||||
} else if (request.kind !== expectedKind) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} requires a ${expectedKind} request, but '${requestId}' is ${request.kind}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (/\b(?:url|method|headers|body)\s*=/.test(attributes)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["template"],
|
||||
message: `${component} cannot declare inline network settings`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomJsxDisplayConfigV2 = z.infer<typeof customJsxDisplayConfigV2Schema>;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { CustomJsxDisplayConfigV2 } from "./custom-jsx-schema";
|
||||
import { displayConfigSchema } from "./display-config-schema";
|
||||
import type { DisplayConfig } from "./display-config-schema";
|
||||
import {
|
||||
customWidgetAuthTypes,
|
||||
customWidgetDisplayTypes,
|
||||
customWidgetMethods,
|
||||
customWidgetSecretKinds,
|
||||
} from "./schema-types";
|
||||
import type { CustomWidgetMethod } from "./schema-types";
|
||||
|
||||
const displayTypesMatch = (displayType?: string, configType?: string): boolean => {
|
||||
if (!displayType) return true;
|
||||
if (!configType) return true;
|
||||
return displayType === configType;
|
||||
};
|
||||
|
||||
const displayTypeMatchMessage = {
|
||||
message: "displayType must match displayConfig.type",
|
||||
path: ["displayConfig", "type"],
|
||||
};
|
||||
|
||||
const isCustomJsxV2 = (config: DisplayConfig | undefined): config is CustomJsxDisplayConfigV2 =>
|
||||
config?.type === "customJsx" && "jsxApiVersion" in config && config.jsxApiVersion === 2;
|
||||
|
||||
const validateDefinitionDisplay = (
|
||||
definition: { method?: CustomWidgetMethod; displayType?: string; displayConfig?: DisplayConfig },
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (!displayTypesMatch(definition.displayType, definition.displayConfig?.type)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: displayTypeMatchMessage.message,
|
||||
path: displayTypeMatchMessage.path,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCustomJsxV2(definition.displayConfig) && definition.method !== undefined && definition.method !== "GET") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Custom JSX v2 base data requests must use GET; mutations belong in named actions",
|
||||
path: ["method"],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const baseDefinitionSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
|
||||
description: z
|
||||
.string()
|
||||
.max(512)
|
||||
.optional()
|
||||
.describe("Optional longer description of what this widget shows, shown in tooltips and management UI"),
|
||||
iconUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional URL to an icon image for the widget (e.g. https://example.com/icon.png). Shown in widget picker.",
|
||||
),
|
||||
url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
|
||||
),
|
||||
authType: z
|
||||
.enum(customWidgetAuthTypes)
|
||||
.describe(
|
||||
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter. Secrets are configured separately in the UI.",
|
||||
),
|
||||
headerName: z
|
||||
.string()
|
||||
.max(256)
|
||||
.optional()
|
||||
.describe(
|
||||
"Custom header or query parameter name for API key auth. For apiKeyHeader: the HTTP header name (default: X-API-Key). For apiKeyQuery: the query param name (default: api_key). Ignored for other auth types.",
|
||||
),
|
||||
method: z
|
||||
.enum(customWidgetMethods)
|
||||
.describe(
|
||||
"HTTP method for the API request: 'GET' (fetch data, most common), 'POST'/'PUT'/'PATCH' (with optional requestBody), 'DELETE' (destructive actions)",
|
||||
),
|
||||
requestBody: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'JSON string body sent with POST/PUT/PATCH requests. Must be valid JSON as a string (e.g. \'{"action":"restart"}\'). Omit for GET requests.',
|
||||
),
|
||||
displayType: z
|
||||
.enum(customWidgetDisplayTypes)
|
||||
.describe(
|
||||
"How to render the API response: 'singleValue' (one big number/text), 'keyValue' (labeled pairs), 'table' (rows/columns), 'statGrid' (colored stat cards), 'progressBars' (usage bars), 'statusIndicator' (health dots), 'countGrid' (simple counts), 'raw' (JSON debug view), 'actionButton' (clickable action), 'customJsx' (custom JSX template with Mantine components)",
|
||||
),
|
||||
displayConfig: displayConfigSchema,
|
||||
});
|
||||
|
||||
const secretsInputSchema = z.array(
|
||||
z.object({
|
||||
kind: z
|
||||
.enum(customWidgetSecretKinds)
|
||||
.describe(
|
||||
"Secret type: 'apiKey' (token/key for bearer/apiKey auth), 'username' (for basic auth), 'password' (for basic auth)",
|
||||
),
|
||||
value: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("The secret value. Never include in exported JSON — configure in the UI after import."),
|
||||
}),
|
||||
);
|
||||
|
||||
export const customWidgetCreateSchema = baseDefinitionSchema
|
||||
.extend({ secrets: secretsInputSchema })
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
export const customWidgetUpdateSchema = baseDefinitionSchema
|
||||
.partial()
|
||||
.extend({ id: z.string(), secrets: secretsInputSchema.optional() })
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
const customWidgetImportFieldsSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
|
||||
description: z
|
||||
.string()
|
||||
.max(512)
|
||||
.nullish()
|
||||
.describe("Optional longer description of what this widget shows. Use null or omit if not needed."),
|
||||
iconUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.nullish()
|
||||
.describe("Optional URL to an icon image for the widget. Use null or omit if not needed."),
|
||||
authType: z
|
||||
.enum(customWidgetAuthTypes)
|
||||
.describe(
|
||||
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter",
|
||||
),
|
||||
headerName: z
|
||||
.string()
|
||||
.max(256)
|
||||
.nullish()
|
||||
.describe(
|
||||
"Custom header or query parameter name for API key auth. For apiKeyHeader: HTTP header name (default: X-API-Key). For apiKeyQuery: query param name (default: api_key).",
|
||||
),
|
||||
method: z
|
||||
.enum(customWidgetMethods)
|
||||
.describe(
|
||||
"HTTP method: 'GET' (fetch data), 'POST'/'PUT'/'PATCH' (with requestBody), 'DELETE' (destructive). Use GET unless the API requires otherwise.",
|
||||
),
|
||||
requestBody: z.string().nullish().describe("JSON string body for POST/PUT/PATCH. Use null or omit for GET requests."),
|
||||
displayType: z
|
||||
.enum(customWidgetDisplayTypes)
|
||||
.describe(
|
||||
"How to render the API response: 'singleValue', 'keyValue', 'table', 'statGrid', 'progressBars', 'statusIndicator', 'countGrid', 'raw', 'actionButton', or 'customJsx'",
|
||||
),
|
||||
displayConfig: displayConfigSchema,
|
||||
});
|
||||
|
||||
export const customWidgetImportSchema = customWidgetImportFieldsSchema
|
||||
.extend({
|
||||
$schema: z
|
||||
.enum(["homarr-custom-widget-v2", "homarr-custom-widget-v3"])
|
||||
.optional()
|
||||
.describe("Schema version identifier. New exports use 'homarr-custom-widget-v3'; v2 remains importable."),
|
||||
url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
|
||||
),
|
||||
})
|
||||
.superRefine(validateDefinitionDisplay);
|
||||
|
||||
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
countGridDisplayConfigSchema,
|
||||
keyValueDisplayConfigSchema,
|
||||
progressBarsDisplayConfigSchema,
|
||||
rawDisplayConfigSchema,
|
||||
singleValueDisplayConfigSchema,
|
||||
statGridDisplayConfigSchema,
|
||||
statusIndicatorDisplayConfigSchema,
|
||||
tableDisplayConfigSchema,
|
||||
} from "./built-in-display-schemas";
|
||||
import { customJsxDisplayConfigV1Schema, customJsxDisplayConfigV2Schema } from "./custom-jsx-schema";
|
||||
|
||||
const actionButtonDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("actionButton")
|
||||
.describe("Display type discriminator — must be 'actionButton' when displayType is actionButton"),
|
||||
buttonLabel: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Text shown on the action button (e.g. 'Restart Service', 'Clear Cache', 'Run Backup')"),
|
||||
buttonColor: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Mantine color name for the button (e.g. 'blue', 'red', 'green'). Default: blue"),
|
||||
confirmText: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"If set, shows a confirmation dialog with this message before executing the API request. Omit for immediate execution.",
|
||||
),
|
||||
successMessage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Notification message shown after successful API execution. Omit for a generic success message."),
|
||||
});
|
||||
|
||||
export const displayConfigSchema = z
|
||||
.union([
|
||||
singleValueDisplayConfigSchema,
|
||||
keyValueDisplayConfigSchema,
|
||||
tableDisplayConfigSchema,
|
||||
statGridDisplayConfigSchema,
|
||||
progressBarsDisplayConfigSchema,
|
||||
statusIndicatorDisplayConfigSchema,
|
||||
countGridDisplayConfigSchema,
|
||||
rawDisplayConfigSchema,
|
||||
actionButtonDisplayConfigSchema,
|
||||
customJsxDisplayConfigV2Schema,
|
||||
customJsxDisplayConfigV1Schema,
|
||||
])
|
||||
.describe(
|
||||
"Configuration for how API response data is displayed. The 'type' field MUST match the top-level 'displayType' field exactly. Choose the variant that matches your displayType.",
|
||||
);
|
||||
|
||||
export type DisplayConfig = z.infer<typeof displayConfigSchema>;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { JSONPath } from "jsonpath-plus";
|
||||
|
||||
import type { DisplayConfig } from "./display-config-schema";
|
||||
import type { CustomWidgetDisplayType } from "./schema-types";
|
||||
|
||||
export type ExtractedDisplayData = Record<string, unknown>;
|
||||
|
||||
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false }) as unknown;
|
||||
|
||||
const queryRows = (json: unknown, path: string): unknown[] => {
|
||||
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
|
||||
return Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
|
||||
};
|
||||
|
||||
const toNumber = (value: unknown, fallback: number): number => Number(value) || fallback;
|
||||
|
||||
export function extractDisplayData(
|
||||
json: unknown,
|
||||
_displayType: CustomWidgetDisplayType,
|
||||
config: DisplayConfig,
|
||||
): ExtractedDisplayData {
|
||||
switch (config.type) {
|
||||
case "singleValue":
|
||||
return {
|
||||
...config,
|
||||
value: query(json, config.jsonPath),
|
||||
valueSize: config.valueSize ?? "lg",
|
||||
labelPosition: config.labelPosition ?? "below",
|
||||
};
|
||||
case "keyValue":
|
||||
return {
|
||||
type: config.type,
|
||||
entries: config.mappings.map((mapping) => ({ ...mapping, value: query(json, mapping.jsonPath) })),
|
||||
layout: config.layout ?? "list",
|
||||
columns: config.columns ?? 2,
|
||||
};
|
||||
case "table": {
|
||||
const rows = queryRows(json, config.tablePath);
|
||||
return {
|
||||
type: config.type,
|
||||
columns: config.columns.map((column) => column.header),
|
||||
rows: rows.map((row) => config.columns.map((column) => query(row, column.jsonPath))),
|
||||
striped: config.striped ?? true,
|
||||
compact: config.compact ?? false,
|
||||
};
|
||||
}
|
||||
case "statGrid":
|
||||
return {
|
||||
type: config.type,
|
||||
items: config.items.map((item) => ({
|
||||
...item,
|
||||
color: item.color ?? "blue",
|
||||
value: query(json, item.jsonPath),
|
||||
})),
|
||||
columns: config.columns ?? 2,
|
||||
cardStyle: config.cardStyle ?? "filled",
|
||||
};
|
||||
case "progressBars":
|
||||
return {
|
||||
type: config.type,
|
||||
bars: config.bars.map((bar) => ({
|
||||
...bar,
|
||||
color: bar.color ?? "blue",
|
||||
value: toNumber(query(json, bar.valuePath), 0),
|
||||
max: bar.maxPath ? toNumber(query(json, bar.maxPath), 100) : undefined,
|
||||
})),
|
||||
showPercentage: config.showPercentage ?? true,
|
||||
barSize: config.barSize ?? "md",
|
||||
};
|
||||
case "statusIndicator":
|
||||
return {
|
||||
type: config.type,
|
||||
items: config.items.map((item) => {
|
||||
const value = query(json, item.jsonPath);
|
||||
return {
|
||||
label: item.label,
|
||||
value: String(value ?? "unknown"),
|
||||
isGood: item.goodValues.some((good) => String(value).toLowerCase() === good.toLowerCase()),
|
||||
};
|
||||
}),
|
||||
layout: config.layout ?? "list",
|
||||
dotSize: config.dotSize ?? "md",
|
||||
};
|
||||
case "countGrid":
|
||||
return {
|
||||
type: config.type,
|
||||
items: config.items.map((item) => ({ ...item, value: query(json, item.jsonPath) })),
|
||||
columns: config.columns ?? 2,
|
||||
valueSize: config.valueSize ?? "md",
|
||||
};
|
||||
case "raw":
|
||||
return { type: config.type, data: query(json, config.jsonPath), maxHeight: config.maxHeight ?? 300 };
|
||||
case "actionButton":
|
||||
return {
|
||||
...config,
|
||||
buttonColor: config.buttonColor ?? "blue",
|
||||
confirmText: config.confirmText ?? "",
|
||||
successMessage: config.successMessage ?? "",
|
||||
};
|
||||
case "customJsx":
|
||||
return {
|
||||
type: config.type,
|
||||
template: config.template,
|
||||
data: json,
|
||||
jsxApiVersion: "jsxApiVersion" in config ? config.jsxApiVersion : 1,
|
||||
requestCapabilities: Array.isArray(config.requests)
|
||||
? config.requests.map(({ id, kind, method, minimumBoardPermission }) => ({
|
||||
id,
|
||||
kind,
|
||||
method,
|
||||
minimumBoardPermission,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const extractDisplayDataWithFallback = extractDisplayData;
|
||||
|
||||
export function extractActionButtonDisplay(config: DisplayConfig): ExtractedDisplayData {
|
||||
if (config.type !== "actionButton") throw new Error("Display configuration is not an action button");
|
||||
return extractDisplayData(null, "actionButton", config);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { customJsxRequestSchema, displayConfigSchema } from "./schema";
|
||||
import type { DisplayConfig } from "./schema";
|
||||
import type { CustomWidgetDisplayType } from "./schema";
|
||||
|
||||
function hydrateDisplayFormValues(displayType: string, config: Record<string, unknown>) {
|
||||
const base = {
|
||||
displayType,
|
||||
jsonPath: "$",
|
||||
label: "",
|
||||
unit: "",
|
||||
valueSize: "lg",
|
||||
labelPosition: "below",
|
||||
mappings: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
kvLayout: "list",
|
||||
kvColumns: 2,
|
||||
tablePath: "$",
|
||||
columns: [{ header: "", jsonPath: "$" }],
|
||||
striped: true,
|
||||
compact: false,
|
||||
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
|
||||
statGridColumns: 2,
|
||||
cardStyle: "filled",
|
||||
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
|
||||
showPercentage: true,
|
||||
barSize: "md",
|
||||
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
|
||||
statusLayout: "list",
|
||||
dotSize: "md",
|
||||
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
|
||||
countGridColumns: 2,
|
||||
countValueSize: "md",
|
||||
rawJsonPath: "$",
|
||||
rawMaxHeight: 300,
|
||||
buttonLabel: "Execute",
|
||||
buttonColor: "blue",
|
||||
confirmText: "",
|
||||
successMessage: "",
|
||||
template: CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: "2",
|
||||
networkScope: "public",
|
||||
requestManifest: "[]",
|
||||
};
|
||||
|
||||
const typeOverrides: Record<string, () => Record<string, unknown>> = {
|
||||
singleValue: () => ({
|
||||
jsonPath: (config.jsonPath as string) ?? "$",
|
||||
label: (config.label as string) ?? "",
|
||||
unit: (config.unit as string) ?? "",
|
||||
valueSize: (config.valueSize as string) ?? "lg",
|
||||
labelPosition: (config.labelPosition as string) ?? "below",
|
||||
}),
|
||||
keyValue: () => ({
|
||||
mappings: (config.mappings as typeof base.mappings) ?? base.mappings,
|
||||
kvLayout: (config.layout as string) ?? "list",
|
||||
kvColumns: (config.columns as number) ?? 2,
|
||||
}),
|
||||
table: () => ({
|
||||
tablePath: (config.tablePath as string) ?? "$",
|
||||
columns: (config.columns as typeof base.columns) ?? base.columns,
|
||||
striped: (config.striped as boolean) ?? true,
|
||||
compact: (config.compact as boolean) ?? false,
|
||||
}),
|
||||
statGrid: () => ({
|
||||
statGridItems: (config.items as typeof base.statGridItems) ?? base.statGridItems,
|
||||
statGridColumns: (config.columns as number) ?? 2,
|
||||
cardStyle: (config.cardStyle as string) ?? "filled",
|
||||
}),
|
||||
progressBars: () => ({
|
||||
progressBars: (
|
||||
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
|
||||
[]
|
||||
).map((bar) => ({ ...bar, maxPath: bar.maxPath ?? "", color: bar.color ?? "blue" })),
|
||||
showPercentage: (config.showPercentage as boolean) ?? true,
|
||||
barSize: (config.barSize as string) ?? "md",
|
||||
}),
|
||||
statusIndicator: () => ({
|
||||
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
|
||||
(item) => ({ ...item, goodValues: item.goodValues.join(", ") }),
|
||||
),
|
||||
statusLayout: (config.layout as string) ?? "list",
|
||||
dotSize: (config.dotSize as string) ?? "md",
|
||||
}),
|
||||
countGrid: () => ({
|
||||
countGridItems: (config.items as typeof base.countGridItems) ?? base.countGridItems,
|
||||
countGridColumns: (config.columns as number) ?? 2,
|
||||
countValueSize: (config.valueSize as string) ?? "md",
|
||||
}),
|
||||
raw: () => ({
|
||||
rawJsonPath: (config.jsonPath as string) ?? "$",
|
||||
rawMaxHeight: (config.maxHeight as number) ?? 300,
|
||||
}),
|
||||
actionButton: () => ({
|
||||
buttonLabel: (config.buttonLabel as string) ?? "Execute",
|
||||
buttonColor: (config.buttonColor as string) ?? "blue",
|
||||
confirmText: (config.confirmText as string) ?? "",
|
||||
successMessage: (config.successMessage as string) ?? "",
|
||||
}),
|
||||
customJsx: () => ({
|
||||
template:
|
||||
typeof config.template === "string" && config.template.trim().length > 0 ? config.template : CUSTOM_JSX_STARTER,
|
||||
jsxApiVersion: String((config.jsxApiVersion as number | undefined) ?? 1),
|
||||
networkScope: (config.networkScope as string) ?? "public",
|
||||
requestManifest: JSON.stringify((config.requests as unknown[] | undefined) ?? [], null, 2),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ...base, ...typeOverrides[displayType]?.() };
|
||||
}
|
||||
const starterExample = customJsxExamples.find((example) => example.id === "simple-metric");
|
||||
if (!starterExample) throw new Error("The simple-metric Custom JSX fixture is required");
|
||||
export const CUSTOM_JSX_STARTER = starterExample.template;
|
||||
|
||||
export type CustomWidgetDisplayFormValues = ReturnType<typeof hydrateDisplayFormValues>;
|
||||
|
||||
const displayConfigBuilders: Record<string, (values: CustomWidgetDisplayFormValues) => Record<string, unknown>> = {
|
||||
singleValue: (values) => ({
|
||||
type: "singleValue",
|
||||
jsonPath: values.jsonPath,
|
||||
label: values.label,
|
||||
unit: values.unit,
|
||||
valueSize: values.valueSize,
|
||||
labelPosition: values.labelPosition,
|
||||
}),
|
||||
keyValue: (values) => ({
|
||||
type: "keyValue",
|
||||
mappings: values.mappings,
|
||||
layout: values.kvLayout,
|
||||
columns: values.kvColumns,
|
||||
}),
|
||||
table: (values) => ({
|
||||
type: "table",
|
||||
tablePath: values.tablePath,
|
||||
columns: values.columns,
|
||||
striped: values.striped,
|
||||
compact: values.compact,
|
||||
}),
|
||||
statGrid: (values) => ({
|
||||
type: "statGrid",
|
||||
items: values.statGridItems,
|
||||
columns: values.statGridColumns,
|
||||
cardStyle: values.cardStyle,
|
||||
}),
|
||||
progressBars: (values) => ({
|
||||
type: "progressBars",
|
||||
bars: values.progressBars.map((bar) => ({ ...bar, maxPath: bar.maxPath || undefined })),
|
||||
showPercentage: values.showPercentage,
|
||||
barSize: values.barSize,
|
||||
}),
|
||||
statusIndicator: (values) => ({
|
||||
type: "statusIndicator",
|
||||
items: values.statusItems.map((item) => ({
|
||||
...item,
|
||||
goodValues: item.goodValues
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
})),
|
||||
layout: values.statusLayout,
|
||||
dotSize: values.dotSize,
|
||||
}),
|
||||
countGrid: (values) => ({
|
||||
type: "countGrid",
|
||||
items: values.countGridItems,
|
||||
columns: values.countGridColumns,
|
||||
valueSize: values.countValueSize,
|
||||
}),
|
||||
raw: (values) => ({ type: "raw", jsonPath: values.rawJsonPath, maxHeight: values.rawMaxHeight }),
|
||||
actionButton: (values) => ({
|
||||
type: "actionButton",
|
||||
buttonLabel: values.buttonLabel,
|
||||
buttonColor: values.buttonColor,
|
||||
confirmText: values.confirmText || undefined,
|
||||
successMessage: values.successMessage || undefined,
|
||||
}),
|
||||
customJsx: (values) =>
|
||||
values.jsxApiVersion === "2"
|
||||
? {
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: values.template.trim() || CUSTOM_JSX_STARTER,
|
||||
networkScope: values.networkScope,
|
||||
requests: parseRequestManifest(values.requestManifest),
|
||||
}
|
||||
: { type: "customJsx", template: values.template.trim() || CUSTOM_JSX_STARTER },
|
||||
};
|
||||
|
||||
export type CustomWidgetDisplayIcon =
|
||||
| "action"
|
||||
| "code"
|
||||
| "count"
|
||||
| "grid"
|
||||
| "json"
|
||||
| "keyValue"
|
||||
| "number"
|
||||
| "progress"
|
||||
| "status"
|
||||
| "table";
|
||||
|
||||
export interface CustomWidgetDisplayDescriptor {
|
||||
type: CustomWidgetDisplayType;
|
||||
icon: CustomWidgetDisplayIcon;
|
||||
supportsActions: boolean;
|
||||
supportsNamedRequests: boolean;
|
||||
defaults(): CustomWidgetDisplayFormValues;
|
||||
hydrate(config: Record<string, unknown>): CustomWidgetDisplayFormValues;
|
||||
serialize(values: CustomWidgetDisplayFormValues): DisplayConfig;
|
||||
validate(config: unknown): DisplayConfig;
|
||||
}
|
||||
|
||||
const descriptorMetadata = [
|
||||
["singleValue", "number", false, false],
|
||||
["keyValue", "keyValue", false, false],
|
||||
["table", "table", false, false],
|
||||
["statGrid", "grid", false, false],
|
||||
["progressBars", "progress", false, false],
|
||||
["statusIndicator", "status", false, false],
|
||||
["countGrid", "count", false, false],
|
||||
["raw", "json", false, false],
|
||||
["actionButton", "action", true, false],
|
||||
["customJsx", "code", true, true],
|
||||
] as const satisfies readonly [CustomWidgetDisplayType, CustomWidgetDisplayIcon, boolean, boolean][];
|
||||
|
||||
export const customWidgetDisplayDescriptors: readonly CustomWidgetDisplayDescriptor[] = descriptorMetadata.map(
|
||||
([type, icon, supportsActions, supportsNamedRequests]) => ({
|
||||
type,
|
||||
icon,
|
||||
supportsActions,
|
||||
supportsNamedRequests,
|
||||
defaults: () => hydrateDisplayFormValues(type, {}),
|
||||
hydrate: (config) => hydrateDisplayFormValues(type, config),
|
||||
serialize: (values) => {
|
||||
const build = displayConfigBuilders[type];
|
||||
if (!build) throw new Error(`Unsupported custom-widget display type: ${type}`);
|
||||
return displayConfigSchema.parse(build(values));
|
||||
},
|
||||
validate: (config) => displayConfigSchema.parse(config),
|
||||
}),
|
||||
);
|
||||
|
||||
export function getCustomWidgetDisplayDescriptor(type: string): CustomWidgetDisplayDescriptor {
|
||||
const descriptor = customWidgetDisplayDescriptors.find((candidate) => candidate.type === type);
|
||||
if (!descriptor) throw new Error(`Unsupported custom-widget display type: ${type}`);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export function buildDisplayFormValues(displayType: string, config: Record<string, unknown>) {
|
||||
return getCustomWidgetDisplayDescriptor(displayType).hydrate(config);
|
||||
}
|
||||
|
||||
export function buildDisplayConfigFromFormValues(values: CustomWidgetDisplayFormValues): DisplayConfig {
|
||||
return getCustomWidgetDisplayDescriptor(values.displayType).serialize(values);
|
||||
}
|
||||
|
||||
function parseRequestManifest(value: string) {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return customJsxRequestSchema.array().parse(parsed);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
export interface CustomJsxExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
template: string;
|
||||
requests: readonly Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export const customJsxExamples: readonly CustomJsxExample[] = [
|
||||
{
|
||||
id: "simple-metric",
|
||||
title: "Simple metric",
|
||||
description: "A compact status and metric display with no nested requests.",
|
||||
template: `<Stack gap="xs" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{data.name}</Text>
|
||||
<Badge color={data.status === "online" ? "green" : "red"}>{data.status}</Badge>
|
||||
</Group>
|
||||
<Title order={2}>{data.value}</Title>
|
||||
</Stack>`,
|
||||
requests: [],
|
||||
},
|
||||
{
|
||||
id: "pokedex-detail",
|
||||
title: "Pokédex detail list",
|
||||
description: "Paginates the list and fetches details only for visible Pokémon.",
|
||||
template: `<Stack gap="sm" p="xs">
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<PaginatedList pageSize={5}>
|
||||
{data.results.map((pokemon) =>
|
||||
<Card key={pokemon.name} withBorder p="xs" mb="xs">
|
||||
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
|
||||
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
{detail.types.map((entry) => <Badge key={entry.type.name} color="red">{entry.type.name}</Badge>)}
|
||||
</Group>
|
||||
<Text size="xs">HP {detail.stats[0].base_stat} · HTTP {meta.status}</Text>
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
</Card>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>`,
|
||||
requests: [
|
||||
{
|
||||
id: "pokemon-detail",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v2/pokemon/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
cacheTtlSeconds: 300,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stocks-dashboard",
|
||||
title: "Stocks dashboard",
|
||||
description: "Responsive portfolio values and chart with parent refresh.",
|
||||
template: `<Stack gap="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>Portfolio</Title>
|
||||
<RefreshButton label="Refresh" size="xs" />
|
||||
</Group>
|
||||
<SimpleGrid cols={3}>
|
||||
{data.holdings.map((stock) =>
|
||||
<Card key={stock.symbol} withBorder p="xs">
|
||||
<Text fw={700}>{stock.symbol}</Text>
|
||||
<NumberFormatter value={stock.price} prefix="$" decimalScale={2} />
|
||||
<Sparkline data={stock.history} h={40} color={stock.change > 0 ? "green" : "red"} />
|
||||
</Card>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<LineChart h={200} data={data.timeline} dataKey="date" series={[{ name: "value", color: "blue" }]} />
|
||||
</Stack>`,
|
||||
requests: [],
|
||||
},
|
||||
{
|
||||
id: "navidrome-player",
|
||||
title: "Navidrome mini-player",
|
||||
description: "Media controls backed by named actions with explicit permissions.",
|
||||
template: `<Stack gap="sm" p="sm">
|
||||
<Group wrap="nowrap">
|
||||
<Image src={data.albumArt} alt={data.album} w={80} h={80} radius="sm" />
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} lineClamp={1}>{data.title}</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>{data.artist}</Text>
|
||||
<Progress value={data.progress} size="xs" color="red" />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group justify="center" gap="sm">
|
||||
<ActionButton requestId="previous" label="Previous" size="xs" variant="subtle" />
|
||||
<ActionButton requestId="toggle-playback" label={data.playing ? "Pause" : "Play"} icon="play" color="red" invalidate={["parent"]} />
|
||||
<ActionButton requestId="next" label="Next" size="xs" variant="subtle" />
|
||||
</Group>
|
||||
<ToggleSwitch requestId="shuffle" onParams={{ enabled: true }} offParams={{ enabled: false }} initialValue={data.shuffle} label="Shuffle" size="xs" />
|
||||
</Stack>`,
|
||||
requests: [
|
||||
{
|
||||
id: "previous",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/player/previous",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
{
|
||||
id: "toggle-playback",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/player/toggle",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
{
|
||||
id: "next",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/player/next",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
{
|
||||
id: "shuffle",
|
||||
kind: "action",
|
||||
method: "PUT",
|
||||
pathTemplate: "/api/player/shuffle",
|
||||
parameters: { enabled: "boolean" },
|
||||
bodyTemplate: { enabled: { $param: "enabled" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pokedex-interactive",
|
||||
title: "Interactive Pokédex",
|
||||
description:
|
||||
"A full-featured Pokédex with paginated list, detail SubFetch, type badges, stats with progress bars, moves grid, and evolution chain. Exercises filter(Boolean), pop(), find, reduce, sort, replaceAll, padStart, arithmetic, and nested SubFetch.",
|
||||
template: `<Stack gap="sm" p="sm" style={{ minWidth: 0 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size="lg" radius="xl" variant="light" color="red">P</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={3}>Pokédex</Title>
|
||||
<Text size="xs" c="dimmed"><NumberFormatter value={data.count} thousandSeparator /> Pokémon discovered</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<RefreshButton label="Refresh" size="xs" />
|
||||
</Group>
|
||||
|
||||
<PaginatedList pageSize={5}>
|
||||
{data.results.map((pokemon, index) =>
|
||||
<Accordion key={pokemon.name} variant="separated" radius="md">
|
||||
<Accordion.Item value={pokemon.name}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="xs">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Avatar size="sm" radius="md" src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + (index + 1) + ".png"} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed">#{String(index + 1).padStart(4, "0")}</Text>
|
||||
<Text fw={700} tt="capitalize" lineClamp={1}>{pokemon.name.replaceAll("-", " ")}</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge size="xs" variant="outline">Details</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SubFetch requestId="pokemon-detail" params={{ name: pokemon.name }}>
|
||||
{(detail, meta) =>
|
||||
<Stack gap="sm">
|
||||
<Grid gutter="sm">
|
||||
<Grid.Col span={{ base: 12, sm: 5 }}>
|
||||
<Center>
|
||||
<Image src={detail.sprites.front_default} alt={detail.name} fit="contain" h={120} />
|
||||
</Center>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 7 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
{detail.types.map((entry) =>
|
||||
<Badge key={entry.type.name} tt="capitalize">{entry.type.name}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<SimpleGrid cols={3} spacing="xs">
|
||||
<Box><Text size="xs" c="dimmed">Height</Text><Text fw={700}><NumberFormatter value={detail.height / 10} decimalScale={1} suffix=" m" /></Text></Box>
|
||||
<Box><Text size="xs" c="dimmed">Weight</Text><Text fw={700}><NumberFormatter value={detail.weight / 10} decimalScale={1} suffix=" kg" /></Text></Box>
|
||||
<Box><Text size="xs" c="dimmed">Base XP</Text><Text fw={700}>{detail.base_experience || "—"}</Text></Box>
|
||||
</SimpleGrid>
|
||||
<Group gap="xs">
|
||||
{detail.abilities.map((entry) =>
|
||||
<Badge key={entry.ability.name} variant="light" size="xs" tt="capitalize">
|
||||
{entry.ability.name.replaceAll("-", " ")}{entry.is_hidden ? " ·H" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Tabs defaultValue="stats" variant="outline" radius="md">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="stats">Stats</Tabs.Tab>
|
||||
<Tabs.Tab value="moves">Moves</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="stats" pt="sm">
|
||||
<Stack gap="xs">
|
||||
{detail.stats.map((entry) =>
|
||||
<Box key={entry.stat.name}>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Text size="xs" tt="capitalize">{entry.stat.name.replaceAll("-", " ")}</Text>
|
||||
<Text size="xs" fw={700}>{entry.base_stat}</Text>
|
||||
</Group>
|
||||
<Progress value={Math.min(100, entry.base_stat / 2.55)} size="sm" radius="xl" />
|
||||
</Box>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={700}>Total</Text>
|
||||
<Badge>{detail.stats.reduce((sum, s) => sum + s.base_stat, 0)}</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="moves" pt="sm">
|
||||
<ScrollArea h={120} type="auto">
|
||||
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="xs">
|
||||
{detail.moves.slice(0, 30).map((entry) =>
|
||||
<Badge key={entry.move.name} variant="light" size="xs" tt="capitalize" style={{ whiteSpace: "normal" }}>
|
||||
{entry.move.name.replaceAll("-", " ")}
|
||||
</Badge>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Text size="xs" c="dimmed">Status: {meta.status}</Text>
|
||||
</Stack>
|
||||
}
|
||||
</SubFetch>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</PaginatedList>
|
||||
</Stack>`,
|
||||
requests: [
|
||||
{
|
||||
id: "pokemon-detail",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v2/pokemon/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
cacheTtlSeconds: 3600,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "smart-home-control",
|
||||
title: "Smart-home control",
|
||||
description: "A stateful light control with an explicit destructive confirmation.",
|
||||
template: `<Stack gap="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>Living room</Title>
|
||||
<Badge color={data.state === "on" ? "yellow" : "gray"}>{data.state}</Badge>
|
||||
</Group>
|
||||
<ToggleSwitch requestId="set-light" onParams={{ state: "on" }} offParams={{ state: "off" }} initialValue={data.state === "on"} label="Power" color="yellow" />
|
||||
<ActionButton requestId="delete-scene" label="Delete scene" color="red" icon="trash" confirmMessage="Delete this scene?" />
|
||||
</Stack>`,
|
||||
requests: [
|
||||
{
|
||||
id: "set-light",
|
||||
kind: "action",
|
||||
method: "PUT",
|
||||
pathTemplate: "/api/lights/living-room",
|
||||
parameters: { state: "string" },
|
||||
bodyTemplate: { state: { $param: "state" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
},
|
||||
{
|
||||
id: "delete-scene",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/api/scenes/living-room",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "full",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,108 @@
|
||||
export interface ImportReview {
|
||||
name: string;
|
||||
origin: string;
|
||||
authType: string;
|
||||
networkScope: string;
|
||||
methods: string[];
|
||||
permissions: string[];
|
||||
hasActions: boolean;
|
||||
}
|
||||
|
||||
function extractFencedBlock(text: string, acceptedLanguages: readonly string[]): string | undefined {
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const fenceStart = text.indexOf("```", cursor);
|
||||
if (fenceStart === -1) return undefined;
|
||||
|
||||
const headerStart = fenceStart + 3;
|
||||
const headerEnd = text.indexOf("\n", headerStart);
|
||||
if (headerEnd === -1) return undefined;
|
||||
|
||||
const contentStart = headerEnd + 1;
|
||||
const fenceEnd = text.indexOf("```", contentStart);
|
||||
if (fenceEnd === -1) return undefined;
|
||||
|
||||
const language = text.slice(headerStart, headerEnd).trim().toLowerCase();
|
||||
if (acceptedLanguages.includes(language)) return text.slice(contentStart, fenceEnd);
|
||||
|
||||
cursor = fenceEnd + 3;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const jsonBlock = extractFencedBlock(text, ["", "json"]);
|
||||
const jsxBlock = extractFencedBlock(text, ["jsx", "tsx"])?.trim();
|
||||
const value: unknown = JSON.parse(jsonBlock ?? text);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const widget = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof widget.name !== "string" ||
|
||||
typeof widget.url !== "string" ||
|
||||
typeof widget.displayType !== "string" ||
|
||||
!widget.displayConfig ||
|
||||
typeof widget.displayConfig !== "object" ||
|
||||
Array.isArray(widget.displayConfig)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (jsxBlock && widget.displayType === "customJsx") {
|
||||
const displayConfig = widget.displayConfig as Record<string, unknown>;
|
||||
if (displayConfig.template === "__HOMARR_TEMPLATE__" || !displayConfig.template) {
|
||||
displayConfig.template = jsxBlock;
|
||||
}
|
||||
}
|
||||
return widget;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
|
||||
if (!value) return null;
|
||||
const displayConfig = value.displayConfig as Record<string, unknown>;
|
||||
const requests = Array.isArray(displayConfig.requests)
|
||||
? displayConfig.requests.filter(
|
||||
(request): request is Record<string, unknown> =>
|
||||
request !== null && typeof request === "object" && !Array.isArray(request),
|
||||
)
|
||||
: [];
|
||||
const baseMethod = typeof value.method === "string" ? value.method : "GET";
|
||||
const methods = [
|
||||
...new Set([
|
||||
baseMethod,
|
||||
...requests.map((request) => request.method).filter((method): method is string => typeof method === "string"),
|
||||
]),
|
||||
];
|
||||
const permissions = [
|
||||
...new Set(
|
||||
requests
|
||||
.map((request) => request.minimumBoardPermission)
|
||||
.filter(
|
||||
(permission): permission is string =>
|
||||
permission === "view" || permission === "modify" || permission === "full",
|
||||
),
|
||||
),
|
||||
];
|
||||
if (permissions.length === 0) permissions.push(baseMethod === "GET" ? "view" : "modify");
|
||||
|
||||
let origin = value.url;
|
||||
try {
|
||||
origin = new URL(value.url as string).origin;
|
||||
} catch {
|
||||
// The server returns the localized validation error after confirmation.
|
||||
}
|
||||
|
||||
return {
|
||||
name: value.name as string,
|
||||
origin: String(origin),
|
||||
authType: typeof value.authType === "string" ? value.authType : "none",
|
||||
networkScope: typeof displayConfig.networkScope === "string" ? displayConfig.networkScope : "legacy",
|
||||
methods,
|
||||
permissions,
|
||||
hasActions: methods.some((method) => method !== "GET"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./ai-prompt";
|
||||
export * from "./component-registry";
|
||||
export * from "./display-data";
|
||||
export * from "./display-form";
|
||||
export * from "./examples";
|
||||
export * from "./import";
|
||||
export * from "./schema";
|
||||
@@ -0,0 +1,22 @@
|
||||
export const customWidgetAuthTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
|
||||
export type CustomWidgetAuthType = (typeof customWidgetAuthTypes)[number];
|
||||
|
||||
export const customWidgetDisplayTypes = [
|
||||
"singleValue",
|
||||
"keyValue",
|
||||
"table",
|
||||
"statGrid",
|
||||
"progressBars",
|
||||
"statusIndicator",
|
||||
"countGrid",
|
||||
"raw",
|
||||
"actionButton",
|
||||
"customJsx",
|
||||
] as const;
|
||||
export type CustomWidgetDisplayType = (typeof customWidgetDisplayTypes)[number];
|
||||
|
||||
export const customWidgetMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
export type CustomWidgetMethod = (typeof customWidgetMethods)[number];
|
||||
|
||||
export const customWidgetSecretKinds = ["apiKey", "username", "password"] as const;
|
||||
export type CustomWidgetSecretKind = (typeof customWidgetSecretKinds)[number];
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./built-in-display-schemas";
|
||||
export * from "./custom-jsx-schema";
|
||||
export * from "./definition-schemas";
|
||||
export * from "./display-config-schema";
|
||||
export * from "./schema-types";
|
||||
Reference in new issue
Block a user