From 943a7d3a1e808ed8a5ec6e518c888a387b2cab8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 10:58:48 -0400 Subject: [PATCH] feat: implement control creation functionality with UI integration (#1497) - Added a new action for creating controls with input validation using Zod. - Introduced CreateControlSheet component for the control creation UI, integrating form handling and submission. - Enhanced ControlsPage to fetch policies, tasks, and requirements for control assignment. - Updated ControlsTable to include the new CreateControlSheet for better user experience. These changes improve control management capabilities within the application. Co-authored-by: Mariano Fuentes --- .../actions/controls/create-control-action.ts | 102 +++++ .../components/CreateControlSheet.tsx | 410 ++++++++++++++++++ .../controls/components/controls-table.tsx | 15 +- .../src/app/(app)/[orgId]/controls/page.tsx | 113 ++++- bun.lock | 64 ++- 5 files changed, 692 insertions(+), 12 deletions(-) create mode 100644 apps/app/src/actions/controls/create-control-action.ts create mode 100644 apps/app/src/app/(app)/[orgId]/controls/components/CreateControlSheet.tsx diff --git a/apps/app/src/actions/controls/create-control-action.ts b/apps/app/src/actions/controls/create-control-action.ts new file mode 100644 index 000000000..72cd1af66 --- /dev/null +++ b/apps/app/src/actions/controls/create-control-action.ts @@ -0,0 +1,102 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; + +const createControlSchema = z.object({ + name: z.string().min(1, { + message: 'Name is required', + }), + description: z.string().min(1, { + message: 'Description is required', + }), + policyIds: z.array(z.string()).optional(), + taskIds: z.array(z.string()).optional(), + requirementMappings: z + .array( + z.object({ + requirementId: z.string(), + frameworkInstanceId: z.string(), + }), + ) + .optional(), +}); + +export const createControlAction = authActionClient + .inputSchema(createControlSchema) + .metadata({ + name: 'create-control', + track: { + event: 'create-control', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { name, description, policyIds, taskIds, requirementMappings } = parsedInput; + const { + session: { activeOrganizationId }, + user, + } = ctx; + + if (!user.id || !activeOrganizationId) { + throw new Error('Invalid user input'); + } + + try { + const control = await db.control.create({ + data: { + name, + description, + organizationId: activeOrganizationId, + ...(policyIds && + policyIds.length > 0 && { + policies: { + connect: policyIds.map((id) => ({ id })), + }, + }), + ...(taskIds && + taskIds.length > 0 && { + tasks: { + connect: taskIds.map((id) => ({ id })), + }, + }), + // Note: Requirements mapping is handled through RequirementMap table + }, + }); + + // Handle requirement mappings separately if provided + if (requirementMappings && requirementMappings.length > 0) { + await Promise.all( + requirementMappings.map((mapping) => + db.requirementMap.create({ + data: { + controlId: control.id, + requirementId: mapping.requirementId, + frameworkInstanceId: mapping.frameworkInstanceId, + }, + }), + ), + ); + } + + // Revalidate the path based on the header + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + control, + }; + } catch (error) { + console.error('Failed to create control:', error); + return { + success: false, + error: 'Failed to create control', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/controls/components/CreateControlSheet.tsx b/apps/app/src/app/(app)/[orgId]/controls/components/CreateControlSheet.tsx new file mode 100644 index 000000000..38df3f8da --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/controls/components/CreateControlSheet.tsx @@ -0,0 +1,410 @@ +'use client'; + +import { createControlAction } from '@/actions/controls/create-control-action'; +import { Button } from '@comp/ui/button'; +import { Drawer, DrawerContent, DrawerTitle } from '@comp/ui/drawer'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; +import { useMediaQuery } from '@comp/ui/hooks'; +import { Input } from '@comp/ui/input'; +import MultipleSelector, { Option } from '@comp/ui/multiple-selector'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@comp/ui/sheet'; +import { Textarea } from '@comp/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ArrowRightIcon, X } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; +import { useQueryState } from 'nuqs'; +import { useCallback, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const createControlSchema = z.object({ + name: z.string().min(1, { + message: 'Name is required', + }), + description: z.string().min(1, { + message: 'Description is required', + }), + policyIds: z.array(z.string()).optional(), + taskIds: z.array(z.string()).optional(), + requirementMappings: z + .array( + z.object({ + requirementId: z.string(), + frameworkInstanceId: z.string(), + }), + ) + .optional(), +}); + +export function CreateControlSheet({ + policies, + tasks, + requirements, +}: { + policies: { id: string; name: string }[]; + tasks: { id: string; title: string }[]; + requirements: { + id: string; + name: string; + identifier: string; + frameworkInstanceId: string; + frameworkName: string; + }[]; +}) { + const isDesktop = useMediaQuery('(min-width: 768px)'); + const [createControlOpen, setCreateControlOpen] = useQueryState('create-control'); + const isOpen = Boolean(createControlOpen); + + const handleOpenChange = (open: boolean) => { + setCreateControlOpen(open ? 'true' : null); + }; + + const createControl = useAction(createControlAction, { + onSuccess: () => { + toast.success('Control created successfully'); + setCreateControlOpen(null); + form.reset(); + }, + onError: (error) => { + toast.error(error.error?.serverError || 'Failed to create control'); + }, + }); + + const form = useForm>({ + resolver: zodResolver(createControlSchema), + defaultValues: { + name: '', + description: '', + policyIds: [], + taskIds: [], + requirementMappings: [], + }, + }); + + const onSubmit = useCallback( + (data: z.infer) => { + createControl.execute(data); + }, + [createControl], + ); + + // Memoize policy options to prevent re-renders + const policyOptions = useMemo( + () => + policies.map((policy) => ({ + value: policy.id, + label: policy.name, + })), + [policies], + ); + + // Memoize task options to prevent re-renders + const taskOptions = useMemo( + () => + tasks.map((task) => ({ + value: task.id, + label: task.title, + })), + [tasks], + ); + + // Memoize requirement options to prevent re-renders + const requirementOptions = useMemo( + () => + requirements.map((req) => ({ + value: req.id, + label: `${req.frameworkName}: ${req.identifier} - ${req.name}`, + frameworkInstanceId: req.frameworkInstanceId, + })), + [requirements], + ); + + // Memoize filter functions + const policyFilterFunction = useCallback( + (value: string, search: string) => { + const option = policyOptions.find((opt) => opt.value === value); + if (!option) return 0; + return option.label.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }, + [policyOptions], + ); + + const taskFilterFunction = useCallback( + (value: string, search: string) => { + const option = taskOptions.find((opt) => opt.value === value); + if (!option) return 0; + return option.label.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }, + [taskOptions], + ); + + // Memoize change handlers + const handlePoliciesChange = useCallback((options: Option[], onChange: (value: any) => void) => { + onChange(options.map((option) => option.value)); + }, []); + + const handleTasksChange = useCallback((options: Option[], onChange: (value: any) => void) => { + onChange(options.map((option) => option.value)); + }, []); + + const requirementFilterFunction = useCallback( + (value: string, search: string) => { + const option = requirementOptions.find((opt) => opt.value === value); + if (!option) return 0; + return option.label.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }, + [requirementOptions], + ); + + const handleRequirementsChange = useCallback( + (options: (Option & { frameworkInstanceId?: string })[], onChange: (value: any) => void) => { + const mappings = options.map((option) => ({ + requirementId: option.value, + frameworkInstanceId: option.frameworkInstanceId || '', + })); + onChange(mappings); + }, + [], + ); + + const controlForm = ( +
+ + ( + + Control Name + + + + + + )} + /> + + ( + + Description + +