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..4a3b5602a0 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,23 @@ 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, isPublishing, 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 +100,7 @@ export function ToDoOverview({ - + Policies ({remainingPolicies}) @@ -157,10 +108,7 @@ export function ToDoOverview({ Tasks ({remainingTasks}) - + Offboarding ({pendingOffboardings.length}) @@ -172,19 +120,15 @@ export function ToDoOverview({
)} @@ -210,14 +154,12 @@ export function ToDoOverview({ {policy.name} - Status: {formatStatus(policy.status)} + Status: {formatQuickActionStatus(policy.status)} @@ -237,9 +179,7 @@ export function ToDoOverview({ {incompleteTasks.length === 0 ? (
- - All tasks are completed! - + All tasks are completed!
) : (
@@ -257,14 +197,12 @@ export function ToDoOverview({ {task.title} - Status: {formatStatus(task.status)} + Status: {formatQuickActionStatus(task.status)}
@@ -283,22 +221,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 +248,7 @@ export function ToDoOverview({ Complete offboarding for {member.name} - {member.completedItems}/{member.totalItems}{' '} - tasks done + {member.completedItems}/{member.totalItems} tasks done
@@ -342,16 +273,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.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.test.tsx new file mode 100644 index 0000000000..3a8e443367 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { usePublishAllPoliciesAction } from './overview-quick-actions'; + +const { mockRefresh } = vi.hoisted(() => ({ + mockRefresh: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + refresh: mockRefresh, + }), +})); + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock('@/components/policies/PolicyAcknowledgmentInvalidationDialog', () => ({ + getPolicyAcknowledgmentTotal: (policies: Array<{ signedBy?: string[] }>) => + policies.reduce((total, policy) => total + (policy.signedBy?.length ?? 0), 0), + PolicyAcknowledgmentInvalidationDialog: () => null, +})); + +function PublishAllButton() { + const { handlePublishAllClick, isPublishing } = usePublishAllPoliciesAction({ + unpublishedPolicies: [{ signedBy: [] }], + }); + + return ( + + ); +} + +describe('usePublishAllPoliciesAction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('blocks duplicate no-warning publishes while the request is in flight', async () => { + const user = userEvent.setup(); + let resolveFetch: () => void = () => {}; + const fetchPromise = new Promise((resolve) => { + resolveFetch = () => resolve(new Response(null, { status: 200 })); + }); + const fetchMock = vi.fn(() => fetchPromise); + vi.stubGlobal('fetch', fetchMock); + + render(); + + const publishButton = screen.getByRole('button', { name: /publish all policies/i }); + await user.dblClick(publishButton); + + expect(fetchMock).toHaveBeenCalledOnce(); + await waitFor(() => expect(publishButton).toBeDisabled()); + + resolveFetch(); + + await waitFor(() => expect(mockRefresh).toHaveBeenCalledOnce()); + await waitFor(() => expect(publishButton).not.toBeDisabled()); + }); +}); 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..3cdaa37533 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/overview-quick-actions.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { + getPolicyAcknowledgmentTotal, + PolicyAcknowledgmentInvalidationDialog, +} from '@/components/policies/PolicyAcknowledgmentInvalidationDialog'; +import type { Policy } from '@db'; +import { useRouter } from 'next/navigation'; +import { useMemo, useRef, 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[]; +} + +type PublishablePolicy = Pick; + +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: PublishablePolicy[]; +}) { + const router = useRouter(); + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const isPublishingRef = useRef(false); + const bulkAcknowledgmentInvalidations = useMemo( + () => getPolicyAcknowledgmentTotal(unpublishedPolicies), + [unpublishedPolicies], + ); + + const handlePublishAllPolicies = async () => { + if (isPublishingRef.current) { + return; + } + + isPublishingRef.current = true; + 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 { + isPublishingRef.current = false; + setIsLoading(false); + } + }; + + const handlePublishAllClick = () => { + if (isPublishingRef.current) { + return; + } + + if (bulkAcknowledgmentInvalidations === 0) { + void handlePublishAllPolicies(); + return; + } + + setIsConfirmDialogOpen(true); + }; + + const publishAllPoliciesDialog = ( + void handlePublishAllPolicies()} + onOpenChange={setIsConfirmDialogOpen} + open={isConfirmDialogOpen} + /> + ); + + return { + handlePublishAllClick, + isPublishing: isLoading, + 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) => ( +