Compare commits
11
Commits
| 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/modals": "^8.3.10",
|
||||||
"@mantine/tiptap": "^8.3.10",
|
"@mantine/tiptap": "^8.3.10",
|
||||||
"@million/lint": "1.0.14",
|
"@million/lint": "1.0.14",
|
||||||
|
"@scalar/api-reference-react": "^0.8.11",
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-query-devtools": "^5.91.1",
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
@@ -85,7 +86,6 @@
|
|||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.96.0",
|
"sass": "^1.96.0",
|
||||||
"superjson": "2.2.6",
|
"superjson": "2.2.6",
|
||||||
"swagger-ui-react": "^5.31.0",
|
|
||||||
"use-deep-compare-effect": "^1.8.1",
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
@@ -98,7 +98,6 @@
|
|||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"node-loader": "^2.1.0",
|
"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 { headers } from "next/headers";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||||
|
import { ApiReferenceReact } from "@scalar/api-reference-react";
|
||||||
|
|
||||||
import { openApiDocument } from "@homarr/api";
|
import { openApiDocument } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
@@ -8,11 +9,14 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { ApiKeysManagement } from "./components/api-keys";
|
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();
|
const session = await auth();
|
||||||
if (!session?.user || !session.user.permissions.includes("admin")) {
|
if (!session?.user || !session.user.permissions.includes("admin")) {
|
||||||
return {};
|
return {};
|
||||||
@@ -45,7 +49,21 @@ export default async function ApiPage() {
|
|||||||
<ApiKeysManagement apiKeys={apiKeys} />
|
<ApiKeysManagement apiKeys={apiKeys} />
|
||||||
</TabsPanel>
|
</TabsPanel>
|
||||||
<TabsPanel value={"documentation"}>
|
<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>
|
</TabsPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</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 { infoRouter } from "./router/info";
|
||||||
import { integrationRouter } from "./router/integration/integration-router";
|
import { integrationRouter } from "./router/integration/integration-router";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
|
import { itemsRouter } from "./router/item/item-router";
|
||||||
import { kubernetesRouter } from "./router/kubernetes/router/kubernetes-router";
|
import { kubernetesRouter } from "./router/kubernetes/router/kubernetes-router";
|
||||||
import { locationRouter } from "./router/location";
|
import { locationRouter } from "./router/location";
|
||||||
import { logRouter } from "./router/log";
|
import { logRouter } from "./router/log";
|
||||||
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
updateChecker: updateCheckerRouter,
|
updateChecker: updateCheckerRouter,
|
||||||
certificates: certificateRouter,
|
certificates: certificateRouter,
|
||||||
info: infoRouter,
|
info: infoRouter,
|
||||||
|
item: itemsRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const throwIfActionForbiddenAsync = async (
|
|||||||
ctx: { db: Database; session: Session | null },
|
ctx: { db: Database; session: Session | null },
|
||||||
boardWhere: SQL<unknown>,
|
boardWhere: SQL<unknown>,
|
||||||
permission: BoardPermission,
|
permission: BoardPermission,
|
||||||
|
error?: TRPCError,
|
||||||
) => {
|
) => {
|
||||||
const { db, session } = ctx;
|
const { db, session } = ctx;
|
||||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||||
@@ -40,7 +41,7 @@ export const throwIfActionForbiddenAsync = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!board) {
|
if (!board) {
|
||||||
notAllowed();
|
notAllowed(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasViewAccess, hasChangeAccess, hasFullAccess } = constructBoardPermissions(board, session);
|
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
|
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
|
* This method returns NOT_FOUND to prevent snooping on board existence
|
||||||
* A function is used to use the method without return statement
|
* 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({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Board 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 { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
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 { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||||
import {
|
import {
|
||||||
groupCreateSchema,
|
groupCreateSchema,
|
||||||
@@ -22,7 +23,20 @@ import { throwIfCredentialsDisabled } from "./invite/checks";
|
|||||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||||
|
|
||||||
export const groupRouter = createTRPCRouter({
|
export const groupRouter = createTRPCRouter({
|
||||||
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
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({
|
const dbGroups = await ctx.db.query.groups.findMany({
|
||||||
with: {
|
with: {
|
||||||
members: {
|
members: {
|
||||||
@@ -49,6 +63,25 @@ export const groupRouter = createTRPCRouter({
|
|||||||
getPaginated: permissionRequiredProcedure
|
getPaginated: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(paginatedSchema)
|
.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 }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
|
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
|
||||||
const groupCount = await ctx.db.$count(groups, whereQuery);
|
const groupCount = await ctx.db.$count(groups, whereQuery);
|
||||||
@@ -84,6 +117,24 @@ export const groupRouter = createTRPCRouter({
|
|||||||
getById: permissionRequiredProcedure
|
getById: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(byIdSchema)
|
.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 }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
const group = await ctx.db.query.groups.findFirst({
|
const group = await ctx.db.query.groups.findFirst({
|
||||||
where: eq(groups.id, input.id),
|
where: eq(groups.id, input.id),
|
||||||
@@ -201,6 +252,10 @@ export const groupRouter = createTRPCRouter({
|
|||||||
createGroup: permissionRequiredProcedure
|
createGroup: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(groupCreateSchema)
|
.input(groupCreateSchema)
|
||||||
|
.output(z.string())
|
||||||
|
.meta({
|
||||||
|
openapi: { method: "POST", path: "/api/groups", tags: ["groups"], protect: true, summary: "Create group" },
|
||||||
|
})
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
await checkSimilarNameAndThrowAsync(ctx.db, input.name);
|
||||||
|
|
||||||
@@ -219,6 +274,10 @@ export const groupRouter = createTRPCRouter({
|
|||||||
updateGroup: permissionRequiredProcedure
|
updateGroup: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(groupUpdateSchema)
|
.input(groupUpdateSchema)
|
||||||
|
.output(z.void())
|
||||||
|
.meta({
|
||||||
|
openapi: { method: "PUT", path: "/api/groups/{id}", tags: ["groups"], protect: true, summary: "Update group" },
|
||||||
|
})
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
|
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
|
||||||
@@ -303,6 +362,10 @@ export const groupRouter = createTRPCRouter({
|
|||||||
deleteGroup: permissionRequiredProcedure
|
deleteGroup: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(byIdSchema)
|
.input(byIdSchema)
|
||||||
|
.output(z.void())
|
||||||
|
.meta({
|
||||||
|
openapi: { method: "DELETE", path: "/api/groups/{id}", tags: ["groups"], protect: true, summary: "Delete group" },
|
||||||
|
})
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
|
await throwIfGroupNameIsReservedAsync(ctx.db, input.id);
|
||||||
@@ -312,6 +375,16 @@ export const groupRouter = createTRPCRouter({
|
|||||||
addMember: permissionRequiredProcedure
|
addMember: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(groupUserSchema)
|
.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 }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||||
await throwIfGroupNameIsReservedAsync(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({
|
await ctx.db.insert(groupMembers).values({
|
||||||
groupId: input.groupId,
|
groupId: input.groupId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -336,6 +411,16 @@ export const groupRouter = createTRPCRouter({
|
|||||||
removeMember: permissionRequiredProcedure
|
removeMember: permissionRequiredProcedure
|
||||||
.requiresPermission("admin")
|
.requiresPermission("admin")
|
||||||
.input(groupUserSchema)
|
.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 }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||||
await throwIfGroupNameIsReservedAsync(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 selectAppSchema = createSelectSchema(apps);
|
||||||
export const selectBoardSchema = createSelectSchema(boards);
|
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 selectGroupSchema = createSelectSchema(groups);
|
||||||
export const selectInviteSchema = createSelectSchema(invites);
|
export const selectInviteSchema = createSelectSchema(invites);
|
||||||
export const selectSearchEnginesSchema = createSelectSchema(searchEngines);
|
export const selectSearchEnginesSchema = createSelectSchema(searchEngines);
|
||||||
|
|||||||
@@ -57,12 +57,7 @@ export const createRequestIntegrationJobHandler = <
|
|||||||
reduceWidgetOptionsWithDefaultValues(
|
reduceWidgetOptionsWithDefaultValues(
|
||||||
itemForIntegration.kind,
|
itemForIntegration.kind,
|
||||||
{
|
{
|
||||||
defaultSearchEngineId: serverSettings.search.defaultSearchEngineId,
|
|
||||||
openSearchInNewTab: true,
|
|
||||||
firstDayOfWeek: 1,
|
firstDayOfWeek: 1,
|
||||||
homeBoardId: serverSettings.board.homeBoardId,
|
|
||||||
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
|
||||||
pingIconsEnabled: true,
|
|
||||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,24 +27,13 @@ export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetIn
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentValidationResult = React.useMemo(() => {
|
const currentValidationResult = React.useMemo(() => {
|
||||||
if (!options.validate) {
|
return options.validate.safeParse(search);
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
result: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationResult = options.validate.safeParse(search);
|
|
||||||
return {
|
|
||||||
success: validationResult.success,
|
|
||||||
result: validationResult,
|
|
||||||
};
|
|
||||||
}, [options.validate, search]);
|
}, [options.validate, search]);
|
||||||
|
|
||||||
const error = React.useMemo(() => {
|
const error = React.useMemo(() => {
|
||||||
/* hide the error when nothing is being typed since "" is not valid but is not an explicit error */
|
/* 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) {
|
if (!currentValidationResult.success && search.length !== 0) {
|
||||||
return currentValidationResult.result.error?.issues[0]?.message;
|
return currentValidationResult.error.issues[0]?.message;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [currentValidationResult, search]);
|
}, [currentValidationResult, search]);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconBookmark, IconX } from "@tabler/icons-react";
|
import { IconBookmark, IconX } from "@tabler/icons-react";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -56,6 +57,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
|||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
validate: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export interface WidgetDefinition {
|
|||||||
icon: TablerIcon;
|
icon: TablerIcon;
|
||||||
supportedIntegrations?: IntegrationKind[];
|
supportedIntegrations?: IntegrationKind[];
|
||||||
integrationsRequired?: boolean;
|
integrationsRequired?: boolean;
|
||||||
createOptions: (settings: SettingsContextProps) => WidgetOptionsRecord;
|
createOptions: (
|
||||||
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "firstDayOfWeek" | "forceDisableStatus">,
|
||||||
|
) => WidgetOptionsRecord;
|
||||||
errors?: Partial<
|
errors?: Partial<
|
||||||
Record<
|
Record<
|
||||||
DefaultErrorData["code"],
|
DefaultErrorData["code"],
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
|
|||||||
|
|
||||||
export const reduceWidgetOptionsWithDefaultValues = (
|
export const reduceWidgetOptionsWithDefaultValues = (
|
||||||
kind: WidgetKind,
|
kind: WidgetKind,
|
||||||
settings: SettingsContextProps,
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "firstDayOfWeek" | "forceDisableStatus">,
|
||||||
currentValue: Record<string, unknown> = {},
|
currentValue: Record<string, unknown> = {},
|
||||||
) => {
|
) => {
|
||||||
const definition = widgetImports[kind].definition;
|
const definition = widgetImports[kind].definition;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod/v4";
|
|||||||
import type { ZodType } from "zod/v4";
|
import type { ZodType } from "zod/v4";
|
||||||
|
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
import { zodEnumFromArray } from "@homarr/validation/enums";
|
||||||
|
|
||||||
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
||||||
import type { ReleasesRepository } from "./releases/releases-repository";
|
import type { ReleasesRepository } from "./releases/releases-repository";
|
||||||
@@ -38,6 +39,7 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
|
|||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
uniqueIdentifier: (item: TItem) => TOptionValue;
|
uniqueIdentifier: (item: TItem) => TOptionValue;
|
||||||
useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown };
|
useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown };
|
||||||
|
validate: z.ZodArray<z.ZodType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectInput<TOptions extends readonly SelectOption[]> extends CommonInput<
|
interface SelectInput<TOptions extends readonly SelectOption[]> extends CommonInput<
|
||||||
@@ -68,12 +70,13 @@ const optionsFactory = {
|
|||||||
type: "switch" as const,
|
type: "switch" as const,
|
||||||
defaultValue: input?.defaultValue ?? false,
|
defaultValue: input?.defaultValue ?? false,
|
||||||
withDescription: input?.withDescription ?? false,
|
withDescription: input?.withDescription ?? false,
|
||||||
|
validate: z.boolean(),
|
||||||
}),
|
}),
|
||||||
text: (input?: TextInput) => ({
|
text: (input?: TextInput) => ({
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
defaultValue: input?.defaultValue ?? "",
|
defaultValue: input?.defaultValue ?? "",
|
||||||
withDescription: input?.withDescription ?? false,
|
withDescription: input?.withDescription ?? false,
|
||||||
validate: input?.validate,
|
validate: input?.validate ?? z.string(),
|
||||||
}),
|
}),
|
||||||
multiSelect: <const TOptions extends SelectOption[]>(input: MultiSelectInput<TOptions>) => ({
|
multiSelect: <const TOptions extends SelectOption[]>(input: MultiSelectInput<TOptions>) => ({
|
||||||
type: "multiSelect" as const,
|
type: "multiSelect" as const,
|
||||||
@@ -81,6 +84,9 @@ const optionsFactory = {
|
|||||||
options: input.options,
|
options: input.options,
|
||||||
searchable: input.searchable ?? false,
|
searchable: input.searchable ?? false,
|
||||||
withDescription: input.withDescription ?? 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>) => ({
|
select: <const TOptions extends SelectOption[]>(input: SelectInput<TOptions>) => ({
|
||||||
type: "select" as const,
|
type: "select" as const,
|
||||||
@@ -88,6 +94,7 @@ const optionsFactory = {
|
|||||||
options: input.options,
|
options: input.options,
|
||||||
searchable: input.searchable ?? false,
|
searchable: input.searchable ?? false,
|
||||||
withDescription: input.withDescription ?? false,
|
withDescription: input.withDescription ?? false,
|
||||||
|
validate: zodEnumFromArray(input.options.map((option) => (typeof option === "string" ? option : option.value))),
|
||||||
}),
|
}),
|
||||||
number: (input: NumberInput) => ({
|
number: (input: NumberInput) => ({
|
||||||
type: "number" as const,
|
type: "number" as const,
|
||||||
@@ -122,19 +129,20 @@ const optionsFactory = {
|
|||||||
defaultValue: input?.defaultValue ?? [],
|
defaultValue: input?.defaultValue ?? [],
|
||||||
withDescription: input?.withDescription ?? false,
|
withDescription: input?.withDescription ?? false,
|
||||||
values: [] as string[],
|
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,
|
type: "multiReleasesRepositories" as const,
|
||||||
defaultValue: input?.defaultValue ?? [],
|
defaultValue: input.defaultValue ?? [],
|
||||||
withDescription: input?.withDescription ?? false,
|
withDescription: input.withDescription ?? false,
|
||||||
values: [] as ReleasesRepository[],
|
values: [] as ReleasesRepository[],
|
||||||
validate: input?.validate,
|
validate: input.validate,
|
||||||
}),
|
}),
|
||||||
app: () => ({
|
app: () => ({
|
||||||
type: "app" as const,
|
type: "app" as const,
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
withDescription: false,
|
withDescription: false,
|
||||||
|
validate: z.string(),
|
||||||
}),
|
}),
|
||||||
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
|
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
|
||||||
input: SortableItemListInput<TItem, TOptionValue>,
|
input: SortableItemListInput<TItem, TOptionValue>,
|
||||||
@@ -146,6 +154,7 @@ const optionsFactory = {
|
|||||||
uniqueIdentifier: input.uniqueIdentifier,
|
uniqueIdentifier: input.uniqueIdentifier,
|
||||||
useData: input.useData,
|
useData: input.useData,
|
||||||
withDescription: false,
|
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