feat(app): add search and pagination (#1860)

This commit is contained in:
Meier Lukas
2025-01-04 23:06:34 +01:00
committed by GitHub
parent bf12944768
commit ccb19e06c1
7 changed files with 60 additions and 18 deletions

View File

@@ -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<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
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 (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("page.list.title")}</Title>
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
@@ -44,6 +60,10 @@ export default async function AppsPage() {
))}
</Stack>
)}
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
);

View File

@@ -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<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface MediaListPageProps {
searchParams: Promise<SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: MediaListPageProps) {

View File

@@ -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<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface SearchEnginesPageProps {
searchParams: Promise<SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {

View File

@@ -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<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface GroupsListPageProps {
searchParams: Promise<SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: GroupsListPageProps) {

View File

@@ -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))

View File

@@ -1,3 +1,5 @@
import type { z } from "zod";
export type MaybePromise<T> = T | Promise<T>;
export type AtLeastOneOf<T> = [T, ...T[]];
@@ -16,3 +18,11 @@ export type Inverse<T extends Invertible> = {
};
type Invertible = Record<PropertyKey, PropertyKey>;
export type inferSearchParamsFromSchema<TSchema extends z.AnyZodObject> = inferSearchParamsFromSchemaInner<
z.infer<TSchema>
>;
type inferSearchParamsFromSchemaInner<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;

View File

@@ -489,6 +489,7 @@
}
},
"app": {
"search": "Find an app",
"page": {
"list": {
"title": "Apps",