diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 0363a01f83..43852f0859 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -106,7 +106,7 @@ "maxfileSize": "Max file size of 1MB exceeded. Upload a smaller file.", "dragAndDropFile": "Drag and drop your file here or upload one.", "uploadYamlFile": "Upload your YAML file", - "deleteQuestionnaire": "Deleting a questionnaire will cascade into the deletion of all answered questionnaires associated to applications and/or archetypes.", + "deleteQuestionnire": "Deleting a questionnaire will cascade into the deletion of all answered questionnaires associated to applications and/or archetypes.", "confirmDeletion": "Confirm deletion by typing <1>{{nameToDelete}}</1> below:" }, "title": { @@ -122,6 +122,7 @@ "importApplicationFile": "Import application file", "import": "Import {{what}}", "leavePage": "Leave page", + "leaveAssessment": "Leave assessment", "new": "New {{what}}", "newArchetype": "Create new archetype", "newAssessment": "New assessment", diff --git a/client/src/app/Routes.tsx b/client/src/app/Routes.tsx index 1575586ea6..7c013154d5 100644 --- a/client/src/app/Routes.tsx +++ b/client/src/app/Routes.tsx @@ -12,7 +12,6 @@ import { ErrorBoundary } from "react-error-boundary"; import { ErrorFallback } from "@app/components/ErrorFallback"; import { FEATURES_ENABLED } from "./FeatureFlags"; -const Assessment = lazy(() => import("./pages/assessment/assessment-page")); const Review = lazy(() => import("./pages/review/review-page")); const AssessmentSettings = lazy( () => @@ -80,16 +79,6 @@ export const devRoutes: IRoute[] = [ comp: ManageImports, exact: false, }, - { - path: Paths.archetypesAssessment, - comp: Assessment, - exact: false, - }, - { - path: Paths.applicationsAssessment, - comp: Assessment, - exact: false, - }, { path: Paths.archetypeReview, comp: Review, diff --git a/client/src/app/pages/assessment/assessment-page.tsx b/client/src/app/pages/assessment/assessment-page.tsx deleted file mode 100644 index e834e4f158..0000000000 --- a/client/src/app/pages/assessment/assessment-page.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useState } from "react"; -import { useParams } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { AxiosError } from "axios"; -import { - Alert, - AlertActionCloseButton, - Bullseye, - PageSection, - PageSectionTypes, -} from "@patternfly/react-core"; -import BanIcon from "@patternfly/react-icons/dist/esm/icons/ban-icon"; -import { AssessmentRoute } from "@app/Paths"; -import { getAxiosErrorMessage } from "@app/utils/utils"; -import { SimpleEmptyState } from "@app/components/SimpleEmptyState"; -import { useFetchAssessmentById } from "@app/queries/assessments"; -import { AssessmentPageHeader } from "./components/assessment-page-header"; -import { AssessmentWizard } from "./components/assessment-wizard/assessment-wizard"; - -const AssessmentPage: React.FC = () => { - const { t } = useTranslation(); - - const { assessmentId } = useParams<AssessmentRoute>(); - - const { assessment, isFetching, fetchError } = - useFetchAssessmentById(assessmentId); - - const [saveError, setSaveError] = useState<AxiosError>(); - - if (fetchError) { - return ( - <> - <PageSection variant="light"> - <AssessmentPageHeader assessment={assessment} /> - </PageSection> - <PageSection variant="light" type={PageSectionTypes.wizard}> - <Bullseye> - <SimpleEmptyState - icon={BanIcon} - title={t("message.couldNotFetchTitle")} - description={t("message.couldNotFetchBody") + "."} - /> - </Bullseye> - </PageSection> - </> - ); - } - return ( - <> - <PageSection variant="light"> - <AssessmentPageHeader assessment={assessment} /> - </PageSection> - <PageSection variant="light" type={PageSectionTypes.wizard}> - {saveError && ( - <Alert - variant="danger" - isInline - title={getAxiosErrorMessage(saveError)} - actionClose={ - <AlertActionCloseButton onClose={() => setSaveError(undefined)} /> - } - /> - )} - <AssessmentWizard - assessment={assessment} - isLoadingAssessment={isFetching} - fetchError={fetchError} - /> - </PageSection> - </> - ); -}; -export default AssessmentPage; diff --git a/client/src/app/pages/assessment/components/assessment-actions/components/dynamic-assessment-actions-row.tsx b/client/src/app/pages/assessment/components/assessment-actions/components/dynamic-assessment-actions-row.tsx index e7651c7a05..7a532b8938 100644 --- a/client/src/app/pages/assessment/components/assessment-actions/components/dynamic-assessment-actions-row.tsx +++ b/client/src/app/pages/assessment/components/assessment-actions/components/dynamic-assessment-actions-row.tsx @@ -11,7 +11,7 @@ import { useCreateAssessmentMutation, useDeleteAssessmentMutation, } from "@app/queries/assessments"; -import { Button } from "@patternfly/react-core"; +import { Button, Spinner } from "@patternfly/react-core"; import React, { FunctionComponent } from "react"; import { useHistory } from "react-router-dom"; import "./dynamic-assessment-actions-row.css"; @@ -20,7 +20,11 @@ import { formatPath, getAxiosErrorMessage } from "@app/utils/utils"; import { Td } from "@patternfly/react-table"; import { NotificationsContext } from "@app/components/NotificationsContext"; import { useTranslation } from "react-i18next"; -import { useQueryClient } from "@tanstack/react-query"; +import { + useIsFetching, + useIsMutating, + useQueryClient, +} from "@tanstack/react-query"; import { TrashIcon } from "@patternfly/react-icons"; import useIsArchetype from "@app/hooks/useIsArchetype"; @@ -36,11 +40,19 @@ interface DynamicAssessmentActionsRowProps { archetype?: Archetype; assessment?: Assessment; isReadonly?: boolean; + onOpenModal: (assessment: Assessment) => void; } const DynamicAssessmentActionsRow: FunctionComponent< DynamicAssessmentActionsRowProps -> = ({ questionnaire, application, archetype, assessment, isReadonly }) => { +> = ({ + questionnaire, + application, + archetype, + assessment, + isReadonly, + onOpenModal, +}) => { const isArchetype = useIsArchetype(); const history = useHistory(); const { t } = useTranslation(); @@ -64,7 +76,11 @@ const DynamicAssessmentActionsRow: FunctionComponent< }), variant: "success", }); - queryClient.invalidateQueries([assessmentsByItemIdQueryKey]); + queryClient.invalidateQueries([ + assessmentsByItemIdQueryKey, + application?.id, + isArchetype, + ]); }; const onDeleteError = (error: AxiosError) => { @@ -74,10 +90,11 @@ const DynamicAssessmentActionsRow: FunctionComponent< }); }; - const { mutate: deleteAssessment } = useDeleteAssessmentMutation( - onDeleteAssessmentSuccess, - onDeleteError - ); + const { mutate: deleteAssessment, isLoading: isDeleting } = + useDeleteAssessmentMutation(onDeleteAssessmentSuccess, onDeleteError); + + const isFetching = useIsFetching(); + const isMutating = useIsMutating(); const { mutateAsync: deleteAssessmentAsync } = useDeleteAssessmentMutation( onDeleteAssessmentSuccess, @@ -117,19 +134,8 @@ const DynamicAssessmentActionsRow: FunctionComponent< try { const result = await createAssessmentAsync(newAssessment); - if (isArchetype) { - history.push( - formatPath(Paths.archetypesAssessment, { - assessmentId: result.id, - }) - ); - } else { - history.push( - formatPath(Paths.applicationsAssessment, { - assessmentId: result.id, - }) - ); - } + + onOpenModal(result); } catch (error) { console.error("Error while creating assessment:", error); pushNotification({ @@ -165,17 +171,8 @@ const DynamicAssessmentActionsRow: FunctionComponent< if (action === AssessmentAction.Take) { takeAssessment(); - } else if (action === AssessmentAction.Continue) { - history.push( - formatPath( - isArchetype - ? Paths.archetypesAssessment - : Paths.applicationsAssessment, - { - assessmentId: assessment?.id, - } - ) - ); + } else if (action === AssessmentAction.Continue && assessment?.id) { + onOpenModal(assessment); } else if (action === AssessmentAction.Retake) { if (assessment) { try { @@ -204,7 +201,7 @@ const DynamicAssessmentActionsRow: FunctionComponent< <> <Td> <div> - {!isReadonly ? ( + {isReadonly ? null : !isDeleting && !isFetching && !isMutating ? ( <Button type="button" variant="primary" @@ -215,7 +212,11 @@ const DynamicAssessmentActionsRow: FunctionComponent< > {determineAction()} </Button> - ) : null} + ) : ( + <Spinner role="status" size="md"> + <span className="sr-only">Loading...</span> + </Spinner> + )} {assessment?.status === "complete" ? ( <Button type="button" diff --git a/client/src/app/pages/assessment/components/assessment-actions/components/questionnaires-table.tsx b/client/src/app/pages/assessment/components/assessment-actions/components/questionnaires-table.tsx index 212236b187..a3aebdbbc0 100644 --- a/client/src/app/pages/assessment/components/assessment-actions/components/questionnaires-table.tsx +++ b/client/src/app/pages/assessment/components/assessment-actions/components/questionnaires-table.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; import { useLocalTableControls } from "@app/hooks/table-controls"; @@ -15,6 +15,7 @@ import { Questionnaire, } from "@app/api/models"; import DynamicAssessmentActionsRow from "./dynamic-assessment-actions-row"; +import AssessmentModal from "../../assessment-wizard/assessment-wizard-modal"; interface QuestionnairesTableProps { tableName: string; @@ -48,6 +49,18 @@ const QuestionnairesTable: React.FC<QuestionnairesTableProps> = ({ numRenderedColumns, propHelpers: { tableProps, getThProps, getTrProps, getTdProps }, } = tableControls; + + const [createdAssessment, setCreatedAssessment] = useState<Assessment | null>( + null + ); + const handleModalOpen = (assessment: Assessment) => { + setCreatedAssessment(assessment); + }; + + const handleModalClose = () => { + setCreatedAssessment(null); + }; + return ( <> <Table @@ -86,10 +99,7 @@ const QuestionnairesTable: React.FC<QuestionnairesTableProps> = ({ ); return ( - <Tr - key={questionnaire.name} - {...getTrProps({ item: questionnaire })} - > + <Tr key={questionnaire.name}> <TableRowContentWithControls {...tableControls} item={questionnaire} @@ -112,6 +122,7 @@ const QuestionnairesTable: React.FC<QuestionnairesTableProps> = ({ application={application} archetype={archetype} isReadonly={isReadonly} + onOpenModal={handleModalOpen} /> ) : null} </TableRowContentWithControls> @@ -121,6 +132,11 @@ const QuestionnairesTable: React.FC<QuestionnairesTableProps> = ({ </Tbody> </ConditionalTableBody> </Table> + <AssessmentModal + isOpen={!!createdAssessment} + onRequestClose={handleModalClose} + assessment={createdAssessment} + /> </> ); }; diff --git a/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard-modal.tsx b/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard-modal.tsx new file mode 100644 index 0000000000..9a886cbd27 --- /dev/null +++ b/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard-modal.tsx @@ -0,0 +1,36 @@ +import { Modal, ModalVariant } from "@patternfly/react-core"; +import React, { FunctionComponent } from "react"; +import { AssessmentWizard } from "./assessment-wizard"; +import { Assessment } from "@app/api/models"; + +interface AssessmentModalProps { + isOpen: boolean; + onRequestClose: () => void; + assessment: Assessment | null; +} + +const AssessmentModal: FunctionComponent<AssessmentModalProps> = ({ + isOpen, + onRequestClose, + assessment, +}) => { + return ( + <> + {isOpen && assessment && ( + <Modal + isOpen={isOpen} + onClose={onRequestClose} + showClose={false} + aria-label="Application assessment wizard modal" + hasNoBodyWrapper + onEscapePress={onRequestClose} + variant={ModalVariant.large} + > + <AssessmentWizard assessment={assessment} onClose={onRequestClose} /> + </Modal> + )} + </> + ); +}; + +export default AssessmentModal; diff --git a/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard.tsx b/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard.tsx index 64e002e708..e017352011 100644 --- a/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard.tsx +++ b/client/src/app/pages/assessment/components/assessment-wizard/assessment-wizard.tsx @@ -1,13 +1,12 @@ import * as yup from "yup"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { FieldErrors, FormProvider, useForm } from "react-hook-form"; import { - Alert, ButtonVariant, - Spinner, Wizard, + WizardHeader, WizardStep, } from "@patternfly/react-core"; @@ -44,7 +43,7 @@ import useIsArchetype from "@app/hooks/useIsArchetype"; import { useFetchStakeholderGroups } from "@app/queries/stakeholdergoups"; import { useFetchStakeholders } from "@app/queries/stakeholders"; import { WizardStepNavDescription } from "../wizard-step-nav-description"; -import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import { AppPlaceholder } from "@app/components/AppPlaceholder"; export const SAVE_ACTION_KEY = "saveAction"; @@ -68,14 +67,12 @@ export interface AssessmentWizardValues { export interface AssessmentWizardProps { assessment?: Assessment; - isLoadingAssessment: boolean; - fetchError?: AxiosError | null; + onClose: () => void; } export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ assessment, - isLoadingAssessment, - fetchError, + onClose, }) => { const isArchetype = useIsArchetype(); const queryClient = useQueryClient(); @@ -135,7 +132,7 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ }); } return questions; - }, [assessment, isLoadingAssessment]); + }, [assessment]); const validationSchema = yup.object().shape({ stakeholders: yup.array().of(yup.string()), @@ -145,22 +142,16 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ const methods = useForm<AssessmentWizardValues>({ resolver: yupResolver(validationSchema), mode: "all", - }); - const values = methods.getValues(); - - useEffect(() => { - methods.reset({ + defaultValues: { stakeholders: assessment?.stakeholders?.map((sh) => sh.name).sort() ?? [], stakeholderGroups: assessment?.stakeholderGroups?.map((sg) => sg.name).sort() ?? [], - questions: initialQuestions, - comments: initialComments, + [COMMENTS_KEY]: initialComments, + [QUESTIONS_KEY]: initialQuestions, [SAVE_ACTION_KEY]: SAVE_ACTION_VALUE.SAVE_AS_DRAFT, - }); - return () => { - methods.reset(); - }; - }, [assessment]); + }, + }); + const values = methods.getValues(); const errors = methods.formState.errors; const isValid = methods.formState.isValid; @@ -213,10 +204,6 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ return allQuestionsAnswered && allQuestionsValid; }; - const maxCategoryWithData = [...sortedSections].reverse().find((section) => { - return section.questions.some((question) => questionHasValue(question)); - }); - const onInvalid = (errors: FieldErrors<AssessmentWizardValues>) => console.error("form errors", errors); @@ -295,19 +282,6 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ title: "Assessment has been saved as a draft.", variant: "info", }); - if (isArchetype) { - history.push( - formatPath(Paths.archetypeAssessmentActions, { - archetypeId: assessment?.archetype?.id, - }) - ); - } else { - history.push( - formatPath(Paths.applicationAssessmentActions, { - applicationId: assessment?.application?.id, - }) - ); - } } catch (error) { pushNotification({ title: "Failed to save as a draft.", @@ -358,20 +332,6 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ title: "Assessment has been saved.", variant: "success", }); - - if (isArchetype) { - history.push( - formatPath(Paths.archetypeAssessmentActions, { - archetypeId: assessment?.archetype?.id, - }) - ); - } else { - history.push( - formatPath(Paths.applicationAssessmentActions, { - applicationId: assessment?.application?.id, - }) - ); - } } catch (error) { pushNotification({ title: "Failed to save.", @@ -478,29 +438,27 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ switch (saveAction) { case SAVE_ACTION_VALUE.SAVE: handleSave(formValues); + methods.reset(); + onClose(); + break; case SAVE_ACTION_VALUE.SAVE_AS_DRAFT: await handleSaveAsDraft(formValues); + methods.reset(); + onClose(); break; case SAVE_ACTION_VALUE.SAVE_AND_REVIEW: handleSaveAndReview(formValues); + methods.reset(); + onClose(); break; default: + methods.reset(); + onClose(); break; } }; - useEffect(() => { - const unlisten = history.listen((newLocation, action) => { - if (action === "PUSH" && assessment) { - handleCancelAssessment(); - } - }); - return () => { - unlisten(); - }; - }, [history, assessment]); - const handleCancelAssessment = () => { if (assessment) { if (isArchetype) { @@ -511,13 +469,6 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ applicationId: assessment.application?.id, archetypeId: assessment.archetype?.id, }); - if (assessmentToCancel) { - history.push( - formatPath(Paths.archetypeAssessmentActions, { - archetypeId: assessment?.archetype?.id, - }) - ); - } } else { assessment.status === "empty" && deleteAssessmentMutation({ @@ -526,16 +477,11 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ applicationId: assessment.application?.id, archetypeId: assessment.archetype?.id, }); - if (assessmentToCancel) { - history.push( - formatPath(Paths.applicationAssessmentActions, { - applicationId: assessment?.application?.id, - }) - ); - } } - setAssessmentToCancel(null); } + setAssessmentToCancel(null); + methods.reset(); + onClose(); }; const getWizardFooter = (step: number, section?: Section) => { @@ -575,19 +521,12 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ return ( <> - {fetchError && ( - <Alert - className={`${spacing.mtMd} ${spacing.mbMd}`} - variant="danger" - isInline - title={getAxiosErrorMessage(fetchError)} - /> - )} - {isLoadingAssessment ? ( - <Spinner /> + {!assessment?.id ? ( + <AppPlaceholder /> ) : ( <FormProvider {...methods}> <Wizard + key={sortedSections.length} isVisitRequired onStepChange={(_e, curr) => { setCurrentStep(curr.index); @@ -595,6 +534,14 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ onClose={() => { assessment && setAssessmentToCancel(assessment); }} + header={ + <WizardHeader + title={t("terms.assessment")} + onClose={() => { + assessment && setAssessmentToCancel(assessment); + }} + /> + } > <WizardStep id={0} @@ -625,15 +572,15 @@ export const AssessmentWizard: React.FC<AssessmentWizardProps> = ({ </Wizard> {assessmentToCancel && ( <ConfirmDialog - title={t("dialog.title.leavePage")} + title={t("dialog.title.leaveAssessment")} isOpen - message={t("dialog.message.leavePage")} confirmBtnVariant={ButtonVariant.primary} confirmBtnLabel={t("actions.continue")} cancelBtnLabel={t("actions.cancel")} onCancel={() => setAssessmentToCancel(null)} onClose={() => setAssessmentToCancel(null)} onConfirm={() => handleCancelAssessment()} + message="Are you sure you want to close the assessment? Any unsaved changes will be lost." /> )} </FormProvider> diff --git a/client/src/app/queries/assessments.ts b/client/src/app/queries/assessments.ts index 33154d3e06..5e0586bde8 100644 --- a/client/src/app/queries/assessments.ts +++ b/client/src/app/queries/assessments.ts @@ -41,10 +41,16 @@ export const useCreateAssessmentMutation = ( mutationFn: (assessment: InitialAssessment) => createAssessment(assessment, isArchetype), onSuccess: (res) => { + const isArchetype = !!res?.archetype?.id; queryClient.invalidateQueries([ assessmentsByItemIdQueryKey, res?.application?.id, + isArchetype, + ]); + queryClient.invalidateQueries([ + assessmentsByItemIdQueryKey, res?.archetype?.id, + isArchetype, ]); }, onError: onError, @@ -61,14 +67,19 @@ export const useUpdateAssessmentMutation = ( mutationFn: (assessment: Assessment) => updateAssessment(assessment), onSuccess: (_, args) => { onSuccess && onSuccess(args.name); + const isArchetype = !!args.archetype?.id; + queryClient.invalidateQueries([QuestionnairesQueryKey]); + queryClient.invalidateQueries([ assessmentsByItemIdQueryKey, args.application?.id, + isArchetype, ]); queryClient.invalidateQueries([ assessmentsByItemIdQueryKey, args.archetype?.id, + isArchetype, ]); }, onError: onError, @@ -89,16 +100,19 @@ export const useDeleteAssessmentMutation = ( archetypeId?: number; }) => { const deletedAssessment = deleteAssessment(args.assessmentId); + const isArchetype = !!args.archetypeId; queryClient.invalidateQueries([assessmentQueryKey, args?.assessmentId]); queryClient.invalidateQueries([ARCHETYPE_QUERY_KEY, args?.archetypeId]); queryClient.invalidateQueries([ assessmentsByItemIdQueryKey, args?.archetypeId, + isArchetype, ]); queryClient.invalidateQueries([ assessmentsByItemIdQueryKey, args?.applicationId, + isArchetype, ]); return deletedAssessment; @@ -116,6 +130,7 @@ export const useFetchAssessmentById = (id?: number | string) => { queryFn: () => (id ? getAssessmentById(id) : undefined), onError: (error: AxiosError) => console.log("error, ", error), enabled: !!id, + refetchOnWindowFocus: false, }); return { assessment: data, @@ -136,9 +151,20 @@ export const useFetchAssessmentsByItemId = ( enabled: !!itemId, }); + const queryClient = useQueryClient(); + + const invalidateAssessmentsQuery = () => { + queryClient.invalidateQueries([ + assessmentsByItemIdQueryKey, + itemId, + isArchetype, + ]); + }; + return { assessments: data, isFetching: isLoading, fetchError: error, + invalidateAssessmentsQuery, }; };