mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-17 10:26:39 +00:00
✨ paginate, search & filter resources by enabled
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
|||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
import { sql, eq, or, inArray, and, count, ilike } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -27,19 +27,30 @@ const listResourcesParamsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listResourcesSchema = z.object({
|
const listResourcesSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20),
|
||||||
.pipe(z.int().nonnegative()),
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
offset: z
|
.int()
|
||||||
.string()
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1),
|
||||||
.pipe(z.int().nonnegative())
|
query: z.string().optional(),
|
||||||
|
enabled: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
authState: z
|
||||||
|
.enum(["protected", "not_protected"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
});
|
});
|
||||||
|
|
||||||
// (resource fields + a single joined target)
|
// (resource fields + a single joined target)
|
||||||
@@ -95,7 +106,7 @@ export type ResourceWithTargets = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryResources(accessibleResourceIds: number[], orgId: string) {
|
function queryResourcesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -147,18 +158,12 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
.leftJoin(
|
.leftJoin(
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
eq(targetHealthCheck.targetId, targets.targetId)
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(resources.resourceId, accessibleResourceIds),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListResourcesResponse = {
|
export type ListResourcesResponse = {
|
||||||
resources: ResourceWithTargets[];
|
resources: ResourceWithTargets[];
|
||||||
pagination: { total: number; limit: number; offset: number };
|
pagination: { total: number; pageSize: number; page: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -190,7 +195,7 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { page, pageSize, authState, enabled, query } = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -252,14 +257,37 @@ export async function listResources(
|
|||||||
(resource) => resource.resourceId
|
(resource) => resource.resourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let conditions = and(
|
||||||
|
and(
|
||||||
|
inArray(resources.resourceId, accessibleResourceIds),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions = and(
|
||||||
|
conditions,
|
||||||
|
or(
|
||||||
|
ilike(resources.name, "%" + query + "%"),
|
||||||
|
ilike(resources.fullDomain, "%" + query + "%")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof enabled !== "undefined") {
|
||||||
|
conditions = and(conditions, eq(resources.enabled, enabled));
|
||||||
|
}
|
||||||
|
|
||||||
const countQuery: any = db
|
const countQuery: any = db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.where(inArray(resources.resourceId, accessibleResourceIds));
|
.where(conditions);
|
||||||
|
|
||||||
const baseQuery = queryResources(accessibleResourceIds, orgId);
|
const baseQuery = queryResourcesBase();
|
||||||
|
|
||||||
const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset);
|
const rows: JoinedRow[] = await baseQuery
|
||||||
|
.where(conditions)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1));
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
@@ -324,8 +352,8 @@ export async function listResources(
|
|||||||
resources: resourcesList,
|
resources: resourcesList,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
pageSize,
|
||||||
offset
|
page
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { cache } from "react";
|
|||||||
|
|
||||||
export interface ProxyResourcesPageProps {
|
export interface ProxyResourcesPageProps {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<{ view?: string }>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProxyResourcesPage(
|
export default async function ProxyResourcesPage(
|
||||||
@@ -24,14 +24,22 @@ export default async function ProxyResourcesPage(
|
|||||||
) {
|
) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let resources: ListResourcesResponse["resources"] = [];
|
let resources: ListResourcesResponse["resources"] = [];
|
||||||
|
let pagination: ListResourcesResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||||
`/org/${params.orgId}/resources`,
|
`/org/${params.orgId}/resources?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
resources = res.data.data.resources;
|
const responseData = res.data.data;
|
||||||
|
resources = responseData.resources;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
@@ -104,9 +112,10 @@ export default async function ProxyResourcesPage(
|
|||||||
<ProxyResourcesTable
|
<ProxyResourcesTable
|
||||||
resources={resourceRows}
|
resources={resourceRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
defaultSort={{
|
rowCount={pagination.total}
|
||||||
id: "name",
|
pagination={{
|
||||||
desc: false
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
|
|||||||
@@ -31,8 +31,14 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import z from "zod";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
|
||||||
export type TargetHealth = {
|
export type TargetHealth = {
|
||||||
targetId: number;
|
targetId: number;
|
||||||
@@ -117,18 +123,22 @@ function StatusIcon({
|
|||||||
type ProxyResourcesTableProps = {
|
type ProxyResourcesTableProps = {
|
||||||
resources: ResourceRow[];
|
resources: ResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
defaultSort?: {
|
pagination: PaginationState;
|
||||||
id: string;
|
rowCount: number;
|
||||||
desc: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProxyResourcesTable({
|
export default function ProxyResourcesTable({
|
||||||
resources,
|
resources,
|
||||||
orgId,
|
orgId,
|
||||||
defaultSort
|
pagination,
|
||||||
|
rowCount
|
||||||
}: ProxyResourcesTableProps) {
|
}: ProxyResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -140,6 +150,7 @@ export default function ProxyResourcesTable({
|
|||||||
useState<ResourceRow | null>();
|
useState<ResourceRow | null>();
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -236,7 +247,7 @@ export default function ProxyResourcesTable({
|
|||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="min-w-[280px]">
|
<DropdownMenuContent align="start" className="min-w-70">
|
||||||
{monitoredTargets.length > 0 && (
|
{monitoredTargets.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{monitoredTargets.map((target) => (
|
{monitoredTargets.map((target) => (
|
||||||
@@ -456,7 +467,24 @@ export default function ProxyResourcesTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "enabled",
|
accessorKey: "enabled",
|
||||||
friendlyName: t("enabled"),
|
friendlyName: t("enabled"),
|
||||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
header: () => (
|
||||||
|
<ColumnFilterButton
|
||||||
|
options={[
|
||||||
|
{ value: "true", label: t("enabled") },
|
||||||
|
{ value: "false", label: t("disabled") }
|
||||||
|
]}
|
||||||
|
selectedValue={booleanSearchFilterSchema.parse(
|
||||||
|
searchParams.get("enabled")
|
||||||
|
)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("enabled", value)
|
||||||
|
}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("enabled")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={
|
defaultChecked={
|
||||||
@@ -525,6 +553,42 @@ export default function ProxyResourcesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const booleanSearchFilterSchema = z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined);
|
||||||
|
|
||||||
|
function handleFilterChange(
|
||||||
|
column: string,
|
||||||
|
value: string | undefined | null
|
||||||
|
) {
|
||||||
|
searchParams.delete(column);
|
||||||
|
searchParams.delete("page");
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
searchParams.set(column, value);
|
||||||
|
}
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaginationChange = (newPage: PaginationState) => {
|
||||||
|
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||||
|
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedResource && (
|
{selectedResource && (
|
||||||
@@ -547,21 +611,27 @@ export default function ProxyResourcesTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DataTable
|
<ControlledDataTable
|
||||||
columns={proxyColumns}
|
columns={proxyColumns}
|
||||||
data={resources}
|
rows={resources}
|
||||||
persistPageSize="proxy-resources"
|
tableId="proxy-resources"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchColumn="name"
|
pagination={pagination}
|
||||||
|
rowCount={rowCount}
|
||||||
|
onSearch={handleSearchChange}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
onAdd={() =>
|
onAdd={() =>
|
||||||
router.push(`/${orgId}/settings/resources/proxy/create`)
|
startNavigation(() => {
|
||||||
|
router.push(
|
||||||
|
`/${orgId}/settings/resources/proxy/create`
|
||||||
|
);
|
||||||
|
})
|
||||||
}
|
}
|
||||||
addButtonText={t("resourceAdd")}
|
addButtonText={t("resourceAdd")}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
defaultSort={defaultSort}
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||||
enableColumnVisibility={true}
|
enableColumnVisibility
|
||||||
persistColumnVisibility="proxy-resources"
|
|
||||||
columnVisibility={{ niceId: false }}
|
columnVisibility={{ niceId: false }}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import {
|
import {
|
||||||
ManualDataTable,
|
ControlledDataTable,
|
||||||
type ExtendedColumnDef
|
type ExtendedColumnDef
|
||||||
} from "./ui/manual-data-table";
|
} from "./ui/controlled-data-table";
|
||||||
import { ColumnFilter } from "./ColumnFilter";
|
import { ColumnFilter } from "./ColumnFilter";
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
@@ -77,6 +77,7 @@ export default function SitesTable({
|
|||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const [getSortDirection, toggleSorting] = useSortColumn();
|
const [getSortDirection, toggleSorting] = useSortColumn();
|
||||||
|
|
||||||
@@ -460,14 +461,19 @@ export default function SitesTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ManualDataTable
|
<ControlledDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={sites}
|
rows={sites}
|
||||||
tableId="sites-table"
|
tableId="sites-table"
|
||||||
searchPlaceholder={t("searchSitesProgress")}
|
searchPlaceholder={t("searchSitesProgress")}
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
onPaginationChange={handlePaginationChange}
|
onPaginationChange={handlePaginationChange}
|
||||||
onAdd={() => router.push(`/${orgId}/settings/sites/create`)}
|
onAdd={() =>
|
||||||
|
startNavigation(() =>
|
||||||
|
router.push(`/${orgId}/settings/sites/create`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||||
searchQuery={searchParams.get("query")?.toString()}
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
onSearch={handleSearchChange}
|
onSearch={handleSearchChange}
|
||||||
addButtonText={t("siteAdd")}
|
addButtonText={t("siteAdd")}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ type DataTableFilter = {
|
|||||||
|
|
||||||
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
|
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
|
||||||
|
|
||||||
type ManualDataTableProps<TData, TValue> = {
|
type ControlledDataTableProps<TData, TValue> = {
|
||||||
columns: ExtendedColumnDef<TData, TValue>[];
|
columns: ExtendedColumnDef<TData, TValue>[];
|
||||||
rows: TData[];
|
rows: TData[];
|
||||||
tableId: string;
|
tableId: string;
|
||||||
@@ -72,12 +72,13 @@ type ManualDataTableProps<TData, TValue> = {
|
|||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
|
isNavigatingToAddPage?: boolean;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
filters?: DataTableFilter[];
|
filters?: DataTableFilter[];
|
||||||
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
|
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
|
||||||
columnVisibility?: Record<string, boolean>;
|
columnVisibility?: Record<string, boolean>;
|
||||||
enableColumnVisibility?: boolean;
|
enableColumnVisibility?: boolean;
|
||||||
onSearch: (input: string) => void;
|
onSearch?: (input: string) => void;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
onPaginationChange: DataTablePaginationUpdateFn;
|
onPaginationChange: DataTablePaginationUpdateFn;
|
||||||
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
|
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
|
||||||
@@ -86,7 +87,7 @@ type ManualDataTableProps<TData, TValue> = {
|
|||||||
pagination: PaginationState;
|
pagination: PaginationState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ManualDataTable<TData, TValue>({
|
export function ControlledDataTable<TData, TValue>({
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
addButtonText,
|
addButtonText,
|
||||||
@@ -105,8 +106,9 @@ export function ManualDataTable<TData, TValue>({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
onPaginationChange,
|
onPaginationChange,
|
||||||
stickyRightColumn,
|
stickyRightColumn,
|
||||||
rowCount
|
rowCount,
|
||||||
}: ManualDataTableProps<TData, TValue>) {
|
isNavigatingToAddPage
|
||||||
|
}: ControlledDataTableProps<TData, TValue>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
@@ -217,17 +219,20 @@ export function ManualDataTable<TData, TValue>({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
||||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||||
<div className="relative w-full sm:max-w-sm">
|
{onSearch && (
|
||||||
<Input
|
<div className="relative w-full sm:max-w-sm">
|
||||||
placeholder={searchPlaceholder}
|
<Input
|
||||||
defaultValue={searchQuery}
|
placeholder={searchPlaceholder}
|
||||||
onChange={(e) =>
|
defaultValue={searchQuery}
|
||||||
onSearch(e.currentTarget.value)
|
onChange={(e) =>
|
||||||
}
|
onSearch(e.currentTarget.value)
|
||||||
className="w-full pl-8"
|
}
|
||||||
/>
|
className="w-full pl-8"
|
||||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
/>
|
||||||
</div>
|
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{filters && filters.length > 0 && (
|
{filters && filters.length > 0 && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{filters.map((filter) => {
|
{filters.map((filter) => {
|
||||||
@@ -326,7 +331,10 @@ export function ManualDataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
{onAdd && addButtonText && (
|
{onAdd && addButtonText && (
|
||||||
<div>
|
<div>
|
||||||
<Button onClick={onAdd}>
|
<Button
|
||||||
|
onClick={onAdd}
|
||||||
|
loading={isNavigatingToAddPage}
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{addButtonText}
|
{addButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
36
src/hooks/useNavigationContext.ts
Normal file
36
src/hooks/useNavigationContext.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useSearchParams, usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
|
||||||
|
export function useNavigationContext() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const path = usePathname();
|
||||||
|
const [isNavigating, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function navigate({
|
||||||
|
searchParams: params,
|
||||||
|
pathname = path,
|
||||||
|
replace = false
|
||||||
|
}: {
|
||||||
|
pathname?: string;
|
||||||
|
searchParams?: URLSearchParams;
|
||||||
|
replace?: boolean;
|
||||||
|
}) {
|
||||||
|
startTransition(() => {
|
||||||
|
const fullPath = pathname + (params ? `?${params.toString()}` : "");
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
router.replace(fullPath);
|
||||||
|
} else {
|
||||||
|
router.push(fullPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pathname: path,
|
||||||
|
searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable
|
||||||
|
navigate,
|
||||||
|
isNavigating
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user