feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { boardLayoutSchema } from "./board";
|
||||
|
||||
describe("board layout gutters", () => {
|
||||
test("defaults both gutters to disabled", () => {
|
||||
expect(
|
||||
boardLayoutSchema.parse({
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
}),
|
||||
).toMatchObject({
|
||||
leftGutterColumnCount: 0,
|
||||
rightGutterColumnCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("allows up to three columns per gutter while preserving a main column", () => {
|
||||
expect(
|
||||
boardLayoutSchema.safeParse({
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 2,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
boardLayoutSchema.safeParse({
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "base",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects sidebars on mobile layouts", () => {
|
||||
expect(
|
||||
boardLayoutSchema.safeParse({
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 6,
|
||||
leftGutterColumnCount: 1,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { responsiveBoardLayoutsSchema } from "./board";
|
||||
import { containerSectionOptionsDefaults, sectionSchema } from "./shared";
|
||||
|
||||
const mobile = {
|
||||
id: "mobile",
|
||||
name: "Mobile",
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile" as const,
|
||||
};
|
||||
const base = {
|
||||
id: "base",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
breakpoint: 768,
|
||||
role: "base" as const,
|
||||
};
|
||||
|
||||
describe("responsiveBoardLayoutsSchema", () => {
|
||||
test("accepts protected layouts and custom layouts strictly between them", () => {
|
||||
expect(
|
||||
responsiveBoardLayoutsSchema.safeParse([
|
||||
mobile,
|
||||
{ id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" },
|
||||
base,
|
||||
]).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ name: "missing Mobile", layouts: [{ ...mobile, role: "custom" }, base] },
|
||||
{ name: "removable Base replacement", layouts: [mobile, { ...base, role: "custom" }] },
|
||||
{ name: "non-zero Mobile breakpoint", layouts: [{ ...mobile, breakpoint: 320 }, base] },
|
||||
{
|
||||
name: "custom layout at Base breakpoint",
|
||||
layouts: [mobile, { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 768, role: "custom" }, base],
|
||||
},
|
||||
{
|
||||
name: "duplicate layout ID",
|
||||
layouts: [mobile, { ...base, id: mobile.id }],
|
||||
},
|
||||
{
|
||||
name: "mobile sidebar",
|
||||
layouts: [{ ...mobile, leftGutterColumnCount: 1 }, base],
|
||||
},
|
||||
])("rejects $name", ({ layouts }) => {
|
||||
expect(responsiveBoardLayoutsSchema.safeParse(layouts).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("section behavior validation", () => {
|
||||
test("normalizes a container to the current behavior defaults", () => {
|
||||
const result = sectionSchema.parse({
|
||||
id: "container",
|
||||
kind: "container",
|
||||
layouts: [],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
collapsed: false,
|
||||
options: containerSectionOptionsDefaults,
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves configured container behavior", () => {
|
||||
const result = sectionSchema.parse({
|
||||
id: "container",
|
||||
kind: "container",
|
||||
options: {
|
||||
title: "Operations",
|
||||
customCssClasses: ["dense"],
|
||||
borderColor: "#123456",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
scrollable: true,
|
||||
},
|
||||
layouts: [],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
collapsed: false,
|
||||
options: {
|
||||
title: "Operations",
|
||||
customCssClasses: ["dense"],
|
||||
borderColor: "#123456",
|
||||
showLabel: false,
|
||||
collapsible: true,
|
||||
showOpenAll: true,
|
||||
scrollable: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes the legacy dynamic kind to a container", () => {
|
||||
expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({
|
||||
id: "legacy",
|
||||
kind: "container",
|
||||
layouts: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects the legacy category kind", () => {
|
||||
expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ xOffset: 0.5, yOffset: 0, width: 1, height: 1 },
|
||||
{ xOffset: -1, yOffset: 0, width: 1, height: 1 },
|
||||
{ xOffset: 0, yOffset: 0, width: 0, height: 1 },
|
||||
{ xOffset: 0, yOffset: 0, width: 1, height: 0 },
|
||||
])("rejects invalid container grid geometry: %o", (layout) => {
|
||||
expect(() =>
|
||||
sectionSchema.parse({
|
||||
id: "container",
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: "layout", parentSectionId: "root", ...layout }],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
boardPermissions,
|
||||
layoutRoles,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
@@ -25,6 +26,7 @@ export const boardNameSchema = z
|
||||
.max(255)
|
||||
.regex(/^[A-Za-z0-9-_]*$/);
|
||||
export const boardColumnCountSchema = z.number().min(1).max(24);
|
||||
export const boardGutterColumnCountSchema = z.number().int().min(0).max(3);
|
||||
|
||||
export const boardByNameSchema = z.object({
|
||||
name: boardNameSchema,
|
||||
@@ -70,16 +72,67 @@ export const boardSavePartialSettingsSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const boardSettingsSchema = boardSavePartialSettingsSchema.required().extend({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const boardLayoutSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().trim().nonempty().max(32),
|
||||
columnCount: boardColumnCountSchema,
|
||||
leftGutterColumnCount: boardGutterColumnCountSchema.default(0),
|
||||
rightGutterColumnCount: boardGutterColumnCountSchema.default(0),
|
||||
breakpoint: z.number().int().min(0).max(32767),
|
||||
role: z.enum(layoutRoles.values),
|
||||
})
|
||||
.refine((layout) => layout.leftGutterColumnCount + layout.rightGutterColumnCount < layout.columnCount, {
|
||||
message: "Gutters must leave at least one dashboard column",
|
||||
path: ["columnCount"],
|
||||
})
|
||||
.refine(
|
||||
(layout) => layout.role !== "mobile" || (layout.leftGutterColumnCount === 0 && layout.rightGutterColumnCount === 0),
|
||||
{
|
||||
message: "Mobile layouts cannot have sidebars",
|
||||
path: ["leftGutterColumnCount"],
|
||||
},
|
||||
);
|
||||
|
||||
export const responsiveBoardLayoutsSchema = z
|
||||
.array(boardLayoutSchema)
|
||||
.min(2)
|
||||
.superRefine((layouts, ctx) => {
|
||||
const mobileLayouts = layouts.filter((layout) => layout.role === "mobile");
|
||||
const baseLayouts = layouts.filter((layout) => layout.role === "base");
|
||||
const mobileLayout = mobileLayouts.at(0);
|
||||
const baseLayout = baseLayouts.at(0);
|
||||
if (mobileLayouts.length !== 1 || baseLayouts.length !== 1 || !mobileLayout || !baseLayout) {
|
||||
ctx.addIssue({ code: "custom", message: "Boards require exactly one Mobile and one Base layout" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mobileLayout.breakpoint !== 0) {
|
||||
ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
|
||||
}
|
||||
|
||||
if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
|
||||
ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
|
||||
}
|
||||
|
||||
if (new Set(layouts.map((layout) => layout.id)).size !== layouts.length) {
|
||||
ctx.addIssue({ code: "custom", message: "Layout IDs must be unique" });
|
||||
}
|
||||
});
|
||||
|
||||
export const boardSaveLayoutsSchema = z.object({
|
||||
id: z.string(),
|
||||
layouts: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().trim().nonempty().max(32),
|
||||
columnCount: boardColumnCountSchema,
|
||||
breakpoint: z.number().min(0).max(32767),
|
||||
}),
|
||||
),
|
||||
layouts: responsiveBoardLayoutsSchema,
|
||||
});
|
||||
|
||||
export const boardResetLayoutSchema = z.object({
|
||||
boardId: z.string(),
|
||||
layoutId: z.string(),
|
||||
});
|
||||
|
||||
export const boardSaveSchema = z.object({
|
||||
@@ -123,5 +176,20 @@ export const addItemToBoardSchema = z.object({
|
||||
boardId: z.string(),
|
||||
kind: zodEnumFromArray(widgetKinds),
|
||||
options: z.record(z.string(), z.unknown()).default({}),
|
||||
integrationIds: z.array(z.string()).default([]),
|
||||
size: z.object({ width: z.number().int().min(1).max(24), height: z.number().int().min(1).max(24) }).optional(),
|
||||
integrationIds: z
|
||||
.array(z.string())
|
||||
.max(32)
|
||||
.refine((ids) => new Set(ids).size === ids.length)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const updateBoardItemLayoutSchema = z.object({
|
||||
boardId: z.string(),
|
||||
itemId: z.string(),
|
||||
layoutId: z.string(),
|
||||
xOffset: z.number().int().min(0).max(32767),
|
||||
yOffset: z.number().int().min(0).max(32767),
|
||||
width: z.number().int().min(1).max(24),
|
||||
height: z.number().int().min(1).max(24),
|
||||
});
|
||||
@@ -35,7 +35,7 @@ export const checkCertificateFile: z.core.CheckFn<File> = (context) => {
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "fileTooLarge",
|
||||
params: { maxSize: "1 MB" },
|
||||
params: { maxSize: "1 MiB" },
|
||||
}),
|
||||
input: context.value.size,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
export const paginatedSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.number().int().positive().default(10),
|
||||
pageSize: z.number().int().positive().max(100).default(10),
|
||||
page: z.number().int().positive().default(1),
|
||||
});
|
||||
|
||||
@@ -12,5 +12,5 @@ export const byIdSchema = z.object({
|
||||
|
||||
export const searchSchema = z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().positive().default(10),
|
||||
limit: z.number().int().positive().max(100).default(10),
|
||||
});
|
||||
@@ -1,471 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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];
|
||||
|
||||
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."),
|
||||
});
|
||||
|
||||
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.",
|
||||
),
|
||||
});
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
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'])",
|
||||
),
|
||||
});
|
||||
|
||||
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."),
|
||||
});
|
||||
|
||||
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)"),
|
||||
});
|
||||
|
||||
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)"),
|
||||
});
|
||||
|
||||
const FORBIDDEN_TEMPLATE_PATTERN =
|
||||
/\bconstructor\b|\b__proto__\b|\bprototype\b|\beval\b|\bFunction\b|\bimport\s*\(|\brequire\b|\bglobalThis\b|\bwindow\b|\bdocument\b|\bfetch\b|\bXMLHttpRequest\b/i;
|
||||
|
||||
const customJsxDisplayConfigSchema = z.object({
|
||||
type: z
|
||||
.literal("customJsx")
|
||||
.describe("Display type discriminator — must be 'customJsx' when displayType is customJsx"),
|
||||
template: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(10000)
|
||||
.refine((t) => !FORBIDDEN_TEMPLATE_PATTERN.test(t), {
|
||||
message:
|
||||
"Template contains forbidden keywords (constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch)",
|
||||
})
|
||||
.describe(
|
||||
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Forbidden keywords: constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch",
|
||||
),
|
||||
});
|
||||
|
||||
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
|
||||
.discriminatedUnion("type", [
|
||||
singleValueDisplayConfigSchema,
|
||||
keyValueDisplayConfigSchema,
|
||||
tableDisplayConfigSchema,
|
||||
statGridDisplayConfigSchema,
|
||||
progressBarsDisplayConfigSchema,
|
||||
statusIndicatorDisplayConfigSchema,
|
||||
countGridDisplayConfigSchema,
|
||||
rawDisplayConfigSchema,
|
||||
actionButtonDisplayConfigSchema,
|
||||
customJsxDisplayConfigSchema,
|
||||
])
|
||||
.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>;
|
||||
|
||||
const displayTypesMatch = (displayType?: string, configType?: string): boolean => {
|
||||
if (!displayType) return true;
|
||||
if (!configType) return true;
|
||||
return displayType === configType;
|
||||
};
|
||||
|
||||
const displayTypeMatchRefinement = (d: { displayType: string; displayConfig: { type: string } }) =>
|
||||
displayTypesMatch(d.displayType, d.displayConfig.type);
|
||||
const displayTypeMatchMessage = {
|
||||
message: "displayType must match displayConfig.type",
|
||||
path: ["displayConfig", "type"],
|
||||
};
|
||||
|
||||
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 })
|
||||
.refine(displayTypeMatchRefinement, displayTypeMatchMessage);
|
||||
|
||||
export const customWidgetUpdateSchema = baseDefinitionSchema
|
||||
.partial()
|
||||
.extend({ id: z.string(), secrets: secretsInputSchema.optional() })
|
||||
.refine((d) => displayTypesMatch(d.displayType, d.displayConfig?.type), displayTypeMatchMessage);
|
||||
|
||||
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
|
||||
.literal("homarr-custom-widget-v2")
|
||||
.optional()
|
||||
.describe("Schema version identifier. Should be 'homarr-custom-widget-v2' for current format."),
|
||||
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.",
|
||||
),
|
||||
})
|
||||
.refine(displayTypeMatchRefinement, displayTypeMatchMessage);
|
||||
|
||||
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
|
||||
@@ -0,0 +1,237 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const headerPreferencesVersion = 3 as const;
|
||||
|
||||
export const headerBuiltinItemIds = [
|
||||
"logo",
|
||||
"search",
|
||||
"home",
|
||||
"boardSwitcher",
|
||||
"assistant",
|
||||
"docker",
|
||||
"boardEdit",
|
||||
"boardSettings",
|
||||
"settings",
|
||||
"themeToggle",
|
||||
"user",
|
||||
] as const;
|
||||
|
||||
export const requiredHeaderBuiltinItemIds = ["user"] as const;
|
||||
|
||||
export const headerPreferenceItemIds = headerBuiltinItemIds;
|
||||
export const headerItemIds = headerBuiltinItemIds;
|
||||
|
||||
export type HeaderBuiltinItemId = (typeof headerBuiltinItemIds)[number];
|
||||
export type HeaderPreferenceItemId = HeaderBuiltinItemId;
|
||||
|
||||
export const headerZoneIds = ["left", "center", "right"] as const;
|
||||
export const headerSearchDisplayValues = ["input", "icon"] as const;
|
||||
export const headerLogoDisplayValues = ["logo", "logoAndText"] as const;
|
||||
|
||||
export type HeaderZoneId = (typeof headerZoneIds)[number];
|
||||
export type HeaderSearchDisplay = (typeof headerSearchDisplayValues)[number];
|
||||
export type HeaderLogoDisplay = (typeof headerLogoDisplayValues)[number];
|
||||
|
||||
const headerBuiltinItemSchema = z
|
||||
.object({
|
||||
type: z.literal("builtin"),
|
||||
id: z.enum(headerBuiltinItemIds),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const headerBoardItemSchema = z
|
||||
.object({
|
||||
type: z.literal("board"),
|
||||
boardId: z.string().min(1).max(255),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const headerItemSchema = z.discriminatedUnion("type", [headerBuiltinItemSchema, headerBoardItemSchema]);
|
||||
|
||||
export type HeaderBuiltinItem = z.infer<typeof headerBuiltinItemSchema>;
|
||||
export type HeaderBoardItem = z.infer<typeof headerBoardItemSchema>;
|
||||
export type HeaderItem = z.infer<typeof headerItemSchema>;
|
||||
export type HeaderItemId = HeaderItem;
|
||||
export type HeaderZones = Record<HeaderZoneId, HeaderItem[]>;
|
||||
|
||||
export const createBuiltinHeaderItem = (id: HeaderBuiltinItemId): HeaderBuiltinItem => ({ type: "builtin", id });
|
||||
|
||||
export const createBoardHeaderItem = (boardId: string): HeaderBoardItem => ({ type: "board", boardId });
|
||||
|
||||
export const isRequiredHeaderItem = (item: HeaderItem) =>
|
||||
item.type === "builtin" && requiredHeaderBuiltinItemIds.some((itemId) => itemId === item.id);
|
||||
|
||||
export const getHeaderItemKey = (item: HeaderItem): string => {
|
||||
if (item.type === "board") return `board:${item.boardId}`;
|
||||
return `builtin:${item.id}`;
|
||||
};
|
||||
|
||||
export const getHeaderItems = (zones: HeaderZones): HeaderItem[] => headerZoneIds.flatMap((zone) => zones[zone]);
|
||||
|
||||
export const getHeaderItemZone = (zones: HeaderZones, item: HeaderItem): HeaderZoneId | undefined => {
|
||||
const itemKey = getHeaderItemKey(item);
|
||||
return headerZoneIds.find((zone) => zones[zone].some((candidate) => getHeaderItemKey(candidate) === itemKey));
|
||||
};
|
||||
|
||||
export const getDefaultHeaderZone = (item: HeaderItem): HeaderZoneId => {
|
||||
if (item.type === "board") return "right";
|
||||
if (item.id === "logo") return "left";
|
||||
if (item.id === "search") return "center";
|
||||
return "right";
|
||||
};
|
||||
|
||||
const headerItemListSchema = z.array(headerItemSchema).max(100);
|
||||
|
||||
const headerZonesSchema = z
|
||||
.object({
|
||||
left: headerItemListSchema,
|
||||
center: headerItemListSchema,
|
||||
right: headerItemListSchema,
|
||||
})
|
||||
.strict()
|
||||
.refine((zones) => getHeaderItems(zones).length > 0, "At least one header item is required")
|
||||
.refine((zones) => {
|
||||
const itemKeys = getHeaderItems(zones).map(getHeaderItemKey);
|
||||
return new Set(itemKeys).size === itemKeys.length;
|
||||
}, "Header items must be unique")
|
||||
.refine(
|
||||
(zones) => getHeaderItems(zones).some((item) => item.type === "builtin" && item.id === "user"),
|
||||
"Account access must remain in the header",
|
||||
);
|
||||
|
||||
export const headerPreferencesSchema = z
|
||||
.object({
|
||||
version: z.literal(headerPreferencesVersion),
|
||||
visible: z.boolean(),
|
||||
searchDisplay: z.enum(headerSearchDisplayValues),
|
||||
logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"),
|
||||
zones: headerZonesSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type HeaderPreferences = z.infer<typeof headerPreferencesSchema>;
|
||||
|
||||
const legacyHeaderItemIds = ["logo", "search", "boardSwitcher", "assistant", "docker", "user"] as const;
|
||||
type LegacyHeaderItemId = (typeof legacyHeaderItemIds)[number];
|
||||
|
||||
const legacyHeaderItemListSchema = z
|
||||
.array(z.enum(legacyHeaderItemIds))
|
||||
.min(1)
|
||||
.max(legacyHeaderItemIds.length)
|
||||
.refine((items) => new Set(items).size === items.length, "Header items must be unique")
|
||||
.refine((items) => items.includes("user"), "Account access must remain in the header");
|
||||
|
||||
const legacyHeaderPreferencesV1Schema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
visible: z.boolean(),
|
||||
items: legacyHeaderItemListSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const legacyHeaderPreferencesV2Schema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
visible: z.boolean(),
|
||||
zones: z
|
||||
.object({
|
||||
left: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||
center: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||
right: z.array(z.enum(legacyHeaderItemIds)).max(legacyHeaderItemIds.length),
|
||||
})
|
||||
.strict()
|
||||
.refine((zones) => {
|
||||
const items = headerZoneIds.flatMap((zone) => zones[zone]);
|
||||
return items.length > 0 && new Set(items).size === items.length && items.includes("user");
|
||||
}, "Header items must be unique and include account access"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const migrateLegacyItems = (
|
||||
visible: boolean,
|
||||
zones: Record<HeaderZoneId, LegacyHeaderItemId[]>,
|
||||
): HeaderPreferences => ({
|
||||
version: headerPreferencesVersion,
|
||||
visible,
|
||||
searchDisplay: "input",
|
||||
logoDisplay: "logoAndText",
|
||||
zones: {
|
||||
left: zones.left.map(createBuiltinHeaderItem),
|
||||
center: zones.center.map(createBuiltinHeaderItem),
|
||||
right: zones.right.map(createBuiltinHeaderItem),
|
||||
},
|
||||
});
|
||||
|
||||
const migrateLegacyHeaderPreferencesV1 = (
|
||||
legacy: z.infer<typeof legacyHeaderPreferencesV1Schema>,
|
||||
): HeaderPreferences => {
|
||||
const zones: Record<HeaderZoneId, LegacyHeaderItemId[]> = { left: [], center: [], right: [] };
|
||||
for (const itemId of legacy.items) {
|
||||
const item = createBuiltinHeaderItem(itemId);
|
||||
zones[getDefaultHeaderZone(item)].push(itemId);
|
||||
}
|
||||
return migrateLegacyItems(legacy.visible, zones);
|
||||
};
|
||||
|
||||
const migrateLegacyHeaderPreferencesV2 = (legacy: z.infer<typeof legacyHeaderPreferencesV2Schema>): HeaderPreferences =>
|
||||
migrateLegacyItems(legacy.visible, legacy.zones);
|
||||
|
||||
const includeRequiredHeaderItems = (preferences: HeaderPreferences): HeaderPreferences => {
|
||||
const itemKeys = new Set(getHeaderItems(preferences.zones).map(getHeaderItemKey));
|
||||
const missingItems = requiredHeaderBuiltinItemIds
|
||||
.map(createBuiltinHeaderItem)
|
||||
.filter((item) => !itemKeys.has(getHeaderItemKey(item)));
|
||||
if (missingItems.length === 0) return preferences;
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
zones: {
|
||||
...preferences.zones,
|
||||
right: [...preferences.zones.right, ...missingItems],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const headerPreferencesMutationSchema = z
|
||||
.discriminatedUnion("version", [
|
||||
headerPreferencesSchema,
|
||||
legacyHeaderPreferencesV1Schema,
|
||||
legacyHeaderPreferencesV2Schema,
|
||||
])
|
||||
.transform((preferences): HeaderPreferences => {
|
||||
if (preferences.version === 1) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV1(preferences));
|
||||
if (preferences.version === 2) return includeRequiredHeaderItems(migrateLegacyHeaderPreferencesV2(preferences));
|
||||
return includeRequiredHeaderItems(preferences);
|
||||
});
|
||||
|
||||
export const defaultHeaderPreferences = {
|
||||
version: headerPreferencesVersion,
|
||||
visible: true,
|
||||
searchDisplay: "input",
|
||||
logoDisplay: "logoAndText",
|
||||
zones: {
|
||||
left: [createBuiltinHeaderItem("logo")],
|
||||
center: [createBuiltinHeaderItem("search")],
|
||||
right: [
|
||||
createBuiltinHeaderItem("boardEdit"),
|
||||
createBuiltinHeaderItem("boardSettings"),
|
||||
createBuiltinHeaderItem("user"),
|
||||
],
|
||||
},
|
||||
} satisfies HeaderPreferences;
|
||||
|
||||
export const defaultHeaderPreferencesSerialized = JSON.stringify(defaultHeaderPreferences);
|
||||
|
||||
export const parseHeaderPreferences = (value: unknown): HeaderPreferences => {
|
||||
try {
|
||||
const decoded = typeof value === "string" ? JSON.parse(value) : value;
|
||||
const parsed = headerPreferencesMutationSchema.safeParse(decoded);
|
||||
if (parsed.success) return parsed.data;
|
||||
return defaultHeaderPreferences;
|
||||
} catch {
|
||||
return defaultHeaderPreferences;
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeHeaderPreferences = (value: HeaderPreferences) =>
|
||||
JSON.stringify(includeRequiredHeaderItems(value));
|
||||
@@ -27,7 +27,7 @@ export const mediaUploadSchema = zfd.formData({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "fileTooLarge",
|
||||
params: { maxSize: "32 MB" },
|
||||
params: { maxSize: "32 MiB" },
|
||||
}),
|
||||
input: context.value.size,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
onboardingCompleteSetupSchema,
|
||||
onboardingCreateIntegrationSchema,
|
||||
onboardingDiscoveredAppSchema,
|
||||
} from "./onboarding";
|
||||
|
||||
describe("onboarding address validation", () => {
|
||||
test.each(["server.local:8989", "0.0.0.0:3000", "sonarr", "/services/sonarr"])(
|
||||
"accepts the self-hosted address %s",
|
||||
(url) => {
|
||||
expect(onboardingCreateIntegrationSchema.safeParse({ name: "Service", url }).success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test("accepts HTTP services and icon identifiers", () => {
|
||||
expect(
|
||||
onboardingDiscoveredAppSchema.safeParse({
|
||||
name: "Sonarr",
|
||||
sourceId: "docker:sonarr",
|
||||
href: "http://sonarr:8989",
|
||||
pingUrl: "https://sonarr.example.com/ping",
|
||||
iconUrl: "sonarr",
|
||||
description: "TV automation",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts bounded integration metadata and rejects empty or oversized metadata", () => {
|
||||
expect(
|
||||
onboardingCreateIntegrationSchema.safeParse({
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
pingUrl: "https://sonarr.example.com/ping",
|
||||
iconUrl: "https://icons.example/sonarr.svg",
|
||||
description: "TV automation",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
onboardingCreateIntegrationSchema.safeParse({
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
pingUrl: "",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
onboardingCreateIntegrationSchema.safeParse({
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
description: "x".repeat(513),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts custom app and ping addresses", () => {
|
||||
expect(
|
||||
onboardingDiscoveredAppSchema.safeParse({
|
||||
name: "Database",
|
||||
href: "postgres://database:5432",
|
||||
pingUrl: "database.local:5432",
|
||||
iconUrl: null,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("requires every app submitted to completeSetup to have a non-empty address", () => {
|
||||
const input = {
|
||||
server: { defaultLocale: "en", defaultColorScheme: "dark" },
|
||||
board: {
|
||||
name: "dashboard",
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md",
|
||||
},
|
||||
apps: [{ sourceId: "docker:status", name: "Status", href: null, iconUrl: null }],
|
||||
};
|
||||
|
||||
expect(onboardingCompleteSetupSchema.safeParse(input).success).toBe(false);
|
||||
expect(
|
||||
onboardingCompleteSetupSchema.safeParse({
|
||||
...input,
|
||||
apps: [{ ...input.apps[0], href: "status.local:3001" }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts Docker source IDs up to 512 characters", () => {
|
||||
const input = {
|
||||
server: { defaultLocale: "en", defaultColorScheme: "dark" },
|
||||
board: {
|
||||
name: "dashboard",
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md",
|
||||
},
|
||||
selectedDockerSourceIds: ["x".repeat(512)],
|
||||
};
|
||||
|
||||
expect(onboardingCompleteSetupSchema.safeParse(input).success).toBe(true);
|
||||
expect(
|
||||
onboardingCompleteSetupSchema.safeParse({ ...input, selectedDockerSourceIds: ["x".repeat(513)] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts board column counts from 8 through 24", () => {
|
||||
const input = {
|
||||
server: { defaultLocale: "en", defaultColorScheme: "dark" },
|
||||
board: {
|
||||
name: "dashboard",
|
||||
primaryColor: "#228BE6",
|
||||
secondaryColor: "#15AABF",
|
||||
itemRadius: "md",
|
||||
columnCount: 8,
|
||||
},
|
||||
};
|
||||
|
||||
expect(onboardingCompleteSetupSchema.safeParse(input).success).toBe(true);
|
||||
expect(
|
||||
onboardingCompleteSetupSchema.safeParse({ ...input, board: { ...input.board, columnCount: 24 } }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
onboardingCompleteSetupSchema.safeParse({ ...input, board: { ...input.board, columnCount: 7 } }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
onboardingCompleteSetupSchema.safeParse({ ...input, board: { ...input.board, columnCount: 25 } }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
colorSchemes,
|
||||
integrationKinds,
|
||||
integrationSecretKinds,
|
||||
onboardingLayoutPresets,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { supportedLanguages } from "@homarr/translation/languages";
|
||||
|
||||
import { boardNameSchema } from "./board";
|
||||
import { zodEnumFromArray } from "./enums";
|
||||
|
||||
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
||||
const serviceAddressSchema = z.string().trim().min(1).max(2048);
|
||||
const uniqueIdsSchema = (maxLength = 255) =>
|
||||
z
|
||||
.array(z.string().trim().min(1).max(maxLength))
|
||||
.max(128)
|
||||
.refine((ids) => new Set(ids).size === ids.length, { message: "IDs must be unique" });
|
||||
const sourceIdSchema = z.string().trim().min(1).max(512);
|
||||
|
||||
export const onboardingCreateIntegrationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(127),
|
||||
url: serviceAddressSchema,
|
||||
sourceId: sourceIdSchema.optional(),
|
||||
iconUrl: z.string().trim().min(1).max(2048).nullable().optional(),
|
||||
description: z.string().trim().max(512).nullable().optional(),
|
||||
pingUrl: serviceAddressSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export const onboardingIntegrationDraftSchema = onboardingCreateIntegrationSchema.extend({
|
||||
sourceId: sourceIdSchema,
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const onboardingDiscoveredAppSchema = z.object({
|
||||
sourceId: sourceIdSchema.optional(),
|
||||
name: z.string().trim().min(1).max(127),
|
||||
href: serviceAddressSchema.nullable(),
|
||||
pingUrl: serviceAddressSchema.nullable().optional(),
|
||||
iconUrl: z.string().trim().min(1).nullable(),
|
||||
description: z.string().trim().max(512).nullable().optional(),
|
||||
});
|
||||
|
||||
export const onboardingAppDraftSchema = onboardingDiscoveredAppSchema.extend({
|
||||
sourceId: sourceIdSchema,
|
||||
href: serviceAddressSchema,
|
||||
});
|
||||
|
||||
const integrationDraftsSchema = z
|
||||
.array(onboardingIntegrationDraftSchema)
|
||||
.max(128)
|
||||
.refine((drafts) => new Set(drafts.map((draft) => draft.sourceId)).size === drafts.length, {
|
||||
message: "Integration source IDs must be unique",
|
||||
});
|
||||
const appDraftsSchema = z
|
||||
.array(onboardingAppDraftSchema)
|
||||
.max(128)
|
||||
.refine((drafts) => new Set(drafts.map((draft) => draft.sourceId)).size === drafts.length, {
|
||||
message: "App source IDs must be unique",
|
||||
});
|
||||
|
||||
export const onboardingCompleteSetupSchema = z
|
||||
.object({
|
||||
server: z.object({
|
||||
defaultLocale: z.enum(supportedLanguages),
|
||||
defaultColorScheme: z.enum(colorSchemes),
|
||||
analyticsEnabled: z.boolean().optional(),
|
||||
}),
|
||||
board: z.object({
|
||||
id: z.string().trim().min(1).max(255).optional(),
|
||||
name: boardNameSchema,
|
||||
primaryColor: hexColorSchema,
|
||||
secondaryColor: hexColorSchema,
|
||||
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]),
|
||||
layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"),
|
||||
columnCount: z.number().int().min(8).max(24).optional(),
|
||||
leftSidebar: z.boolean().default(false),
|
||||
rightSidebar: z.boolean().default(false),
|
||||
}),
|
||||
selectedIntegrationIds: uniqueIdsSchema().default([]),
|
||||
selectedAppIds: uniqueIdsSchema().default([]),
|
||||
integrations: integrationDraftsSchema.default([]),
|
||||
apps: appDraftsSchema.default([]),
|
||||
selectedDockerSourceIds: uniqueIdsSchema(512).default([]),
|
||||
selectedWidgetKinds: z
|
||||
.array(z.enum(widgetKinds))
|
||||
.max(widgetKinds.length)
|
||||
.refine((kinds) => new Set(kinds).size === kinds.length, { message: "Widget kinds must be unique" })
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
const integrationSourceIds = new Set(input.integrations.map((integration) => integration.sourceId));
|
||||
for (const [index, app] of input.apps.entries()) {
|
||||
if (!integrationSourceIds.has(app.sourceId)) continue;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "A source ID cannot be used by both an integration and an app",
|
||||
path: ["apps", index, "sourceId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type OnboardingCompleteSetupInput = z.infer<typeof onboardingCompleteSetupSchema>;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const settingsInitSchema = z.object({
|
||||
analytics: z.object({
|
||||
enableGeneral: z.boolean(),
|
||||
}),
|
||||
crawlingAndIndexing: z.object({
|
||||
noIndex: z.boolean(),
|
||||
noFollow: z.boolean(),
|
||||
noTranslate: z.boolean(),
|
||||
noSiteLinksSearchBox: z.boolean(),
|
||||
}),
|
||||
});
|
||||
@@ -21,15 +21,18 @@ export const itemAdvancedOptionsSchema = z.object({
|
||||
|
||||
export type BoardItemAdvancedOptions = z.infer<typeof itemAdvancedOptionsSchema>;
|
||||
|
||||
const gridOffsetSchema = z.number().int().min(0).max(32767);
|
||||
const gridSizeSchema = z.number().int().min(1).max(32767);
|
||||
|
||||
export const sharedItemSchema = z.object({
|
||||
id: z.string(),
|
||||
layouts: z.array(
|
||||
z.object({
|
||||
layoutId: z.string(),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
yOffset: gridOffsetSchema,
|
||||
xOffset: gridOffsetSchema,
|
||||
width: gridSizeSchema,
|
||||
height: gridSizeSchema,
|
||||
sectionId: z.string(),
|
||||
}),
|
||||
),
|
||||
@@ -44,42 +47,56 @@ export const commonItemSchema = z
|
||||
})
|
||||
.and(sharedItemSchema);
|
||||
|
||||
const categorySectionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.literal("category"),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
collapsed: z.boolean(),
|
||||
});
|
||||
|
||||
const emptySectionSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("empty"),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
yOffset: z.number().int(),
|
||||
xOffset: z.number().int(),
|
||||
});
|
||||
|
||||
export const dynamicSectionOptionsSchema = z.object({
|
||||
title: z.string().max(20).default(""),
|
||||
customCssClasses: z.array(z.string()).default([]),
|
||||
borderColor: z.string().default(""),
|
||||
});
|
||||
export const containerSectionOptionsDefaults = {
|
||||
title: "",
|
||||
customCssClasses: [] as string[],
|
||||
borderColor: "",
|
||||
showLabel: true,
|
||||
collapsible: false,
|
||||
showOpenAll: false,
|
||||
scrollable: false,
|
||||
} as const;
|
||||
|
||||
const dynamicSectionSchema = z.object({
|
||||
export const containerSectionOptionsSchema = z
|
||||
.object({
|
||||
title: z.string().max(64).default(containerSectionOptionsDefaults.title),
|
||||
customCssClasses: z.array(z.string()).default([]),
|
||||
borderColor: z.string().default(containerSectionOptionsDefaults.borderColor),
|
||||
showLabel: z.boolean().default(containerSectionOptionsDefaults.showLabel),
|
||||
collapsible: z.boolean().default(containerSectionOptionsDefaults.collapsible),
|
||||
showOpenAll: z.boolean().default(containerSectionOptionsDefaults.showOpenAll),
|
||||
scrollable: z.boolean().default(containerSectionOptionsDefaults.scrollable),
|
||||
})
|
||||
.default(containerSectionOptionsDefaults);
|
||||
|
||||
export type ContainerSectionOptions = z.infer<typeof containerSectionOptionsSchema>;
|
||||
|
||||
const containerSectionSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("dynamic"),
|
||||
options: dynamicSectionOptionsSchema,
|
||||
kind: z.literal("container"),
|
||||
options: containerSectionOptionsSchema,
|
||||
collapsed: z.boolean().default(false),
|
||||
layouts: z.array(
|
||||
z.object({
|
||||
layoutId: z.string(),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
yOffset: gridOffsetSchema,
|
||||
xOffset: gridOffsetSchema,
|
||||
width: gridSizeSchema,
|
||||
height: gridSizeSchema,
|
||||
parentSectionId: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const sectionSchema = z.union([categorySectionSchema, emptySectionSchema, dynamicSectionSchema]);
|
||||
const legacyDynamicSectionSchema = containerSectionSchema
|
||||
.extend({ kind: z.literal("dynamic") })
|
||||
.transform((section) => ({ ...section, kind: "container" as const }));
|
||||
|
||||
export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema, legacyDynamicSectionSchema]);
|
||||
@@ -8,6 +8,42 @@ import { zodEnumFromArray } from "./enums";
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
import { nullableEmailSchema, optionalEmailSchema } from "./email";
|
||||
|
||||
export {
|
||||
createBoardHeaderItem,
|
||||
createBuiltinHeaderItem,
|
||||
defaultHeaderPreferences,
|
||||
getDefaultHeaderZone,
|
||||
getHeaderItemKey,
|
||||
getHeaderItems,
|
||||
getHeaderItemZone,
|
||||
headerBuiltinItemIds,
|
||||
headerItemIds,
|
||||
headerItemSchema,
|
||||
headerLogoDisplayValues,
|
||||
headerPreferenceItemIds,
|
||||
headerPreferencesMutationSchema,
|
||||
headerPreferencesSchema,
|
||||
headerPreferencesVersion,
|
||||
headerSearchDisplayValues,
|
||||
headerZoneIds,
|
||||
isRequiredHeaderItem,
|
||||
parseHeaderPreferences,
|
||||
requiredHeaderBuiltinItemIds,
|
||||
} from "./header-preferences";
|
||||
export type {
|
||||
HeaderBoardItem,
|
||||
HeaderBuiltinItem,
|
||||
HeaderBuiltinItemId,
|
||||
HeaderItem,
|
||||
HeaderItemId,
|
||||
HeaderLogoDisplay,
|
||||
HeaderPreferenceItemId,
|
||||
HeaderPreferences,
|
||||
HeaderSearchDisplay,
|
||||
HeaderZoneId,
|
||||
HeaderZones,
|
||||
} from "./header-preferences";
|
||||
|
||||
// We always want the lowercase version of the username to compare it in a case-insensitive way
|
||||
export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255);
|
||||
|
||||
@@ -118,6 +154,10 @@ export const userChangeColorSchemeSchema = z.object({
|
||||
colorScheme: zodEnumFromArray(colorSchemes),
|
||||
});
|
||||
|
||||
export const userByteUnitSystemSchema = z.object({
|
||||
byteUnitSystem: z.enum(["binary", "decimal"]),
|
||||
});
|
||||
|
||||
export const userFirstDayOfWeekSchema = z.object({
|
||||
firstDayOfWeek: z
|
||||
.number()
|
||||
|
||||
Reference in new issue
Block a user