wip(api): add items router

This commit is contained in:
Meier Lukas committed 2025-08-31 21:39:05 +02:00
1 parent 7ea71a57b7
commit bd7a9fbd10
7 files changed
+220 -10

No files matched your search

+2
View File
@@ -57,6 +57,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
import { boardItemsRouter } from "./board/items/item-router";
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
@@ -1325,6 +1326,7 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
items: boardItemsRouter,
});
/**
@@ -0,0 +1,192 @@
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import z from "zod";
import { createId, objectEntries } from "@homarr/common";
import { and, eq } from "@homarr/db";
import type { InferInsertModel } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
import type { itemLayouts } from "@homarr/db/schema";
import { items } from "@homarr/db/schema";
import { selectItemLayoutSchema, selectitemSchema } from "@homarr/db/validationSchemas";
import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds } from "@homarr/definitions";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "../../../../../widgets/src";
import type { WidgetOptionDefinition } from "../../../../../widgets/src/options";
import { createTRPCRouter, protectedProcedure } from "../../../trpc";
const createItemOptionsSchema = (kind: WidgetKind): z.ZodObject =>
z.object(
objectEntries(
widgetImports[kind].definition.createOptions({
enableStatusByDefault: true,
firstDayOfWeek: 1,
forceDisableStatus: false,
}),
).reduce(
(previous, [key, value]: [string, WidgetOptionDefinition]) => {
previous[key] = value.validate;
return previous;
},
{} as Record<string, z.ZodType>,
),
);
export const boardItemsRouter = createTRPCRouter({
getAll: protectedProcedure
.input(
z.object({
boardId: z.string(),
}),
)
.output(
z.array(
z.union([
...widgetKinds.map((kind) =>
z
.object({
kind: z.literal(kind),
options: createItemOptionsSchema(kind),
advancedOptions: itemAdvancedOptionsSchema,
layouts: z.array(selectItemLayoutSchema.omit({ itemId: true })),
})
.and(
selectitemSchema.pick({
id: true,
boardId: true,
}),
),
),
]),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/boards/{boardId}/items",
tags: ["boardItems"],
protect: true,
summary: "Retrieve all items for board",
},
})
.query(async ({ ctx, input }) => {
const items = await ctx.db.query.items.findMany({
where: (fields, { eq }) => eq(fields.boardId, input.boardId),
with: {
layouts: true,
},
});
return items.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefaultValues(
item.kind,
{
enableStatusByDefault: true,
firstDayOfWeek: 1,
forceDisableStatus: false,
},
SuperJSON.parse(item.options),
),
advancedOptions: itemAdvancedOptionsSchema.parse(SuperJSON.parse(item.advancedOptions)),
layouts: item.layouts.map(({ itemId: _, ...layout }) => layout),
}));
}),
createItem: protectedProcedure
.input(
z.object({
boardId: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown().optional()),
advancedOptions: itemAdvancedOptionsSchema,
layouts: z.array(selectItemLayoutSchema.omit({ itemId: true })),
}),
)
.output(
z.union([
...widgetKinds.map((kind) =>
z
.object({
kind: z.literal(kind),
options: createItemOptionsSchema(kind),
advancedOptions: itemAdvancedOptionsSchema,
layouts: z.array(selectItemLayoutSchema.omit({ itemId: true })),
})
.and(
selectitemSchema.pick({
id: true,
boardId: true,
}),
),
),
]),
)
.meta({
openapi: {
method: "POST",
path: "/api/boards/{boardId}/items",
tags: ["boardItems"],
protect: true,
summary: "Create item on board",
description: "Options available can be viewed in response options object. Input options are optional",
},
})
.mutation(async ({ ctx, input }) => {
const itemOptionsSchema = createItemOptionsSchema(input.kind);
const fullOptions = reduceWidgetOptionsWithDefaultValues(
input.kind,
{ enableStatusByDefault: true, firstDayOfWeek: 1, forceDisableStatus: false },
input.options,
);
const result = await itemOptionsSchema.safeParseAsync(fullOptions);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to parse item options",
cause: result.error,
});
}
const item = {
id: createId(),
boardId: input.boardId,
kind: input.kind,
advancedOptions: input.advancedOptions,
options: result.data,
};
const itemInsert: InferInsertModel<typeof items> = {
...item,
advancedOptions: SuperJSON.stringify(item.advancedOptions),
options: SuperJSON.stringify(item.options),
};
const layoutInserts: InferInsertModel<typeof itemLayouts>[] = input.layouts.map((layout) => ({
...layout,
itemId: item.id,
}));
const insertCollection = createDbInsertCollectionWithoutTransaction(["items", "itemLayouts"]);
insertCollection.items.push(itemInsert);
insertCollection.itemLayouts.push(...layoutInserts);
await insertCollection.insertAllAsync(ctx.db);
// TODO: Add validations
return {
...item,
layouts: layoutInserts,
};
}),
deleteItem: protectedProcedure
.input(z.object({ boardId: z.string(), itemId: z.string() }))
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/boards/{boardId}/items/{itemId}" } })
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(items).where(and(eq(items.id, input.itemId), eq(items.boardId, input.boardId)));
}),
});
// TODO: Add check if you are allowed to do this
// TODO: Maybe move to /api/items instead so we don't have to pass boardId for deletion & modification
+5 -2
View File
@@ -1,9 +1,12 @@
import { createSelectSchema } from "drizzle-zod";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { apps, boards, groups, invites, searchEngines, serverSettings, users } from "./schema";
import { apps, boards, groups, invites, itemLayouts, items, searchEngines, serverSettings, users } from "./schema";
export const selectAppSchema = createSelectSchema(apps);
export const selectBoardSchema = createSelectSchema(boards);
export const selectitemSchema = createSelectSchema(items);
export const insertItemSchema = createInsertSchema(items);
export const selectItemLayoutSchema = createSelectSchema(itemLayouts);
export const selectGroupSchema = createSelectSchema(groups);
export const selectInviteSchema = createSelectSchema(invites);
export const selectSearchEnginesSchema = createSelectSchema(searchEngines);
+2
View File
@@ -1,5 +1,6 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconX } from "@tabler/icons-react";
import z from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -56,6 +57,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
isLoading,
};
},
validate: z.array(z.string()),
}),
}));
},
+3 -1
View File
@@ -42,7 +42,9 @@ export interface WidgetDefinition {
icon: TablerIcon;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
createOptions: (settings: SettingsContextProps) => WidgetOptionsRecord;
createOptions: (
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "firstDayOfWeek" | "forceDisableStatus">,
) => WidgetOptionsRecord;
errors?: Partial<
Record<
DefaultErrorData["code"],
+1 -1
View File
@@ -115,7 +115,7 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
export const reduceWidgetOptionsWithDefaultValues = (
kind: WidgetKind,
settings: SettingsContextProps,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "firstDayOfWeek" | "forceDisableStatus">,
currentValue: Record<string, unknown> = {},
) => {
const definition = widgetImports[kind].definition;
+15 -6
View File
@@ -5,6 +5,7 @@ import { z } from "zod/v4";
import type { ZodType } from "zod/v4";
import type { IntegrationKind } from "@homarr/definitions";
import { zodEnumFromArray } from "@homarr/validation/enums";
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
import type { ReleasesRepository } from "./releases/releases-repository";
@@ -35,6 +36,7 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
}) => React.ReactNode;
uniqueIdentifier: (item: TItem) => TOptionValue;
useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown };
validate: z.ZodArray<z.ZodType>;
}
interface SelectInput<TOptions extends readonly SelectOption[]>
@@ -64,12 +66,13 @@ const optionsFactory = {
type: "switch" as const,
defaultValue: input?.defaultValue ?? false,
withDescription: input?.withDescription ?? false,
validate: z.boolean(),
}),
text: (input?: TextInput) => ({
type: "text" as const,
defaultValue: input?.defaultValue ?? "",
withDescription: input?.withDescription ?? false,
validate: input?.validate,
validate: input?.validate ?? z.string(),
}),
multiSelect: <const TOptions extends SelectOption[]>(input: MultiSelectInput<TOptions>) => ({
type: "multiSelect" as const,
@@ -77,6 +80,9 @@ const optionsFactory = {
options: input.options,
searchable: input.searchable ?? false,
withDescription: input.withDescription ?? false,
validate: z.array(
zodEnumFromArray(input.options.map((option) => (typeof option === "string" ? option : option.value))),
),
}),
select: <const TOptions extends SelectOption[]>(input: SelectInput<TOptions>) => ({
type: "select" as const,
@@ -84,6 +90,7 @@ const optionsFactory = {
options: input.options,
searchable: input.searchable ?? false,
withDescription: input.withDescription ?? false,
validate: zodEnumFromArray(input.options.map((option) => (typeof option === "string" ? option : option.value))),
}),
number: (input: NumberInput) => ({
type: "number" as const,
@@ -118,19 +125,20 @@ const optionsFactory = {
defaultValue: input?.defaultValue ?? [],
withDescription: input?.withDescription ?? false,
values: [] as string[],
validate: input?.validate,
validate: input?.validate ?? z.array(z.string()),
}),
multiReleasesRepositories: (input?: CommonInput<ReleasesRepository[]> & { validate?: ZodType }) => ({
multiReleasesRepositories: (input: CommonInput<ReleasesRepository[]> & { validate: ZodType }) => ({
type: "multiReleasesRepositories" as const,
defaultValue: input?.defaultValue ?? [],
withDescription: input?.withDescription ?? false,
defaultValue: input.defaultValue ?? [],
withDescription: input.withDescription ?? false,
values: [] as ReleasesRepository[],
validate: input?.validate,
validate: input.validate,
}),
app: () => ({
type: "app" as const,
defaultValue: "",
withDescription: false,
validate: z.string(),
}),
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
input: SortableItemListInput<TItem, TOptionValue>,
@@ -142,6 +150,7 @@ const optionsFactory = {
uniqueIdentifier: input.uniqueIdentifier,
useData: input.useData,
withDescription: false,
validate: input.validate,
}),
};