diff --git a/apps/console/src/pages/organizations/measures/MeasureView.tsx b/apps/console/src/pages/organizations/measures/MeasureView.tsx index db50ab7ee..5bddd5023 100644 --- a/apps/console/src/pages/organizations/measures/MeasureView.tsx +++ b/apps/console/src/pages/organizations/measures/MeasureView.tsx @@ -101,7 +101,6 @@ import { MeasureViewLinkedControlsQuery, MeasureViewLinkedControlsQuery$data, } from "./__generated__/MeasureViewLinkedControlsQuery.graphql"; -import { MeasureViewCreateEvidenceMutation as MeasureViewCreateEvidenceMutationType } from "./__generated__/MeasureViewCreateEvidenceMutation.graphql"; import { MeasureViewFulfillEvidenceMutation as MeasureViewFulfillEvidenceMutationType } from "./__generated__/MeasureViewFulfillEvidenceMutation.graphql"; import { MeasureViewCreateControlMappingMutation } from "./__generated__/MeasureViewCreateControlMappingMutation.graphql"; import { MeasureViewDeleteControlMappingMutation } from "./__generated__/MeasureViewDeleteControlMappingMutation.graphql"; @@ -156,6 +155,22 @@ const measureViewQuery = graphql` description state category + evidences(first: 100) @connection(key: "MeasureView_evidences") { + __id + edges { + node { + id + mimeType + filename + size + state + type + url + createdAt + description + } + } + } tasks(first: 100) @connection(key: "MeasureView_tasks") { __id edges { @@ -170,7 +185,7 @@ const measureViewQuery = graphql` fullName primaryEmailAddress } - evidences(first: 50) @connection(key: "MeasureView_evidences") { + evidences(first: 50) @connection(key: "MeasureView_task_evidences") { __id edges { node { @@ -241,12 +256,12 @@ const deleteTaskMutation = graphql` } `; -const createEvidenceMutation = graphql` - mutation MeasureViewCreateEvidenceMutation( - $input: CreateEvidenceInput! +const requestEvidenceMutation = graphql` + mutation MeasureViewRequestEvidenceMutation( + $input: RequestEvidenceInput! $connections: [ID!]! ) { - createEvidence(input: $input) { + requestEvidence(input: $input) { evidenceEdge @appendEdge(connections: $connections) { node { id @@ -482,6 +497,56 @@ const measureRisksQuery = graphql` } `; +// Add a mutation for uploadMeasureEvidence +const uploadMeasureEvidenceMutation = graphql` + mutation MeasureViewUploadMeasureEvidenceMutation( + $input: UploadMeasureEvidenceInput! + $connections: [ID!]! + ) { + uploadMeasureEvidence(input: $input) { + evidenceEdge @appendEdge(connections: $connections) { + node { + id + filename + fileUrl + mimeType + type + url + size + state + createdAt + description + } + } + } + } +`; + +// Add the uploadTaskEvidence mutation +const uploadTaskEvidenceMutation = graphql` + mutation MeasureViewUploadTaskEvidenceMutation( + $input: UploadTaskEvidenceInput! + $connections: [ID!]! + ) { + uploadTaskEvidence(input: $input) { + evidenceEdge @appendEdge(connections: $connections) { + node { + id + filename + fileUrl + mimeType + type + url + size + state + createdAt + description + } + } + } + } +`; + function MeasureViewContent({ queryRef, }: { @@ -491,6 +556,30 @@ function MeasureViewContent({ measureViewQuery, queryRef ); + + // Define a type for the measure with evidences field + type MeasureWithEvidences = typeof data.measure & { + evidences?: { + __id: string; + edges: Array<{ + node: { + id: string; + mimeType: string; + filename: string; + size: number; + state: string; + type: string; + url?: string; + createdAt: string; + description: string; + }; + } | null> | null; + }; + }; + + // Cast data.measure to include evidences field + const measureWithEvidences = data.measure as MeasureWithEvidences; + const navigate = useNavigate(); const { toast } = useToast(); const { organizationId, measureId } = useParams<{ @@ -499,11 +588,11 @@ function MeasureViewContent({ }>(); const environment = useRelayEnvironment(); - const [commitDeleteMeasure, isDeletingMeasure] = useMutation(deleteMeasureMutation); + const [commitDeleteMeasure, isDeletingMeasure] = useMutation(deleteMeasureMutation); const [isDeleteMeasureOpen, setIsDeleteMeasureOpen] = useState(false); // Add state for main content tabs - const [mainContentTab, setMainContentTab] = useState("tasks"); + const [mainContentTab, setMainContentTab] = useState("evidence"); // Add URLSearchParams handling for task persistence const [searchParams, setSearchParams] = useSearchParams(); @@ -626,9 +715,7 @@ function MeasureViewContent({ useMutation(createTaskMutation); const [deleteTask] = useMutation(deleteTaskMutation); - const [createEvidence] = useMutation( - createEvidenceMutation - ); + const [requestEvidence] = useMutation(requestEvidenceMutation); const [deleteEvidence] = useMutation( deleteEvidenceMutation ); @@ -725,6 +812,9 @@ function MeasureViewContent({ // Track if task panel is open const [isTaskPanelOpen, setIsTaskPanelOpen] = useState(false); + // Add state for tracking when files are dragged over the evidence zone + const [isDraggingOverEvidenceZone, setIsDraggingOverEvidenceZone] = useState(false); + const tasks = data.measure.tasks?.edges.map((edge) => edge.node) || []; // Add useEffect to handle URL parameters for task selection @@ -754,6 +844,11 @@ function MeasureViewContent({ [tasks] ); + // Add a function to get the measure evidence connection ID + const getMeasureEvidenceConnectionId = useCallback(() => { + return measureWithEvidences.evidences?.__id || null; + }, [measureWithEvidences.evidences]); + // Function to convert days, hours, and minutes to ISO 8601 duration format const convertToISODuration = useCallback(() => { let duration = "P"; @@ -972,16 +1067,13 @@ function MeasureViewContent({ // Get the evidence connection ID for this task const evidenceConnectionId = getEvidenceConnectionId(taskForEvidence.id); - createEvidence({ + uploadMeasureEvidence({ variables: { input: { - taskId: taskForEvidence.id, - name: file.name, - type: "FILE", + measureId: measureId, file: null, - description: "Document evidence", }, - connections: evidenceConnectionId ? [evidenceConnectionId] : [], + connections: getMeasureEvidenceConnectionId() ? [getMeasureEvidenceConnectionId()!] : [], }, uploadables: { "input.file": file, @@ -1025,9 +1117,6 @@ function MeasureViewContent({ return; } - // Description is now optional for link evidence - // Remove the validation check for empty description - // Get the evidence connection ID for this task const evidenceConnectionId = getEvidenceConnectionId(taskForEvidence.id); @@ -1035,15 +1124,14 @@ function MeasureViewContent({ const description = linkEvidenceDescription.trim() || `Link to ${linkEvidenceUrl}`; - createEvidence({ + // Use requestEvidence instead of createEvidence + requestEvidence({ variables: { input: { taskId: taskForEvidence.id, name: linkEvidenceName, type: "LINK", - url: linkEvidenceUrl, description: description, - file: null, }, connections: evidenceConnectionId ? [evidenceConnectionId] : [], }, @@ -1098,19 +1186,14 @@ function MeasureViewContent({ variant: "default", }); - // Get the evidence connection ID for this task - const evidenceConnectionId = getEvidenceConnectionId(taskId); - - createEvidence({ + // Instead of using task-specific upload, use measure-level evidence + uploadMeasureEvidence({ variables: { input: { - taskId: taskId, - name: file.name, - type: "FILE", + measureId: measureId, file: null, - description: "Document evidence", }, - connections: evidenceConnectionId ? [evidenceConnectionId] : [], + connections: getMeasureEvidenceConnectionId() ? [getMeasureEvidenceConnectionId()!] : [], }, uploadables: { "input.file": file, @@ -1351,9 +1434,15 @@ function MeasureViewContent({ const confirmDeleteEvidence = () => { if (!evidenceToDelete) return; - const evidenceConnectionId = getEvidenceConnectionId( - evidenceToDelete.taskId - ); + let evidenceConnectionId; + + // If taskId is empty, it's a measure-level evidence + if (!evidenceToDelete.taskId) { + evidenceConnectionId = getMeasureEvidenceConnectionId(); + } else { + // Otherwise it's a task-level evidence + evidenceConnectionId = getEvidenceConnectionId(evidenceToDelete.taskId); + } deleteEvidence({ variables: { @@ -1365,6 +1454,10 @@ function MeasureViewContent({ onCompleted: () => { setIsDeleteEvidenceOpen(false); setEvidenceToDelete(null); + toast({ + title: "Evidence deleted", + description: "Evidence has been deleted successfully.", + }); }, onError: (error) => { toast({ @@ -1869,10 +1962,64 @@ function MeasureViewContent({ }); }; + // Add the mutation hook for uploadMeasureEvidence + const [uploadMeasureEvidence] = useMutation(uploadMeasureEvidenceMutation); + + // Add a function to handle uploading evidence directly to the measure + const handleUploadMeasureEvidence = (file: File) => { + // Show toast for add started + toast({ + title: "Adding document", + description: `Adding ${file.name}...`, + variant: "default", + }); + + const evidenceConnectionId = getMeasureEvidenceConnectionId(); + + uploadMeasureEvidence({ + variables: { + input: { + measureId: data.measure.id, + file: null, + }, + connections: evidenceConnectionId ? [evidenceConnectionId] : [], + }, + uploadables: { + "input.file": file, + }, + onCompleted: () => { + toast({ + title: "Document added", + description: "Document evidence has been added successfully.", + variant: "default", + }); + }, + onError: (error) => { + toast({ + title: "Error adding document", + description: error.message, + variant: "destructive", + }); + }, + }); + }; + + // Add a function to create evidence at the measure level + const handleCreateMeasureEvidence = () => { + setEvidenceDialogOpen(true); + setActiveTab("file"); + // Reset form fields + setLinkEvidenceName(""); + setLinkEvidenceUrl(""); + setLinkEvidenceDescription(""); + }; + + // Add uploadTaskEvidence mutation hook + const [uploadTaskEvidence] = useMutation(uploadTaskEvidenceMutation); + return ( - - - {/* Control Mapping Dialog */} - - - - Map Measure to Controls - - Search and select controls to link to this measure. This - helps track which controls are addressed by this measure. - - - -
-
-
- - setControlSearchQuery(e.target.value)} - className="w-full pl-10" - /> -
-
- -
- -
-
- -
- {isLoadingControls ? ( -
- - Loading controls... -
- ) : ( -
- {filteredControls().length === 0 ? ( -
- No controls found. Try adjusting your search or select - a different framework. -
- ) : ( - filteredControls().map((control) => { - const isLinked = isControlLinked(control.id); - return ( - -
-
-
-
-
- {control.referenceId} -
- {isLinked && ( - - Linked - - )} -
-

- {control.name} -

- {control.description && ( -

- {control.description} -

- )} -
-
- {isLinked ? ( - - ) : ( - - )} -
-
-
-
- ); - }) - )} -
- )} -
- - - - -
-
- - {/* Linked Controls List */} - - - {linkedControlsData?.measure?.controls?.edges && - linkedControlsData.measure.controls.edges.length > 0 ? ( -
- - - - - - - - - - {getLinkedControls().map((control) => ( - - - - - - ))} - -
- ID - - Control Name - - Actions -
-
- {control.referenceId} -
-
- {control.name} - - -
-
- ) : ( -
- No controls linked to this measure yet. Click "Map to - Controls" to link controls. -
- )} -
-
- - - - {/* Tasks Tab Content */} - + {/* Evidence Tab Content */} +
-

Tasks

+

Evidence

- Drag & drop files onto tasks to add evidence + Drag & drop files here to add evidence
- - - - - - - Create New Task - - Add a new task to this measure. Click save when - you're done. - - -
-
- - setNewTaskName(e.target.value)} - placeholder="What needs to be done?" - /> -
-
- -