diff --git a/api/src/openapi/schemas/draft.ts b/api/src/openapi/schemas/draft.ts index f49e1ad04f..2dc2e848c6 100644 --- a/api/src/openapi/schemas/draft.ts +++ b/api/src/openapi/schemas/draft.ts @@ -15,3 +15,25 @@ export const draftResponseObject = { } } }; + +/** + * Response object for getting a draft + */ +export const draftGetResponseObject = { + title: 'Draft Get Response Object', + type: 'object', + required: ['id', 'name', 'data'], + properties: { + id: { + type: 'number' + }, + name: { + type: 'string', + description: 'The name of the draft' + }, + data: { + type: 'string', + description: 'The data associated with this draft' + } + } +}; diff --git a/api/src/paths/draft/{draftId}/delete.ts b/api/src/paths/draft/{draftId}/delete.ts new file mode 100644 index 0000000000..8d5f6bb8f8 --- /dev/null +++ b/api/src/paths/draft/{draftId}/delete.ts @@ -0,0 +1,85 @@ +'use strict'; + +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getDBConnection } from '../../../database/db'; +import { HTTP400 } from '../../../errors/CustomError'; +import { getLogger } from '../../../utils/logger'; +import { WRITE_ROLES } from '../../../constants/roles'; +import { deleteDraftSQL } from '../../../queries/draft-queries'; + +const defaultLog = getLogger('/api/draft/{draftId}/delete'); + +export const DELETE: Operation = [deleteDraft()]; + +DELETE.apiDoc = { + description: 'Delete a draft record.', + tags: ['attachment'], + security: [ + { + Bearer: WRITE_ROLES + } + ], + parameters: [ + { + in: 'path', + name: 'draftId', + schema: { + type: 'number' + }, + required: true + } + ], + responses: { + 200: { + description: 'Row count of successfully deleted draft record', + content: { + 'text/plain': { + schema: { + type: 'number' + } + } + } + }, + 401: { + $ref: '#/components/responses/401' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +function deleteDraft(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'Delete draft', message: 'params', req_params: req.params }); + + if (!req.params.draftId) { + throw new HTTP400('Missing required path param `draftId`'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const deleteDraftSQLStatement = deleteDraftSQL(Number(req.params.draftId)); + + if (!deleteDraftSQLStatement) { + throw new HTTP400('Failed to build SQL delete statement'); + } + + const result = await connection.query(deleteDraftSQLStatement.text, deleteDraftSQLStatement.values); + + await connection.commit(); + + return res.status(200).json(result && result.rowCount); + } catch (error) { + defaultLog.debug({ label: 'deleteDraft', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/draft/{draftId}/get.ts b/api/src/paths/draft/{draftId}/get.ts new file mode 100644 index 0000000000..735e7ad370 --- /dev/null +++ b/api/src/paths/draft/{draftId}/get.ts @@ -0,0 +1,96 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { READ_ROLES } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { HTTP400 } from '../../../errors/CustomError'; +import { draftGetResponseObject } from '../../../openapi/schemas/draft'; +import { getDraftSQL } from '../../../queries/draft-queries'; +import { getLogger } from '../../../utils/logger'; +import { logRequest } from '../../../utils/path-utils'; + +const defaultLog = getLogger('paths/draft/{draftId}'); + +export const GET: Operation = [logRequest('paths/draft/{draftId}', 'GET'), getSingleDraft()]; + +GET.apiDoc = { + description: 'Get a draft.', + tags: ['draft'], + security: [ + { + Bearer: READ_ROLES + } + ], + parameters: [ + { + in: 'path', + name: 'draftId', + schema: { + type: 'number' + }, + required: true + } + ], + responses: { + 200: { + description: 'Draft with matching draftId.', + content: { + 'application/json': { + schema: { + ...(draftGetResponseObject as object) + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/401' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get a draft by its id. + * + * @returns {RequestHandler} + */ +function getSingleDraft(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + const getDraftSQLStatement = getDraftSQL(Number(req.params.draftId)); + + if (!getDraftSQLStatement) { + throw new HTTP400('Failed to build SQL get statement'); + } + + await connection.open(); + + const draftResponse = await connection.query(getDraftSQLStatement.text, getDraftSQLStatement.values); + + await connection.commit(); + + const draftResult = (draftResponse && draftResponse.rows && draftResponse.rows[0]) || null; + + defaultLog.debug('draftResult:', draftResult); + + return res.status(200).json(draftResult); + } catch (error) { + defaultLog.debug({ label: 'getSingleDraft', message: 'error', error }); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/attachments/list.ts b/api/src/paths/project/{projectId}/attachments/list.ts index fca27a13c1..047d99451d 100644 --- a/api/src/paths/project/{projectId}/attachments/list.ts +++ b/api/src/paths/project/{projectId}/attachments/list.ts @@ -9,7 +9,7 @@ import { GetAttachmentsData } from '../../../../models/project-attachments'; import { getProjectAttachmentsSQL } from '../../../../queries/project/project-attachments-queries'; import { getLogger } from '../../../../utils/logger'; -const defaultLog = getLogger('/api/projects/{projectId}/artifacts/attachments/view'); +const defaultLog = getLogger('/api/project/{projectId}/attachments/list'); export const GET: Operation = [getAttachments()]; diff --git a/api/src/paths/project/{projectId}/attachments/upload.ts b/api/src/paths/project/{projectId}/attachments/upload.ts index b8d2132cdb..984a56b911 100644 --- a/api/src/paths/project/{projectId}/attachments/upload.ts +++ b/api/src/paths/project/{projectId}/attachments/upload.ts @@ -10,11 +10,11 @@ import { uploadFileToS3 } from '../../../../utils/file-utils'; import { getLogger } from '../../../../utils/logger'; import { upsertProjectAttachment } from '../../../project'; -const defaultLog = getLogger('/api/projects/{projectId}/artifacts/upload'); +const defaultLog = getLogger('/api/project/{projectId}/attachments/upload'); export const POST: Operation = [uploadMedia()]; POST.apiDoc = { - description: 'Upload project-specific artifacts.', + description: 'Upload project-specific attachments.', tags: ['artifacts'], security: [ { @@ -37,7 +37,7 @@ POST.apiDoc = { properties: { media: { type: 'array', - description: 'An array of artifacts to upload', + description: 'An array of attachments to upload', items: { type: 'string', format: 'binary' @@ -50,7 +50,7 @@ POST.apiDoc = { }, responses: { 200: { - description: 'Artifacts upload response.', + description: 'Attachments upload response.', content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts index 59c6004e65..8ebd5f8606 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts @@ -7,13 +7,13 @@ import { HTTP400 } from '../../../../../errors/CustomError'; import { deleteProjectAttachmentSQL } from '../../../../../queries/project/project-attachments-queries'; import { deleteFileFromS3 } from '../../../../../utils/file-utils'; import { getLogger } from '../../../../../utils/logger'; -import { getAttachmentApiDocObject } from '../../../../../utils/shared-api-docs'; +import { attachmentApiDocObject } from '../../../../../utils/shared-api-docs'; -const defaultLog = getLogger('/api/projects/{projectId}/artifacts/attachments/{attachmentId}/delete'); +const defaultLog = getLogger('/api/project/{projectId}/attachments/{attachmentId}/delete'); export const DELETE: Operation = [deleteAttachment()]; -DELETE.apiDoc = getAttachmentApiDocObject( +DELETE.apiDoc = attachmentApiDocObject( 'Delete an attachment of a project.', 'Row count of successfully deleted attachment record' ); diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/getSignedUrl.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/getSignedUrl.ts index 537c7d576f..051793f859 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/getSignedUrl.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/getSignedUrl.ts @@ -7,13 +7,13 @@ import { getLogger } from '../../../../../utils/logger'; import { getDBConnection } from '../../../../../database/db'; import { getProjectAttachmentS3KeySQL } from '../../../../../queries/project/project-attachments-queries'; import { getS3SignedURL } from '../../../../../utils/file-utils'; -import { getAttachmentApiDocObject } from '../../../../../utils/shared-api-docs'; +import { attachmentApiDocObject } from '../../../../../utils/shared-api-docs'; -const defaultLog = getLogger('/api/projects/{projectId}/artifacts/attachments/{attachmentId}/view'); +const defaultLog = getLogger('/api/project/{projectId}/attachments/{attachmentId}/getSignedUrl'); export const GET: Operation = [getSingleAttachmentURL()]; -GET.apiDoc = getAttachmentApiDocObject( +GET.apiDoc = attachmentApiDocObject( 'Retrieves the signed url of an attachment in a project by its file name.', 'GET response containing the signed url of an attachment.' ); diff --git a/api/src/queries/draft-queries.test.ts b/api/src/queries/draft-queries.test.ts index bb98c38290..a9aac98daf 100644 --- a/api/src/queries/draft-queries.test.ts +++ b/api/src/queries/draft-queries.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { describe } from 'mocha'; -import { getDraftsSQL, postDraftSQL, putDraftSQL } from './draft-queries'; +import { deleteDraftSQL, getDraftSQL, getDraftsSQL, postDraftSQL, putDraftSQL } from './draft-queries'; describe('postDraftSQL', () => { it('Null systemUserId', () => { @@ -71,3 +71,27 @@ describe('getDraftsSQL', () => { expect(response).to.not.be.null; }); }); + +describe('getDraftSQL', () => { + it('Null draftId', () => { + const response = getDraftSQL((null as unknown) as number); + expect(response).to.be.null; + }); + + it('Valid parameters', () => { + const response = getDraftSQL(1); + expect(response).to.not.be.null; + }); +}); + +describe('deleteDraftSQL', () => { + it('Null draftId', () => { + const response = deleteDraftSQL((null as unknown) as number); + expect(response).to.be.null; + }); + + it('Valid parameters', () => { + const response = deleteDraftSQL(1); + expect(response).to.not.be.null; + }); +}); diff --git a/api/src/queries/draft-queries.ts b/api/src/queries/draft-queries.ts index 9e45d94b77..4f91c537b5 100644 --- a/api/src/queries/draft-queries.ts +++ b/api/src/queries/draft-queries.ts @@ -115,3 +115,65 @@ export const getDraftsSQL = (systemUserId: number): SQLStatement | null => { return sqlStatement; }; + +/** + * SQL query to get a single draft from the webform_draft table. + * + * @param {number} draftId + * @return {SQLStatement} {(SQLStatement | null)} + */ +export const getDraftSQL = (draftId: number): SQLStatement | null => { + defaultLog.debug({ label: 'getDraftSQL', message: 'params', draftId }); + + if (!draftId) { + return null; + } + + const sqlStatement: SQLStatement = SQL` + SELECT + id, + name, + data + FROM + webform_draft + WHERE + id = ${draftId}; + `; + + defaultLog.debug({ + label: 'getDraftSQL', + message: 'sql', + 'sqlStatement.text': sqlStatement.text, + 'sqlStatement.values': sqlStatement.values + }); + + return sqlStatement; +}; + +/** + * SQL query to delete a single draft from the webform_draft table. + * + * @param {number} draftId + * @return {SQLStatement} {(SQLStatement) | null} + */ +export const deleteDraftSQL = (draftId: number): SQLStatement | null => { + defaultLog.debug({ label: 'deleteDraftSQL', message: 'params', draftId }); + + if (!draftId) { + return null; + } + + const sqlStatement: SQLStatement = SQL` + DELETE from webform_draft + WHERE id = ${draftId}; + `; + + defaultLog.debug({ + label: 'deleteDraftSQL', + message: 'sql', + 'sqlStatement.text': sqlStatement.text, + 'sqlStatement.values': sqlStatement.values + }); + + return sqlStatement; +}; diff --git a/api/src/utils/shared-api-docs.test.ts b/api/src/utils/shared-api-docs.test.ts index 4467a72921..b838b41116 100644 --- a/api/src/utils/shared-api-docs.test.ts +++ b/api/src/utils/shared-api-docs.test.ts @@ -1,14 +1,14 @@ import { expect } from 'chai'; import { describe } from 'mocha'; import { - getAttachmentApiDocObject, + attachmentApiDocObject, deleteFundingSourceApiDocObject, addFundingSourceApiDocObject } from './shared-api-docs'; -describe('getAttachmentApiResponseObject', () => { +describe('attachmentApiResponseObject', () => { it('returns a valid response object', () => { - const result = getAttachmentApiDocObject('basic', 'success'); + const result = attachmentApiDocObject('basic', 'success'); expect(result).to.not.be.null; expect(result?.description).to.equal('basic'); diff --git a/api/src/utils/shared-api-docs.ts b/api/src/utils/shared-api-docs.ts index c02329d19b..d160d2b9db 100644 --- a/api/src/utils/shared-api-docs.ts +++ b/api/src/utils/shared-api-docs.ts @@ -1,7 +1,7 @@ import { WRITE_ROLES } from '../constants/roles'; import { projectFundingSourcePostRequestObject } from '../openapi/schemas/project-funding-source'; -export const getAttachmentApiDocObject = (basicDescription: string, successDescription: string) => { +export const attachmentApiDocObject = (basicDescription: string, successDescription: string) => { return { description: basicDescription, tags: ['attachment'], diff --git a/app/src/features/projects/CreateProjectPage.test.tsx b/app/src/features/projects/CreateProjectPage.test.tsx index 9c0b2150de..a44ec3f086 100644 --- a/app/src/features/projects/CreateProjectPage.test.tsx +++ b/app/src/features/projects/CreateProjectPage.test.tsx @@ -4,12 +4,21 @@ import { getByText as rawGetByText, fireEvent, render, - waitFor + waitFor, + screen } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { useBiohubApi } from 'hooks/useBioHubApi'; import React from 'react'; -import { Router } from 'react-router'; +import { MemoryRouter, Router } from 'react-router'; +import { ProjectDetailsFormInitialValues } from './components/ProjectDetailsForm'; +import { ProjectFundingFormInitialValues } from './components/ProjectFundingForm'; +import { ProjectIUCNFormInitialValues } from './components/ProjectIUCNForm'; +import { ProjectLocationFormInitialValues } from './components/ProjectLocationForm'; +import { ProjectObjectivesFormInitialValues } from './components/ProjectObjectivesForm'; +import { ProjectPartnershipsFormInitialValues } from './components/ProjectPartnershipsForm'; +import { ProjectPermitFormInitialValues } from './components/ProjectPermitForm'; +import { ProjectSpeciesFormInitialValues } from './components/ProjectSpeciesForm'; import CreateProjectPage from './CreateProjectPage'; const history = createMemoryHistory(); @@ -21,7 +30,8 @@ const mockUseBiohubApi = { }, draft: { createDraft: jest.fn, []>(), - updateDraft: jest.fn, []>() + updateDraft: jest.fn, []>(), + getDraft: jest.fn() } }; @@ -43,6 +53,7 @@ describe('CreateProjectPage', () => { mockBiohubApi().codes.getAllCodeSets.mockClear(); mockBiohubApi().draft.createDraft.mockClear(); mockBiohubApi().draft.updateDraft.mockClear(); + mockBiohubApi().draft.getDraft.mockClear(); }); afterEach(() => { @@ -235,6 +246,42 @@ describe('CreateProjectPage', () => { }); }); + it('preloads draft data and populates on form fields', async () => { + mockBiohubApi().draft.getDraft.mockResolvedValue({ + id: 1, + name: 'My draft', + data: { + coordinator: { + first_name: 'Draft first name', + last_name: 'Draft last name', + email_address: 'draftemail@example.com', + coordinator_agency: '', + share_contact_details: 'false' + }, + permit: ProjectPermitFormInitialValues, + project: ProjectDetailsFormInitialValues, + objectives: ProjectObjectivesFormInitialValues, + species: ProjectSpeciesFormInitialValues, + location: ProjectLocationFormInitialValues, + iucn: ProjectIUCNFormInitialValues, + funding: ProjectFundingFormInitialValues, + partnerships: ProjectPartnershipsFormInitialValues + } + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByDisplayValue('Draft first name')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Draft last name')).toBeInTheDocument(); + expect(screen.getByDisplayValue('draftemail@example.com')).toBeInTheDocument(); + }); + }); + it('opens the save as draft dialog', async () => { const { getByText, findByText } = renderContainer(); diff --git a/app/src/features/projects/CreateProjectPage.tsx b/app/src/features/projects/CreateProjectPage.tsx index 5e3fdd51c0..9b9314a4d5 100644 --- a/app/src/features/projects/CreateProjectPage.tsx +++ b/app/src/features/projects/CreateProjectPage.tsx @@ -57,6 +57,7 @@ import { Formik, FormikProps } from 'formik'; import * as History from 'history'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useQuery } from 'hooks/useQuery'; import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { ICreatePermitNoSamplingRequest, ICreateProjectRequest } from 'interfaces/useProjectApi.interface'; import React, { useEffect, useRef, useState } from 'react'; @@ -120,9 +121,12 @@ const CreateProjectPage: React.FC = () => { const biohubApi = useBiohubApi(); + const queryParams = useQuery(); + const [codes, setCodes] = useState(); const [isLoadingCodes, setIsLoadingCodes] = useState(false); + const [hasLoadedDraftData, setHasLoadedDraftData] = useState(!queryParams.draftId); const [enableCancelCheck, setEnableCancelCheck] = useState(true); @@ -159,6 +163,38 @@ const CreateProjectPage: React.FC = () => { const [openDraftDialog, setOpenDraftDialog] = useState(false); const [draft, setDraft] = useState({ id: 0, date: '' }); + const [initialProjectFieldData, setInitialProjectFieldData] = useState({ + coordinator: ProjectCoordinatorInitialValues, + permit: ProjectPermitFormInitialValues, + project: ProjectDetailsFormInitialValues, + objectives: ProjectObjectivesFormInitialValues, + species: ProjectSpeciesFormInitialValues, + location: ProjectLocationFormInitialValues, + iucn: ProjectIUCNFormInitialValues, + funding: ProjectFundingFormInitialValues, + partnerships: ProjectPartnershipsFormInitialValues + }); + + // Get draft project fields if draft id exists + useEffect(() => { + const getDraftProjectFields = async () => { + const response = await biohubApi.draft.getDraft(queryParams.draftId); + + setHasLoadedDraftData(true); + + if (!response || !response.data) { + return; + } + + setInitialProjectFieldData(response.data); + }; + + if (hasLoadedDraftData) { + return; + } + + getDraftProjectFields(); + }, [useBiohubApi]); // 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? @@ -166,9 +202,7 @@ const CreateProjectPage: React.FC = () => { const getAllCodeSets = async () => { const response = await biohubApi.codes.getAllCodeSets(); - if (!response) { - // TODO error handling/user messaging - Cant create a project if required code sets fail to fetch - } + // TODO error handling/user messaging - Cant create a project if required code sets fail to fetch setCodes(() => { setIsLoadingCodes(false); @@ -184,7 +218,7 @@ const CreateProjectPage: React.FC = () => { // Initialize the forms for each step of the workflow useEffect(() => { - if (!codes) { + if (!codes || !hasLoadedDraftData) { return; } @@ -197,7 +231,7 @@ const CreateProjectPage: React.FC = () => { stepTitle: 'Project Coordinator', stepSubTitle: 'Enter contact details for the project coordinator', stepContent: , - stepValues: ProjectCoordinatorInitialValues, + stepValues: initialProjectFieldData.coordinator, stepValidation: ProjectCoordinatorYupSchema }, { @@ -214,60 +248,60 @@ const CreateProjectPage: React.FC = () => { }} /> ), - stepValues: ProjectPermitFormInitialValues, + stepValues: initialProjectFieldData.permit, stepValidation: ProjectPermitFormYupSchema }, { stepTitle: 'General Information', stepSubTitle: 'General information and details about this project', stepContent: , - stepValues: ProjectDetailsFormInitialValues, + stepValues: initialProjectFieldData.project, stepValidation: ProjectDetailsFormYupSchema }, { stepTitle: 'Objectives', stepSubTitle: 'Enter the objectives and potential caveats for this project', stepContent: , - stepValues: ProjectObjectivesFormInitialValues, + stepValues: initialProjectFieldData.objectives, stepValidation: ProjectObjectivesFormYupSchema }, { stepTitle: 'Location', stepSubTitle: 'Specify project regions and boundary information', stepContent: , - stepValues: ProjectLocationFormInitialValues, + stepValues: initialProjectFieldData.location, stepValidation: ProjectLocationFormYupSchema }, { stepTitle: 'Species', stepSubTitle: 'Information about species this project is inventorying or monitoring', stepContent: , - stepValues: ProjectSpeciesFormInitialValues, + stepValues: initialProjectFieldData.species, stepValidation: ProjectSpeciesFormYupSchema }, { stepTitle: 'IUCN Classification', stepSubTitle: 'Lorem ipsum dolor sit amet, consectur whatever whatever', stepContent: , - stepValues: ProjectIUCNFormInitialValues, + stepValues: initialProjectFieldData.iucn, stepValidation: ProjectIUCNFormYupSchema }, { stepTitle: 'Funding', stepSubTitle: 'Specify funding sources for the project', stepContent: , - stepValues: ProjectFundingFormInitialValues, + stepValues: initialProjectFieldData.funding, stepValidation: ProjectFundingFormYupSchema }, { stepTitle: 'Partnerships', stepSubTitle: 'Specify partnerships for the project', stepContent: , - stepValues: ProjectPartnershipsFormInitialValues, + stepValues: initialProjectFieldData.partnerships, stepValidation: ProjectPartnershipsFormYupSchema } ]); - }, [codes, stepForms]); + }, [codes, stepForms, initialProjectFieldData]); /** * Return true if the user has indicated that sampling has been conducted. @@ -344,8 +378,10 @@ const CreateProjectPage: React.FC = () => { partnerships: (activeStep === 8 && formikRef?.current?.values) || stepForms[8].stepValues }; - if (draft?.id) { - response = await biohubApi.draft.updateDraft(draft.id, values.draft_name, draftFormData); + const draftId = Number(queryParams.draftId) || draft?.id; + + if (draftId) { + response = await biohubApi.draft.updateDraft(draftId, values.draft_name, draftFormData); } else { response = await biohubApi.draft.createDraft(values.draft_name, draftFormData); } @@ -376,14 +412,30 @@ const CreateProjectPage: React.FC = () => { * Handle creation of partial or full projects. */ const handleSubmit = async () => { + const isFullProject = isSamplingConducted(stepForms[1].stepValues); + const draftId = Number(queryParams.draftId); + try { - if (!isSamplingConducted(stepForms[1].stepValues)) { - await createPermitNoSampling({ + if (!isFullProject) { + const response = await createPermitNoSampling({ coordinator: stepForms[0].stepValues, permit: stepForms[1].stepValues }); + + if (!response) { + return; + } + + // when project has been created, if a draft is still associated to the project, delete it + if (draftId) { + await deleteDraft(draftId); + } + + setEnableCancelCheck(false); + + history.push(`/projects`); } else { - await createProject({ + const response = await createProject({ coordinator: stepForms[0].stepValues, permit: stepForms[1].stepValues, project: stepForms[2].stepValues, @@ -394,10 +446,42 @@ const CreateProjectPage: React.FC = () => { funding: stepForms[7].stepValues, partnerships: stepForms[8].stepValues }); + + if (!response) { + return; + } + + // when project has been created, if a draft is still associated to the project, delete it + if (draftId) { + await deleteDraft(draftId); + } + + setEnableCancelCheck(false); + + history.push(`/projects/${response.id}`); } } catch (error) { const apiError = error as APIError; - showCreateErrorDialog({ dialogError: apiError?.message, dialogErrorDetails: apiError?.errors }); + showCreateErrorDialog({ + dialogTitle: 'Error Creating Project', + dialogError: apiError?.message, + dialogErrorDetails: apiError?.errors + }); + } + }; + + /** + * Deletes a draft record + * (called when project is successfully created for record which was once a draft) + * + * @param {number} draftId + * @returns {*} + */ + const deleteDraft = async (draftId: number) => { + try { + await biohubApi.draft.deleteDraft(draftId); + } catch (error) { + return error; } }; @@ -415,9 +499,7 @@ const CreateProjectPage: React.FC = () => { return; } - setEnableCancelCheck(false); - - history.push(`/projects`); + return response; }; /** @@ -434,9 +516,7 @@ const CreateProjectPage: React.FC = () => { return; } - setEnableCancelCheck(false); - - history.push(`/projects/${response.id}`); + return response; }; const showDraftErrorDialog = (textDialogProps?: Partial) => { diff --git a/app/src/features/projects/list/ProjectsListPage.test.tsx b/app/src/features/projects/list/ProjectsListPage.test.tsx index 603542570a..21ebc5fd54 100644 --- a/app/src/features/projects/list/ProjectsListPage.test.tsx +++ b/app/src/features/projects/list/ProjectsListPage.test.tsx @@ -110,6 +110,35 @@ describe('ProjectsListPage', () => { await waitFor(() => { expect(history.location.pathname).toEqual('/projects/create'); + expect(history.location.search).toEqual(''); + }); + }); + }); + + test('navigating to the create project page works on draft projects', async () => { + await act(async () => { + mockBiohubApi().draft.getDraftsList.mockResolvedValue([ + { + id: 1, + name: 'Draft 1' + } + ]); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId('project-table')).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId('Draft 1')); + + await waitFor(() => { + expect(history.location.pathname).toEqual('/projects/create'); + expect(history.location.search).toEqual('?draftId=1'); }); }); }); diff --git a/app/src/features/projects/list/ProjectsListPage.tsx b/app/src/features/projects/list/ProjectsListPage.tsx index c5164ad4b3..4efd24bfb3 100644 --- a/app/src/features/projects/list/ProjectsListPage.tsx +++ b/app/src/features/projects/list/ProjectsListPage.tsx @@ -32,7 +32,12 @@ const ProjectsListPage: React.FC = () => { const [isLoading, setIsLoading] = useState(true); - const navigateToCreateProjectPage = () => { + const navigateToCreateProjectPage = (draftId?: number) => { + if (draftId) { + history.push(`/projects/create?draftId=${draftId}`); + return; + } + history.push('/projects/create'); }; @@ -65,85 +70,80 @@ const ProjectsListPage: React.FC = () => { } }, [biohubApi, isLoading]); - /** - * Displays project list. - */ + const getProjectsTableData = () => { + const hasProjects = projects?.length > 0; + const hasDrafts = drafts?.length > 0; - const hasProjects = projects?.length > 0; - const hasDrafts = drafts?.length > 0; - - if (!hasProjects && !hasDrafts) { - return ( - - - - Projects - - - - - - - - - - No Projects found + if (!hasProjects && !hasDrafts) { + return ( + +
+ + + + + + No Projects found + + +
+
+ ); + } else { + return ( + + + + + Project Name + Species + Location + Start Date + End Date + + + + {drafts?.map((row) => ( + navigateToCreateProjectPage(row.id)}> + {row.name} (Draft) + + + + - -
-
-
-
- ); - } else { - return ( - - - - Projects - - - - - - - Project Name - Species - Location - Start Date - End Date + ))} + {projects?.map((row) => ( + navigateToProjectPage(row.id)}> + {row.name} + {row.focal_species_name_list} + {row.regions_name_list} + {getFormattedDate(DATE_FORMAT.ShortMediumDateFormat, row.start_date)} + {getFormattedDate(DATE_FORMAT.ShortMediumDateFormat, row.end_date)} - - - {drafts?.map((row) => ( - - {row.name} (Draft) - - - - - - ))} - {projects.map((row) => ( - navigateToProjectPage(row.id)}> - {row.name} - {row.focal_species_name_list} - {row.regions_name_list} - {getFormattedDate(DATE_FORMAT.ShortMediumDateFormat, row.start_date)} - {getFormattedDate(DATE_FORMAT.ShortMediumDateFormat, row.end_date)} - - ))} - -
-
-
-
- ); - } + ))} + + + + ); + } + }; + + /** + * Displays project list. + */ + return ( + + + + Projects + + + {getProjectsTableData()} + + + ); }; export default ProjectsListPage; diff --git a/app/src/features/projects/view/ProjectPage.tsx b/app/src/features/projects/view/ProjectPage.tsx index 3f819c2d33..31a06b04fe 100644 --- a/app/src/features/projects/view/ProjectPage.tsx +++ b/app/src/features/projects/view/ProjectPage.tsx @@ -38,8 +38,6 @@ const useStyles = makeStyles((theme: Theme) => ({ /** * Page to display a single Project. * - * // TODO WIP - * * @return {*} */ const ProjectPage: React.FC = () => { diff --git a/app/src/hooks/api/useDraftApi.ts b/app/src/hooks/api/useDraftApi.ts index 46f6ac4f7d..b6a4858f4b 100644 --- a/app/src/hooks/api/useDraftApi.ts +++ b/app/src/hooks/api/useDraftApi.ts @@ -1,5 +1,5 @@ import { AxiosInstance } from 'axios'; -import { IDraftResponse, IGetDraftsListResponse } from 'interfaces/useDraftApi.interface'; +import { IDraftResponse, IGetDraftResponse, IGetDraftsListResponse } from 'interfaces/useDraftApi.interface'; /** * Returns a set of supported api methods for working with drafts. @@ -50,10 +50,34 @@ const useDraftApi = (axios: AxiosInstance) => { return data; }; + /** + * Get details for a single draft based on its id. + * + * @return {*} {Promise} + */ + const getDraft = async (draftId: number): Promise => { + const { data } = await axios.get(`/api/draft/${draftId}/get`); + + return data; + }; + + /** + * Delete a single draft based on its id. + * + * @return {*} {Promise} + */ + const deleteDraft = async (draftId: number): Promise => { + const { data } = await axios.delete(`api/draft/${draftId}/delete`); + + return data; + }; + return { createDraft, updateDraft, - getDraftsList + getDraftsList, + getDraft, + deleteDraft }; }; diff --git a/app/src/hooks/useQuery.ts b/app/src/hooks/useQuery.ts new file mode 100644 index 0000000000..5cba65a45d --- /dev/null +++ b/app/src/hooks/useQuery.ts @@ -0,0 +1,12 @@ +import qs from 'qs'; +import { useLocation } from 'react-router'; + +/** + * Convenience wrapper for `useLocation` that parses `location.search` into an object of query string values. + * + * @return {*} + */ +export const useQuery = () => { + const location = useLocation(); + return qs.parse(location.search.replace('?', '')) as any; +}; diff --git a/app/src/interfaces/useDraftApi.interface.ts b/app/src/interfaces/useDraftApi.interface.ts index 7272aa0a02..a2cc109095 100644 --- a/app/src/interfaces/useDraftApi.interface.ts +++ b/app/src/interfaces/useDraftApi.interface.ts @@ -1,3 +1,5 @@ +import { ICreateProjectRequest } from './useProjectApi.interface'; + /** * Create/Update draft response object. * @@ -19,3 +21,15 @@ export interface IGetDraftsListResponse { id: number; name: string; } + +/** + * Get single draft response object. + * + * @export + * @interface IGetDraftResponse + */ +export interface IGetDraftResponse { + id: number; + name: string; + data: ICreateProjectRequest; +}