Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,36 +1,39 @@
'use client';

import type { StatusType } from '@/components/status-indicator';
import {
type EvidenceSubmissionInfo,
type RequirementArtifactCounts,
getControlProgressPercent,
getControlStatus,
getRequirementArtifactCounts,
getRequirementCompliancePercent,
getRequirementStatus,
} from '@/lib/control-compliance';
import type { FrameworkInstanceWithControls } from '@/lib/types/framework';
import type { Control, FrameworkEditorRequirement, Task } from '@db';
import {
Badge,

InputGroup,
InputGroupAddon,
InputGroupInput,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Text,
} from '@trycompai/design-system';
import { Search } from '@trycompai/design-system/icons';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import {
REQUIREMENTS_TABLE_COLUMN_COUNT,
REQUIREMENTS_TABLE_STYLE,
RequirementsTableColumnGroup,
RequirementsTableHeader,
} from './requirements-table-layout';

const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
import type { FrameworkInstanceWithControls } from '@/lib/types/framework';
import {
type EvidenceSubmissionInfo,
type RequirementArtifactCounts,
getControlProgressPercent,
getControlStatus,
getRequirementArtifactCounts,
getRequirementCompliancePercent,
getRequirementStatus,
} from '@/lib/control-compliance';
import type { StatusType } from '@/components/status-indicator';

interface RequirementItem extends FrameworkEditorRequirement {
mappedControlsCount: number;
Expand Down Expand Up @@ -109,9 +112,10 @@ export function FrameworkRequirements({
}, [requirementDefinitions, frameworkInstanceWithControls.controls, tasks, evidenceSubmissions]);

const sortedItems = useMemo(
() => [...items].sort((a, b) =>
(a.identifier ?? '').localeCompare(b.identifier ?? '', undefined, { numeric: true }),
),
() =>
[...items].sort((a, b) =>
(a.identifier ?? '').localeCompare(b.identifier ?? '', undefined, { numeric: true }),
),
[items],
);

Expand Down Expand Up @@ -151,12 +155,15 @@ export function FrameworkRequirements({
<InputGroupInput
placeholder="Search requirements..."
value={searchTerm}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(event.target.value)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(event.target.value)
}
/>
</InputGroup>
</div>
<Table
variant="bordered"
style={REQUIREMENTS_TABLE_STYLE}
pagination={{
page,
pageCount,
Expand All @@ -169,23 +176,12 @@ export function FrameworkRequirements({
},
}}
>
<TableHeader>
<TableRow>
<TableHead>Identifier</TableHead>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Compliance</TableHead>
<TableHead>Status</TableHead>
<TableHead>Controls</TableHead>
<TableHead>Policies</TableHead>
<TableHead>Tasks</TableHead>
<TableHead>Documents</TableHead>
</TableRow>
</TableHeader>
<RequirementsTableColumnGroup />
<RequirementsTableHeader />
<TableBody>
{paginatedItems.length === 0 ? (
<TableRow>
<TableCell colSpan={9}>
<TableCell colSpan={REQUIREMENTS_TABLE_COLUMN_COUNT}>
<Text size="sm" variant="muted">
No requirements found.
</Text>
Expand Down Expand Up @@ -214,24 +210,18 @@ export function FrameworkRequirements({
<span className="text-sm">{identifier || '—'}</span>
</TableCell>
<TableCell>
<span
className="block max-w-[280px] truncate text-sm"
title={item.name}
>
<span className="block truncate text-sm" title={item.name}>
{item.name}
</span>
</TableCell>
<TableCell>
<span
className="block max-w-[240px] truncate text-sm"
title={item.description || ''}
>
<span className="block truncate text-sm" title={item.description || ''}>
{item.description || '—'}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 min-w-[100px]">
<div className="flex-1 rounded-full bg-muted/50 h-1.5">
<div className="flex min-w-0 items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 rounded-full bg-muted/50">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${item.compliancePercent}%` }}
Expand All @@ -257,7 +247,8 @@ export function FrameworkRequirements({
<TableCell>
<div className="tabular-nums">
<Text size="sm" variant="muted">
{item.artifactCounts.policies.completed}/{item.artifactCounts.policies.total}
{item.artifactCounts.policies.completed}/
{item.artifactCounts.policies.total}
</Text>
</div>
</TableCell>
Expand All @@ -271,7 +262,8 @@ export function FrameworkRequirements({
<TableCell>
<div className="tabular-nums">
<Text size="sm" variant="muted">
{item.artifactCounts.documents.completed}/{item.artifactCounts.documents.total}
{item.artifactCounts.documents.completed}/
{item.artifactCounts.documents.total}
</Text>
</div>
</TableCell>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,33 @@ import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Text,
} from '@trycompai/design-system';
import { ChevronDown, ChevronRight, Search } from '@trycompai/design-system/icons';
import { useParams, useRouter } from 'next/navigation';
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
import { useCallback, useMemo, useState } from 'react';
import { FamilyFilterDropdown } from './FamilyFilterDropdown';
import {
areAllFamiliesExpanded,
isFamilyExpanded,
toggleAllFamilyExpansion,
toggleFamilyExpansion,
} from './family-expansion-state';
import { FamilyFilterDropdown } from './FamilyFilterDropdown';
import {
buildRequirementItems,
getFamilyDisplayLabel,
groupRequirementsByFamily,
type RequirementFamilyGroup,
} from './framework-controls-shared';
import { GroupedRequirementRow } from './GroupedRequirementRow';

const COLUMN_COUNT = 9;
import {
REQUIREMENTS_TABLE_COLUMN_COUNT,
REQUIREMENTS_TABLE_STYLE,
RequirementsTableColumnGroup,
RequirementsTableHeader,
} from './requirements-table-layout';

export function FrameworkRequirementsGrouped({
requirementDefinitions,
Expand All @@ -48,7 +50,10 @@ export function FrameworkRequirementsGrouped({
tasks?: (Task & { controls: Control[] })[];
evidenceSubmissions?: EvidenceSubmissionInfo[];
}) {
const { orgId, frameworkInstanceId } = useParams<{ orgId: string; frameworkInstanceId: string }>();
const { orgId, frameworkInstanceId } = useParams<{
orgId: string;
frameworkInstanceId: string;
}>();
const router = useRouter();

const handleRowClick = useCallback(
Expand All @@ -58,19 +63,26 @@ export function FrameworkRequirementsGrouped({
[orgId, frameworkInstanceId, router],
);

const [searchTerm, setSearchTerm] = useQueryState('rq', parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 }));
const [familyFilterParam, setFamilyFilterParam] = useQueryState('rfamilies', parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true }));
const [searchTerm, setSearchTerm] = useQueryState(
'rq',
parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 }),
);
const [familyFilterParam, setFamilyFilterParam] = useQueryState(
'rfamilies',
parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true }),
);
const [expandedFamilies, setExpandedFamilies] = useState<Set<string>>(new Set());

const selectedFamilyFilter = useMemo(() => new Set(familyFilterParam), [familyFilterParam]);

const allItems = useMemo(
() => buildRequirementItems(
requirementDefinitions,
frameworkInstanceWithControls.controls,
tasks ?? [],
evidenceSubmissions,
),
() =>
buildRequirementItems(
requirementDefinitions,
frameworkInstanceWithControls.controls,
tasks ?? [],
evidenceSubmissions,
),
[requirementDefinitions, frameworkInstanceWithControls.controls, tasks, evidenceSubmissions],
);

Expand All @@ -93,7 +105,10 @@ export function FrameworkRequirementsGrouped({
}, [allGroups, selectedFamilyFilter]);

const allFamilyNames = useMemo(() => allGroups.map((g) => g.family), [allGroups]);
const familyCounts = useMemo(() => new Map(allGroups.map((g) => [g.family, g.items.length])), [allGroups]);
const familyCounts = useMemo(
() => new Map(allGroups.map((g) => [g.family, g.items.length])),
[allGroups],
);

const isSearching = searchTerm.trim().length > 0;
const visibleFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]);
Expand All @@ -103,9 +118,7 @@ export function FrameworkRequirementsGrouped({
});

const handleToggleFamily = (family: string) => {
setExpandedFamilies((prev) =>
toggleFamilyExpansion({ expandedFamilies: prev, family }),
);
setExpandedFamilies((prev) => toggleFamilyExpansion({ expandedFamilies: prev, family }));
};

const handleToggleAll = () => {
Expand Down Expand Up @@ -164,24 +177,13 @@ export function FrameworkRequirementsGrouped({
</Button>
)}
</div>
<Table variant="bordered">
<TableHeader>
<TableRow>
<TableHead>Identifier</TableHead>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Compliance</TableHead>
<TableHead>Status</TableHead>
<TableHead>Controls</TableHead>
<TableHead>Policies</TableHead>
<TableHead>Tasks</TableHead>
<TableHead>Documents</TableHead>
</TableRow>
</TableHeader>
<Table variant="bordered" style={REQUIREMENTS_TABLE_STYLE}>
<RequirementsTableColumnGroup />
<RequirementsTableHeader />
<TableBody>
{groups.length === 0 ? (
<TableRow>
<TableCell colSpan={COLUMN_COUNT}>
<TableCell colSpan={REQUIREMENTS_TABLE_COLUMN_COUNT}>
<Text size="sm" variant="muted">
No requirements found.
</Text>
Expand Down Expand Up @@ -226,7 +228,7 @@ function RequirementFamilySection({
return (
<>
<TableRow data-state="selected">
<TableCell colSpan={COLUMN_COUNT}>
<TableCell colSpan={REQUIREMENTS_TABLE_COLUMN_COUNT}>
<button
type="button"
className="flex w-full items-center gap-2 py-1 text-left font-medium cursor-pointer"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,18 @@ export function GroupedRequirementRow({
</Link>
</TableCell>
<TableCell>
<span className="block max-w-[280px] truncate text-sm" title={item.name}>
<span className="block truncate text-sm" title={item.name}>
{item.name}
</span>
</TableCell>
<TableCell>
<span
className="block max-w-[240px] truncate text-sm"
title={item.description || ''}
>
<span className="block truncate text-sm" title={item.description || ''}>
{item.description || '—'}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 min-w-[100px]">
<div className="flex-1 rounded-full bg-muted/50 h-1.5">
<div className="flex min-w-0 items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 rounded-full bg-muted/50">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${item.compliancePercent}%` }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import {
REQUIREMENTS_TABLE_COLUMN_COUNT,
REQUIREMENTS_TABLE_STYLE,
RequirementsTableColumnGroup,
RequirementsTableHeader,
} from './requirements-table-layout';

describe('requirements table layout', () => {
it('defines a compact column for every visible requirement field', () => {
const { container } = render(
<table>
<RequirementsTableColumnGroup />
<RequirementsTableHeader />
</table>,
);

expect(container.querySelectorAll('col')).toHaveLength(REQUIREMENTS_TABLE_COLUMN_COUNT);
expect(screen.getByRole('columnheader', { name: 'Identifier' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Description' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Controls' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Compliance' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Status' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Docs' })).toHaveAttribute(
'title',
'Documents',
);
expect(REQUIREMENTS_TABLE_STYLE).toMatchObject({ tableLayout: 'fixed' });
});
});
Loading
Loading