From ccb19e06c192029a81c851900982d52b045185a6 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 4 Jan 2025 23:06:34 +0100 Subject: [PATCH] feat(app): add search and pagination (#1860) --- .../src/app/[locale]/manage/apps/page.tsx | 26 ++++++++++++++++--- .../src/app/[locale]/manage/medias/page.tsx | 7 ++--- .../[locale]/manage/search-engines/page.tsx | 7 ++--- .../app/[locale]/manage/users/groups/page.tsx | 7 ++--- packages/api/src/router/app.ts | 20 ++++++++++++++ packages/common/src/types.ts | 10 +++++++ packages/translation/src/lang/en.json | 1 + 7 files changed, 60 insertions(+), 18 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx index f95882a56..5193166f3 100644 --- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx @@ -6,7 +6,10 @@ import { IconBox, IconPencil } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; +import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; +import { SearchInput, TablePagination } from "@homarr/ui"; +import { z } from "@homarr/validation"; import { ManageContainer } from "~/components/manage/manage-container"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; @@ -14,22 +17,35 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { NoResults } from "~/components/no-results"; import { AppDeleteButton } from "./_app-delete-button"; -export default async function AppsPage() { +const searchParamsSchema = z.object({ + search: z.string().optional(), + pageSize: z.string().regex(/\d+/).transform(Number).catch(10), + page: z.string().regex(/\d+/).transform(Number).catch(1), +}); + +interface AppsPageProps { + searchParams: Promise>; +} + +export default async function AppsPage(props: AppsPageProps) { const session = await auth(); if (!session) { redirect("/auth/login"); } - const apps = await api.app.all(); + const searchParams = searchParamsSchema.parse(await props.searchParams); + + const { items: apps, totalCount } = await api.app.getPaginated(searchParams); const t = await getScopedI18n("app"); return ( + {t("page.list.title")} - {t("page.list.title")} + {session.user.permissions.includes("app-create") && ( {t("page.create.title")} @@ -44,6 +60,10 @@ export default async function AppsPage() { ))} )} + + + + ); diff --git a/apps/nextjs/src/app/[locale]/manage/medias/page.tsx b/apps/nextjs/src/app/[locale]/manage/medias/page.tsx index ed50a18ad..102e54cd8 100644 --- a/apps/nextjs/src/app/[locale]/manage/medias/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/medias/page.tsx @@ -7,6 +7,7 @@ import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { humanFileSize } from "@homarr/common"; +import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { getI18n } from "@homarr/translation/server"; import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui"; import { z } from "@homarr/validation"; @@ -29,12 +30,8 @@ const searchParamsSchema = z.object({ page: z.string().regex(/\d+/).transform(Number).catch(1), }); -type SearchParamsSchemaInputFromSchema> = Partial<{ - [K in keyof TSchema]: Exclude extends unknown[] ? string[] : string; -}>; - interface MediaListPageProps { - searchParams: Promise>>; + searchParams: Promise>; } export default async function GroupsListPage(props: MediaListPageProps) { diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx index e7af60531..8caeefbe7 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/page.tsx @@ -6,6 +6,7 @@ import { IconPencil, IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; +import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { SearchInput, TablePagination } from "@homarr/ui"; import { z } from "@homarr/validation"; @@ -22,12 +23,8 @@ const searchParamsSchema = z.object({ page: z.string().regex(/\d+/).transform(Number).catch(1), }); -type SearchParamsSchemaInputFromSchema> = Partial<{ - [K in keyof TSchema]: Exclude extends unknown[] ? string[] : string; -}>; - interface SearchEnginesPageProps { - searchParams: Promise>>; + searchParams: Promise>; } export default async function SearchEnginesPage(props: SearchEnginesPageProps) { diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx index 80c128c03..d1113bf4f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx @@ -5,6 +5,7 @@ import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; +import type { inferSearchParamsFromSchema } from "@homarr/common/types"; import { getI18n } from "@homarr/translation/server"; import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui"; import { z } from "@homarr/validation"; @@ -19,12 +20,8 @@ const searchParamsSchema = z.object({ page: z.string().regex(/\d+/).transform(Number).catch(1), }); -type SearchParamsSchemaInputFromSchema> = Partial<{ - [K in keyof TSchema]: Exclude extends unknown[] ? string[] : string; -}>; - interface GroupsListPageProps { - searchParams: Promise>>; + searchParams: Promise>; } export default async function GroupsListPage(props: GroupsListPageProps) { diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index da4184e89..5eb65804b 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -10,6 +10,26 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ import { canUserSeeAppAsync } from "./app/app-access-control"; export const appRouter = createTRPCRouter({ + getPaginated: protectedProcedure + .input(validation.common.paginated) + .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() })) + .meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } }) + .query(async ({ input, ctx }) => { + const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined; + const totalCount = await ctx.db.$count(apps, whereQuery); + + const dbApps = await ctx.db.query.apps.findMany({ + limit: input.pageSize, + offset: (input.page - 1) * input.pageSize, + where: whereQuery, + orderBy: asc(apps.name), + }); + + return { + items: dbApps, + totalCount, + }; + }), all: protectedProcedure .input(z.void()) .output(z.array(selectAppSchema)) diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 1dcd019bc..e6b0621ff 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -1,3 +1,5 @@ +import type { z } from "zod"; + export type MaybePromise = T | Promise; export type AtLeastOneOf = [T, ...T[]]; @@ -16,3 +18,11 @@ export type Inverse = { }; type Invertible = Record; + +export type inferSearchParamsFromSchema = inferSearchParamsFromSchemaInner< + z.infer +>; + +type inferSearchParamsFromSchemaInner> = Partial<{ + [K in keyof TSchema]: Exclude extends unknown[] ? string[] : string; +}>; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5e0754650..11cf49ac6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -489,6 +489,7 @@ } }, "app": { + "search": "Find an app", "page": { "list": { "title": "Apps",