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!
-
-
- previous
+
+ Create Project
-
- Create Project
+
+ Cancel
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Previous
+
+
+
+
+
+
+
+ Next
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Next
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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}
-
-
-
+
+
);
};
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
-
+ {errors.investment_action_category}
+
+
+ )}
+
-
+
+ Funding Details
+
+
$ }}
InputLabelProps={{
shrink: true
}}
/>
+
+
+
+
+
+
+
+
-
+
{
const existingFormValues: IProjectPermitForm = {
permits: [
{
- permit_number: '123',
+ permit_number: 123,
sampling_conducted: 'true'
},
{
- permit_number: '3213123123',
+ permit_number: 3213123123,
sampling_conducted: 'false'
}
]
diff --git a/app/src/features/projects/components/ProjectPermitForm.tsx b/app/src/features/projects/components/ProjectPermitForm.tsx
index 9bb08598de..0e129121a6 100644
--- a/app/src/features/projects/components/ProjectPermitForm.tsx
+++ b/app/src/features/projects/components/ProjectPermitForm.tsx
@@ -10,14 +10,14 @@ import {
Select,
TextField
} from '@material-ui/core';
-import Icon from '@mdi/react';
import { mdiTrashCanOutline } from '@mdi/js';
+import Icon from '@mdi/react';
import { FieldArray, useFormikContext } from 'formik';
-import React from 'react';
+import React, { useEffect } from 'react';
import * as yup from 'yup';
export interface IProjectPermitFormArrayItem {
- permit_number: string;
+ permit_number: number;
sampling_conducted: string;
}
@@ -26,7 +26,7 @@ export interface IProjectPermitForm {
}
export const ProjectPermitFormArrayItemInitialValues: IProjectPermitFormArrayItem = {
- permit_number: '',
+ permit_number: ('' as unknown) as number,
sampling_conducted: 'false'
};
@@ -37,20 +37,36 @@ export const ProjectPermitFormInitialValues: IProjectPermitForm = {
export const ProjectPermitFormYupSchema = yup.object().shape({
permits: yup.array().of(
yup.object().shape({
- permit_number: yup.string().required('Required'),
+ permit_number: yup
+ .number()
+ .transform((value) => (isNaN(value) && null) || value)
+ .typeError('Must be a number')
+ .min(0, 'Must be a positive number')
+ .required('Required'),
sampling_conducted: yup.string().required('Required')
})
)
});
+export interface IProjectPermitFormProps {
+ /**
+ * Emits every time a form value changes.
+ */
+ onValuesChange?: (values: IProjectPermitForm) => void;
+}
+
/**
* Create project - Permit section
*
* @return {*}
*/
-const ProjectPermitForm: React.FC = () => {
+const ProjectPermitForm: React.FC = (props) => {
const { values, handleChange, handleSubmit, getFieldMeta } = useFormikContext();
+ useEffect(() => {
+ props?.onValuesChange?.(values);
+ }, [values, props]);
+
return (
-
+
`;
exports[`Project Coordinator Form renders correctly the filled component correctly 1`] = `
-
+
-
+
`;
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
-
-
-
-
+ 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
-
-
@@ -584,10 +576,10 @@ exports[`ProjectFundingForm renders correctly with existing funding values 1`] =
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+ Funding Details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -442,286 +464,196 @@ exports[`ProjectFundingItemForm renders correctly with existing funding item val
Enter Funding Source Details
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ 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
-
-
-
+ class="MuiTypography-root MuiTypography-h6"
+ >
+ Add Funding Source
+
+
+
+
+ Enter Funding Source Details
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
@@ -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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ 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%;"
>
@@ -121,7 +129,7 @@ exports[`ProjectPermitForm renders correctly with default empty values 1`] = `
@@ -250,18 +258,24 @@ exports[`ProjectPermitForm renders correctly with existing permit values 1`] = `
style="width: 100%;"
>
@@ -301,7 +317,7 @@ exports[`ProjectPermitForm renders correctly with existing permit values 1`] = `
@@ -396,18 +412,24 @@ exports[`ProjectPermitForm renders correctly with existing permit values 1`] = `
style="width: 100%;"
>
@@ -447,7 +471,7 @@ exports[`ProjectPermitForm renders correctly with existing permit values 1`] = `
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;