From 929ed7af4beca7ad9fd98171988784caf75eda38 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 17:12:45 -0400 Subject: [PATCH 01/57] docs: add design spec for NIST SP800-53 controls grouping Covers CS-389 (control family column), CS-390 (sort order), CS-391 (collapsed default view), CS-392 (expand all), CS-393 (remove duplicate requirements heading). Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-nist-sp800-53-controls-grouping-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-nist-sp800-53-controls-grouping-design.md diff --git a/docs/superpowers/specs/2026-05-21-nist-sp800-53-controls-grouping-design.md b/docs/superpowers/specs/2026-05-21-nist-sp800-53-controls-grouping-design.md new file mode 100644 index 0000000000..aa1cc6bc80 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-nist-sp800-53-controls-grouping-design.md @@ -0,0 +1,156 @@ +# NIST SP800-53 Controls Grouping Design + +Covers Linear tickets CS-389, CS-390, CS-391, CS-392, CS-393. + +## Problem + +Large frameworks like NIST SP800-53 have 1200+ controls. The current flat table in `FrameworkControls.tsx` is unusable at that scale — users can't navigate, group, or collapse controls by family. + +## Data Layer + +### Schema Change + +Add one nullable column to `FrameworkEditorControlTemplate`: + +```prisma +model FrameworkEditorControlTemplate { + controlFamily String? + // ... existing fields unchanged +} +``` + +No changes to `Control`. Read via `control.controlTemplate.controlFamily` through the existing `controlTemplateId` FK. + +**Why no field on `Control`?** Every framework-instantiated control already has `controlTemplateId`. Reading through the relation avoids backfill, sync, and version-publish concerns — platform admins set the family once on the template, and all orgs see the grouping immediately. + +### Migration + +Single `ALTER TABLE ADD COLUMN ... NULL` — no data migration needed. + +## API Changes + +### Framework Editor API + +- `CreateControlTemplateDto`: add optional `controlFamily: string` +- `UpdateControlTemplateDto`: add optional `controlFamily: string` +- No new endpoints. Existing `POST` and `PATCH` on `/v1/framework-editor/control-template` handle it. + +### Controls Fetch + +When fetching controls for framework views, include the template relation: + +```typescript +controlTemplate: { select: { controlFamily: true } } +``` + +This applies to the controls include in `frameworks.service.ts` (or wherever the framework-with-controls query is built). The `FrameworkInstanceWithControls` type gains `controlTemplate?: { controlFamily: string | null }` on each control. + +## Framework Editor UI + +Add a `controlFamily` text input to `ControlsClientPage.tsx` in `apps/framework-editor/`. Simple text field alongside existing name/description fields. Value like `"AC - Access Control"`. + +## Frontend — Controls View + +### Component Structure + +``` +FrameworkDetailContent.tsx (existing parent) +├── FrameworkControls.tsx (existing flat list — unchanged, used when no families) +├── FrameworkControlsGrouped.tsx (NEW — grouped/collapsible view) +└── framework-controls-shared.ts (NEW — extracted shared helpers) +``` + +**Switching logic** in `FrameworkDetailContent`: if any control has `controlTemplate?.controlFamily`, render `FrameworkControlsGrouped`. Otherwise render `FrameworkControls`. + +### Shared Helpers (`framework-controls-shared.ts`) + +Extracted from `FrameworkControls.tsx` so both views reuse them: + +- `getStatusBadge(status)` → badge label + variant +- `RequirementCell` component +- Compliance bar rendering +- `ControlItem` type definition +- `buildControlItems()` — maps raw controls to `ControlItem[]` with resolved requirements + +### `FrameworkControlsGrouped.tsx` (CS-389, CS-390, CS-391, CS-392) + +#### Grouping + +- Group controls by `control.controlTemplate?.controlFamily` +- Controls without a family → "Other" section at the bottom +- Groups sorted alphabetically by family name + +#### Default View (CS-391) + +- All families collapsed on mount +- Each family row shows: chevron icon, family name (e.g., "AC - Access Control"), control count +- Expansion state is session-only — not persisted across page loads or navigation + +#### Expand/Collapse (CS-389) + +- Chevron icon on each family row toggles expanded/collapsed +- Multiple families can be open simultaneously +- Expanded controls are visually indented under the family header +- Standard control-level actions (click to navigate to control detail) work on expanded rows +- Keyboard accessible: family rows focusable, Enter/Space toggles + +#### Sort Within Families (CS-390) + +- Controls within each family sorted by name (contains identifier: "AC-1", "AC-2", etc.) +- This gives natural identifier ordering since names start with the identifier + +#### Expand All / Collapse All (CS-392) + +- Toggle button in the toolbar (above the table, near search) +- When all are collapsed → button shows expand-all icon/label +- When any are expanded → button collapses all + +#### Search (CS-391) + +- Searching filters controls across all families +- Families that contain matching controls auto-expand +- Families with no matches are hidden +- Clearing search restores previous expand/collapse state + +#### Table Columns (within expanded families) + +Same as current `FrameworkControls.tsx`: + +| Name | Requirement | Compliance | Status | Policies | Tasks | Documents | + +Family header rows span all columns. + +### Pagination + +- Pagination applies to the family list, not individual controls +- All controls within an expanded family are shown (no nested pagination) + +## CS-393 — Remove Duplicate "Requirements (N)" + +In `SingleControl.tsx`, the Requirements tab trigger shows `"Requirements (N)"`. Inside the tab body, there's a redundant heading repeating the same text. Remove the heading inside the tab body to reclaim screen real estate. + +**File**: `apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControl.tsx` + +## Files to Create/Modify + +### New Files +- `apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx` +- `apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts` + +### Modified Files +- `packages/db/prisma/schema/framework-editor.prisma` — add `controlFamily` column +- `apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts` — add field +- `apps/api/src/framework-editor/control-template/dto/update-control-template.dto.ts` — add field +- `apps/api/src/frameworks/frameworks.service.ts` — include `controlTemplate.controlFamily` in controls fetch +- `apps/framework-editor/app/components/editor/ControlsClientPage.tsx` — add controlFamily input +- `apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx` — switch between flat/grouped +- `apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx` — extract shared helpers +- `apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControl.tsx` — remove duplicate heading +- `apps/app/src/lib/types/framework.ts` — extend type to include `controlTemplate` + +## Out of Scope + +- Persisting expand/collapse state across sessions +- Column-level sorting (sort by status, compliance, etc.) within families +- Control family management UI (rename, merge families) — families are just strings +- Adding control families to the global `/controls` page (only framework views) From 494673d6b154d238ec33e78cefe2cccc198ce072 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 17:23:22 -0400 Subject: [PATCH 02/57] docs: add implementation plan for NIST SP800-53 controls grouping Co-Authored-By: Claude Opus 4.6 (1M context) --- ...6-05-21-nist-sp800-53-controls-grouping.md | 1057 +++++++++++++++++ 1 file changed, 1057 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-nist-sp800-53-controls-grouping.md diff --git a/docs/superpowers/plans/2026-05-21-nist-sp800-53-controls-grouping.md b/docs/superpowers/plans/2026-05-21-nist-sp800-53-controls-grouping.md new file mode 100644 index 0000000000..0e58db09e3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-nist-sp800-53-controls-grouping.md @@ -0,0 +1,1057 @@ +# NIST SP800-53 Controls Grouping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add control family grouping with expand/collapse to the framework controls view so large frameworks (1200+ controls) are navigable. + +**Architecture:** Add `controlFamily` to `FrameworkEditorControlTemplate` (read through existing FK, no field on `Control`). Build a new `FrameworkControlsGrouped` component that renders collapsible family sections. The parent switches between flat/grouped view based on whether controls have family data. + +**Tech Stack:** Prisma, NestJS, Next.js, React, @trycompai/design-system + +**Working directory:** `/Users/mariano/code/comp/.worktrees/nist-sp800-53-readiness` + +--- + +### Task 1: Schema Migration + +**Files:** +- Modify: `packages/db/prisma/schema/framework-editor.prisma:91-110` + +- [ ] **Step 1: Add `controlFamily` field to schema** + +In `packages/db/prisma/schema/framework-editor.prisma`, add the field after `description`: + +```prisma +model FrameworkEditorControlTemplate { + id String @id @default(dbgenerated("generate_prefixed_cuid('frk_ct'::text)")) + name String + description String + controlFamily String? + + policyTemplates FrameworkEditorPolicyTemplate[] + requirements FrameworkEditorRequirement[] + taskTemplates FrameworkEditorTaskTemplate[] + documentTypes EvidenceFormType[] + frameworkPolicyLinks FrameworkEditorControlPolicyTemplateLink[] + frameworkTaskLinks FrameworkEditorControlTaskTemplateLink[] + frameworkDocumentLinks FrameworkEditorControlDocumentTypeLink[] + + // Dates + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + // Instances + controls Control[] +} +``` + +- [ ] **Step 2: Generate and run migration** + +```bash +cd packages/db && DATABASE_URL="postgresql://postgres:postgres@localhost:5432/compdev_nist_sp800_53_readiness" bunx prisma migrate dev --name add_control_family +``` + +- [ ] **Step 3: Regenerate Prisma client** + +```bash +cd packages/db && DATABASE_URL="postgresql://postgres:postgres@localhost:5432/compdev_nist_sp800_53_readiness" bunx prisma generate +``` + +- [ ] **Step 4: Verify typecheck** + +```bash +npx turbo run typecheck --filter=@trycompai/db +``` + +- [ ] **Step 5: Commit** + +```bash +git add packages/db/prisma/schema/framework-editor.prisma packages/db/prisma/migrations/ +git commit -m "feat(db): add controlFamily to FrameworkEditorControlTemplate" +``` + +--- + +### Task 2: API — DTOs and Service + +**Files:** +- Modify: `apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts` +- Modify: `apps/api/src/framework-editor/control-template/control-template.service.ts:102-137` (create) and `:139-186` (update) + +- [ ] **Step 1: Add `controlFamily` to `CreateControlTemplateDto`** + +In `apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts`, add after the `description` field: + +```typescript + @ApiPropertyOptional({ example: 'AC - Access Control' }) + @IsString() + @IsOptional() + @MaxLength(255) + controlFamily?: string; +``` + +`UpdateControlTemplateDto` inherits via `PartialType` — no changes needed there. + +- [ ] **Step 2: Include `controlFamily` in service `create` method** + +In `apps/api/src/framework-editor/control-template/control-template.service.ts`, update both `create` paths to include `controlFamily` in the Prisma `data` object. + +Line ~105 (no documentTypes path): +```typescript + const ct = await db.frameworkEditorControlTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + controlFamily: dto.controlFamily ?? null, + }, + }); +``` + +Line ~119 (with documentTypes path): +```typescript + const created = await tx.frameworkEditorControlTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + controlFamily: dto.controlFamily ?? null, + }, + }); +``` + +- [ ] **Step 3: Include `controlFamily` in service `update` method** + +In the `update` method, the no-documentTypes path (line ~141) does a direct `db.frameworkEditorControlTemplate.update`. Since the DTO uses `PartialType`, Prisma's update already handles partial fields. But verify the data spread includes `controlFamily`. The current code at line ~141: + +```typescript + async update(id: string, dto: UpdateControlTemplateDto) { + if (dto.documentTypes === undefined) { + return db.frameworkEditorControlTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.controlFamily !== undefined && { controlFamily: dto.controlFamily }), + }, + }); + } +``` + +Check the existing update code — if it already spreads all DTO fields, `controlFamily` flows automatically. If it uses explicit field mapping (like above), add the `controlFamily` line. Also update the transaction path similarly. + +- [ ] **Step 4: Verify typecheck** + +```bash +npx turbo run typecheck --filter=@trycompai/api +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/framework-editor/control-template/ +git commit -m "feat(api): add controlFamily to control template DTOs and service" +``` + +--- + +### Task 3: API — Include `controlFamily` in Framework Controls Fetch + +**Files:** +- Modify: `apps/api/src/frameworks/frameworks.service.ts:272-289` (findOne control include) + +- [ ] **Step 1: Add `controlTemplate` to the control include in `findOne`** + +In `apps/api/src/frameworks/frameworks.service.ts`, inside the `findOne` method's Prisma query (line ~272), the `control` include currently has `frameworkPolicyLinks`, `requirementsMapped`, and `frameworkDocumentLinks`. Add `controlTemplate`: + +```typescript + control: { + include: { + controlTemplate: { + select: { controlFamily: true }, + }, + frameworkPolicyLinks: { + where: { + frameworkInstanceId, + policy: { archivedAt: null }, + }, + include: { + policy: { + select: { id: true, name: true, status: true }, + }, + }, + }, + requirementsMapped: { where: { archivedAt: null } }, + frameworkDocumentLinks: { + where: { frameworkInstanceId }, + }, + }, + }, +``` + +- [ ] **Step 2: Pass `controlTemplate` through the transformation** + +In the same file, the transformation at lines ~303-325 destructures `rm.control` and spreads `controlData`. The `controlTemplate` field will be included in `controlData` automatically since it's not destructured out. Verify this by checking the destructure: + +```typescript + const { + requirementsMapped: _, + frameworkPolicyLinks, + frameworkDocumentLinks, + ...controlData // controlTemplate is included here + } = rm.control; +``` + +If `controlTemplate` is not being destructured out, it flows through. No code change needed for the transformation. + +- [ ] **Step 3: Verify typecheck** + +```bash +npx turbo run typecheck --filter=@trycompai/api +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/frameworks/frameworks.service.ts +git commit -m "feat(api): include controlTemplate.controlFamily in framework controls fetch" +``` + +--- + +### Task 4: Frontend Type Update + +**Files:** +- Modify: `apps/app/src/lib/types/framework.ts` + +- [ ] **Step 1: Add `controlTemplate` to `FrameworkInstanceWithControls`** + +```typescript +import type { + Control, + CustomFramework, + FrameworkEditorFramework, + FrameworkInstance, + PolicyStatus, + RequirementMap, +} from '@db'; + +export type FrameworkInstanceWithControls = FrameworkInstance & { + framework: FrameworkEditorFramework | null; + customFramework: CustomFramework | null; + controls: (Control & { + policies: Array<{ + id: string; + name: string; + status: PolicyStatus; + }>; + requirementsMapped: RequirementMap[]; + controlDocumentTypes?: Array<{ + formType: string; + isNotRelevant?: boolean; + }>; + controlTemplate?: { + controlFamily: string | null; + }; + })[]; +}; + +export interface FrameworkInstanceWithComplianceScore { + frameworkInstance: FrameworkInstanceWithControls; + complianceScore: number; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/app/src/lib/types/framework.ts +git commit -m "feat(app): add controlTemplate to FrameworkInstanceWithControls type" +``` + +--- + +### Task 5: Extract Shared Helpers from `FrameworkControls.tsx` + +**Files:** +- Create: `apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts` +- Modify: `apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx` + +- [ ] **Step 1: Create `framework-controls-shared.ts`** + +Extract the shared types and helpers that both `FrameworkControls` and the new `FrameworkControlsGrouped` will use: + +```typescript +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 }; + }); +} +``` + +- [ ] **Step 2: Update `FrameworkControls.tsx` to use shared helpers** + +Replace inline definitions with imports: + +```typescript +import { + type ControlItem, + PAGE_SIZE_OPTIONS, + buildControlItems, + buildRequirementMap, + getStatusBadge, +} from './framework-controls-shared'; +``` + +Remove the duplicated `PAGE_SIZE_OPTIONS`, `getStatusBadge`, and `ControlItem` from `FrameworkControls.tsx`. Replace the inline `requirementMap` and `items` useMemos: + +```typescript + const requirementMap = useMemo( + () => buildRequirementMap(requirementDefinitions), + [requirementDefinitions], + ); + + const items: ControlItem[] = useMemo( + () => buildControlItems(frameworkInstanceWithControls.controls, requirementMap), + [frameworkInstanceWithControls.controls, requirementMap], + ); +``` + +- [ ] **Step 3: Verify `FrameworkControls.tsx` still works** + +```bash +npx turbo run typecheck --filter=@trycompai/app +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/app/src/app/(app)/\[orgId\]/frameworks/\[frameworkInstanceId\]/components/framework-controls-shared.ts apps/app/src/app/(app)/\[orgId\]/frameworks/\[frameworkInstanceId\]/components/FrameworkControls.tsx +git commit -m "refactor(app): extract shared helpers from FrameworkControls" +``` + +--- + +### Task 6: Build `FrameworkControlsGrouped` Component + +**Files:** +- Create: `apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx` + +This is the core deliverable — a grouped, collapsible controls table. It covers CS-389 (families), CS-390 (sort), CS-391 (default collapsed), CS-392 (expand all). + +- [ ] **Step 1: Create the component file** + +```typescript +'use client'; + +import type { StatusType } from '@/components/status-indicator'; +import { + type EvidenceSubmissionInfo, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, +} from '@/lib/control-compliance'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import type { Control, FrameworkEditorRequirement, Task } from '@db'; +import { + Badge, + Button, + Heading, + InputGroup, + InputGroupAddon, + InputGroupInput, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { ChevronDown, ChevronRight, Launch, Search } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + type ControlItem, + buildControlItems, + buildRequirementMap, + getStatusBadge, +} from './framework-controls-shared'; + +const COLUMN_COUNT = 7; +const UNGROUPED_FAMILY = '__ungrouped__'; + +interface ControlFamily { + name: string; + displayName: string; + items: ControlItem[]; +} + +function groupByFamily(items: ControlItem[]): ControlFamily[] { + const familyMap = new Map(); + + for (const item of items) { + const family = item.control.controlTemplate?.controlFamily ?? UNGROUPED_FAMILY; + const existing = familyMap.get(family) ?? []; + existing.push(item); + familyMap.set(family, existing); + } + + const families: ControlFamily[] = []; + const ungrouped: ControlItem[] = []; + + for (const [name, groupItems] of familyMap) { + if (name === UNGROUPED_FAMILY) { + ungrouped.push(...groupItems); + continue; + } + const sorted = groupItems.sort((a, b) => a.control.name.localeCompare(b.control.name)); + families.push({ name, displayName: name, items: sorted }); + } + + families.sort((a, b) => a.name.localeCompare(b.name)); + + if (ungrouped.length > 0) { + ungrouped.sort((a, b) => a.control.name.localeCompare(b.control.name)); + families.push({ name: UNGROUPED_FAMILY, displayName: 'Other', items: ungrouped }); + } + + return families; +} + +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 [searchTerm, setSearchTerm] = useState(''); + const [expandedFamilies, setExpandedFamilies] = useState>(new Set()); + + 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 searchLower = searchTerm.toLowerCase(); + return allItems.filter( + (item) => + item.control.name.toLowerCase().includes(searchLower) || + item.control.description?.toLowerCase().includes(searchLower) || + item.requirements.some( + (r) => + r.name.toLowerCase().includes(searchLower) || + r.identifier.toLowerCase().includes(searchLower), + ), + ); + }, [allItems, searchTerm]); + + const families = useMemo(() => groupByFamily(filteredItems), [filteredItems]); + + // Auto-expand families when searching + useEffect(() => { + if (searchTerm.trim()) { + setExpandedFamilies(new Set(families.map((f) => f.name))); + } else { + setExpandedFamilies(new Set()); + } + }, [searchTerm, families]); + + const allExpanded = families.length > 0 && families.every((f) => expandedFamilies.has(f.name)); + + const handleToggleAll = useCallback(() => { + if (allExpanded) { + setExpandedFamilies(new Set()); + } else { + setExpandedFamilies(new Set(families.map((f) => f.name))); + } + }, [allExpanded, families]); + + const handleToggleFamily = useCallback((familyName: string) => { + setExpandedFamilies((prev) => { + const next = new Set(prev); + if (next.has(familyName)) { + next.delete(familyName); + } else { + next.add(familyName); + } + return next; + }); + }, []); + + const getControlHref = (controlId: string) => + `/${orgId}/frameworks/${frameworkInstanceId}/controls/${controlId}`; + + const totalControls = filteredItems.length; + + return ( +
+ Controls ({totalControls}) +
+
+ + + + + ) => setSearchTerm(e.target.value)} + /> + +
+ +
+ + + + Name + Requirement + Compliance + Status + Policies + Tasks + Documents + + + + {families.length === 0 ? ( + + + + No controls found. + + + + ) : ( + families.map((family) => ( + handleToggleFamily(family.name)} + getControlHref={getControlHref} + onRowClick={(controlId) => router.push(getControlHref(controlId))} + tasks={tasks} + evidenceSubmissions={evidenceSubmissions} + /> + )) + )} + +
+
+ ); +} + +function FamilySection({ + family, + expanded, + onToggle, + getControlHref, + onRowClick, + tasks, + evidenceSubmissions, +}: { + family: ControlFamily; + expanded: boolean; + onToggle: () => void; + getControlHref: (controlId: string) => string; + onRowClick: (controlId: string) => void; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions: EvidenceSubmissionInfo[]; +}) { + const ChevronIcon = expanded ? ChevronDown : ChevronRight; + + return ( + <> + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggle(); + } + }} + role="button" + tabIndex={0} + style={{ cursor: 'pointer' }} + className="bg-muted/30 hover:bg-muted/50" + > + +
+ + {family.displayName} + + ({family.items.length}) + +
+
+
+ {expanded && + family.items.map(({ control, requirements }) => ( + + ))} + + ); +} + +function ControlRow({ + control, + requirements, + getControlHref, + onRowClick, + tasks, + evidenceSubmissions, +}: { + control: FrameworkInstanceWithControls['controls'][number]; + requirements: Array<{ id: string; name: string; identifier: string }>; + getControlHref: (controlId: string) => string; + onRowClick: (controlId: string) => void; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions: EvidenceSubmissionInfo[]; +}) { + 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 label = requirements.map((r) => r.identifier || r.name).join(', '); + + return ( + onRowClick(control.id)} style={{ cursor: 'pointer' }}> + +
+ e.stopPropagation()} + className="group flex items-center gap-2" + > + + {control.name} + + + +
+
+ + {requirements.length === 0 ? ( + + ) : ( + {label} + )} + + +
+
+
+
+
+ {compliancePercent}% +
+
+ + + {badge.label} + + +
+ {counts.policies.completed}/{counts.policies.total} +
+
+ +
+ {counts.tasks.completed}/{counts.tasks.total} +
+
+ +
+ {counts.documents.completed}/{counts.documents.total} +
+
+ + ); +} +``` + +- [ ] **Step 2: Verify typecheck** + +```bash +npx turbo run typecheck --filter=@trycompai/app +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/app/src/app/(app)/\[orgId\]/frameworks/\[frameworkInstanceId\]/components/FrameworkControlsGrouped.tsx +git commit -m "feat(app): add FrameworkControlsGrouped component with expand/collapse" +``` + +--- + +### Task 7: Wire Up Grouped View in `FrameworkDetailContent` + +**Files:** +- Modify: `apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx:196-203` + +- [ ] **Step 1: Import `FrameworkControlsGrouped` and add switching logic** + +Add the import at the top of `FrameworkDetailContent.tsx`: + +```typescript +import { FrameworkControlsGrouped } from './FrameworkControlsGrouped'; +``` + +Then add a `useMemo` to detect whether controls have family data (put it near the other data setup, around line ~70): + +```typescript + const hasControlFamilies = useMemo( + () => + frameworkInstanceWithControls.controls.some( + (c) => c.controlTemplate?.controlFamily, + ), + [frameworkInstanceWithControls.controls], + ); +``` + +- [ ] **Step 2: Replace the controls tab content** + +Replace lines 196-203 (the `` block): + +```tsx + + {hasControlFamilies ? ( + + ) : ( + + )} + +``` + +- [ ] **Step 3: Verify typecheck** + +```bash +npx turbo run typecheck --filter=@trycompai/app +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/app/src/app/(app)/\[orgId\]/frameworks/\[frameworkInstanceId\]/components/FrameworkDetailContent.tsx +git commit -m "feat(app): switch between flat and grouped controls view based on family data" +``` + +--- + +### Task 8: Framework Editor — Add `controlFamily` Column + +**Files:** +- Modify: `apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx` +- Modify: `apps/framework-editor/app/(pages)/controls/types.ts` +- Modify: `apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts` + +- [ ] **Step 1: Add `controlFamily` to `ControlsPageGridData` type** + +In `apps/framework-editor/app/(pages)/controls/types.ts`, add `controlFamily` to `ControlsPageGridData`: + +```typescript +export type ControlsPageGridData = { + id: string; + name: string | null; + description: string | null; + controlFamily: string | null; + policyTemplates: ItemWithName[]; + requirements: RequirementGridItem[]; + taskTemplates: ItemWithName[]; + documentTypes: string[]; + policyTemplatesLength: number; + requirementsLength: number; + taskTemplatesLength: number; + documentTypesLength: number; + createdAt: Date | null; + updatedAt: Date | null; +}; +``` + +- [ ] **Step 2: Add `controlFamily` to `ControlMutations` interface** + +In `apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts`, update the mutation interfaces: + +```typescript +export interface ControlMutations { + createControl: (data: { + name: string | null; + description: string | null; + controlFamily: string | null; + documentTypes: string[]; + }) => Promise<{ id: string }>; + updateControl: ( + id: string, + data: { name: string; description: string; controlFamily: string | null; documentTypes: string[] }, + ) => Promise; + deleteControl: (id: string) => Promise; +} +``` + +- [ ] **Step 3: Include `controlFamily` in `handleCommit` data** + +In the same file, update the `handleCommit` callback. In the create loop (line ~151): + +```typescript + const newControl = await mutations.createControl({ + name: row.name, + description: row.description, + controlFamily: row.controlFamily, + documentTypes: row.documentTypes, + }); +``` + +In the update loop (line ~186): + +```typescript + await mutations.updateControl(id, { + name: row.name, + description: row.description || '', + controlFamily: row.controlFamily, + documentTypes: row.documentTypes, + }); +``` + +- [ ] **Step 4: Add `controlFamily` to grid data mapping in `ControlsClientPage.tsx`** + +In `ControlsClientPage.tsx`, update the `initialGridData` mapping (line ~120) to include: + +```typescript + controlFamily: control.controlFamily ?? null, +``` + +And update the `mutations` useMemo (line ~92) to pass `controlFamily`: + +```typescript + createControl: (data: { + name: string | null; + description: string | null; + controlFamily: string | null; + documentTypes: string[]; + }) => + apiClient<{ id: string }>('/control-template', { + method: 'POST', + body: JSON.stringify({ ...data, frameworkId }), + }), + updateControl: ( + id: string, + data: { name: string; description: string; controlFamily: string | null; documentTypes: string[] }, + ) => + apiClient(`/control-template/${id}`, { + method: 'PATCH', + body: JSON.stringify({ ...data, frameworkId }), + }), +``` + +- [ ] **Step 5: Add the `controlFamily` column to the table** + +In `ControlsClientPage.tsx`, add a new column definition after the `description` column (around line ~190): + +```typescript + columnHelper.accessor('controlFamily', { + header: 'Control Family', + size: 200, + cell: ({ row, getValue }) => ( + + ), + }), +``` + +- [ ] **Step 6: Verify typecheck** + +```bash +npx turbo run typecheck --filter=framework-editor +``` + +If the framework-editor package name differs, find it: + +```bash +grep '"name"' apps/framework-editor/package.json +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/framework-editor/app/\(pages\)/controls/ +git commit -m "feat(framework-editor): add controlFamily column to control template editor" +``` + +--- + +### Task 9: CS-393 — Remove Duplicate Requirements Heading + +**Files:** +- Modify: `apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx` + +- [ ] **Step 1: Check for duplicate heading** + +Read `RequirementsTable.tsx` for any heading that duplicates the tab trigger's "Requirements (N)". Looking at the current file (lines 59-137), there is no `` element — the component starts directly with the search input and table. The tab trigger in `SingleControl.tsx` line 59 shows `Requirements ({count})`. + +If there IS no duplicate heading in `RequirementsTable.tsx`, then CS-393 may already be resolved or the duplicate exists elsewhere. Check `SingleControl.tsx` — the tabs already show the count, and the tab content directly renders `RequirementsTable` without an additional heading. + +Verify by running the app and inspecting the control detail page. If no duplicate exists, mark CS-393 as already satisfied by the current code. + +- [ ] **Step 2: If a duplicate heading exists, remove it** + +Remove the `` or `

` element that shows "Requirements (N)" inside the tab content. Keep only the tab trigger's label. + +- [ ] **Step 3: Commit (if changes made)** + +```bash +git add apps/app/src/app/(app)/\[orgId\]/controls/\[controlId\]/components/RequirementsTable.tsx +git commit -m "fix(app): remove duplicate requirements heading (CS-393)" +``` + +--- + +### Task 10: Verify End-to-End + +- [ ] **Step 1: Run full typecheck** + +```bash +npx turbo run typecheck +``` + +- [ ] **Step 2: Run app tests** + +```bash +cd apps/app && npx vitest run +``` + +- [ ] **Step 3: Run API tests** + +```bash +cd apps/api && npx jest --passWithNoTests +``` + +- [ ] **Step 4: Start the dev server and test manually** + +```bash +cd /Users/mariano/code/comp/.worktrees/nist-sp800-53-readiness +bun run --filter '@trycompai/app' dev:no-trigger +``` + +In a separate terminal: + +```bash +bun run --filter '@trycompai/api' dev:no-trigger +``` + +Test the following: +1. Open a framework that has controls with `controlFamily` set — verify grouped view shows +2. Open a framework without control families — verify flat view still works +3. Expand/collapse individual families +4. Click "Expand All" / "Collapse All" +5. Search for a control — verify matching families auto-expand +6. Clear search — verify families collapse back +7. Click a control row — verify navigation to control detail works +8. In the framework editor, add/edit the `controlFamily` field on a control template + +- [ ] **Step 5: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "fix(app): address issues found during e2e verification" +``` From fb89192c2458b7ec8b523ad352d7614d702c0287 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 17:56:26 -0400 Subject: [PATCH 03/57] feat(db): add controlFamily to FrameworkEditorControlTemplate Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 27 +++++++++++++++++++ .../db/prisma/schema/framework-editor.prisma | 7 ++--- 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 packages/db/prisma/migrations/20260521215612_add_control_family/migration.sql diff --git a/packages/db/prisma/migrations/20260521215612_add_control_family/migration.sql b/packages/db/prisma/migrations/20260521215612_add_control_family/migration.sql new file mode 100644 index 0000000000..02530d7a54 --- /dev/null +++ b/packages/db/prisma/migrations/20260521215612_add_control_family/migration.sql @@ -0,0 +1,27 @@ +-- DropForeignKey +ALTER TABLE "OffboardingAccessRevocation" DROP CONSTRAINT "OffboardingAccessRevocation_revokedById_fkey"; + +-- DropForeignKey +ALTER TABLE "OffboardingChecklistCompletion" DROP CONSTRAINT "OffboardingChecklistCompletion_completedById_fkey"; + +-- DropForeignKey +ALTER TABLE "OffboardingChecklistCompletion" DROP CONSTRAINT "OffboardingChecklistCompletion_templateItemId_fkey"; + +-- AlterTable +ALTER TABLE "FrameworkEditorControlTemplate" ADD COLUMN "controlFamily" TEXT; + +-- AlterTable +ALTER TABLE "OffboardingAccessRevocation" ALTER COLUMN "revokedById" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "OffboardingChecklistCompletion" ALTER COLUMN "templateItemId" DROP NOT NULL, +ALTER COLUMN "completedById" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "OffboardingChecklistCompletion" ADD CONSTRAINT "OffboardingChecklistCompletion_templateItemId_fkey" FOREIGN KEY ("templateItemId") REFERENCES "OffboardingChecklistTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffboardingChecklistCompletion" ADD CONSTRAINT "OffboardingChecklistCompletion_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffboardingAccessRevocation" ADD CONSTRAINT "OffboardingAccessRevocation_revokedById_fkey" FOREIGN KEY ("revokedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/framework-editor.prisma b/packages/db/prisma/schema/framework-editor.prisma index 00db60d196..4f388172a0 100644 --- a/packages/db/prisma/schema/framework-editor.prisma +++ b/packages/db/prisma/schema/framework-editor.prisma @@ -89,9 +89,10 @@ model FrameworkEditorTaskTemplate { } model FrameworkEditorControlTemplate { - id String @id @default(dbgenerated("generate_prefixed_cuid('frk_ct'::text)")) - name String - description String + id String @id @default(dbgenerated("generate_prefixed_cuid('frk_ct'::text)")) + name String + description String + controlFamily String? policyTemplates FrameworkEditorPolicyTemplate[] requirements FrameworkEditorRequirement[] From a799afa2d5b44910a72dffbb59593f9148ac8a29 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 17:57:03 -0400 Subject: [PATCH 04/57] feat(app): add controlTemplate to FrameworkInstanceWithControls type Co-Authored-By: Claude Sonnet 4.6 --- apps/app/src/lib/types/framework.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/app/src/lib/types/framework.ts b/apps/app/src/lib/types/framework.ts index a494fc9880..24a18b69d5 100644 --- a/apps/app/src/lib/types/framework.ts +++ b/apps/app/src/lib/types/framework.ts @@ -21,6 +21,9 @@ export type FrameworkInstanceWithControls = FrameworkInstance & { formType: string; isNotRelevant?: boolean; }>; + controlTemplate?: { + controlFamily: string | null; + }; })[]; }; From c398365cd89ef68eaf966fc3f137e186856859ee Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 18:00:02 -0400 Subject: [PATCH 05/57] feat(api): include controlTemplate.controlFamily in framework controls fetch Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/frameworks/frameworks.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 99b58d839a..0e708751f3 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -271,6 +271,9 @@ export class FrameworksService { include: { control: { include: { + controlTemplate: { + select: { controlFamily: true }, + }, frameworkPolicyLinks: { where: { frameworkInstanceId, From 9188ab0769ca9e08143057303fde7b3f56825211 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 18:00:05 -0400 Subject: [PATCH 06/57] feat(api): add controlFamily to control template DTOs and service Co-Authored-By: Claude Sonnet 4.6 --- .../control-template/control-template.service.ts | 4 ++++ .../control-template/dto/create-control-template.dto.ts | 6 ++++++ 2 files changed, 10 insertions(+) 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..d3f64a1ae9 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 }), }, }); 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 }), }, }); 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 }) From f713e2288199e8c94abef3c97500a0874cde9c2c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 18:01:50 -0400 Subject: [PATCH 07/57] refactor(app): extract shared helpers from FrameworkControls Moves PAGE_SIZE_OPTIONS, ControlItem, getStatusBadge, buildRequirementMap, and buildControlItems into framework-controls-shared.ts so both the flat view and the upcoming grouped view can reuse them without duplication. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FrameworkControls.tsx | 55 +++++-------------- .../components/framework-controls-shared.ts | 48 ++++++++++++++++ 2 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts 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/framework-controls-shared.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts new file mode 100644 index 0000000000..6f85b149f8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts @@ -0,0 +1,48 @@ +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 }; + }); +} From aabe2fc390262a8011c671e3772d92b67d4b6848 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 18:03:58 -0400 Subject: [PATCH 08/57] feat(app): add FrameworkControlsGrouped component with expand/collapse Implements grouped view of framework controls organized by control family with search, expand/collapse individual families, and expand all/collapse all toggle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FrameworkControlsGrouped.tsx | 258 ++++++++++++++++++ .../components/GroupedControlRow.tsx | 125 +++++++++ 2 files changed, 383 insertions(+) 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 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..541ac3f6df --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx @@ -0,0 +1,258 @@ +'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 { useMemo, useState } from 'react'; +import { + buildControlItems, + buildRequirementMap, + type ControlItem, +} from './framework-controls-shared'; +import { GroupedControlRow } from './GroupedControlRow'; + +const COLUMN_COUNT = 7; + +interface FamilyGroup { + family: string; + items: ControlItem[]; +} + +function groupByFamily(items: ControlItem[]): FamilyGroup[] { + const familyMap = new Map(); + const otherItems: ControlItem[] = []; + + for (const item of items) { + const family = item.control.controlTemplate?.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)), + })); + + if (otherItems.length > 0) { + groups.push({ + family: 'Other', + items: otherItems.sort((a, b) => a.control.name.localeCompare(b.control.name)), + }); + } + + return groups; +} + +export function FrameworkControlsGrouped({ + frameworkInstanceWithControls, + requirementDefinitions, + tasks, + evidenceSubmissions = [], +}: { + frameworkInstanceWithControls: FrameworkInstanceWithControls; + requirementDefinitions: FrameworkEditorRequirement[]; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions?: EvidenceSubmissionInfo[]; +}) { + const [searchTerm, setSearchTerm] = useState(''); + const [expandedFamilies, setExpandedFamilies] = useState>(new Set()); + + 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 groups = useMemo(() => groupByFamily(filteredItems), [filteredItems]); + + const isSearching = searchTerm.trim().length > 0; + const allFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); + const hasAnyExpanded = isSearching || expandedFamilies.size > 0; + + const handleToggleFamily = (family: string) => { + setExpandedFamilies((prev) => { + const next = new Set(prev); + if (next.has(family)) { + next.delete(family); + } else { + next.add(family); + } + return next; + }); + }; + + const handleToggleAll = () => { + if (hasAnyExpanded && !isSearching) { + setExpandedFamilies(new Set()); + } else { + setExpandedFamilies(new Set(allFamilyNames)); + } + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchTerm(value); + if (!value.trim()) { + setExpandedFamilies(new Set()); + } + }; + + const isFamilyExpanded = (family: string) => isSearching || expandedFamilies.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} + /> + )) + )} + +
+
+ ); +} + +function FamilySection({ + group, + expanded, + onToggle, + tasks, + evidenceSubmissions, +}: { + group: FamilyGroup; + expanded: boolean; + onToggle: () => void; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions: EvidenceSubmissionInfo[]; +}) { + const ChevronIcon = expanded ? ChevronDown : ChevronRight; + + return ( + <> + + + + + + {expanded && + group.items.map(({ control, requirements }) => ( + + ))} + + ); +} 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..a15f5f75e1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedControlRow.tsx @@ -0,0 +1,125 @@ +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 { useParams, useRouter } from 'next/navigation'; +import { getStatusBadge } from './framework-controls-shared'; + +export function GroupedControlRow({ + control, + requirements, + tasks, + evidenceSubmissions, +}: { + control: FrameworkInstanceWithControls['controls'][number]; + requirements: Array<{ id: string; name: string; identifier: string }>; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions: EvidenceSubmissionInfo[]; +}) { + const { orgId, frameworkInstanceId } = useParams<{ + orgId: string; + frameworkInstanceId: string; + }>(); + const router = useRouter(); + + 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 = () => { + router.push(controlHref); + }; + + 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} + +
+
+ + ); +} From b4be0f941433f7f3993d4c3e5ab277f74bec1215 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 18:06:08 -0400 Subject: [PATCH 09/57] feat(app): switch between flat and grouped controls view based on family data Co-Authored-By: Claude Sonnet 4.6 --- .../components/FrameworkDetailContent.tsx | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) 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..d0414c1836 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 { 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,15 @@ export function FrameworkDetailContent({ const evidenceSubmissions = framework.evidenceSubmissions || []; const requirementDefinitions = framework.requirementDefinitions || []; + const hasControlFamilies = useMemo( + () => + frameworkInstanceWithControls.controls.some( + (c: { controlTemplate?: { controlFamily?: unknown } }) => + c.controlTemplate?.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 +204,21 @@ export function FrameworkDetailContent({ )} - + {hasControlFamilies ? ( + + ) : ( + + )} From 931bd900a46323fcc348030bbfcf401ba7b823d8 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 21 May 2026 18:06:35 -0400 Subject: [PATCH 10/57] feat(framework-editor): add controlFamily column to control template editor Co-Authored-By: Claude Sonnet 4.6 --- .../app/(pages)/controls/ControlsClientPage.tsx | 17 ++++++++++++++++- .../(pages)/controls/hooks/useChangeTracking.ts | 5 ++++- .../app/(pages)/controls/types.ts | 1 + 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx index eb73f364c7..d14027db67 100644 --- a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx @@ -94,6 +94,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 +103,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 +122,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) => ({ @@ -188,6 +190,18 @@ 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, @@ -350,6 +364,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId id: simpleUUID(), name: 'New Control', description: '', + controlFamily: null, policyTemplates: [], requirements: [], taskTemplates: [], diff --git a/apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts b/apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts index 23f5bdff22..7901d6c198 100644 --- a/apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts +++ b/apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts @@ -8,11 +8,12 @@ export interface ControlMutations { createControl: (data: { name: string | null; description: string | null; + controlFamily: string | null; documentTypes: string[]; }) => Promise<{ id: string }>; updateControl: ( id: string, - data: { name: string; description: string; documentTypes: string[] }, + data: { name: string; description: string; controlFamily: string | null; documentTypes: string[] }, ) => Promise; deleteControl: (id: string) => Promise; } @@ -151,6 +152,7 @@ export const useChangeTracking = ( const newControl = await mutations.createControl({ name: row.name, description: row.description, + controlFamily: row.controlFamily, documentTypes: row.documentTypes, }); results.successes.push(`Created: ${row.name}`); @@ -186,6 +188,7 @@ export const useChangeTracking = ( await mutations.updateControl(id, { name: row.name, description: row.description || '', + controlFamily: row.controlFamily, documentTypes: row.documentTypes, }); results.successes.push(`Updated: ${row.name}`); diff --git a/apps/framework-editor/app/(pages)/controls/types.ts b/apps/framework-editor/app/(pages)/controls/types.ts index 762b1ab5f4..257772985b 100644 --- a/apps/framework-editor/app/(pages)/controls/types.ts +++ b/apps/framework-editor/app/(pages)/controls/types.ts @@ -30,6 +30,7 @@ export type ControlsPageGridData = { id: string; name: string | null; description: string | null; + controlFamily: string | null; policyTemplates: ItemWithName[]; requirements: RequirementGridItem[]; taskTemplates: ItemWithName[]; From 97971fdf8e745c92b221924491ac32815f0874bc Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 22 May 2026 10:42:34 -0400 Subject: [PATCH 11/57] feat(framework-editor): add ComboboxCell for control family selection Replace EditableCell with a searchable ComboboxCell dropdown for the controlFamily column. Shows existing families from current data, supports filtering, and allows creating new families inline. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(pages)/controls/ControlsClientPage.tsx | 28 ++-- .../app/components/table/ComboboxCell.tsx | 145 ++++++++++++++++++ .../app/components/table/index.ts | 1 + 3 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 apps/framework-editor/app/components/table/ComboboxCell.tsx diff --git a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx index d14027db67..0a91745fc9 100644 --- a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx @@ -17,6 +17,7 @@ import { type ExistingItemRaw, } from '../../components/AddExistingItemDialog'; import { + ComboboxCell, DateCell, EditableCell, MultiSelectCell, @@ -193,14 +194,23 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId columnHelper.accessor('controlFamily', { header: 'Control Family', size: 200, - cell: ({ row, getValue }) => ( - - ), + cell: ({ row, getValue }) => { + const uniqueFamilies = [...new Set( + data + .map((r) => r.controlFamily) + .filter((f): f is string => f != null && f !== ''), + )].sort(); + return ( + + ); + }, }), columnHelper.accessor('policyTemplates', { header: 'Linked Policies', @@ -332,7 +342,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId ), }), ], - [updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate, frameworkId], + [data, updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate, frameworkId], ); const [sorting, setSorting] = useState([]); diff --git a/apps/framework-editor/app/components/table/ComboboxCell.tsx b/apps/framework-editor/app/components/table/ComboboxCell.tsx new file mode 100644 index 0000000000..fc7ad6fbc1 --- /dev/null +++ b/apps/framework-editor/app/components/table/ComboboxCell.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { Check, ChevronDown, Plus, Search } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +interface ComboboxCellProps { + value: string | null; + rowId: string; + columnId: string; + options: string[]; + onUpdate: (rowId: string, columnId: string, value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +export function ComboboxCell({ + value, + rowId, + columnId, + options, + onUpdate, + disabled = false, + placeholder = 'Select...', +}: ComboboxCellProps) { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const containerRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + setSearch(''); + } + }; + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + const trimmedSearch = search.trim(); + const normalizedSearch = trimmedSearch.toLowerCase(); + + const filteredOptions = trimmedSearch + ? options.filter((opt) => opt.toLowerCase().includes(normalizedSearch)) + : options; + + const exactMatchExists = options.some( + (opt) => opt.toLowerCase() === normalizedSearch, + ); + const showCreateOption = trimmedSearch !== '' && !exactMatchExists; + + const handleSelect = (selected: string) => { + const newValue = selected === value ? '' : selected; + onUpdate(rowId, columnId, newValue); + setIsOpen(false); + setSearch(''); + }; + + const handleCreate = () => { + onUpdate(rowId, columnId, trimmedSearch); + setIsOpen(false); + setSearch(''); + }; + + if (disabled) { + return ( + + {value ?? ''} + + ); + } + + return ( +
+
setIsOpen(!isOpen)} + > + + {value || placeholder} + + +
+ + {isOpen && ( +
+
+ + setSearch(e.target.value)} + className="w-full bg-transparent text-sm outline-none" + placeholder="Search..." + /> +
+
+ {filteredOptions.map((option) => ( + + ))} + {filteredOptions.length === 0 && !showCreateOption && ( +
+ No options found +
+ )} + {showCreateOption && ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/framework-editor/app/components/table/index.ts b/apps/framework-editor/app/components/table/index.ts index 80355acd98..ace4a760ea 100644 --- a/apps/framework-editor/app/components/table/index.ts +++ b/apps/framework-editor/app/components/table/index.ts @@ -1,3 +1,4 @@ +export { ComboboxCell } from './ComboboxCell'; export { DateCell } from './DateCell'; export { EditableCell } from './EditableCell'; export { MarkdownCell } from './MarkdownCell'; From d863b48a292bff934bd7032c7caa5deb0248204e Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 22 May 2026 10:53:39 -0400 Subject: [PATCH 12/57] feat(app): default expand all families and add family filter Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FamilyFilterDropdown.tsx | 86 +++++++++++++++++++ .../components/FrameworkControlsGrouped.tsx | 56 +++++++++--- 2 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx 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..45df46db52 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx @@ -0,0 +1,86 @@ +'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'; + +interface FamilyFilterDropdownProps { + allFamilyNames: string[]; + selectedFamilies: Set; + onToggleFamily: (family: string) => void; + onClear: () => void; +} + +export function FamilyFilterDropdown({ + allFamilyNames, + selectedFamilies, + onToggleFamily, + onClear, +}: FamilyFilterDropdownProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) 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'; + + return ( +
+
+ + {hasFilter && ( + + )} +
+ + {open && ( +
+
+ {allFamilyNames.map((family) => { + const isSelected = selectedFamilies.has(family); + const Icon = isSelected ? CheckboxCheckedFilled : Checkbox; + + return ( + + ); + })} +
+
+ )} +
+ ); +} 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 index 541ac3f6df..aa0d6d1e80 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx @@ -18,7 +18,8 @@ import { Text, } from '@trycompai/design-system'; import { ChevronDown, ChevronRight, Search } from '@trycompai/design-system/icons'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { FamilyFilterDropdown } from './FamilyFilterDropdown'; import { buildControlItems, buildRequirementMap, @@ -83,6 +84,8 @@ export function FrameworkControlsGrouped({ }) { const [searchTerm, setSearchTerm] = useState(''); const [expandedFamilies, setExpandedFamilies] = useState>(new Set()); + const [initialized, setInitialized] = useState(false); + const [selectedFamilyFilter, setSelectedFamilyFilter] = useState>(new Set()); const requirementMap = useMemo( () => buildRequirementMap(requirementDefinitions), @@ -107,11 +110,24 @@ export function FrameworkControlsGrouped({ ); }, [allItems, searchTerm]); - const groups = useMemo(() => groupByFamily(filteredItems), [filteredItems]); + 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]); + + useEffect(() => { + if (!initialized && allFamilyNames.length > 0) { + setExpandedFamilies(new Set(allFamilyNames)); + setInitialized(true); + } + }, [initialized, allFamilyNames]); const isSearching = searchTerm.trim().length > 0; - const allFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); - const hasAnyExpanded = isSearching || expandedFamilies.size > 0; + const allExpanded = groups.length > 0 && groups.every((g) => expandedFamilies.has(g.family)); const handleToggleFamily = (family: string) => { setExpandedFamilies((prev) => { @@ -126,7 +142,7 @@ export function FrameworkControlsGrouped({ }; const handleToggleAll = () => { - if (hasAnyExpanded && !isSearching) { + if (allExpanded) { setExpandedFamilies(new Set()); } else { setExpandedFamilies(new Set(allFamilyNames)); @@ -134,11 +150,23 @@ export function FrameworkControlsGrouped({ }; const handleSearchChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setSearchTerm(value); - if (!value.trim()) { - setExpandedFamilies(new Set()); - } + setSearchTerm(e.target.value); + }; + + const handleToggleFamilyFilter = (family: string) => { + setSelectedFamilyFilter((prev) => { + const next = new Set(prev); + if (next.has(family)) { + next.delete(family); + } else { + next.add(family); + } + return next; + }); + }; + + const handleClearFamilyFilter = () => { + setSelectedFamilyFilter(new Set()); }; const isFamilyExpanded = (family: string) => isSearching || expandedFamilies.has(family); @@ -159,9 +187,15 @@ export function FrameworkControlsGrouped({ />
+ {!isSearching && ( )}

From 45ebb3661ffdb0542b80dcbc1f999dac61de091c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 22 May 2026 10:56:38 -0400 Subject: [PATCH 13/57] fix(app): improve family header contrast and filter dropdown UX - Stronger bg-muted/60 on family header rows for clearer group separation - Add search input to family filter dropdown with auto-reset on close - Cap dropdown list at 256px with overflow scroll (search stays pinned) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FamilyFilterDropdown.tsx | 20 +++++++++++++++++-- .../components/FrameworkControlsGrouped.tsx | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) 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 index 45df46db52..94f9d6c111 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx @@ -23,10 +23,14 @@ export function FamilyFilterDropdown({ onClear, }: FamilyFilterDropdownProps) { const [open, setOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); const containerRef = useRef(null); useEffect(() => { - if (!open) return; + if (!open) { + setSearchTerm(''); + return; + } const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { @@ -41,6 +45,10 @@ export function FamilyFilterDropdown({ const hasFilter = selectedFamilies.size > 0; const label = hasFilter ? `Families (${selectedFamilies.size})` : 'Families'; + const filteredFamilies = allFamilyNames.filter((f) => + f.toLowerCase().includes(searchTerm.toLowerCase()), + ); + return (
@@ -61,8 +69,16 @@ export function FamilyFilterDropdown({ {open && (
+ setSearchTerm(e.target.value)} + className="w-full border-b border-border bg-transparent px-3 py-1.5 text-sm outline-none" + autoFocus + />
- {allFamilyNames.map((family) => { + {filteredFamilies.map((family) => { const isSelected = selectedFamilies.has(family); const Icon = isSelected ? CheckboxCheckedFilled : Checkbox; 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 index aa0d6d1e80..728d293957 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx @@ -255,7 +255,7 @@ function FamilySection({ return ( <> - + From 0c380bf6a87dda2145492b142ba5e418338dab3e Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 22 May 2026 11:04:26 -0400 Subject: [PATCH 16/57] fix(app): use default size for collapse all button Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FrameworkControlsGrouped.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 053841732f..20bfd0e213 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx @@ -194,7 +194,7 @@ export function FrameworkControlsGrouped({ onClear={handleClearFamilyFilter} /> {!isSearching && ( - )} From 3332dfacc520c5293c658b1f8bb9ad970343fcfb Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 22 May 2026 11:04:58 -0400 Subject: [PATCH 17/57] fix(app): use ghost variant for collapse all button Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FrameworkControlsGrouped.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 20bfd0e213..0ddb5d68b0 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx @@ -194,7 +194,7 @@ export function FrameworkControlsGrouped({ onClear={handleClearFamilyFilter} /> {!isSearching && ( - )} From 8d2021f00cb47ad2e68c8b6b0bf838aaf446c16f Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 22 May 2026 11:07:29 -0400 Subject: [PATCH 18/57] fix(framework-editor): add clear option to ComboboxCell Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/components/table/ComboboxCell.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/framework-editor/app/components/table/ComboboxCell.tsx b/apps/framework-editor/app/components/table/ComboboxCell.tsx index fc7ad6fbc1..fd00f86552 100644 --- a/apps/framework-editor/app/components/table/ComboboxCell.tsx +++ b/apps/framework-editor/app/components/table/ComboboxCell.tsx @@ -107,6 +107,15 @@ export function ComboboxCell({ />
+ {value && !trimmedSearch && ( + + )} {filteredOptions.map((option) => (
+ {families.length > 0 && ( + + )} {frameworkId && (