diff --git a/api/src/models/project.ts b/api/src/models/project.ts index 4f46c20642..e39eaab9dd 100644 --- a/api/src/models/project.ts +++ b/api/src/models/project.ts @@ -171,7 +171,7 @@ export class PostCoordinatorData { } export interface IPostPermit { - permit_number: string; + permit_number: number; sampling_conducted: boolean; } diff --git a/api/src/openapi/schemas/project.ts b/api/src/openapi/schemas/project.ts index 4f01062456..aae948af10 100644 --- a/api/src/openapi/schemas/project.ts +++ b/api/src/openapi/schemas/project.ts @@ -40,7 +40,7 @@ export const projectPostBody = { type: 'object', properties: { permit_number: { - type: 'string' + type: 'number' }, sampling_conducted: { type: 'string' diff --git a/app/src/__mocks__/popper.js.js b/app/src/__mocks__/popper.js.js new file mode 100644 index 0000000000..086cf880cd --- /dev/null +++ b/app/src/__mocks__/popper.js.js @@ -0,0 +1,19 @@ +// __mocks__/popper.js.js + +// mocks will be picked up by Jest and applied automatically. +// Required to make the material-ui select/autocomplete components work during unit tests +// See https://stackoverflow.com/questions/60333156/how-to-fix-typeerror-document-createrange-is-not-a-function-error-while-testi + +import PopperJs from 'popper.js'; + +export default class Popper { + constructor() { + this.placements = PopperJs.placements; + + return { + update: () => {}, + destroy: () => {}, + scheduleUpdate: () => {} + }; + } +} diff --git a/app/src/components/fields/AutocompleteField.tsx b/app/src/components/fields/AutocompleteField.tsx index a371af9c0f..4c13c5b71b 100644 --- a/app/src/components/fields/AutocompleteField.tsx +++ b/app/src/components/fields/AutocompleteField.tsx @@ -1,4 +1,4 @@ -import { MenuItem, TextField } from '@material-ui/core'; +import { TextField } from '@material-ui/core'; import Autocomplete, { createFilterOptions } from '@material-ui/lab/Autocomplete'; import { useFormikContext } from 'formik'; import React from 'react'; @@ -16,14 +16,6 @@ export interface IAutocompleteField { const AutocompleteField: React.FC = (props) => { const { touched, errors, setFieldValue } = useFormikContext(); - const handleGetOptionSelected = (option: string, value: string): boolean => { - if (!option || !value) { - return false; - } - - return option === value; - }; - return ( = (props) => { value={props.value} options={props.options} getOptionLabel={(option) => option} - getOptionSelected={handleGetOptionSelected} filterOptions={createFilterOptions({ limit: props.filterLimit })} onChange={(event, option) => { setFieldValue(props.id, option); }} - renderOption={(option, { selected }) => { - return ( - <> - - {option} - - ); - }} renderInput={(params) => ( , []>(), getAllCodes: jest.fn, []>() }; @@ -30,7 +27,6 @@ const renderContainer = () => { describe('CreateProjectPage', () => { beforeEach(() => { // clear mocks before each test - mockBiohubApi().createProject.mockClear(); mockBiohubApi().getAllCodes.mockClear(); }); @@ -38,88 +34,127 @@ describe('CreateProjectPage', () => { cleanup(); }); - it('shows the page title', async () => { - await act(async () => { - mockBiohubApi().createProject.mockResolvedValue({ - id: 100 - }); - mockBiohubApi().getAllCodes.mockResolvedValue({ - code_set: [] - }); - const { findByText } = renderContainer(); - const PageTitle = await findByText('Create Project'); + it('renders the initial default page correctly', async () => { + mockBiohubApi().getAllCodes.mockResolvedValue({ + code_set: [] + }); + const { getByText, asFragment } = renderContainer(); - expect(PageTitle).toBeVisible(); + await waitFor(() => { + expect(getByText('Project Coordinator')).toBeVisible(); + + expect(getByText('Permits')).toBeVisible(); + + expect(asFragment()).toMatchSnapshot(); }); }); + it('adds the extra create project steps if at least 1 permit is marked as having conducted sampling', async () => { + mockBiohubApi().getAllCodes.mockResolvedValue({ + coordinator_agency: [{ id: 1, name: 'code 1' }] + }); + const { findByText, asFragment, getByText, getByTestId, getByLabelText } = renderContainer(); + + // wait for project coordinator form to load + expect(await findByText('First Name')).toBeVisible(); + + fireEvent.change(getByLabelText('First Name *'), { target: { value: 'first name' } }); + fireEvent.change(getByLabelText('Last Name *'), { target: { value: 'last name' } }); + fireEvent.change(getByLabelText('Business Email Address *'), { target: { value: 'email@email.com' } }); + fireEvent.change(getByLabelText('Coordinator Agency *'), { target: { value: 'agency name' } }); + + fireEvent.click(getByText('Next')); + + // wait for permit form to load + expect(await findByText('Add Another')).toBeVisible(); + + fireEvent.change(getByLabelText('Permit Number *'), { target: { value: 12345 } }); + fireEvent.change(getByTestId('sampling_conducted'), { target: { value: 'true' } }); + + // wait for forms to load + await waitFor(() => { + expect(getByText('General Information')).toBeVisible(); + + expect(getByText('Project Coordinator')).toBeVisible(); + + expect(getByText('Permits')).toBeVisible(); + + expect(getByText('General Information')).toBeVisible(); + + expect(getByText('Objectives')).toBeVisible(); + + expect(getByText('Location')).toBeVisible(); + + expect(getByText('Species')).toBeVisible(); + + expect(getByText('Funding and Partnerships')).toBeVisible(); + + expect(asFragment()).toMatchSnapshot(); + }); + }); + + it('shows the page title', async () => { + mockBiohubApi().getAllCodes.mockResolvedValue({ + code_set: [] + }); + const { findByText } = renderContainer(); + const PageTitle = await findByText('Create Project'); + + expect(PageTitle).toBeVisible(); + }); + describe('Are you sure? Dialog', () => { it('shows warning dialog if the user clicks the `Cancel and Exit` button', async () => { - await act(async () => { - mockBiohubApi().createProject.mockResolvedValue({ - id: 100 - }); - mockBiohubApi().getAllCodes.mockResolvedValue({ - code_set: [] - }); - history.push('/home'); - history.push('/projects/create'); - const { findByText, getByRole } = renderContainer(); - const BackToProjectsButton = await findByText('Cancel and Exit', { exact: false }); - - fireEvent.click(BackToProjectsButton); - const AreYouSureTitle = await findByText('Cancel Create Project'); - const AreYouSureText = await findByText('Are you sure you want to cancel?'); - const AreYouSureYesButton = await rawFindByText(getByRole('dialog'), 'Yes', { exact: false }); - - expect(AreYouSureTitle).toBeVisible(); - expect(AreYouSureText).toBeVisible(); - expect(AreYouSureYesButton).toBeVisible(); + mockBiohubApi().getAllCodes.mockResolvedValue({ + code_set: [] }); + history.push('/home'); + history.push('/projects/create'); + const { findByText, getByRole } = renderContainer(); + const BackToProjectsButton = await findByText('Cancel and Exit', { exact: false }); + + fireEvent.click(BackToProjectsButton); + const AreYouSureTitle = await findByText('Cancel Create Project'); + const AreYouSureText = await findByText('Are you sure you want to cancel?'); + const AreYouSureYesButton = await rawFindByText(getByRole('dialog'), 'Yes', { exact: false }); + + expect(AreYouSureTitle).toBeVisible(); + expect(AreYouSureText).toBeVisible(); + expect(AreYouSureYesButton).toBeVisible(); }); it('it calls history.push() if the user clicks `Yes`', async () => { - await act(async () => { - mockBiohubApi().createProject.mockResolvedValue({ - id: 100 - }); - mockBiohubApi().getAllCodes.mockResolvedValue({ - code_set: [] - }); - history.push('/home'); - history.push('/projects/create'); - const { findByText, getByRole } = renderContainer(); - const BackToProjectsButton = await findByText('Cancel and Exit', { exact: false }); - - fireEvent.click(BackToProjectsButton); - const AreYouSureYesButton = await rawFindByText(getByRole('dialog'), 'Yes', { exact: false }); - - expect(history.location.pathname).toEqual('/projects/create'); - fireEvent.click(AreYouSureYesButton); - expect(history.location.pathname).toEqual('/projects'); + mockBiohubApi().getAllCodes.mockResolvedValue({ + code_set: [] }); + history.push('/home'); + history.push('/projects/create'); + const { findByText, getByRole } = renderContainer(); + const BackToProjectsButton = await findByText('Cancel and Exit', { exact: false }); + + fireEvent.click(BackToProjectsButton); + const AreYouSureYesButton = await rawFindByText(getByRole('dialog'), 'Yes', { exact: false }); + + expect(history.location.pathname).toEqual('/projects/create'); + fireEvent.click(AreYouSureYesButton); + expect(history.location.pathname).toEqual('/projects'); }); it('it does nothing if the user clicks `No`', async () => { - await act(async () => { - mockBiohubApi().createProject.mockResolvedValue({ - id: 100 - }); - mockBiohubApi().getAllCodes.mockResolvedValue({ - code_set: [] - }); - history.push('/home'); - history.push('/projects/create'); - const { findByText, getByRole } = renderContainer(); - const BackToProjectsButton = await findByText('Cancel and Exit', { exact: false }); - - fireEvent.click(BackToProjectsButton); - const AreYouSureNoButton = await rawFindByText(getByRole('dialog'), 'No', { exact: false }); - - expect(history.location.pathname).toEqual('/projects/create'); - fireEvent.click(AreYouSureNoButton); - expect(history.location.pathname).toEqual('/projects/create'); + mockBiohubApi().getAllCodes.mockResolvedValue({ + code_set: [] }); + history.push('/home'); + history.push('/projects/create'); + const { findByText, getByRole } = renderContainer(); + const BackToProjectsButton = await findByText('Cancel and Exit', { exact: false }); + + fireEvent.click(BackToProjectsButton); + const AreYouSureNoButton = await rawFindByText(getByRole('dialog'), 'No', { exact: false }); + + expect(history.location.pathname).toEqual('/projects/create'); + fireEvent.click(AreYouSureNoButton); + expect(history.location.pathname).toEqual('/projects/create'); }); }); }); diff --git a/app/src/features/projects/CreateProjectPage.tsx b/app/src/features/projects/CreateProjectPage.tsx index 5b52add0d5..05e340237f 100644 --- a/app/src/features/projects/CreateProjectPage.tsx +++ b/app/src/features/projects/CreateProjectPage.tsx @@ -19,38 +19,49 @@ import { ErrorDialog, IErrorDialogProps } from 'components/dialog/ErrorDialog'; import YesNoDialog from 'components/dialog/YesNoDialog'; import { CreateProjectI18N } from 'constants/i18n'; import ProjectCoordinatorForm, { + IProjectCoordinatorForm, ProjectCoordinatorInitialValues, ProjectCoordinatorYupSchema } from 'features/projects/components/ProjectCoordinatorForm'; import ProjectDetailsForm, { + IProjectDetailsForm, ProjectDetailsFormInitialValues, ProjectDetailsFormYupSchema } from 'features/projects/components/ProjectDetailsForm'; import ProjectFundingForm, { + IProjectFundingForm, ProjectFundingFormInitialValues, ProjectFundingFormYupSchema } from 'features/projects/components/ProjectFundingForm'; -import ProjectPermitForm, { - ProjectPermitFormInitialValues, - ProjectPermitFormYupSchema -} from 'features/projects/components/ProjectPermitForm'; -import ProjectSpeciesForm, { - ProjectSpeciesFormInitialValues, - ProjectSpeciesFormYupSchema -} from 'features/projects/components/ProjectSpeciesForm'; import ProjectLocationForm, { ProjectLocationFormInitialValues, ProjectLocationFormYupSchema } from 'features/projects/components/ProjectLocationForm'; import ProjectObjectivesForm, { + IProjectObjectivesForm, ProjectObjectivesFormInitialValues, ProjectObjectivesFormYupSchema } from 'features/projects/components/ProjectObjectivesForm'; +import ProjectPermitForm, { + IProjectPermitForm, + ProjectPermitFormInitialValues, + ProjectPermitFormYupSchema +} from 'features/projects/components/ProjectPermitForm'; +import ProjectSpeciesForm, { + IProjectSpeciesForm, + ProjectSpeciesFormInitialValues, + ProjectSpeciesFormYupSchema +} from 'features/projects/components/ProjectSpeciesForm'; import { Formik } from 'formik'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { IGetAllCodesResponse, IProjectPostObject } from 'interfaces/useBioHubApi-interfaces'; +import { + IGetAllCodesResponse, + IPartialProjectPostObject, + IProjectPostObject +} from 'interfaces/useBioHubApi-interfaces'; import React, { useEffect, useState } from 'react'; import { useHistory } from 'react-router'; +import { IProjectLocationForm } from './components/ProjectLocationForm'; export interface ICreateProjectStep { stepTitle: string; @@ -92,6 +103,9 @@ const useStyles = makeStyles((theme) => ({ } })); +const NUM_PARTIAL_PROJECT_STEPS = 2; +const NUM_ALL_PROJECT_STEPS = 7; + /** * Page for creating a new project. * @@ -104,11 +118,18 @@ const CreateProjectPage: React.FC = () => { const biohubApi = useBiohubApi(); - // Tracks the current stepper step + const [codes, setCodes] = useState(); + + const [isLoadingCodes, setIsLoadingCodes] = useState(false); + + // Tracks the active step # const [activeStep, setActiveStep] = useState(0); - // Steps for the create project workflow - const [steps, setSteps] = useState([]); + // The number of steps listed in the UI based on the current state of the component/forms + const [numberOfSteps, setNumberOfSteps] = useState(NUM_PARTIAL_PROJECT_STEPS); + + // All possible step forms, and their current state + const [stepForms, setStepForms] = useState([]); // Whether or not to show the 'Are you sure you want to cancel' dialog const [openCancelDialog, setOpenCancelDialog] = useState(false); @@ -126,10 +147,6 @@ const CreateProjectPage: React.FC = () => { } }); - const [codes, setCodes] = useState(); - - const [isLoadingCodes, setIsLoadingCodes] = useState(false); - // Get code sets // TODO refine this call to only fetch code sets this form cares about? Or introduce caching so multiple calls is still fast? useEffect(() => { @@ -152,137 +169,159 @@ const CreateProjectPage: React.FC = () => { } }, [biohubApi, isLoadingCodes, codes]); - // Define steps + // Initialize the forms for each step of the workflow useEffect(() => { - const setFormSteps = () => { - setSteps([ - { - stepTitle: 'Project Coordinator', - stepSubTitle: 'Enter contact details for the project coordinator', - stepContent: ( - { - return item.name; - }) || [] - } - /> - ), - stepValues: ProjectCoordinatorInitialValues, - stepValidation: ProjectCoordinatorYupSchema - }, - { - stepTitle: 'Permits', - stepSubTitle: 'Enter permits associated with this project', - stepContent: , - stepValues: ProjectPermitFormInitialValues, - stepValidation: ProjectPermitFormYupSchema - }, - { - stepTitle: 'General Information', - stepSubTitle: 'General information and details about this project', - stepContent: ( - { - return { value: item.id, label: item.name }; - }) || [] - } - project_activity={ - codes?.project_activity?.map((item) => { - return { value: item.id, label: item.name }; - }) || [] - } - climate_change_initiative={ - codes?.climate_change_initiative?.map((item) => { - return { value: item.id, label: item.name }; - }) || [] - } - /> - ), - stepValues: ProjectDetailsFormInitialValues, - stepValidation: ProjectDetailsFormYupSchema - }, - { - stepTitle: 'Objectives', - stepSubTitle: 'Enter the objectives and potential caveats for this project', - stepContent: , - stepValues: ProjectObjectivesFormInitialValues, - stepValidation: ProjectObjectivesFormYupSchema - }, - { - stepTitle: 'Location', - stepSubTitle: 'Specify project regions and boundary information', - stepContent: ( - { - return { value: item.name, label: item.name }; - }) || [] - } - /> - ), - stepValues: ProjectLocationFormInitialValues, - stepValidation: ProjectLocationFormYupSchema - }, - { - stepTitle: 'Species', - stepSubTitle: 'Information about species this project is inventorying or monitoring', - stepContent: ( - { - return { value: item.name, label: item.name }; - }) || [] - } - /> - ), - stepValues: ProjectSpeciesFormInitialValues, - stepValidation: ProjectSpeciesFormYupSchema - }, - { - stepTitle: 'Funding and Partnerships', - stepSubTitle: 'Specify funding and partnerships for the project', - stepContent: ( - { - return { value: item.id, label: item.name }; - }) || [] - } - investment_action_category={ - codes?.investment_action_category?.map((item) => { - return { value: item.id, fs_id: item.fs_id, label: item.name }; - }) || [] - } - first_nations={ - codes?.first_nations?.map((item) => { - return { value: item.id, label: item.name }; - }) || [] - } - stakeholder_partnerships={ - codes?.funding_source?.map((item) => { - return { value: item.name, label: item.name }; - }) || [] - } - /> - ), - stepValues: ProjectFundingFormInitialValues, - stepValidation: ProjectFundingFormYupSchema - } - ]); - }; + if (!codes) { + return; + } - if (codes && !steps?.length) { - setFormSteps(); + if (stepForms.length) { + return; } - }, [codes, steps]); + + setStepForms([ + { + stepTitle: 'Project Coordinator', + stepSubTitle: 'Enter contact details for the project coordinator', + stepContent: ( + { + return item.name; + }) || [] + } + /> + ), + stepValues: ProjectCoordinatorInitialValues, + stepValidation: ProjectCoordinatorYupSchema + }, + { + stepTitle: 'Permits', + stepSubTitle: 'Enter permits associated with this project', + stepContent: ( + { + if (isAtLeastOnePermitMarkedSamplingConducted(values)) { + setNumberOfSteps(NUM_ALL_PROJECT_STEPS); + } else { + setNumberOfSteps(NUM_PARTIAL_PROJECT_STEPS); + } + }} + /> + ), + stepValues: ProjectPermitFormInitialValues, + stepValidation: ProjectPermitFormYupSchema + }, + { + stepTitle: 'General Information', + stepSubTitle: 'General information and details about this project', + stepContent: ( + { + return { value: item.id, label: item.name }; + }) || [] + } + project_activity={ + codes?.project_activity?.map((item) => { + return { value: item.id, label: item.name }; + }) || [] + } + climate_change_initiative={ + codes?.climate_change_initiative?.map((item) => { + return { value: item.id, label: item.name }; + }) || [] + } + /> + ), + stepValues: ProjectDetailsFormInitialValues, + stepValidation: ProjectDetailsFormYupSchema + }, + { + stepTitle: 'Objectives', + stepSubTitle: 'Enter the objectives and potential caveats for this project', + stepContent: , + stepValues: ProjectObjectivesFormInitialValues, + stepValidation: ProjectObjectivesFormYupSchema + }, + { + stepTitle: 'Location', + stepSubTitle: 'Specify project regions and boundary information', + stepContent: ( + { + return { value: item.name, label: item.name }; + }) || [] + } + /> + ), + stepValues: ProjectLocationFormInitialValues, + stepValidation: ProjectLocationFormYupSchema + }, + { + stepTitle: 'Species', + stepSubTitle: 'Information about species this project is inventorying or monitoring', + stepContent: ( + { + return { value: item.name, label: item.name }; + }) || [] + } + /> + ), + stepValues: ProjectSpeciesFormInitialValues, + stepValidation: ProjectSpeciesFormYupSchema + }, + { + stepTitle: 'Funding and Partnerships', + stepSubTitle: 'Specify funding and partnerships for the project', + stepContent: ( + { + return { value: item.id, label: item.name }; + }) || [] + } + investment_action_category={ + codes?.investment_action_category?.map((item) => { + return { value: item.id, fs_id: item.fs_id, label: item.name }; + }) || [] + } + first_nations={ + codes?.first_nations?.map((item) => { + return { value: item.id, label: item.name }; + }) || [] + } + stakeholder_partnerships={ + codes?.funding_source?.map((item) => { + return { value: item.name, label: item.name }; + }) || [] + } + /> + ), + stepValues: ProjectFundingFormInitialValues, + stepValidation: ProjectFundingFormYupSchema + } + ]); + }, [codes, stepForms]); + + /** + * Return true if there exists at least 1 permit, which has been marked as having conducted sampling, false otherwise. + * + * @param {IProjectPermitForm} permitFormValues + * @return {boolean} {boolean} + */ + const isAtLeastOnePermitMarkedSamplingConducted = (permitFormValues: IProjectPermitForm): boolean => { + return permitFormValues?.permits?.some((permitItem) => permitItem.sampling_conducted === 'true'); + }; const updateSteps = (values: any) => { - setSteps((currentSteps) => { - let updatedSteps = [...currentSteps]; - updatedSteps[activeStep].stepValues = values; - return updatedSteps; + setStepForms((currentStepForms) => { + let updatedStepForms = [...currentStepForms]; + updatedStepForms[activeStep].stepValues = values; + return updatedStepForms; }); }; @@ -321,48 +360,160 @@ const CreateProjectPage: React.FC = () => { }; /** - * Handle project creation. - * - * Format the data to match the API request, and send to the API. + * Handle creation of partial or full projects. */ const handleSubmit = async () => { try { - const coordinatorData = steps[0].stepValues; - const permitData = steps[1].stepValues; - const generalData = steps[2].stepValues; - const objectivesData = steps[3].stepValues; - const locationData = steps[4].stepValues; - const speciesData = steps[5].stepValues; - const fundingData = steps[6].stepValues; - - const projectPostObject: IProjectPostObject = { - coordinator: coordinatorData, - permit: permitData, - project: generalData, - objectives: objectivesData, - location: locationData, - species: speciesData, - funding: fundingData - }; - - const response = await biohubApi.createProject(projectPostObject); - - if (!response || !response.id) { - showErrorDialog({ dialogError: 'The response from the server was null, or did not contain a project ID.' }); - return; + const coordinatorData = stepForms[0].stepValues as IProjectCoordinatorForm; + const permitData = stepForms[1].stepValues as IProjectPermitForm; + const generalData = stepForms[2].stepValues as IProjectDetailsForm; + const objectivesData = stepForms[3].stepValues as IProjectObjectivesForm; + const locationData = stepForms[4].stepValues as IProjectLocationForm; + const speciesData = stepForms[5].stepValues as IProjectSpeciesForm; + const fundingData = stepForms[6].stepValues as IProjectFundingForm; + + if (!isAtLeastOnePermitMarkedSamplingConducted(permitData)) { + await createPartialProject({ + coordinator: coordinatorData, + permit: permitData + }); + } else { + await createProject({ + coordinator: coordinatorData, + permit: permitData, + project: generalData, + objectives: objectivesData, + location: locationData, + species: speciesData, + funding: fundingData + }); } - - history.push(`/projects/${response.id}`); } catch (error) { showErrorDialog({ ...((error?.message && { dialogError: error.message }) || {}) }); } }; + /** + * Creates a new partial-project record + * + * @param {IPartialProjectPostObject} partialProjectPostObject + * @return {*} + */ + const createPartialProject = async (partialProjectPostObject: IPartialProjectPostObject) => { + // TODO update this api call + const response = await biohubApi.createPartialProject(partialProjectPostObject); + + if (!response || !response.id) { + showErrorDialog({ dialogError: 'The response from the server was null.' }); + return; + } + + history.push(`/projects/${response.id}`); + }; + + /** + * Creates a new project record + * + * @param {IProjectPostObject} projectPostObject + * @return {*} + */ + const createProject = async (projectPostObject: IProjectPostObject) => { + const response = await biohubApi.createProject(projectPostObject); + + if (!response || !response.id) { + showErrorDialog({ dialogError: 'The response from the server was null, or did not contain a project ID.' }); + return; + } + + history.push(`/projects/${response.id}`); + }; + const showErrorDialog = (textDialogProps?: Partial) => { setOpenErrorDialogProps({ ...openErrorDialogProps, ...textDialogProps, open: true }); }; - if (!steps?.length) { + /** + * Build an array of form steps, based on the current value of `numberOfSteps` + * + * Example: if `numberOfSteps=4`, then return an array of 4 `` items, one for each of the first 4 elements of + * the `formSteps` array. + * + * @return {*} + */ + const getProjectSteps = () => { + const stepsToRender = []; + + for (let index = 0; index < numberOfSteps; index++) { + stepsToRender.push( + + + + + {stepForms[index].stepTitle} + + {stepForms[index].stepSubTitle} + + + + + { + handleSaveAndNext(values); + }}> + {(props) => ( + <> + {stepForms[index].stepContent} + + + + + + {activeStep !== 0 && ( + + )} + + + + + + + + )} + + + + + ); + } + + return stepsToRender; + }; + + if (!stepForms.length) { return ; } @@ -395,90 +546,34 @@ const CreateProjectPage: React.FC = () => { - {steps.map((step) => ( - - - - - {step.stepTitle} - - {step.stepSubTitle} - - - - - { - handleSaveAndNext(values); - }}> - {(props) => ( - <> - {step.stepContent} - - - - - - {activeStep === 0 ? ( - ' ' - ) : ( - - )} - - - - - - - - )} - - - - - ))} + {getProjectSteps()} - {activeStep === steps.length && ( + {activeStep === numberOfSteps && ( All steps complete! - - - diff --git a/app/src/features/projects/__snapshots__/CreateProjectPage.test.tsx.snap b/app/src/features/projects/__snapshots__/CreateProjectPage.test.tsx.snap new file mode 100644 index 0000000000..b28b0b4feb --- /dev/null +++ b/app/src/features/projects/__snapshots__/CreateProjectPage.test.tsx.snap @@ -0,0 +1,1428 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateProjectPage adds the extra create project steps if at least 1 permit is marked as having conducted sampling 1`] = ` + +
+
+
+
+ +
+

+ Create Project +

+
+
+
+
+
+
+
+ + + + + + +
+

+ Project Coordinator +

+
+ Enter contact details for the project coordinator +
+
+
+
+
+
+
+
+ +
+
+ + + + + + +
+

+ Permits +

+
+ Enter permits associated with this project +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+
+ Yes +
+ + + +
+

+

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
+ + + + + + +
+

+ General Information +

+
+ General information and details about this project +
+
+
+
+
+
+
+
+ +
+
+ + + + + + +
+

+ Objectives +

+
+ Enter the objectives and potential caveats for this project +
+
+
+
+
+
+
+
+ +
+
+ + + + + + +
+

+ Location +

+
+ Specify project regions and boundary information +
+
+
+
+
+
+
+
+ +
+
+ + + + + + +
+

+ Species +

+
+ Information about species this project is inventorying or monitoring +
+
+
+
+
+
+
+
+ +
+
+ + + + + + +
+

+ Funding and Partnerships +

+
+ Specify funding and partnerships for the project +
+
+
+
+
+
+
+
+
+
+
+ , + +`; + +exports[`CreateProjectPage renders the initial default page correctly 1`] = ` + +
+
+
+
+ +
+

+ Create Project +

+
+
+
+
+
+
+
+ + + + + + +
+

+ Project Coordinator +

+
+ Enter contact details for the project coordinator +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+ +
+
+
+ + Share Contact Details + + +

+ Do you want the project coordinator contact information visible to the public? +

+
+
+ + +

+

+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
+ + + + + + +
+

+ Permits +

+
+ Enter permits associated with this project +
+
+
+
+
+
+
+
+
+
+
+ , + +`; diff --git a/app/src/features/projects/components/ProjectCoordinatorForm.tsx b/app/src/features/projects/components/ProjectCoordinatorForm.tsx index ab79d6da1d..17f4306e0c 100644 --- a/app/src/features/projects/components/ProjectCoordinatorForm.tsx +++ b/app/src/features/projects/components/ProjectCoordinatorForm.tsx @@ -1,4 +1,3 @@ -import AutocompleteField from 'components/fields/AutocompleteField'; import { Box, FormControl, @@ -12,6 +11,7 @@ import { TextField, Typography } from '@material-ui/core'; +import AutocompleteField from 'components/fields/AutocompleteField'; import { useFormikContext } from 'formik'; import React from 'react'; import * as yup from 'yup'; @@ -24,10 +24,6 @@ export interface IProjectCoordinatorForm { share_contact_details: string; } -export interface IProjectCoordinatorFormProps { - coordinator_agency: string[]; -} - export const ProjectCoordinatorInitialValues: IProjectCoordinatorForm = { first_name: '', last_name: '', @@ -44,6 +40,10 @@ export const ProjectCoordinatorYupSchema = yup.object().shape({ share_contact_details: yup.string().required('Required') }); +export interface IProjectCoordinatorFormProps { + coordinator_agency: string[]; +} + const useStyles = makeStyles((theme) => ({ legend: { marginTop: '1rem', @@ -60,87 +60,87 @@ const useStyles = makeStyles((theme) => ({ */ const ProjectCoordinatorForm: React.FC = (props) => { const classes = useStyles(); - const { values, touched, errors, handleChange } = useFormikContext(); + const { values, touched, errors, handleChange, handleSubmit } = useFormikContext(); return ( - - - - - - - - - - - - - - - - - - - Share Contact Details - - Do you want the project coordinator contact information visible to the public? - - - } label="No" /> - } label="Yes" /> - {errors.share_contact_details} - - - +
+ + + + + + + + + + + + + + + + + Share Contact Details + + Do you want the project coordinator contact information visible to the public? + + + } label="No" /> + } label="Yes" /> + {errors.share_contact_details} + + + + - +
); }; diff --git a/app/src/features/projects/components/ProjectFundingForm.tsx b/app/src/features/projects/components/ProjectFundingForm.tsx index 807ac047b0..df3b84bd92 100644 --- a/app/src/features/projects/components/ProjectFundingForm.tsx +++ b/app/src/features/projects/components/ProjectFundingForm.tsx @@ -3,8 +3,10 @@ import { Delete, Edit } from '@material-ui/icons'; import MultiAutocompleteFieldVariableSize, { IMultiAutocompleteFieldOption } from 'components/fields/MultiAutocompleteFieldVariableSize'; +import { DATE_FORMAT } from 'constants/dateFormats'; import { FieldArray, useFormikContext } from 'formik'; import React, { useState } from 'react'; +import { getFormattedDateRangeString } from 'utils/Utils'; import * as yup from 'yup'; import ProjectFundingItemForm, { IProjectFundingFormArrayItem, @@ -107,80 +109,65 @@ const ProjectFundingForm: React.FC = (props) => { )} {values.funding_agencies.map((fundingAgency, index) => { return ( - - - - - - - {getCodeValueNameByID(props.funding_sources, fundingAgency.agency_id)} - - - - { - setCurrentProjectFundingFormArrayItem({ - index: index, - values: values.funding_agencies[index] - }); - setIsModalOpen(true); - }}> - - - arrayHelpers.remove(index)}> - - - - - - - - - Funding Amount - - - - - Start Date / End Date - + + + + + + + + {getCodeValueNameByID(props.funding_sources, fundingAgency.agency_id)} + - - - Agency Project ID - - - - - Investment Action/Category - + + { + setCurrentProjectFundingFormArrayItem({ + index: index, + values: values.funding_agencies[index] + }); + setIsModalOpen(true); + }}> + + + arrayHelpers.remove(index)}> + + - - - - - {fundingAgency.funding_amount} + + + Funding Amount + {fundingAgency.funding_amount} - - {fundingAgency.start_date} - {fundingAgency.end_date && ` / ${fundingAgency.end_date}`} + + Start Date / End Date + + {getFormattedDateRangeString( + DATE_FORMAT.MediumDateFormat, + fundingAgency.start_date, + fundingAgency.end_date + )} + - - {fundingAgency.agency_project_id} + + Agency Project ID + {fundingAgency.agency_project_id} - - {fundingAgency.investment_action_category} + + Investment Action/Category + {fundingAgency.investment_action_category} - - - + + + ); })} diff --git a/app/src/features/projects/components/ProjectFundingItemForm.test.tsx b/app/src/features/projects/components/ProjectFundingItemForm.test.tsx index 28730bd274..7a79868cc9 100644 --- a/app/src/features/projects/components/ProjectFundingItemForm.test.tsx +++ b/app/src/features/projects/components/ProjectFundingItemForm.test.tsx @@ -41,11 +41,6 @@ const investment_action_category: IInvestmentActionCategoryOption[] = [ ]; describe('ProjectFundingItemForm', () => { - beforeAll(() => { - // ignore un-fixable warning about initial agency_id select value being 0 when it expects it to be > 1 - jest.spyOn(console, 'warn').mockImplementation(() => {}); - }); - it('renders correctly with default empty values', () => { const { baseElement } = render(
diff --git a/app/src/features/projects/components/ProjectFundingItemForm.tsx b/app/src/features/projects/components/ProjectFundingItemForm.tsx index 3d49ccf96c..191d32101f 100644 --- a/app/src/features/projects/components/ProjectFundingItemForm.tsx +++ b/app/src/features/projects/components/ProjectFundingItemForm.tsx @@ -1,4 +1,5 @@ import { + Box, Button, Dialog, DialogActions, @@ -13,7 +14,8 @@ import { MenuItem, OutlinedInput, Select, - TextField + TextField, + Typography } from '@material-ui/core'; import { IMultiAutocompleteFieldOption } from 'components/fields/MultiAutocompleteFieldVariableSize'; import { Formik, FormikHelpers } from 'formik'; @@ -31,19 +33,27 @@ export interface IProjectFundingFormArrayItem { } export const ProjectFundingFormArrayItemInitialValues: IProjectFundingFormArrayItem = { - agency_id: 0, - investment_action_category: 0, + agency_id: ('' as unknown) as number, + investment_action_category: ('' as unknown) as number, agency_project_id: '', - funding_amount: 0, + funding_amount: ('' as unknown) as number, start_date: '', end_date: '' }; export const ProjectFundingFormArrayItemYupSchema = yup.object().shape({ - agency_id: yup.number().min(1).required('Required'), - investment_action_category: yup.number(), + agency_id: yup + .number() + .transform((value) => (isNaN(value) && null) || value) + .required('Required'), + investment_action_category: yup.number().required('Required'), agency_project_id: yup.string(), - funding_amount: yup.number().required('Required'), + funding_amount: yup + .number() + .transform((value) => (isNaN(value) && null) || value) + .typeError('Must be a number') + .min(0, 'Must be a positive number') + .required('Required'), start_date: yup.date().required('Required'), end_date: yup .date() @@ -99,147 +109,155 @@ const ProjectFundingItemForm: React.FC = (props) = Add Funding Source Enter Funding Source Details - - - - Agency Name - { + handleChange(event); + // investment_action_category is dependent on agency_id, so reset it if agency_id changes setFieldValue( 'investment_action_category', - props.investment_action_category.find((item) => item.fs_id === event.target.value) - ?.value || 0 + ProjectFundingFormArrayItemInitialValues.investment_action_category ); - } - }} - error={touched.agency_id && Boolean(errors.agency_id)} - displayEmpty - inputProps={{ 'aria-label': 'Agency Name' }} - input={}> - {props.funding_sources.map((item) => ( - - {item.label} - - ))} - - {errors.agency_id} - - - {investment_action_category_label && ( - - - - {investment_action_category_label} - - - {errors.investment_action_category} + {errors.agency_id} - )} - - - - - $ }} - InputLabelProps={{ - shrink: true - }} - /> - - - + {investment_action_category_label && ( + + + + {investment_action_category_label} + + + {errors.investment_action_category} + + + )} + - + + Funding Details + + $ }} InputLabelProps={{ shrink: true }} /> + + + + + + + + - +
-
-
-
-
- -
- - +
-
-
-
- - +  * + + +
+ + +
-
-
-
-
-
- - Share Contact Details - - -

- Do you want the project coordinator contact information visible to the public? -

+
+
+
+ + Share Contact Details + +

+ Do you want the project coordinator contact information visible to the public? +

+
+
+ + -

+ + + +

+ + +
+ + + + + Yes + + +

+

-
- + +
-
+ `; exports[`Project Coordinator Form renders correctly the filled component correctly 1`] = ` -
+
-
- - -
-
-
-
-
- -
- - +
-
-
-
- - +  * + + +
+ + +
-
-
-
-
-
- - Share Contact Details - - -

- Do you want the project coordinator contact information visible to the public? -

+
+
+
+ + Share Contact Details + +

+ Do you want the project coordinator contact information visible to the public? +

+
+
+ + -

+ + + +

+ + +
+ + + + + Yes + + +

+

-
- + +
-
+ `; diff --git a/app/src/features/projects/components/__snapshots__/ProjectFundingForm.test.tsx.snap b/app/src/features/projects/components/__snapshots__/ProjectFundingForm.test.tsx.snap index ad7a57eae9..db7f5c7ffd 100644 --- a/app/src/features/projects/components/__snapshots__/ProjectFundingForm.test.tsx.snap +++ b/app/src/features/projects/components/__snapshots__/ProjectFundingForm.test.tsx.snap @@ -298,150 +298,142 @@ exports[`ProjectFundingForm renders correctly with existing funding values 1`] = class="MuiBox-root MuiBox-root-10" >
-

- agency 1 -

-
-
-
+
- - - - - - + + + + + +
-
-
-
Funding Amount -
+

+

+ 222 +

-
Start Date / End Date -
+

+

+ March 14, 2021 - April 14, 2021 +

-
Agency Project ID -
+

+

+ 111 +

-
Investment Action/Category -
-
-
-
-
-
-
- 222 -
-
- 2021-03-14 / 2021-04-14 -
-
- 111 -
-
- 1 +

+

+ 1 +

@@ -584,10 +576,10 @@ exports[`ProjectFundingForm renders correctly with existing funding values 1`] =
-
@@ -283,18 +185,27 @@ exports[`ProjectFundingItemForm renders correctly with default empty values 1`]
+

+ Funding Details +

+
+
+
+

+ $ +

+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
@@ -442,286 +464,196 @@ exports[`ProjectFundingItemForm renders correctly with existing funding item val Enter Funding Source Details

- -
-
- agency 1 -
- - - -
-

+ Agency Details +

-
-
-
-
- action 1 -
- -
- + agency 1 +
+ - - + + + +
+

-

-
-
-
- - -
-
-
-
-
- -
+   + * + +
-

- $ -

+ action 1 +
+ + +
- -
-
-
@@ -729,18 +661,27 @@ exports[`ProjectFundingItemForm renders correctly with existing funding item val
+

+ Funding Details +

+
+
+
+

+ $ +

+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
@@ -873,271 +925,119 @@ exports[`ProjectFundingItemForm renders correctly with existing funding item val id="alert-dialog-title" >

- Add Funding Source -

- -
-

- Enter Funding Source Details -

-
-
-
- -
-
- agency 2 -
- - - -
-

-

-
+ class="MuiTypography-root MuiTypography-h6" + > + Add Funding Source + +
+
+

+ Enter Funding Source Details +

+
- -
-
- category 1 -
- - - -
-

+ Agency Details +

-
-
-
- - -
-
-
-
-
- -
+   + * + +
-

+ agency 2 +

+ + +
- -
-
-
+
+
+
+ +
@@ -1175,18 +1137,27 @@ exports[`ProjectFundingItemForm renders correctly with existing funding item val
+

+ Funding Details +

+
+
+
+

+ $ +

+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
@@ -1334,223 +1416,125 @@ exports[`ProjectFundingItemForm renders correctly with existing funding item val Enter Funding Source Details

- -
-
- agency 3 -
- - - -
-

+ Agency Details +

-
-
-
- - -
-
-
-
-
- -
+   + * + +
-

+ agency 3 +

+ + +
- -
-
-
@@ -1558,18 +1542,27 @@ exports[`ProjectFundingItemForm renders correctly with existing funding item val
+

+ Funding Details +

+
+
+
+

+ $ +

+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
diff --git a/app/src/features/projects/components/__snapshots__/ProjectPermitForm.test.tsx.snap b/app/src/features/projects/components/__snapshots__/ProjectPermitForm.test.tsx.snap index f2be24d4dd..fe7c6e4a74 100644 --- a/app/src/features/projects/components/__snapshots__/ProjectPermitForm.test.tsx.snap +++ b/app/src/features/projects/components/__snapshots__/ProjectPermitForm.test.tsx.snap @@ -70,18 +70,24 @@ exports[`ProjectPermitForm renders correctly with default empty values 1`] = ` style="width: 100%;" >

@@ -250,18 +258,24 @@ exports[`ProjectPermitForm renders correctly with existing permit values 1`] = ` style="width: 100%;" >

@@ -396,18 +412,24 @@ exports[`ProjectPermitForm renders correctly with existing permit values 1`] = ` style="width: 100%;" >

diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index a28d39f354..abebe48e67 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -5,9 +5,11 @@ import { IProject } from 'interfaces/project-interfaces'; import { IActivity, ICreateActivity, + ICreatePartialProjectResponse, ICreateProjectResponse, IGetAllCodesResponse, IMedia, + IPartialProjectPostObject, IProjectPostObject, ITemplate } from 'interfaces/useBioHubApi-interfaces'; @@ -79,6 +81,18 @@ export const useBiohubApi = () => { return data; }; + /** + * Create a new partial project. + * + * @param {IpartialProjectPostObject} project + * @return {*} {Promise} + */ + const createPartialProject = async (project: IPartialProjectPostObject): Promise => { + const { data } = await api.post('/api/project/partial', project); + + return data; + }; + /** * Upload project artifacts. * @@ -169,11 +183,13 @@ export const useBiohubApi = () => { return mediaKeyList; }; + return { getProjects, getProject, getTemplate, createProject, + createPartialProject, createActivity, getAllCodes, getApiSpec, diff --git a/app/src/interfaces/useBioHubApi-interfaces.ts b/app/src/interfaces/useBioHubApi-interfaces.ts index 812d495f0a..3c91b747ae 100644 --- a/app/src/interfaces/useBioHubApi-interfaces.ts +++ b/app/src/interfaces/useBioHubApi-interfaces.ts @@ -87,6 +87,17 @@ export interface IProjectPostObject { funding: IProjectFundingForm; } +/** + * Create partial-project post body. + * + * @export + * @interface IPartialProjectPostObject + */ +export interface IPartialProjectPostObject { + coordinator: IProjectCoordinatorForm; + permit: IProjectPermitForm; +} + /** * Create project response object. * @@ -97,6 +108,16 @@ export interface ICreateProjectResponse { id: number; } +/** + * Create partial-project response object. + * + * @export + * @interface ICreatePartialProjectResponse + */ +export interface ICreatePartialProjectResponse { + id: number; +} + /** * Media object. * diff --git a/app/src/utils/MapEditControls.tsx b/app/src/utils/MapEditControls.tsx index 5302b70981..324e1b8d6b 100644 --- a/app/src/utils/MapEditControls.tsx +++ b/app/src/utils/MapEditControls.tsx @@ -132,6 +132,7 @@ const MapEditControls: React.FC = (props) => { map.on(eventHandlers.onDeleted, onDrawDelete); onMounted && onMounted(drawRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -161,6 +162,7 @@ const MapEditControls: React.FC = (props) => { } }); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.geometry]); useEffect(() => { @@ -181,6 +183,7 @@ const MapEditControls: React.FC = (props) => { const { onMounted } = props; onMounted && onMounted(drawRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.draw, props.edit, props.position]); return null;