diff --git a/api/db/migrations/20240910190935_add_is_manual_field_upload_validation/migration.sql b/api/db/migrations/20240910190935_add_is_manual_field_upload_validation/migration.sql new file mode 100644 index 00000000..1589ecd6 --- /dev/null +++ b/api/db/migrations/20240910190935_add_is_manual_field_upload_validation/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "UploadValidation" ADD COLUMN "isManual" BOOLEAN NOT NULL DEFAULT false; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index bf96eeab..3b3685ab 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -136,11 +136,12 @@ model Upload { } model UploadValidation { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) uploadId Int - upload Upload @relation(fields: [uploadId], references: [id], onDelete: Cascade) + upload Upload @relation(fields: [uploadId], references: [id], onDelete: Cascade) results Json? @db.JsonB passed Boolean + isManual Boolean @default(false) initiatedById Int initiatedBy User @relation(fields: [initiatedById], references: [id]) createdAt DateTime @default(now()) @db.Timestamptz(6) diff --git a/api/src/graphql/uploadValidations.sdl.ts b/api/src/graphql/uploadValidations.sdl.ts index fd57a94b..88a50808 100644 --- a/api/src/graphql/uploadValidations.sdl.ts +++ b/api/src/graphql/uploadValidations.sdl.ts @@ -5,6 +5,7 @@ export const schema = gql` upload: Upload! results: JSON passed: Boolean! + isManual: Boolean! initiatedById: Int! initiatedBy: User! createdAt: DateTime! @@ -22,6 +23,7 @@ export const schema = gql` uploadId: Int! results: JSON passed: Boolean! + isManual: Boolean initiatedById: Int! } @@ -29,6 +31,7 @@ export const schema = gql` uploadId: Int results: JSON passed: Boolean + isManual: Boolean initiatedById: Int } diff --git a/api/src/services/uploads/uploads.ts b/api/src/services/uploads/uploads.ts index 2627756d..086fe6f8 100644 --- a/api/src/services/uploads/uploads.ts +++ b/api/src/services/uploads/uploads.ts @@ -66,6 +66,7 @@ export const createUpload: MutationResolvers['createUpload'] = async ({ uploadId: upload.id, initiatedById: upload.uploadedById, passed: false, + isManual: false, results: null, }, }) diff --git a/web/src/components/Upload/Upload/Upload.tsx b/web/src/components/Upload/Upload/Upload.tsx index 61a5022d..517b725b 100644 --- a/web/src/components/Upload/Upload/Upload.tsx +++ b/web/src/components/Upload/Upload/Upload.tsx @@ -1,11 +1,16 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' + +import { useAuth } from 'web/src/auth' import { useMutation } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' import { timeTag } from 'src/lib/formatters' import UploadValidationButtonGroup from '../UploadValidationButtonGroup/UploadValidationButtonGroup' -import UploadValidationResultsTable from '../UploadValidationResultsTable/UploadValidationResultsTable' +import UploadValidationResultsTable, { + Severity, +} from '../UploadValidationResultsTable/UploadValidationResultsTable' import UploadValidationStatus from '../UploadValidationStatus/UploadValidationStatus' const DOWNLOAD_UPLOAD_FILE = gql` @@ -13,12 +18,29 @@ const DOWNLOAD_UPLOAD_FILE = gql` downloadUploadFile(id: $id) } ` + +const CREATE_VALIDATION_MUTATION = gql` + mutation CreateUploadValidationMutation( + $input: CreateUploadValidationInput! + ) { + createUploadValidation(input: $input) { + initiatedById + passed + isManual + results + uploadId + } + } +` + const Upload = ({ upload, queryResult }) => { - const { startPolling, stopPolling } = queryResult + const { currentUser } = useAuth() + const { startPolling, stopPolling, refetch } = queryResult const hasErrors = upload.latestValidation?.results?.errors !== null && Array.isArray(upload.latestValidation?.results?.errors) && upload.latestValidation?.results?.errors.length > 0 + const [savingUpload, setSavingUpload] = useState(false) useEffect(() => { const pollingTimeout = 120 * 1000 @@ -55,8 +77,44 @@ const Upload = ({ upload, queryResult }) => { downloadUploadFile({ variables: { id: upload.id } }) } + const [createValidation] = useMutation(CREATE_VALIDATION_MUTATION, { + onCompleted: async () => { + toast.success('Upload invalidated') + await refetch() + setSavingUpload(false) + }, + onError: (error) => { + toast.error(error.message) + setSavingUpload(false) + }, + }) + const handleValidate = () => {} - const handleForceInvalidate = () => {} + + const handleForceInvalidate = async () => { + setSavingUpload(true) + const invalidateResult = [ + { + message: `Manually invalidated by User: ${currentUser.name} (${currentUser.email})`, + tab: 'N/A', + row: 'N/A', + col: 'N/A', + severity: Severity.Error, + }, + ] + + const inputManual = { + initiatedById: currentUser.id, + passed: false, + isManual: true, + results: { + errors: invalidateResult, + }, + uploadId: upload.id, + } + + await createValidation({ variables: { input: inputManual } }) + } return ( <> @@ -104,6 +162,7 @@ const Upload = ({ upload, queryResult }) => { handleValidate={handleValidate} handleForceInvalidate={handleForceInvalidate} handleFileDownload={handleFileDownload} + savingUpload={savingUpload} /> )} diff --git a/web/src/components/Upload/UploadCell/UploadCell.tsx b/web/src/components/Upload/UploadCell/UploadCell.tsx index e4d5ee0b..e90f5d07 100644 --- a/web/src/components/Upload/UploadCell/UploadCell.tsx +++ b/web/src/components/Upload/UploadCell/UploadCell.tsx @@ -25,6 +25,7 @@ export const QUERY = gql` latestValidation { id passed + isManual results createdAt initiatedBy { diff --git a/web/src/components/Upload/UploadValidationButtonGroup/UploadValidationButtonGroup.tsx b/web/src/components/Upload/UploadValidationButtonGroup/UploadValidationButtonGroup.tsx index 3a3e5196..15c37f16 100644 --- a/web/src/components/Upload/UploadValidationButtonGroup/UploadValidationButtonGroup.tsx +++ b/web/src/components/Upload/UploadValidationButtonGroup/UploadValidationButtonGroup.tsx @@ -1,13 +1,13 @@ -import { ROLES } from 'api/src/lib/constants' import Button from 'react-bootstrap/Button' -import { useAuth } from 'web/src/auth' + +import { Severity } from 'src/components/Upload/UploadValidationResultsTable/UploadValidationResultsTable' interface ValidationError { message: string tab?: string row?: string col?: string - severity: 'warn' | 'err' | 'info' + severity: Severity } interface ValidationResult { @@ -15,20 +15,23 @@ interface ValidationResult { } interface UploadValidationButtonGroupProps { - latestValidation?: { passed: boolean; results: ValidationResult | null } + latestValidation?: { + passed: boolean + isManual: boolean + results: ValidationResult | null + } handleFileDownload: () => void handleForceInvalidate: () => void handleValidate: () => void + savingUpload: boolean } const UploadValidationButtonGroup = ({ latestValidation, handleFileDownload, handleForceInvalidate, - handleValidate, + savingUpload, }: UploadValidationButtonGroupProps) => { - const { hasRole } = useAuth() - /* If the upload has been validated, renders "Invalidate" and "Re-validate" buttons If the upload has been invalidated, renders the "Validate" button @@ -43,13 +46,14 @@ const UploadValidationButtonGroup = ({ variant="outline-primary" size="sm" onClick={handleForceInvalidate} + disabled={savingUpload} > Invalidate )}{' '} - + {/* */} ) } @@ -60,10 +64,7 @@ const UploadValidationButtonGroup = ({ {' '} - {/* TODO: Remove USDR_ADMIN check when ready || 2024-05-13 Milestone */} - {hasRole(ROLES.USDR_ADMIN) && - latestValidation?.results && - renderValidationButtons()} + {latestValidation?.results && renderValidationButtons()} ) diff --git a/web/src/components/Upload/UploadValidationStatus/UploadValidationStatus.tsx b/web/src/components/Upload/UploadValidationStatus/UploadValidationStatus.tsx index 5fef00dd..9f5b930a 100644 --- a/web/src/components/Upload/UploadValidationStatus/UploadValidationStatus.tsx +++ b/web/src/components/Upload/UploadValidationStatus/UploadValidationStatus.tsx @@ -7,7 +7,15 @@ const UploadValidationStatus = ({ latestValidation }) => { } const getValidationStatus = () => { - if (latestValidation?.results === null) { + const { + passed, + isManual, + createdAt, + results, + initiatedBy: { name }, + } = latestValidation + + if (results === null) { return ( Validation in progress... @@ -15,14 +23,29 @@ const UploadValidationStatus = ({ latestValidation }) => { ) } - const { passed, createdAt, initiatedBy } = latestValidation - const statusText = passed ? 'Validated' : 'Did not pass validation' - const statusClass = passed ? 'text-success' : 'text-danger' + const valueClassName = !passed ? 'text-danger' : 'text-success' + const time = timeTag(createdAt) + + let statusOutcome + if (isManual) { + statusOutcome = 'Invalidated' + } else { + statusOutcome = passed ? 'Validated' : 'Did not pass validation' + } + + const statusText = ( + <> + {statusOutcome} + {' on '} + {time} + {' by '} + {name} + + ) return ( - - {getValidationIcon(passed)} {statusText} on {timeTag(createdAt)} by{' '} - {initiatedBy?.name} + + {getValidationIcon(passed)} {statusText} ) } diff --git a/web/src/components/Upload/Uploads/columns.tsx b/web/src/components/Upload/Uploads/columns.tsx index 09203517..7b6a3ec1 100644 --- a/web/src/components/Upload/Uploads/columns.tsx +++ b/web/src/components/Upload/Uploads/columns.tsx @@ -1,4 +1,5 @@ import { createColumnHelper } from '@tanstack/react-table' +import type { Upload } from 'types/graphql' import { Link, routes } from '@redwoodjs/router' @@ -22,20 +23,20 @@ function valueAsLink(cell): JSX.Element { function validationDisplay(row) { const { latestValidation } = row + const { results, passed, isManual, createdAt } = latestValidation - if (!latestValidation || latestValidation.results === null) { + if (!latestValidation || results === null) { return 'Not set' } - const { passed, createdAt } = latestValidation const formattedDate = formatDateString(createdAt) if (!passed) { - return ( - - Did not pass validation on {formattedDate} - - ) + const invalidText = isManual + ? `Invalidated on ${formattedDate}` + : `Did not pass validation on ${formattedDate}` + + return {invalidText} } return formattedDate @@ -50,7 +51,7 @@ export const columnDefs = [ cell: (info) => info.getValue(), header: 'Agency', }), - columnHelper.accessor('expenditureCategory.code', { + columnHelper.accessor((row: Upload) => row.expenditureCategory?.code, { cell: (info) => info.getValue() ?? 'Not set', header: 'EC Code', }), diff --git a/web/src/components/Upload/UploadsCell/UploadsCell.tsx b/web/src/components/Upload/UploadsCell/UploadsCell.tsx index 75459de5..c3b0b067 100644 --- a/web/src/components/Upload/UploadsCell/UploadsCell.tsx +++ b/web/src/components/Upload/UploadsCell/UploadsCell.tsx @@ -30,6 +30,7 @@ export const QUERY = gql` id createdAt passed + isManual results } createdAt diff --git a/web/src/components/User/Users/Users.tsx b/web/src/components/User/Users/Users.tsx index d6f5e588..047811a7 100644 --- a/web/src/components/User/Users/Users.tsx +++ b/web/src/components/User/Users/Users.tsx @@ -30,8 +30,8 @@ const UsersList = ({ usersByOrganization, updateUser, usersUpdating }) => { ? user.name : user.name + ' (Deactivated)' const deactivateTitle = user.isActive - ? 'Deactivate user ' + user.id - : 'Reactivate user ' + user.id + ? 'Deactivate user ' + user.name + : 'Reactivate user ' + user.name const deactivateLabel = user.isActive ? 'Deactivate' : 'Reactivate' // Actions @@ -48,11 +48,11 @@ const UsersList = ({ usersByOrganization, updateUser, usersUpdating }) => { {truncate(user.agency?.name)} {timeTag(user.createdAt)} -
+
{hasEditAccess && ( Edit