feat: board settings (#137)

* refactor: improve user feedback for general board settings section

* wip: add board settings for background and colors, move danger zone to own file, refactor code

* feat: add shade selector

* feat: add slider for opacity

* fix: issue with invalid hex values for color preview

* refactor: add shared mutation hook for saving partial board settings with invalidate query

* fix: add cleanup for not applied changes to logo and page title

* feat: add layout settings

* feat: add empty custom css section to board settings

* refactor: improve layout of board logo on mobile

* feat: add theme provider for board colors

* refactor: add auto contrast for better contrast of buttons with low primary shade

* feat: add background for boards

* feat: add opacity for boards

* feat: add rename board

* feat: add visibility and delete of board settings

* fix: issue that wrong data is updated with update board method

* refactor: improve danger zone button placement for mobile

* fix: board not revalidated when already in boards layout

* refactor: improve board color preview

* refactor: change save button color to teal, add placeholders for general board settings

* chore: update initial migration

* refactor: remove unnecessary div

* chore: address pull request feedback

* fix: ci issues

* fix: deepsource issues

* chore: address pull request feedback

* fix: formatting issue

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-03-03 16:01:32 +01:00
committed by GitHub
parent 2a83df3485
commit bb02163e25
49 changed files with 1620 additions and 406 deletions

View File

@@ -79,6 +79,24 @@ export const boardRouter = createTRPCRouter({
});
});
}),
rename: publicProcedure
.input(validation.board.rename)
.mutation(async ({ ctx, input }) => {
await noBoardWithSimilarName(ctx.db, input.name, [input.id]);
await ctx.db
.update(boards)
.set({ name: input.name })
.where(eq(boards.id, input.id));
}),
changeVisibility: publicProcedure
.input(validation.board.changeVisibility)
.mutation(async ({ ctx, input }) => {
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
@@ -92,11 +110,11 @@ export const boardRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name));
}),
saveGeneralSettings: publicProcedure
.input(validation.board.saveGeneralSettings)
savePartialSettings: publicProcedure
.input(validation.board.savePartialSettings)
.mutation(async ({ ctx, input }) => {
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
where: eq(boards.id, input.id),
});
if (!board) {
@@ -109,12 +127,30 @@ export const boardRouter = createTRPCRouter({
await ctx.db
.update(boards)
.set({
// general settings
pageTitle: input.pageTitle,
metaTitle: input.metaTitle,
logoImageUrl: input.logoImageUrl,
faviconImageUrl: input.faviconImageUrl,
// background settings
backgroundImageUrl: input.backgroundImageUrl,
backgroundImageAttachment: input.backgroundImageAttachment,
backgroundImageRepeat: input.backgroundImageRepeat,
backgroundImageSize: input.backgroundImageSize,
// color settings
primaryColor: input.primaryColor,
secondaryColor: input.secondaryColor,
opacity: input.opacity,
// custom css
customCss: input.customCss,
// layout settings
columnCount: input.columnCount,
})
.where(eq(boards.id, input.boardId));
.where(eq(boards.id, input.id));
}),
save: publicProcedure
.input(validation.board.save)
@@ -122,7 +158,7 @@ export const boardRouter = createTRPCRouter({
await ctx.db.transaction(async (tx) => {
const dbBoard = await getFullBoardWithWhere(
tx,
eq(boards.id, input.boardId),
eq(boards.id, input.id),
);
const addedSections = filterAddedItems(
@@ -276,6 +312,32 @@ export const boardRouter = createTRPCRouter({
}),
});
const noBoardWithSimilarName = async (
db: Database,
name: string,
ignoredIds: string[] = [],
) => {
const boards = await db.query.boards.findMany({
columns: {
id: true,
name: true,
},
});
const board = boards.find(
(board) =>
board.name.toLowerCase() === name.toLowerCase() &&
!ignoredIds.includes(board.id),
);
if (board) {
throw new TRPCError({
code: "CONFLICT",
message: "Board with similar name already exists",
});
}
};
const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
const board = await db.query.boards.findFirst({
where,

View File

@@ -66,7 +66,7 @@ describe("byName should return board by name", () => {
it("should throw error when not present");
});
describe("saveGeneralSettings should save general settings", () => {
describe("savePartialSettings should save general settings", () => {
it("should save general settings", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, session: null });
@@ -78,12 +78,12 @@ describe("saveGeneralSettings should save general settings", () => {
const { boardId } = await createFullBoardAsync(db, "default");
await caller.saveGeneralSettings({
await caller.savePartialSettings({
pageTitle: newPageTitle,
metaTitle: newMetaTitle,
logoImageUrl: newLogoImageUrl,
faviconImageUrl: newFaviconImageUrl,
boardId,
id: boardId,
});
});
@@ -92,12 +92,12 @@ describe("saveGeneralSettings should save general settings", () => {
const caller = boardRouter.createCaller({ db, session: null });
const act = async () =>
await caller.saveGeneralSettings({
await caller.savePartialSettings({
pageTitle: "newPageTitle",
metaTitle: "newMetaTitle",
logoImageUrl: "http://logo.image/url.png",
faviconImageUrl: "http://favicon.image/url.png",
boardId: "nonExistentBoardId",
id: "nonExistentBoardId",
});
await expect(act()).rejects.toThrowError("Board not found");
@@ -112,7 +112,7 @@ describe("save should save full board", () => {
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
await caller.save({
boardId,
id: boardId,
sections: [
{
id: createId(),
@@ -149,7 +149,7 @@ describe("save should save full board", () => {
);
await caller.save({
boardId,
id: boardId,
sections: [
{
id: sectionId,
@@ -208,7 +208,7 @@ describe("save should save full board", () => {
await db.insert(integrations).values(anotherIntegration);
await caller.save({
boardId,
id: boardId,
sections: [
{
id: sectionId,
@@ -269,7 +269,7 @@ describe("save should save full board", () => {
const newSectionId = createId();
await caller.save({
boardId,
id: boardId,
sections: [
{
id: newSectionId,
@@ -319,7 +319,7 @@ describe("save should save full board", () => {
const newItemId = createId();
await caller.save({
boardId,
id: boardId,
sections: [
{
id: sectionId,
@@ -392,7 +392,7 @@ describe("save should save full board", () => {
await db.insert(integrations).values(integration);
await caller.save({
boardId,
id: boardId,
sections: [
{
id: sectionId,
@@ -459,7 +459,7 @@ describe("save should save full board", () => {
});
await caller.save({
boardId,
id: boardId,
sections: [
{
id: sectionId,
@@ -512,7 +512,7 @@ describe("save should save full board", () => {
);
await caller.save({
boardId,
id: boardId,
sections: [
{
id: sectionId,
@@ -569,7 +569,7 @@ describe("save should save full board", () => {
const act = async () =>
await caller.save({
boardId: "nonExistentBoardId",
id: "nonExistentBoardId",
sections: [],
});

View File

@@ -26,13 +26,10 @@ CREATE TABLE `board` (
`background_image_attachment` text DEFAULT 'fixed' NOT NULL,
`background_image_repeat` text DEFAULT 'no-repeat' NOT NULL,
`background_image_size` text DEFAULT 'cover' NOT NULL,
`primary_color` text DEFAULT 'red' NOT NULL,
`secondary_color` text DEFAULT 'orange' NOT NULL,
`primary_shade` integer DEFAULT 6 NOT NULL,
`app_opacity` integer DEFAULT 100 NOT NULL,
`primary_color` text DEFAULT '#fa5252' NOT NULL,
`secondary_color` text DEFAULT '#fd7e14' NOT NULL,
`opacity` integer DEFAULT 100 NOT NULL,
`custom_css` text,
`show_right_sidebar` integer DEFAULT false NOT NULL,
`show_left_sidebar` integer DEFAULT false NOT NULL,
`column_count` integer DEFAULT 10 NOT NULL
);
--> statement-breakpoint
@@ -106,6 +103,7 @@ CREATE TABLE `verificationToken` (
);
--> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint
CREATE UNIQUE INDEX `board_name_unique` ON `board` (`name`);--> statement-breakpoint
CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint

View File

@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "sqlite",
"id": "c9ea435a-5bbf-4439-84a1-55d3e2581b13",
"id": "7ba12cbf-d8eb-4469-b7c5-7f31acb7717d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@@ -201,7 +201,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'red'"
"default": "'#fa5252'"
},
"secondary_color": {
"name": "secondary_color",
@@ -209,18 +209,10 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'orange'"
"default": "'#fd7e14'"
},
"primary_shade": {
"name": "primary_shade",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 6
},
"app_opacity": {
"name": "app_opacity",
"opacity": {
"name": "opacity",
"type": "integer",
"primaryKey": false,
"notNull": true,
@@ -234,22 +226,6 @@
"notNull": false,
"autoincrement": false
},
"show_right_sidebar": {
"name": "show_right_sidebar",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"show_left_sidebar": {
"name": "show_left_sidebar",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"column_count": {
"name": "column_count",
"type": "integer",
@@ -259,7 +235,13 @@
"default": 10
}
},
"indexes": {},
"indexes": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}

View File

@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1707511343363,
"tag": "0000_true_red_wolf",
"when": 1709409142712,
"tag": "0000_sloppy_bloodstorm",
"breakpoints": true
}
]

View File

@@ -1,5 +1,4 @@
import type { AdapterAccount } from "@auth/core/adapters";
import type { MantineColor } from "@mantine/core";
import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm";
import {
@@ -11,6 +10,11 @@ import {
text,
} from "drizzle-orm/sqlite-core";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import type {
BackgroundImageAttachment,
BackgroundImageRepeat,
@@ -125,37 +129,20 @@ export const boards = sqliteTable("board", {
backgroundImageUrl: text("background_image_url"),
backgroundImageAttachment: text("background_image_attachment")
.$type<BackgroundImageAttachment>()
.default("fixed")
.default(backgroundImageAttachments.defaultValue)
.notNull(),
backgroundImageRepeat: text("background_image_repeat")
.$type<BackgroundImageRepeat>()
.default("no-repeat")
.default(backgroundImageRepeats.defaultValue)
.notNull(),
backgroundImageSize: text("background_image_size")
.$type<BackgroundImageSize>()
.default("cover")
.default(backgroundImageSizes.defaultValue)
.notNull(),
primaryColor: text("primary_color")
.$type<MantineColor>()
.default("red")
.notNull(),
secondaryColor: text("secondary_color")
.$type<MantineColor>()
.default("orange")
.notNull(),
primaryShade: int("primary_shade").default(6).notNull(),
appOpacity: int("app_opacity").default(100).notNull(),
primaryColor: text("primary_color").default("#fa5252").notNull(),
secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
opacity: int("opacity").default(100).notNull(),
customCss: text("custom_css"),
showRightSidebar: int("show_right_sidebar", {
mode: "boolean",
})
.default(false)
.notNull(),
showLeftSidebar: int("show_left_sidebar", {
mode: "boolean",
})
.default(false)
.notNull(),
columnCount: int("column_count").default(10).notNull(),
});

View File

@@ -0,0 +1,20 @@
export const createDefinition = <
const TKeys extends string[],
TOptions extends { defaultValue: TKeys[number] } | void,
>(
values: TKeys,
options: TOptions,
) => ({
values,
defaultValue: options?.defaultValue as TOptions extends {
defaultValue: infer T;
}
? T
: undefined,
});
export type inferDefinitionType<TDefinition> = TDefinition extends {
values: readonly (infer T)[];
}
? T
: never;

View File

@@ -1,13 +1,24 @@
export const backgroundImageAttachments = ["fixed", "scroll"] as const;
export const backgroundImageRepeats = [
"repeat",
"repeat-x",
"repeat-y",
"no-repeat",
] as const;
export const backgroundImageSizes = ["cover", "contain"] as const;
import type { inferDefinitionType } from "./_definition";
import { createDefinition } from "./_definition";
export type BackgroundImageAttachment =
(typeof backgroundImageAttachments)[number];
export type BackgroundImageRepeat = (typeof backgroundImageRepeats)[number];
export type BackgroundImageSize = (typeof backgroundImageSizes)[number];
export const backgroundImageAttachments = createDefinition(
["fixed", "scroll"],
{ defaultValue: "fixed" },
);
export const backgroundImageRepeats = createDefinition(
["repeat", "repeat-x", "repeat-y", "no-repeat"],
{ defaultValue: "no-repeat" },
);
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
defaultValue: "cover",
});
export type BackgroundImageAttachment = inferDefinitionType<
typeof backgroundImageAttachments
>;
export type BackgroundImageRepeat = inferDefinitionType<
typeof backgroundImageRepeats
>;
export type BackgroundImageSize = inferDefinitionType<
typeof backgroundImageSizes
>;

View File

@@ -1,2 +1,2 @@
export const sectionKinds = ["category", "empty", "sidebar"] as const;
export const sectionKinds = ["category", "empty"] as const;
export type SectionKind = (typeof sectionKinds)[number];

View File

@@ -153,6 +153,12 @@ export default {
multiSelect: {
placeholder: "Pick one or more values",
},
select: {
placeholder: "Pick value",
badge: {
recommended: "Recommended",
},
},
search: {
placeholder: "Search for anything...",
nothingFound: "Nothing found",
@@ -172,6 +178,10 @@ export default {
},
},
noResults: "No results found",
preview: {
show: "Show preview",
hide: "Hide preview",
},
},
section: {
category: {
@@ -299,18 +309,98 @@ export default {
faviconImageUrl: {
label: "Favicon image URL",
},
backgroundImageUrl: {
label: "Background image URL",
},
backgroundImageAttachment: {
label: "Background image attachment",
option: {
fixed: {
label: "Fixed",
description: "Background stays in the same position.",
},
scroll: {
label: "Scroll",
description: "Background scrolls with your mouse.",
},
},
},
backgroundImageRepeat: {
label: "Background image repeat",
option: {
repeat: {
label: "Repeat",
description:
"The image is repeated as much as needed to cover the whole background image painting area.",
},
"no-repeat": {
label: "No repeat",
description:
"The image is not repeated and may not fill the entire space.",
},
"repeat-x": {
label: "Repeat X",
description: "Same as 'Repeat' but only on horizontal axis.",
},
"repeat-y": {
label: "Repeat Y",
description: "Same as 'Repeat' but only on vertical axis.",
},
},
},
backgroundImageSize: {
label: "Background image size",
option: {
cover: {
label: "Cover",
description:
"Scales the image as small as possible to cover the entire window by cropping excessive space.",
},
contain: {
label: "Contain",
description:
"Scales the image as large as possible within its container without cropping or stretching the image.",
},
},
},
primaryColor: {
label: "Primary color",
},
secondaryColor: {
label: "Secondary color",
},
opacity: {
label: "Opacity",
},
customCss: {
label: "Custom CSS",
},
columnCount: {
label: "Column count",
},
name: {
label: "Name",
},
},
setting: {
title: "Settings for {boardName} board",
section: {
general: {
title: "General",
unrecognizedLink:
"The provided link is not recognized and won't preview, it might still work.",
},
layout: {
title: "Layout",
},
appearance: {
title: "Appearance",
background: {
title: "Background",
},
color: {
title: "Colors",
},
customCss: {
title: "Custom css",
},
dangerZone: {
title: "Danger Zone",
@@ -320,6 +410,9 @@ export default {
description:
"Changing the name will break any links to this board.",
button: "Change name",
modal: {
title: "Rename board",
},
},
visibility: {
label: "Change board visibility",
@@ -331,12 +424,29 @@ export default {
public: "Make private",
private: "Make public",
},
confirm: {
public: {
title: "Make board private",
description:
"Are you sure you want to make this board private? This will hide the board from the public. Links for guest users will break.",
},
private: {
title: "Make board public",
description:
"Are you sure you want to make this board public? This will make the board accessible to everyone.",
},
},
},
delete: {
label: "Delete this board",
description:
"Once you delete a board, there is no going back. Please be certain.",
button: "Delete this board",
confirm: {
title: "Delete board",
description:
"Are you sure you want to delete this board? This will permanently delete the board and all its content.",
},
},
},
},

View File

@@ -1 +1,3 @@
export * from "./count-badge";
export * from "./select-with-description";
export * from "./select-with-description-and-badge";

View File

@@ -0,0 +1,101 @@
"use client";
import { useCallback, useMemo } from "react";
import type { SelectProps } from "@mantine/core";
import { Combobox, Input, InputBase, useCombobox } from "@mantine/core";
import { useUncontrolled } from "@mantine/hooks";
interface BaseSelectItem {
value: string;
label: string;
}
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
extends Pick<
SelectProps,
"label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"
> {
data: TSelectItem[];
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
}
type Props<TSelectItem extends BaseSelectItem> =
SelectWithCustomItemsProps<TSelectItem> & {
SelectOption: React.ComponentType<TSelectItem>;
};
export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
data,
onChange,
value,
defaultValue,
placeholder,
SelectOption,
...props
}: Props<TSelectItem>) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const [_value, setValue] = useUncontrolled({
value,
defaultValue,
finalValue: null,
onChange,
});
const selectedOption = useMemo(
() => data.find((item) => item.value === _value),
[data, _value],
);
const options = data.map((item) => (
<Combobox.Option value={item.value} key={item.value}>
<SelectOption {...item} />
</Combobox.Option>
));
const toggle = useCallback(() => combobox.toggleDropdown(), [combobox]);
const onOptionSubmit = useCallback(
(value: string) => {
setValue(
value,
data.find((item) => item.value === value),
);
combobox.closeDropdown();
},
[setValue, data, combobox],
);
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={onOptionSubmit}
>
<Combobox.Target>
<InputBase
{...props}
component="button"
type="button"
pointer
rightSection={<Combobox.Chevron />}
onClick={toggle}
rightSectionPointerEvents="none"
multiline
>
{selectedOption ? (
<SelectOption {...selectedOption} />
) : (
<Input.Placeholder>{placeholder}</Input.Placeholder>
)}
</InputBase>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>{options}</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};

View File

@@ -0,0 +1,49 @@
"use client";
import type { MantineColor } from "@mantine/core";
import { Badge, Group, Text } from "@mantine/core";
import type { SelectWithCustomItemsProps } from "./select-with-custom-items";
import { SelectWithCustomItems } from "./select-with-custom-items";
export interface SelectItemWithDescriptionBadge {
value: string;
label: string;
badge?: { label: string; color: MantineColor };
description: string;
}
type Props = SelectWithCustomItemsProps<SelectItemWithDescriptionBadge>;
export const SelectWithDescriptionBadge = (props: Props) => {
return (
<SelectWithCustomItems<SelectItemWithDescriptionBadge>
{...props}
SelectOption={SelectOption}
/>
);
};
const SelectOption = ({
label,
description,
badge,
}: SelectItemWithDescriptionBadge) => {
return (
<Group justify="space-between">
<div>
<Text fz="sm" fw={500}>
{label}
</Text>
<Text fz="xs" opacity={0.6}>
{description}
</Text>
</div>
{badge && (
<Badge color={badge.color} variant="outline" size="sm">
{badge.label}
</Badge>
)}
</Group>
);
};

View File

@@ -0,0 +1,35 @@
"use client";
import { Text } from "@mantine/core";
import type { SelectWithCustomItemsProps } from "./select-with-custom-items";
import { SelectWithCustomItems } from "./select-with-custom-items";
export interface SelectItemWithDescription {
value: string;
label: string;
description: string;
}
type Props = SelectWithCustomItemsProps<SelectItemWithDescription>;
export const SelectWithDescription = (props: Props) => {
return (
<SelectWithCustomItems<SelectItemWithDescription>
{...props}
SelectOption={SelectOption}
/>
);
};
const SelectOption = ({ label, description }: SelectItemWithDescription) => {
return (
<div>
<Text fz="sm" fw={500}>
{label}
</Text>
<Text fz="xs" opacity={0.6}>
{description}
</Text>
</div>
);
};

View File

@@ -1,7 +1,15 @@
import { z } from "zod";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
} from "@homarr/definitions";
import { commonItemSchema, createSectionSchema } from "./shared";
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
const boardNameSchema = z
.string()
.min(1)
@@ -12,36 +20,56 @@ const byNameSchema = z.object({
name: boardNameSchema,
});
const saveGeneralSettingsSchema = z.object({
pageTitle: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
metaTitle: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
logoImageUrl: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
faviconImageUrl: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
boardId: z.string(),
const renameSchema = z.object({
id: z.string(),
name: boardNameSchema,
});
const changeVisibilitySchema = z.object({
id: z.string(),
visibility: z.enum(["public", "private"]),
});
const trimmedNullableString = z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value));
const savePartialSettingsSchema = z
.object({
pageTitle: trimmedNullableString,
metaTitle: trimmedNullableString,
logoImageUrl: trimmedNullableString,
faviconImageUrl: trimmedNullableString,
backgroundImageUrl: trimmedNullableString,
backgroundImageAttachment: z.enum(backgroundImageAttachments.values),
backgroundImageRepeat: z.enum(backgroundImageRepeats.values),
backgroundImageSize: z.enum(backgroundImageSizes.values),
primaryColor: hexColorSchema,
secondaryColor: hexColorSchema,
opacity: z.number().min(0).max(100),
customCss: z.string().max(16384),
columnCount: z.number().min(1).max(24),
})
.partial()
.and(
z.object({
id: z.string(),
}),
);
const saveSchema = z.object({
boardId: z.string(),
id: z.string(),
sections: z.array(createSectionSchema(commonItemSchema)),
});
const createSchema = z.object({ name: z.string() });
const createSchema = z.object({ name: boardNameSchema });
export const boardSchemas = {
byName: byNameSchema,
saveGeneralSettings: saveGeneralSettingsSchema,
savePartialSettings: savePartialSettingsSchema,
save: saveSchema,
create: createSchema,
rename: renameSchema,
changeVisibility: changeVisibilitySchema,
};

View File

@@ -48,21 +48,6 @@ const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(
items: z.array(itemSchema),
});
const createSidebarSchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.object({
id: z.string(),
kind: z.literal("sidebar"),
position: z.union([z.literal(0), z.literal(1)]),
items: z.array(itemSchema),
});
export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.union([
createCategorySchema(itemSchema),
createEmptySchema(itemSchema),
createSidebarSchema(itemSchema),
]);
) => z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]);