Compare commits

...
18 changed files with 2262 additions and 12154 deletions

No files matched your search

+1 -2
View File
@@ -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
+2
View File
@@ -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",
+104 -19
View File
@@ -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);
+366
View File
@@ -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));
}),
});
+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);
@@ -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]);
+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";
@@ -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,
}),
};
+1731 -1059
View File
File diff suppressed because it is too large. Load diff