From edc5b1a49ceeadbf92474f7128cc0b8767025476 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 12:58:11 -0400 Subject: [PATCH 1/6] feat: Add control families for nist readiness [dev] [Marfuen] mariano/nist-sp800-53-readiness --- .../framework-manifest-builder.ts | 1 + .../control-template.service.ts | 4 + .../dto/create-control-template.dto.ts | 6 + .../framework/dto/import-framework.dto.ts | 6 + .../framework/framework-export.service.ts | 3 + .../framework-versioning/framework-diff.ts | 2 +- .../framework-rollback.service.ts | 24 + .../framework-sync-apply.ts | 51 + .../framework-versioning/manifest.types.ts | 1 + .../undo-payload.types.ts | 13 + .../frameworks-source-loader.helper.ts | 5 +- .../frameworks/frameworks-upsert.helper.ts | 18 + apps/api/src/frameworks/frameworks.service.ts | 23 + .../components/FamilyFilterDropdown.tsx | 109 ++ .../components/FrameworkControls.tsx | 55 +- .../components/FrameworkControlsGrouped.tsx | 271 +++++ .../components/FrameworkDetailContent.tsx | 34 +- .../components/GroupedControlRow.tsx | 126 ++ .../framework-controls-shared.test.ts | 266 +++++ .../components/framework-controls-shared.ts | 101 ++ .../components/ReviewUpdateContent.test.ts | 81 ++ .../components/ReviewUpdateContent.tsx | 30 +- apps/app/src/lib/types/framework.ts | 1 + apps/app/src/types/framework-versioning.ts | 1 + .../(pages)/controls/ControlsClientPage.tsx | 56 +- .../controls/DeleteFamilyConfirmation.tsx | 118 ++ .../(pages)/controls/ManageFamiliesDialog.tsx | 236 ++++ .../controls/hooks/useChangeTracking.ts | 41 +- .../controls/hooks/useFamiliesManagement.ts | 96 ++ .../app/(pages)/controls/types.ts | 1 + .../versions/components/VersionDiffView.tsx | 33 +- .../versions/hooks/useFrameworkDraftDiff.ts | 1 + .../app/components/table/ComboboxCell.tsx | 161 +++ .../app/components/table/index.ts | 1 + apps/framework-editor/next.config.mjs | 1 + apps/framework-editor/tailwind.config.ts | 1 + ...6-05-21-nist-sp800-53-controls-grouping.md | 1057 +++++++++++++++++ ...-nist-sp800-53-controls-grouping-design.md | 156 +++ .../migration.sql | 27 + .../migration.sql | 21 + packages/db/prisma/schema/control.prisma | 17 +- .../db/prisma/schema/framework-editor.prisma | 7 +- packages/db/prisma/schema/framework.prisma | 1 + packages/db/prisma/seed/seed.ts | 54 + packages/docs/openapi.json | 162 +++ 45 files changed, 3418 insertions(+), 62 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedControlRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.test.ts create mode 100644 apps/framework-editor/app/(pages)/controls/DeleteFamilyConfirmation.tsx create mode 100644 apps/framework-editor/app/(pages)/controls/ManageFamiliesDialog.tsx create mode 100644 apps/framework-editor/app/(pages)/controls/hooks/useFamiliesManagement.ts create mode 100644 apps/framework-editor/app/components/table/ComboboxCell.tsx create mode 100644 docs/superpowers/plans/2026-05-21-nist-sp800-53-controls-grouping.md create mode 100644 docs/superpowers/specs/2026-05-21-nist-sp800-53-controls-grouping-design.md create mode 100644 packages/db/prisma/migrations/20260521215612_add_control_family/migration.sql create mode 100644 packages/db/prisma/migrations/20260522152845_add_control_family_table/migration.sql 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 && ( + )} +
+ + + + Identifier + Name + Description + Compliance + Status + Controls + Policies + Tasks + Documents + + + + {groups.length === 0 ? ( + + + + No requirements found. + + + + ) : ( + groups.map((group) => ( + handleToggleFamily(group.family)} + orgId={orgId} + frameworkInstanceId={frameworkInstanceId} + onRowClick={handleRowClick} + /> + )) + )} + +
+ + ); +} + +function RequirementFamilySection({ + group, + expanded, + onToggle, + orgId, + frameworkInstanceId, + onRowClick, +}: { + group: RequirementFamilyGroup; + expanded: boolean; + onToggle: () => void; + orgId: string; + frameworkInstanceId: string; + onRowClick: (requirementId: string) => void; +}) { + const ChevronIcon = expanded ? ChevronDown : ChevronRight; + + return ( + <> + + + + + + {expanded && + group.items.map((item) => ( + + ))} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedRequirementRow.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedRequirementRow.tsx new file mode 100644 index 0000000000..b08c1306d9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedRequirementRow.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { getRequirementStatus } from '@/lib/control-compliance'; +import { Badge, TableCell, TableRow, Text } from '@trycompai/design-system'; +import { Launch } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import type { RequirementItem } from './framework-controls-shared'; + +export function GroupedRequirementRow({ + item, + orgId, + frameworkInstanceId, + onRowClick, +}: { + item: RequirementItem; + orgId: string; + frameworkInstanceId: string; + onRowClick: (requirementId: string) => void; +}) { + const status = getRequirementStatus(item.controlStatuses); + const identifier = item.identifier?.trim(); + const href = `/${orgId}/frameworks/${frameworkInstanceId}/requirements/${item.id}`; + + return ( + onRowClick(item.id)} style={{ cursor: 'pointer' }}> + + e.stopPropagation()} + className="group flex items-center gap-2 pl-6" + > + {identifier || '—'} + + + + + + {item.name} + + + + + {item.description || '—'} + + + +
+
+
+
+
+ + {item.compliancePercent}% + +
+
+ + + {status.label} + + +
+ + {item.satisfiedControlsCount}/{item.mappedControlsCount} + +
+
+ +
+ + {item.artifactCounts.policies.completed}/{item.artifactCounts.policies.total} + +
+
+ +
+ + {item.artifactCounts.tasks.completed}/{item.artifactCounts.tasks.total} + +
+
+ +
+ + {item.artifactCounts.documents.completed}/{item.artifactCounts.documents.total} + +
+
+ + ); +} 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 index b280df91af..51b50d4c60 100644 --- 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 @@ -1,6 +1,14 @@ import type { StatusType } from '@/components/status-indicator'; +import { + type EvidenceSubmissionInfo, + type RequirementArtifactCounts, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, + getRequirementCompliancePercent, +} from '@/lib/control-compliance'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; -import type { FrameworkEditorRequirement } from '@db'; +import type { Control, FrameworkEditorRequirement, Task } from '@db'; export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; @@ -99,3 +107,92 @@ export function groupByFamily(items: ControlItem[]): FamilyGroup[] { export function getFamilyDisplayLabel(family: string): string { return family === UNCATEGORIZED_FAMILY ? UNCATEGORIZED_FAMILY_LABEL : family; } + +// --------------------------------------------------------------------------- +// Requirement grouping +// --------------------------------------------------------------------------- + +export interface RequirementItem extends FrameworkEditorRequirement { + mappedControlsCount: number; + satisfiedControlsCount: number; + compliancePercent: number; + controlStatuses: StatusType[]; + artifactCounts: RequirementArtifactCounts; +} + +export interface RequirementFamilyGroup { + family: string; + items: RequirementItem[]; +} + +export function groupRequirementsByFamily(items: RequirementItem[]): RequirementFamilyGroup[] { + const familyMap = new Map(); + const otherItems: RequirementItem[] = []; + + for (const item of items) { + const family = item.requirementFamily; + 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 sortItems = (a: RequirementItem, b: RequirementItem) => + (a.identifier ?? a.name).localeCompare(b.identifier ?? b.name, undefined, { numeric: true }); + + const groups: RequirementFamilyGroup[] = sortedFamilies.map(([family, familyItems]) => ({ + family, + items: familyItems.sort(sortItems), + })); + + if (otherItems.length > 0) { + groups.push({ + family: UNCATEGORIZED_FAMILY, + items: otherItems.sort(sortItems), + }); + } + + return groups; +} + +export function buildRequirementItems( + requirementDefinitions: FrameworkEditorRequirement[], + controls: FrameworkInstanceWithControls['controls'], + tasks: (Task & { controls: Control[] })[], + evidenceSubmissions: EvidenceSubmissionInfo[], +): RequirementItem[] { + return requirementDefinitions.map((def) => { + const mappedControls = controls.filter( + (control) => + control.requirementsMapped?.some((rm) => rm.requirementId === def.id) ?? false, + ); + + const controlStatuses = mappedControls.map((c) => + getControlStatus(c.policies, tasks, c.id, c.controlDocumentTypes, evidenceSubmissions), + ); + const satisfiedControlsCount = controlStatuses.filter((s) => s === 'completed').length; + + const progressPercents = mappedControls.map((c) => + getControlProgressPercent(c.policies, tasks, c.id, c.controlDocumentTypes, evidenceSubmissions), + ); + + return { + ...def, + mappedControlsCount: mappedControls.length, + satisfiedControlsCount, + compliancePercent: getRequirementCompliancePercent(progressPercents), + controlStatuses, + artifactCounts: getRequirementArtifactCounts(mappedControls, tasks, evidenceSubmissions), + }; + }); +} 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 336979c1e8..0e1ebfdb94 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 @@ -508,6 +508,28 @@ function LinkRowItem({ row }: { row: LinkRow }) { ); } +export function describeRequirementChanges( + from: UpdatePreview['requirements']['updated'][number]['from'], + to: UpdatePreview['requirements']['updated'][number]['to'], +): string { + const changes: string[] = []; + if (from.name !== to.name) changes.push('Name updated'); + if (from.identifier !== to.identifier) changes.push('Identifier updated'); + if (from.description !== to.description) changes.push('Description updated'); + const fromFamily = from.requirementFamily ?? null; + const toFamily = to.requirementFamily ?? null; + if (fromFamily !== toFamily) { + if (!fromFamily && toFamily) { + changes.push(`Requirement family set to "${toFamily}"`); + } else if (fromFamily && !toFamily) { + changes.push('Requirement family removed'); + } else { + changes.push(`Requirement family changed from "${fromFamily}" to "${toFamily}"`); + } + } + return changes.join('. ') || 'Modified'; +} + export function describeControlChanges( from: UpdatePreview['controls']['updatedApplied'][number]['manifestFrom'], to: UpdatePreview['controls']['updatedApplied'][number]['manifestTo'], @@ -639,11 +661,12 @@ function buildGroups(preview: UpdatePreview): ChangeGroup[] { out.push({ title: 'MODIFIED REQUIREMENTS', kind: 'modified', - rows: preview.requirements.updated.map(({ to }) => ({ + rows: preview.requirements.updated.map(({ from, to }) => ({ key: `req-mod-${to.id}`, identifier: to.identifier, name: to.name, description: to.description, + changeSummary: describeRequirementChanges(from, to), kind: 'modified' as const, })), }); diff --git a/apps/app/src/types/framework-versioning.ts b/apps/app/src/types/framework-versioning.ts index ffb3a166f3..fbd36523cc 100644 --- a/apps/app/src/types/framework-versioning.ts +++ b/apps/app/src/types/framework-versioning.ts @@ -14,6 +14,7 @@ export interface ManifestRequirement { identifier: string; name: string; description: string | null; + requirementFamily?: string | null; } export interface ManifestControl { diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx index 4abb90d8ee..a7a9402081 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx @@ -14,7 +14,7 @@ import { ArrowDown, ArrowUp, ArrowUpDown, Download, PencilIcon, Plus, Trash2 } f import { useRouter } from 'next/navigation'; import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { DateCell, EditableCell, RelationalCell } from '../../../components/table'; +import { ComboboxCell, DateCell, EditableCell, RelationalCell } from '../../../components/table'; import { EditFrameworkDialog } from './components/EditFrameworkDialog'; import { DeleteFrameworkDialog } from './components/DeleteFrameworkDialog'; import { @@ -36,6 +36,7 @@ interface RequirementInput { name: string; identifier: string; description: string; + requirementFamily?: string | null; frameworkId: string; createdAt: string | Date; updatedAt: string | Date; @@ -80,6 +81,7 @@ export function FrameworkRequirementsClientPage({ name: r.name ?? null, identifier: r.identifier ?? null, description: r.description ?? null, + requirementFamily: r.requirementFamily ?? null, controlTemplates: r.controlTemplates ?? [], controlTemplatesLength: r.controlTemplates?.length ?? 0, createdAt: r.createdAt ? new Date(r.createdAt) : null, @@ -102,8 +104,29 @@ export function FrameworkRequirementsClientPage({ changesSummary, } = useRequirementChangeTracking(initialGridData, frameworkDetails.id); + const uniqueFamilies = useMemo(() => { + const families = new Set(); + for (const row of data) { + if (row.requirementFamily) families.add(row.requirementFamily); + } + return [...families].sort(); + }, [data]); + const columns = useMemo( () => [ + columnHelper.accessor('requirementFamily', { + header: 'Family', + size: 200, + cell: ({ row, getValue }) => ( + + ), + }), columnHelper.accessor('identifier', { header: 'Identifier', size: 140, @@ -189,7 +212,7 @@ export function FrameworkRequirementsClientPage({ ), }), ], - [updateCell, updateRelational, deleteRow, createdIds], + [uniqueFamilies, updateCell, updateRelational, deleteRow, createdIds], ); const [sorting, setSorting] = useState([]); @@ -210,6 +233,7 @@ export function FrameworkRequirementsClientPage({ name: 'New Requirement', identifier: '', description: '', + requirementFamily: null, controlTemplates: [], controlTemplatesLength: 0, createdAt: new Date(), diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts index 4c96db8a02..ad7845ff9b 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts @@ -7,6 +7,7 @@ export interface RequirementGridRow { name: string | null; identifier: string | null; description: string | null; + requirementFamily: string | null; controlTemplates: Array<{ id: string; name: string }>; controlTemplatesLength: number; createdAt: Date | null; @@ -131,6 +132,7 @@ export function useRequirementChangeTracking( name: row.name, identifier: row.identifier ?? '', description: row.description ?? '', + requirementFamily: row.requirementFamily ?? undefined, }), }); results.successes.push(`Created: ${row.name}`); @@ -156,6 +158,7 @@ export function useRequirementChangeTracking( name: row.name, identifier: row.identifier ?? '', description: row.description ?? '', + requirementFamily: row.requirementFamily ?? null, }), }); results.successes.push(`Updated: ${row.name}`); diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx index b787cc3cdf..c6c1c10d48 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx @@ -69,6 +69,13 @@ export function VersionDiffView({ diff, linkChanges }: VersionDiffViewProps) { {r.name} )} + renderUpdatedRow={(u) => ( + + {u.to.identifier} + {u.to.name} + + + )} /> + ({changes.join(', ')}) + + ); +} + function DiffRow({ kind, children, diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts index 38d3e2ceac..909d9b7994 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts @@ -15,6 +15,7 @@ export interface DiffRequirement { name: string; identifier: string; description?: string | null; + requirementFamily?: string | null; } export interface DiffPolicy { diff --git a/packages/db/prisma/migrations/20260527000000_add_requirement_family/migration.sql b/packages/db/prisma/migrations/20260527000000_add_requirement_family/migration.sql new file mode 100644 index 0000000000..df7e41d81f --- /dev/null +++ b/packages/db/prisma/migrations/20260527000000_add_requirement_family/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "FrameworkEditorRequirement" ADD COLUMN "requirementFamily" TEXT; diff --git a/packages/db/prisma/schema/framework-editor.prisma b/packages/db/prisma/schema/framework-editor.prisma index 4f388172a0..cdc427cc69 100644 --- a/packages/db/prisma/schema/framework-editor.prisma +++ b/packages/db/prisma/schema/framework-editor.prisma @@ -38,9 +38,10 @@ model FrameworkEditorRequirement { frameworkId String framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id]) - name String // Original requirement ID within that framework, e.g., "Privacy" - identifier String @default("") // Unique identifier for the requirement, e.g., "cc1-1" - description String + name String // Original requirement ID within that framework, e.g., "Privacy" + identifier String @default("") // Unique identifier for the requirement, e.g., "cc1-1" + description String + requirementFamily String? controlTemplates FrameworkEditorControlTemplate[] requirementMaps RequirementMap[] From c4b50c69096fde69b7fd533447ffd7375f244314 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 12:09:40 -0400 Subject: [PATCH 4/6] fix(framework-editor): flip ComboboxCell dropdown upward when near viewport bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(framework-editor): flip ComboboxCell dropdown upward when near viewport bottom Measures available space below the cell on open. If less than 260px, the dropdown renders above the cell instead of below. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(framework-editor): default sort requirements by identifier Co-Authored-By: Claude Opus 4.6 (1M context) * fix(app): remove duplicate headings, default sort requirements by identifier CS-390: Requirements now sorted by identifier with numeric-aware comparison (AC-2 before AC-10) in the flat view. CS-393: Removed duplicate "Requirements (N)" and "Controls (N)" headings — the tab already shows the count. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- .../components/FrameworkControls.tsx | 3 +-- .../components/FrameworkRequirements.tsx | 14 ++++++++++---- .../FrameworkRequirementsClientPage.tsx | 2 +- .../app/components/table/ComboboxCell.tsx | 12 +++++++++++- 4 files changed, 23 insertions(+), 8 deletions(-) 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 cffdff5e40..42cb8101d8 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 @@ -17,7 +17,7 @@ import { } from './framework-controls-shared'; import { Badge, - Heading, + InputGroup, InputGroupAddon, InputGroupInput, @@ -98,7 +98,6 @@ export function FrameworkControls({ return (
- Controls ({filteredItems.length})
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx index ab13e7d36f..6809df96b7 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx @@ -3,7 +3,7 @@ import type { Control, FrameworkEditorRequirement, Task } from '@db'; import { Badge, - Heading, + InputGroup, InputGroupAddon, InputGroupInput, @@ -108,10 +108,17 @@ export function FrameworkRequirements({ }); }, [requirementDefinitions, frameworkInstanceWithControls.controls, tasks, evidenceSubmissions]); + const sortedItems = useMemo( + () => [...items].sort((a, b) => + (a.identifier ?? '').localeCompare(b.identifier ?? '', undefined, { numeric: true }), + ), + [items], + ); + const filteredItems = useMemo(() => { - if (!searchTerm.trim()) return items; + if (!searchTerm.trim()) return sortedItems; const lowerSearch = searchTerm.toLowerCase(); - return items.filter( + return sortedItems.filter( (item) => item.name.toLowerCase().includes(lowerSearch) || item.identifier?.toLowerCase().includes(lowerSearch) || @@ -136,7 +143,6 @@ export function FrameworkRequirements({ return (
- Requirements ({filteredItems.length})
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx index a7a9402081..a20650d1e1 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx @@ -215,7 +215,7 @@ export function FrameworkRequirementsClientPage({ [uniqueFamilies, updateCell, updateRelational, deleteRow, createdIds], ); - const [sorting, setSorting] = useState([]); + const [sorting, setSorting] = useState([{ id: 'identifier', desc: false }]); const table = useReactTable({ data, diff --git a/apps/framework-editor/app/components/table/ComboboxCell.tsx b/apps/framework-editor/app/components/table/ComboboxCell.tsx index 2e6e56af9f..f18d4ba60d 100644 --- a/apps/framework-editor/app/components/table/ComboboxCell.tsx +++ b/apps/framework-editor/app/components/table/ComboboxCell.tsx @@ -24,6 +24,7 @@ export function ComboboxCell({ }: ComboboxCellProps) { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(''); + const [dropUp, setDropUp] = useState(false); const containerRef = useRef(null); const inputRef = useRef(null); @@ -41,6 +42,11 @@ export function ComboboxCell({ }, [isOpen]); useEffect(() => { + if (isOpen && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + setDropUp(spaceBelow < 260); + } if (isOpen && inputRef.current) { inputRef.current.focus(); } @@ -82,6 +88,10 @@ export function ComboboxCell({ ); } + const dropdownPosition = dropUp + ? 'bottom-full mb-1' + : 'top-full mt-1'; + return (
{isOpen && ( -
+
Date: Wed, 27 May 2026 12:21:44 -0400 Subject: [PATCH 5/6] fix(api): wrap custom framework link sync in transaction (#2931) The three createMany calls (policy, task, document links) now run inside a transaction when no external client is provided, preventing partial sync state if one fails. Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- .../controls/sync-custom-framework-links.ts | 146 +++++++++--------- 1 file changed, 76 insertions(+), 70 deletions(-) diff --git a/apps/api/src/controls/sync-custom-framework-links.ts b/apps/api/src/controls/sync-custom-framework-links.ts index bd6c679d41..b6d8722b96 100644 --- a/apps/api/src/controls/sync-custom-framework-links.ts +++ b/apps/api/src/controls/sync-custom-framework-links.ts @@ -11,82 +11,88 @@ export async function syncDirectLinksToCustomFrameworks({ organizationId: string; client?: DbClient; }) { - const prisma = client ?? db; + async function run(prisma: DbClient) { + const hasCustomFrameworks = await prisma.frameworkInstance.count({ + where: { organizationId, customFrameworkId: { not: null } }, + }); + if (hasCustomFrameworks === 0) return; - const hasCustomFrameworks = await prisma.frameworkInstance.count({ - where: { organizationId, customFrameworkId: { not: null } }, - }); - if (hasCustomFrameworks === 0) return; - - const customFiIds = await prisma.requirementMap.findMany({ - where: { - controlId, - archivedAt: null, - frameworkInstance: { - organizationId, - customFrameworkId: { not: null }, + const customFiIds = await prisma.requirementMap.findMany({ + where: { + controlId, + archivedAt: null, + frameworkInstance: { + organizationId, + customFrameworkId: { not: null }, + }, }, - }, - select: { frameworkInstanceId: true }, - distinct: ['frameworkInstanceId'], - }); + select: { frameworkInstanceId: true }, + distinct: ['frameworkInstanceId'], + }); - if (customFiIds.length === 0) return; + if (customFiIds.length === 0) return; - const control = await prisma.control.findUnique({ - where: { id: controlId, organizationId }, - include: { - policies: { - where: { archivedAt: null }, - select: { id: true }, - }, - tasks: { - where: { archivedAt: null }, - select: { id: true }, + const control = await prisma.control.findUnique({ + where: { id: controlId, organizationId }, + include: { + policies: { + where: { archivedAt: null }, + select: { id: true }, + }, + tasks: { + where: { archivedAt: null }, + select: { id: true }, + }, + controlDocumentTypes: { + select: { formType: true }, + }, }, - controlDocumentTypes: { - select: { formType: true }, - }, - }, - }); + }); + + if (!control) return; - if (!control) return; + const fiIds = customFiIds.map((r) => r.frameworkInstanceId); - const fiIds = customFiIds.map((r) => r.frameworkInstanceId); + await Promise.all([ + control.policies.length > 0 && + prisma.frameworkControlPolicyLink.createMany({ + data: fiIds.flatMap((frameworkInstanceId) => + control.policies.map((p) => ({ + frameworkInstanceId, + controlId, + policyId: p.id, + })), + ), + skipDuplicates: true, + }), + control.tasks.length > 0 && + prisma.frameworkControlTaskLink.createMany({ + data: fiIds.flatMap((frameworkInstanceId) => + control.tasks.map((t) => ({ + frameworkInstanceId, + controlId, + taskId: t.id, + })), + ), + skipDuplicates: true, + }), + control.controlDocumentTypes.length > 0 && + prisma.frameworkControlDocumentTypeLink.createMany({ + data: fiIds.flatMap((frameworkInstanceId) => + control.controlDocumentTypes.map((d) => ({ + frameworkInstanceId, + controlId, + formType: d.formType, + })), + ), + skipDuplicates: true, + }), + ]); + } - await Promise.all([ - control.policies.length > 0 && - prisma.frameworkControlPolicyLink.createMany({ - data: fiIds.flatMap((frameworkInstanceId) => - control.policies.map((p) => ({ - frameworkInstanceId, - controlId, - policyId: p.id, - })), - ), - skipDuplicates: true, - }), - control.tasks.length > 0 && - prisma.frameworkControlTaskLink.createMany({ - data: fiIds.flatMap((frameworkInstanceId) => - control.tasks.map((t) => ({ - frameworkInstanceId, - controlId, - taskId: t.id, - })), - ), - skipDuplicates: true, - }), - control.controlDocumentTypes.length > 0 && - prisma.frameworkControlDocumentTypeLink.createMany({ - data: fiIds.flatMap((frameworkInstanceId) => - control.controlDocumentTypes.map((d) => ({ - frameworkInstanceId, - controlId, - formType: d.formType, - })), - ), - skipDuplicates: true, - }), - ]); + if (client) { + await run(client); + } else { + await db.$transaction((tx) => run(tx)); + } } From 8ce8b3c42dbbe90af8730b6d99fbeab481c7ac0b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 13:30:32 -0400 Subject: [PATCH 6/6] fix(api): add batch update endpoint for requirements to avoid rate limiting * fix(api): add batch update endpoint for requirements to avoid rate limiting Co-Authored-By: Claude Opus 4.6 (1M context) * fix(api): add IsNotEmpty to batch update requirement ID field Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- .../dto/batch-update-requirements.dto.ts | 49 +++++++++++++++++++ .../requirement/requirement.controller.ts | 8 +++ .../requirement/requirement.service.ts | 31 ++++++++++++ .../hooks/useRequirementChangeTracking.ts | 39 ++++++++++----- 4 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts diff --git a/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts b/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts new file mode 100644 index 0000000000..e0a8530105 --- /dev/null +++ b/apps/api/src/framework-editor/requirement/dto/batch-update-requirements.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsNotEmpty, + IsString, + IsOptional, + MaxLength, + ValidateNested, +} from 'class-validator'; + +class BatchUpdateRequirementItem { + @ApiProperty() + @IsString() + @IsNotEmpty() + id: string; + + @ApiProperty() + @IsString() + @IsOptional() + @MaxLength(255) + name?: string; + + @ApiProperty() + @IsString() + @IsOptional() + @MaxLength(255) + identifier?: string; + + @ApiProperty() + @IsString() + @IsOptional() + @MaxLength(5000) + description?: string; + + @ApiProperty() + @IsString() + @IsOptional() + @MaxLength(255) + requirementFamily?: string; +} + +export class BatchUpdateRequirementsDto { + @ApiProperty({ type: [BatchUpdateRequirementItem] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BatchUpdateRequirementItem) + updates: BatchUpdateRequirementItem[]; +} diff --git a/apps/api/src/framework-editor/requirement/requirement.controller.ts b/apps/api/src/framework-editor/requirement/requirement.controller.ts index cc24d046cd..b6764a2ac4 100644 --- a/apps/api/src/framework-editor/requirement/requirement.controller.ts +++ b/apps/api/src/framework-editor/requirement/requirement.controller.ts @@ -13,6 +13,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { BatchUpdateRequirementsDto } from './dto/batch-update-requirements.dto'; import { CreateRequirementDto } from './dto/create-requirement.dto'; import { UpdateRequirementDto } from './dto/update-requirement.dto'; import { RequirementService } from './requirement.service'; @@ -38,6 +39,13 @@ export class RequirementController { return this.service.create(dto); } + @Patch('batch') + @ApiOperation({ summary: 'Batch update requirements' }) + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async batchUpdate(@Body() dto: BatchUpdateRequirementsDto) { + return this.service.batchUpdate(dto.updates); + } + @Patch(':id') @ApiOperation({ summary: 'Update a requirement' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) diff --git a/apps/api/src/framework-editor/requirement/requirement.service.ts b/apps/api/src/framework-editor/requirement/requirement.service.ts index a921161306..42732802dd 100644 --- a/apps/api/src/framework-editor/requirement/requirement.service.ts +++ b/apps/api/src/framework-editor/requirement/requirement.service.ts @@ -70,6 +70,37 @@ export class RequirementService { return updated; } + async batchUpdate( + updates: Array<{ + id: string; + name?: string; + identifier?: string; + description?: string; + requirementFamily?: string; + }>, + ) { + return db.$transaction( + updates.map((update) => { + const { id, ...data } = update; + return db.frameworkEditorRequirement.update({ + where: { id }, + data: { + ...(data.name !== undefined && { name: data.name }), + ...(data.identifier !== undefined && { + identifier: data.identifier, + }), + ...(data.description !== undefined && { + description: data.description, + }), + ...(data.requirementFamily !== undefined && { + requirementFamily: data.requirementFamily || null, + }), + }, + }); + }), + ); + } + async delete(id: string) { const existing = await db.frameworkEditorRequirement.findUnique({ where: { id }, diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts index ad7845ff9b..6bc3aa5cf9 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts @@ -147,26 +147,41 @@ export function useRequirementChangeTracking( } } + const updatesToSend: Array<{ + id: string; + name: string; + identifier: string; + description: string; + requirementFamily: string | null; + }> = []; for (const id of updatedIds) { if (createdIds.has(id) || deletedIds.has(id)) continue; const row = currentData.find((r) => r.id === id); if (!row?.name) continue; + updatesToSend.push({ + id, + name: row.name, + identifier: row.identifier ?? '', + description: row.description ?? '', + requirementFamily: row.requirementFamily ?? null, + }); + } + if (updatesToSend.length > 0) { try { - await apiClient(`/requirement/${id}`, { + await apiClient('/requirement/batch', { method: 'PATCH', - body: JSON.stringify({ - name: row.name, - identifier: row.identifier ?? '', - description: row.description ?? '', - requirementFamily: row.requirementFamily ?? null, - }), + body: JSON.stringify({ updates: updatesToSend }), }); - results.successes.push(`Updated: ${row.name}`); - okUpdated.add(id); + for (const u of updatesToSend) { + results.successes.push(`Updated: ${u.name}`); + okUpdated.add(u.id); + } } catch (error) { - results.errors.push( - `Failed to update ${row.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); + for (const u of updatesToSend) { + results.errors.push( + `Failed to update ${u.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } } }