merge: stack assistant on mobile boards

This commit is contained in:
Thomas Camlong committed 2026-08-08 21:27:56 +02:00
commit 9fd7a8a05b
1326 files changed
+131355 -46380

No files matched your search

@@ -0,0 +1,42 @@
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,
}),
).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,
}).success,
).toBe(true);
expect(
boardLayoutSchema.safeParse({
id: "layout",
name: "Base",
columnCount: 6,
leftGutterColumnCount: 3,
rightGutterColumnCount: 3,
breakpoint: 0,
}).success,
).toBe(false);
});
});
+109
View File
@@ -0,0 +1,109 @@
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 }],
},
])("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,
},
layouts: [],
});
expect(result).toMatchObject({
collapsed: false,
options: {
title: "Operations",
customCssClasses: ["dense"],
borderColor: "#123456",
showLabel: false,
collapsible: true,
showOpenAll: true,
},
});
});
test.each(["category", "dynamic"])("rejects the legacy %s kind", (kind) => {
expect(() => sectionSchema.parse({ id: "legacy", kind, 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();
});
});
+53 -8
View File
@@ -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,
@@ -75,16 +77,59 @@ export const boardSettingsSchema = boardSavePartialSettingsSchema.required().ext
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"],
});
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 (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
}
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({
-471
View File
@@ -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>;
+39 -28
View File
@@ -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,50 @@ 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,
} 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),
})
.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]);
export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema]);