mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-01 00:06:38 +00:00
Add expandable columns
This commit is contained in:
@@ -24,6 +24,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { metadata } from "@app/app/[orgId]/settings/layout";
|
||||||
|
|
||||||
export const queryActionAuditLogsQuery = z.object({
|
export const queryActionAuditLogsQuery = z.object({
|
||||||
// iso string just validate its a parseable date
|
// iso string just validate its a parseable date
|
||||||
@@ -65,6 +66,7 @@ export function queryAction(timeStart: number, timeEnd: number, orgId: string) {
|
|||||||
orgId: actionAuditLog.orgId,
|
orgId: actionAuditLog.orgId,
|
||||||
action: actionAuditLog.action,
|
action: actionAuditLog.action,
|
||||||
actorType: actionAuditLog.actorType,
|
actorType: actionAuditLog.actorType,
|
||||||
|
metadata: actionAuditLog.metadata,
|
||||||
actorId: actionAuditLog.actorId,
|
actorId: actionAuditLog.actorId,
|
||||||
timestamp: actionAuditLog.timestamp,
|
timestamp: actionAuditLog.timestamp,
|
||||||
actor: actionAuditLog.actor
|
actor: actionAuditLog.actor
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export async function exportRequestAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
|
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -52,16 +51,17 @@ export async function exportRequestAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const baseQuery = queryRequest(timeStart, timeEnd, orgId);
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
const log = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = queryRequest(data);
|
||||||
|
|
||||||
|
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
const csvData = generateCSV(log);
|
const csvData = generateCSV(log);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${orgId}-${Date.now()}.csv"`);
|
res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`);
|
||||||
|
|
||||||
return res.send(csvData);
|
return res.send(csvData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export const queryAccessAuditLogsQuery = z.object({
|
|||||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||||
.optional()
|
.optional()
|
||||||
.default(new Date().toISOString()),
|
.default(new Date().toISOString()),
|
||||||
|
action: z.boolean().optional(),
|
||||||
|
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
|
||||||
|
reason: z.number().optional(),
|
||||||
|
resourceId: z.number().optional(),
|
||||||
|
actor: z.string().optional(),
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -46,55 +51,64 @@ export const queryRequestAuditLogsParams = z.object({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
export function queryRequest(timeStart: number, timeEnd: number, orgId: string) {
|
export const queryRequestAuditLogsCombined =
|
||||||
|
queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams);
|
||||||
|
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
|
||||||
|
|
||||||
|
function getWhere(data: Q) {
|
||||||
|
return and(
|
||||||
|
gt(requestAuditLog.timestamp, data.timeStart),
|
||||||
|
lt(requestAuditLog.timestamp, data.timeEnd),
|
||||||
|
eq(requestAuditLog.orgId, data.orgId),
|
||||||
|
data.resourceId
|
||||||
|
? eq(requestAuditLog.resourceId, data.resourceId)
|
||||||
|
: undefined,
|
||||||
|
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
||||||
|
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
||||||
|
data.reason ? eq(requestAuditLog.reason, data.reason) : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queryRequest(data: Q) {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
timestamp: requestAuditLog.timestamp,
|
timestamp: requestAuditLog.timestamp,
|
||||||
orgId: requestAuditLog.orgId,
|
orgId: requestAuditLog.orgId,
|
||||||
action: requestAuditLog.action,
|
action: requestAuditLog.action,
|
||||||
reason: requestAuditLog.reason,
|
reason: requestAuditLog.reason,
|
||||||
actorType: requestAuditLog.actorType,
|
actorType: requestAuditLog.actorType,
|
||||||
actor: requestAuditLog.actor,
|
actor: requestAuditLog.actor,
|
||||||
actorId: requestAuditLog.actorId,
|
actorId: requestAuditLog.actorId,
|
||||||
resourceId: requestAuditLog.resourceId,
|
resourceId: requestAuditLog.resourceId,
|
||||||
ip: requestAuditLog.ip,
|
ip: requestAuditLog.ip,
|
||||||
location: requestAuditLog.location,
|
location: requestAuditLog.location,
|
||||||
userAgent: requestAuditLog.userAgent,
|
userAgent: requestAuditLog.userAgent,
|
||||||
metadata: requestAuditLog.metadata,
|
metadata: requestAuditLog.metadata,
|
||||||
headers: requestAuditLog.headers,
|
headers: requestAuditLog.headers,
|
||||||
query: requestAuditLog.query,
|
query: requestAuditLog.query,
|
||||||
originalRequestURL: requestAuditLog.originalRequestURL,
|
originalRequestURL: requestAuditLog.originalRequestURL,
|
||||||
scheme: requestAuditLog.scheme,
|
scheme: requestAuditLog.scheme,
|
||||||
host: requestAuditLog.host,
|
host: requestAuditLog.host,
|
||||||
path: requestAuditLog.path,
|
path: requestAuditLog.path,
|
||||||
method: requestAuditLog.method,
|
method: requestAuditLog.method,
|
||||||
tls: requestAuditLog.tls,
|
tls: requestAuditLog.tls,
|
||||||
resourceName: resources.name,
|
resourceName: resources.name,
|
||||||
resourceNiceId: resources.niceId
|
resourceNiceId: resources.niceId
|
||||||
})
|
})
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.leftJoin(resources, eq(requestAuditLog.resourceId, resources.resourceId)) // TODO: Is this efficient?
|
.leftJoin(
|
||||||
.where(
|
resources,
|
||||||
and(
|
eq(requestAuditLog.resourceId, resources.resourceId)
|
||||||
gt(requestAuditLog.timestamp, timeStart),
|
) // TODO: Is this efficient?
|
||||||
lt(requestAuditLog.timestamp, timeEnd),
|
.where(getWhere(data))
|
||||||
eq(requestAuditLog.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(requestAuditLog.timestamp);
|
.orderBy(requestAuditLog.timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countRequestQuery(timeStart: number, timeEnd: number, orgId: string) {
|
export function countRequestQuery(data: Q) {
|
||||||
const countQuery = db
|
const countQuery = db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(
|
.where(getWhere(data));
|
||||||
and(
|
|
||||||
gt(requestAuditLog.timestamp, timeStart),
|
|
||||||
lt(requestAuditLog.timestamp, timeEnd),
|
|
||||||
eq(requestAuditLog.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return countQuery;
|
return countQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +139,6 @@ export async function queryRequestAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { timeStart, timeEnd, limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
|
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -136,13 +149,14 @@ export async function queryRequestAuditLogs(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const baseQuery = queryRequest(timeStart, timeEnd, orgId);
|
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||||
|
|
||||||
const log = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = queryRequest(data);
|
||||||
|
|
||||||
const totalCountResult = await countRequestQuery(timeStart, timeEnd, orgId);
|
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||||
|
|
||||||
|
const totalCountResult = await countRequestQuery(data);
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
return response<QueryRequestAuditLogResponse>(res, {
|
return response<QueryRequestAuditLogResponse>(res, {
|
||||||
@@ -150,8 +164,8 @@ export async function queryRequestAuditLogs(
|
|||||||
log: log,
|
log: log,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
limit: data.limit,
|
||||||
offset
|
offset: data.offset
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type QueryActionAuditLogResponse = {
|
|||||||
action: string;
|
action: string;
|
||||||
actorType: string;
|
actorType: string;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
metadata: string | null;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
actor: string;
|
actor: string;
|
||||||
}[];
|
}[];
|
||||||
@@ -49,8 +50,9 @@ export type QueryRequestAuditLogResponse = {
|
|||||||
export type QueryAccessAuditLogResponse = {
|
export type QueryAccessAuditLogResponse = {
|
||||||
log: {
|
log: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
action: string;
|
action: boolean;
|
||||||
type: string;
|
actorType: string | null;
|
||||||
|
actorId: string | null;
|
||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
resourceName: string | null;
|
resourceName: string | null;
|
||||||
resourceNiceId: string | null;
|
resourceNiceId: string | null;
|
||||||
@@ -58,10 +60,9 @@ export type QueryAccessAuditLogResponse = {
|
|||||||
location: string | null;
|
location: string | null;
|
||||||
userAgent: string | null;
|
userAgent: string | null;
|
||||||
metadata: string | null;
|
metadata: string | null;
|
||||||
actorType: string;
|
type: string;
|
||||||
actorId: string;
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
actor: string;
|
actor: string | null;
|
||||||
}[];
|
}[];
|
||||||
pagination: {
|
pagination: {
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -340,6 +340,21 @@ export default function GeneralPage() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const renderExpandedRow = (row: any) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<strong>Metadata:</strong>
|
||||||
|
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||||
|
{row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LogDataTable
|
<LogDataTable
|
||||||
@@ -369,6 +384,9 @@ export default function GeneralPage() {
|
|||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
defaultPageSize={pageSize}
|
defaultPageSize={pageSize}
|
||||||
|
// Row expansion props
|
||||||
|
expandable={true}
|
||||||
|
renderExpandedRow={renderExpandedRow}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -269,6 +269,21 @@ export default function GeneralPage() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const renderExpandedRow = (row: any) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<strong>Metadata:</strong>
|
||||||
|
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||||
|
{row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LogDataTable
|
<LogDataTable
|
||||||
@@ -298,6 +313,9 @@ export default function GeneralPage() {
|
|||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
defaultPageSize={pageSize}
|
defaultPageSize={pageSize}
|
||||||
|
// Row expansion props
|
||||||
|
expandable={true}
|
||||||
|
renderExpandedRow={renderExpandedRow}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export default function GeneralPage() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
|
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -402,6 +403,55 @@ export default function GeneralPage() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const renderExpandedRow = (row: any) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<strong>User Agent:</strong>
|
||||||
|
<p className="text-muted-foreground mt-1 break-all">
|
||||||
|
{row.userAgent || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Original URL:</strong>
|
||||||
|
<p className="text-muted-foreground mt-1 break-all">
|
||||||
|
{row.originalRequestURL || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Scheme:</strong>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{row.scheme || "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Metadata:</strong>
|
||||||
|
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||||
|
{row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{row.headers && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<strong>Headers:</strong>
|
||||||
|
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||||
|
{JSON.stringify(JSON.parse(row.headers), null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{row.query && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<strong>Query Parameters:</strong>
|
||||||
|
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||||
|
{JSON.stringify(JSON.parse(row.query), null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LogDataTable
|
<LogDataTable
|
||||||
@@ -431,6 +481,9 @@ export default function GeneralPage() {
|
|||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
defaultPageSize={pageSize}
|
defaultPageSize={pageSize}
|
||||||
|
// Row expansion props
|
||||||
|
expandable={true}
|
||||||
|
renderExpandedRow={renderExpandedRow}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,16 @@ import { Button } from "@app/components/ui/button";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||||
import { Plus, Search, RefreshCw, Filter, X, Download } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Filter,
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -109,6 +118,9 @@ type DataTableProps<TData, TValue> = {
|
|||||||
onPageChange?: (page: number) => void;
|
onPageChange?: (page: number) => void;
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
// Row expansion props
|
||||||
|
expandable?: boolean;
|
||||||
|
renderExpandedRow?: (row: TData) => React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LogDataTable<TData, TValue>({
|
export function LogDataTable<TData, TValue>({
|
||||||
@@ -132,7 +144,9 @@ export function LogDataTable<TData, TValue>({
|
|||||||
currentPage = 0,
|
currentPage = 0,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
onPageSizeChange: onPageSizeChangeProp,
|
onPageSizeChange: onPageSizeChangeProp,
|
||||||
isLoading = false
|
isLoading = false,
|
||||||
|
expandable = false,
|
||||||
|
renderExpandedRow
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -161,6 +175,7 @@ export function LogDataTable<TData, TValue>({
|
|||||||
dateRange?.start || {}
|
dateRange?.start || {}
|
||||||
);
|
);
|
||||||
const [endDate, setEndDate] = useState<DateTimeValue>(dateRange?.end || {});
|
const [endDate, setEndDate] = useState<DateTimeValue>(dateRange?.end || {});
|
||||||
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Sync internal date state with external dateRange prop
|
// Sync internal date state with external dateRange prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -186,25 +201,77 @@ export function LogDataTable<TData, TValue>({
|
|||||||
return data.filter(activeTabFilter.filterFn);
|
return data.filter(activeTabFilter.filterFn);
|
||||||
}, [data, tabs, activeTab]);
|
}, [data, tabs, activeTab]);
|
||||||
|
|
||||||
|
// Toggle row expansion
|
||||||
|
const toggleRowExpansion = (rowId: string) => {
|
||||||
|
setExpandedRows((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(rowId)) {
|
||||||
|
newSet.delete(rowId);
|
||||||
|
} else {
|
||||||
|
newSet.add(rowId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Determine if using server-side pagination
|
// Determine if using server-side pagination
|
||||||
const isServerPagination = totalCount !== undefined;
|
const isServerPagination = totalCount !== undefined;
|
||||||
|
|
||||||
|
// Create columns with expansion column if expandable
|
||||||
|
const enhancedColumns = useMemo(() => {
|
||||||
|
if (!expandable) {
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expansionColumn: ColumnDef<TData, TValue> = {
|
||||||
|
id: "expand",
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isExpanded = expandedRows.has(row.id);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
toggleRowExpansion(row.id);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 40
|
||||||
|
};
|
||||||
|
|
||||||
|
return [expansionColumn, ...columns];
|
||||||
|
}, [columns, expandable, expandedRows, toggleRowExpansion]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns: enhancedColumns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
// Only use client-side pagination if totalCount is not provided
|
// Only use client-side pagination if totalCount is not provided
|
||||||
...(isServerPagination ? {} : { getPaginationRowModel: getPaginationRowModel() }),
|
...(isServerPagination
|
||||||
|
? {}
|
||||||
|
: { getPaginationRowModel: getPaginationRowModel() }),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
// Configure pagination state
|
// Configure pagination state
|
||||||
...(isServerPagination ? {
|
...(isServerPagination
|
||||||
manualPagination: true,
|
? {
|
||||||
pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0,
|
manualPagination: true,
|
||||||
} : {}),
|
pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
initialState: {
|
initialState: {
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
@@ -321,10 +388,7 @@ export function LogDataTable<TData, TValue>({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onExport && (
|
{onExport && (
|
||||||
<Button
|
<Button onClick={onExport} disabled={isExporting}>
|
||||||
onClick={onExport}
|
|
||||||
disabled={isExporting}
|
|
||||||
>
|
|
||||||
<Download
|
<Download
|
||||||
className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`}
|
className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
@@ -354,48 +418,84 @@ export function LogDataTable<TData, TValue>({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => {
|
||||||
<TableRow
|
const isExpanded =
|
||||||
key={row.id}
|
expandable && expandedRows.has(row.id);
|
||||||
data-state={
|
return (
|
||||||
row.getIsSelected() && "selected"
|
<>
|
||||||
}
|
<TableRow
|
||||||
className="text-xs" // made smaller
|
key={row.id}
|
||||||
>
|
data-state={
|
||||||
{row.getVisibleCells().map((cell) => {
|
row.getIsSelected() &&
|
||||||
const originalRow =
|
"selected"
|
||||||
row.original as any;
|
}
|
||||||
const actionValue =
|
onClick={() =>
|
||||||
originalRow?.action;
|
expandable
|
||||||
let className = "";
|
? toggleRowExpansion(
|
||||||
|
row.id
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="text-xs" // made smaller
|
||||||
|
>
|
||||||
|
{row
|
||||||
|
.getVisibleCells()
|
||||||
|
.map((cell) => {
|
||||||
|
const originalRow =
|
||||||
|
row.original as any;
|
||||||
|
const actionValue =
|
||||||
|
originalRow?.action;
|
||||||
|
let className = "";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof actionValue === "boolean"
|
typeof actionValue ===
|
||||||
) {
|
"boolean"
|
||||||
className = actionValue
|
) {
|
||||||
? "bg-green-100 dark:bg-green-900/50"
|
className =
|
||||||
: "bg-red-100 dark:bg-red-900/50";
|
actionValue
|
||||||
}
|
? "bg-green-100 dark:bg-green-900/50"
|
||||||
|
: "bg-red-100 dark:bg-red-900/50";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className={`${className} py-2`} // made smaller
|
className={`${className} py-2`} // made smaller
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef
|
cell.column
|
||||||
.cell,
|
.columnDef
|
||||||
cell.getContext()
|
.cell,
|
||||||
)}
|
cell.getContext()
|
||||||
</TableCell>
|
)}
|
||||||
);
|
</TableCell>
|
||||||
})}
|
);
|
||||||
</TableRow>
|
})}
|
||||||
))
|
</TableRow>
|
||||||
|
{isExpanded &&
|
||||||
|
renderExpandedRow && (
|
||||||
|
<TableRow
|
||||||
|
key={`${row.id}-expanded`}
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
colSpan={
|
||||||
|
enhancedColumns.length
|
||||||
|
}
|
||||||
|
className="p-4 bg-muted/50"
|
||||||
|
>
|
||||||
|
{renderExpandedRow(
|
||||||
|
row.original
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={columns.length}
|
colSpan={enhancedColumns.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
No results found.
|
No results found.
|
||||||
@@ -408,7 +508,11 @@ export function LogDataTable<TData, TValue>({
|
|||||||
<DataTablePagination
|
<DataTablePagination
|
||||||
table={table}
|
table={table}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
onPageChange={isServerPagination ? handlePageChange : undefined}
|
onPageChange={
|
||||||
|
isServerPagination
|
||||||
|
? handlePageChange
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
isServerPagination={isServerPagination}
|
isServerPagination={isServerPagination}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
Reference in New Issue
Block a user