diff --git a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts index 094d47d90c..18c3a49061 100644 --- a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts +++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts @@ -60,6 +60,7 @@ export async function buildManifestForFramework(frameworkId: string): Promise r.id) .filter((id) => ownRequirementIds.has(id)), diff --git a/apps/api/src/framework-editor/control-template/control-template.service.ts b/apps/api/src/framework-editor/control-template/control-template.service.ts index 44be100d1e..63d0251a1d 100644 --- a/apps/api/src/framework-editor/control-template/control-template.service.ts +++ b/apps/api/src/framework-editor/control-template/control-template.service.ts @@ -105,6 +105,7 @@ export class ControlTemplateService { data: { name: dto.name, description: dto.description ?? '', + controlFamily: dto.controlFamily || null, }, }); this.logger.log(`Created control template: ${ct.name} (${ct.id})`); @@ -120,6 +121,7 @@ export class ControlTemplateService { data: { name: dto.name, description: dto.description ?? '', + controlFamily: dto.controlFamily || null, }, }); await tx.frameworkEditorControlDocumentTypeLink.createMany({ @@ -144,6 +146,7 @@ export class ControlTemplateService { data: { ...(dto.name !== undefined && { name: dto.name }), ...(dto.description !== undefined && { description: dto.description }), + ...(dto.controlFamily !== undefined && { controlFamily: dto.controlFamily || null }), }, }); this.logger.log(`Updated control template: ${updated.name} (${id})`); @@ -163,6 +166,7 @@ export class ControlTemplateService { data: { ...(dto.name !== undefined && { name: dto.name }), ...(dto.description !== undefined && { description: dto.description }), + ...(dto.controlFamily !== undefined && { controlFamily: dto.controlFamily || null }), }, }); await tx.frameworkEditorControlDocumentTypeLink.deleteMany({ diff --git a/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts b/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts index 58b523b980..6e595750bb 100644 --- a/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts +++ b/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts @@ -19,6 +19,12 @@ export class CreateControlTemplateDto { @MaxLength(5000) description: string; + @ApiPropertyOptional({ example: 'AC - Access Control' }) + @IsString() + @IsOptional() + @MaxLength(255) + controlFamily?: string; + @ApiPropertyOptional({ example: ['penetration-test', 'rbac-matrix'] }) @IsArray() @IsString({ each: true }) diff --git a/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts b/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts index 1cede340f7..90bcdd88b4 100644 --- a/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts +++ b/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts @@ -80,6 +80,12 @@ class ImportControlTemplateDto { @MaxLength(5000) description: string; + @ApiPropertyOptional({ example: 'AC - Access Control' }) + @IsString() + @IsOptional() + @MaxLength(255) + controlFamily?: string; + @ApiPropertyOptional() @IsArray() @ArrayMaxSize(50) diff --git a/apps/api/src/framework-editor/framework/framework-export.service.ts b/apps/api/src/framework-editor/framework/framework-export.service.ts index cbb1627c49..009b56fe9f 100644 --- a/apps/api/src/framework-editor/framework/framework-export.service.ts +++ b/apps/api/src/framework-editor/framework/framework-export.service.ts @@ -24,6 +24,7 @@ export interface ExportedFramework { controlTemplates: Array<{ name: string; description: string; + controlFamily: string | null; documentTypes: string[]; requirementIndices: number[]; policyTemplateIndices: number[]; @@ -131,6 +132,7 @@ export class FrameworkExportService { controlTemplates: controlTemplates.map((ct) => ({ name: ct.name, description: ct.description, + controlFamily: ct.controlFamily ?? null, documentTypes: ct.frameworkDocumentLinks.map((link) => link.formType), requirementIndices: ct.requirements .map((r) => reqIdToIndex.get(r.id)) @@ -225,6 +227,7 @@ export class FrameworkExportService { data: { name: ct.name, description: ct.description, + controlFamily: ct.controlFamily ?? null, requirements: { connect: (ct.requirementIndices ?? []).map((i) => ({ id: createdRequirements[i].id, diff --git a/apps/api/src/frameworks/framework-versioning/framework-diff.ts b/apps/api/src/frameworks/framework-versioning/framework-diff.ts index 0901263eb6..f065968142 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-diff.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-diff.ts @@ -169,7 +169,7 @@ function edgesFromControls( } function controlEqual(a: ManifestControl, b: ManifestControl): boolean { - return a.name === b.name && a.description === b.description; + return a.name === b.name && a.description === b.description && (a.controlFamily ?? null) === (b.controlFamily ?? null); } function requirementEqual(a: ManifestRequirement, b: ManifestRequirement): boolean { diff --git a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts index dc962bab18..180fa415d9 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts @@ -311,6 +311,30 @@ async function replayUndo( }); } + // Restore control families — guarded so older undo payloads without + // this bucket don't break rollback. + if (ctx.undo.controlFamilies) { + for (const entry of ctx.undo.controlFamilies.created) { + await tx.frameworkControlFamily.deleteMany({ + where: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId }, + }); + } + for (const entry of ctx.undo.controlFamilies.updated) { + await tx.frameworkControlFamily.upsert({ + where: { frameworkInstanceId_controlId: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId } }, + create: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId, controlFamily: entry.prevFamily }, + update: { controlFamily: entry.prevFamily }, + }); + } + for (const entry of ctx.undo.controlFamilies.deleted) { + await tx.frameworkControlFamily.upsert({ + where: { frameworkInstanceId_controlId: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId } }, + create: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId, controlFamily: entry.prevFamily }, + update: { controlFamily: entry.prevFamily }, + }); + } + } + // Revert framework instance version pointer await tx.frameworkInstance.update({ where: { id: ctx.syncOp.frameworkInstanceId }, diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts index 1ad1ab1e12..b19fa5b9d6 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts @@ -60,6 +60,7 @@ export async function applySync( frameworkControlPolicyLinks: { connected: [], disconnected: [] }, frameworkControlTaskLinks: { connected: [], disconnected: [] }, frameworkControlDocumentTypeLinks: { connected: [], disconnected: [] }, + controlFamilies: { created: [], updated: [], deleted: [] }, }; const summary: SyncSummary = { controlsAdded: 0, controlsArchived: 0, controlsUpdatedApplied: 0, controlsUpdatedPreserved: 0, @@ -83,6 +84,20 @@ export async function applySync( ctlByTemplate.set(targetControl.id, created); undo.controls.created.push(created.id); summary.controlsAdded += 1; + // Per-instance family entry for the new control + if (targetControl.controlFamily) { + await tx.frameworkControlFamily.create({ + data: { + frameworkInstanceId: ctx.instance.id, + controlId: created.id, + controlFamily: targetControl.controlFamily, + }, + }); + undo.controlFamilies!.created.push({ + frameworkInstanceId: ctx.instance.id, + controlId: created.id, + }); + } } for (const removed of diff.controls.removed) { const inst = ctlByTemplate.get(removed.id); @@ -96,6 +111,42 @@ export async function applySync( for (const u of diff.controls.updated) { const inst = ctlByTemplate.get(u.id); if (!inst) continue; + + // Sync family assignment regardless of whether the control content was edited. + // Family is structural metadata, not user-authored content. + const existingFamily = await tx.frameworkControlFamily.findUnique({ + where: { frameworkInstanceId_controlId: { frameworkInstanceId: ctx.instance.id, controlId: inst.id } }, + select: { controlFamily: true }, + }); + if (u.to.controlFamily) { + await tx.frameworkControlFamily.upsert({ + where: { frameworkInstanceId_controlId: { frameworkInstanceId: ctx.instance.id, controlId: inst.id } }, + create: { frameworkInstanceId: ctx.instance.id, controlId: inst.id, controlFamily: u.to.controlFamily }, + update: { controlFamily: u.to.controlFamily }, + }); + if (existingFamily) { + undo.controlFamilies!.updated.push({ + frameworkInstanceId: ctx.instance.id, + controlId: inst.id, + prevFamily: existingFamily.controlFamily, + }); + } else { + undo.controlFamilies!.created.push({ + frameworkInstanceId: ctx.instance.id, + controlId: inst.id, + }); + } + } else if (existingFamily) { + await tx.frameworkControlFamily.deleteMany({ + where: { frameworkInstanceId: ctx.instance.id, controlId: inst.id }, + }); + undo.controlFamilies!.deleted.push({ + frameworkInstanceId: ctx.instance.id, + controlId: inst.id, + prevFamily: existingFamily.controlFamily, + }); + } + if (isControlEdited(inst, u.from)) { summary.controlsUpdatedPreserved += 1; continue; diff --git a/apps/api/src/frameworks/framework-versioning/manifest.types.ts b/apps/api/src/frameworks/framework-versioning/manifest.types.ts index 8cd564455d..5bb93c910e 100644 --- a/apps/api/src/frameworks/framework-versioning/manifest.types.ts +++ b/apps/api/src/frameworks/framework-versioning/manifest.types.ts @@ -26,6 +26,7 @@ export interface ManifestControl { id: string; // frk_ct_* name: string; description: string; + controlFamily?: string | null; requirementIds: string[]; policyIds: string[]; taskIds: string[]; diff --git a/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts index 8adac1ac8e..11b062d6bd 100644 --- a/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts +++ b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts @@ -15,6 +15,8 @@ export interface UndoPayload { frameworkControlPolicyLinks?: ImplicitEdgeBucket; frameworkControlTaskLinks?: ImplicitEdgeBucket; frameworkControlDocumentTypeLinks?: ImplicitEdgeBucket; + // Per-instance control family assignments. Older syncs may not have this bucket. + controlFamilies?: ControlFamilyUndoBucket; } export interface EntityUndoBucket { @@ -75,6 +77,17 @@ export interface ImplicitEdgeBucket { disconnected: Array<{ controlId: string; otherId: string }>; } +/** + * Tracks FrameworkControlFamily changes so rollback can restore prior state. + * `created` entries are deleted on rollback; `updated` entries are restored to + * prevFamily; `deleted` entries are recreated with prevFamily. + */ +export interface ControlFamilyUndoBucket { + created: Array<{ frameworkInstanceId: string; controlId: string }>; + updated: Array<{ frameworkInstanceId: string; controlId: string; prevFamily: string }>; + deleted: Array<{ frameworkInstanceId: string; controlId: string; prevFamily: string }>; +} + export interface SyncSummary { controlsAdded: number; controlsArchived: number; diff --git a/apps/api/src/frameworks/frameworks-source-loader.helper.ts b/apps/api/src/frameworks/frameworks-source-loader.helper.ts index fa726f786d..9c16337f16 100644 --- a/apps/api/src/frameworks/frameworks-source-loader.helper.ts +++ b/apps/api/src/frameworks/frameworks-source-loader.helper.ts @@ -19,6 +19,7 @@ export interface LoadedFrameworkSources { id: string; name: string; description: string; + controlFamily?: string | null; documentTypes: EvidenceFormType[]; }>; policyTemplates: Array<{ @@ -133,6 +134,7 @@ export async function loadFrameworkSources({ id: c.id, name: c.name, description: c.description, + controlFamily: c.controlFamily, documentTypes: (c.documentTypes ?? []) as EvidenceFormType[], }); } @@ -211,7 +213,7 @@ export async function loadFrameworkSources({ const liveControls = await tx.frameworkEditorControlTemplate.findMany({ where: { requirements: { some: { id: { in: fallbackRequirementIds } } } }, - select: { id: true, name: true, description: true, documentTypes: true }, + select: { id: true, name: true, description: true, controlFamily: true, documentTypes: true }, }); for (const lc of liveControls) { if (!controlsMap.has(lc.id)) { @@ -219,6 +221,7 @@ export async function loadFrameworkSources({ id: lc.id, name: lc.name, description: lc.description, + controlFamily: lc.controlFamily ?? undefined, documentTypes: lc.documentTypes, }); } diff --git a/apps/api/src/frameworks/frameworks-upsert.helper.ts b/apps/api/src/frameworks/frameworks-upsert.helper.ts index 51b7035755..4b5ecff086 100644 --- a/apps/api/src/frameworks/frameworks-upsert.helper.ts +++ b/apps/api/src/frameworks/frameworks-upsert.helper.ts @@ -285,6 +285,7 @@ export async function upsertOrgFrameworkStructure({ const frameworkControlPolicyEntries: Prisma.FrameworkControlPolicyLinkCreateManyInput[] = []; const frameworkControlTaskEntries: Prisma.FrameworkControlTaskLinkCreateManyInput[] = []; const frameworkControlDocumentTypeEntries: Prisma.FrameworkControlDocumentTypeLinkCreateManyInput[] = []; + const frameworkControlFamilyEntries: Prisma.FrameworkControlFamilyCreateManyInput[] = []; const controlTemplateById = new Map(controlTemplates.map((c) => [c.id, c])); for (const relation of groupedRelations) { @@ -360,6 +361,16 @@ export async function upsertOrgFrameworkStructure({ formType, }); } + + // FrameworkControlFamily: per-instance family grouping from the template. + const template = controlTemplateById.get(relation.controlTemplateId); + if (template?.controlFamily) { + frameworkControlFamilyEntries.push({ + frameworkInstanceId, + controlId, + controlFamily: template.controlFamily, + }); + } } if (requirementMapEntries.length > 0) { @@ -397,6 +408,13 @@ export async function upsertOrgFrameworkStructure({ }); } + if (frameworkControlFamilyEntries.length > 0) { + await tx.frameworkControlFamily.createMany({ + data: frameworkControlFamilyEntries, + skipDuplicates: true, + }); + } + return { processedFrameworks: frameworkEditorFrameworks, controlTemplates, diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 99b58d839a..4806a850e5 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -165,6 +165,9 @@ export class FrameworksService { }, }, frameworkDocumentLinks: true, + frameworkControlFamilies: { + select: { frameworkInstanceId: true, controlFamily: true }, + }, requirementsMapped: { where: { archivedAt: null } }, }, }, @@ -189,6 +192,7 @@ export class FrameworksService { requirementsMapped: _, frameworkPolicyLinks, frameworkDocumentLinks, + frameworkControlFamilies, ...controlData } = rm.control; const policyLinks = rm.control.frameworkPolicyLinks.filter( @@ -199,8 +203,13 @@ export class FrameworksService { (link: { frameworkInstanceId: string }) => link.frameworkInstanceId === fi.id, ); + const familyEntry = (frameworkControlFamilies ?? []).find( + (f: { frameworkInstanceId: string }) => + f.frameworkInstanceId === fi.id, + ); controlsMap.set(rm.control.id, { ...controlData, + controlFamily: familyEntry?.controlFamily ?? null, policies: policyLinks.map( (link: { policy: { id: string; name: string; status: string } }) => link.policy, @@ -286,6 +295,11 @@ export class FrameworksService { frameworkDocumentLinks: { where: { frameworkInstanceId }, }, + frameworkControlFamilies: { + where: { frameworkInstanceId }, + select: { controlFamily: true }, + take: 1, + }, }, }, }, @@ -307,10 +321,12 @@ export class FrameworksService { requirementsMapped: _, frameworkPolicyLinks, frameworkDocumentLinks, + frameworkControlFamilies, ...controlData } = rm.control; controlsMap.set(rm.control.id, { ...controlData, + controlFamily: frameworkControlFamilies?.[0]?.controlFamily ?? null, policies: rm.control.frameworkPolicyLinks?.map((link) => link.policy) || [], requirementsMapped: rm.control.requirementsMapped || [], @@ -797,6 +813,11 @@ export class FrameworksService { frameworkDocumentLinks: { where: { frameworkInstanceId }, }, + frameworkControlFamilies: { + where: { frameworkInstanceId }, + select: { controlFamily: true }, + take: 1, + }, }, }, }, @@ -849,10 +870,12 @@ export class FrameworksService { const { frameworkPolicyLinks, frameworkDocumentLinks, + frameworkControlFamilies, ...control } = relatedControl.control; return { ...control, + controlFamily: frameworkControlFamilies?.[0]?.controlFamily ?? null, policies: frameworkPolicyLinks.map((link) => link.policy), controlDocumentTypes: frameworkDocumentLinks.map( (documentType) => ({ diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx new file mode 100644 index 0000000000..ab2299b81f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { Button, Text } from '@trycompai/design-system'; +import { + Checkbox, + CheckboxCheckedFilled, + Close, + Filter, +} from '@trycompai/design-system/icons'; +import { useEffect, useRef, useState } from 'react'; +import { getFamilyDisplayLabel } from './framework-controls-shared'; + +interface FamilyFilterDropdownProps { + allFamilyNames: string[]; + familyCounts: Map; + selectedFamilies: Set; + onToggleFamily: (family: string) => void; + onClear: () => void; +} + +export function FamilyFilterDropdown({ + allFamilyNames, + familyCounts, + selectedFamilies, + onToggleFamily, + onClear, +}: FamilyFilterDropdownProps) { + const [open, setOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) { + setSearchTerm(''); + return; + } + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + const hasFilter = selectedFamilies.size > 0; + const label = hasFilter ? `Families (${selectedFamilies.size})` : 'Families'; + + const filteredFamilies = allFamilyNames.filter((f) => + getFamilyDisplayLabel(f).toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( +
+
+ + {hasFilter && ( + + )} +
+ + {open && ( +
+ setSearchTerm(e.target.value)} + className="w-full border-b border-border bg-transparent px-3 py-1.5 text-sm outline-none" + autoFocus + /> +
+ {filteredFamilies.map((family) => { + const isSelected = selectedFamilies.has(family); + const Icon = isSelected ? CheckboxCheckedFilled : Checkbox; + + return ( + + ); + })} + {filteredFamilies.length === 0 && ( +

No matching families.

+ )} +
+
+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx index 88adb2dfe7..cffdff5e40 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx @@ -1,6 +1,5 @@ 'use client'; -import type { StatusType } from '@/components/status-indicator'; import { type EvidenceSubmissionInfo, getControlProgressPercent, @@ -9,6 +8,13 @@ import { } from '@/lib/control-compliance'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import type { Control, FrameworkEditorRequirement, Task } from '@db'; +import { + buildControlItems, + buildRequirementMap, + type ControlItem, + getStatusBadge, + PAGE_SIZE_OPTIONS, +} from './framework-controls-shared'; import { Badge, Heading, @@ -28,29 +34,6 @@ import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; -const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; - -function getStatusBadge(status: StatusType): { - label: string; - variant: 'default' | 'secondary' | 'destructive'; -} { - switch (status) { - case 'completed': - return { label: 'Satisfied', variant: 'default' }; - case 'in_progress': - return { label: 'In Progress', variant: 'secondary' }; - case 'not_relevant': - return { label: 'Not Relevant', variant: 'secondary' }; - default: - return { label: 'Not Started', variant: 'destructive' }; - } -} - -interface ControlItem { - control: FrameworkInstanceWithControls['controls'][number]; - requirements: Array<{ id: string; name: string; identifier: string }>; -} - export function FrameworkControls({ frameworkInstanceWithControls, requirementDefinitions, @@ -71,23 +54,15 @@ export function FrameworkControls({ const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); - const requirementMap = useMemo(() => { - const map = new Map(); - for (const req of requirementDefinitions) { - map.set(req.id, { id: req.id, name: req.name, identifier: req.identifier ?? '' }); - } - return map; - }, [requirementDefinitions]); - - const items: ControlItem[] = useMemo(() => { - return frameworkInstanceWithControls.controls.map((control) => { - const requirements = (control.requirementsMapped ?? []) - .map((rm) => (rm.requirementId ? requirementMap.get(rm.requirementId) : undefined)) - .filter((r): r is { id: string; name: string; identifier: string } => r != null); + const requirementMap = useMemo( + () => buildRequirementMap(requirementDefinitions), + [requirementDefinitions], + ); - return { control, requirements }; - }); - }, [frameworkInstanceWithControls.controls, requirementMap]); + const items: ControlItem[] = useMemo( + () => buildControlItems(frameworkInstanceWithControls.controls, requirementMap), + [frameworkInstanceWithControls.controls, requirementMap], + ); const filteredItems = useMemo(() => { if (!searchTerm.trim()) return items; diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx new file mode 100644 index 0000000000..9d7b4bdf6a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx @@ -0,0 +1,271 @@ +'use client'; + +import type { EvidenceSubmissionInfo } from '@/lib/control-compliance'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import type { Control, FrameworkEditorRequirement, Task } from '@db'; +import { + Button, + Heading, + InputGroup, + InputGroupAddon, + InputGroupInput, + 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 { + buildControlItems, + buildRequirementMap, + getFamilyDisplayLabel, + groupByFamily, + type ControlItem, + type FamilyGroup, +} from './framework-controls-shared'; +import { GroupedControlRow } from './GroupedControlRow'; + +const COLUMN_COUNT = 7; + +export function FrameworkControlsGrouped({ + frameworkInstanceWithControls, + requirementDefinitions, + tasks, + evidenceSubmissions = [], +}: { + frameworkInstanceWithControls: FrameworkInstanceWithControls; + requirementDefinitions: FrameworkEditorRequirement[]; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions?: EvidenceSubmissionInfo[]; +}) { + const { orgId, frameworkInstanceId } = useParams<{ orgId: string; frameworkInstanceId: string }>(); + const router = useRouter(); + + const handleRowClick = useCallback( + (controlId: string) => { + router.push(`/${orgId}/frameworks/${frameworkInstanceId}/controls/${controlId}`); + }, + [orgId, frameworkInstanceId, router], + ); + + const [searchTerm, setSearchTerm] = useQueryState('q', parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 })); + const [familyFilterParam, setFamilyFilterParam] = useQueryState('families', parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true })); + const [collapsedFamilies, setCollapsedFamilies] = useState>(new Set()); + + const selectedFamilyFilter = useMemo(() => new Set(familyFilterParam), [familyFilterParam]); + + const requirementMap = useMemo( + () => buildRequirementMap(requirementDefinitions), + [requirementDefinitions], + ); + + const allItems = useMemo( + () => buildControlItems(frameworkInstanceWithControls.controls, requirementMap), + [frameworkInstanceWithControls.controls, requirementMap], + ); + + const filteredItems = useMemo(() => { + if (!searchTerm.trim()) return allItems; + const lower = searchTerm.toLowerCase(); + return allItems.filter( + (item) => + item.control.name.toLowerCase().includes(lower) || + item.control.description?.toLowerCase().includes(lower) || + item.requirements.some( + (r) => r.name.toLowerCase().includes(lower) || r.identifier.toLowerCase().includes(lower), + ), + ); + }, [allItems, searchTerm]); + + const allGroups = useMemo(() => groupByFamily(filteredItems), [filteredItems]); + + const groups = useMemo(() => { + if (selectedFamilyFilter.size === 0) return allGroups; + return allGroups.filter((g) => selectedFamilyFilter.has(g.family)); + }, [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 isSearching = searchTerm.trim().length > 0; + const allCollapsed = groups.length > 0 && groups.every((g) => collapsedFamilies.has(g.family)); + + const handleToggleFamily = (family: string) => { + setCollapsedFamilies((prev) => { + const next = new Set(prev); + if (next.has(family)) { + next.delete(family); + } else { + next.add(family); + } + return next; + }); + }; + + const handleToggleAll = () => { + if (allCollapsed) { + setCollapsedFamilies(new Set()); + } else { + setCollapsedFamilies(new Set(allFamilyNames)); + } + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value || null); + }; + + const handleToggleFamilyFilter = (family: string) => { + const next = new Set(selectedFamilyFilter); + if (next.has(family)) { + next.delete(family); + } else { + next.add(family); + } + setFamilyFilterParam(next.size > 0 ? [...next].sort() : null); + }; + + const handleClearFamilyFilter = () => { + setFamilyFilterParam(null); + }; + + const isFamilyExpanded = (family: string) => isSearching || !collapsedFamilies.has(family); + + return ( +
+ Controls ({filteredItems.length}) +
+
+ + + + + + +
+ + {!isSearching && ( + + )} +
+ + + + Name + Requirement + Compliance + Status + Policies + Tasks + Documents + + + + {groups.length === 0 ? ( + + + + No controls found. + + + + ) : ( + groups.map((group) => ( + handleToggleFamily(group.family)} + tasks={tasks} + evidenceSubmissions={evidenceSubmissions} + orgId={orgId} + frameworkInstanceId={frameworkInstanceId} + onRowClick={handleRowClick} + /> + )) + )} + +
+
+ ); +} + +function FamilySection({ + group, + expanded, + onToggle, + tasks, + evidenceSubmissions, + orgId, + frameworkInstanceId, + onRowClick, +}: { + group: FamilyGroup; + expanded: boolean; + onToggle: () => void; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions: EvidenceSubmissionInfo[]; + orgId: string; + frameworkInstanceId: string; + onRowClick: (controlId: string) => void; +}) { + const ChevronIcon = expanded ? ChevronDown : ChevronRight; + + return ( + <> + + + + + + {expanded && + group.items.map(({ control, requirements }) => ( + + ))} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx index 31b19ed28b..5ce1bed0b4 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx @@ -24,9 +24,10 @@ import { } from '@trycompai/ui/dropdown-menu'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useState } from 'react'; +import { Suspense, useCallback, useMemo, useState } from 'react'; import { AddCustomRequirementSheet } from './AddCustomRequirementSheet'; import { FrameworkControls } from './FrameworkControls'; +import { FrameworkControlsGrouped } from './FrameworkControlsGrouped'; import { FrameworkDeleteDialog } from './FrameworkDeleteDialog'; import { FrameworkProgress } from './FrameworkProgress'; import { FrameworkRequirements } from './FrameworkRequirements'; @@ -75,6 +76,14 @@ export function FrameworkDetailContent({ const evidenceSubmissions = framework.evidenceSubmissions || []; const requirementDefinitions = framework.requirementDefinitions || []; + const hasControlFamilies = useMemo( + () => + frameworkInstanceWithControls.controls.some( + (c: { controlFamily?: string | null }) => c.controlFamily, + ), + [frameworkInstanceWithControls.controls], + ); + // Tab state synced to ?tab= // Progress tab only exists when the compliance timeline flag is on — when // it's off, the lightweight FrameworkProgress renders above the tabs. @@ -194,12 +203,23 @@ export function FrameworkDetailContent({ )} - + {hasControlFamilies ? ( + + + + ) : ( + + )} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedControlRow.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedControlRow.tsx new file mode 100644 index 0000000000..4d45d7fd20 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedControlRow.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { + type EvidenceSubmissionInfo, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, +} from '@/lib/control-compliance'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import type { Control, Task } from '@db'; +import { Badge, TableCell, TableRow, Text } from '@trycompai/design-system'; +import { Launch } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { getStatusBadge } from './framework-controls-shared'; + +export function GroupedControlRow({ + control, + requirements, + tasks, + evidenceSubmissions, + orgId, + frameworkInstanceId, + onRowClick, +}: { + control: FrameworkInstanceWithControls['controls'][number]; + requirements: Array<{ id: string; name: string; identifier: string }>; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions: EvidenceSubmissionInfo[]; + orgId: string; + frameworkInstanceId: string; + onRowClick: (controlId: string) => void; +}) { + const policies = control.policies ?? []; + const documentTypes = control.controlDocumentTypes ?? []; + const counts = getRequirementArtifactCounts([control], tasks, evidenceSubmissions); + const status = getControlStatus(policies, tasks, control.id, documentTypes, evidenceSubmissions); + const badge = getStatusBadge(status); + const compliancePercent = getControlProgressPercent( + policies, + tasks, + control.id, + documentTypes, + evidenceSubmissions, + ); + + const controlHref = `/${orgId}/frameworks/${frameworkInstanceId}/controls/${control.id}`; + + const handleRowClick = () => { + onRowClick(control.id); + }; + + const reqLabel = + requirements.length > 0 + ? requirements.map((r) => r.identifier || r.name).join(', ') + : null; + + return ( + + + e.stopPropagation()} + className="group flex items-center gap-2 pl-6" + > + + {control.name} + + + + + + {reqLabel ? ( + + {reqLabel} + + ) : ( + + — + + )} + + +
+
+
+
+
+ + {compliancePercent}% + +
+
+ + + {badge.label} + + +
+ + {counts.policies.completed}/{counts.policies.total} + +
+
+ +
+ + {counts.tasks.completed}/{counts.tasks.total} + +
+
+ +
+ + {counts.documents.completed}/{counts.documents.total} + +
+
+ + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.test.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.test.ts new file mode 100644 index 0000000000..45e1c79423 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.test.ts @@ -0,0 +1,266 @@ +import type { FrameworkEditorRequirement } from '@db'; +import { describe, expect, it } from 'vitest'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import { + buildControlItems, + buildRequirementMap, + getStatusBadge, + groupByFamily, + UNCATEGORIZED_FAMILY, + type ControlItem, +} from './framework-controls-shared'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type ControlEntry = FrameworkInstanceWithControls['controls'][number]; + +function makeControlItem(overrides: { + id?: string; + name: string; + controlFamily?: string | null; +}): ControlItem { + return { + control: { + id: overrides.id ?? `ctrl_${overrides.name}`, + name: overrides.name, + controlFamily: overrides.controlFamily ?? null, + policies: [], + requirementsMapped: [], + } as unknown as ControlEntry, + requirements: [], + }; +} + +function makeRequirement(overrides: Partial = {}) { + return { + id: overrides.id ?? 'req_1', + frameworkId: 'fw_1', + name: overrides.name ?? 'Requirement 1', + identifier: overrides.identifier ?? 'R-1', + description: 'desc', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as FrameworkEditorRequirement; +} + +// --------------------------------------------------------------------------- +// getStatusBadge +// --------------------------------------------------------------------------- + +describe('getStatusBadge', () => { + it('returns Satisfied / default for completed', () => { + expect(getStatusBadge('completed')).toEqual({ + label: 'Satisfied', + variant: 'default', + }); + }); + + it('returns In Progress / secondary for in_progress', () => { + expect(getStatusBadge('in_progress')).toEqual({ + label: 'In Progress', + variant: 'secondary', + }); + }); + + it('returns Not Relevant / secondary for not_relevant', () => { + expect(getStatusBadge('not_relevant')).toEqual({ + label: 'Not Relevant', + variant: 'secondary', + }); + }); + + it('returns Not Started / destructive for not_started', () => { + expect(getStatusBadge('not_started')).toEqual({ + label: 'Not Started', + variant: 'destructive', + }); + }); + + it('returns Not Started / destructive for any unrecognized status', () => { + expect(getStatusBadge('draft')).toEqual({ + label: 'Not Started', + variant: 'destructive', + }); + }); +}); + +// --------------------------------------------------------------------------- +// buildRequirementMap +// --------------------------------------------------------------------------- + +describe('buildRequirementMap', () => { + it('builds a map keyed by requirement id', () => { + const reqs = [ + makeRequirement({ id: 'r1', name: 'Privacy', identifier: 'cc1-1' }), + makeRequirement({ id: 'r2', name: 'Security', identifier: 'cc2-1' }), + ]; + + const map = buildRequirementMap(reqs); + + expect(map.size).toBe(2); + expect(map.get('r1')).toEqual({ id: 'r1', name: 'Privacy', identifier: 'cc1-1' }); + expect(map.get('r2')).toEqual({ id: 'r2', name: 'Security', identifier: 'cc2-1' }); + }); + + it('defaults identifier to empty string when null', () => { + const reqs = [makeRequirement({ id: 'r1', identifier: null as unknown as string })]; + const map = buildRequirementMap(reqs); + + expect(map.get('r1')?.identifier).toBe(''); + }); + + it('returns empty map for empty input', () => { + expect(buildRequirementMap([]).size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// buildControlItems +// --------------------------------------------------------------------------- + +describe('buildControlItems', () => { + it('maps controls to items with resolved requirements', () => { + const reqMap = new Map([ + ['r1', { id: 'r1', name: 'Privacy', identifier: 'cc1-1' }], + ['r2', { id: 'r2', name: 'Security', identifier: 'cc2-1' }], + ]); + + const controls = [ + { + id: 'c1', + name: 'Control 1', + policies: [], + requirementsMapped: [{ requirementId: 'r1' }, { requirementId: 'r2' }], + }, + ] as unknown as Parameters[0]; + + const items = buildControlItems(controls, reqMap); + + expect(items).toHaveLength(1); + expect(items[0].requirements).toEqual([ + { id: 'r1', name: 'Privacy', identifier: 'cc1-1' }, + { id: 'r2', name: 'Security', identifier: 'cc2-1' }, + ]); + }); + + it('filters out requirementIds that are not in the map', () => { + const reqMap = new Map([['r1', { id: 'r1', name: 'Privacy', identifier: 'cc1-1' }]]); + + const controls = [ + { + id: 'c1', + name: 'Control 1', + policies: [], + requirementsMapped: [{ requirementId: 'r1' }, { requirementId: 'r_missing' }], + }, + ] as unknown as Parameters[0]; + + const items = buildControlItems(controls, reqMap); + + expect(items[0].requirements).toHaveLength(1); + expect(items[0].requirements[0].id).toBe('r1'); + }); + + it('handles controls with no requirementsMapped', () => { + const controls = [ + { id: 'c1', name: 'Control 1', policies: [], requirementsMapped: undefined }, + ] as unknown as Parameters[0]; + + const items = buildControlItems(controls, new Map()); + + expect(items[0].requirements).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// groupByFamily +// --------------------------------------------------------------------------- + +describe('groupByFamily', () => { + it('groups controls by controlFamily field', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: 'Access Control' }), + makeControlItem({ name: 'C2', controlFamily: 'Audit' }), + makeControlItem({ name: 'C3', controlFamily: 'Access Control' }), + ]; + + const groups = groupByFamily(items); + + expect(groups).toHaveLength(2); + expect(groups[0].family).toBe('Access Control'); + expect(groups[0].items).toHaveLength(2); + expect(groups[1].family).toBe('Audit'); + expect(groups[1].items).toHaveLength(1); + }); + + it('sorts groups alphabetically by family name', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: 'Zoning' }), + makeControlItem({ name: 'C2', controlFamily: 'Access Control' }), + makeControlItem({ name: 'C3', controlFamily: 'Media Protection' }), + ]; + + const families = groupByFamily(items).map((g) => g.family); + + expect(families).toEqual(['Access Control', 'Media Protection', 'Zoning']); + }); + + it('sorts controls within each group by name', () => { + const items = [ + makeControlItem({ name: 'Zulu', controlFamily: 'Access Control' }), + makeControlItem({ name: 'Alpha', controlFamily: 'Access Control' }), + makeControlItem({ name: 'Mike', controlFamily: 'Access Control' }), + ]; + + const names = groupByFamily(items)[0].items.map((i) => i.control.name); + + expect(names).toEqual(['Alpha', 'Mike', 'Zulu']); + }); + + it('places controls without a family into "Other" at the bottom', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: 'Audit' }), + makeControlItem({ name: 'C2', controlFamily: null }), + makeControlItem({ name: 'C3', controlFamily: undefined }), + ]; + + const groups = groupByFamily(items); + + expect(groups).toHaveLength(2); + expect(groups[0].family).toBe('Audit'); + expect(groups[1].family).toBe(UNCATEGORIZED_FAMILY); + expect(groups[1].items).toHaveLength(2); + }); + + it('returns empty array for empty input', () => { + expect(groupByFamily([])).toEqual([]); + }); + + it('returns single group when all controls share one family', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: 'Risk Assessment' }), + makeControlItem({ name: 'C2', controlFamily: 'Risk Assessment' }), + ]; + + const groups = groupByFamily(items); + + expect(groups).toHaveLength(1); + expect(groups[0].family).toBe('Risk Assessment'); + }); + + it('returns single "Other" group when no controls have families', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: null }), + makeControlItem({ name: 'C2', controlFamily: undefined }), + ]; + + const groups = groupByFamily(items); + + expect(groups).toHaveLength(1); + expect(groups[0].family).toBe(UNCATEGORIZED_FAMILY); + expect(groups[0].items).toHaveLength(2); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts new file mode 100644 index 0000000000..b280df91af --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts @@ -0,0 +1,101 @@ +import type { StatusType } from '@/components/status-indicator'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import type { FrameworkEditorRequirement } from '@db'; + +export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +export interface ControlItem { + control: FrameworkInstanceWithControls['controls'][number]; + requirements: Array<{ id: string; name: string; identifier: string }>; +} + +export function getStatusBadge(status: StatusType): { + label: string; + variant: 'default' | 'secondary' | 'destructive'; +} { + switch (status) { + case 'completed': + return { label: 'Satisfied', variant: 'default' }; + case 'in_progress': + return { label: 'In Progress', variant: 'secondary' }; + case 'not_relevant': + return { label: 'Not Relevant', variant: 'secondary' }; + default: + return { label: 'Not Started', variant: 'destructive' }; + } +} + +export function buildRequirementMap( + requirementDefinitions: FrameworkEditorRequirement[], +): Map { + const map = new Map(); + for (const req of requirementDefinitions) { + map.set(req.id, { id: req.id, name: req.name, identifier: req.identifier ?? '' }); + } + return map; +} + +export function buildControlItems( + controls: FrameworkInstanceWithControls['controls'], + requirementMap: Map, +): ControlItem[] { + return controls.map((control) => { + const requirements = (control.requirementsMapped ?? []) + .map((rm) => (rm.requirementId ? requirementMap.get(rm.requirementId) : undefined)) + .filter((r): r is { id: string; name: string; identifier: string } => r != null); + return { control, requirements }; + }); +} + +/** Sentinel value for uncategorized controls — avoids collision with a real family named "Other". */ +export const UNCATEGORIZED_FAMILY = '__uncategorized__'; + +/** Display label for the uncategorized family group. */ +export const UNCATEGORIZED_FAMILY_LABEL = 'Other'; + +export interface FamilyGroup { + family: string; + items: ControlItem[]; +} + +export function groupByFamily(items: ControlItem[]): FamilyGroup[] { + const familyMap = new Map(); + const otherItems: ControlItem[] = []; + + for (const item of items) { + const family = item.control.controlFamily; + if (family) { + const existing = familyMap.get(family); + if (existing) { + existing.push(item); + } else { + familyMap.set(family, [item]); + } + } else { + otherItems.push(item); + } + } + + const sortedFamilies = Array.from(familyMap.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + + const groups: FamilyGroup[] = sortedFamilies.map(([family, items]) => ({ + family, + items: items.sort((a, b) => a.control.name.localeCompare(b.control.name, undefined, { numeric: true })), + })); + + if (otherItems.length > 0) { + groups.push({ + family: UNCATEGORIZED_FAMILY, + items: otherItems.sort((a, b) => a.control.name.localeCompare(b.control.name, undefined, { numeric: true })), + }); + } + + return groups; +} + +/** Returns the display label for a family key (handles the uncategorized sentinel). */ +export function getFamilyDisplayLabel(family: string): string { + return family === UNCATEGORIZED_FAMILY ? UNCATEGORIZED_FAMILY_LABEL : family; +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.test.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.test.ts new file mode 100644 index 0000000000..93bd4ec492 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.test.ts @@ -0,0 +1,81 @@ +import type { ManifestControl } from '@/types/framework-versioning'; +import { describe, expect, it } from 'vitest'; +import { describeControlChanges } from './ReviewUpdateContent'; + +function makeManifest(overrides: Partial = {}): ManifestControl { + return { + id: 'mc_1', + name: 'Control A', + description: 'Desc A', + controlFamily: null, + requirementIds: [], + policyIds: [], + taskIds: [], + ...overrides, + }; +} + +describe('describeControlChanges', () => { + it('returns "Control family set to X" when family added', () => { + const from = makeManifest({ controlFamily: null }); + const to = makeManifest({ controlFamily: 'Access Control' }); + + expect(describeControlChanges(from, to)).toBe( + 'Control family set to "Access Control"', + ); + }); + + it('returns "Control family removed" when family removed', () => { + const from = makeManifest({ controlFamily: 'Audit' }); + const to = makeManifest({ controlFamily: null }); + + expect(describeControlChanges(from, to)).toBe('Control family removed'); + }); + + it('returns "Control family changed from X to Y" when family renamed', () => { + const from = makeManifest({ controlFamily: 'Audit' }); + const to = makeManifest({ controlFamily: 'Logging' }); + + expect(describeControlChanges(from, to)).toBe( + 'Control family changed from "Audit" to "Logging"', + ); + }); + + it('returns "Name updated" when name changes', () => { + const from = makeManifest({ name: 'Old Name' }); + const to = makeManifest({ name: 'New Name' }); + + expect(describeControlChanges(from, to)).toBe('Name updated'); + }); + + it('returns combined message when multiple fields change', () => { + const from = makeManifest({ name: 'Old', controlFamily: null }); + const to = makeManifest({ name: 'New', controlFamily: 'AC' }); + + expect(describeControlChanges(from, to)).toBe( + 'Name updated. Control family set to "AC"', + ); + }); + + it('returns "Description updated" when only description changes', () => { + const from = makeManifest({ description: 'Old desc' }); + const to = makeManifest({ description: 'New desc' }); + + expect(describeControlChanges(from, to)).toBe('Description updated'); + }); + + it('returns "Modified" when nothing visibly changed', () => { + const manifest = makeManifest(); + + expect(describeControlChanges(manifest, manifest)).toBe('Modified'); + }); + + it('treats undefined controlFamily the same as null', () => { + const from = makeManifest({ controlFamily: undefined }); + const to = makeManifest({ controlFamily: 'Security' }); + + expect(describeControlChanges(from, to)).toBe( + 'Control family set to "Security"', + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx index cbf4d312f2..336979c1e8 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx @@ -37,6 +37,7 @@ interface ChangeRow { identifier?: string; name: string; description?: string | null; + changeSummary?: string | null; kind: ChangeKind; } @@ -323,6 +324,11 @@ function ItemRow({ row }: { row: ChangeRow }) { {row.name} + {row.changeSummary && ( + + {row.changeSummary} + + )} {row.description && ( {row.description} @@ -502,6 +508,27 @@ function LinkRowItem({ row }: { row: LinkRow }) { ); } +export function describeControlChanges( + from: UpdatePreview['controls']['updatedApplied'][number]['manifestFrom'], + to: UpdatePreview['controls']['updatedApplied'][number]['manifestTo'], +): string { + const changes: string[] = []; + if (from.name !== to.name) changes.push('Name updated'); + if (from.description !== to.description) changes.push('Description updated'); + const fromFamily = from.controlFamily ?? null; + const toFamily = to.controlFamily ?? null; + if (fromFamily !== toFamily) { + if (!fromFamily && toFamily) { + changes.push(`Control family set to "${toFamily}"`); + } else if (fromFamily && !toFamily) { + changes.push('Control family removed'); + } else { + changes.push(`Control family changed from "${fromFamily}" to "${toFamily}"`); + } + } + return changes.join('. ') || 'Modified'; +} + function buildGroups(preview: UpdatePreview): ChangeGroup[] { const out: ChangeGroup[] = []; @@ -625,10 +652,11 @@ function buildGroups(preview: UpdatePreview): ChangeGroup[] { out.push({ title: 'MODIFIED CONTROLS', kind: 'modified', - rows: preview.controls.updatedApplied.map(({ instance, manifestTo }) => ({ + rows: preview.controls.updatedApplied.map(({ instance, manifestFrom, manifestTo }) => ({ key: `ctl-mod-${instance.id}`, name: manifestTo.name, description: manifestTo.description, + changeSummary: describeControlChanges(manifestFrom, manifestTo), kind: 'modified' as const, })), }); diff --git a/apps/app/src/lib/types/framework.ts b/apps/app/src/lib/types/framework.ts index a494fc9880..87d2d38de4 100644 --- a/apps/app/src/lib/types/framework.ts +++ b/apps/app/src/lib/types/framework.ts @@ -11,6 +11,7 @@ export type FrameworkInstanceWithControls = FrameworkInstance & { framework: FrameworkEditorFramework | null; customFramework: CustomFramework | null; controls: (Control & { + controlFamily?: string | null; policies: Array<{ id: string; name: string; diff --git a/apps/app/src/types/framework-versioning.ts b/apps/app/src/types/framework-versioning.ts index 03e43c2dde..ffb3a166f3 100644 --- a/apps/app/src/types/framework-versioning.ts +++ b/apps/app/src/types/framework-versioning.ts @@ -20,6 +20,7 @@ export interface ManifestControl { id: string; name: string; description: string; + controlFamily?: string | null; requirementIds: string[]; policyIds: string[]; taskIds: string[]; diff --git a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx index eb73f364c7..f4f5d17172 100644 --- a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx @@ -10,22 +10,24 @@ import { type SortingState, } from '@tanstack/react-table'; import { Button } from '@trycompai/ui'; -import { ArrowDown, ArrowUp, ArrowUpDown, Link, Plus, Trash2 } from 'lucide-react'; +import { ArrowDown, ArrowUp, ArrowUpDown, Link, Plus, Settings, Trash2 } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { AddExistingItemDialog, type ExistingItemRaw, } from '../../components/AddExistingItemDialog'; +import { ManageFamiliesDialog } from './ManageFamiliesDialog'; import { + ComboboxCell, DateCell, EditableCell, MultiSelectCell, - type MultiSelectOption, RelationalCell, type RelationalItem, } from '../../components/table'; import { DOCUMENT_TYPE_OPTIONS } from './document-type-options'; import { simpleUUID, useChangeTracking, type ControlMutations } from './hooks/useChangeTracking'; +import { useFamiliesManagement } from './hooks/useFamiliesManagement'; import type { ControlsPageGridData, FrameworkEditorControlTemplateWithRelatedData } from './types'; interface RequirementApiItem { @@ -94,6 +96,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId createControl: (data: { name: string | null; description: string | null; + controlFamily: string | null; documentTypes: string[]; }) => apiClient<{ id: string }>('/control-template', { @@ -102,7 +105,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId }), updateControl: ( id: string, - data: { name: string; description: string; documentTypes: string[] }, + data: { name: string; description: string; controlFamily: string | null; documentTypes: string[] }, ) => apiClient(`/control-template/${id}`, { method: 'PATCH', @@ -121,6 +124,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId id: control.id || simpleUUID(), name: control.name ?? null, description: control.description ?? null, + controlFamily: control.controlFamily ?? null, policyTemplates: control.policyTemplates?.map((pt) => ({ id: pt.id, name: pt.name })) ?? [], requirements: control.requirements?.map((r) => ({ @@ -143,6 +147,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId const { data, updateCell, + batchUpdateCells, updateRelational, addRow, deleteRow, @@ -154,6 +159,15 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId changesSummary, } = useChangeTracking(initialGridData, mutations); + const { + families, + uniqueFamilies, + manageFamiliesOpen, + setManageFamiliesOpen, + handleRenameFamily, + handleDeleteFamily, + } = useFamiliesManagement({ data, batchUpdateCells }); + const handleDocumentTypesUpdate = useCallback( (rowId: string, values: string[]) => { updateCell(rowId, 'documentTypes', values); @@ -188,6 +202,20 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId /> ), }), + columnHelper.accessor('controlFamily', { + header: 'Control Family', + size: 200, + cell: ({ row, getValue }) => ( + + ), + }), columnHelper.accessor('policyTemplates', { header: 'Linked Policies', size: 220, @@ -318,7 +346,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId ), }), ], - [updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate, frameworkId], + [uniqueFamilies, updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate, frameworkId], ); const [sorting, setSorting] = useState([]); @@ -350,6 +378,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId id: simpleUUID(), name: 'New Control', description: '', + controlFamily: null, policyTemplates: [], requirements: [], taskTemplates: [], @@ -380,6 +409,17 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId )}
+ {families.length > 0 && ( + + )} {frameworkId && (