diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/ArtifactsTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/ArtifactsTable.tsx new file mode 100644 index 0000000000..6b0498456e --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/ArtifactsTable.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; +import { DataTableSortList } from "@/components/data-table/data-table-sort-list"; +import { useDataTable } from "@/hooks/use-data-table"; +import { useI18n } from "@/locales/client"; +import { Input } from "@comp/ui/input"; +import { ColumnDef } from "@tanstack/react-table"; +import { useMemo, useState } from "react"; +import type { RelatedArtifact } from "../data/getRelatedArtifacts"; +import { Card, CardTitle, CardHeader, CardContent } from "@comp/ui/card"; + +interface ArtifactsTableProps { + artifacts: RelatedArtifact[]; + orgId: string; + controlId: string; +} + +export function ArtifactsTable({ + artifacts, + orgId, + controlId, +}: ArtifactsTableProps) { + const t = useI18n(); + const [searchTerm, setSearchTerm] = useState(""); + + // Define columns for artifacts table + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => {row.original.name}, + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + return rowA.original.name.localeCompare(rowB.original.name); + }, + }, + { + accessorKey: "type", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.type} + ), + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + return rowA.original.type.localeCompare(rowB.original.type); + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {new Date(row.original.createdAt).toLocaleDateString()} + ), + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const dateA = new Date(rowA.original.createdAt); + const dateB = new Date(rowB.original.createdAt); + return dateA.getTime() - dateB.getTime(); + }, + }, + ], + [t], + ); + + // Filter artifacts data based on search term + const filteredArtifacts = useMemo(() => { + if (!searchTerm.trim()) return artifacts; + + const searchLower = searchTerm.toLowerCase(); + return artifacts.filter( + (artifact) => + artifact.id.toLowerCase().includes(searchLower) || + artifact.name.toLowerCase().includes(searchLower) || + artifact.type.toLowerCase().includes(searchLower), + ); + }, [artifacts, searchTerm]); + + // Set up the artifacts table + const table = useDataTable({ + data: filteredArtifacts, + columns, + pageCount: 1, + shallow: false, + getRowId: (row) => row.id, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + }, + tableId: "a", + }); + + return ( + + + + {t("frameworks.artifacts.title")} ({filteredArtifacts.length}) + + + +
+ setSearchTerm(e.target.value)} + className="max-w-sm" + /> +
+ +
+
+ row.id} + tableId={"a"} + /> +
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx new file mode 100644 index 0000000000..661c5f1853 --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; +import { DataTableSortList } from "@/components/data-table/data-table-sort-list"; +import { useDataTable } from "@/hooks/use-data-table"; +import { useI18n } from "@/locales/client"; +import type { FrameworkId, RequirementMap } from "@comp/db/types"; +import { Input } from "@comp/ui/input"; +import { ColumnDef } from "@tanstack/react-table"; +import { useMemo, useState } from "react"; +import { getRequirementDetails } from "../../../frameworks/lib/getRequirementDetails"; +import { Card, CardTitle, CardHeader } from "@comp/ui/card"; +import { CardContent } from "@comp/ui/card"; + +interface RequirementsTableProps { + requirements: RequirementMap[]; + orgId: string; +} + +export function RequirementsTable({ + requirements, + orgId, +}: RequirementsTableProps) { + const t = useI18n(); + const [searchTerm, setSearchTerm] = useState(""); + + // Define columns for requirements table + const columns = useMemo[]>( + () => [ + { + accessorKey: "requirementId", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const requirementId = row.original.requirementId.split("_").pop(); + return {requirementId}; + }, + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const a = rowA.original.requirementId.split("_").pop() || ""; + const b = rowB.original.requirementId.split("_").pop() || ""; + return a.localeCompare(b); + }, + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const [frameworkId, requirementId] = + row.original.requirementId.split("_"); + const details = getRequirementDetails( + frameworkId as FrameworkId, + requirementId, + ); + return {details?.name}; + }, + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const [frameworkIdA, requirementIdA] = + rowA.original.requirementId.split("_"); + const [frameworkIdB, requirementIdB] = + rowB.original.requirementId.split("_"); + + const detailsA = getRequirementDetails( + frameworkIdA as FrameworkId, + requirementIdA, + ); + const detailsB = getRequirementDetails( + frameworkIdB as FrameworkId, + requirementIdB, + ); + + const nameA = detailsA?.name || ""; + const nameB = detailsB?.name || ""; + + return nameA.localeCompare(nameB); + }, + }, + { + accessorKey: "description", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const [frameworkId, requirementId] = + row.original.requirementId.split("_"); + const details = getRequirementDetails( + frameworkId as FrameworkId, + requirementId, + ); + return ( + + {details?.description} + + ); + }, + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const [frameworkIdA, requirementIdA] = + rowA.original.requirementId.split("_"); + const [frameworkIdB, requirementIdB] = + rowB.original.requirementId.split("_"); + + const detailsA = getRequirementDetails( + frameworkIdA as FrameworkId, + requirementIdA, + ); + const detailsB = getRequirementDetails( + frameworkIdB as FrameworkId, + requirementIdB, + ); + + const descA = detailsA?.description || ""; + const descB = detailsB?.description || ""; + + return descA.localeCompare(descB); + }, + }, + ], + [t], + ); + + // Filter requirements data based on search term + const filteredRequirements = useMemo(() => { + if (!searchTerm.trim()) return requirements; + + const searchLower = searchTerm.toLowerCase(); + return requirements.filter((req) => { + const [frameworkId, requirementId] = req.requirementId.split("_"); + const details = getRequirementDetails( + frameworkId as FrameworkId, + requirementId, + ); + + // Search in ID, name, and description + return ( + requirementId.toLowerCase().includes(searchLower) || + details?.name?.toLowerCase().includes(searchLower) || + false || + details?.description?.toLowerCase().includes(searchLower) || + false + ); + }); + }, [requirements, searchTerm]); + + // Set up the requirements table + const table = useDataTable({ + data: filteredRequirements, + columns, + pageCount: 1, + shallow: false, + getRowId: (row) => row.id, + initialState: { + sorting: [{ id: "requirementId", desc: false }], + }, + tableId: "r", + }); + + return ( + + + + {t("frameworks.requirements.title")} ({filteredRequirements.length}) + + + +
+ setSearchTerm(e.target.value)} + className="max-w-sm" + /> +
+ +
+
+ { + const [_, requirementId] = row.requirementId.split("_"); + return `${row.frameworkInstanceId}/requirements/${requirementId}`; + }} + tableId={"r"} + /> +
+
+ ); +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/SingleControl.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/SingleControl.tsx index a7c0e5df3e..4e728d5867 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/SingleControl.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/components/SingleControl.tsx @@ -1,34 +1,36 @@ "use client"; import { DisplayFrameworkStatus } from "@/components/frameworks/framework-status"; -import type { Control, RequirementMap } from "@comp/db/types"; +import type { Control } from "@comp/db/types"; import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@comp/ui/table"; import { useMemo } from "react"; import type { ControlProgressResponse } from "../data/getOrganizationControlProgress"; import { SingleControlSkeleton } from "./SingleControlSkeleton"; import { useI18n } from "@/locales/client"; -import { RequirementRow } from "./RequirementRow"; +import { useParams } from "next/navigation"; +import { RequirementsTable } from "./RequirementsTable"; +import { ArtifactsTable } from "./ArtifactsTable"; +import type { RelatedArtifact } from "../data/getRelatedArtifacts"; +import { Separator } from "@comp/ui/separator"; interface SingleControlProps { control: Control & { - requirementsMapped: RequirementMap[]; + requirementsMapped: any[]; }; controlProgress: ControlProgressResponse; + relatedArtifacts: RelatedArtifact[]; } export const SingleControl = ({ control, controlProgress, + relatedArtifacts = [], }: SingleControlProps) => { const t = useI18n(); + const params = useParams(); + const orgId = params.orgId as string; + const controlId = params.controlId as string; + const progressStatus = useMemo(() => { if (!controlProgress) return "not_started"; @@ -62,51 +64,17 @@ export const SingleControl = ({ - - - - {t("frameworks.requirements.title")} ( - {control.requirementsMapped.length}) - - - -
-
- - - - - {t("frameworks.requirements.table.id")} - - - {t("frameworks.requirements.table.name")} - - - {t("frameworks.requirements.table.description")} - - - - - {control.requirementsMapped.length > 0 ? ( - control.requirementsMapped.map((requirement) => ( - - )) - ) : ( - - - {t("controls.requirements.no_requirements_mapped")} - - - )} - -
-
-
-
-
+ + + + ); }; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/data/getRelatedArtifacts.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/data/getRelatedArtifacts.ts new file mode 100644 index 0000000000..e8221985ba --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/data/getRelatedArtifacts.ts @@ -0,0 +1,79 @@ +"use server"; + +import { auth } from "@comp/auth"; +import { db } from "@comp/db"; +import { headers } from "next/headers"; +import { cache } from "react"; + +export interface RelatedArtifact { + id: string; + name: string; + type: string; + createdAt: string; +} + +interface GetRelatedArtifactsParams { + organizationId: string; + controlId: string; +} + +export const getRelatedArtifacts = cache( + async ({ + organizationId, + controlId, + }: GetRelatedArtifactsParams): Promise => { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session || !session.session.activeOrganizationId) { + return []; + } + + // Fetch the control with its artifacts + const control = await db.control.findUnique({ + where: { + id: controlId, + organizationId: organizationId, + }, + include: { + artifacts: { + include: { + policy: true, + evidence: true, + }, + }, + }, + }); + + if (!control || !control.artifacts) { + return []; + } + + // Transform the artifacts into the format expected by the UI + return control.artifacts.map((artifact) => { + let name = "Unknown"; + let displayType = artifact.type; + + if (artifact.policy) { + name = artifact.policy.name; + displayType = "policy"; + } else if (artifact.evidence) { + name = artifact.evidence.name; + displayType = "evidence"; + } + + return { + id: artifact.id, + name, + type: displayType, + createdAt: artifact.createdAt.toISOString(), + }; + }); + } catch (error) { + console.error("Error fetching related artifacts:", error); + return []; + } + }, +); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/page.tsx index 76e5f59141..044cc750ff 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/[controlId]/page.tsx @@ -6,13 +6,19 @@ import { getOrganizationControlProgress } from "./data/getOrganizationControlPro import type { ControlProgressResponse } from "./data/getOrganizationControlProgress"; import { headers } from "next/headers"; import PageWithBreadcrumb from "@/components/pages/PageWithBreadcrumb"; +import { getRelatedArtifacts } from "./data/getRelatedArtifacts"; -interface PageProps { - params: Promise<{ controlId: string }>; +interface ControlPageProps { + params: { + controlId: string; + orgId: string; + locale: string; + }; } -export default async function SingleControlPage({ params }: PageProps) { - const { controlId } = await params; +export default async function ControlPage({ params }: ControlPageProps) { + // Await params before using them + const { controlId, orgId, locale } = await Promise.resolve(params); const session = await auth.api.getSession({ headers: await headers(), @@ -42,17 +48,23 @@ export default async function SingleControlPage({ params }: PageProps) { byType: {}, }; + const relatedArtifacts = await getRelatedArtifacts({ + organizationId: orgId, + controlId: controlId, + }); + return ( - + ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/components/controls-table-columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/components/controls-table-columns.tsx index ddcb6e49fd..12eff489ba 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/components/controls-table-columns.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/components/controls-table-columns.tsx @@ -1,74 +1,103 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { Control, Artifact, Policy, Evidence, RequirementMap } from "@comp/db/types"; import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; import { DisplayFrameworkStatus } from "@/components/frameworks/framework-status"; +import { + Artifact, + Control, + FrameworkInstance, + RequirementMap, +} from "@comp/db/types"; +import { Badge } from "@comp/ui/badge"; +import { ColumnDef } from "@tanstack/react-table"; +import { getFrameworkDetails } from "../../frameworks/lib/getFrameworkDetails"; import { getControlStatus } from "../lib/utils"; -export function getControlColumns(): ColumnDef[] { - return [ - { - id: "name", - accessorKey: "name", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( -
- - {row.getValue("name")} - -
- ); - }, - meta: { - label: "Control Name", - placeholder: "Search for a control...", - variant: "text", - }, - enableColumnFilter: true, - }, - { - id: "status", - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const control = row.original; - const status = getControlStatus(control); +export function getControlColumns(): ColumnDef< + Control & { + artifacts: (Artifact & { + policy: { status: string } | null; + evidence: { published: boolean } | null; + })[]; + requirementsMapped: (RequirementMap & { + frameworkInstance: FrameworkInstance; + })[]; + } +>[] { + return [ + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( +
+ + {row.getValue("name")} + +
+ ); + }, + meta: { + label: "Control Name", + placeholder: "Search for a control...", + variant: "text", + }, + enableColumnFilter: true, + }, + { + id: "status", + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const control = row.original; + const status = getControlStatus(control); + + return ; + }, + meta: { + label: "Status", + placeholder: "Search status...", + variant: "select", + }, + }, + { + id: "mappedRequirements", + accessorKey: "mappedRequirements", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const control = row.original; - return ; - }, - meta: { - label: "Status", - placeholder: "Search status...", - variant: "select", - }, - }, - { - id: "mappedRequirements", - accessorKey: "mappedRequirements", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const control = row.original; - return
{control.requirementsMapped.length}
; - }, - meta: { - label: "Mapped Requirements", - placeholder: "Search mapped requirements...", - variant: "text", - }, - }, - ]; + return ( +
+ {control.requirementsMapped.length > 0 ? ( + control.requirementsMapped.map((req) => { + const frameworkName = getFrameworkDetails( + req.frameworkInstance.frameworkId, + ).name; + return ( + + {frameworkName}: {req.requirementId.split("_").pop()} + + ); + }) + ) : ( + None + )} +
+ ); + }, + meta: { + label: "Mapped Requirements", + placeholder: "Search mapped requirements...", + variant: "text", + }, + }, + ]; } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/data/queries.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/data/queries.ts index 3023d564f5..de18d4534d 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/data/queries.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/data/queries.ts @@ -56,7 +56,11 @@ export async function getControls(input: GetControlSchema) { }, }, }, - requirementsMapped: true, + requirementsMapped: { + include: { + frameworkInstance: true, + }, + }, }, }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/page.tsx index c97d6fcf9d..513414bc4c 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/controls/page.tsx @@ -31,7 +31,7 @@ export default async function ControlsPage({ ]); return ( - + ); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx index 9d3cc7238c..a0d092eb87 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx @@ -47,7 +47,7 @@ export default async function FrameworkPage({ params }: PageProps) {
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx index e05a7d1cc1..0a181d8f52 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx @@ -71,7 +71,11 @@ export default async function RequirementPage({ params }: PageProps) { label: frameworkName, href: `/${organizationId}/frameworks/${frameworkInstanceId}`, }, - { label: requirement.name, dropdown: siblingRequirementsDropdown }, + { + label: requirement.name, + dropdown: siblingRequirementsDropdown, + current: true, + }, ]} >
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/page.tsx index 56997e07a8..6e2b1eacaa 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/frameworks/page.tsx @@ -31,7 +31,7 @@ export default async function DashboardPage() { }); return ( - + ); diff --git a/apps/app/src/components/data-table/data-table-column-header.tsx b/apps/app/src/components/data-table/data-table-column-header.tsx index bf5c90734c..2e432591d0 100644 --- a/apps/app/src/components/data-table/data-table-column-header.tsx +++ b/apps/app/src/components/data-table/data-table-column-header.tsx @@ -45,21 +45,41 @@ export function DataTableColumnHeader({ > {title} {column.getCanSort() && - (column.getIsSorted() === "desc" ? ( - - ) : column.getIsSorted() === "asc" ? ( - - ) : ( - - ))} + (() => { + try { + const sortDirection = column.getIsSorted(); + if (sortDirection === "desc") { + return ; + } + if (sortDirection === "asc") { + return ; + } + return ; + } catch (e) { + // If there's an error with sorting state, show the default unsorted icon + return ; + } + })()} {column.getCanSort() && ( <> column.toggleSorting(false)} + checked={(() => { + try { + return column.getIsSorted() === "asc"; + } catch (e) { + return false; + } + })()} + onClick={() => { + try { + column.toggleSorting(false); + } catch (e) { + console.error("Error toggling sort:", e); + } + }} >
@@ -68,25 +88,52 @@ export function DataTableColumnHeader({ column.toggleSorting(true)} + checked={(() => { + try { + return column.getIsSorted() === "desc"; + } catch (e) { + return false; + } + })()} + onClick={() => { + try { + column.toggleSorting(true); + } catch (e) { + console.error("Error toggling sort:", e); + } + }} >
Descend
- {column.getIsSorted() && ( - column.clearSorting()} - > -
- - Reset -
-
- )} + {(() => { + try { + if (column.getIsSorted()) { + return ( + { + try { + column.clearSorting(); + } catch (e) { + console.error("Error clearing sort:", e); + } + }} + > +
+ + Reset +
+
+ ); + } + return null; + } catch (e) { + return null; + } + })()} )} {column.getCanHide() && ( diff --git a/apps/app/src/components/data-table/data-table-pagination.tsx b/apps/app/src/components/data-table/data-table-pagination.tsx index b187aedcc6..3bace2dcb8 100644 --- a/apps/app/src/components/data-table/data-table-pagination.tsx +++ b/apps/app/src/components/data-table/data-table-pagination.tsx @@ -1,113 +1,189 @@ import type { Table } from "@tanstack/react-table"; import { - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, } from "lucide-react"; +import { useQueryState } from "nuqs"; +import * as React from "react"; import { Button } from "@comp/ui/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@comp/ui/select"; import { cn } from "@comp/ui/cn"; interface DataTablePaginationProps extends React.ComponentProps<"div"> { - table: Table; - pageSizeOptions?: number[]; + table: Table; + pageSizeOptions?: number[]; + tableId?: string; } export function DataTablePagination({ - table, - pageSizeOptions = [10, 20, 30, 40, 50], - className, - ...props + table, + pageSizeOptions = [10, 20, 30, 40, 50], + tableId, + className, + ...props }: DataTablePaginationProps) { - return ( -
-
-

- {table.getCoreRowModel().rows.length} items -

-
-
-
-

Rows per page

- -
-
- Page {table.getState().pagination.pageIndex + 1} of{" "} - {table.getPageCount()} -
-
- - - - -
-
-
- ); + const pageParam = tableId ? `${tableId}_page` : "page"; + const perPageParam = tableId ? `${tableId}_perPage` : "perPage"; + + const [page, setPage] = useQueryState(pageParam); + const [perPage, setPerPage] = useQueryState(perPageParam); + + // Parse URL query params + const pageIndex = React.useMemo(() => { + if (!page) return table.getState().pagination.pageIndex; + try { + const parsed = Number.parseInt(page, 10); + return Number.isNaN(parsed) + ? table.getState().pagination.pageIndex + : parsed - 1; + } catch (e) { + return table.getState().pagination.pageIndex; + } + }, [page, table]); + + const pageSizeValue = React.useMemo(() => { + if (!perPage) return table.getState().pagination.pageSize; + try { + const parsed = Number.parseInt(perPage, 10); + return Number.isNaN(parsed) + ? table.getState().pagination.pageSize + : parsed; + } catch (e) { + return table.getState().pagination.pageSize; + } + }, [perPage, table]); + + // Use effect to update table pagination when query params change + React.useEffect(() => { + if (pageIndex !== table.getState().pagination.pageIndex) { + table.setPageIndex(pageIndex); + } + + if (pageSizeValue !== table.getState().pagination.pageSize) { + table.setPageSize(pageSizeValue); + } + }, [pageIndex, pageSizeValue, table]); + + // Sync URL with table state on initial render + React.useEffect(() => { + const currentPageIndex = table.getState().pagination.pageIndex; + const currentPageSize = table.getState().pagination.pageSize; + + if (page === undefined && currentPageIndex > 0) { + setPage((currentPageIndex + 1).toString()); + } + + if (perPage === undefined && currentPageSize !== 10) { + setPerPage(currentPageSize.toString()); + } + }, [table, page, perPage, setPage, setPerPage]); + + // Handlers that update both table state and URL query params + const handlePageChange = React.useCallback( + (newPage: number) => { + setPage((newPage + 1).toString()); + table.setPageIndex(newPage); + }, + [setPage, table], + ); + + const handlePageSizeChange = React.useCallback( + (value: string) => { + const newSize = Number(value); + setPerPage(value); + table.setPageSize(newSize); + }, + [setPerPage, table], + ); + + return ( +
+
+

+ {table.getCoreRowModel().rows.length} items +

+
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); } diff --git a/apps/app/src/components/data-table/data-table-sort-list.tsx b/apps/app/src/components/data-table/data-table-sort-list.tsx index 35bf114f04..40f517f05a 100644 --- a/apps/app/src/components/data-table/data-table-sort-list.tsx +++ b/apps/app/src/components/data-table/data-table-sort-list.tsx @@ -8,6 +8,7 @@ import { Trash2, } from "lucide-react"; import * as React from "react"; +import { useQueryState } from "nuqs"; import { Badge } from "@comp/ui/badge"; import { Button } from "@comp/ui/button"; @@ -43,10 +44,12 @@ const REMOVE_SORT_SHORTCUTS = ["backspace", "delete"]; interface DataTableSortListProps extends React.ComponentProps { table: Table; + tableId?: string; } export function DataTableSortList({ table, + tableId, ...props }: DataTableSortListProps) { const id = React.useId(); @@ -55,8 +58,91 @@ export function DataTableSortList({ const [open, setOpen] = React.useState(false); const addButtonRef = React.useRef(null); - const sorting = table.getState().sorting; - const onSortingChange = table.setSorting; + const sortParam = tableId ? `${tableId}_sort` : "sort"; + const [urlSorting, setUrlSorting] = useQueryState(sortParam); + + // Parse the URL sorting state + const parsedSorting = React.useMemo(() => { + try { + if (!urlSorting) return []; + + // Check if urlSorting is already an object (this can happen with nuqs) + if (typeof urlSorting === "object" && urlSorting !== null) { + // If it's already an array, validate its structure + if (Array.isArray(urlSorting)) { + const sortArray = urlSorting as unknown[]; + return sortArray.every( + (item: unknown) => + typeof item === "object" && + item !== null && + "id" in (item as object) && + "desc" in (item as object), + ) + ? (sortArray as ColumnSort[]) + : []; + } + return []; + } + + // Parse the string if it's a string + if (typeof urlSorting === "string") { + const parsed = JSON.parse(urlSorting); + // Validate that we have a proper array of ColumnSort objects + if ( + Array.isArray(parsed) && + parsed.every( + (item) => + typeof item === "object" && + item !== null && + "id" in item && + "desc" in item && + typeof item.id === "string", + ) + ) { + return parsed as ColumnSort[]; + } + } + + return []; + } catch (e) { + console.error("Error parsing sort state:", e); + return []; + } + }, [urlSorting]); + + // Use URL sorting if available, otherwise use table state + const sorting = React.useMemo(() => { + return parsedSorting.length > 0 + ? parsedSorting + : table.getState().sorting || []; + }, [parsedSorting, table]); + + // Custom sorting change handler that updates both table and URL + const onSortingChange = React.useCallback( + (updater: ColumnSort[] | ((prev: ColumnSort[]) => ColumnSort[])) => { + // Update table sorting + table.setSorting(updater); + + // Update URL sorting + const newSorting = + typeof updater === "function" ? updater(sorting) : updater; + + // Only set URL if there's something to save + if (newSorting.length > 0) { + // Convert to a proper JSON string + try { + const stringified = JSON.stringify(newSorting); + setUrlSorting(stringified); + } catch (e) { + console.error("Error stringifying sort state:", e); + setUrlSorting(null); + } + } else { + setUrlSorting(null); + } + }, + [table, sorting, setUrlSorting], + ); const { columnLabels, columns } = React.useMemo(() => { const labels = new Map(); @@ -66,7 +152,18 @@ export function DataTableSortList({ for (const column of table.getAllColumns()) { if (!column.getCanSort()) continue; - const label = column.columnDef.meta?.label ?? column.id; + // Debug column definitions + console.log(`Column ID: ${column.id}`, { + meta: column.columnDef.meta, + def: column.columnDef, + }); + + // Use a safe way to get the label + let label = column.columnDef.meta?.label; + if (!label) { + // Try to get accessorKey if available + label = column.id; + } labels.set(column.id, label); if (!sortingIds.has(column.id)) { @@ -74,6 +171,11 @@ export function DataTableSortList({ } } + // Debug all labels and available columns + console.log("Column Labels:", Object.fromEntries(labels)); + console.log("Available Columns:", availableColumns); + console.log("Current Sorting:", sorting); + return { columnLabels: labels, columns: availableColumns, @@ -93,7 +195,7 @@ export function DataTableSortList({ const onSortUpdate = React.useCallback( (sortId: string, updates: Partial) => { onSortingChange((prevSorting) => { - if (!prevSorting) return prevSorting; + if (!prevSorting.length) return prevSorting; return prevSorting.map((sort) => sort.id === sortId ? { ...sort, ...updates } : sort, ); @@ -111,10 +213,21 @@ export function DataTableSortList({ [onSortingChange], ); - const onSortingReset = React.useCallback( - () => onSortingChange(table.initialState.sorting), - [onSortingChange, table.initialState.sorting], - ); + const onSortingReset = React.useCallback(() => { + const initialSorting = table.initialState.sorting || []; + onSortingChange(initialSorting); + }, [onSortingChange, table.initialState.sorting]); + + // Sync table sorting with URL on component mount + React.useEffect(() => { + if (parsedSorting.length > 0) { + // Only update if different to avoid unnecessary renders + const currentSorting = table.getState().sorting; + if (JSON.stringify(parsedSorting) !== JSON.stringify(currentSorting)) { + table.setSorting(parsedSorting); + } + } + }, [parsedSorting, table]); React.useEffect(() => { function onKeyDown(event: KeyboardEvent) { @@ -319,7 +432,9 @@ function DataTableSortItem({ size="sm" className="w-44 justify-between font-normal" > - {columnLabels.get(sort.id)} + + {columnLabels.get(sort.id) || sort.id || "Unknown column"} + diff --git a/apps/app/src/components/data-table/data-table.tsx b/apps/app/src/components/data-table/data-table.tsx index 672f4642ce..2f6ea6bc32 100644 --- a/apps/app/src/components/data-table/data-table.tsx +++ b/apps/app/src/components/data-table/data-table.tsx @@ -13,12 +13,14 @@ import { TableRow, } from "@comp/ui/table"; import { DataTablePagination } from "./data-table-pagination"; +import { DataTableSortList } from "./data-table-sort-list"; interface DataTableProps extends React.ComponentProps<"div"> { table: TanstackTable; actionBar?: React.ReactNode; getRowId?: (row: TData) => string; rowClickBasePath: string; + tableId?: string; } export function DataTable({ @@ -28,6 +30,7 @@ export function DataTable({ className, getRowId, rowClickBasePath, + tableId, ...props }: DataTableProps) { const router = useRouter(); @@ -116,7 +119,7 @@ export function DataTable({
- + {actionBar && table.getFilteredSelectedRowModel().rows.length > 0 && actionBar} diff --git a/apps/app/src/components/pages/PageWithBreadcrumb.tsx b/apps/app/src/components/pages/PageWithBreadcrumb.tsx index 91abbb9117..9d0eb792d1 100644 --- a/apps/app/src/components/pages/PageWithBreadcrumb.tsx +++ b/apps/app/src/components/pages/PageWithBreadcrumb.tsx @@ -69,9 +69,20 @@ export default function PageWithBreadcrumb({ {item.dropdown ? ( - - {item.label} - + + {item.current ? ( + + {item.label} + + + ) : ( + <> + {item.label} + + + )} {item.dropdown.map((dropdownItem) => ( diff --git a/apps/app/src/hooks/use-data-table.ts b/apps/app/src/hooks/use-data-table.ts index 62d458fb4a..7c5e29d990 100644 --- a/apps/app/src/hooks/use-data-table.ts +++ b/apps/app/src/hooks/use-data-table.ts @@ -62,6 +62,7 @@ interface UseDataTableProps scroll?: boolean; shallow?: boolean; startTransition?: React.TransitionStartFunction; + tableId?: string; } export function useDataTable(props: UseDataTableProps) { @@ -77,6 +78,7 @@ export function useDataTable(props: UseDataTableProps) { scroll = false, shallow = true, startTransition, + tableId, ...tableProps } = props; @@ -109,12 +111,17 @@ export function useDataTable(props: UseDataTableProps) { const [columnVisibility, setColumnVisibility] = React.useState(initialState?.columnVisibility ?? {}); + // Use tableId prefix for URL parameters if provided + const pageKey = tableId ? `${tableId}_${PAGE_KEY}` : PAGE_KEY; + const perPageKey = tableId ? `${tableId}_${PER_PAGE_KEY}` : PER_PAGE_KEY; + const sortKey = tableId ? `${tableId}_${SORT_KEY}` : SORT_KEY; + const [page, setPage] = useQueryState( - PAGE_KEY, + pageKey, parseAsInteger.withOptions(queryStateOptions).withDefault(1), ); const [perPage, setPerPage] = useQueryState( - PER_PAGE_KEY, + perPageKey, parseAsInteger .withOptions(queryStateOptions) .withDefault(initialState?.pagination?.pageSize ?? 10), @@ -148,7 +155,7 @@ export function useDataTable(props: UseDataTableProps) { }, [columns]); const [sorting, setSorting] = useQueryState( - SORT_KEY, + sortKey, getSortingStateParser(columnIds) .withOptions(queryStateOptions) .withDefault(initialState?.sorting ?? []), @@ -288,7 +295,7 @@ export function useDataTable(props: UseDataTableProps) { getFacetedUniqueValues: getFacetedUniqueValues(), getFacetedMinMaxValues: getFacetedMinMaxValues(), manualPagination: true, - manualSorting: true, + manualSorting: pageCount !== 1, manualFiltering: true, }); diff --git a/apps/app/src/locales/features/controls.ts b/apps/app/src/locales/features/controls.ts index 67432911c0..f58e4791a6 100644 --- a/apps/app/src/locales/features/controls.ts +++ b/apps/app/src/locales/features/controls.ts @@ -13,4 +13,7 @@ export const controls = { requirements: { no_requirements_mapped: "No requirements mapped to this control.", }, + artifacts: { + no_artifacts: "No related artifacts found", + }, } as const; diff --git a/apps/app/src/locales/features/frameworks.ts b/apps/app/src/locales/features/frameworks.ts index bac94c5e15..19e7cfc779 100644 --- a/apps/app/src/locales/features/frameworks.ts +++ b/apps/app/src/locales/features/frameworks.ts @@ -56,5 +56,27 @@ export const frameworks = { name: "Name", description: "Description", }, + search: { + id_placeholder: "Search by ID...", + name_placeholder: "Search by name...", + description_placeholder: "Search in description...", + universal_placeholder: "Search requirements...", + }, + }, + artifacts: { + title: "Related Artifacts", + table: { + id: "ID", + name: "Name", + type: "Type", + created_at: "Created At", + }, + search: { + id_placeholder: "Search by ID...", + name_placeholder: "Search by name...", + type_placeholder: "Filter by type...", + universal_placeholder: "Search artifacts...", + }, + no_artifacts: "No related artifacts found", }, } as const;