Compare commits
11
Commits
next
...
add-more-openapi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4cdf7aca6 | ||
|
|
75454e7913 | ||
|
|
8a7b1d61b2 | ||
|
|
df5704019b | ||
|
|
5ccb1e70f9 | ||
|
|
82a5fd0118 | ||
|
|
b0a1c5486c | ||
|
|
56aae88853 | ||
|
|
5332f0d5c0 | ||
|
|
bd7a9fbd10 | ||
|
|
7ea71a57b7 |
No files matched your search
@@ -55,6 +55,7 @@
|
||||
"@mantine/modals": "^8.3.10",
|
||||
"@mantine/tiptap": "^8.3.10",
|
||||
"@million/lint": "1.0.14",
|
||||
"@scalar/api-reference-react": "^0.8.11",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
@@ -85,7 +86,6 @@
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sass": "^1.96.0",
|
||||
"superjson": "2.2.6",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
@@ -98,7 +98,6 @@
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9.39.2",
|
||||
"node-loader": "^2.1.0",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { OpenAPIObject } from "openapi3-ts/oas31";
|
||||
import SwaggerUI from "swagger-ui-react";
|
||||
|
||||
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
|
||||
import "../swagger-ui-dark.css";
|
||||
import "../swagger-ui-overrides.css";
|
||||
import "../swagger-ui.css";
|
||||
|
||||
interface SwaggerUIClientProps {
|
||||
document: OpenAPIObject;
|
||||
}
|
||||
|
||||
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const requestInterceptor = (req: Record<string, any>) => {
|
||||
req.credentials = "omit";
|
||||
return req;
|
||||
};
|
||||
|
||||
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||
import { ApiReferenceReact } from "@scalar/api-reference-react";
|
||||
|
||||
import { openApiDocument } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -8,11 +9,14 @@ import { auth } from "@homarr/auth/next";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { ApiKeysManagement } from "./components/api-keys";
|
||||
|
||||
export async function generateMetadata() {
|
||||
import "@scalar/api-reference-react/style.css";
|
||||
|
||||
import { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||
return {};
|
||||
@@ -45,7 +49,21 @@ export default async function ApiPage() {
|
||||
<ApiKeysManagement apiKeys={apiKeys} />
|
||||
</TabsPanel>
|
||||
<TabsPanel value={"documentation"}>
|
||||
<SwaggerUIClient document={document} />
|
||||
<ApiReferenceReact
|
||||
configuration={{
|
||||
content: document,
|
||||
layout: "classic",
|
||||
showSidebar: false,
|
||||
hideDarkModeToggle: true,
|
||||
showDeveloperTools: "never",
|
||||
hiddenClients: true,
|
||||
darkMode: session.user.colorScheme === "dark",
|
||||
authentication: {
|
||||
preferredSecurityScheme: "apikey",
|
||||
},
|
||||
hideSearch: true,
|
||||
}}
|
||||
/>
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +0,0 @@
|
||||
.swagger-ui .info {
|
||||
margin: 0 !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@ import { importRouter } from "./router/import/import-router";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { itemsRouter } from "./router/item/item-router";
|
||||
import { kubernetesRouter } from "./router/kubernetes/router/kubernetes-router";
|
||||
import { locationRouter } from "./router/location";
|
||||
import { logRouter } from "./router/log";
|
||||
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
|
||||
updateChecker: updateCheckerRouter,
|
||||
certificates: certificateRouter,
|
||||
info: infoRouter,
|
||||
item: itemsRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -17,6 +17,7 @@ export const throwIfActionForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
boardWhere: SQL<unknown>,
|
||||
permission: BoardPermission,
|
||||
error?: TRPCError,
|
||||
) => {
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
@@ -40,7 +41,7 @@ export const throwIfActionForbiddenAsync = async (
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
notAllowed();
|
||||
notAllowed(error);
|
||||
}
|
||||
|
||||
const { hasViewAccess, hasChangeAccess, hasFullAccess } = constructBoardPermissions(board, session);
|
||||
@@ -57,14 +58,18 @@ export const throwIfActionForbiddenAsync = async (
|
||||
return; // As view access is required and user has view access, allow
|
||||
}
|
||||
|
||||
notAllowed();
|
||||
notAllowed(error);
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns NOT_FOUND to prevent snooping on board existence
|
||||
* A function is used to use the method without return statement
|
||||
*/
|
||||
function notAllowed(): never {
|
||||
function notAllowed(error?: TRPCError): never {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { selectGroupSchema, selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import { everyoneGroup, groupPermissionKeys } from "@homarr/definitions";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
groupCreateSchema,
|
||||
@@ -22,33 +23,65 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
const dbGroups = await ctx.db.query.groups.findMany({
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
getAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.array(
|
||||
selectGroupSchema.and(
|
||||
z.object({ members: z.array(selectUserSchema.pick({ id: true, name: true, email: true, image: true })) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
.meta({
|
||||
openapi: { method: "GET", path: "/api/groups", tags: ["groups"], protect: true, summary: "Retrieve all groups" },
|
||||
})
|
||||
.query(async ({ ctx }) => {
|
||||
const dbGroups = await ctx.db.query.groups.findMany({
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return dbGroups.map((group) => ({
|
||||
...group,
|
||||
members: group.members.map((member) => member.user),
|
||||
}));
|
||||
}),
|
||||
return dbGroups.map((group) => ({
|
||||
...group,
|
||||
members: group.members.map((member) => member.user),
|
||||
}));
|
||||
}),
|
||||
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(paginatedSchema)
|
||||
.output(
|
||||
z.object({
|
||||
items: z.array(
|
||||
selectGroupSchema.and(
|
||||
z.object({ members: z.array(selectUserSchema.pick({ id: true, name: true, email: true, image: true })) }),
|
||||
),
|
||||
),
|
||||
totalCount: z.number(),
|
||||
}),
|
||||
)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/api/groups/paginated",
|
||||
tags: ["groups"],
|
||||
protect: true,
|
||||
summary: "Retrieve groups with pagination",
|
||||
},
|
||||
})
|
||||
.query(async ({ input, ctx }) => {
|
||||
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
|
||||
const groupCount = await ctx.db.$count(groups, whereQuery);
|
||||
@@ -84,6 +117,24 @@ export const groupRouter = createTRPCRouter({
|
||||
getById: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(byIdSchema)
|
||||
.output(
|
||||
selectGroupSchema.and(
|
||||
z.object({
|
||||
owner: selectUserSchema.pick({ id: true, name: true, image: true, email: true }).nullable(),
|
||||
members: z.array(selectUserSchema.pick({ id: true, name: true, email: true, image: true, provider: true })),
|
||||
permissions: z.array(z.enum(groupPermissionKeys)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/api/groups/{id}",
|
||||
tags: ["groups"],
|
||||
protect: true,
|
||||
summary: "Retrieve group details",
|
||||
},
|
||||
})
|
||||
.query(async ({ input, ctx }) => {
|
||||
const group = await ctx.db.query.groups.findFirst({
|
||||
where: eq(groups.id, input.id),
|
||||
@@ -201,6 +252,10 @@ export const groupRouter = createTRPCRouter({
|
||||
createGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(groupCreateSchema)
|
||||
.output(z.string())
|
||||
.meta({
|
||||
openapi: { method: "POST", path: "/api/groups", tags: ["groups"], protect: true, summary: "Create group" },
|
||||
})
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||
|
||||
@@ -219,6 +274,10 @@ export const groupRouter = createTRPCRouter({
|
||||
updateGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(groupUpdateSchema)
|
||||
.output(z.void())
|
||||
.meta({
|
||||
openapi: { method: "PUT", path: "/api/groups/{id}", tags: ["groups"], protect: true, summary: "Update group" },
|
||||
})
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
|
||||
@@ -303,6 +362,10 @@ export const groupRouter = createTRPCRouter({
|
||||
deleteGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(byIdSchema)
|
||||
.output(z.void())
|
||||
.meta({
|
||||
openapi: { method: "DELETE", path: "/api/groups/{id}", tags: ["groups"], protect: true, summary: "Delete group" },
|
||||
})
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
|
||||
@@ -312,6 +375,16 @@ export const groupRouter = createTRPCRouter({
|
||||
addMember: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(groupUserSchema)
|
||||
.output(z.void())
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/api/groups/{groupId}/members/{userId}",
|
||||
tags: ["groups"],
|
||||
protect: true,
|
||||
summary: "Add member to group",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
@@ -328,6 +401,8 @@ export const groupRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Create another pr to only allow adding users with provider=credentials
|
||||
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId: input.groupId,
|
||||
userId: input.userId,
|
||||
@@ -336,6 +411,16 @@ export const groupRouter = createTRPCRouter({
|
||||
removeMember: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(groupUserSchema)
|
||||
.output(z.void())
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "DELETE",
|
||||
path: "/api/groups/{groupId}/members/{userId}",
|
||||
tags: ["groups"],
|
||||
protect: true,
|
||||
summary: "Remove member from group",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import SuperJSON from "superjson";
|
||||
import z from "zod";
|
||||
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync } from "@homarr/db";
|
||||
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
|
||||
import { boards, itemLayouts, items } from "@homarr/db/schema";
|
||||
import { selectItemLayoutSchema, selectitemSchema } from "@homarr/db/validationSchemas";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetKinds } from "@homarr/definitions";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
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 { convertIntersectionToZodObject } from "../../schema-merger";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
|
||||
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>,
|
||||
),
|
||||
);
|
||||
|
||||
const outputItemSchema = 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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const manageItemSchema = 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 })),
|
||||
});
|
||||
|
||||
export const itemsRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
boardId: z.string(),
|
||||
}),
|
||||
)
|
||||
.output(z.array(outputItemSchema))
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/api/boards/{boardId}/items",
|
||||
tags: ["items"],
|
||||
protect: true,
|
||||
summary: "Retrieve all items for board",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx, input }) => {
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.id, input.boardId),
|
||||
with: {
|
||||
items: {
|
||||
with: {
|
||||
layouts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const error = new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
|
||||
if (!board) throw error;
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "view", error);
|
||||
|
||||
return board.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(manageItemSchema)
|
||||
.output(outputItemSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/api/items",
|
||||
tags: ["items"],
|
||||
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 board = await ctx.db.query.boards.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.id, input.boardId),
|
||||
});
|
||||
|
||||
const error = new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
|
||||
if (!board) throw error;
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, board.id), "modify", error);
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
...item,
|
||||
layouts: layoutInserts,
|
||||
};
|
||||
}),
|
||||
getItem: protectedProcedure
|
||||
.input(byIdSchema)
|
||||
.output(outputItemSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/api/items/{id}",
|
||||
tags: ["items"],
|
||||
protect: true,
|
||||
summary: "Retrieve item from a board",
|
||||
},
|
||||
})
|
||||
.query(async ({ ctx, input }) => {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.id, input.id),
|
||||
with: {
|
||||
layouts: true,
|
||||
},
|
||||
});
|
||||
|
||||
const error = new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Item not found",
|
||||
});
|
||||
|
||||
if (!item) throw error;
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view", error);
|
||||
|
||||
return {
|
||||
...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),
|
||||
};
|
||||
}),
|
||||
updateItem: protectedProcedure
|
||||
.input(convertIntersectionToZodObject(manageItemSchema.omit({ boardId: true, kind: true }).and(byIdSchema)))
|
||||
.output(outputItemSchema)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "PUT",
|
||||
path: "/api/items/{id}",
|
||||
tags: ["items"],
|
||||
protect: true,
|
||||
summary: "Update an item on the board",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.id, input.id),
|
||||
with: {
|
||||
layouts: true,
|
||||
},
|
||||
});
|
||||
|
||||
const error = new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Item not found",
|
||||
});
|
||||
if (!item) throw error;
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify", error);
|
||||
|
||||
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
|
||||
const combinedOptions = {
|
||||
...previousOptions,
|
||||
...input.options,
|
||||
};
|
||||
const itemOptionsSchema = createItemOptionsSchema(item.kind);
|
||||
const fullOptions = reduceWidgetOptionsWithDefaultValues(
|
||||
item.kind,
|
||||
{ enableStatusByDefault: true, firstDayOfWeek: 1, forceDisableStatus: false },
|
||||
combinedOptions,
|
||||
);
|
||||
const optionsResult = await itemOptionsSchema.safeParseAsync(fullOptions);
|
||||
if (!optionsResult.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Failed to parse item options",
|
||||
cause: optionsResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(schema.items)
|
||||
.set({
|
||||
advancedOptions: SuperJSON.stringify(input.advancedOptions),
|
||||
options: SuperJSON.stringify(optionsResult),
|
||||
})
|
||||
.where(eq(schema.items.id, input.id));
|
||||
|
||||
for (const itemLayout of input.layouts) {
|
||||
await transaction
|
||||
.update(schema.itemLayouts)
|
||||
.set({
|
||||
height: itemLayout.height,
|
||||
width: itemLayout.width,
|
||||
sectionId: itemLayout.sectionId,
|
||||
xOffset: itemLayout.xOffset,
|
||||
yOffset: itemLayout.yOffset,
|
||||
})
|
||||
.where(
|
||||
and(eq(schema.itemLayouts.itemId, input.id), eq(schema.itemLayouts.layoutId, itemLayout.layoutId)),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.update(items)
|
||||
.set({
|
||||
advancedOptions: SuperJSON.stringify(input.advancedOptions),
|
||||
options: SuperJSON.stringify(optionsResult),
|
||||
})
|
||||
.where(eq(items.id, input.id));
|
||||
|
||||
for (const itemLayout of input.layouts) {
|
||||
transaction
|
||||
.update(itemLayouts)
|
||||
.set({
|
||||
height: itemLayout.height,
|
||||
width: itemLayout.width,
|
||||
sectionId: itemLayout.sectionId,
|
||||
xOffset: itemLayout.xOffset,
|
||||
yOffset: itemLayout.yOffset,
|
||||
})
|
||||
.where(and(eq(itemLayouts.itemId, input.id), eq(itemLayouts.layoutId, itemLayout.layoutId)));
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
kind: item.kind,
|
||||
boardId: item.boardId,
|
||||
advancedOptions: input.advancedOptions,
|
||||
options: optionsResult,
|
||||
layouts: input.layouts,
|
||||
};
|
||||
}),
|
||||
deleteItem: protectedProcedure
|
||||
.input(byIdSchema)
|
||||
.output(z.void())
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "DELETE",
|
||||
path: "/api/items/{id}",
|
||||
tags: ["items"],
|
||||
protect: true,
|
||||
summary: "Delete an item from board",
|
||||
},
|
||||
})
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: (fields, { eq }) => eq(fields.id, input.id),
|
||||
});
|
||||
const error = new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Item not found",
|
||||
});
|
||||
if (!item) throw error;
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify", error);
|
||||
|
||||
await ctx.db.delete(items).where(eq(items.id, input.id));
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -57,12 +57,7 @@ export const createRequestIntegrationJobHandler = <
|
||||
reduceWidgetOptionsWithDefaultValues(
|
||||
itemForIntegration.kind,
|
||||
{
|
||||
defaultSearchEngineId: serverSettings.search.defaultSearchEngineId,
|
||||
openSearchInNewTab: true,
|
||||
firstDayOfWeek: 1,
|
||||
homeBoardId: serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: true,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
|
||||
@@ -27,24 +27,13 @@ export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetIn
|
||||
};
|
||||
|
||||
const currentValidationResult = React.useMemo(() => {
|
||||
if (!options.validate) {
|
||||
return {
|
||||
success: false,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = options.validate.safeParse(search);
|
||||
return {
|
||||
success: validationResult.success,
|
||||
result: validationResult,
|
||||
};
|
||||
return options.validate.safeParse(search);
|
||||
}, [options.validate, search]);
|
||||
|
||||
const error = React.useMemo(() => {
|
||||
/* hide the error when nothing is being typed since "" is not valid but is not an explicit error */
|
||||
if (!currentValidationResult.success && currentValidationResult.result && search.length !== 0) {
|
||||
return currentValidationResult.result.error?.issues[0]?.message;
|
||||
if (!currentValidationResult.success && search.length !== 0) {
|
||||
return currentValidationResult.error.issues[0]?.message;
|
||||
}
|
||||
return null;
|
||||
}, [currentValidationResult, search]);
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -38,6 +39,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[]> extends CommonInput<
|
||||
@@ -68,12 +70,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,
|
||||
@@ -81,6 +84,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,
|
||||
@@ -88,6 +94,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,
|
||||
@@ -122,19 +129,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>,
|
||||
@@ -146,6 +154,7 @@ const optionsFactory = {
|
||||
uniqueIdentifier: input.uniqueIdentifier,
|
||||
useData: input.useData,
|
||||
withDescription: false,
|
||||
validate: input.validate,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
Generated
+1731
-1059
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user