From 5d807226a9ee6e3e3eea99956ea0cb1ae6727453 Mon Sep 17 00:00:00 2001 From: Al Rosenthal Date: Fri, 8 Sep 2023 16:40:56 -0700 Subject: [PATCH] SISMBIOHUB-259: Survey Block (#1085) Survey Block UI/ API endpoints added --- api/src/models/survey-create.ts | 3 + api/src/models/survey-update.ts | 3 + api/src/models/survey-view.ts | 2 + .../project/{projectId}/survey/create.ts | 15 ++ .../{projectId}/survey/{surveyId}/update.ts | 19 ++ .../survey/{surveyId}/update/get.ts | 18 ++ .../survey-block-repository.test.ts | 210 ++++++++++++++++++ .../repositories/survey-block-repository.ts | 145 ++++++++++++ api/src/services/survey-block-service.test.ts | 175 +++++++++++++++ api/src/services/survey-block-service.ts | 73 ++++++ api/src/services/survey-service.test.ts | 12 +- api/src/services/survey-service.ts | 31 ++- app/src/features/surveys/CreateSurveyPage.tsx | 11 +- .../features/surveys/components/BlockForm.tsx | 38 ++++ .../components/CreateSurveyBlockDialog.tsx | 74 ++++++ .../components/EditSurveyBlockDialog.tsx | 88 ++++++++ .../surveys/components/SurveyBlockSection.tsx | 169 ++++++++++++++ .../features/surveys/edit/EditSurveyForm.tsx | 11 +- app/src/interfaces/useSurveyApi.interface.ts | 11 +- 19 files changed, 1102 insertions(+), 6 deletions(-) create mode 100644 api/src/repositories/survey-block-repository.test.ts create mode 100644 api/src/repositories/survey-block-repository.ts create mode 100644 api/src/services/survey-block-service.test.ts create mode 100644 api/src/services/survey-block-service.ts create mode 100644 app/src/features/surveys/components/BlockForm.tsx create mode 100644 app/src/features/surveys/components/CreateSurveyBlockDialog.tsx create mode 100644 app/src/features/surveys/components/EditSurveyBlockDialog.tsx create mode 100644 app/src/features/surveys/components/SurveyBlockSection.tsx diff --git a/api/src/models/survey-create.ts b/api/src/models/survey-create.ts index 77a59a500b..0ef9b427f7 100644 --- a/api/src/models/survey-create.ts +++ b/api/src/models/survey-create.ts @@ -1,4 +1,5 @@ import { Feature } from 'geojson'; +import { PostSurveyBlock } from '../repositories/survey-block-repository'; export class PostSurveyObject { survey_details: PostSurveyDetailsData; @@ -11,6 +12,7 @@ export class PostSurveyObject { agreements: PostAgreementsData; participants: PostParticipationData[]; partnerships: PostPartnershipsData; + blocks: PostSurveyBlock[]; constructor(obj?: any) { this.survey_details = (obj?.survey_details && new PostSurveyDetailsData(obj.survey_details)) || null; @@ -26,6 +28,7 @@ export class PostSurveyObject { this.participants = (obj?.participants?.length && obj.participants.map((p: any) => new PostParticipationData(p))) || []; this.partnerships = (obj?.partnerships && new PostPartnershipsData(obj.partnerships)) || null; + this.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; } } diff --git a/api/src/models/survey-update.ts b/api/src/models/survey-update.ts index 502f526655..e54623a04a 100644 --- a/api/src/models/survey-update.ts +++ b/api/src/models/survey-update.ts @@ -1,4 +1,5 @@ import { Feature } from 'geojson'; +import { PostSurveyBlock } from '../repositories/survey-block-repository'; export class PutSurveyObject { survey_details: PutSurveyDetailsData; @@ -10,6 +11,7 @@ export class PutSurveyObject { location: PutSurveyLocationData; participants: PutSurveyParticipantsData[]; partnerships: PutPartnershipsData; + blocks: PostSurveyBlock[]; constructor(obj?: any) { this.survey_details = (obj?.survey_details && new PutSurveyDetailsData(obj.survey_details)) || null; @@ -24,6 +26,7 @@ export class PutSurveyObject { this.participants = (obj?.participants?.length && obj.participants.map((p: any) => new PutSurveyParticipantsData(p))) || []; this.partnerships = (obj?.partnerships && new PutPartnershipsData(obj.partnerships)) || null; + this.blocks = (obj?.blocks && obj.blocks.map((p: any) => p as PostSurveyBlock)) || []; } } diff --git a/api/src/models/survey-view.ts b/api/src/models/survey-view.ts index ef04263302..5358b3c3a0 100644 --- a/api/src/models/survey-view.ts +++ b/api/src/models/survey-view.ts @@ -1,6 +1,7 @@ import { Feature } from 'geojson'; import { SurveyMetadataPublish } from '../repositories/history-publish-repository'; import { IPermitModel } from '../repositories/permit-repository'; +import { SurveyBlockRecord } from '../repositories/survey-block-repository'; import { SurveyUser } from '../repositories/survey-participation-repository'; export type SurveyObject = { @@ -13,6 +14,7 @@ export type SurveyObject = { location: GetSurveyLocationData; participants: SurveyUser[]; partnerships: ISurveyPartnerships; + blocks: SurveyBlockRecord[]; }; export interface ISurveyPartnerships { diff --git a/api/src/paths/project/{projectId}/survey/create.ts b/api/src/paths/project/{projectId}/survey/create.ts index ff1984b304..0115b203aa 100644 --- a/api/src/paths/project/{projectId}/survey/create.ts +++ b/api/src/paths/project/{projectId}/survey/create.ts @@ -248,6 +248,21 @@ POST.apiDoc = { } } } + }, + blocks: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description'], + properties: { + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts index 26571e3a1c..d1a7b49fb9 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts @@ -305,6 +305,25 @@ PUT.apiDoc = { } } } + }, + blocks: { + type: 'array', + items: { + type: 'object', + required: ['name', 'description'], + properties: { + survey_block_id: { + type: 'number', + nullable: true + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts index bf9a2acc44..6888f4f60f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts @@ -341,6 +341,24 @@ GET.apiDoc = { } } } + }, + blocks: { + type: 'array', + items: { + type: 'object', + required: ['survey_block_id', 'name', 'description'], + properties: { + survey_block_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/repositories/survey-block-repository.test.ts b/api/src/repositories/survey-block-repository.test.ts new file mode 100644 index 0000000000..59838972d4 --- /dev/null +++ b/api/src/repositories/survey-block-repository.test.ts @@ -0,0 +1,210 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { getMockDBConnection } from '../__mocks__/db'; +import { PostSurveyBlock, SurveyBlockRepository } from './survey-block-repository'; + +chai.use(sinonChai); + +describe('SurveyBlockRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getSurveyBlocksForSurveyId', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: '', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const response = await repo.getSurveyBlocksForSurveyId(1); + + response.forEach((item) => { + expect(item.survey_id).to.be.eql(1); + }); + }); + + it('should succeed with empty data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const response = await repo.getSurveyBlocksForSurveyId(1); + expect(response).to.be.empty; + }); + }); + + describe('updateSurveyBlock', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: 'Updated name', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const block: PostSurveyBlock = { survey_block_id: 1, survey_id: 1, name: 'Updated name', description: 'block' }; + const response = await repo.updateSurveyBlock(block); + expect(response.survey_block_id).to.be.eql(1); + expect(response.name).to.be.eql('Updated name'); + }); + + it('should failed with erroneous data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + try { + await repo.updateSurveyBlock(block); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to update survey block'); + } + }); + }); + + describe('insertSurveyBlock', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: 'new', + description: 'block', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + const repo = new SurveyBlockRepository(dbConnection); + + const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + const response = await repo.insertSurveyBlock(block); + + expect(response.name).to.be.eql('new'); + expect(response.description).to.be.eql('block'); + }); + + it('should fail with erroneous data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + const repo = new SurveyBlockRepository(dbConnection); + try { + const block = ({ + survey_block_id: null, + survey_id: 1, + name: null, + description: null + } as any) as PostSurveyBlock; + await repo.insertSurveyBlock(block); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to insert survey block'); + } + }); + }); + + describe('deleteSurveyBlockRecord', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: 'Deleted record', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + const response = await repo.deleteSurveyBlockRecord(1); + expect(response.survey_block_id).to.be.eql(1); + }); + + it('should failed with erroneous data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const repo = new SurveyBlockRepository(dbConnection); + try { + await repo.deleteSurveyBlockRecord(1); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to delete survey block record'); + } + }); + }); +}); diff --git a/api/src/repositories/survey-block-repository.ts b/api/src/repositories/survey-block-repository.ts new file mode 100644 index 0000000000..9f70ec3aa7 --- /dev/null +++ b/api/src/repositories/survey-block-repository.ts @@ -0,0 +1,145 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { BaseRepository } from './base-repository'; + +export interface PostSurveyBlock { + survey_block_id: number | null; + survey_id: number; + name: string; + description: string; +} + +// This describes the a row in the database for Survey Block +export const SurveyBlockRecord = z.object({ + survey_block_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); +export type SurveyBlockRecord = z.infer; + +/** + * A repository class for accessing Survey Block data. + * + * @export + * @class SurveyBlockRepository + * @extends {BaseRepository} + */ +export class SurveyBlockRepository extends BaseRepository { + /** + * Gets all Survey Block Records for a given survey id. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SurveyBlockRepository + */ + async getSurveyBlocksForSurveyId(surveyId: number): Promise { + const sql = SQL` + SELECT * + FROM survey_block + WHERE survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sql, SurveyBlockRecord); + return response.rows || []; + } + + /** + * Updates a survey block record. + * + * @param {SurveyBlock} block + * @return {*} {Promise} + * @memberof SurveyBlockRepository + */ + async updateSurveyBlock(block: PostSurveyBlock): Promise { + const sql = SQL` + UPDATE survey_block + SET + name = ${block.name}, + description = ${block.description}, + survey_id=${block.survey_id} + WHERE + survey_block_id = ${block.survey_block_id} + RETURNING + *; + `; + const response = await this.connection.sql(sql, SurveyBlockRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to update survey block', [ + 'SurveyBlockRepository->updateSurveyBlock', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Inserts a survey block record. + * + * @param {SurveyBlock} block + * @return {*} {Promise} + * @memberof SurveyBlockRepository + */ + async insertSurveyBlock(block: PostSurveyBlock): Promise { + const sql = SQL` + INSERT INTO survey_block ( + survey_id, + name, + description + ) VALUES ( + ${block.survey_id}, + ${block.name}, + ${block.description} + ) + RETURNING + *; + `; + const response = await this.connection.sql(sql, SurveyBlockRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to insert survey block', [ + 'SurveyBlockRepository->postSurveyBlock', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Deletes a survey block record. + * + * @param {number} surveyBlockId + * @return {*} {Promise} + * @memberof SurveyBlockRepository + */ + async deleteSurveyBlockRecord(surveyBlockId: number): Promise { + const sqlStatement = SQL` + DELETE FROM + survey_block + WHERE + survey_block_id = ${surveyBlockId} + RETURNING + *; + `; + + const response = await this.connection.sql(sqlStatement, SurveyBlockRecord); + + if (!response?.rowCount) { + throw new ApiExecuteSQLError('Failed to delete survey block record', [ + 'SurveyBlockRepository->deleteSurveyBlockRecord', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } +} diff --git a/api/src/services/survey-block-service.test.ts b/api/src/services/survey-block-service.test.ts new file mode 100644 index 0000000000..922d54ed3b --- /dev/null +++ b/api/src/services/survey-block-service.test.ts @@ -0,0 +1,175 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { PostSurveyBlock, SurveyBlockRepository } from '../repositories/survey-block-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { SurveyBlockService } from './survey-block-service'; + +chai.use(sinonChai); + +describe('SurveyBlockService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getSurveyBlocksForSurveyId', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: '', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const service = new SurveyBlockService(dbConnection); + const response = await service.getSurveyBlocksForSurveyId(1); + + response.forEach((item) => { + expect(item.survey_id).to.be.eql(1); + }); + }); + + it('should succeed with empty data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const service = new SurveyBlockService(dbConnection); + const response = await service.getSurveyBlocksForSurveyId(1); + expect(response).to.be.empty; + }); + }); + + describe('upsertSurveyBlocks', () => { + it('should succeed with valid data', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyBlockService(dbConnection); + + const getOldBlocks = sinon.stub(SurveyBlockService.prototype, 'getSurveyBlocksForSurveyId').resolves([]); + const deleteBlock = sinon.stub(SurveyBlockService.prototype, 'deleteSurveyBlock').resolves(); + const insertBlock = sinon.stub(SurveyBlockRepository.prototype, 'insertSurveyBlock').resolves(); + const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); + + const blocks: PostSurveyBlock[] = [ + { survey_block_id: null, survey_id: 1, name: 'Old Block', description: 'Updated' }, + { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + ]; + await service.upsertSurveyBlocks(1, blocks); + + expect(getOldBlocks).to.be.calledOnce; + expect(insertBlock).to.be.calledTwice; + expect(deleteBlock).to.not.be.calledOnce; + expect(updateBlock).to.not.be.calledOnce; + }); + + it('should run delete block code', async () => { + const dbConnection = getMockDBConnection(); + const service = new SurveyBlockService(dbConnection); + + const getOldBlocks = sinon.stub(SurveyBlockService.prototype, 'getSurveyBlocksForSurveyId').resolves([ + { + survey_block_id: 10, + survey_id: 1, + name: 'Old Block', + description: 'Updated', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + }, + { + survey_block_id: 11, + survey_id: 1, + name: 'Old Block', + description: 'Going to be deleted', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ]); + const deleteBlock = sinon.stub(SurveyBlockService.prototype, 'deleteSurveyBlock').resolves(); + const insertBlock = sinon.stub(SurveyBlockRepository.prototype, 'insertSurveyBlock').resolves(); + const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); + + const blocks: PostSurveyBlock[] = [ + { survey_block_id: 10, survey_id: 1, name: 'Old Block', description: 'Updated' }, + { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + ]; + await service.upsertSurveyBlocks(1, blocks); + + expect(getOldBlocks).to.be.calledOnce; + expect(deleteBlock).to.be.calledOnce; + expect(insertBlock).to.be.calledOnce; + expect(updateBlock).to.be.calledOnce; + }); + }); + + describe('deleteSurveyBlockRecord', () => { + it('should succeed with valid data', async () => { + const mockResponse = ({ + rows: [ + { + survey_block_id: 1, + survey_id: 1, + name: 'Deleted record', + description: '', + create_date: '', + create_user: 1, + update_date: '', + update_user: 1, + revision_count: 1 + } + ], + rowCount: 1 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const service = new SurveyBlockService(dbConnection); + const response = await service.deleteSurveyBlock(1); + expect(response.survey_block_id).to.be.eql(1); + }); + + it('should failed with erroneous data', async () => { + const mockResponse = ({ + rows: [], + rowCount: 0 + } as any) as Promise>; + const dbConnection = getMockDBConnection({ + sql: () => mockResponse + }); + + const service = new SurveyBlockService(dbConnection); + try { + await service.deleteSurveyBlock(1); + expect.fail(); + } catch (error) { + expect(((error as any) as ApiExecuteSQLError).message).to.be.eq('Failed to delete survey block record'); + } + }); + }); +}); diff --git a/api/src/services/survey-block-service.ts b/api/src/services/survey-block-service.ts new file mode 100644 index 0000000000..b7688f2301 --- /dev/null +++ b/api/src/services/survey-block-service.ts @@ -0,0 +1,73 @@ +import { IDBConnection } from '../database/db'; +import { PostSurveyBlock, SurveyBlockRecord, SurveyBlockRepository } from '../repositories/survey-block-repository'; +import { DBService } from './db-service'; + +export class SurveyBlockService extends DBService { + surveyBlockRepository: SurveyBlockRepository; + + constructor(connection: IDBConnection) { + super(connection); + this.surveyBlockRepository = new SurveyBlockRepository(connection); + } + + /** + * Gets Block Survey Records for a given survey id + * + * @param {number} surveyId + * @return {*} {Promise} + * @returns + */ + async getSurveyBlocksForSurveyId(surveyId: number): Promise { + return await this.surveyBlockRepository.getSurveyBlocksForSurveyId(surveyId); + } + + /** + * Deletes a survey block record. + * + * @param {number} surveyBlockId + * @return {*} {Promise} + * @memberof SurveyBlockService + */ + async deleteSurveyBlock(surveyBlockId: number): Promise { + return this.surveyBlockRepository.deleteSurveyBlockRecord(surveyBlockId); + } + + /** + * Inserts, Updates and Deletes Block records + * All passed in blocks are treated as the source of truth, + * Any pre existing blocks that do not collide with passed in blocks are deleted + * + * @param {number} surveyId + * @param {SurveyBlock[]} blocks + * @return {*} {Promise} + * @memberof SurveyBlockService + */ + async upsertSurveyBlocks(surveyId: number, blocks: PostSurveyBlock[]): Promise { + // all actions to take + const promises: Promise[] = []; + + // Get existing blocks + const existingBlocks = await this.getSurveyBlocksForSurveyId(surveyId); + + // Filter out any + const blocksToDelete = existingBlocks.filter( + (item) => !blocks.find((incoming) => incoming.survey_block_id === item.survey_block_id) + ); + + blocksToDelete.forEach((item) => { + promises.push(this.deleteSurveyBlock(item.survey_block_id)); + }); + + // update or insert block data + blocks.forEach((item: PostSurveyBlock) => { + item.survey_id = surveyId; + if (item.survey_block_id) { + promises.push(this.surveyBlockRepository.updateSurveyBlock(item)); + } else { + promises.push(this.surveyBlockRepository.insertSurveyBlock(item)); + } + }); + + await Promise.all(promises); + } +} diff --git a/api/src/services/survey-service.test.ts b/api/src/services/survey-service.test.ts index 6c9c64ec1c..b0d1683538 100644 --- a/api/src/services/survey-service.test.ts +++ b/api/src/services/survey-service.test.ts @@ -33,6 +33,7 @@ import { getMockDBConnection } from '../__mocks__/db'; import { HistoryPublishService } from './history-publish-service'; import { PermitService } from './permit-service'; import { PlatformService } from './platform-service'; +import { SurveyBlockService } from './survey-block-service'; import { SurveyParticipationService } from './survey-participation-service'; import { SurveyService } from './survey-service'; import { TaxonomyService } from './taxonomy-service'; @@ -78,6 +79,7 @@ describe('SurveyService', () => { const getSurveyParticipantsStub = sinon .stub(SurveyParticipationService.prototype, 'getSurveyParticipants') .resolves([{ data: 'participantData' } as any]); + const getSurveyBlockStub = sinon.stub(SurveyBlockService.prototype, 'getSurveyBlocksForSurveyId').resolves([]); const getSurveyPartnershipsDataStub = sinon.stub(SurveyService.prototype, 'getSurveyPartnershipsData').resolves({ indigenous_partnerships: [], @@ -95,6 +97,7 @@ describe('SurveyService', () => { expect(getSurveyLocationDataStub).to.be.calledOnce; expect(getSurveyParticipantsStub).to.be.calledOnce; expect(getSurveyPartnershipsDataStub).to.be.calledOnce; + expect(getSurveyBlockStub).to.be.calledOnce; expect(response).to.eql({ survey_details: { data: 'surveyData' }, @@ -108,7 +111,8 @@ describe('SurveyService', () => { stakeholder_partnerships: [] }, participants: [{ data: 'participantData' } as any], - location: { data: 'locationData' } + location: { data: 'locationData' }, + blocks: [] }); }); }); @@ -137,6 +141,7 @@ describe('SurveyService', () => { const upsertSurveyParticipantDataStub = sinon .stub(SurveyService.prototype, 'upsertSurveyParticipantData') .resolves(); + sinon.stub(SurveyBlockService.prototype, 'upsertSurveyBlocks').resolves(); const surveyService = new SurveyService(dbConnectionObj); @@ -175,6 +180,7 @@ describe('SurveyService', () => { const upsertSurveyParticipantDataStub = sinon .stub(SurveyService.prototype, 'upsertSurveyParticipantData') .resolves(); + const upsertBlocks = sinon.stub(SurveyBlockService.prototype, 'upsertSurveyBlocks').resolves(); const surveyService = new SurveyService(dbConnectionObj); @@ -187,7 +193,8 @@ describe('SurveyService', () => { proprietor: {}, purpose_and_methodology: {}, location: {}, - participants: [{}] + participants: [{}], + blocks: [{}] }); await surveyService.updateSurvey(surveyId, putSurveyData); @@ -201,6 +208,7 @@ describe('SurveyService', () => { expect(updateSurveyProprietorDataStub).to.have.been.calledOnce; expect(updateSurveyRegionStub).to.have.been.calledOnce; expect(upsertSurveyParticipantDataStub).to.have.been.calledOnce; + expect(upsertBlocks).to.have.been.calledOnce; }); }); diff --git a/api/src/services/survey-service.ts b/api/src/services/survey-service.ts index a067280423..4c3b9a4424 100644 --- a/api/src/services/survey-service.ts +++ b/api/src/services/survey-service.ts @@ -20,6 +20,7 @@ import { } from '../models/survey-view'; import { AttachmentRepository } from '../repositories/attachment-repository'; import { PublishStatus } from '../repositories/history-publish-repository'; +import { PostSurveyBlock, SurveyBlockRecord } from '../repositories/survey-block-repository'; import { IGetLatestSurveyOccurrenceSubmission, IObservationSubmissionInsertDetails, @@ -35,6 +36,7 @@ import { HistoryPublishService } from './history-publish-service'; import { PermitService } from './permit-service'; import { PlatformService } from './platform-service'; import { RegionService } from './region-service'; +import { SurveyBlockService } from './survey-block-service'; import { SurveyParticipationService } from './survey-participation-service'; import { TaxonomyService } from './taxonomy-service'; @@ -94,10 +96,16 @@ export class SurveyService extends DBService { purpose_and_methodology: await this.getSurveyPurposeAndMethodology(surveyId), proprietor: await this.getSurveyProprietorDataForView(surveyId), location: await this.getSurveyLocationData(surveyId), - participants: await this.surveyParticipationService.getSurveyParticipants(surveyId) + participants: await this.surveyParticipationService.getSurveyParticipants(surveyId), + blocks: await this.getSurveyBlocksForSurveyId(surveyId) }; } + async getSurveyBlocksForSurveyId(surveyId: number): Promise { + const service = new SurveyBlockService(this.connection); + return service.getSurveyBlocksForSurveyId(surveyId); + } + async getSurveyPartnershipsData(surveyId: number): Promise { const [indigenousPartnerships, stakeholderPartnerships] = [ await this.surveyRepository.getIndigenousPartnershipsBySurveyId(surveyId), @@ -445,11 +453,28 @@ export class SurveyService extends DBService { promises.push(this.insertRegion(surveyId, postSurveyData.location.geometry)); } + if (postSurveyData.blocks) { + promises.push(this.upsertBlocks(surveyId, postSurveyData.blocks)); + } + await Promise.all(promises); return surveyId; } + /** + * Insert, updates and deletes Survey Blocks for a given survey id + * + * @param {number} surveyId + * @param {SurveyBlock[]} blocks + * @returns {*} {Promise} + * @memberof SurveyService + */ + async upsertBlocks(surveyId: number, blocks: PostSurveyBlock[]): Promise { + const service = new SurveyBlockService(this.connection); + return service.upsertSurveyBlocks(surveyId, blocks); + } + async insertRegion(projectId: number, features: Feature[]): Promise { const regionService = new RegionService(this.connection); return regionService.addRegionsToSurveyFromFeatures(projectId, features); @@ -654,6 +679,10 @@ export class SurveyService extends DBService { promises.push(this.upsertSurveyParticipantData(surveyId, putSurveyData)); } + if (putSurveyData?.blocks) { + promises.push(this.upsertBlocks(surveyId, putSurveyData.blocks)); + } + await Promise.all(promises); } diff --git a/app/src/features/surveys/CreateSurveyPage.tsx b/app/src/features/surveys/CreateSurveyPage.tsx index 5f144316ca..4d65d2a87c 100644 --- a/app/src/features/surveys/CreateSurveyPage.tsx +++ b/app/src/features/surveys/CreateSurveyPage.tsx @@ -43,6 +43,7 @@ import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from './components/PurposeAndMethodologyForm'; import StudyAreaForm, { StudyAreaInitialValues, StudyAreaYupSchema } from './components/StudyAreaForm'; +import SurveyBlockSection, { SurveyBlockInitialValues } from './components/SurveyBlockSection'; import SurveyFundingSourceForm, { SurveyFundingSourceFormInitialValues, SurveyFundingSourceFormYupSchema @@ -136,7 +137,8 @@ const CreateSurveyPage = () => { ...SurveyPartnershipsFormInitialValues, ...ProprietaryDataInitialValues, ...AgreementsInitialValues, - ...SurveyUserJobFormInitialValues + ...SurveyUserJobFormInitialValues, + ...SurveyBlockInitialValues }); // Yup schemas for the survey form sections @@ -377,6 +379,13 @@ const CreateSurveyPage = () => { + } + /> + + { + return ( +
+ + + Name and Description + + + + + + +
+ ); +}; + +export default BlockForm; diff --git a/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx b/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx new file mode 100644 index 0000000000..7069429f4d --- /dev/null +++ b/app/src/features/surveys/components/CreateSurveyBlockDialog.tsx @@ -0,0 +1,74 @@ +import CloseIcon from '@mui/icons-material/Close'; +import { Typography } from '@mui/material'; +import IconButton from '@mui/material/IconButton'; +import Snackbar from '@mui/material/Snackbar'; +import EditDialog from 'components/dialog/EditDialog'; +import { useState } from 'react'; +import BlockForm from './BlockForm'; +import { BlockYupSchema } from './SurveyBlockSection'; +interface ICreateBlockProps { + open: boolean; + onSave: (data: any) => void; + onClose: () => void; +} + +const CreateSurveyBlockDialog: React.FC = (props) => { + const { open, onSave, onClose } = props; + const [isSnackBarOpen, setIsSnackBarOpen] = useState(false); + const [blockName, setBlockName] = useState(''); + return ( + <> + , + initialValues: { + survey_block_id: null, + name: '', + description: '' + }, + validationSchema: BlockYupSchema + }} + dialogSaveButtonLabel="Add Block" + onCancel={() => onClose()} + onSave={(formValues) => { + setBlockName(formValues.name); + setIsSnackBarOpen(true); + onSave(formValues); + }} + /> + + { + setIsSnackBarOpen(false); + setBlockName(''); + }} + message={ + <> + + Block {blockName} has been added. + + + } + action={ + setIsSnackBarOpen(false)}> + + + } + /> + + ); +}; + +export default CreateSurveyBlockDialog; diff --git a/app/src/features/surveys/components/EditSurveyBlockDialog.tsx b/app/src/features/surveys/components/EditSurveyBlockDialog.tsx new file mode 100644 index 0000000000..570c03020a --- /dev/null +++ b/app/src/features/surveys/components/EditSurveyBlockDialog.tsx @@ -0,0 +1,88 @@ +import CloseIcon from '@mui/icons-material/Close'; +import { Typography } from '@mui/material'; +import IconButton from '@mui/material/IconButton'; +import Snackbar from '@mui/material/Snackbar'; +import EditDialog from 'components/dialog/EditDialog'; +import { useState } from 'react'; +import BlockForm from './BlockForm'; +import { BlockYupSchema, IEditBlock } from './SurveyBlockSection'; + +interface IEditBlockProps { + open: boolean; + initialData?: IEditBlock; + onSave: (data: any, index?: number) => void; + onClose: () => void; +} + +const EditSurveyBlockDialog: React.FC = (props) => { + const { open, initialData, onSave, onClose } = props; + const [isSnackBarOpen, setIsSnackBarOpen] = useState(false); + const [blockName, setBlockName] = useState(''); + return ( + <> + , + initialValues: { + survey_block_id: initialData?.block.survey_block_id || null, + name: initialData?.block.name || '', + description: initialData?.block.description || '' + }, + validationSchema: BlockYupSchema + }} + dialogSaveButtonLabel="Save" + onCancel={() => { + setBlockName(''); + setIsSnackBarOpen(true); + onClose(); + }} + onSave={(formValues) => { + setBlockName(formValues.name); + setIsSnackBarOpen(true); + onSave(formValues, initialData?.index); + }} + /> + + { + setIsSnackBarOpen(false); + setBlockName(''); + }} + message={ + <> + + {initialData?.block.survey_block_id ? ( + <> + Block {blockName} has been updated. + + ) : ( + <> + Block {blockName} has been added. + + )} + + + } + action={ + setIsSnackBarOpen(false)}> + + + } + /> + + ); +}; + +export default EditSurveyBlockDialog; diff --git a/app/src/features/surveys/components/SurveyBlockSection.tsx b/app/src/features/surveys/components/SurveyBlockSection.tsx new file mode 100644 index 0000000000..cfb194e0b9 --- /dev/null +++ b/app/src/features/surveys/components/SurveyBlockSection.tsx @@ -0,0 +1,169 @@ +import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { ListItemIcon, Menu, MenuItem, MenuProps, Typography } from '@mui/material'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import IconButton from '@mui/material/IconButton'; +import { useFormikContext } from 'formik'; +import { ICreateSurveyRequest } from 'interfaces/useSurveyApi.interface'; +import React, { useState } from 'react'; +import yup from 'utils/YupSchema'; +import CreateSurveyBlockDialog from './CreateSurveyBlockDialog'; +import EditSurveyBlockDialog from './EditSurveyBlockDialog'; + +export const SurveyBlockInitialValues = { + blocks: [] +}; + +// Form validation for Block Item +export const BlockYupSchema = yup.object({ + name: yup.string().required().max(50, 'Maximum 50 characters'), + description: yup.string().required().max(250, 'Maximum 250 characters') +}); + +export const SurveyBlockYupSchema = yup.array(BlockYupSchema); + +export interface IEditBlock { + index: number; + block: { + survey_block_id: number | null; + name: string; + description: string; + }; +} + +const SurveyBlockSection: React.FC = () => { + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const [editData, setEditData] = useState(undefined); + + const formikProps = useFormikContext(); + const { values, handleSubmit, setFieldValue } = formikProps; + + const handleMenuClick = (event: React.MouseEvent, index: number) => { + setAnchorEl(event.currentTarget); + setEditData({ index: index, block: values.blocks[index] }); + }; + + const handleDelete = () => { + if (editData) { + const data = values.blocks; + data.splice(editData.index, 1); + setFieldValue('blocks', data); + } + setAnchorEl(null); + }; + + return ( + <> + {/* CREATE BLOCK DIALOG */} + setIsCreateModalOpen(false)} + onSave={(data) => { + setEditData(undefined); + setFieldValue(`blocks[${values.blocks.length}]`, data); + setIsCreateModalOpen(false); + }} + /> + + {/* EDIT BLOCK DIALOG */} + { + setIsEditModalOpen(false); + setAnchorEl(null); + }} + onSave={(data, index) => { + setIsEditModalOpen(false); + setAnchorEl(null); + setEditData(undefined); + setFieldValue(`blocks[${index}]`, data); + }} + /> + + Define Blocks + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam at porttitor sem. Aliquam erat volutpat. Donec + placerat nisl magna, et faucibus arcu condimentum sed. + + setAnchorEl(null)} + anchorEl={anchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + setIsEditModalOpen(true)}> + + + + Edit Details + + handleDelete()}> + + + + Remove + + +
+ + + {values.blocks.map((item, index) => { + return ( + + ) => + handleMenuClick(event, index) + } + aria-label="settings"> + + + } + title={item.name} + subheader={item.description} + /> + + ); + })} + +
+ + ); +}; + +export default SurveyBlockSection; diff --git a/app/src/features/surveys/edit/EditSurveyForm.tsx b/app/src/features/surveys/edit/EditSurveyForm.tsx index ce38478e49..424efde03b 100644 --- a/app/src/features/surveys/edit/EditSurveyForm.tsx +++ b/app/src/features/surveys/edit/EditSurveyForm.tsx @@ -28,6 +28,7 @@ import GeneralInformationForm, { import ProprietaryDataForm, { ProprietaryDataYupSchema } from '../components/ProprietaryDataForm'; import PurposeAndMethodologyForm, { PurposeAndMethodologyYupSchema } from '../components/PurposeAndMethodologyForm'; import StudyAreaForm, { StudyAreaInitialValues, StudyAreaYupSchema } from '../components/StudyAreaForm'; +import SurveyBlockSection, { SurveyBlockInitialValues } from '../components/SurveyBlockSection'; import SurveyFundingSourceForm, { SurveyFundingSourceFormInitialValues, SurveyFundingSourceFormYupSchema @@ -96,7 +97,8 @@ const EditSurveyForm: React.FC = (props) => { foippa_requirements_accepted: 'true' as unknown as StringBoolean } }, - ...SurveyUserJobFormInitialValues + ...SurveyUserJobFormInitialValues, + ...SurveyBlockInitialValues }); // Yup schemas for the survey form sections @@ -233,6 +235,13 @@ const EditSurveyForm: React.FC = (props) => { + } + /> + +