feat(app): add search and pagination (#1860)
This commit is contained in:
@@ -6,7 +6,10 @@ import { IconBox, IconPencil } from "@tabler/icons-react";
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
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 { ManageContainer } from "~/components/manage/manage-container";
|
||||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
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 { NoResults } from "~/components/no-results";
|
||||||
import { AppDeleteButton } from "./_app-delete-button";
|
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();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/auth/login");
|
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");
|
const t = await getScopedI18n("app");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManageContainer>
|
<ManageContainer>
|
||||||
<DynamicBreadcrumb />
|
<DynamicBreadcrumb />
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<Title>{t("page.list.title")}</Title>
|
||||||
<Group justify="space-between" align="center">
|
<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") && (
|
{session.user.permissions.includes("app-create") && (
|
||||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||||
{t("page.create.title")}
|
{t("page.create.title")}
|
||||||
@@ -44,6 +60,10 @@ export default async function AppsPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||||
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</ManageContainer>
|
</ManageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { RouterOutputs } from "@homarr/api";
|
|||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { humanFileSize } from "@homarr/common";
|
||||||
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
@@ -29,12 +30,8 @@ const searchParamsSchema = z.object({
|
|||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
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 {
|
interface MediaListPageProps {
|
||||||
searchParams: Promise<SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconPencil, IconSearch } from "@tabler/icons-react";
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination } from "@homarr/ui";
|
import { SearchInput, TablePagination } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
@@ -22,12 +23,8 @@ const searchParamsSchema = z.object({
|
|||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
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 {
|
interface SearchEnginesPageProps {
|
||||||
searchParams: Promise<SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead,
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||||
import { getI18n } from "@homarr/translation/server";
|
import { getI18n } from "@homarr/translation/server";
|
||||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
@@ -19,12 +20,8 @@ const searchParamsSchema = z.object({
|
|||||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
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 {
|
interface GroupsListPageProps {
|
||||||
searchParams: Promise<SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>>;
|
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ
|
|||||||
import { canUserSeeAppAsync } from "./app/app-access-control";
|
import { canUserSeeAppAsync } from "./app/app-access-control";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
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
|
all: protectedProcedure
|
||||||
.input(z.void())
|
.input(z.void())
|
||||||
.output(z.array(selectAppSchema))
|
.output(z.array(selectAppSchema))
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
export type MaybePromise<T> = T | Promise<T>;
|
export type MaybePromise<T> = T | Promise<T>;
|
||||||
|
|
||||||
export type AtLeastOneOf<T> = [T, ...T[]];
|
export type AtLeastOneOf<T> = [T, ...T[]];
|
||||||
@@ -16,3 +18,11 @@ export type Inverse<T extends Invertible> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Invertible = Record<PropertyKey, PropertyKey>;
|
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;
|
||||||
|
}>;
|
||||||
|
|||||||
@@ -489,6 +489,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
"search": "Find an app",
|
||||||
"page": {
|
"page": {
|
||||||
"list": {
|
"list": {
|
||||||
"title": "Apps",
|
"title": "Apps",
|
||||||
|
|||||||
Reference in New Issue
Block a user