From b1037104271c5c750bc7a5d111c98d3672b39c40 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 29 May 2026 15:58:23 -0400 Subject: [PATCH 1/8] fix(api): avoid registering duplicated device when the status is flipping to compliant --- .../device-registration.helpers.spec.ts | 157 ++++++++++++++++++ .../device-registration.helpers.ts | 27 +++ 2 files changed, 184 insertions(+) create mode 100644 apps/api/src/device-agent/device-registration.helpers.spec.ts diff --git a/apps/api/src/device-agent/device-registration.helpers.spec.ts b/apps/api/src/device-agent/device-registration.helpers.spec.ts new file mode 100644 index 0000000000..2e2f6a8862 --- /dev/null +++ b/apps/api/src/device-agent/device-registration.helpers.spec.ts @@ -0,0 +1,157 @@ +jest.mock('@db', () => ({ + db: { + device: { + findUnique: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + }, +})); + +import { db } from '@db'; +import { + registerWithSerial, + registerWithoutSerial, +} from './device-registration.helpers'; +import type { RegisterDeviceDto } from './dto/register-device.dto'; + +const mockDb = db as jest.Mocked; + +const orgId = 'org_test'; +const member = { id: 'mem_test' }; + +function makeDto( + overrides: Partial = {}, +): RegisterDeviceDto { + return { + organizationId: orgId, + hostname: 'my-laptop.local', + name: 'My Laptop', + platform: 'macos', + osVersion: '15.0', + serialNumber: 'ABC123', + hardwareModel: 'MacBookPro18,1', + agentVersion: '1.0.0', + ...overrides, + }; +} + +describe('registerWithSerial — orphan adoption', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adopts an existing serial-less row for the same hostname+member instead of creating a duplicate', async () => { + // The bug scenario: agent first registered without a serial (e.g. cold- + // boot `system_profiler` returned empty), creating a row with + // serialNumber=null. A later registration succeeds in reading the + // serial. Without adoption, registerWithSerial would create a brand-new + // row and the old one would stay orphaned. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_orphan', + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_orphan', + }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.findFirst).toHaveBeenCalledWith({ + where: { + hostname: dto.hostname, + memberId: member.id, + organizationId: orgId, + serialNumber: null, + }, + select: { id: true }, + }); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_orphan' }, + data: expect.objectContaining({ + serialNumber: dto.serialNumber, + hostname: dto.hostname, + }), + }); + expect(mockDb.device.create).not.toHaveBeenCalled(); + }); + + it('creates a fresh row when no orphan exists', async () => { + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev_new' }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.update).not.toHaveBeenCalled(); + expect(mockDb.device.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + serialNumber: dto.serialNumber, + memberId: member.id, + organizationId: orgId, + }), + }); + }); + + it('updates the existing serial-match row without looking for an orphan', async () => { + // Plain re-registration of an already-known device — must not trigger + // the orphan lookup or do anything other than an in-place update. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue({ + id: 'dev_existing', + memberId: member.id, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_existing', + }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.findFirst).not.toHaveBeenCalled(); + expect(mockDb.device.create).not.toHaveBeenCalled(); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_existing' }, + data: expect.objectContaining({ hostname: dto.hostname }), + }); + }); + + it('only adopts an orphan that belongs to the same member', async () => { + // Safety: the orphan lookup is scoped by memberId, so another member's + // serial-less row for the same hostname must not be hijacked. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev_new' }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + const call = (mockDb.device.findFirst as jest.Mock).mock.calls[0]?.[0]; + expect(call?.where.memberId).toBe(member.id); + expect(call?.where.serialNumber).toBeNull(); + }); +}); + +describe('registerWithoutSerial — unchanged behavior', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates the matching null-serial row when one exists', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_null', + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev_null' }); + + const dto = makeDto({ serialNumber: undefined }); + await registerWithoutSerial({ member, dto }); + + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_null' }, + data: expect.any(Object), + }); + expect(mockDb.device.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/device-agent/device-registration.helpers.ts b/apps/api/src/device-agent/device-registration.helpers.ts index e170c4e8b9..57c6e5ac57 100644 --- a/apps/api/src/device-agent/device-registration.helpers.ts +++ b/apps/api/src/device-agent/device-registration.helpers.ts @@ -46,6 +46,33 @@ export async function registerWithSerial({ }); } + // Adopt any prior serial-less registration for the same physical device + // before creating a new row. The agent's serial extraction can return + // undefined on a cold boot (e.g. macOS `system_profiler` cache not yet + // built) and a real value on a subsequent boot — without this, the second + // registration creates a duplicate while the first row stays orphaned and + // never receives another check-in (frozen at its old compliance state). + const orphan = await db.device.findFirst({ + where: { + hostname: dto.hostname, + memberId: member.id, + organizationId: dto.organizationId, + serialNumber: null, + }, + select: { id: true }, + }); + + if (orphan) { + return db.device.update({ + where: { id: orphan.id }, + data: { + ...updateData, + hostname: dto.hostname, + serialNumber: dto.serialNumber!, + }, + }); + } + return db.device.create({ data: { ...updateData, From 85f0b1e52d3f978935fddf6858eec6f6239a4e69 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 29 May 2026 15:59:12 -0400 Subject: [PATCH 2/8] fix(device-agent): make serial number extraction more robust on agent --- packages/device-agent/src/main/device-info.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/device-agent/src/main/device-info.ts b/packages/device-agent/src/main/device-info.ts index 8b5811977c..a4c0f74593 100644 --- a/packages/device-agent/src/main/device-info.ts +++ b/packages/device-agent/src/main/device-info.ts @@ -73,11 +73,19 @@ function getOSVersion(platform: DevicePlatform): string { function getSerialNumber(platform: DevicePlatform): string | undefined { try { if (platform === 'macos') { + // `$NF` (last field) handles both "Serial Number: ABC" (3 fields) and + // "Serial Number (system): ABC" (4 fields, newer macOS). The fixed + // `$4` we used before silently returned empty on the 3-field variant, + // which made the agent register without a serial and later create a + // duplicate row once `system_profiler`'s cache warmed up and produced + // the 4-field variant. `exit` stops after the first match so any + // sub-component "Serial Number" lines added by future hardware can't + // smuggle a second value into the output. return ( - execSync("system_profiler SPHardwareDataType | awk '/Serial Number/{print $4}'", { - encoding: 'utf-8', - timeout: 5000, - }).trim() || undefined + execSync( + "system_profiler SPHardwareDataType | awk '/Serial Number/{print $NF; exit}'", + { encoding: 'utf-8', timeout: 5000 }, + ).trim() || undefined ); } if (platform === 'linux') { From 424d863458ccfd9059611515239e86895fee6c5f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Sat, 30 May 2026 14:58:08 -0400 Subject: [PATCH 3/8] fix(app): resolve small linear tasks --- .../FrameworkRequirementsGrouped.tsx | 2 -- .../components/PlatformIntegrations.tsx | 16 ++++++------ .../components/integration-search.test.ts | 25 +++++++++++++++++++ .../components/integration-search.ts | 8 ++++++ apps/app/src/instrumentation-client.ts | 12 +-------- 5 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx index facf74d3c3..90608327a9 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx @@ -5,7 +5,6 @@ import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import type { Control, FrameworkEditorRequirement, Task } from '@db'; import { Button, - Heading, InputGroup, InputGroupAddon, InputGroupInput, @@ -126,7 +125,6 @@ export function FrameworkRequirementsGrouped({ return (
- Requirements ({filteredItems.length})
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx index 05092eafde..d222b51e35 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx @@ -38,6 +38,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { CATEGORIES, type Integration, type IntegrationCategory } from '../data/integrations'; +import { matchesIntegrationSearch } from './integration-search'; import { SearchInput } from './SearchInput'; import { TaskCard, TaskCardSkeleton } from './TaskCard'; @@ -288,18 +289,15 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg // Search filter if (searchQuery.trim()) { - const query = searchQuery.toLowerCase().trim(); - const terms = query.split(' ').filter(Boolean); + const query = searchQuery.trim(); filtered = filtered.filter((item) => { - if (item.type === 'platform') { - const searchText = - `${item.provider.name} ${item.provider.description} ${item.provider.category}`.toLowerCase(); - return terms.every((term) => searchText.includes(term)); - } const searchText = - `${item.integration.name} ${item.integration.description} ${item.integration.category} ${item.integration.examplePrompts.join(' ')}`.toLowerCase(); - return terms.every((term) => searchText.includes(term)); + item.type === 'platform' + ? `${item.provider.name} ${item.provider.description} ${item.provider.category}` + : `${item.integration.name} ${item.integration.description} ${item.integration.category} ${item.integration.examplePrompts.join(' ')}`; + + return matchesIntegrationSearch(searchText, query); }); } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts new file mode 100644 index 0000000000..520ce14d85 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { matchesIntegrationSearch } from './integration-search'; + +describe('matchesIntegrationSearch', () => { + it('matches SAST as its own searchable token', () => { + expect(matchesIntegrationSearch('SAST scanning configuration', 'sast')).toBe(true); + }); + + it('does not match SAST inside disaster', () => { + expect(matchesIntegrationSearch('Datto backup and disaster recovery platform', 'sast')).toBe(false); + }); + + it('keeps prefix matching for provider names', () => { + expect(matchesIntegrationSearch('GitHub code hosting Development', 'git')).toBe(true); + }); + + it('requires every search term to match a token prefix', () => { + expect(matchesIntegrationSearch('Aikido code security scanner', 'code scan')).toBe(true); + expect(matchesIntegrationSearch('Aikido code security scanner', 'code backup')).toBe(false); + }); + + it('handles punctuation in search queries', () => { + expect(matchesIntegrationSearch('SOC 2 compliance evidence', 'soc-2')).toBe(true); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts new file mode 100644 index 0000000000..67b4d0745d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts @@ -0,0 +1,8 @@ +export function matchesIntegrationSearch(searchText: string, searchQuery: string): boolean { + const terms = searchQuery.toLowerCase().match(/[a-z0-9]+/g) ?? []; + if (terms.length === 0) return true; + + const tokens = searchText.toLowerCase().match(/[a-z0-9]+/g) ?? []; + + return terms.every((term) => tokens.some((token) => token.startsWith(term))); +} diff --git a/apps/app/src/instrumentation-client.ts b/apps/app/src/instrumentation-client.ts index 49774232c3..cd27359c8f 100644 --- a/apps/app/src/instrumentation-client.ts +++ b/apps/app/src/instrumentation-client.ts @@ -6,17 +6,7 @@ import { initBotId } from 'botid/client/core'; import * as Sentry from '@sentry/nextjs'; initBotId({ - protect: [ - { path: '/api/chat', method: 'POST' }, - { - path: `${process.env.NEXT_PUBLIC_ENTERPRISE_API_URL}/api/tasks-automations/chat`, - method: 'POST', - }, - { - path: `${process.env.NEXT_PUBLIC_ENTERPRISE_API_URL}/api/tasks-automations/errors`, - method: 'POST', - }, - ], + protect: [{ path: '/api/chat', method: 'POST' }], }); Sentry.init({ From 4db91546dfefa9b010bedbc65aedf7a781ce6a0b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Sat, 30 May 2026 15:34:19 -0400 Subject: [PATCH 4/8] fix(app): search integrations by name --- .../components/PlatformIntegrations.tsx | 9 +++---- .../components/integration-search.test.ts | 27 +++++++++---------- .../components/integration-search.ts | 12 ++++++--- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx index d222b51e35..f7457e2abb 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/PlatformIntegrations.tsx @@ -38,7 +38,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { CATEGORIES, type Integration, type IntegrationCategory } from '../data/integrations'; -import { matchesIntegrationSearch } from './integration-search'; +import { matchesIntegrationNameSearch } from './integration-search'; import { SearchInput } from './SearchInput'; import { TaskCard, TaskCardSkeleton } from './TaskCard'; @@ -292,12 +292,9 @@ export function PlatformIntegrations({ className, taskTemplates }: PlatformInteg const query = searchQuery.trim(); filtered = filtered.filter((item) => { - const searchText = - item.type === 'platform' - ? `${item.provider.name} ${item.provider.description} ${item.provider.category}` - : `${item.integration.name} ${item.integration.description} ${item.integration.category} ${item.integration.examplePrompts.join(' ')}`; + const name = item.type === 'platform' ? item.provider.name : item.integration.name; - return matchesIntegrationSearch(searchText, query); + return matchesIntegrationNameSearch(name, query); }); } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts index 520ce14d85..3e94b8dfbd 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.test.ts @@ -1,25 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { matchesIntegrationSearch } from './integration-search'; +import { matchesIntegrationNameSearch } from './integration-search'; -describe('matchesIntegrationSearch', () => { - it('matches SAST as its own searchable token', () => { - expect(matchesIntegrationSearch('SAST scanning configuration', 'sast')).toBe(true); +describe('matchesIntegrationNameSearch', () => { + it('matches integration names', () => { + expect(matchesIntegrationNameSearch('GitHub', 'git')).toBe(true); + expect(matchesIntegrationNameSearch('Google Workspace', 'workspace')).toBe(true); }); - it('does not match SAST inside disaster', () => { - expect(matchesIntegrationSearch('Datto backup and disaster recovery platform', 'sast')).toBe(false); + it('does not match terms that only appear in descriptions', () => { + expect(matchesIntegrationNameSearch('Datto', 'sast')).toBe(false); }); - it('keeps prefix matching for provider names', () => { - expect(matchesIntegrationSearch('GitHub code hosting Development', 'git')).toBe(true); + it('requires every search term to match the name', () => { + expect(matchesIntegrationNameSearch('Google Workspace', 'google work')).toBe(true); + expect(matchesIntegrationNameSearch('Google Workspace', 'google backup')).toBe(false); }); - it('requires every search term to match a token prefix', () => { - expect(matchesIntegrationSearch('Aikido code security scanner', 'code scan')).toBe(true); - expect(matchesIntegrationSearch('Aikido code security scanner', 'code backup')).toBe(false); - }); - - it('handles punctuation in search queries', () => { - expect(matchesIntegrationSearch('SOC 2 compliance evidence', 'soc-2')).toBe(true); + it('handles punctuation in name searches', () => { + expect(matchesIntegrationNameSearch('SOC 2', 'soc-2')).toBe(true); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts index 67b4d0745d..7c97a0b31b 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts +++ b/apps/app/src/app/(app)/[orgId]/integrations/components/integration-search.ts @@ -1,8 +1,14 @@ -export function matchesIntegrationSearch(searchText: string, searchQuery: string): boolean { +const normalizeSearchText = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + +export function matchesIntegrationNameSearch(name: string, searchQuery: string): boolean { const terms = searchQuery.toLowerCase().match(/[a-z0-9]+/g) ?? []; if (terms.length === 0) return true; - const tokens = searchText.toLowerCase().match(/[a-z0-9]+/g) ?? []; + const normalizedName = normalizeSearchText(name); - return terms.every((term) => tokens.some((token) => token.startsWith(term))); + return terms.every((term) => normalizedName.includes(term)); } From d8ce45093d87a3f7222fc14123ca88a82043f9c5 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Sat, 30 May 2026 16:19:29 -0400 Subject: [PATCH 5/8] fix(app): resolve archived policy and compliance UI tasks --- .../admin-policies.controller.ts | 2 +- apps/api/src/openapi/operation-metadata.ts | 8 +- .../src/policies/dto/policy-responses.dto.ts | 7 ++ .../src/policies/policies.controller.spec.ts | 41 ++++++++- apps/api/src/policies/policies.controller.ts | 12 ++- .../api/src/policies/policies.service.spec.ts | 17 +++- apps/api/src/policies/policies.service.ts | 26 ++++-- .../schemas/get-all-policies.responses.ts | 1 + .../src/policies/schemas/policy-operations.ts | 2 +- .../components/EditableSOAFields.test.tsx | 30 +++++++ .../components/EditableSOAFields.tsx | 89 +++++++++---------- .../components/FrameworkControlsGrouped.tsx | 55 ++++++------ .../FrameworkRequirementsGrouped.tsx | 39 +++++--- .../components/family-expansion-state.test.ts | 72 +++++++++++++++ .../components/family-expansion-state.ts | 68 ++++++++++++++ .../people/all/components/MemberRow.tsx | 3 +- .../MemberRowBackgroundCheck.test.tsx | 21 +++-- .../(overview)/hooks/usePoliciesOverview.ts | 5 +- .../(overview)/lib/compute-overview.ts | 8 +- .../[orgId]/policies/(overview)/page.tsx | 7 +- .../all/components/PoliciesTableDS.tsx | 3 +- .../policies/all/components/PolicyFilters.tsx | 9 +- .../all/components/policies-table-columns.tsx | 19 ++-- .../policies/lib/policy-archive-state.test.ts | 18 ++++ .../policies/lib/policy-archive-state.ts | 8 ++ packages/docs/openapi.json | 33 ++++++- 26 files changed, 468 insertions(+), 135 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/family-expansion-state.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/family-expansion-state.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/lib/policy-archive-state.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/policies/lib/policy-archive-state.ts diff --git a/apps/api/src/admin-organizations/admin-policies.controller.ts b/apps/api/src/admin-organizations/admin-policies.controller.ts index 98caca9679..c3d56fa8e0 100644 --- a/apps/api/src/admin-organizations/admin-policies.controller.ts +++ b/apps/api/src/admin-organizations/admin-policies.controller.ts @@ -44,7 +44,7 @@ export class AdminPoliciesController { @Get(':orgId/policies') @ApiOperation({ summary: 'List all policies for an organization (admin)' }) async list(@Param('orgId') orgId: string) { - return this.policiesService.findAll(orgId); + return this.policiesService.findAll({ organizationId: orgId }); } @Post(':orgId/policies') diff --git a/apps/api/src/openapi/operation-metadata.ts b/apps/api/src/openapi/operation-metadata.ts index f18d6971b8..b843ee0715 100644 --- a/apps/api/src/openapi/operation-metadata.ts +++ b/apps/api/src/openapi/operation-metadata.ts @@ -50,7 +50,7 @@ const CORE_OPERATION_METADATA: Record = { PoliciesController_getAllPolicies_v1: { summary: 'List compliance policies', description: - 'Lists compliance policies for the organization. Use this to find a policy by name, look up a policy ID, browse drafts, or get an overview of all policies for SOC 2, ISO 27001, HIPAA, and GDPR workflows. Returns id, name, status, department, and other metadata for each policy. Pass excludeContent=true to skip the heavy TipTap content fields — recommended when you only need to identify a policy. To read or edit a single policy in detail, fetch it by ID via get-compliance-policy.', + 'Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata.', codeSamples: [ { lang: 'bash', @@ -64,6 +64,12 @@ const CORE_OPERATION_METADATA: Record = { source: 'curl --request GET --url "https://api.trycomp.ai/v1/policies?excludeContent=true" --header "X-API-Key: $COMP_AI_API_KEY"', }, + { + lang: 'bash', + label: 'List policies including archived', + source: + 'curl --request GET --url "https://api.trycomp.ai/v1/policies?includeArchived=true" --header "X-API-Key: $COMP_AI_API_KEY"', + }, ], }, PoliciesController_createPolicy_v1: { diff --git a/apps/api/src/policies/dto/policy-responses.dto.ts b/apps/api/src/policies/dto/policy-responses.dto.ts index 755d708cd2..74c05b18f1 100644 --- a/apps/api/src/policies/dto/policy-responses.dto.ts +++ b/apps/api/src/policies/dto/policy-responses.dto.ts @@ -95,6 +95,13 @@ export class PolicyResponseDto { }) isArchived: boolean; + @ApiProperty({ + description: 'When the policy was archived by framework sync', + example: '2024-02-01T00:00:00.000Z', + nullable: true, + }) + archivedAt?: Date; + @ApiProperty({ description: 'When the policy was created', example: '2024-01-01T00:00:00.000Z', diff --git a/apps/api/src/policies/policies.controller.spec.ts b/apps/api/src/policies/policies.controller.spec.ts index 408c632437..b9a5aa5998 100644 --- a/apps/api/src/policies/policies.controller.spec.ts +++ b/apps/api/src/policies/policies.controller.spec.ts @@ -25,6 +25,7 @@ jest.mock('@db', () => ({ db: { policy: { findFirst: jest.fn(), + findUnique: jest.fn(), update: jest.fn(), }, control: { @@ -43,6 +44,10 @@ jest.mock('@db', () => ({ findFirst: jest.fn(), update: jest.fn(), }, + frameworkControlPolicyLink: { + deleteMany: jest.fn(), + }, + $transaction: jest.fn(), }, Frequency: { monthly: 'monthly', @@ -152,8 +157,10 @@ describe('PoliciesController', () => { const result = await controller.getAllPolicies(orgId, mockAuthContext); - expect(policiesService.findAll).toHaveBeenCalledWith(orgId, { + expect(policiesService.findAll).toHaveBeenCalledWith({ + organizationId: orgId, excludeContent: false, + includeArchived: false, }); expect(result).toEqual({ data: mockPolicies, @@ -167,8 +174,10 @@ describe('PoliciesController', () => { await controller.getAllPolicies(orgId, mockAuthContext, 'true'); - expect(policiesService.findAll).toHaveBeenCalledWith(orgId, { + expect(policiesService.findAll).toHaveBeenCalledWith({ + organizationId: orgId, excludeContent: true, + includeArchived: false, }); }); @@ -177,8 +186,22 @@ describe('PoliciesController', () => { await controller.getAllPolicies(orgId, mockAuthContext, 'false'); - expect(policiesService.findAll).toHaveBeenCalledWith(orgId, { + expect(policiesService.findAll).toHaveBeenCalledWith({ + organizationId: orgId, + excludeContent: false, + includeArchived: false, + }); + }); + + it('should pass includeArchived=true to service when query param is "true"', async () => { + mockPoliciesService.findAll.mockResolvedValue([]); + + await controller.getAllPolicies(orgId, mockAuthContext, undefined, 'true'); + + expect(policiesService.findAll).toHaveBeenCalledWith({ + organizationId: orgId, excludeContent: false, + includeArchived: true, }); }); @@ -621,7 +644,19 @@ describe('PoliciesController', () => { describe('removePolicyControl', () => { it('should disconnect control from policy and return success', async () => { const { db } = require('@db'); + db.policy.findUnique.mockResolvedValue({ controls: [] }); db.policy.update.mockResolvedValue({}); + db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => + callback({ + policy: { + findUnique: db.policy.findUnique, + update: db.policy.update, + }, + frameworkControlPolicyLink: { + deleteMany: db.frameworkControlPolicyLink.deleteMany, + }, + }), + ); const result = await controller.removePolicyControl( 'pol_1', diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 5faab9b76f..7416cb8e7a 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -122,6 +122,13 @@ export class PoliciesController { description: 'When true, omits `content` and `draftContent` from each policy in the response. Use this when listing policies to find one by name/ID — fetch the full content via GET /v1/policies/{id} after.', }) + @ApiQuery({ + name: 'includeArchived', + required: false, + type: Boolean, + description: + 'When true, includes user-archived and framework-sync-archived policies in the response. Defaults to false.', + }) @ApiExtension('x-speakeasy-mcp', { name: 'list-policies' }) @ApiResponse(GET_ALL_POLICIES_RESPONSES[200]) @ApiResponse(GET_ALL_POLICIES_RESPONSES[401]) @@ -129,9 +136,12 @@ export class PoliciesController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, @Query('excludeContent') excludeContent?: string, + @Query('includeArchived') includeArchived?: string, ) { - const policies = await this.policiesService.findAll(organizationId, { + const policies = await this.policiesService.findAll({ + organizationId, excludeContent: excludeContent === 'true', + includeArchived: includeArchived === 'true', }); return { diff --git a/apps/api/src/policies/policies.service.spec.ts b/apps/api/src/policies/policies.service.spec.ts index ac8bd73243..218309eda8 100644 --- a/apps/api/src/policies/policies.service.spec.ts +++ b/apps/api/src/policies/policies.service.spec.ts @@ -140,7 +140,7 @@ describe('PoliciesService', () => { }); it('includes content and draftContent in the select by default', async () => { - await service.findAll(orgId); + await service.findAll({ organizationId: orgId }); const callArgs = db.policy.findMany.mock.calls[0][0]; expect(callArgs.select.content).toBe(true); @@ -148,7 +148,7 @@ describe('PoliciesService', () => { }); it('includes content and draftContent when excludeContent is false', async () => { - await service.findAll(orgId, { excludeContent: false }); + await service.findAll({ organizationId: orgId, excludeContent: false }); const callArgs = db.policy.findMany.mock.calls[0][0]; expect(callArgs.select.content).toBe(true); @@ -156,7 +156,7 @@ describe('PoliciesService', () => { }); it('omits content and draftContent from select when excludeContent is true', async () => { - await service.findAll(orgId, { excludeContent: true }); + await service.findAll({ organizationId: orgId, excludeContent: true }); const callArgs = db.policy.findMany.mock.calls[0][0]; expect(callArgs.select.content).toBeUndefined(); @@ -169,13 +169,22 @@ describe('PoliciesService', () => { }); it('scopes results to the organization regardless of excludeContent', async () => { - await service.findAll(orgId, { excludeContent: true }); + await service.findAll({ organizationId: orgId, excludeContent: true }); const callArgs = db.policy.findMany.mock.calls[0][0]; expect(callArgs.where.organizationId).toBe(orgId); expect(callArgs.where.isArchived).toBe(false); expect(callArgs.where.archivedAt).toBeNull(); }); + + it('includes archived policies when includeArchived is true', async () => { + await service.findAll({ organizationId: orgId, includeArchived: true }); + + const callArgs = db.policy.findMany.mock.calls[0][0]; + expect(callArgs.where).toEqual({ organizationId: orgId }); + expect(callArgs.select.archivedAt).toBe(true); + expect(callArgs.select.isArchived).toBe(true); + }); }); describe('updateById', () => { diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index f58db3170f..a7b0c2702a 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -43,6 +43,7 @@ const POLICY_UPDATE_SELECT = { signedBy: true, reviewDate: true, isArchived: true, + archivedAt: true, createdAt: true, updatedAt: true, lastArchivedAt: true, @@ -79,19 +80,26 @@ export class PoliciesService { private readonly timelinesService: TimelinesService, ) {} - async findAll( - organizationId: string, - options?: { excludeContent?: boolean }, - ) { + async findAll({ + organizationId, + excludeContent, + includeArchived, + }: { + organizationId: string; + excludeContent?: boolean; + includeArchived?: boolean; + }) { try { const policies = await db.policy.findMany({ - where: { organizationId, isArchived: false, archivedAt: null }, + where: includeArchived + ? { organizationId } + : { organizationId, isArchived: false, archivedAt: null }, select: { id: true, name: true, description: true, status: true, - ...(options?.excludeContent + ...(excludeContent ? {} : { content: true, draftContent: true }), frequency: true, @@ -100,6 +108,7 @@ export class PoliciesService { signedBy: true, reviewDate: true, isArchived: true, + archivedAt: true, createdAt: true, updatedAt: true, lastArchivedAt: true, @@ -128,7 +137,8 @@ export class PoliciesService { this.logger.log( `Retrieved ${policies.length} policies for organization ${organizationId}` + - (options?.excludeContent ? ' (content excluded)' : ''), + (excludeContent ? ' (content excluded)' : '') + + (includeArchived ? ' (archived included)' : ''), ); return policies; } catch (error) { @@ -240,6 +250,7 @@ export class PoliciesService { signedBy: true, reviewDate: true, isArchived: true, + archivedAt: true, createdAt: true, updatedAt: true, lastArchivedAt: true, @@ -338,6 +349,7 @@ export class PoliciesService { signedBy: true, reviewDate: true, isArchived: true, + archivedAt: true, createdAt: true, updatedAt: true, lastArchivedAt: true, diff --git a/apps/api/src/policies/schemas/get-all-policies.responses.ts b/apps/api/src/policies/schemas/get-all-policies.responses.ts index c322decaf2..2e9a0a06c8 100644 --- a/apps/api/src/policies/schemas/get-all-policies.responses.ts +++ b/apps/api/src/policies/schemas/get-all-policies.responses.ts @@ -65,6 +65,7 @@ export const GET_ALL_POLICIES_RESPONSES: Record = { signedBy: [], reviewDate: '2024-12-31T00:00:00.000Z', isArchived: false, + archivedAt: null, createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-15T00:00:00.000Z', lastArchivedAt: null, diff --git a/apps/api/src/policies/schemas/policy-operations.ts b/apps/api/src/policies/schemas/policy-operations.ts index 1ca19caf39..481f26ea56 100644 --- a/apps/api/src/policies/schemas/policy-operations.ts +++ b/apps/api/src/policies/schemas/policy-operations.ts @@ -4,7 +4,7 @@ export const POLICY_OPERATIONS: Record = { getAllPolicies: { summary: 'Get all policies', description: - 'Lists all policies. Pass excludeContent=true to skip the heavy content fields (recommended unless you need every policy fully). Fetch one policy via get-policy by ID when you need the full content to edit.', + 'Lists active policies by default. Pass includeArchived=true to include archived rows and excludeContent=true to skip heavy content fields. Fetch one policy by ID for full content.', }, getPolicyById: { summary: 'Get policy by ID', diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.test.tsx new file mode 100644 index 0000000000..75b182b63c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { EditableSOAFields } from './EditableSOAFields'; + +vi.mock('../hooks/useSOADocument', () => ({ + useSOADocument: () => ({ + saveAnswer: vi.fn(), + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +describe('EditableSOAFields', () => { + it('shows the edit action without requiring hover', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Edit answer' })).toHaveClass('opacity-100'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx index aa2dc5d144..d067f26b54 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx @@ -1,24 +1,22 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; -import { Button } from '@trycompai/design-system'; -import { Textarea } from '@trycompai/ui/textarea'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@trycompai/ui/select'; import { + Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, -} from '@trycompai/ui/dialog'; -import { X, Loader2, Edit2 } from 'lucide-react'; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@trycompai/design-system'; +import { Close, Edit } from '@trycompai/design-system/icons'; import { toast } from 'sonner'; import { useSOADocument } from '../hooks/useSOADocument'; import { ApplicableReadOnlyDisplay, ApplicableSwatchRow } from './ApplicableSwatch'; @@ -128,7 +126,11 @@ export function EditableSOAFields({ setIsEditing(true); }; - const handleSelectChange = (value: 'yes' | 'no' | 'null') => { + const handleSelectChange = (value: string | null) => { + if (value !== 'yes' && value !== 'no' && value !== 'null') { + return; + } + const newValue = value === 'yes' ? true : value === 'no' ? false : null; setIsApplicable(newValue); setError(null); @@ -191,10 +193,11 @@ export function EditableSOAFields({
); @@ -203,25 +206,27 @@ export function EditableSOAFields({ return (
- +
+ +
@@ -250,13 +255,14 @@ export function EditableSOAFields({ setError(null); }} placeholder="Enter justification (required)" - className="min-h-[120px]" + rows={5} + size="full" required /> {error && (

{error}

)} - + 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 dc0c3383d2..47eb2096c0 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 @@ -22,6 +22,12 @@ import { useParams, useRouter } from 'next/navigation'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { useCallback, useMemo, useState } from 'react'; import { FamilyFilterDropdown } from './FamilyFilterDropdown'; +import { + areAllFamiliesExpanded, + isFamilyExpanded, + toggleAllFamilyExpansion, + toggleFamilyExpansion, +} from './family-expansion-state'; import { buildControlItems, buildRequirementMap, @@ -57,7 +63,7 @@ export function FrameworkControlsGrouped({ 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 [expandedFamilies, setExpandedFamilies] = useState>(new Set()); const selectedFamilyFilter = useMemo(() => new Set(familyFilterParam), [familyFilterParam]); @@ -95,36 +101,26 @@ export function FrameworkControlsGrouped({ 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 visibleFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); + const allExpanded = areAllFamiliesExpanded({ + expandedFamilies, + familyNames: visibleFamilyNames, + }); const handleToggleFamily = (family: string) => { - setCollapsedFamilies((prev) => { - const next = new Set(prev); - if (next.has(family)) { - next.delete(family); - } else { - next.add(family); - } - return next; - }); + setExpandedFamilies((prev) => + toggleFamilyExpansion({ expandedFamilies: prev, family }), + ); }; - const visibleFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); - const handleToggleAll = () => { - if (allCollapsed) { - setCollapsedFamilies((prev) => { - const next = new Set(prev); - for (const name of visibleFamilyNames) next.delete(name); - return next; - }); - } else { - setCollapsedFamilies((prev) => { - const next = new Set(prev); - for (const name of visibleFamilyNames) next.add(name); - return next; - }); - } + setExpandedFamilies((prev) => + toggleAllFamilyExpansion({ + expandedFamilies: prev, + familyNames: visibleFamilyNames, + shouldExpand: !allExpanded, + }), + ); }; const handleSearchChange = (e: React.ChangeEvent) => { @@ -145,7 +141,8 @@ export function FrameworkControlsGrouped({ setFamilyFilterParam(null); }; - const isFamilyExpanded = (family: string) => isSearching || !collapsedFamilies.has(family); + const getIsFamilyExpanded = (family: string) => + isFamilyExpanded({ expandedFamilies, family, isSearching }); return (
@@ -172,7 +169,7 @@ export function FrameworkControlsGrouped({ /> {!isSearching && ( )}
@@ -202,7 +199,7 @@ export function FrameworkControlsGrouped({ handleToggleFamily(group.family)} tasks={tasks} evidenceSubmissions={evidenceSubmissions} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx index 90608327a9..535dbf88d6 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx @@ -21,6 +21,12 @@ import { useParams, useRouter } from 'next/navigation'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { useCallback, useMemo, useState } from 'react'; import { FamilyFilterDropdown } from './FamilyFilterDropdown'; +import { + areAllFamiliesExpanded, + isFamilyExpanded, + toggleAllFamilyExpansion, + toggleFamilyExpansion, +} from './family-expansion-state'; import { buildRequirementItems, getFamilyDisplayLabel, @@ -54,7 +60,7 @@ export function FrameworkRequirementsGrouped({ const [searchTerm, setSearchTerm] = useQueryState('rq', parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 })); const [familyFilterParam, setFamilyFilterParam] = useQueryState('rfamilies', parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true })); - const [collapsedFamilies, setCollapsedFamilies] = useState>(new Set()); + const [expandedFamilies, setExpandedFamilies] = useState>(new Set()); const selectedFamilyFilter = useMemo(() => new Set(familyFilterParam), [familyFilterParam]); @@ -90,20 +96,26 @@ export function FrameworkRequirementsGrouped({ 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 visibleFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); + const allExpanded = areAllFamiliesExpanded({ + expandedFamilies, + familyNames: visibleFamilyNames, + }); const handleToggleFamily = (family: string) => { - setCollapsedFamilies((prev) => { - const next = new Set(prev); - if (next.has(family)) next.delete(family); - else next.add(family); - return next; - }); + setExpandedFamilies((prev) => + toggleFamilyExpansion({ expandedFamilies: prev, family }), + ); }; const handleToggleAll = () => { - if (allCollapsed) setCollapsedFamilies(new Set()); - else setCollapsedFamilies(new Set(allFamilyNames)); + setExpandedFamilies((prev) => + toggleAllFamilyExpansion({ + expandedFamilies: prev, + familyNames: visibleFamilyNames, + shouldExpand: !allExpanded, + }), + ); }; const handleSearchChange = (e: React.ChangeEvent) => { @@ -121,7 +133,8 @@ export function FrameworkRequirementsGrouped({ setFamilyFilterParam(null); }; - const isFamilyExpanded = (family: string) => isSearching || !collapsedFamilies.has(family); + const getIsFamilyExpanded = (family: string) => + isFamilyExpanded({ expandedFamilies, family, isSearching }); return (
@@ -147,7 +160,7 @@ export function FrameworkRequirementsGrouped({ /> {!isSearching && ( )}
@@ -179,7 +192,7 @@ export function FrameworkRequirementsGrouped({ handleToggleFamily(group.family)} orgId={orgId} frameworkInstanceId={frameworkInstanceId} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/family-expansion-state.test.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/family-expansion-state.test.ts new file mode 100644 index 0000000000..683072ea21 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/family-expansion-state.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + areAllFamiliesExpanded, + isFamilyExpanded, + toggleAllFamilyExpansion, + toggleFamilyExpansion, +} from './family-expansion-state'; + +describe('family expansion state', () => { + it('defaults families to collapsed until expanded by the user', () => { + expect( + isFamilyExpanded({ + expandedFamilies: new Set(), + family: 'Access Control', + isSearching: false, + }), + ).toBe(false); + }); + + it('expands families while searching without changing saved expansion state', () => { + expect( + isFamilyExpanded({ + expandedFamilies: new Set(), + family: 'Access Control', + isSearching: true, + }), + ).toBe(true); + }); + + it('toggles one family at a time', () => { + const expanded = toggleFamilyExpansion({ + expandedFamilies: new Set(), + family: 'Access Control', + }); + expect(expanded.has('Access Control')).toBe(true); + + const collapsed = toggleFamilyExpansion({ + expandedFamilies: expanded, + family: 'Access Control', + }); + expect(collapsed.has('Access Control')).toBe(false); + }); + + it('expands and collapses all visible families', () => { + const familyNames = ['Access Control', 'Audit']; + const expanded = toggleAllFamilyExpansion({ + expandedFamilies: new Set(), + familyNames, + shouldExpand: true, + }); + + expect( + areAllFamiliesExpanded({ + expandedFamilies: expanded, + familyNames, + }), + ).toBe(true); + + const collapsed = toggleAllFamilyExpansion({ + expandedFamilies: expanded, + familyNames, + shouldExpand: false, + }); + + expect( + areAllFamiliesExpanded({ + expandedFamilies: collapsed, + familyNames, + }), + ).toBe(false); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/family-expansion-state.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/family-expansion-state.ts new file mode 100644 index 0000000000..f5b8874912 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/family-expansion-state.ts @@ -0,0 +1,68 @@ +interface FamilyExpansionParams { + expandedFamilies: Set; + family: string; + isSearching: boolean; +} + +interface ToggleAllFamilyExpansionParams { + expandedFamilies: Set; + familyNames: string[]; + shouldExpand: boolean; +} + +interface FamilyExpansionListParams { + expandedFamilies: Set; + familyNames: string[]; +} + +interface ToggleFamilyExpansionParams { + expandedFamilies: Set; + family: string; +} + +export function isFamilyExpanded({ + expandedFamilies, + family, + isSearching, +}: FamilyExpansionParams): boolean { + return isSearching || expandedFamilies.has(family); +} + +export function areAllFamiliesExpanded({ + expandedFamilies, + familyNames, +}: FamilyExpansionListParams): boolean { + return ( + familyNames.length > 0 && + familyNames.every((family) => expandedFamilies.has(family)) + ); +} + +export function toggleFamilyExpansion({ + expandedFamilies, + family, +}: ToggleFamilyExpansionParams): Set { + const next = new Set(expandedFamilies); + if (next.has(family)) { + next.delete(family); + } else { + next.add(family); + } + return next; +} + +export function toggleAllFamilyExpansion({ + expandedFamilies, + familyNames, + shouldExpand, +}: ToggleAllFamilyExpansionParams): Set { + const next = new Set(expandedFamilies); + for (const family of familyNames) { + if (shouldExpand) { + next.add(family); + } else { + next.delete(family); + } + } + return next; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 5cb8463ebd..1026a33b74 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -149,6 +149,7 @@ export function MemberRow({ const currentRoles = parseRoles(member.role); const isOwner = currentRoles.includes('owner'); + const isAuditorOnly = currentRoles.length > 0 && currentRoles.every((role) => role === 'auditor'); const isPlatformAdmin = member.user.role === 'admin'; const canRemove = !isOwner; const isDeactivated = member.deactivated || !member.isActive; @@ -190,7 +191,7 @@ export function MemberRow({ }); } - if (shouldShowTaskRequirements && backgroundCheckStepEnabled && !memberExempt) { + if (shouldShowTaskRequirements && backgroundCheckStepEnabled && !memberExempt && !isAuditorOnly) { taskItems.push({ label: 'Background check', completed: hasCompletedBackgroundCheck ? 1 : 0, diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx index 269db85f97..09d7c528a2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx @@ -46,12 +46,18 @@ const member = { const noop = vi.fn(); -function renderRow(backgroundCheckStatus?: 'completed' | 'completed_with_flags' | 'invited') { +function renderRow({ + backgroundCheckStatus, + role = 'employee', +}: { + backgroundCheckStatus?: 'completed' | 'completed_with_flags' | 'invited'; + role?: string; +} = {}) { return render( { it('shows background check as incomplete in the tasks column', () => { - renderRow('invited'); + renderRow({ backgroundCheckStatus: 'invited' }); expect(screen.getByText('Background check 0/1')).toBeInTheDocument(); }); it('shows background check as complete in the tasks column', () => { - renderRow('completed_with_flags'); + renderRow({ backgroundCheckStatus: 'completed_with_flags' }); expect(screen.getByText('Background check 1/1')).toBeInTheDocument(); expect(screen.getByLabelText('Employee has completed a background check')).toBeInTheDocument(); }); it('does not show the verified tick for incomplete checks', () => { - renderRow('invited'); + renderRow({ backgroundCheckStatus: 'invited' }); expect(screen.queryByLabelText('Employee has completed a background check')).not.toBeInTheDocument(); }); + + it('does not show background check tracking for auditor-only members', () => { + renderRow({ backgroundCheckStatus: 'invited', role: 'auditor' }); + expect(screen.queryByText(/Background check/)).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/usePoliciesOverview.ts b/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/usePoliciesOverview.ts index 244e9a9fbe..77e4143788 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/usePoliciesOverview.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/usePoliciesOverview.ts @@ -16,6 +16,7 @@ interface PolicyFromApi { id: string; status: string; isArchived: boolean; + archivedAt: string | null; assigneeId: string | null; assignee?: { id: string; @@ -32,8 +33,8 @@ interface UsePoliciesOverviewOptions { export function usePoliciesOverview({ organizationId, initialData }: UsePoliciesOverviewOptions) { const { data, error, isLoading, mutate } = useSWR( - ['/v1/policies', organizationId], - async ([endpoint, orgId]) => { + ['/v1/policies?includeArchived=true', organizationId], + async ([endpoint]) => { const response = await apiClient.get<{ data: PolicyFromApi[] }>(endpoint); if (response.error) throw new Error(response.error); return response.data?.data ?? []; diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/lib/compute-overview.ts b/apps/app/src/app/(app)/[orgId]/policies/(overview)/lib/compute-overview.ts index ac2a2ee29a..6f35c353c6 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/lib/compute-overview.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/lib/compute-overview.ts @@ -1,9 +1,11 @@ // Shared utility - can be used by both server and client +import { isArchivedPolicy } from '../../lib/policy-archive-state'; interface PolicyForOverview { id: string; status: string; isArchived: boolean; + archivedAt?: Date | string | null; assigneeId: string | null; assignee?: { id: string; @@ -45,8 +47,10 @@ export function computePoliciesOverview(policies: PolicyForOverview[]): Policies const policyDataByOwner = new Map(); for (const policy of policies) { + const archived = isArchivedPolicy(policy); + // Count by status - if (policy.isArchived) { + if (archived) { archivedPolicies += 1; } else if (policy.status === 'published') { publishedPolicies += 1; @@ -74,7 +78,7 @@ export function computePoliciesOverview(policies: PolicyForOverview[]): Policies const assigneeData = policyDataByOwner.get(assigneeId)!; assigneeData.total += 1; - if (policy.isArchived) { + if (archived) { assigneeData.archived += 1; } else if (policy.status === 'published') { assigneeData.published += 1; diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx index efa640c196..519e183493 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx @@ -5,6 +5,7 @@ import type { Metadata } from 'next'; import { Suspense } from 'react'; import { PolicyFilters } from '../all/components/PolicyFilters'; import { PolicyPageActions } from '../all/components/PolicyPageActions'; +import { isArchivedPolicy } from '../lib/policy-archive-state'; import { PolicyChartsClient } from './components/PolicyChartsClient'; import { computePoliciesOverview } from './lib/compute-overview'; import Loading from './loading'; @@ -25,7 +26,7 @@ export default async function PoliciesPage({ params }: PoliciesPageProps) { // policies…" status during first-run AI generation. Mirrors the pattern // used by the risks and vendors pages. const [policiesRes, onboardingRes] = await Promise.all([ - serverApi.get<{ data: PolicyWithAssignee[] }>('/v1/policies'), + serverApi.get<{ data: PolicyWithAssignee[] }>('/v1/policies?includeArchived=true'), serverApi.get<{ triggerJobId: string | null; triggerJobCompleted: boolean; @@ -41,7 +42,7 @@ export default async function PoliciesPage({ params }: PoliciesPageProps) { policies.map((p) => ({ id: p.id, status: p.status, - isArchived: p.isArchived, + isArchived: isArchivedPolicy(p), assigneeId: p.assigneeId, assignee: p.assignee, })), @@ -52,7 +53,7 @@ export default async function PoliciesPage({ params }: PoliciesPageProps) { !p.isArchived)} />} + actions={ !isArchivedPolicy(p))} />} /> }> diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PoliciesTableDS.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PoliciesTableDS.tsx index 7b50fc2a88..d27f9db4f6 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/PoliciesTableDS.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PoliciesTableDS.tsx @@ -17,6 +17,7 @@ import { import { ArrowDown, ArrowUp, ArrowsVertical, Launch } from '@trycompai/design-system/icons'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; +import { isArchivedPolicy } from '../../lib/policy-archive-state'; import { usePolicyTailoringStatus, type PolicyTailoringStatus } from './policy-tailoring-context'; type SortColumn = 'name' | 'status' | 'updatedAt'; @@ -223,7 +224,7 @@ function PolicyStatusCell({ policy, status, isTailoring }: PolicyStatusCellProps ); } - if (policy.isArchived) { + if (isArchivedPolicy(policy)) { return ; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx index aef118f3f3..9f67f00b2a 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx @@ -12,11 +12,12 @@ import { SelectTrigger, SelectValue, Stack, + Spinner, } from '@trycompai/design-system'; import { Search } from '@trycompai/design-system/icons'; -import { Loader2 } from 'lucide-react'; import { useMemo, useState } from 'react'; import { usePolicyOnboardingStatus } from '../../(overview)/hooks/use-policy-onboarding-status'; +import { isArchivedPolicy } from '../../lib/policy-archive-state'; import { PoliciesTableDS } from './PoliciesTableDS'; import { PolicyTailoringProvider } from './policy-tailoring-context'; import { comparePoliciesByName } from './policy-name-sort'; @@ -70,9 +71,9 @@ export function PolicyFilters({ policies, onboardingRunId }: PolicyFiltersProps) // Status filter if (statusFilter === 'archived') { - result = result.filter((p) => p.isArchived); + result = result.filter((p) => isArchivedPolicy(p)); } else { - result = result.filter((p) => !p.isArchived); + result = result.filter((p) => !isArchivedPolicy(p)); if (statusFilter !== 'all') { result = result.filter((p) => p.status === statusFilter); } @@ -170,7 +171,7 @@ export function PolicyFilters({ policies, onboardingRunId }: PolicyFiltersProps) {showTailoringBanner && progress !== null && (
- +
Tailoring your policies diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table-columns.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table-columns.tsx index 39dc844771..adaa47607f 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table-columns.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table-columns.tsx @@ -3,12 +3,13 @@ import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header'; import { StatusIndicator } from '@/components/status-indicator'; import { formatDate } from '@/lib/format'; -import { Badge } from '@trycompai/ui/badge'; -import { Policy } from '@db'; +import type { Policy } from '@db'; import { type ColumnDef, type Row } from '@tanstack/react-table'; -import { ExternalLink, Loader2 } from 'lucide-react'; +import { Badge, Spinner } from '@trycompai/design-system'; +import { Launch } from '@trycompai/design-system/icons'; import Link from 'next/link'; +import { isArchivedPolicy } from '../../lib/policy-archive-state'; import { usePolicyTailoringStatus } from './policy-tailoring-context'; export type PolicyTailoringStatus = 'queued' | 'pending' | 'processing' | 'completed'; @@ -44,7 +45,7 @@ export function getPolicyColumns(orgId: string): ColumnDef[] { header: ({ column }) => , cell: ({ row }) => { return ( - + {row.original.department} ); @@ -81,7 +82,9 @@ function PolicyNameCell({ row, orgId }: { row: Row; orgId: string }) { if (isTailoring) { return (
- + + + {policyName} @@ -100,7 +103,7 @@ function PolicyNameCell({ row, orgId }: { row: Row; orgId: string }) { {policyName} - + ); } @@ -119,13 +122,13 @@ function PolicyStatusCell({ row }: { row: Row }) { : 'Preparing'; return (
- + {label}
); } - if (row.original.isArchived) { + if (isArchivedPolicy(row.original)) { return ; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/lib/policy-archive-state.test.ts b/apps/app/src/app/(app)/[orgId]/policies/lib/policy-archive-state.test.ts new file mode 100644 index 0000000000..44c748f9d6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/lib/policy-archive-state.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { isArchivedPolicy } from './policy-archive-state'; + +describe('isArchivedPolicy', () => { + it('treats user-archived policies as archived', () => { + expect(isArchivedPolicy({ isArchived: true, archivedAt: null })).toBe(true); + }); + + it('treats framework-sync-archived policies as archived', () => { + expect(isArchivedPolicy({ isArchived: false, archivedAt: '2026-05-30T12:00:00Z' })).toBe( + true, + ); + }); + + it('treats active policies as not archived', () => { + expect(isArchivedPolicy({ isArchived: false, archivedAt: null })).toBe(false); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/policies/lib/policy-archive-state.ts b/apps/app/src/app/(app)/[orgId]/policies/lib/policy-archive-state.ts new file mode 100644 index 0000000000..820c86a3fb --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/lib/policy-archive-state.ts @@ -0,0 +1,8 @@ +interface PolicyArchiveState { + archivedAt?: Date | string | null; + isArchived?: boolean; +} + +export function isArchivedPolicy(policy: PolicyArchiveState): boolean { + return policy.isArchived === true || policy.archivedAt != null; +} diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 68ada0dd85..c88967fb05 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -7127,7 +7127,7 @@ }, "/v1/policies": { "get": { - "description": "Lists compliance policies for the organization. Use this to find a policy by name, look up a policy ID, browse drafts, or get an overview of all policies for SOC 2, ISO 27001, HIPAA, and GDPR workflows. Returns id, name, status, department, and other metadata for each policy. Pass excludeContent=true to skip the heavy TipTap content fields — recommended when you only need to identify a policy. To read or edit a single policy in detail, fetch it by ID via get-compliance-policy.", + "description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata.", "operationId": "PoliciesController_getAllPolicies_v1", "parameters": [ { @@ -7147,6 +7147,15 @@ "schema": { "type": "boolean" } + }, + { + "name": "includeArchived", + "required": false, + "in": "query", + "description": "When true, includes user-archived and framework-sync-archived policies in the response. Defaults to false.", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -7221,6 +7230,7 @@ "signedBy": [], "reviewDate": "2024-12-31T00:00:00.000Z", "isArchived": false, + "archivedAt": null, "createdAt": "2024-01-01T00:00:00.000Z", "updatedAt": "2024-01-15T00:00:00.000Z", "lastArchivedAt": null, @@ -7276,9 +7286,9 @@ "metadata": { "title": "List compliance policies | Comp AI API", "sidebarTitle": "List compliance policies", - "description": "Lists compliance policies for the organization. Use this to find a policy by name, look up a policy ID, browse drafts, or get an overview of all policies.", + "description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows.", "og:title": "List compliance policies | Comp AI API", - "og:description": "Lists compliance policies for the organization. Use this to find a policy by name, look up a policy ID, browse drafts, or get an overview of all policies." + "og:description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows." } }, "x-codeSamples": [ @@ -7291,6 +7301,11 @@ "lang": "bash", "label": "List policies (lightweight, no content)", "source": "curl --request GET --url \"https://api.trycomp.ai/v1/policies?excludeContent=true\" --header \"X-API-Key: $COMP_AI_API_KEY\"" + }, + { + "lang": "bash", + "label": "List policies including archived", + "source": "curl --request GET --url \"https://api.trycomp.ai/v1/policies?includeArchived=true\" --header \"X-API-Key: $COMP_AI_API_KEY\"" } ] }, @@ -7349,6 +7364,7 @@ "signedBy": [], "reviewDate": "2024-12-31T00:00:00.000Z", "isArchived": false, + "archivedAt": null, "createdAt": "2024-01-01T00:00:00.000Z", "updatedAt": "2024-01-15T00:00:00.000Z", "organizationId": "org_abc123def456", @@ -8470,6 +8486,7 @@ ], "reviewDate": "2024-12-31T00:00:00.000Z", "isArchived": false, + "archivedAt": null, "createdAt": "2024-01-01T00:00:00.000Z", "updatedAt": "2024-01-15T00:00:00.000Z", "organizationId": "org_abc123def456", @@ -26937,6 +26954,13 @@ "description": "Whether this policy is archived", "example": false }, + "archivedAt": { + "format": "date-time", + "type": "string", + "description": "When the policy was archived by framework sync", + "example": "2024-02-01T00:00:00.000Z", + "nullable": true + }, "createdAt": { "format": "date-time", "type": "string", @@ -26999,6 +27023,7 @@ "signedBy", "reviewDate", "isArchived", + "archivedAt", "createdAt", "updatedAt", "lastArchivedAt", @@ -29627,4 +29652,4 @@ } } } -} \ No newline at end of file +} From 318330ef7b3d55b51152c1eee4c31cc960d8bd9c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Sat, 30 May 2026 16:43:03 -0400 Subject: [PATCH 6/8] fix(app): fit requirements table columns --- .../components/FrameworkRequirements.tsx | 80 +++++++++---------- .../FrameworkRequirementsGrouped.tsx | 70 ++++++++-------- .../components/GroupedRequirementRow.tsx | 11 +-- .../requirements-table-layout.test.tsx | 31 +++++++ .../components/requirements-table-layout.tsx | 55 +++++++++++++ 5 files changed, 162 insertions(+), 85 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/requirements-table-layout.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/requirements-table-layout.tsx 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 6809df96b7..04bdbb62ab 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 @@ -1,36 +1,39 @@ 'use client'; +import type { StatusType } from '@/components/status-indicator'; +import { + type EvidenceSubmissionInfo, + type RequirementArtifactCounts, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, + getRequirementCompliancePercent, + getRequirementStatus, +} from '@/lib/control-compliance'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import type { Control, FrameworkEditorRequirement, Task } from '@db'; import { Badge, - InputGroup, InputGroupAddon, InputGroupInput, Table, TableBody, TableCell, - TableHead, - TableHeader, TableRow, Text, } from '@trycompai/design-system'; import { Search } from '@trycompai/design-system/icons'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; +import { + REQUIREMENTS_TABLE_COLUMN_COUNT, + REQUIREMENTS_TABLE_STYLE, + RequirementsTableColumnGroup, + RequirementsTableHeader, +} from './requirements-table-layout'; const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; -import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; -import { - type EvidenceSubmissionInfo, - type RequirementArtifactCounts, - getControlProgressPercent, - getControlStatus, - getRequirementArtifactCounts, - getRequirementCompliancePercent, - getRequirementStatus, -} from '@/lib/control-compliance'; -import type { StatusType } from '@/components/status-indicator'; interface RequirementItem extends FrameworkEditorRequirement { mappedControlsCount: number; @@ -109,9 +112,10 @@ export function FrameworkRequirements({ }, [requirementDefinitions, frameworkInstanceWithControls.controls, tasks, evidenceSubmissions]); const sortedItems = useMemo( - () => [...items].sort((a, b) => - (a.identifier ?? '').localeCompare(b.identifier ?? '', undefined, { numeric: true }), - ), + () => + [...items].sort((a, b) => + (a.identifier ?? '').localeCompare(b.identifier ?? '', undefined, { numeric: true }), + ), [items], ); @@ -151,12 +155,15 @@ export function FrameworkRequirements({ ) => setSearchTerm(event.target.value)} + onChange={(event: React.ChangeEvent) => + setSearchTerm(event.target.value) + } />
- - - Identifier - Name - Description - Compliance - Status - Controls - Policies - Tasks - Documents - - + + {paginatedItems.length === 0 ? ( - + No requirements found. @@ -214,24 +210,18 @@ export function FrameworkRequirements({ {identifier || '—'} - + {item.name} - + {item.description || '—'} -
-
+
+
- {item.artifactCounts.policies.completed}/{item.artifactCounts.policies.total} + {item.artifactCounts.policies.completed}/ + {item.artifactCounts.policies.total}
@@ -271,7 +262,8 @@ export function FrameworkRequirements({
- {item.artifactCounts.documents.completed}/{item.artifactCounts.documents.total} + {item.artifactCounts.documents.completed}/ + {item.artifactCounts.documents.total}
diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx index 535dbf88d6..ac10dafb9b 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsGrouped.tsx @@ -11,8 +11,6 @@ import { Table, TableBody, TableCell, - TableHead, - TableHeader, TableRow, Text, } from '@trycompai/design-system'; @@ -20,13 +18,13 @@ import { ChevronDown, ChevronRight, Search } from '@trycompai/design-system/icon import { useParams, useRouter } from 'next/navigation'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; import { useCallback, useMemo, useState } from 'react'; -import { FamilyFilterDropdown } from './FamilyFilterDropdown'; import { areAllFamiliesExpanded, isFamilyExpanded, toggleAllFamilyExpansion, toggleFamilyExpansion, } from './family-expansion-state'; +import { FamilyFilterDropdown } from './FamilyFilterDropdown'; import { buildRequirementItems, getFamilyDisplayLabel, @@ -34,8 +32,12 @@ import { type RequirementFamilyGroup, } from './framework-controls-shared'; import { GroupedRequirementRow } from './GroupedRequirementRow'; - -const COLUMN_COUNT = 9; +import { + REQUIREMENTS_TABLE_COLUMN_COUNT, + REQUIREMENTS_TABLE_STYLE, + RequirementsTableColumnGroup, + RequirementsTableHeader, +} from './requirements-table-layout'; export function FrameworkRequirementsGrouped({ requirementDefinitions, @@ -48,7 +50,10 @@ export function FrameworkRequirementsGrouped({ tasks?: (Task & { controls: Control[] })[]; evidenceSubmissions?: EvidenceSubmissionInfo[]; }) { - const { orgId, frameworkInstanceId } = useParams<{ orgId: string; frameworkInstanceId: string }>(); + const { orgId, frameworkInstanceId } = useParams<{ + orgId: string; + frameworkInstanceId: string; + }>(); const router = useRouter(); const handleRowClick = useCallback( @@ -58,19 +63,26 @@ export function FrameworkRequirementsGrouped({ [orgId, frameworkInstanceId, router], ); - const [searchTerm, setSearchTerm] = useQueryState('rq', parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 })); - const [familyFilterParam, setFamilyFilterParam] = useQueryState('rfamilies', parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true })); + const [searchTerm, setSearchTerm] = useQueryState( + 'rq', + parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 }), + ); + const [familyFilterParam, setFamilyFilterParam] = useQueryState( + 'rfamilies', + parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true }), + ); const [expandedFamilies, setExpandedFamilies] = useState>(new Set()); const selectedFamilyFilter = useMemo(() => new Set(familyFilterParam), [familyFilterParam]); const allItems = useMemo( - () => buildRequirementItems( - requirementDefinitions, - frameworkInstanceWithControls.controls, - tasks ?? [], - evidenceSubmissions, - ), + () => + buildRequirementItems( + requirementDefinitions, + frameworkInstanceWithControls.controls, + tasks ?? [], + evidenceSubmissions, + ), [requirementDefinitions, frameworkInstanceWithControls.controls, tasks, evidenceSubmissions], ); @@ -93,7 +105,10 @@ export function FrameworkRequirementsGrouped({ }, [allGroups, selectedFamilyFilter]); const allFamilyNames = useMemo(() => allGroups.map((g) => g.family), [allGroups]); - const familyCounts = useMemo(() => new Map(allGroups.map((g) => [g.family, g.items.length])), [allGroups]); + const familyCounts = useMemo( + () => new Map(allGroups.map((g) => [g.family, g.items.length])), + [allGroups], + ); const isSearching = searchTerm.trim().length > 0; const visibleFamilyNames = useMemo(() => groups.map((g) => g.family), [groups]); @@ -103,9 +118,7 @@ export function FrameworkRequirementsGrouped({ }); const handleToggleFamily = (family: string) => { - setExpandedFamilies((prev) => - toggleFamilyExpansion({ expandedFamilies: prev, family }), - ); + setExpandedFamilies((prev) => toggleFamilyExpansion({ expandedFamilies: prev, family })); }; const handleToggleAll = () => { @@ -164,24 +177,13 @@ export function FrameworkRequirementsGrouped({ )}
-
- - - Identifier - Name - Description - Compliance - Status - Controls - Policies - Tasks - Documents - - +
+ + {groups.length === 0 ? ( - + No requirements found. @@ -226,7 +228,7 @@ function RequirementFamilySection({ return ( <> - +
+ + +
, + ); + + expect(container.querySelectorAll('col')).toHaveLength(REQUIREMENTS_TABLE_COLUMN_COUNT); + expect(screen.getByRole('columnheader', { name: 'Identifier' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Description' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Controls' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Compliance' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Status' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Docs' })).toHaveAttribute( + 'title', + 'Documents', + ); + expect(REQUIREMENTS_TABLE_STYLE).toMatchObject({ tableLayout: 'fixed' }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/requirements-table-layout.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/requirements-table-layout.tsx new file mode 100644 index 0000000000..21a7a1df84 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/requirements-table-layout.tsx @@ -0,0 +1,55 @@ +import { TableHead, TableHeader, TableRow } from '@trycompai/design-system'; +import type { CSSProperties } from 'react'; + +interface RequirementsTableColumn { + id: string; + label: string; + title?: string; + width: string; +} + +const REQUIREMENTS_TABLE_COLUMNS = [ + { id: 'identifier', label: 'Identifier', width: '10%' }, + { id: 'name', label: 'Name', width: '19%' }, + { id: 'description', label: 'Description', width: '22%' }, + { id: 'compliance', label: 'Compliance', width: '13%' }, + { id: 'status', label: 'Status', width: '11%' }, + { id: 'controls', label: 'Controls', width: '7%' }, + { id: 'policies', label: 'Policies', width: '6.5%' }, + { id: 'tasks', label: 'Tasks', width: '5.5%' }, + { id: 'documents', label: 'Docs', title: 'Documents', width: '6%' }, +] as const satisfies readonly RequirementsTableColumn[]; + +export const REQUIREMENTS_TABLE_COLUMN_COUNT = REQUIREMENTS_TABLE_COLUMNS.length; + +export const REQUIREMENTS_TABLE_STYLE: CSSProperties = { + tableLayout: 'fixed', +}; + +export function RequirementsTableColumnGroup() { + return ( + + {REQUIREMENTS_TABLE_COLUMNS.map((column) => ( + + ))} + + ); +} + +export function RequirementsTableHeader() { + return ( + + + {REQUIREMENTS_TABLE_COLUMNS.map((column) => ( + + {column.label} + + ))} + + + ); +} From 76e106f1ab125ddbeaaa9945e2be39185b45230b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Sat, 30 May 2026 16:53:09 -0400 Subject: [PATCH 7/8] fix(app): warn before invalidating policy acknowledgments --- .../components/ConfirmActionDialog.tsx | 59 ------ .../overview/components/ToDoOverview.test.tsx | 119 ++++++++---- .../overview/components/ToDoOverview.tsx | 143 ++++---------- .../components/overview-quick-actions.tsx | 112 +++++++++++ .../components/PolicyAlerts.test.tsx | 178 +++++++++++++----- .../[policyId]/components/PolicyAlerts.tsx | 43 ++++- ...yAcknowledgmentInvalidationDialog.test.tsx | 88 +++++++++ ...PolicyAcknowledgmentInvalidationDialog.tsx | 82 ++++++++ 8 files changed, 574 insertions(+), 250 deletions(-) delete mode 100644 apps/app/src/app/(app)/[orgId]/overview/components/ConfirmActionDialog.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.tsx create mode 100644 apps/app/src/components/policies/PolicyAcknowledgmentInvalidationDialog.test.tsx create mode 100644 apps/app/src/components/policies/PolicyAcknowledgmentInvalidationDialog.tsx diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ConfirmActionDialog.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ConfirmActionDialog.tsx deleted file mode 100644 index b57ab3820e..0000000000 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ConfirmActionDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client'; - -import { Button } from '@trycompai/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@trycompai/ui/dialog'; - -interface ConfirmActionDialogProps { - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - title: string; - description: string; - confirmText?: string; - cancelText?: string; - variant?: 'default' | 'destructive'; - isLoading?: boolean; -} - -export function ConfirmActionDialog({ - isOpen, - onClose, - onConfirm, - title, - description, - confirmText = 'Confirm', - cancelText = 'Cancel', - variant = 'default', - isLoading = false, -}: ConfirmActionDialogProps) { - const handleConfirm = () => { - onConfirm(); - onClose(); - }; - - return ( - - - - {title} - {description} - - - - - - - - ); -} diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx index 02413cab70..4f7ac451e8 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx @@ -1,12 +1,13 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS, NO_PERMISSIONS, mockHasPermission, + setMockPermissions, } from '@/test-utils/mocks/permissions'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/hooks/use-permissions', () => ({ usePermissions: () => ({ @@ -27,6 +28,15 @@ vi.mock('next/link', () => ({ default: ({ children, href }: any) => {children}, })); +const { mockRefresh } = vi.hoisted(() => ({ + mockRefresh: vi.fn(), +})); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + refresh: mockRefresh, + }), +})); + vi.mock('@trycompai/ui/button', () => ({ Button: ({ children, disabled, asChild, ...props }: any) => ( +
+ ) : null, })); vi.mock('lucide-react', () => ({ @@ -89,12 +114,14 @@ const mockUnpublishedPolicies = [ name: 'Security Policy', status: 'draft', organizationId: 'org-1', + signedBy: ['user-1', 'user-2'], }, { id: 'pol-2', name: 'Privacy Policy', status: 'draft', organizationId: 'org-1', + signedBy: [], }, ] as any[]; @@ -123,6 +150,14 @@ const defaultProps = { describe('ToDoOverview', () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + }), + ), + ); }); describe('Permission gating', () => { @@ -131,9 +166,7 @@ describe('ToDoOverview', () => { render(); - expect( - screen.getByRole('button', { name: /publish all policies/i }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /publish all policies/i })).toBeInTheDocument(); }); it('hides "Publish All Policies" button when user lacks policy:update permission (auditor)', () => { @@ -188,37 +221,21 @@ describe('ToDoOverview', () => { render(); - expect( - screen.getByText('Complete SOC 2 audit'), - ).toBeInTheDocument(); + expect(screen.getByText('Complete SOC 2 audit')).toBeInTheDocument(); }); it('shows "All policies are published!" when no unpublished policies exist', () => { setMockPermissions(ADMIN_PERMISSIONS); - render( - , - ); + render(); - expect( - screen.getByText('All policies are published!'), - ).toBeInTheDocument(); + expect(screen.getByText('All policies are published!')).toBeInTheDocument(); }); it('does not show publish button even with permissions when no unpublished policies', () => { setMockPermissions(ADMIN_PERMISSIONS); - render( - , - ); + render(); expect( screen.queryByRole('button', { name: /publish all policies/i }), @@ -232,9 +249,47 @@ describe('ToDoOverview', () => { expect(screen.getByTestId('tab-trigger-policies')).toBeInTheDocument(); expect(screen.getByTestId('tab-trigger-tasks')).toBeInTheDocument(); - expect( - screen.getByTestId('tab-trigger-offboarding'), - ).toBeInTheDocument(); + expect(screen.getByTestId('tab-trigger-offboarding')).toBeInTheDocument(); + }); + + it('shows acknowledgment warning before publishing policies with existing acknowledgments', async () => { + const user = userEvent.setup(); + setMockPermissions(ADMIN_PERMISSIONS); + + render(); + + await user.click(screen.getByRole('button', { name: /publish all policies/i })); + + expect(screen.getByTestId('acknowledgment-dialog')).toHaveTextContent('2'); + expect(fetch).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: /publish and invalidate/i })); + + expect(fetch).toHaveBeenCalledWith('/api/policies/publish-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + }); + + it('publishes immediately when no acknowledgments will be invalidated', async () => { + const user = userEvent.setup(); + setMockPermissions(ADMIN_PERMISSIONS); + + render( + ({ + ...policy, + signedBy: [], + }))} + />, + ); + + await user.click(screen.getByRole('button', { name: /publish all policies/i })); + + expect(screen.queryByTestId('acknowledgment-dialog')).not.toBeInTheDocument(); + expect(fetch).toHaveBeenCalledOnce(); }); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx index ccb6621184..715f98dd92 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx @@ -1,11 +1,12 @@ 'use client'; import { useApiSWR } from '@/hooks/use-api-swr'; +import { usePermissions } from '@/hooks/use-permissions'; +import { Policy, Task } from '@db'; import { Button } from '@trycompai/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@trycompai/ui/card'; import { ScrollArea } from '@trycompai/ui/scroll-area'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@trycompai/ui/tabs'; -import { Policy, Task } from '@db'; import { ArrowRight, CheckCircle2, @@ -16,25 +17,14 @@ import { Upload, UserMinus, } from 'lucide-react'; -import { usePermissions } from '@/hooks/use-permissions'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; -import { toast } from 'sonner'; -import { ConfirmActionDialog } from './ConfirmActionDialog'; - -interface PendingOffboardingMember { - memberId: string; - name: string; - email: string; - offboardDate: string; - completedItems: number; - totalItems: number; -} - -interface PendingOffboardingResponse { - members: PendingOffboardingMember[]; -} +import { useEffect, useState } from 'react'; +import { + formatQuickActionStatus, + getQuickActionProgressWidth, + type PendingOffboardingResponse, + usePublishAllPoliciesAction, +} from './overview-quick-actions'; export function ToDoOverview({ totalPolicies, @@ -60,8 +50,6 @@ export function ToDoOverview({ onboardingTriggerJobId: string | null; }) { const { hasPermission } = usePermissions(); - const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); const [activeTab, setActiveTab] = useState( unpublishedPolicies.length === 0 ? 'tasks' : 'policies', ); @@ -81,57 +69,22 @@ export function ToDoOverview({ const isOnboardingInProgress = !!onboardingTriggerJobId; - const formatStatus = (status: string) => { - return status.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase()); - }; - - const router = useRouter(); const canPublishPolicies = hasPermission('policy', 'update'); - - const handleConfirmAction = async () => { - setIsLoading(true); - try { - const response = await fetch('/api/policies/publish-all', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - }); - - if (!response.ok) { - throw new Error('Failed to publish policies'); - } - - toast.success('All policies published!'); - router.refresh(); - } catch { - toast.error('Failed to publish policies.'); - } finally { - setIsLoading(false); - } - }; - - const width = useMemo(() => { - return totalPolicies + totalTasks === 0 - ? 0 - : ((totalPolicies + - totalTasks - - (unpublishedPolicies.length + incompleteTasks.length)) / - (totalPolicies + totalTasks)) * - 100; - }, [ + const { handlePublishAllClick, publishAllPoliciesDialog } = usePublishAllPoliciesAction({ + unpublishedPolicies, + }); + const width = getQuickActionProgressWidth({ totalPolicies, totalTasks, - unpublishedPolicies.length, - incompleteTasks.length, - ]); + unpublishedPolicies: unpublishedPolicies.length, + incompleteTasks: incompleteTasks.length, + }); return (
- - {'Quick Actions'} - + {'Quick Actions'}
@@ -146,10 +99,7 @@ export function ToDoOverview({ - + Policies ({remainingPolicies}) @@ -157,10 +107,7 @@ export function ToDoOverview({ Tasks ({remainingTasks}) - + Offboarding ({pendingOffboardings.length}) @@ -172,19 +119,15 @@ export function ToDoOverview({
)} @@ -210,14 +153,12 @@ export function ToDoOverview({ {policy.name} - Status: {formatStatus(policy.status)} + Status: {formatQuickActionStatus(policy.status)}
@@ -237,9 +178,7 @@ export function ToDoOverview({ {incompleteTasks.length === 0 ? (
- - All tasks are completed! - + All tasks are completed!
) : (
@@ -257,14 +196,12 @@ export function ToDoOverview({ {task.title} - Status: {formatStatus(task.status)} + Status: {formatQuickActionStatus(task.status)}
@@ -283,22 +220,16 @@ export function ToDoOverview({ {isPendingLoading ? (
- - Loading offboardings... - + Loading offboardings...
) : pendingError ? (
- - Failed to load offboardings - + Failed to load offboardings
) : pendingOffboardings.length === 0 ? (
- - No pending offboardings - + No pending offboardings
) : (
@@ -316,8 +247,7 @@ export function ToDoOverview({ Complete offboarding for {member.name} - {member.completedItems}/{member.totalItems}{' '} - tasks done + {member.completedItems}/{member.totalItems} tasks done
@@ -342,16 +272,7 @@ export function ToDoOverview({ - setIsConfirmDialogOpen(false)} - onConfirm={handleConfirmAction} - title="Are you sure you want to publish all policies?" - description="This will automatically publish all policies that are in draft status. This action cannot be undone." - confirmText="Publish Policies" - cancelText="Cancel" - isLoading={isLoading} - /> + {publishAllPoliciesDialog} ); } diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.tsx new file mode 100644 index 0000000000..79b7d874d6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { + getPolicyAcknowledgmentTotal, + PolicyAcknowledgmentInvalidationDialog, +} from '@/components/policies/PolicyAcknowledgmentInvalidationDialog'; +import type { Policy } from '@db'; +import { useRouter } from 'next/navigation'; +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; + +export interface PendingOffboardingMember { + memberId: string; + name: string; + email: string; + offboardDate: string; + completedItems: number; + totalItems: number; +} + +export interface PendingOffboardingResponse { + members: PendingOffboardingMember[]; +} + +export function formatQuickActionStatus(status: string) { + return status.replace('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +export function getQuickActionProgressWidth({ + totalPolicies, + totalTasks, + unpublishedPolicies, + incompleteTasks, +}: { + totalPolicies: number; + totalTasks: number; + unpublishedPolicies: number; + incompleteTasks: number; +}) { + if (totalPolicies + totalTasks === 0) { + return 0; + } + + return ( + ((totalPolicies + totalTasks - (unpublishedPolicies + incompleteTasks)) / + (totalPolicies + totalTasks)) * + 100 + ); +} + +export function usePublishAllPoliciesAction({ + unpublishedPolicies, +}: { + unpublishedPolicies: Policy[]; +}) { + const router = useRouter(); + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const bulkAcknowledgmentInvalidations = useMemo( + () => getPolicyAcknowledgmentTotal(unpublishedPolicies), + [unpublishedPolicies], + ); + + const handlePublishAllPolicies = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/policies/publish-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('Failed to publish policies'); + } + + toast.success('All policies published!'); + setIsConfirmDialogOpen(false); + router.refresh(); + } catch { + toast.error('Failed to publish policies.'); + } finally { + setIsLoading(false); + } + }; + + const handlePublishAllClick = () => { + if (bulkAcknowledgmentInvalidations === 0) { + void handlePublishAllPolicies(); + return; + } + + setIsConfirmDialogOpen(true); + }; + + const publishAllPoliciesDialog = ( + void handlePublishAllPolicies()} + onOpenChange={setIsConfirmDialogOpen} + open={isConfirmDialogOpen} + /> + ); + + return { + handlePublishAllClick, + publishAllPoliciesDialog, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx index c620ad73e2..fd827ef23b 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx @@ -1,11 +1,13 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, - mockHasPermission, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS, + mockHasPermission, + setMockPermissions, } from '@/test-utils/mocks/permissions'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps, ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock usePermissions vi.mock('@/hooks/use-permissions', () => ({ @@ -15,6 +17,97 @@ vi.mock('@/hooks/use-permissions', () => ({ }), })); +interface MockApprovalBannerProps { + approveConfirmation?: { content?: ReactNode }; + approveLoading?: boolean; + description: string; + onApprove: () => void; + onReject: () => void; + rejectLoading?: boolean; + title: string; +} + +type MockButtonProps = ComponentProps<'button'> & { + iconLeft?: ReactNode; + size?: string; + variant?: string; +}; + +interface MockLayoutProps { + children: ReactNode; +} + +type MockTextProps = MockLayoutProps & { + as?: string; + leading?: string; + size?: string; + variant?: string; + weight?: string; +}; + +vi.mock('@trycompai/design-system', async () => { + const { forwardRef } = await import('react'); + + return { + ApprovalBanner: ({ + approveConfirmation, + approveLoading, + description, + onApprove, + onReject, + rejectLoading, + title, + }: MockApprovalBannerProps) => ( +
+
{title}
+
{description}
+ {approveConfirmation?.content} + + +
+ ), + Button: ({ children, iconLeft, ...props }: MockButtonProps) => ( + + ), + HStack: ({ children }: MockLayoutProps) =>
{children}
, + Label: ({ children, ...props }: ComponentProps<'label'>) => ( + + ), + Stack: ({ children }: MockLayoutProps) =>
{children}
, + Text: ({ children }: MockTextProps) => {children}, + Textarea: forwardRef>((props, ref) => ( +