diff --git a/.github/workflows/addComments.yml b/.github/workflows/addComments.yml index 45e14bc914..9126ecf0d1 100644 --- a/.github/workflows/addComments.yml +++ b/.github/workflows/addComments.yml @@ -5,6 +5,7 @@ on: pull_request: types: [opened, ready_for_review] branches-ignore: + - test - prod jobs: diff --git a/api/.vscode/settings.json b/api/.vscode/settings.json index 65a1965328..bc77a2329f 100644 --- a/api/.vscode/settings.json +++ b/api/.vscode/settings.json @@ -1,3 +1,4 @@ { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.preferences.importModuleSpecifier": "non-relative" } diff --git a/api/src/database/db-utils.ts b/api/src/database/db-utils.ts index 4cd7956dca..687adae674 100644 --- a/api/src/database/db-utils.ts +++ b/api/src/database/db-utils.ts @@ -1,3 +1,4 @@ +import { DatabaseError } from 'pg'; import { z } from 'zod'; import { SYSTEM_IDENTITY_SOURCE } from '../constants/database'; import { ApiExecuteSQLError } from '../errors/api-error'; @@ -59,6 +60,8 @@ export const syncErrorWrapper = /** * This function parses the passed in error and translates them into a human readable error * + * @see https://www.postgresql.org/docs/current/errcodes-appendix.html for postgres error codes + * * @param error error to be parsed * @returns an error to throw */ @@ -67,10 +70,17 @@ const parseError = (error: any) => { throw new ApiExecuteSQLError('SQL response failed schema check', [error]); } - if (error.message === 'CONCURRENCY_EXCEPTION') { - // error thrown by DB trigger based on revision_count - // will be thrown if two updates to the same record are made concurrently - throw new ApiExecuteSQLError('Failed to update stale data', [error]); + if (error instanceof DatabaseError) { + if (error.message === 'CONCURRENCY_EXCEPTION') { + // error thrown by DB trigger based on revision_count + // will be thrown if two updates to the same record are made concurrently + throw new ApiExecuteSQLError('Failed to update stale data', [error]); + } + + if (error.code === '23503') { + // error thrown by DB when query fails due to foreign key constraint + throw new ApiExecuteSQLError('Failed to delete record due to foreign key constraint', [error]); + } } // Generic error thrown if not captured above diff --git a/api/src/openapi/schemas/technique.ts b/api/src/openapi/schemas/technique.ts new file mode 100644 index 0000000000..fb0fd24dc3 --- /dev/null +++ b/api/src/openapi/schemas/technique.ts @@ -0,0 +1,154 @@ +import { OpenAPIV3 } from 'openapi-types'; + +const techniqueAttractantsSchema: OpenAPIV3.SchemaObject = { + type: 'array', + description: 'Attractants used to lure species during the technique.', + items: { + type: 'object', + required: ['attractant_lookup_id'], + additionalProperties: false, + properties: { + attractant_lookup_id: { + type: 'integer', + description: 'The ID of a known attractant type.' + } + } + } +}; + +const techniqueAttributesSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'Attributes of the technique.', + required: ['qualitative_attributes', 'quantitative_attributes'], + additionalProperties: false, + properties: { + quantitative_attributes: { + type: 'array', + items: { + type: 'object', + required: ['method_technique_attribute_quantitative_id', 'method_lookup_attribute_quantitative_id', 'value'], + additionalProperties: false, + properties: { + method_technique_attribute_quantitative_id: { + type: 'integer', + description: 'Primary key of the attribute.', + nullable: true + }, + method_lookup_attribute_quantitative_id: { + type: 'string', + format: 'uuid', + description: 'The ID of a known quantitative attribute.' + }, + value: { + type: 'number', + description: 'The value of the quantitative attribute.' + } + } + } + }, + qualitative_attributes: { + type: 'array', + items: { + type: 'object', + required: [ + 'method_technique_attribute_qualitative_id', + 'method_lookup_attribute_qualitative_id', + 'method_lookup_attribute_qualitative_option_id' + ], + additionalProperties: false, + properties: { + method_technique_attribute_qualitative_id: { + type: 'integer', + description: 'Primary key of the attribute', + nullable: true + }, + method_lookup_attribute_qualitative_id: { + type: 'string', + format: 'uuid', + description: 'The ID of a known qualitative attribute.' + }, + method_lookup_attribute_qualitative_option_id: { + type: 'string', + format: 'uuid', + description: 'The ID of a known qualitative attribute option.' + } + } + } + } + } +}; + +export const techniqueSimpleViewSchema: OpenAPIV3.SchemaObject = { + type: 'object', + required: ['method_technique_id', 'name', 'description', 'attractants'], + additionalProperties: false, + properties: { + method_technique_id: { + type: 'integer', + description: 'Primary key of the technique' + }, + name: { + type: 'string', + description: 'Name of the technique.' + }, + description: { + type: 'string', + description: 'Description of the technique.', + nullable: true + }, + attractants: techniqueAttractantsSchema + } +}; + +export const techniqueCreateSchema: OpenAPIV3.SchemaObject = { + type: 'object', + required: ['name', 'description', 'method_lookup_id', 'distance_threshold', 'attractants', 'attributes'], + additionalProperties: false, + properties: { + name: { + type: 'string', + description: 'Name of the technique.' + }, + description: { + type: 'string', + description: 'Description of the technique.', + nullable: true + }, + method_lookup_id: { + type: 'integer', + description: 'The ID of a known method type.', + minimum: 1 + }, + distance_threshold: { + type: 'number', + description: 'Maximum detection distance (meters).', + nullable: true + }, + attractants: techniqueAttractantsSchema, + attributes: techniqueAttributesSchema + } +}; + +export const techniqueUpdateSchema: OpenAPIV3.SchemaObject = { + ...techniqueCreateSchema, + required: [...(techniqueCreateSchema.required ?? []), 'method_technique_id'], + properties: { + ...techniqueCreateSchema.properties, + method_technique_id: { + type: 'number', + description: 'Primary key for the technique.' + } + } +}; + +export const techniqueViewSchema: OpenAPIV3.SchemaObject = { + ...techniqueCreateSchema, + required: [...(techniqueCreateSchema.required ?? []), 'method_technique_id'], + properties: { + ...techniqueCreateSchema.properties, + method_technique_id: { + type: 'number', + description: 'Primary key for the technique.' + } + } +}; diff --git a/api/src/paths/codes.test.ts b/api/src/paths/codes.test.ts index 86a8b4b383..c7704de0b8 100644 --- a/api/src/paths/codes.test.ts +++ b/api/src/paths/codes.test.ts @@ -5,45 +5,31 @@ import sinonChai from 'sinon-chai'; import * as db from '../database/db'; import { HTTPError } from '../errors/http-error'; import { CodeService } from '../services/code-service'; -import { getMockDBConnection } from '../__mocks__/db'; +import { getMockDBConnection, getRequestHandlerMocks } from '../__mocks__/db'; import * as codes from './codes'; chai.use(sinonChai); describe('codes', () => { - const dbConnectionObj = getMockDBConnection(); - - const sampleReq = { - keycloak_token: {} - } as any; - - let actualResult = { - management_action_type: null - }; - - const sampleRes = { - status: () => { - return { - json: (result: any) => { - actualResult = result; - } - }; - } - }; - describe('getAllCodes', () => { afterEach(() => { sinon.restore(); }); it('should throw a 500 error when fails to fetch codes', async () => { + const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getAPIUserDBConnection').returns(dbConnectionObj); + sinon.stub(CodeService.prototype, 'getAllCodeSets').resolves(undefined); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {}; + try { - const result = codes.getAllCodes(); + const requestHandler = codes.getAllCodes(); - await result(sampleReq, null as unknown as any, null as unknown as any); + await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).status).to.equal(500); @@ -52,28 +38,40 @@ describe('codes', () => { }); it('should return the fetched codes on success', async () => { + const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getAPIUserDBConnection').returns(dbConnectionObj); + sinon.stub(CodeService.prototype, 'getAllCodeSets').resolves({ management_action_type: { id: 1, name: 'management action type' } } as any); - const result = codes.getAllCodes(); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - await result(sampleReq, sampleRes as any, null as unknown as any); + mockReq.keycloak_token = {}; - expect(actualResult.management_action_type).to.eql({ id: 1, name: 'management action type' }); + const requestHandler = codes.getAllCodes(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue.management_action_type).to.eql({ id: 1, name: 'management action type' }); }); it('should throw an error when a failure occurs', async () => { const expectedError = new Error('cannot process request'); + const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getAPIUserDBConnection').returns(dbConnectionObj); + sinon.stub(CodeService.prototype, 'getAllCodeSets').rejects(expectedError); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.keycloak_token = {}; + try { - const result = codes.getAllCodes(); + const requestHandler = codes.getAllCodes(); - await result(sampleReq, sampleRes as any, null as unknown as any); + await requestHandler(mockReq, mockRes, mockNext); expect.fail(); } catch (actualError) { expect((actualError as HTTPError).message).to.equal(expectedError.message); diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index 882107504e..8f2c6e3644 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -37,7 +37,8 @@ GET.apiDoc = { 'vantage_codes', 'site_selection_strategies', 'survey_progress', - 'method_response_metrics' + 'method_response_metrics', + 'attractants' ], properties: { management_action_type: { @@ -316,11 +317,13 @@ GET.apiDoc = { }, survey_progress: { type: 'array', + description: 'Indicates the progress of a survey (e.g. planned, in progress, completed).', items: { type: 'object', properties: { id: { - type: 'integer' + type: 'integer', + minimum: 1 }, name: { type: 'string' @@ -333,13 +336,37 @@ GET.apiDoc = { }, method_response_metrics: { type: 'array', + description: + 'Indicates the measurement type of a sampling method (e.g. count, precent cover, biomass, etc).', items: { type: 'object', additionalProperties: false, required: ['id', 'name', 'description'], properties: { id: { - type: 'integer' + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + }, + attractants: { + type: 'array', + description: 'Describes the attractants that can be used by a sampling technique.', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name', 'description'], + properties: { + id: { + type: 'integer', + minimum: 1 }, name: { type: 'string' @@ -395,6 +422,9 @@ export function getAllCodes(): RequestHandler { throw new HTTP500('Failed to fetch codes'); } + // Allow browsers to cache this response for 300 seconds (5 minutes) + res.setHeader('Cache-Control', 'private, max-age=300'); + return res.status(200).json(allCodeSets); } catch (error) { defaultLog.error({ label: 'getAllCodes', message: 'error', error }); diff --git a/api/src/paths/project/{projectId}/delete.ts b/api/src/paths/project/{projectId}/delete.ts index d618a8f73f..b4c40199e8 100644 --- a/api/src/paths/project/{projectId}/delete.ts +++ b/api/src/paths/project/{projectId}/delete.ts @@ -14,7 +14,7 @@ export const DELETE: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], projectId: Number(req.params.projectId), discriminator: 'ProjectPermission' }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.ts index 8658d99cd8..87537e4702 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.ts @@ -2,7 +2,7 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; -import { HTTP400, HTTP500 } from '../../../../../../errors/http-error'; +import { HTTP400, HTTP409 } from '../../../../../../errors/http-error'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { ObservationService } from '../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../services/sample-location-service'; @@ -15,8 +15,8 @@ export const POST: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], - projectId: Number(req.params.projectId), + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, { @@ -90,6 +90,9 @@ POST.apiDoc = { 403: { $ref: '#/components/responses/403' }, + 409: { + $ref: '#/components/responses/409' + }, 500: { $ref: '#/components/responses/500' }, @@ -122,7 +125,7 @@ export function deleteSurveySampleSiteRecords(): RequestHandler { ); if (observationCount > 0) { - throw new HTTP500(`Cannot delete a sampling site that is associated with an observation`); + throw new HTTP409(`Cannot delete a sampling site that is associated with an observation`); } for (const surveySampleSiteId of surveySampleSiteIds) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts index d98c7416b9..c350b04c75 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts @@ -8,6 +8,7 @@ import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../../../../../openapi/schemas/pagination'; +import { techniqueSimpleViewSchema } from '../../../../../../openapi/schemas/technique'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { PostSampleLocations, SampleLocationService } from '../../../../../../services/sample-location-service'; import { getLogger } from '../../../../../../utils/logger'; @@ -111,7 +112,7 @@ GET.apiDoc = { required: [ 'survey_sample_method_id', 'survey_sample_site_id', - 'method_lookup_id', + 'technique', 'method_response_metric_id', 'sample_periods' ], @@ -127,10 +128,7 @@ GET.apiDoc = { type: 'integer', minimum: 1 }, - method_lookup_id: { - type: 'integer', - minimum: 1 - }, + technique: techniqueSimpleViewSchema, method_response_metric_id: { type: 'integer', minimum: 1 @@ -306,7 +304,7 @@ export const POST: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, @@ -371,7 +369,7 @@ POST.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['method_lookup_id', 'description', 'sample_periods', 'method_response_metric_id'], + required: ['method_technique_id', 'description', 'sample_periods', 'method_response_metric_id'], properties: { survey_sample_site_id: { type: 'integer', @@ -381,13 +379,12 @@ POST.apiDoc = { type: 'integer', nullable: true }, - method_lookup_id: { - type: 'integer', - nullable: true - }, description: { type: 'string' }, + method_technique_id: { + type: 'integer' + }, sample_periods: { type: 'array', minItems: 1, @@ -404,10 +401,6 @@ POST.apiDoc = { type: 'integer', nullable: true }, - method_lookup_id: { - type: 'integer', - nullable: true - }, start_date: { type: 'string' }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts index af2db5dc6f..310e7007a0 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts @@ -17,66 +17,6 @@ describe('updateSurveySampleSite', () => { sinon.restore(); }); - it('should throw a 400 error when no surveyId is provided', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - try { - const requestHandler = updateSurveySampleSite(); - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required path param `surveyId`'); - } - }); - - it('should throw a 400 error when no surveySampleSiteId is provided', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.params = { - surveyId: '1' - }; - - try { - const requestHandler = updateSurveySampleSite(); - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required path param `surveySampleSiteId`'); - } - }); - - it('should throw a 400 error when no sampleSite is provided', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - mockReq.params = { - surveyId: '1', - surveySampleSiteId: '2' - }; - - try { - const requestHandler = updateSurveySampleSite(); - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required body param `sampleSite`'); - } - }); - it('should catch and re-throw an error if SampleLocationService throws an error', async () => { const dbConnectionObj = getMockDBConnection(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts index 4cdb8d8984..83be828906 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts @@ -2,8 +2,9 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; -import { HTTP400 } from '../../../../../../../errors/http-error'; +import { HTTP400, HTTP409 } from '../../../../../../../errors/http-error'; import { GeoJSONFeature } from '../../../../../../../openapi/schemas/geoJson'; +import { techniqueSimpleViewSchema } from '../../../../../../../openapi/schemas/technique'; import { UpdateSampleLocationRecord } from '../../../../../../../repositories/sample-location-repository'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { ObservationService } from '../../../../../../../services/observation-service'; @@ -17,7 +18,7 @@ export const PUT: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, @@ -99,21 +100,33 @@ PUT.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['method_lookup_id', 'description', 'sample_periods', 'method_response_metric_id'], + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'method_technique_id', + 'method_response_metric_id', + 'description', + 'sample_periods' + ], properties: { - survey_sample_site_id: { - type: 'integer', - nullable: true - }, survey_sample_method_id: { type: 'integer', + minimum: 1, nullable: true }, - method_lookup_id: { + survey_sample_site_id: { type: 'integer', minimum: 1, nullable: true }, + method_technique_id: { + type: 'integer', + minimum: 1 + }, + method_response_metric_id: { + type: 'integer', + minimum: 1 + }, description: { type: 'string' }, @@ -133,10 +146,6 @@ PUT.apiDoc = { type: 'integer', nullable: true }, - method_lookup_id: { - type: 'integer', - nullable: true - }, start_date: { type: 'string' }, @@ -153,10 +162,6 @@ PUT.apiDoc = { } } } - }, - method_response_metric_id: { - type: 'integer', - minimum: 1 } } } @@ -222,19 +227,8 @@ PUT.apiDoc = { export function updateSurveySampleSite(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required path param `surveyId`'); - } - - if (!req.params.surveySampleSiteId) { - throw new HTTP400('Missing required path param `surveySampleSiteId`'); - } - - if (!req.body.sampleSite) { - throw new HTTP400('Missing required body param `sampleSite`'); - } - const surveyId = Number(req.params.surveyId); + const connection = getDBConnection(req['keycloak_token']); try { @@ -268,7 +262,7 @@ export const DELETE: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, @@ -332,6 +326,9 @@ DELETE.apiDoc = { 403: { $ref: '#/components/responses/403' }, + 409: { + $ref: '#/components/responses/409' + }, 500: { $ref: '#/components/responses/500' }, @@ -361,7 +358,7 @@ export function deleteSurveySampleSiteRecord(): RequestHandler { ]); if (samplingSiteObservationsCount > 0) { - throw new HTTP400('Cannot delete a sample site that is associated with an observation'); + throw new HTTP409('Cannot delete a sample site that is associated with an observation'); } const sampleLocationService = new SampleLocationService(connection); @@ -440,7 +437,16 @@ GET.apiDoc = { schema: { type: 'object', additionalProperties: false, - required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geojson'], + required: [ + 'survey_sample_site_id', + 'survey_id', + 'name', + 'description', + 'geojson', + 'sample_methods', + 'blocks', + 'stratums' + ], properties: { survey_sample_site_id: { type: 'integer', @@ -466,13 +472,21 @@ GET.apiDoc = { required: [ 'survey_sample_method_id', 'survey_sample_site_id', - 'method_lookup_id', + 'technique', 'method_response_metric_id', 'sample_periods' ], items: { type: 'object', additionalProperties: false, + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'method_response_metric_id', + 'description', + 'sample_periods', + 'technique' + ], properties: { survey_sample_method_id: { type: 'integer', @@ -482,7 +496,7 @@ GET.apiDoc = { type: 'integer', minimum: 1 }, - method_lookup_id: { + method_response_metric_id: { type: 'integer', minimum: 1 }, @@ -529,7 +543,7 @@ GET.apiDoc = { } } }, - method_response_metric_id: { type: 'integer', minimum: 1 } + technique: techniqueSimpleViewSchema } } }, @@ -538,7 +552,13 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['survey_sample_block_id', 'survey_sample_site_id', 'survey_block_id'], + required: [ + 'survey_sample_block_id', + 'survey_sample_site_id', + 'survey_block_id', + 'name', + 'description' + ], properties: { survey_sample_block_id: { type: 'number' @@ -563,7 +583,13 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['survey_sample_stratum_id', 'survey_sample_site_id', 'survey_stratum_id'], + required: [ + 'survey_sample_stratum_id', + 'survey_sample_site_id', + 'survey_stratum_id', + 'name', + 'description' + ], properties: { survey_sample_stratum_id: { type: 'number' diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts index 61d6bd1a54..2a42958220 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.test.ts @@ -71,7 +71,7 @@ describe('getSurveySampleMethodRecords', () => { survey_sample_method_id: 1, survey_sample_site_id: 1, method_response_metric_id: 1, - method_lookup_id: 1, + method_technique_id: 1, description: 'desc', create_date: 'date', create_user: 1, @@ -141,7 +141,7 @@ describe('createSurveySampleSiteRecord', () => { const sampleMethod = { survey_sample_method_id: 1, survey_sample_site_id: 1, - method_lookup_id: 1, + method_technique_id: 1, description: 'desc', create_date: 'date', create_user: 1, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts index 2d5bc3c71f..3974cbd455 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts @@ -87,15 +87,34 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'method_technique_id', + 'method_response_metric_id', + 'description', + 'create_date', + 'create_user', + 'update_date', + 'update_user', + 'revision_count' + ], properties: { survey_sample_method_id: { - type: 'integer' + type: 'integer', + minimum: 1 }, survey_sample_site_id: { - type: 'integer' + type: 'integer', + minimum: 1 }, - method_lookup_id: { - type: 'integer' + method_technique_id: { + type: 'integer', + minimum: 1 + }, + method_response_metric_id: { + type: 'integer', + minimum: 1 }, description: { type: 'string' @@ -104,7 +123,8 @@ GET.apiDoc = { type: 'string' }, create_user: { - type: 'integer' + type: 'integer', + minimum: 1 }, update_date: { type: 'string', @@ -112,6 +132,7 @@ GET.apiDoc = { }, update_user: { type: 'integer', + minimum: 1, nullable: true }, revision_count: { @@ -184,7 +205,7 @@ export const POST: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts index 3004ab8d16..132f53ff6c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.test.ts @@ -90,7 +90,7 @@ describe('updateSurveySampleMethod', () => { mockReq.body = { sampleMethod: { - method_lookup_id: 1, + method_technique_id: 1, method_response_metric_id: 1, description: 'description' } @@ -122,7 +122,7 @@ describe('updateSurveySampleMethod', () => { }; const sampleMethod = { - method_lookup_id: 1, + method_technique_id: 1, description: 'description', survey_sample_method_id: 6, survey_sample_site_id: 9 diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.ts index 9621af8fd7..b6b173a2df 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.ts @@ -17,7 +17,7 @@ export const PUT: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, @@ -79,7 +79,7 @@ PUT.apiDoc = { type: 'object', additionalProperties: false, properties: { - method_lookup_id: { + method_technique_id: { type: 'integer' }, description: { @@ -161,7 +161,7 @@ export const DELETE: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/index.ts index 28919744e1..17e53873e5 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/index.ts @@ -192,7 +192,7 @@ export const POST: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/{surveySamplePeriodId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/{surveySamplePeriodId}.ts index 7283bdbd51..4bdaa19774 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/{surveySamplePeriodId}.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/{surveySamplePeriodId}.ts @@ -17,7 +17,7 @@ export const PUT: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, @@ -180,7 +180,7 @@ export const DELETE: Operation = [ return { or: [ { - validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR], + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], surveyId: Number(req.params.surveyId), discriminator: 'ProjectPermission' }, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.test.ts new file mode 100644 index 0000000000..a7c91d6fa9 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.test.ts @@ -0,0 +1,128 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../../database/db'; +import { HTTPError } from '../../../../../../errors/http-error'; +import { ObservationService } from '../../../../../../services/observation-service'; +import { TechniqueService } from '../../../../../../services/technique-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; +import { deleteSurveyTechniqueRecords } from './delete'; + +chai.use(sinonChai); + +describe('deleteSurveyTechniqueRecords', () => { + afterEach(() => { + sinon.restore(); + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const getObservationsCountByTechniqueIdsStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + .resolves(0); + + const deleteTechniqueStub = sinon + .stub(TechniqueService.prototype, 'deleteTechnique') + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + mockReq.body = { + methodTechniqueIds: [1, 2, 3] + }; + + const requestHandler = deleteSurveyTechniqueRecords(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnce; + expect(deleteTechniqueStub).to.have.been.calledOnce; + + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + + it('throws an error if any technique records are associated to an observation record', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const getObservationsCountByTechniqueIdsStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + .resolves(10); // technique records are associated to 10 observation records + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + mockReq.body = { + methodTechniqueIds: [1, 2, 3] + }; + + const requestHandler = deleteSurveyTechniqueRecords(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnce; + + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal( + 'Cannot delete a technique that is associated with an observation' + ); + expect((actualError as HTTPError).status).to.equal(409); + } + }); + + it('should delete technique records', async () => { + const mockDBConnection = getMockDBConnection({ commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const getObservationsCountByTechniqueIdsStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + .resolves(0); + + const deleteTechniqueStub = sinon.stub(TechniqueService.prototype, 'deleteTechnique').resolves(); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2' + }; + + mockReq.body = { + methodTechniqueIds: [1, 2, 3] + }; + + const requestHandler = deleteSurveyTechniqueRecords(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnce; + expect(deleteTechniqueStub).to.have.been.calledThrice; + + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect(mockRes.status).to.have.been.calledWith(204); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts new file mode 100644 index 0000000000..9a21fdf7b0 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts @@ -0,0 +1,146 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { HTTP409 } from '../../../../../../errors/http-error'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { ObservationService } from '../../../../../../services/observation-service'; +import { TechniqueService } from '../../../../../../services/technique-service'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/sample-site/delete'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteSurveyTechniqueRecords() +]; + +POST.apiDoc = { + description: 'Delete survey techniques.', + tags: ['survey'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + requestBody: { + description: 'Survey technique delete request object.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['methodTechniqueIds'], + properties: { + methodTechniqueIds: { + items: { + type: 'integer', + minimum: 1 + }, + minItems: 1, + description: 'An array of technique record IDs to delete.' + } + } + } + } + } + }, + responses: { + 204: { + description: 'Delete survey techniques OK' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 409: { + $ref: '#/components/responses/409' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function deleteSurveyTechniqueRecords(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const methodTechniqueIds = req.body.methodTechniqueIds as number[]; + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const observationService = new ObservationService(connection); + + const observationCount = await observationService.getObservationsCountByTechniqueIds( + surveyId, + methodTechniqueIds + ); + + if (observationCount > 0) { + throw new HTTP409('Cannot delete a technique that is associated with an observation'); + } + + const techniqueService = new TechniqueService(connection); + + // TODO Update to handle all deletes in one request rather than one at a time + for (const methodTechniqueId of methodTechniqueIds) { + await techniqueService.deleteTechnique(surveyId, methodTechniqueId); + } + + await connection.commit(); + + return res.status(204).send(); + } catch (error) { + defaultLog.error({ label: 'deleteSurveySampleSiteRecords', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts new file mode 100644 index 0000000000..c02f2a1b17 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts @@ -0,0 +1,268 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { createTechniques, getTechniques } from '.'; +import * as db from '../../../../../../database/db'; +import { HTTPError } from '../../../../../../errors/http-error'; +import { TechniqueObject } from '../../../../../../repositories/technique-repository'; +import { TechniqueService } from '../../../../../../services/technique-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('createTechniques', () => { + afterEach(() => { + sinon.restore(); + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + mockReq.body = { + techniques: [ + { + name: 'name', + description: 'description', + method_lookup_id: 33, + distance_threshold: 200, + attributes: { + quantitative_attributes: [ + { + method_technique_attribute_quantitative_id: 44, + method_lookup_attribute_quantitative_id: 55, + value: 66 + } + ], + qualitative_attributes: [ + { + method_technique_attribute_qualitative_id: 77, + method_lookup_attribute_qualitative_id: 88, + method_lookup_attribute_qualitative_option_id: 99 + } + ] + }, + attractants: [ + { + attractant_lookup_id: 111 + } + ] + } + ] + }; + + const insertTechniquesForSurveyStub = sinon + .stub(TechniqueService.prototype, 'insertTechniquesForSurvey') + .rejects(new Error('a test error')); + + const requestHandler = createTechniques(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(insertTechniquesForSurveyStub).to.have.been.calledOnceWith(1, mockReq.body.techniques); + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + + it('creates a new technique record', async () => { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + mockReq.body = { + techniques: [ + { + name: 'name', + description: 'description', + method_lookup_id: 33, + distance_threshold: 200, + attributes: { + quantitative_attributes: [ + { + method_technique_attribute_quantitative_id: 44, + method_lookup_attribute_quantitative_id: 55, + value: 66 + } + ], + qualitative_attributes: [ + { + method_technique_attribute_qualitative_id: 77, + method_lookup_attribute_qualitative_id: 88, + method_lookup_attribute_qualitative_option_id: 99 + } + ] + }, + attractants: [ + { + attractant_lookup_id: 111 + } + ] + } + ] + }; + + const insertTechniquesForSurveyStub = sinon + .stub(TechniqueService.prototype, 'insertTechniquesForSurvey') + .resolves([{ method_technique_id: 11 }]); + + const requestHandler = createTechniques(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(insertTechniquesForSurveyStub).to.have.been.calledOnceWith(1, mockReq.body.techniques); + }); +}); + +describe('getTechniques', () => { + afterEach(() => { + sinon.restore(); + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + const techniqueRecord: TechniqueObject = { + method_technique_id: 11, + name: 'name', + description: 'description', + distance_threshold: 200, + method_lookup_id: 33, + attractants: [ + { + attractant_lookup_id: 111 + } + ], + attributes: { + quantitative_attributes: [ + { + method_technique_attribute_quantitative_id: 44, + method_lookup_attribute_quantitative_id: '123-456-55', + value: 66 + } + ], + qualitative_attributes: [ + { + method_technique_attribute_qualitative_id: 77, + method_lookup_attribute_qualitative_id: '123-456-88', + method_lookup_attribute_qualitative_option_id: '123-456-99' + } + ] + } + }; + + const getTechniquesForSurveyIdStub = sinon + .stub(TechniqueService.prototype, 'getTechniquesForSurveyId') + .resolves([techniqueRecord]); + + const getTechniquesCountForSurveyIdStub = sinon + .stub(TechniqueService.prototype, 'getTechniquesCountForSurveyId') + .rejects(new Error('a test error')); + + const requestHandler = getTechniques(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(getTechniquesForSurveyIdStub).to.have.been.calledOnceWith(1, undefined); + expect(getTechniquesCountForSurveyIdStub).to.have.been.calledOnceWith(1); + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + + it('returns technique records', async () => { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + const techniqueRecord: TechniqueObject = { + method_technique_id: 11, + name: 'name', + description: 'description', + distance_threshold: 200, + method_lookup_id: 33, + attractants: [ + { + attractant_lookup_id: 111 + } + ], + attributes: { + quantitative_attributes: [ + { + method_technique_attribute_quantitative_id: 44, + method_lookup_attribute_quantitative_id: '123-456-55', + value: 66 + } + ], + qualitative_attributes: [ + { + method_technique_attribute_qualitative_id: 77, + method_lookup_attribute_qualitative_id: '123-456-88', + method_lookup_attribute_qualitative_option_id: '123-456-99' + } + ] + } + }; + + const getTechniquesForSurveyIdStub = sinon + .stub(TechniqueService.prototype, 'getTechniquesForSurveyId') + .resolves([techniqueRecord]); + + const getTechniquesCountForSurveyIdStub = sinon + .stub(TechniqueService.prototype, 'getTechniquesCountForSurveyId') + .resolves(1); + + const requestHandler = getTechniques(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getTechniquesForSurveyIdStub).to.have.been.calledOnceWith(1, undefined); + expect(getTechniquesCountForSurveyIdStub).to.have.been.calledOnceWith(1); + + expect(mockRes.jsonValue).to.eql({ + techniques: [techniqueRecord], + count: 1, + pagination: { + total: 1, + per_page: 1, + current_page: 1, + last_page: 1, + sort: undefined, + order: undefined + } + }); + expect(mockRes.statusValue).to.eql(200); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts new file mode 100644 index 0000000000..fd664d79c3 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts @@ -0,0 +1,273 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { HTTP400 } from '../../../../../../errors/http-error'; +import { + paginationRequestQueryParamSchema, + paginationResponseSchema +} from '../../../../../../openapi/schemas/pagination'; +import { techniqueCreateSchema, techniqueViewSchema } from '../../../../../../openapi/schemas/technique'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { TechniqueService } from '../../../../../../services/technique-service'; +import { getLogger } from '../../../../../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../../../../utils/pagination'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/technique/index'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + createTechniques() +]; + +POST.apiDoc = { + description: 'Insert a new technique for a survey.', + tags: ['technique'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + } + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['techniques'], + additionalProperties: false, + properties: { + techniques: { + type: 'array', + items: techniqueCreateSchema + } + } + } + } + } + }, + responses: { + 201: { + description: 'Technique created OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Create new techniques for a survey. + * + * @returns + */ +export function createTechniques(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const surveyId = Number(req.params.surveyId); + + const techniqueService = new TechniqueService(connection); + await techniqueService.insertTechniquesForSurvey(surveyId, req.body.techniques); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'createTechniques', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getTechniques() +]; + +GET.apiDoc = { + description: 'Get all techniques for a survey.', + tags: ['technique'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'List of survey techniques.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['techniques', 'count'], + additionalProperties: false, + properties: { + techniques: { + type: 'array', + items: techniqueViewSchema + }, + count: { + type: 'number', + description: 'Count of method techniques in the respective survey.' + }, + pagination: { ...paginationResponseSchema } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get all techniques for a survey. + * + * @returns {RequestHandler} + */ +export function getTechniques(): RequestHandler { + return async (req, res) => { + if (!req.params.surveyId) { + throw new HTTP400('Missing required param `surveyId`'); + } + + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const surveyId = Number(req.params.surveyId); + const paginationOptions = makePaginationOptionsFromRequest(req); + + const techniqueService = new TechniqueService(connection); + + const [techniques, techniquesCount] = await Promise.all([ + techniqueService.getTechniquesForSurveyId(surveyId, ensureCompletePaginationOptions(paginationOptions)), + techniqueService.getTechniquesCountForSurveyId(surveyId) + ]); + + await connection.commit(); + + return res.status(200).json({ + techniques, + count: techniquesCount, + pagination: makePaginationResponse(techniquesCount, paginationOptions) + }); + } catch (error) { + defaultLog.error({ label: 'getSurveyTechniques', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.test.ts new file mode 100644 index 0000000000..6cd5ec809b --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.test.ts @@ -0,0 +1,273 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { deleteTechnique, updateTechnique } from '.'; +import * as db from '../../../../../../../database/db'; +import { HTTPError } from '../../../../../../../errors/http-error'; +import { AttractantService } from '../../../../../../../services/attractants-service'; +import { ObservationService } from '../../../../../../../services/observation-service'; +import { TechniqueAttributeService } from '../../../../../../../services/technique-attributes-service'; +import { TechniqueService } from '../../../../../../../services/technique-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('deleteTechnique', () => { + afterEach(() => { + sinon.restore(); + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + techniqueId: '3' + }; + + const getObservationsCountByTechniqueIdsStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + .resolves(0); + + const deleteTechniqueStub = sinon + .stub(TechniqueService.prototype, 'deleteTechnique') + .rejects(new Error('a test error')); // throw error + + const requestHandler = deleteTechnique(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnceWith(2, [3]); + expect(deleteTechniqueStub).to.have.been.calledOnceWith(2, 3); + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + + it('throws an error if any technique records are associated to an observation record', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + techniqueId: '3' + }; + + const getObservationsCountByTechniqueIdsStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + .resolves(10); // technique records are associated to 10 observation records + + const requestHandler = deleteTechnique(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnce; + + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal( + 'Cannot delete a technique that is associated with an observation' + ); + expect((actualError as HTTPError).status).to.equal(409); + } + }); + + it('deletes a technique record', async () => { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + techniqueId: '3' + }; + + const getObservationsCountByTechniqueIdsStub = sinon + .stub(ObservationService.prototype, 'getObservationsCountByTechniqueIds') + .resolves(0); + + const deleteTechniqueStub = sinon.stub(TechniqueService.prototype, 'deleteTechnique').resolves(); + + const requestHandler = deleteTechnique(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getObservationsCountByTechniqueIdsStub).to.have.been.calledOnceWith(2, [3]); + expect(deleteTechniqueStub).to.have.been.calledOnceWith(2, 3); + + expect(mockRes.statusValue).to.eql(200); + }); +}); + +describe('updateTechnique', () => { + afterEach(() => { + sinon.restore(); + }); + + it('catches and re-throws error', async () => { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + techniqueId: '3' + }; + + const requestBody = { + technique: { + method_technique_id: 444, + name: 'name', + description: 'description', + method_lookup_id: 33, + distance_threshold: 200, + attributes: { + quantitative_attributes: [ + { + method_technique_attribute_quantitative_id: 44, + method_lookup_attribute_quantitative_id: 55, + value: 66 + } + ], + qualitative_attributes: [ + { + method_technique_attribute_qualitative_id: 77, + method_lookup_attribute_qualitative_id: 88, + method_lookup_attribute_qualitative_option_id: 99 + } + ] + }, + attractants: [ + { + attractant_lookup_id: 111 + } + ] + } + }; + + mockReq.body = requestBody; + + const updateTechniqueStub = sinon + .stub(TechniqueService.prototype, 'updateTechnique') + .rejects(new Error('a test error')); // throw error + + const requestHandler = updateTechnique(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(updateTechniqueStub).to.have.been.calledOnceWith(2, { + method_technique_id: 444, + name: 'name', + description: 'description', + method_lookup_id: 33, + distance_threshold: 200 + }); + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + + it('updates a technique record', async () => { + const mockDBConnection = getMockDBConnection(); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + techniqueId: '3' + }; + + const requestBody = { + technique: { + method_technique_id: 444, + name: 'name', + description: 'description', + method_lookup_id: 33, + distance_threshold: 200, + attributes: { + quantitative_attributes: [ + { + method_technique_attribute_quantitative_id: 44, + method_lookup_attribute_quantitative_id: 55, + value: 66 + } + ], + qualitative_attributes: [ + { + method_technique_attribute_qualitative_id: 77, + method_lookup_attribute_qualitative_id: 88, + method_lookup_attribute_qualitative_option_id: 99 + } + ] + }, + attractants: [ + { + attractant_lookup_id: 111 + } + ] + } + }; + + mockReq.body = requestBody; + + const updateTechniqueStub = sinon.stub(TechniqueService.prototype, 'updateTechnique').resolves(); + + const updateTechniqueAttractantsStub = sinon + .stub(AttractantService.prototype, 'updateTechniqueAttractants') + .resolves(); + + const updateQualitativeAttributesForTechniqueStub = sinon + .stub(TechniqueAttributeService.prototype, 'insertUpdateDeleteQualitativeAttributesForTechnique') + .resolves(); + + const updateQuantitativeAttributesForTechniqueStub = sinon + .stub(TechniqueAttributeService.prototype, 'insertUpdateDeleteQuantitativeAttributesForTechnique') + .resolves(); + + const requestHandler = updateTechnique(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(updateTechniqueStub).to.have.been.calledOnceWith(2, { + method_technique_id: 444, + name: 'name', + description: 'description', + method_lookup_id: 33, + distance_threshold: 200 + }); + expect(updateTechniqueAttractantsStub).to.have.been.calledOnceWith(2, 3, requestBody.technique.attractants); + expect(updateQualitativeAttributesForTechniqueStub).to.have.been.calledOnceWith( + 2, + 3, + requestBody.technique.attributes.qualitative_attributes + ); + expect(updateQuantitativeAttributesForTechniqueStub).to.have.been.calledOnceWith( + 2, + 3, + requestBody.technique.attributes.quantitative_attributes + ); + + expect(mockRes.statusValue).to.eql(200); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts new file mode 100644 index 0000000000..11249e0e30 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts @@ -0,0 +1,408 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../database/db'; +import { HTTP409 } from '../../../../../../../errors/http-error'; +import { techniqueUpdateSchema, techniqueViewSchema } from '../../../../../../../openapi/schemas/technique'; +import { ITechniquePutData } from '../../../../../../../repositories/technique-repository'; +import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; +import { AttractantService } from '../../../../../../../services/attractants-service'; +import { ObservationService } from '../../../../../../../services/observation-service'; +import { TechniqueAttributeService } from '../../../../../../../services/technique-attributes-service'; +import { TechniqueService } from '../../../../../../../services/technique-service'; +import { getLogger } from '../../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index'); + +export const DELETE: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteTechnique() +]; + +DELETE.apiDoc = { + description: 'Delete a technique from a Survey.', + tags: ['technique'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'techniqueId', + schema: { + type: 'integer', + minimum: 1 + }, + description: 'A method technique ID', + required: true + } + ], + responses: { + 200: { + description: 'Delete technique OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 409: { + $ref: '#/components/responses/409' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Delete a technique from a Survey + * + * @returns {RequestHandler} + */ +export function deleteTechnique(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const methodTechniqueId = Number(req.params.techniqueId); + const surveyId = Number(req.params.surveyId); + + const observationService = new ObservationService(connection); + + const observationCount = await observationService.getObservationsCountByTechniqueIds(surveyId, [ + methodTechniqueId + ]); + + if (observationCount > 0) { + throw new HTTP409('Cannot delete a technique that is associated with an observation'); + } + + const techniqueService = new TechniqueService(connection); + + await techniqueService.deleteTechnique(surveyId, methodTechniqueId); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'getSurveyTechniques', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const PUT: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + updateTechnique() +]; + +PUT.apiDoc = { + description: 'Update a technique', + tags: ['technique'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'techniqueId', + schema: { + type: 'integer', + minimum: 1 + }, + description: 'An array of method technique IDs', + required: true + } + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['technique'], + additionalProperties: false, + properties: { + technique: techniqueUpdateSchema + } + } + } + } + }, + responses: { + 200: { + description: 'Technique updated OK.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Update a technique, including its attributes and attractants. + * + * @export + * @return {*} {RequestHandler} + */ +export function updateTechnique(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const surveyId = Number(req.params.surveyId); + const methodTechniqueId = Number(req.params.techniqueId); + + const technique: ITechniquePutData = req.body.technique; + + const { attributes, attractants, ...techniqueRow } = technique; + + // Update the technique record + const techniqueService = new TechniqueService(connection); + await techniqueService.updateTechnique(surveyId, techniqueRow); + + // Update the technique's attributes and attractants + const attractantsService = new AttractantService(connection); + const techniqueAttributeService = new TechniqueAttributeService(connection); + await Promise.all([ + // Update attractants + attractantsService.updateTechniqueAttractants(surveyId, methodTechniqueId, attractants), + // Update qualitative attributes + techniqueAttributeService.insertUpdateDeleteQualitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + attributes.qualitative_attributes + ), + // Update quantitative attributes + techniqueAttributeService.insertUpdateDeleteQuantitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + attributes.quantitative_attributes + ) + ]); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'updateTechnique', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getTechniqueById() +]; + +GET.apiDoc = { + description: 'Get a technique by ID.', + tags: ['survey'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'techniqueId', + schema: { + type: 'integer', + minimum: 1 + }, + description: 'An array of method technique IDs', + required: true + } + ], + responses: { + 200: { + description: 'A survey sample site', + content: { + 'application/json': { + schema: techniqueViewSchema + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get a single technique by Id. + * + * @returns {RequestHandler} + */ +export function getTechniqueById(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + + try { + await connection.open(); + + const surveyId = Number(req.params.surveyId); + const methodTechniqueId = Number(req.params.techniqueId); + + const techniqueService = new TechniqueService(connection); + const sampleSite = await techniqueService.getTechniqueById(surveyId, methodTechniqueId); + + await connection.commit(); + + return res.status(200).json(sampleSite); + } catch (error) { + defaultLog.error({ label: 'getTechniqueById', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/reference/get/technique-attribute.ts b/api/src/paths/reference/get/technique-attribute.ts new file mode 100644 index 0000000000..67119bf603 --- /dev/null +++ b/api/src/paths/reference/get/technique-attribute.ts @@ -0,0 +1,174 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getAPIUserDBConnection } from '../../../database/db'; +import { TechniqueAttributeService } from '../../../services/technique-attributes-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/reference'); + +export const GET: Operation = [getTechniqueAttributes()]; + +GET.apiDoc = { + description: 'Find technique attributes', + tags: ['reference'], + parameters: [ + { + in: 'query', + name: 'methodLookupId', + schema: { + type: 'array', + items: { + type: 'string' + }, + minItems: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Attribute techniques for a method lookup id.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + method_lookup_id: { + type: 'integer', + minimum: 1 + }, + quantitative_attributes: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['method_lookup_attribute_quantitative_id', 'name', 'description', 'min', 'max', 'unit'], + properties: { + method_lookup_attribute_quantitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + min: { + type: 'integer', + nullable: true + }, + max: { + type: 'integer', + nullable: true + }, + unit: { + type: 'string', + nullable: true + } + } + } + }, + qualitative_attributes: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['method_lookup_attribute_qualitative_id', 'name', 'description', 'options'], + properties: { + method_lookup_attribute_qualitative_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + options: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['method_lookup_attribute_qualitative_option_id', 'name', 'description'], + properties: { + method_lookup_attribute_qualitative_option_id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + } + } + } + } + } + } + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get all technique attributes for multiple method lookup ids. + * + * @returns {RequestHandler} + */ +export function getTechniqueAttributes(): RequestHandler { + return async (req, res) => { + const connection = getAPIUserDBConnection(); + + try { + const methodLookupIds: number[] = (req.query.methodLookupId as string[]).map(Number); + + await connection.open(); + + const techniqueAttributeService = new TechniqueAttributeService(connection); + + const response = await techniqueAttributeService.getAttributeDefinitionsByMethodLookupIds(methodLookupIds); + + // Allow browsers to cache this response for 300 seconds (5 minutes) + res.setHeader('Cache-Control', 'private, max-age=300'); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getTechniqueAttributes', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/reference/search/environment.ts b/api/src/paths/reference/search/environment.ts index 11a563f3ea..b8c470e839 100644 --- a/api/src/paths/reference/search/environment.ts +++ b/api/src/paths/reference/search/environment.ts @@ -4,7 +4,7 @@ import { getAPIUserDBConnection } from '../../../database/db'; import { CodeService } from '../../../services/code-service'; import { getLogger } from '../../../utils/logger'; -const defaultLog = getLogger('paths/code'); +const defaultLog = getLogger('paths/reference'); export const GET: Operation = [findSubcountEnvironments()]; @@ -152,6 +152,9 @@ export function findSubcountEnvironments(): RequestHandler { await connection.commit(); + // Allow browsers to cache this response for 300 seconds (5 minutes) + res.setHeader('Cache-Control', 'private, max-age=300'); + return res.status(200).json(response); } catch (error) { defaultLog.error({ label: 'findSubcountEnvironments', message: 'error', error }); diff --git a/api/src/repositories/attractants-repository.ts b/api/src/repositories/attractants-repository.ts new file mode 100644 index 0000000000..8820a6a81c --- /dev/null +++ b/api/src/repositories/attractants-repository.ts @@ -0,0 +1,160 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { BaseRepository } from './base-repository'; + +export const AttractantRecord = z.object({ + method_technique_attractant_id: z.number(), + method_technique_id: z.number(), + attractant_lookup_id: z.number() +}); + +export type AttractantRecord = z.infer; + +export interface IAttractantPostData { + attractant_lookup_id: number; +} + +/** + * Attractant repository. + * + * Handles all database operations related to technique attractants. + * + * @export + * @class AttractantRepository + * @extends {BaseRepository} + */ +export class AttractantRepository extends BaseRepository { + /** + * Get attractants for a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise} + * @memberof AttractantRepository + */ + async getAttractantsByTechniqueId(surveyId: number, methodTechniqueId: number): Promise { + const sqlStatement = SQL` + SELECT + method_technique_attractant.method_technique_attractant_id, + method_technique_attractant.method_technique_id, + method_technique_attractant.attractant_lookup_id + FROM + method_technique_attractant + INNER JOIN + method_technique + ON method_technique.method_technique_id = method_technique_attractant.method_technique_id + WHERE + method_technique_attractant.method_technique_id = ${methodTechniqueId} + AND + method_technique.survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, AttractantRecord); + + return response.rows; + } + + /** + * Create attractant records for a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @param {IAttractantPostData[]} attractants + * @return {*} {Promise<{ method_technique_attractant_id: number }[]>} + * @memberof AttractantRepository + */ + async insertAttractantsForTechnique( + surveyId: number, + methodTechniqueId: number, + attractants: IAttractantPostData[] + ): Promise<{ method_technique_attractant_id: number }[]> { + if (!attractants.length) { + return []; + } + + const queryBuilder = getKnex() + .insert( + attractants.map((attractant) => ({ + attractant_lookup_id: attractant.attractant_lookup_id, + method_technique_id: methodTechniqueId + })) + ) + .into('method_technique_attractant') + .innerJoin( + 'method_technique', + 'method_technique.method_technique_id', + 'method_technique_attractant.method_technique_id' + ) + .where('method_technique.survey_id', surveyId) + .returning('method_technique_attractant_id'); + + const response = await this.connection.knex(queryBuilder, z.object({ method_technique_attractant_id: z.number() })); + + return response.rows; + } + + /** + * Delete technique attractants. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @param {number[]} attractantLookupIds + * @return {*} {Promise} + * @memberof AttractantRepository + */ + async deleteTechniqueAttractants( + surveyId: number, + methodTechniqueId: number, + attractantLookupIds: number[] + ): Promise { + if (attractantLookupIds.length > 0) { + const queryBuilder = getKnex() + .delete() + .table('method_technique_attractant') + .join( + 'method_technique', + 'method_technique.method_technique_id', + 'method_technique_attractant.method_technique_id' + ) + .whereIn('method_technique_attractant.attractant_lookup_id', attractantLookupIds) + .andWhere('method_technique_attractant.method_technique_id', methodTechniqueId) + .andWhere('method_technique.survey_id', surveyId); + + const response = await this.connection.knex(queryBuilder); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to delete technique', [ + 'AttractantRepository->deleteTechnique', + 'rows was null or undefined, expected rows != null' + ]); + } + } + } + + /** + * Delete all technique attractants of a survey. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise} + * @memberof AttractantRepository + */ + async deleteAllTechniqueAttractants(surveyId: number, methodTechniqueId: number): Promise { + const sqlStatement = SQL` + DELETE FROM + method_technique_attractant mta + USING + method_technique mt + WHERE + mt.method_technique_id = mta.method_technique_id + AND + mta.method_technique_id = ${methodTechniqueId} + AND + mt.survey_id = ${surveyId}; + `; + + await this.connection.sql(sqlStatement); + } +} diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 8b3894ede8..27e5decc98 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -20,6 +20,7 @@ const IntendedOutcomeCode = ICode.extend({ description: z.string() }); const SampleMethodsCode = ICode.extend({ description: z.string() }); const SurveyProgressCode = ICode.extend({ description: z.string() }); const MethodResponseMetricsCode = ICode.extend({ description: z.string() }); +const AttractantCode = ICode.extend({ description: z.string() }); export const IAllCodeSets = z.object({ management_action_type: CodeSet(), @@ -40,7 +41,8 @@ export const IAllCodeSets = z.object({ site_selection_strategies: CodeSet(), sample_methods: CodeSet(SampleMethodsCode.shape), survey_progress: CodeSet(SurveyProgressCode.shape), - method_response_metrics: CodeSet(MethodResponseMetricsCode.shape) + method_response_metrics: CodeSet(MethodResponseMetricsCode.shape), + attractants: CodeSet(AttractantCode.shape) }); export type IAllCodeSets = z.infer; @@ -419,7 +421,7 @@ export class CodeRepository extends BaseRepository { } /** - * Fetch method response metrics + * Fetch method response metrics codes. * * @return {*} * @memberof CodeRepository @@ -438,4 +440,25 @@ export class CodeRepository extends BaseRepository { return response.rows; } + + /** + * Fetch attractants codes. + * + * @return {*} + * @memberof CodeRepository + */ + async getAttractants() { + const sqlStatement = SQL` + SELECT + attractant_lookup_id AS id, + name, + description + FROM attractant_lookup + WHERE record_end_date IS null; + `; + + const response = await this.connection.sql(sqlStatement, AttractantCode); + + return response.rows; + } } diff --git a/api/src/repositories/observation-repository/observation-repository.ts b/api/src/repositories/observation-repository/observation-repository.ts index f9ad81ce6a..80a0db0611 100644 --- a/api/src/repositories/observation-repository/observation-repository.ts +++ b/api/src/repositories/observation-repository/observation-repository.ts @@ -651,4 +651,37 @@ export class ObservationRepository extends BaseRepository { return response.rows[0].count; } + + /** + * Retrieves observation records count for the given survey and method technique ids. + * + * @param {number[]} methodTechniqueIds + * @return {*} {Promise} + * @memberof ObservationRepository + */ + async getObservationsCountByTechniqueIds(surveyId: number, methodTechniqueIds: number[]): Promise { + const knex = getKnex(); + const sqlStatement = knex + .queryBuilder() + .select(knex.raw('COUNT(survey_observation_id)::integer as count')) + .from('survey_observation') + .innerJoin( + 'survey_sample_method', + 'survey_observation.survey_sample_method_id', + 'survey_sample_method.survey_sample_method_id' + ) + .where('survey_observation.survey_id', surveyId) + .whereIn('survey_sample_method.method_technique_id', methodTechniqueIds); + + const response = await this.connection.knex(sqlStatement, z.object({ count: z.number() })); + + if (response?.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to get observations count', [ + 'ObservationRepository->getObservationsCountBySampleTechniqueId', + 'response.rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].count; + } } diff --git a/api/src/repositories/observation-repository/utils.ts b/api/src/repositories/observation-repository/utils.ts index 4ac32f7614..09cb61988d 100644 --- a/api/src/repositories/observation-repository/utils.ts +++ b/api/src/repositories/observation-repository/utils.ts @@ -112,10 +112,14 @@ export function getSurveyObservationsBaseQuery( .select( 'survey_sample_method.survey_sample_site_id', 'survey_sample_method.survey_sample_method_id', - 'method_lookup.name as survey_sample_method_name' + 'method_technique.name as survey_sample_method_name' ) .from('survey_sample_method') - .innerJoin('method_lookup', 'survey_sample_method.method_lookup_id', 'method_lookup.method_lookup_id') + .innerJoin( + 'method_technique', + 'survey_sample_method.method_technique_id', + 'method_technique.method_technique_id' + ) .innerJoin( 'w_survey_sample_site', 'survey_sample_method.survey_sample_site_id', diff --git a/api/src/repositories/sample-location-repository.ts b/api/src/repositories/sample-location-repository.ts index a5cf3c23bf..e8731c44bd 100644 --- a/api/src/repositories/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository.ts @@ -25,11 +25,20 @@ export const SampleLocationRecord = z.object({ SampleMethodRecord.pick({ survey_sample_method_id: true, survey_sample_site_id: true, - method_lookup_id: true, description: true, method_response_metric_id: true }).extend( z.object({ + technique: z.object({ + method_technique_id: z.number(), + name: z.string(), + description: z.string().nullable(), + attractants: z.array( + z.object({ + attractant_lookup_id: z.number() + }) + ) + }), sample_periods: z.array( SamplePeriodRecord.pick({ survey_sample_period_id: true, @@ -134,6 +143,33 @@ export class SampleLocationRepository extends BaseRepository { const knex = getKnex(); const queryBuilder = knex .queryBuilder() + .with('w_method_technique_attractant', (qb) => { + // Gather technique attractants + qb.select( + 'mta.method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'attractant_lookup_id', mta.attractant_lookup_id + )) as attractants`) + ) + .from({ mta: 'method_technique_attractant' }) + .groupBy('mta.method_technique_id'); + }) + .with('w_method_technique', (qb) => { + // Gather method techniques + qb.select( + 'mt.method_technique_id', + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name, + 'description', mt.description, + 'attractants', COALESCE(wmta.attractants, '[]'::json) + ) as method_technique`) + ) + .from({ mt: 'method_technique' }) + .leftJoin('w_method_technique_attractant as wmta', 'wmta.method_technique_id', 'mt.method_technique_id'); + }) .with('w_survey_sample_period', (qb) => { // Aggregate sample periods into an array of objects qb.select( @@ -146,8 +182,7 @@ export class SampleLocationRepository extends BaseRepository { 'start_time', ssp.start_time, 'end_date', ssp.end_date, 'end_time', ssp.end_time - ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods - `) + ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods`) ) .from({ ssp: 'survey_sample_period' }) .groupBy('ssp.survey_sample_method_id'); @@ -160,14 +195,15 @@ export class SampleLocationRepository extends BaseRepository { json_agg(json_build_object( 'survey_sample_method_id', ssm.survey_sample_method_id, 'survey_sample_site_id', ssm.survey_sample_site_id, - 'method_lookup_id', ssm.method_lookup_id, 'description', ssm.description, 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), + 'technique', wmt.method_technique, 'method_response_metric_id', ssm.method_response_metric_id )) as sample_methods`) ) .from({ ssm: 'survey_sample_method' }) .leftJoin('w_survey_sample_period as wssp', 'wssp.survey_sample_method_id', 'ssm.survey_sample_method_id') + .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id') .groupBy('ssm.survey_sample_site_id'); }) .with('w_survey_sample_block', (qb) => { @@ -211,9 +247,10 @@ export class SampleLocationRepository extends BaseRepository { 'sss.name', 'sss.description', 'sss.geojson', - knex.raw(`COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, - COALESCE(wssb.blocks, '[]'::json) as blocks, - COALESCE(wssst.stratums, '[]'::json) as stratums`) + knex.raw(` + COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) ) .from({ sss: 'survey_sample_site' }) .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') @@ -307,6 +344,33 @@ export class SampleLocationRepository extends BaseRepository { const knex = getKnex(); const queryBuilder = knex .queryBuilder() + .with('w_method_technique_attractant', (qb) => { + // Gather technique attractants + qb.select( + 'mta.method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'attractant_lookup_id', mta.attractant_lookup_id + )) as attractants`) + ) + .from({ mta: 'method_technique_attractant' }) + .groupBy('mta.method_technique_id'); + }) + .with('w_method_technique', (qb) => { + // Gather method techniques + qb.select( + 'mt.method_technique_id', + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name, + 'description', mt.description, + 'attractants', COALESCE(wmta.attractants, '[]'::json) + ) as method_technique`) + ) + .from({ mt: 'method_technique' }) + .leftJoin('w_method_technique_attractant as wmta', 'wmta.method_technique_id', 'mt.method_technique_id'); + }) .with('w_survey_sample_period', (qb) => { // Aggregate sample periods into an array of objects qb.select( @@ -319,8 +383,7 @@ export class SampleLocationRepository extends BaseRepository { 'start_time', ssp.start_time, 'end_date', ssp.end_date, 'end_time', ssp.end_time - )) as sample_periods - `) + ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods`) ) .from({ ssp: 'survey_sample_period' }) .groupBy('ssp.survey_sample_method_id'); @@ -333,7 +396,8 @@ export class SampleLocationRepository extends BaseRepository { json_agg(json_build_object( 'survey_sample_method_id', ssm.survey_sample_method_id, 'survey_sample_site_id', ssm.survey_sample_site_id, - 'method_lookup_id', ssm.method_lookup_id, + + 'technique', wmt.method_technique, 'description', ssm.description, 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), 'method_response_metric_id', ssm.method_response_metric_id @@ -341,6 +405,7 @@ export class SampleLocationRepository extends BaseRepository { ) .from({ ssm: 'survey_sample_method' }) .leftJoin('w_survey_sample_period as wssp', 'wssp.survey_sample_method_id', 'ssm.survey_sample_method_id') + .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id') .groupBy('ssm.survey_sample_site_id'); }) .with('w_survey_sample_block', (qb) => { @@ -384,9 +449,10 @@ export class SampleLocationRepository extends BaseRepository { 'sss.name', 'sss.description', 'sss.geojson', - knex.raw(`COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, - COALESCE(wssb.blocks, '[]'::json) as blocks, - COALESCE(wssst.stratums, '[]'::json) as stratums`) + knex.raw(` + COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) ) .from({ sss: 'survey_sample_site' }) .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') diff --git a/api/src/repositories/sample-method-repository.test.ts b/api/src/repositories/sample-method-repository.test.ts index dc65d2736c..d95ead8b13 100644 --- a/api/src/repositories/sample-method-repository.test.ts +++ b/api/src/repositories/sample-method-repository.test.ts @@ -55,7 +55,7 @@ describe('SampleMethodRepository', () => { survey_sample_method_id: 1, survey_sample_site_id: 2, method_response_metric_id: 1, - method_lookup_id: 3, + method_technique_id: 3, description: 'description', sample_periods: [ { @@ -91,7 +91,7 @@ describe('SampleMethodRepository', () => { const sampleMethod: UpdateSampleMethodRecord = { survey_sample_method_id: 1, survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', sample_periods: [ @@ -132,7 +132,7 @@ describe('SampleMethodRepository', () => { const sampleMethod: InsertSampleMethodRecord = { survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', sample_periods: [ @@ -166,7 +166,7 @@ describe('SampleMethodRepository', () => { const sampleMethod: InsertSampleMethodRecord = { survey_sample_site_id: 2, method_response_metric_id: 1, - method_lookup_id: 3, + method_technique_id: 3, description: 'description', sample_periods: [ { diff --git a/api/src/repositories/sample-method-repository.ts b/api/src/repositories/sample-method-repository.ts index 5b4e6ef635..608e0ed177 100644 --- a/api/src/repositories/sample-method-repository.ts +++ b/api/src/repositories/sample-method-repository.ts @@ -9,7 +9,7 @@ import { InsertSamplePeriodRecord, UpdateSamplePeriodRecord } from './sample-per */ export type InsertSampleMethodRecord = Pick< SampleMethodRecord, - 'survey_sample_site_id' | 'method_lookup_id' | 'description' | 'method_response_metric_id' + 'survey_sample_site_id' | 'method_technique_id' | 'description' | 'method_response_metric_id' > & { sample_periods: InsertSamplePeriodRecord[] }; /** @@ -17,7 +17,11 @@ export type InsertSampleMethodRecord = Pick< */ export type UpdateSampleMethodRecord = Pick< SampleMethodRecord, - 'survey_sample_method_id' | 'survey_sample_site_id' | 'method_lookup_id' | 'description' | 'method_response_metric_id' + | 'survey_sample_method_id' + | 'survey_sample_site_id' + | 'method_technique_id' + | 'description' + | 'method_response_metric_id' > & { sample_periods: UpdateSamplePeriodRecord[] }; /** @@ -26,7 +30,7 @@ export type UpdateSampleMethodRecord = Pick< export const SampleMethodRecord = z.object({ survey_sample_method_id: z.number(), survey_sample_site_id: z.number(), - method_lookup_id: z.number(), + method_technique_id: z.number(), method_response_metric_id: z.number(), description: z.string(), create_date: z.string(), @@ -37,6 +41,18 @@ export const SampleMethodRecord = z.object({ }); export type SampleMethodRecord = z.infer; +/** + * A survey_sample_method detail object. + */ +export const SampleMethodDetails = SampleMethodRecord.extend({ + technique: z.object({ + method_technique_id: z.number(), + name: z.string(), + description: z.string().nullable() + }) +}); +export type SampleMethodDetails = z.infer; + /** * Sample Method Repository * @@ -93,7 +109,7 @@ export class SampleMethodRepository extends BaseRepository { UPDATE survey_sample_method ssm SET survey_sample_site_id = ${sampleMethod.survey_sample_site_id}, - method_lookup_id = ${sampleMethod.method_lookup_id}, + method_technique_id = ${sampleMethod.method_technique_id}, description = ${sampleMethod.description}, method_response_metric_id = ${sampleMethod.method_response_metric_id} FROM @@ -128,12 +144,12 @@ export class SampleMethodRepository extends BaseRepository { const sqlStatement = SQL` INSERT INTO survey_sample_method ( survey_sample_site_id, - method_lookup_id, + method_technique_id, description, method_response_metric_id ) VALUES ( ${sampleMethod.survey_sample_site_id}, - ${sampleMethod.method_lookup_id}, + ${sampleMethod.method_technique_id}, ${sampleMethod.description}, ${sampleMethod.method_response_metric_id} ) diff --git a/api/src/repositories/technique-attribute-repository.test.ts b/api/src/repositories/technique-attribute-repository.test.ts new file mode 100644 index 0000000000..687c33af4a --- /dev/null +++ b/api/src/repositories/technique-attribute-repository.test.ts @@ -0,0 +1,282 @@ +import { fail } from 'assert'; +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { + IQualitativeAttributePostData, + IQuantitativeAttributePostData, + TechniqueAttributeRepository, + TechniqueAttributesLookupObject, + TechniqueAttributesObject +} from './technique-attribute-repository'; + +chai.use(sinonChai); + +describe('TechniqueAttributeRepository', () => { + afterEach(() => { + sinon.restore(); + }); + describe('getAttributeDefinitionsByMethodLookupIds', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueAttributesLookupObject = { + method_lookup_id: 1, + qualitative_attributes: [], + quantitative_attributes: [] + }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodLookupIds = [1]; + + const response = await repository.getAttributeDefinitionsByMethodLookupIds(methodLookupIds); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('getAttributeDefinitionsByTechniqueId', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueAttributesLookupObject = { + method_lookup_id: 1, + qualitative_attributes: [], + quantitative_attributes: [] + }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodTechniqueId = 1; + + const response = await repository.getAttributeDefinitionsByTechniqueId(methodTechniqueId); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('getAttributesByTechniqueId', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueAttributesObject = { + qualitative_attributes: [], + quantitative_attributes: [] + }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodTechniqueId = 1; + + const response = await repository.getAttributesByTechniqueId(methodTechniqueId); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('insertQualitativeAttributesForTechnique', () => { + it('should return undefined if no attributes provided', async () => { + const dbConnection = getMockDBConnection({ knex: () => fail() }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodTechniqueId = 1; + const attributes: IQualitativeAttributePostData[] = []; + + const response = await repository.insertQualitativeAttributesForTechnique(methodTechniqueId, attributes); + + expect(response).to.be.undefined; + }); + + it('should run successfully', async () => { + const mockRecord = { method_technique_attribute_qualitative_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodTechniqueId = 1; + const attributes: IQualitativeAttributePostData[] = [ + { + method_technique_attribute_qualitative_id: 1, + method_lookup_attribute_qualitative_option_id: '123-456-22', + method_lookup_attribute_qualitative_id: '123-456-33' + } + ]; + + const response = await repository.insertQualitativeAttributesForTechnique(methodTechniqueId, attributes); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('insertQuantitativeAttributesForTechnique', () => { + it('should return undefined if no attributes provided', async () => { + const dbConnection = getMockDBConnection({ knex: () => fail() }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodTechniqueId = 1; + const attributes: IQuantitativeAttributePostData[] = []; + + const response = await repository.insertQuantitativeAttributesForTechnique(methodTechniqueId, attributes); + + expect(response).to.be.undefined; + }); + + it('should run successfully', async () => { + const mockRecord = { method_technique_attribute_quantitative_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodTechniqueId = 1; + const attributes: IQuantitativeAttributePostData[] = [ + { + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-22', + value: 3 + } + ]; + + const response = await repository.insertQuantitativeAttributesForTechnique(methodTechniqueId, attributes); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('updateQuantitativeAttributeForTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_attribute_quantitative_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodTechniqueId = 1; + const attribute: IQuantitativeAttributePostData = { + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-22', + value: 3 + }; + + const response = await repository.updateQuantitativeAttributeForTechnique(methodTechniqueId, attribute); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('updateQualitativeAttributeForTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_attribute_qualitative_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const methodTechniqueId = 1; + const attribute: IQualitativeAttributePostData = { + method_technique_attribute_qualitative_id: 1, + method_lookup_attribute_qualitative_option_id: '123-456-22', + method_lookup_attribute_qualitative_id: '123-456-33' + }; + + const response = await repository.updateQualitativeAttributeForTechnique(methodTechniqueId, attribute); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('deleteQualitativeAttributesForTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_attribute_qualitative_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + const methodTechniqueAttributeQualitativeIds = [3, 4]; + + const response = await repository.deleteQualitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + methodTechniqueAttributeQualitativeIds + ); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('deleteQuantitativeAttributesForTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_attribute_quantitative_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + const methodTechniqueAttributeQuantitativeIds = [3, 4]; + + const response = await repository.deleteQuantitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + methodTechniqueAttributeQuantitativeIds + ); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('deleteAllTechniqueAttributes', () => { + it('should run successfully', async () => { + const mockQualitativeRecord = { method_technique_attribute_qualitative_id: 1 }; + const mockQuantitativeRecord = { method_technique_attribute_quantitative_id: 2 }; + + const mockQualitativeResponse = { rows: [mockQualitativeRecord], rowCount: 1 } as any as Promise< + QueryResult + >; + const mockQuantitativeResponse = { rows: [mockQuantitativeRecord], rowCount: 1 } as any as Promise< + QueryResult + >; + + const dbConnection = getMockDBConnection({ + sql: sinon + .stub() + .onFirstCall() + .resolves(mockQualitativeResponse) + .onSecondCall() + .resolves(mockQuantitativeResponse) + }); + + const repository = new TechniqueAttributeRepository(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + + const response = await repository.deleteAllTechniqueAttributes(surveyId, methodTechniqueId); + + expect(response).to.eql({ + qualitative_attributes: [mockQualitativeRecord], + quantitative_attributes: [mockQuantitativeRecord] + }); + }); + }); +}); diff --git a/api/src/repositories/technique-attribute-repository.ts b/api/src/repositories/technique-attribute-repository.ts new file mode 100644 index 0000000000..41a34a0ae7 --- /dev/null +++ b/api/src/repositories/technique-attribute-repository.ts @@ -0,0 +1,614 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { getLogger } from '../utils/logger'; +import { BaseRepository } from './base-repository'; + +const defaultLog = getLogger('repositories/technique-attribute-repository'); + +export interface IQuantitativeAttributePostData { + method_technique_attribute_quantitative_id?: number; + method_lookup_attribute_quantitative_id: string; + value: number; +} + +export interface IQualitativeAttributePostData { + method_technique_attribute_qualitative_id?: number; + method_lookup_attribute_qualitative_option_id: string; + method_lookup_attribute_qualitative_id: string; +} + +const TechniqueAttributeQuantitative = z.object({ + method_lookup_attribute_quantitative_id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + unit: z.string().nullable(), + min: z.number().nullable(), + max: z.number().nullable() +}); + +const TechniqueAttributeQualitativeOption = z.object({ + method_lookup_attribute_qualitative_option_id: z.string(), + name: z.string(), + description: z.string().nullable() +}); + +const TechniqueAttributeQualitative = z.object({ + method_lookup_attribute_qualitative_id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + options: z.array(TechniqueAttributeQualitativeOption) +}); + +export const TechniqueAttributesLookupObject = z.object({ + method_lookup_id: z.number(), + quantitative_attributes: z.array(TechniqueAttributeQuantitative), + qualitative_attributes: z.array(TechniqueAttributeQualitative) +}); +export type TechniqueAttributesLookupObject = z.infer; + +export const TechniqueAttributesObject = z.object({ + quantitative_attributes: z.array( + z.object({ + method_technique_attribute_quantitative_id: z.number(), + method_technique_id: z.number(), + method_lookup_attribute_quantitative_id: z.string().uuid(), + value: z.number() + }) + ), + qualitative_attributes: z.array( + z.object({ + method_technique_attribute_qualitative_id: z.number(), + method_technique_id: z.number(), + method_lookup_attribute_qualitative_id: z.string().uuid(), + method_lookup_attribute_qualitative_option_id: z.string() + }) + ) +}); +export type TechniqueAttributesObject = z.infer; + +export class TechniqueAttributeRepository extends BaseRepository { + /** + * Get quantitative and qualitative attribute definition records for method lookup Ids. + * + * @param {number[]} methodLookupIds + * @return {*} {Promise} + * @memberof TechniqueAttributeRepository + */ + async getAttributeDefinitionsByMethodLookupIds( + methodLookupIds: number[] + ): Promise { + const knex = getKnex(); + + const queryBuilder = knex + .with( + 'w_quantitative_attributes', + knex + .select( + 'mlaq.method_lookup_id', + knex.raw(` + json_agg(json_build_object( + 'method_lookup_attribute_quantitative_id', mlaq.method_lookup_attribute_quantitative_id, + 'name', taq.name, + 'description', taq.description, + 'min', mlaq.min, + 'max', mlaq.max, + 'unit', mlaq.unit + )) as quantitative_attributes + `) + ) + .from('method_lookup_attribute_quantitative as mlaq') + .leftJoin( + 'technique_attribute_quantitative as taq', + 'taq.technique_attribute_quantitative_id', + 'mlaq.technique_attribute_quantitative_id' + ) + .where('mlaq.record_end_date', null) + .groupBy('mlaq.method_lookup_id') + ) + .with( + 'w_qualitative_attributes_options', + knex + .select( + 'method_lookup_attribute_qualitative_id', + knex.raw(` + json_agg(json_build_object( + 'method_lookup_attribute_qualitative_option_id', method_lookup_attribute_qualitative_option_id, + 'name', name, + 'description', description + )) as options + `) + ) + .from('method_lookup_attribute_qualitative_option') + .where('record_end_date', null) + .groupBy('method_lookup_attribute_qualitative_id') + ) + .with( + 'w_qualitative_attributes', + knex + .select( + 'mlaq.method_lookup_id', + knex.raw(` + json_agg(json_build_object( + 'method_lookup_attribute_qualitative_id', mlaq.method_lookup_attribute_qualitative_id, + 'name', taq.name, + 'description', taq.description, + 'options', COALESCE(wqao.options, '[]'::json) + )) as qualitative_attributes + `) + ) + .from('method_lookup_attribute_qualitative as mlaq') + .leftJoin( + 'technique_attribute_qualitative as taq', + 'taq.technique_attribute_qualitative_id', + 'mlaq.technique_attribute_qualitative_id' + ) + .innerJoin( + 'w_qualitative_attributes_options as wqao', + 'wqao.method_lookup_attribute_qualitative_id', + 'mlaq.method_lookup_attribute_qualitative_id' + ) + .where('mlaq.record_end_date', null) + .groupBy('mlaq.method_lookup_id') + ) + .select( + 'ml.method_lookup_id', + knex.raw(`COALESCE(qual.qualitative_attributes, '[]'::json) as qualitative_attributes`), + knex.raw(`COALESCE(quant.quantitative_attributes, '[]'::json) as quantitative_attributes`) + ) + .from('method_lookup as ml') + .leftJoin('w_qualitative_attributes as qual', 'ml.method_lookup_id', 'qual.method_lookup_id') + .leftJoin('w_quantitative_attributes as quant', 'ml.method_lookup_id', 'quant.method_lookup_id') + .whereIn('ml.method_lookup_id', methodLookupIds); + + const response = await this.connection.knex(queryBuilder, TechniqueAttributesLookupObject); + + return response.rows; + } + + /** + * Get quantitative and qualitative attribute definition records for a technique Id. + * + * @param {number} methodTechniqueId + * @return {*} {Promise} + * @memberof TechniqueAttributeRepository + */ + async getAttributeDefinitionsByTechniqueId(methodTechniqueId: number): Promise { + defaultLog.debug({ label: 'getAttributesForMethodLookupId', methodTechniqueId }); + + const knex = getKnex(); + + const queryBuilder = knex + .with( + 'w_quantitative_attributes', + knex + .select( + 'mlaq.method_lookup_id', + knex.raw(` + json_agg(json_build_object( + 'method_lookup_attribute_quantitative_id', mlaq.method_lookup_attribute_quantitative_id, + 'name', taq.name, + 'description', taq.description, + 'min', mlaq.min, + 'max', mlaq.max, + 'unit', mlaq.unit + )) as quantitative_attributes + `) + ) + .from('method_lookup_attribute_quantitative as mlaq') + .leftJoin( + 'technique_attribute_quantitative as taq', + 'taq.technique_attribute_quantitative_id', + 'mlaq.technique_attribute_quantitative_id' + ) + .where('mlaq.record_end_date', null) + .groupBy('mlaq.method_lookup_id') + ) + .with( + 'w_qualitative_attributes_options', + knex + .select( + 'method_lookup_attribute_qualitative_id', + knex.raw(` + json_agg(json_build_object( + 'method_lookup_attribute_qualitative_option_id', method_lookup_attribute_qualitative_option_id, + 'name', name, + 'description', description + )) as options + `) + ) + .from('method_lookup_attribute_qualitative_option') + .where('record_end_date', null) + .groupBy('method_lookup_attribute_qualitative_id') + ) + .with( + 'w_qualitative_attributes', + knex + .select( + 'mlaq.method_lookup_id', + knex.raw(` + json_agg(json_build_object( + 'method_lookup_attribute_qualitative_id', mlaq.method_lookup_attribute_qualitative_id, + 'name', taq.name, + 'description', taq.description, + 'options', COALESCE(wqao.options, '[]'::json) + )) as qualitative_attributes + `) + ) + .from('method_lookup_attribute_qualitative as mlaq') + .leftJoin( + 'technique_attribute_qualitative as taq', + 'taq.technique_attribute_qualitative_id', + 'mlaq.technique_attribute_qualitative_id' + ) + .innerJoin( + 'w_qualitative_attributes_options as wqao', + 'wqao.method_lookup_attribute_qualitative_id', + 'mlaq.method_lookup_attribute_qualitative_id' + ) + .where('mlaq.record_end_date', null) + .groupBy('mlaq.method_lookup_id') + ) + .select( + 'ml.method_lookup_id', + knex.raw(`COALESCE(qual.qualitative_attributes, '[]'::json) as qualitative_attributes`), + knex.raw(`COALESCE(quant.quantitative_attributes, '[]'::json) as quantitative_attributes`) + ) + .from('method_technique as mt') + .leftJoin('method_lookup as ml', 'ml.method_lookup_id', 'mt.method_lookup_id') + .leftJoin('w_qualitative_attributes as qual', 'ml.method_lookup_id', 'qual.method_lookup_id') + .leftJoin('w_quantitative_attributes as quant', 'ml.method_lookup_id', 'quant.method_lookup_id') + .where('mt.method_technique_id', methodTechniqueId); + + const response = await this.connection.knex(queryBuilder, TechniqueAttributesLookupObject); + + return response.rows[0]; + } + + /** + * Get quantitative and qualitative attribute records for a technique Id. + * + * @param {number} methodTechniqueId + * @return {*} {Promise} + * @memberof TechniqueAttributeRepository + */ + async getAttributesByTechniqueId(methodTechniqueId: number): Promise { + defaultLog.debug({ label: 'getAttributesByTechniqueId', methodTechniqueId }); + + const knex = getKnex(); + + const queryBuilder = knex + .with( + 'w_quantitative_attributes', + knex + .select( + 'method_technique_id', + knex.raw(`json_agg(json_build_object( + 'method_technique_attribute_quantitative_id', method_technique_attribute_quantitative_id, + 'method_technique_id', method_technique_id, + 'method_lookup_attribute_quantitative_id', method_lookup_attribute_quantitative_id, + 'value', value + )) as quantitative_attributes`) + ) + .from('method_technique_attribute_quantitative') + .groupBy('method_technique_id') + ) + .with( + 'w_qualitative_attributes', + knex + .select( + 'method_technique_id', + knex.raw(`json_agg(json_build_object( + 'method_technique_attribute_qualitative_id', method_technique_attribute_qualitative_id, + 'method_technique_id', method_technique_id, + 'method_lookup_attribute_qualitative_id', method_lookup_attribute_qualitative_id, + 'method_lookup_attribute_qualitative_option_id', method_lookup_attribute_qualitative_option_id + )) as qualitative_attributes`) + ) + .from('method_technique_attribute_qualitative') + .groupBy('method_technique_id') + ) + .select( + knex.raw(`COALESCE(w_quantitative_attributes.quantitative_attributes, '[]'::json) as quantitative_attributes`), + knex.raw(`COALESCE(w_qualitative_attributes.qualitative_attributes, '[]'::json) as qualitative_attributes`) + ) + .from('method_technique as mt') + .leftJoin('w_qualitative_attributes', 'w_qualitative_attributes.method_technique_id', 'mt.method_technique_id') + .leftJoin('w_quantitative_attributes', 'w_quantitative_attributes.method_technique_id', 'mt.method_technique_id') + .where('mt.method_technique_id', methodTechniqueId); + + const response = await this.connection.knex(queryBuilder, TechniqueAttributesObject); + + return response.rows[0]; + } + + /** + * Insert qualitative attribute records for a technique. + * + * @param {number} methodTechniqueId + * @param {IQualitativeAttributePostData[]} attributes + * @return {*} {(Promise<{ method_technique_attribute_qualitative_id: number }[] | undefined>)} + * @memberof TechniqueAttributeRepository + */ + async insertQualitativeAttributesForTechnique( + methodTechniqueId: number, + attributes: IQualitativeAttributePostData[] + ): Promise<{ method_technique_attribute_qualitative_id: number }[] | undefined> { + defaultLog.debug({ label: 'insertQualitativeAttributesForTechnique', methodTechniqueId }); + + if (!attributes.length) { + return; + } + + const queryBuilder = getKnex() + .insert( + attributes.map((attribute) => ({ + method_lookup_attribute_qualitative_id: attribute.method_lookup_attribute_qualitative_id, + method_lookup_attribute_qualitative_option_id: attribute.method_lookup_attribute_qualitative_option_id, + method_technique_id: methodTechniqueId + })) + ) + .into('method_technique_attribute_qualitative') + .returning('method_technique_attribute_qualitative_id'); + + const response = await this.connection.knex( + queryBuilder, + z.object({ method_technique_attribute_qualitative_id: z.number() }) + ); + + return response.rows; + } + + /** + * Insert quantitative attribute records for a technique. + * + * @param {number} methodTechniqueId + * @param {IQuantitativeAttributePostData[]} attributes + * @return {*} {(Promise<{ method_technique_attribute_quantitative_id: number }[] | undefined>)} + * @memberof TechniqueAttributeRepository + */ + async insertQuantitativeAttributesForTechnique( + methodTechniqueId: number, + attributes: IQuantitativeAttributePostData[] + ): Promise<{ method_technique_attribute_quantitative_id: number }[] | undefined> { + defaultLog.debug({ label: 'insertQuantitativeAttributesForTechnique', methodTechniqueId }); + + if (!attributes.length) { + return; + } + + const queryBuilder = getKnex() + .insert( + attributes.map((attribute) => ({ + method_lookup_attribute_quantitative_id: attribute.method_lookup_attribute_quantitative_id, + value: attribute.value, + method_technique_id: methodTechniqueId + })) + ) + .into('method_technique_attribute_quantitative') + .returning('method_technique_attribute_quantitative_id'); + + const response = await this.connection.knex( + queryBuilder, + z.object({ method_technique_attribute_quantitative_id: z.number() }) + ); + + return response.rows; + } + + /** + * Update quantitative attribute records for a technique. + * + * @param {number} methodTechniqueId + * @param {IQuantitativeAttributePostData} attribute + * @return {*} {Promise<{ method_technique_attribute_quantitative_id: number }>} + * @memberof TechniqueAttributeRepository + */ + async updateQuantitativeAttributeForTechnique( + methodTechniqueId: number, + attribute: IQuantitativeAttributePostData + ): Promise<{ method_technique_attribute_quantitative_id: number }> { + defaultLog.debug({ label: 'updateQuantitativeAttributesForTechnique', methodTechniqueId }); + + const queryBuilder = getKnex() + .table('method_technique_attribute_quantitative') + .update({ + method_lookup_attribute_quantitative_id: attribute.method_lookup_attribute_quantitative_id, + value: attribute.value + }) + .where('method_technique_attribute_quantitative_id', attribute.method_technique_attribute_quantitative_id) + .returning('method_technique_attribute_quantitative_id'); + + const response = await this.connection.knex( + queryBuilder, + z.object({ method_technique_attribute_quantitative_id: z.number() }) + ); + + return response.rows[0]; + } + + /** + * Update qualitative attribute records for a technique. + * + * @param {number} methodTechniqueId + * @param {IQualitativeAttributePostData} attribute + * @return {*} {Promise<{ method_technique_attribute_qualitative_id: number }>} + * @memberof TechniqueAttributeRepository + */ + async updateQualitativeAttributeForTechnique( + methodTechniqueId: number, + attribute: IQualitativeAttributePostData + ): Promise<{ method_technique_attribute_qualitative_id: number }> { + defaultLog.debug({ label: 'updateQualitativeAttributesForTechnique', methodTechniqueId }); + + const queryBuilder = getKnex() + .table('method_technique_attribute_qualitative') + .update({ + method_lookup_attribute_qualitative_id: attribute.method_lookup_attribute_qualitative_id, + method_lookup_attribute_qualitative_option_id: attribute.method_lookup_attribute_qualitative_option_id + }) + .where('method_technique_attribute_qualitative_id', attribute.method_technique_attribute_qualitative_id) + .returning('method_technique_attribute_qualitative_id'); + + const response = await this.connection.knex( + queryBuilder, + z.object({ method_technique_attribute_qualitative_id: z.number() }) + ); + + return response.rows[0]; + } + + /** + * Delete qualitative attribute records for a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @param {number[]} methodTechniqueAttributeQualitativeIds + * @return {*} {Promise<{ method_technique_attribute_qualitative_id: number }[]>} + * @memberof TechniqueAttributeRepository + */ + async deleteQualitativeAttributesForTechnique( + surveyId: number, + methodTechniqueId: number, + methodTechniqueAttributeQualitativeIds: number[] + ): Promise<{ method_technique_attribute_qualitative_id: number }[]> { + defaultLog.debug({ label: 'deleteQualitativeAttributesForTechnique', methodTechniqueId }); + + const queryBuilder = getKnex() + .del() + .from('method_technique_attribute_qualitative as mtaq') + .leftJoin('method_technique as mt', 'mt.method_technique_id', 'mtaq.method_technique_id') + .whereIn('method_technique_attribute_qualitative_id', methodTechniqueAttributeQualitativeIds) + .andWhere('mtaq.method_technique_id', methodTechniqueId) + .andWhere('mt.survey_id', surveyId) + .returning('method_technique_attribute_qualitative.method_technique_attribute_qualitative_id'); + + const response = await this.connection.knex( + queryBuilder, + z.object({ + method_technique_attribute_qualitative_id: z.number() + }) + ); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to delete qualitative attribute', [ + 'TechniqueAttributeRepository->deleteQualitativeAttributesForTechnique', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows; + } + + /** + * Delete quantitative attribute records for a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @param {number[]} methodTechniqueAttributeQuantitativeIds + * @return {*} {Promise<{ method_technique_attribute_quantitative_id: number }[]>} + * @memberof TechniqueAttributeRepository + */ + async deleteQuantitativeAttributesForTechnique( + surveyId: number, + methodTechniqueId: number, + methodTechniqueAttributeQuantitativeIds: number[] + ): Promise<{ method_technique_attribute_quantitative_id: number }[]> { + defaultLog.debug({ label: 'deleteQuantitativeAttributesForTechnique', methodTechniqueId }); + + const queryBuilder = getKnex() + .del() + .from('method_technique_attribute_quantitative as mtaq') + .leftJoin('method_technique as mt', 'mt.method_technique_id', 'mtaq.method_technique_id') + .whereIn('method_technique_attribute_quantitative_id', methodTechniqueAttributeQuantitativeIds) + .andWhere('mtaq.method_technique_id', methodTechniqueId) + .andWhere('mt.survey_id', surveyId) + .returning('method_technique_attribute_quantitative.method_technique_attribute_quantitative_id'); + + const response = await this.connection.knex( + queryBuilder, + z.object({ + method_technique_attribute_quantitative_id: z.number() + }) + ); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to delete quantitative attribute', [ + 'TechniqueAttributeRepository->deleteQuantitativeAttributesForTechnique', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows; + } + + /** + * Delete all qualitative and quantitative attribute records for a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise<{ + * qualitative_attributes: { method_technique_attribute_qualitative_id: number }[]; + * quantitative_attributes: { method_technique_attribute_quantitative_id: number }[]; + * }>} + * @memberof TechniqueAttributeRepository + */ + async deleteAllTechniqueAttributes( + surveyId: number, + methodTechniqueId: number + ): Promise<{ + qualitative_attributes: { method_technique_attribute_qualitative_id: number }[]; + quantitative_attributes: { method_technique_attribute_quantitative_id: number }[]; + }> { + defaultLog.debug({ label: 'deleteAllTechniqueAttributes', surveyId, methodTechniqueId }); + + // Query to delete all qualitative attributes for a technique + const sqlStatement1 = SQL` + DELETE FROM + method_technique_attribute_qualitative using method_technique + WHERE + method_technique.method_technique_id = method_technique_attribute_qualitative.method_technique_id + AND + method_technique_attribute_qualitative.method_technique_id = ${methodTechniqueId} + AND + method_technique.survey_id = ${surveyId} + RETURNING + method_technique_attribute_qualitative.method_technique_attribute_qualitative_id; + `; + + // Query to delete all qualitative attributes for a technique + const sqlStatement2 = SQL` + DELETE FROM + method_technique_attribute_quantitative using method_technique + WHERE + method_technique.method_technique_id = method_technique_attribute_quantitative.method_technique_id + AND + method_technique_attribute_quantitative.method_technique_id = ${methodTechniqueId} + AND + method_technique.survey_id = ${surveyId} + RETURNING + method_technique_attribute_quantitative.method_technique_attribute_quantitative_id; + `; + + const response = await Promise.all([ + this.connection.sql( + sqlStatement1, + z.object({ + method_technique_attribute_qualitative_id: z.number() + }) + ), + this.connection.sql( + sqlStatement2, + z.object({ + method_technique_attribute_quantitative_id: z.number() + }) + ) + ]); + + return { + qualitative_attributes: response[0].rows, + quantitative_attributes: response[1].rows + }; + } +} diff --git a/api/src/repositories/technique-repository.test.ts b/api/src/repositories/technique-repository.test.ts new file mode 100644 index 0000000000..ab7081fbc4 --- /dev/null +++ b/api/src/repositories/technique-repository.test.ts @@ -0,0 +1,159 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { + ITechniqueRowDataForInsert, + ITechniqueRowDataForUpdate, + TechniqueObject, + TechniqueRepository +} from './technique-repository'; + +chai.use(sinonChai); + +describe('TechniqueRepository', () => { + afterEach(() => { + sinon.restore(); + }); + describe('getTechniqueById', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueObject = { + method_technique_id: 1, + method_lookup_id: 2, + name: 'name', + description: 'desc', + distance_threshold: 200, + attractants: [], + attributes: { + qualitative_attributes: [], + quantitative_attributes: [] + } + }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueRepository(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + + const response = await repository.getTechniqueById(surveyId, methodTechniqueId); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('getTechniquesForSurveyId', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueObject = { + method_technique_id: 1, + method_lookup_id: 2, + name: 'name', + description: 'desc', + distance_threshold: 200, + attractants: [], + attributes: { + qualitative_attributes: [], + quantitative_attributes: [] + } + }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueRepository(dbConnection); + + const surveyId = 1; + const pagination = undefined; + + const response = await repository.getTechniquesForSurveyId(surveyId, pagination); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('getTechniquesCountForSurveyId', () => { + it('should run successfully', async () => { + const mockRecord = { count: 10 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueRepository(dbConnection); + + const surveyId = 1; + + const response = await repository.getTechniquesCountForSurveyId(surveyId); + + expect(response).to.equal(10); + }); + }); + + describe('insertTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueRepository(dbConnection); + + const surveyId = 1; + const techniqueObject: ITechniqueRowDataForInsert = { + name: 'name', + description: 'desc', + distance_threshold: 200, + method_lookup_id: 2 + }; + + const response = await repository.insertTechnique(surveyId, techniqueObject); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('updateTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ knex: () => mockResponse }); + + const repository = new TechniqueRepository(dbConnection); + + const surveyId = 1; + const techniqueObject: ITechniqueRowDataForUpdate = { + method_technique_id: 1, + name: 'name', + description: 'desc', + distance_threshold: 200, + method_lookup_id: 2 + }; + + const response = await repository.updateTechnique(surveyId, techniqueObject); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('deleteTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_id: 1 }; + + const mockResponse = { rows: [mockRecord], rowCount: 1 } as any as Promise>; + const dbConnection = getMockDBConnection({ sql: () => mockResponse }); + + const repository = new TechniqueRepository(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + + const response = await repository.deleteTechnique(surveyId, methodTechniqueId); + + expect(response).to.eql(mockRecord); + }); + }); +}); diff --git a/api/src/repositories/technique-repository.ts b/api/src/repositories/technique-repository.ts new file mode 100644 index 0000000000..9eee7317ec --- /dev/null +++ b/api/src/repositories/technique-repository.ts @@ -0,0 +1,302 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; +import { IAttractantPostData } from './attractants-repository'; +import { BaseRepository } from './base-repository'; +import { IQualitativeAttributePostData, IQuantitativeAttributePostData } from './technique-attribute-repository'; + +export interface ITechniquePostData { + name: string; + description: string | null; + distance_threshold: number | null; + method_lookup_id: number; + attributes: { + quantitative_attributes: IQuantitativeAttributePostData[]; + qualitative_attributes: IQualitativeAttributePostData[]; + }; + attractants: IAttractantPostData[]; +} + +export interface ITechniquePutData extends ITechniquePostData { + method_technique_id: number; +} + +export interface ITechniqueRowDataForInsert { + name: string; + description: string | null; + distance_threshold: number | null; + method_lookup_id: number; +} + +export interface ITechniqueRowDataForUpdate extends ITechniqueRowDataForInsert { + method_technique_id: number; +} + +export const TechniqueObject = z.object({ + method_technique_id: z.number(), + name: z.string(), + description: z.string().nullable(), + distance_threshold: z.number().nullable(), + method_lookup_id: z.number(), + attractants: z.array(z.object({ attractant_lookup_id: z.number() })), + attributes: z.object({ + quantitative_attributes: z.array( + z.object({ + method_technique_attribute_quantitative_id: z.number(), + method_lookup_attribute_quantitative_id: z.string().uuid(), + value: z.number() + }) + ), + qualitative_attributes: z.array( + z.object({ + method_technique_attribute_qualitative_id: z.number(), + method_lookup_attribute_qualitative_id: z.string().uuid(), + method_lookup_attribute_qualitative_option_id: z.string().uuid() + }) + ) + }) +}); +export type TechniqueObject = z.infer; + +export class TechniqueRepository extends BaseRepository { + /** + * Private utility function to generate the common SQL query for fetching method technique records, including + * associated attractants and attributes. + * + * @param {number} surveyId The survey ID + * @returns {*} + */ + _generateGetTechniqueQuery(surveyId: number) { + const knex = getKnex(); + + const queryBuilder = knex + .with( + 'w_attractants', + knex + .select( + 'method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'attractant_lookup_id', attractant_lookup_id + )) AS attractants + `) + ) + .from('method_technique_attractant') + .groupBy('method_technique_id') + ) + .with( + 'w_quantitative_attributes', + knex + .select( + 'method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'method_technique_attribute_quantitative_id', method_technique_attribute_quantitative_id, + 'method_lookup_attribute_quantitative_id', method_lookup_attribute_quantitative_id, + 'value', value + )) as quantitative_attributes + `) + ) + .from('method_technique_attribute_quantitative') + .groupBy('method_technique_id') + ) + .with( + 'w_qualitative_attributes', + knex + .select( + 'method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'method_technique_attribute_qualitative_id', method_technique_attribute_qualitative_id, + 'method_lookup_attribute_qualitative_id', method_lookup_attribute_qualitative_id, + 'method_lookup_attribute_qualitative_option_id', method_lookup_attribute_qualitative_option_id + )) as qualitative_attributes + `) + ) + .from('method_technique_attribute_qualitative') + .groupBy('method_technique_id') + ) + .select( + 'mt.method_technique_id', + 'mt.name', + 'mt.description', + 'mt.distance_threshold', + 'mt.method_lookup_id', + knex.raw(` + COALESCE(w_attractants.attractants, '[]'::json) AS attractants + `), + knex.raw(` + json_build_object( + 'quantitative_attributes', COALESCE(w_quantitative_attributes.quantitative_attributes, '[]'::json), + 'qualitative_attributes', COALESCE(w_qualitative_attributes.qualitative_attributes, '[]'::json + )) AS attributes + `) + ) + .from('method_technique as mt') + .leftJoin('w_attractants', 'w_attractants.method_technique_id', 'mt.method_technique_id') + .leftJoin('w_quantitative_attributes', 'w_quantitative_attributes.method_technique_id', 'mt.method_technique_id') + .leftJoin('w_qualitative_attributes', 'w_qualitative_attributes.method_technique_id', 'mt.method_technique_id') + .where('mt.survey_id', surveyId); + + return queryBuilder; + } + + /** + * Get a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise} + * @memberof TechniqueRepository + */ + async getTechniqueById(surveyId: number, methodTechniqueId: number): Promise { + const queryBuilder = this._generateGetTechniqueQuery(surveyId).andWhere( + 'mt.method_technique_id', + methodTechniqueId + ); + + const response = await this.connection.knex(queryBuilder, TechniqueObject); + + return response.rows[0]; + } + + /** + * Get a paginated list of techniques for a survey. + * + * @param {number} surveyId + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof TechniqueRepository + */ + async getTechniquesForSurveyId(surveyId: number, pagination?: ApiPaginationOptions): Promise { + const queryBuilder = this._generateGetTechniqueQuery(surveyId); + + if (pagination) { + queryBuilder.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + queryBuilder.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex(queryBuilder, TechniqueObject); + + return response.rows; + } + + /** + * Get total count of all techniques for a survey. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof TechniqueRepository + */ + async getTechniquesCountForSurveyId(surveyId: number): Promise { + const knex = getKnex(); + + const queryBuilder = knex + .select(knex.raw('count(*)::integer as count')) + .from('method_technique as mt') + .where('survey_id', surveyId); + + const response = await this.connection.knex(queryBuilder, z.object({ count: z.number() })); + + return response.rows[0].count; + } + + /** + * Create a new technique. + * + * @param {number} surveyId + * @param {number} techniqueObject + * @returns {*} {Promise<{id: number}[]>} + * @memberof TechniqueRepository + */ + async insertTechnique( + surveyId: number, + techniqueObject: ITechniqueRowDataForInsert + ): Promise<{ method_technique_id: number }> { + const queryBuilder = getKnex() + .insert({ + name: techniqueObject.name, + description: techniqueObject.description, + distance_threshold: techniqueObject.distance_threshold, + method_lookup_id: techniqueObject.method_lookup_id, + survey_id: surveyId + }) + .into('method_technique') + .returning('method_technique_id'); + + const response = await this.connection.knex(queryBuilder, z.object({ method_technique_id: z.number() })); + + return response.rows[0]; + } + + /** + * Update an existing technique. + * + * @param {number} surveyId + * @param {ITechniqueRowDataForUpdate} techniqueObject + * @return {*} {Promise<{ method_technique_id: number }>} + * @memberof TechniqueRepository + */ + async updateTechnique( + surveyId: number, + techniqueObject: ITechniqueRowDataForUpdate + ): Promise<{ method_technique_id: number }> { + const queryBuilder = getKnex() + .table('method_technique') + .update({ + name: techniqueObject.name, + description: techniqueObject.description, + method_lookup_id: techniqueObject.method_lookup_id, + distance_threshold: techniqueObject.distance_threshold + }) + .where('method_technique_id', techniqueObject.method_technique_id) + .andWhere('survey_id', surveyId) + .returning('method_technique_id'); + + const response = await this.connection.knex(queryBuilder, z.object({ method_technique_id: z.number() })); + + return response.rows[0]; + } + + /** + * Delete a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise<{ method_technique_id: number }>} + * @memberof TechniqueRepository + */ + async deleteTechnique(surveyId: number, methodTechniqueId: number): Promise<{ method_technique_id: number }> { + const sqlStatement = SQL` + DELETE FROM + method_technique mt + WHERE + mt.survey_id = ${surveyId} + AND + mt.method_technique_id = ${methodTechniqueId} + RETURNING + mt.method_technique_id; + `; + + const response = await this.connection.sql( + sqlStatement, + z.object({ + method_technique_id: z.number() + }) + ); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to delete technique', [ + 'TechniqueRepository->deleteTechnique', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } +} diff --git a/api/src/services/attractants-service.ts b/api/src/services/attractants-service.ts new file mode 100644 index 0000000000..e8e4ac46b0 --- /dev/null +++ b/api/src/services/attractants-service.ts @@ -0,0 +1,122 @@ +import { IDBConnection } from '../database/db'; +import { AttractantRecord, AttractantRepository, IAttractantPostData } from '../repositories/attractants-repository'; +import { DBService } from './db-service'; + +/** + * Attractant service. + * + * Handles all business logic related to technique attractants. + * + * @export + * @class AttractantService + * @extends {DBService} + */ +export class AttractantService extends DBService { + attractantRepository: AttractantRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.attractantRepository = new AttractantRepository(connection); + } + + /** + * Get all attractants for a technique. + * + * @param {number} surveyId The ID of the survey the technique is associated with + * @param {number} methodTechniqueId The ID of the technique to get attractants for + * @return {*} {Promise} + * @memberof AttractantService + */ + async getAttractantsByTechniqueId(surveyId: number, methodTechniqueId: number): Promise { + return this.attractantRepository.getAttractantsByTechniqueId(surveyId, methodTechniqueId); + } + + /** + * Insert attractant records for a technique. + * + * @param {number} surveyId The ID of the survey the technique is associated with + * @param {number} methodTechniqueId The ID of the technique to insert attractants for + * @param {IAttractantPostData[]} attractants The attractants to insert + * @return {*} {Promise<{ method_technique_attractant_id: number }[]>} + * @memberof AttractantService + */ + async insertTechniqueAttractants( + surveyId: number, + methodTechniqueId: number, + attractants: IAttractantPostData[] + ): Promise<{ method_technique_attractant_id: number }[]> { + return this.attractantRepository.insertAttractantsForTechnique(surveyId, methodTechniqueId, attractants); + } + + /** + * Update attractant records for a technique. + * + * @param {number} surveyId The ID of the survey the technique is associated with + * @param {number} methodTechniqueId The ID of the technique to update attractants for + * @param {IAttractantPostData[]} attractants The attractants to update + * @return {*} {Promise} + * @memberof AttractantService + */ + async updateTechniqueAttractants( + surveyId: number, + methodTechniqueId: number, + attractants: IAttractantPostData[] + ): Promise { + // Get existing attractants associated with the technique + const existingAttractants = await this.attractantRepository.getAttractantsByTechniqueId( + surveyId, + methodTechniqueId + ); + + // Find existing attractants to delete + const attractantsToDelete = existingAttractants.filter( + (existing) => !attractants.some((incoming) => incoming.attractant_lookup_id === existing.attractant_lookup_id) + ); + + // Delete existing attractants that are not in the new list + if (attractantsToDelete.length > 0) { + const attractantIdsToDelete = attractantsToDelete.map((attractant) => attractant.attractant_lookup_id); + await this.attractantRepository.deleteTechniqueAttractants(surveyId, methodTechniqueId, attractantIdsToDelete); + } + + // If the incoming data does not yet exist in the DB, insert the record + const attractantsToInsert = attractants.filter( + (incoming) => + !existingAttractants.some((existing) => existing.attractant_lookup_id === incoming.attractant_lookup_id) + ); + + if (attractantsToInsert.length > 0) { + await this.attractantRepository.insertAttractantsForTechnique(surveyId, methodTechniqueId, attractantsToInsert); + } + } + + /** + * Delete some attractants for a technique. + * + * @param {number} surveyId The ID of the survey the technique is associated with + * @param {number} methodTechniqueId The ID of the technique to delete attractants for + * @param {number[]} attractantLookupIds The IDs of the attractants to delete + * @return {*} {Promise} + * @memberof AttractantService + */ + async deleteTechniqueAttractants( + surveyId: number, + methodTechniqueId: number, + attractantLookupIds: number[] + ): Promise { + await this.attractantRepository.deleteTechniqueAttractants(surveyId, methodTechniqueId, attractantLookupIds); + } + + /** + * Delete all attractants for a technique. + * + * @param {number} surveyId The ID of the survey the technique is associated with + * @param {number} methodTechniqueId The ID of the technique to delete attractants for + * @return {*} {Promise} + * @memberof AttractantService + */ + async deleteAllTechniqueAttractants(surveyId: number, methodTechniqueId: number): Promise { + await this.attractantRepository.deleteAllTechniqueAttractants(surveyId, methodTechniqueId); + } +} diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 0e88eed428..85a2e7a494 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -29,6 +29,7 @@ describe('CodeService', () => { 'management_action_type', 'first_nations', 'agency', + 'attractants', 'investment_action_category', 'type', 'proprietor_type', diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 8802e52ed0..2121227753 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -43,7 +43,8 @@ export class CodeService extends DBService { site_selection_strategies, sample_methods, survey_progress, - method_response_metrics + method_response_metrics, + attractants ] = await Promise.all([ await this.codeRepository.getManagementActionType(), await this.codeRepository.getFirstNations(), @@ -63,7 +64,8 @@ export class CodeService extends DBService { await this.codeRepository.getSiteSelectionStrategies(), await this.codeRepository.getSampleMethods(), await this.codeRepository.getSurveyProgress(), - await this.codeRepository.getMethodResponseMetrics() + await this.codeRepository.getMethodResponseMetrics(), + await this.codeRepository.getAttractants() ]); return { @@ -85,7 +87,8 @@ export class CodeService extends DBService { site_selection_strategies, sample_methods, survey_progress, - method_response_metrics + method_response_metrics, + attractants }; } diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 5bd5200643..5c0a660d26 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -418,6 +418,18 @@ export class ObservationService extends DBService { return this.observationRepository.getObservationsCountBySamplePeriodIds(samplePeriodIds); } + /** + * Retrieves observation records count for the given survey and technique ids + * + * @param {number} surveyId + * @param {number[]} methodTechniqueIds + * @return {*} {Promise} + * @memberof ObservationService + */ + async getObservationsCountByTechniqueIds(surveyId: number, methodTechniqueIds: number[]): Promise { + return this.observationRepository.getObservationsCountByTechniqueIds(surveyId, methodTechniqueIds); + } + /** * Processes an observation CSV file submission. * diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index 49fa0e1e7d..c1b23aba2a 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -50,7 +50,7 @@ describe('SampleLocationService', () => { sample_methods: [ { survey_sample_site_id: 1, - method_lookup_id: 1, + method_technique_id: 1, method_response_metric_id: 1, description: '', sample_periods: [ @@ -215,12 +215,12 @@ describe('SampleLocationService', () => { const methods = [ { survey_sample_method_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'Cool method', sample_periods: [] } as any, - { method_lookup_id: 4, method_response_metric_id: 1, description: 'Cool method', sample_periods: [] } as any + { method_technique_id: 4, method_response_metric_id: 1, description: 'Cool method', sample_periods: [] } as any ]; const blocks = [ { @@ -319,7 +319,7 @@ describe('SampleLocationService', () => { }); expect(insertSampleMethodStub).to.be.calledOnceWith({ survey_sample_site_id: survey_sample_site_id, - method_lookup_id: 4, + method_technique_id: 4, method_response_metric_id: 1, description: 'Cool method', @@ -328,7 +328,7 @@ describe('SampleLocationService', () => { expect(updateSampleMethodStub).to.be.calledOnceWith(mockSurveyId, { survey_sample_site_id: survey_sample_site_id, survey_sample_method_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'Cool method', sample_periods: [] diff --git a/api/src/services/sample-location-service.ts b/api/src/services/sample-location-service.ts index dc6ecabdf9..0eb3f45d67 100644 --- a/api/src/services/sample-location-service.ts +++ b/api/src/services/sample-location-service.ts @@ -167,7 +167,7 @@ export class SampleLocationService extends DBService { sampleLocations.sample_methods.map((item) => { const sampleMethod = { survey_sample_site_id: sampleSiteRecord.survey_sample_site_id, - method_lookup_id: item.method_lookup_id, + method_technique_id: item.method_technique_id, description: item.description, sample_periods: item.sample_periods, method_response_metric_id: item.method_response_metric_id @@ -212,6 +212,9 @@ export class SampleLocationService extends DBService { /** * Updates a survey entire Sample Site Record, with Location and associated methods and periods. * + * TODO: This function awaits every db request, could parallelize similar requests (Promise.all) to improve + * performance. + * * @param {number} surveyId * @param {UpdateSampleLocationRecord} sampleSite * @memberof SampleLocationService @@ -273,19 +276,20 @@ export class SampleLocationService extends DBService { // If it does not exist, create it for (const item of sampleSite.methods) { if (item.survey_sample_method_id) { + // A defined survey_sample_method_id indicates an existing record const sampleMethod = { - survey_sample_site_id: sampleSite.survey_sample_site_id, survey_sample_method_id: item.survey_sample_method_id, - method_lookup_id: item.method_lookup_id, + survey_sample_site_id: sampleSite.survey_sample_site_id, method_response_metric_id: item.method_response_metric_id, description: item.description, - sample_periods: item.sample_periods + sample_periods: item.sample_periods, + method_technique_id: item.method_technique_id }; await methodService.updateSampleMethod(surveyId, sampleMethod); } else { const sampleMethod = { survey_sample_site_id: sampleSite.survey_sample_site_id, - method_lookup_id: item.method_lookup_id, + method_technique_id: item.method_technique_id, method_response_metric_id: item.method_response_metric_id, description: item.description, sample_periods: item.sample_periods diff --git a/api/src/services/sample-method-service.test.ts b/api/src/services/sample-method-service.test.ts index dfb9d9357a..496b03ead1 100644 --- a/api/src/services/sample-method-service.test.ts +++ b/api/src/services/sample-method-service.test.ts @@ -36,7 +36,7 @@ describe('SampleMethodService', () => { { survey_sample_method_id: 1, survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', @@ -79,7 +79,7 @@ describe('SampleMethodService', () => { const mockSampleMethodRecord: SampleMethodRecord = { survey_sample_method_id: 1, survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', @@ -119,7 +119,7 @@ describe('SampleMethodService', () => { const mockSampleMethodRecord: SampleMethodRecord = { survey_sample_method_id: 1, survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', @@ -151,7 +151,7 @@ describe('SampleMethodService', () => { const sampleMethod: InsertSampleMethodRecord = { survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', sample_periods: [ @@ -204,7 +204,7 @@ describe('SampleMethodService', () => { const mockSampleMethodRecord: SampleMethodRecord = { survey_sample_method_id: 1, survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', @@ -225,7 +225,7 @@ describe('SampleMethodService', () => { const sampleMethod: UpdateSampleMethodRecord = { survey_sample_method_id: 1, survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', sample_periods: [ @@ -268,7 +268,7 @@ describe('SampleMethodService', () => { const mockSampleMethodRecord: SampleMethodRecord = { survey_sample_method_id: mockSampleMethodId, survey_sample_site_id: 2, - method_lookup_id: 3, + method_technique_id: 3, method_response_metric_id: 1, description: 'description', create_date: '2023-05-06', diff --git a/api/src/services/sample-method-service.ts b/api/src/services/sample-method-service.ts index fc98ba38aa..79cd434bd1 100644 --- a/api/src/services/sample-method-service.ts +++ b/api/src/services/sample-method-service.ts @@ -1,5 +1,5 @@ import { IDBConnection } from '../database/db'; -import { HTTP400 } from '../errors/http-error'; +import { HTTP409 } from '../errors/http-error'; import { InsertSampleMethodRecord, SampleMethodRecord, @@ -128,7 +128,8 @@ export class SampleMethodService extends DBService { existingSampleMethodIds ); if (samplingMethodObservationsCount > 0) { - throw new HTTP400('Cannot delete a sample method that is associated with an observation'); + // TODO services should not throw HTTP errors (only endpoints should) + throw new HTTP409('Cannot delete a sample method that is associated with an observation'); } await Promise.all( diff --git a/api/src/services/sample-period-service.ts b/api/src/services/sample-period-service.ts index 44e0a45ffe..38cd96d8c7 100644 --- a/api/src/services/sample-period-service.ts +++ b/api/src/services/sample-period-service.ts @@ -1,5 +1,5 @@ import { IDBConnection } from '../database/db'; -import { HTTP400 } from '../errors/http-error'; +import { HTTP409 } from '../errors/http-error'; import { InsertSamplePeriodRecord, SamplePeriodHierarchyIds, @@ -131,7 +131,8 @@ export class SamplePeriodService extends DBService { ); if (samplingPeriodObservationsCount > 0) { - throw new HTTP400('Cannot delete a sample period that is associated with an observation'); + // TODO services should not throw HTTP errors (only endpoints should) + throw new HTTP409('Cannot delete a sample period that is associated with an observation'); } await this.deleteSamplePeriodRecords(surveyId, existingSamplePeriodIds); diff --git a/api/src/services/technique-attributes-service.test.ts b/api/src/services/technique-attributes-service.test.ts new file mode 100644 index 0000000000..bcc5a236bf --- /dev/null +++ b/api/src/services/technique-attributes-service.test.ts @@ -0,0 +1,567 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; + +import { + IQualitativeAttributePostData, + IQuantitativeAttributePostData, + TechniqueAttributeRepository, + TechniqueAttributesLookupObject, + TechniqueAttributesObject +} from '../repositories/technique-attribute-repository'; +import { TechniqueAttributeService } from './technique-attributes-service'; + +chai.use(sinonChai); + +describe('TechniqueAttributeService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getAttributeDefinitionsByMethodLookupIds', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueAttributesLookupObject = { + method_lookup_id: 1, + quantitative_attributes: [], + qualitative_attributes: [] + }; + + sinon + .stub(TechniqueAttributeRepository.prototype, 'getAttributeDefinitionsByMethodLookupIds') + .resolves([mockRecord]); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const methodLookupIds = [1]; + + const response = await service.getAttributeDefinitionsByMethodLookupIds(methodLookupIds); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('getAttributesByTechniqueId', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueAttributesObject = { + quantitative_attributes: [], + qualitative_attributes: [] + }; + + sinon.stub(TechniqueAttributeRepository.prototype, 'getAttributesByTechniqueId').resolves(mockRecord); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const methodTechniqueId = 1; + + const response = await service.getAttributesByTechniqueId(methodTechniqueId); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('insertQuantitativeAttributesForTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { + method_technique_attribute_quantitative_id: 1 + }; + + const _areAttributesValidForTechniqueStub = sinon + .stub(TechniqueAttributeService.prototype, '_areAttributesValidForTechnique') + .resolves(); + + sinon + .stub(TechniqueAttributeRepository.prototype, 'insertQuantitativeAttributesForTechnique') + .resolves([mockRecord]); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const methodTechniqueId = 1; + const attributes: IQuantitativeAttributePostData[] = [ + { + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-22', + value: 3 + } + ]; + + const response = await service.insertQuantitativeAttributesForTechnique(methodTechniqueId, attributes); + + expect(_areAttributesValidForTechniqueStub).to.have.been.calledOnceWith(methodTechniqueId, attributes); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('insertQualitativeAttributesForTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { + method_technique_attribute_qualitative_id: 1 + }; + + const _areAttributesValidForTechniqueStub = sinon + .stub(TechniqueAttributeService.prototype, '_areAttributesValidForTechnique') + .resolves(); + + sinon + .stub(TechniqueAttributeRepository.prototype, 'insertQualitativeAttributesForTechnique') + .resolves([mockRecord]); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const methodTechniqueId = 1; + const attributes: IQualitativeAttributePostData[] = [ + { + method_technique_attribute_qualitative_id: 1, + method_lookup_attribute_qualitative_option_id: '123-456-22', + method_lookup_attribute_qualitative_id: '123-456-33' + } + ]; + + const response = await service.insertQualitativeAttributesForTechnique(methodTechniqueId, attributes); + + expect(_areAttributesValidForTechniqueStub).to.have.been.calledOnceWith(methodTechniqueId, attributes); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('deleteQuantitativeAttributesForTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { + method_technique_attribute_quantitative_id: 1 + }; + + sinon + .stub(TechniqueAttributeRepository.prototype, 'deleteQuantitativeAttributesForTechnique') + .resolves([mockRecord]); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + const methodLookupAttributeQuantitativeIds = [3, 4]; + + const response = await service.deleteQuantitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + methodLookupAttributeQuantitativeIds + ); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('deleteQualitativeAttributesForTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { + method_technique_attribute_qualitative_id: 1 + }; + + sinon + .stub(TechniqueAttributeRepository.prototype, 'deleteQualitativeAttributesForTechnique') + .resolves([mockRecord]); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + const methodLookupAttributeQualitativeIds = [3, 4]; + + const response = await service.deleteQualitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + methodLookupAttributeQualitativeIds + ); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('deleteAllTechniqueAttributes', () => { + it('should run successfully', async () => { + const mockRecord = { + qualitative_attributes: [{ method_technique_attribute_qualitative_id: 3 }], + quantitative_attributes: [{ method_technique_attribute_quantitative_id: 4 }] + }; + + sinon.stub(TechniqueAttributeRepository.prototype, 'deleteAllTechniqueAttributes').resolves(mockRecord); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + + const response = await service.deleteAllTechniqueAttributes(surveyId, methodTechniqueId); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('insertUpdateDeleteQuantitativeAttributesForTechnique', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueAttributesObject = { + quantitative_attributes: [ + { + method_technique_id: 1, + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-22', + value: 22 + }, + { + method_technique_id: 2, + method_technique_attribute_quantitative_id: 2, + method_lookup_attribute_quantitative_id: '123-456-33', + value: 33 + } + ], + qualitative_attributes: [] + }; + + const _areAttributesValidForTechniqueStub = sinon + .stub(TechniqueAttributeService.prototype, '_areAttributesValidForTechnique') + .resolves(); + const getAttributesByTechniqueIdStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'getAttributesByTechniqueId') + .resolves(mockRecord); + const deleteQuantitativeAttributesForTechniqueStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'deleteQuantitativeAttributesForTechnique') + .resolves(); + const insertQuantitativeAttributesForTechniqueStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'insertQuantitativeAttributesForTechnique') + .resolves(); + const updateQuantitativeAttributeForTechniqueStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'updateQuantitativeAttributeForTechnique') + .resolves(); + + const dbConnection = getMockDBConnection({ + knex: sinon + .stub() + .onFirstCall() + .resolves({ rows: [mockRecord], rowCount: 1 }) + .resolves() + }); + + const service = new TechniqueAttributeService(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + const attributes: IQuantitativeAttributePostData[] = [ + { + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-22', + value: 66 + }, + { + method_lookup_attribute_quantitative_id: '123-456-33', + value: 33 + } + ]; + + const response = await service.insertUpdateDeleteQuantitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + attributes + ); + + expect(_areAttributesValidForTechniqueStub).to.have.been.calledOnceWith(methodTechniqueId, attributes); + expect(getAttributesByTechniqueIdStub).to.have.been.calledOnceWith(methodTechniqueId); + expect(deleteQuantitativeAttributesForTechniqueStub).to.have.been.calledOnceWith(surveyId, methodTechniqueId, [ + 2 + ]); + expect(insertQuantitativeAttributesForTechniqueStub).to.have.been.calledOnceWith(methodTechniqueId, [ + { + method_lookup_attribute_quantitative_id: '123-456-33', + value: 33 + } + ]); + expect(updateQuantitativeAttributeForTechniqueStub).to.have.been.calledOnceWith(methodTechniqueId, { + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-22', + value: 66 + }); + + expect(response).to.be.undefined; + }); + }); + + describe('insertUpdateDeleteQualitativeAttributesForTechnique', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueAttributesObject = { + quantitative_attributes: [], + qualitative_attributes: [ + { + method_technique_id: 1, + method_technique_attribute_qualitative_id: 1, + method_lookup_attribute_qualitative_id: '123-456-22', + method_lookup_attribute_qualitative_option_id: '123-456-33' + }, + { + method_technique_id: 2, + method_technique_attribute_qualitative_id: 2, + method_lookup_attribute_qualitative_option_id: '123-456-44', + method_lookup_attribute_qualitative_id: '123-456-55' + } + ] + }; + + const _areAttributesValidForTechniqueStub = sinon + .stub(TechniqueAttributeService.prototype, '_areAttributesValidForTechnique') + .resolves(); + const getAttributesByTechniqueIdStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'getAttributesByTechniqueId') + .resolves(mockRecord); + const deleteQualitativeAttributesForTechniqueStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'deleteQualitativeAttributesForTechnique') + .resolves(); + const insertQualitativeAttributesForTechniqueStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'insertQualitativeAttributesForTechnique') + .resolves(); + const updateQualitativeAttributeForTechniqueStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'updateQualitativeAttributeForTechnique') + .resolves(); + + const dbConnection = getMockDBConnection({ + knex: sinon + .stub() + .onFirstCall() + .resolves({ rows: [mockRecord], rowCount: 1 }) + .resolves() + }); + + const service = new TechniqueAttributeService(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + const attributes: IQualitativeAttributePostData[] = [ + { + method_technique_attribute_qualitative_id: 1, + method_lookup_attribute_qualitative_id: '123-456-22', + method_lookup_attribute_qualitative_option_id: '123-456-99' + }, + { + method_lookup_attribute_qualitative_id: '123-456-66', + method_lookup_attribute_qualitative_option_id: '123-456-77' + } + ]; + + const response = await service.insertUpdateDeleteQualitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + attributes + ); + + expect(_areAttributesValidForTechniqueStub).to.have.been.calledOnceWith(methodTechniqueId, attributes); + expect(getAttributesByTechniqueIdStub).to.have.been.calledOnceWith(methodTechniqueId); + expect(deleteQualitativeAttributesForTechniqueStub).to.have.been.calledOnceWith(surveyId, methodTechniqueId, [2]); + expect(insertQualitativeAttributesForTechniqueStub).to.have.been.calledOnceWith(methodTechniqueId, [ + { + method_lookup_attribute_qualitative_id: '123-456-66', + method_lookup_attribute_qualitative_option_id: '123-456-77' + } + ]); + expect(updateQualitativeAttributeForTechniqueStub).to.have.been.calledOnceWith(methodTechniqueId, { + method_technique_attribute_qualitative_id: 1, + method_lookup_attribute_qualitative_id: '123-456-22', + method_lookup_attribute_qualitative_option_id: '123-456-99' + }); + + expect(response).to.be.undefined; + }); + }); + + describe('_areAttributesValidForTechnique', () => { + it('throws if a qualitative attribute is not valid', async () => { + const mockRecord: TechniqueAttributesLookupObject = { + method_lookup_id: 1, + quantitative_attributes: [ + { + method_lookup_attribute_quantitative_id: '123-456-22', + name: 'quant definition 1', + description: 'quant desc 1', + unit: 'unit 1', + min: 0, + max: null + } + ], + qualitative_attributes: [ + { + method_lookup_attribute_qualitative_id: '123-456-66', + name: 'qual definition 1', + description: 'qual desc 1', + options: [ + { + method_lookup_attribute_qualitative_option_id: '123-456-77', + name: 'option 1', + description: 'option desc 1' + } + ] + } + ] + }; + + const getAttributeDefinitionsByTechniqueIdStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'getAttributeDefinitionsByTechniqueId') + .resolves(mockRecord); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const methodTechniqueId = 2; + const attributes: (IQualitativeAttributePostData | IQuantitativeAttributePostData)[] = [ + { + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-22', + value: 3 + }, + { + method_technique_attribute_qualitative_id: 3, + method_lookup_attribute_qualitative_id: '123-456-invalid', // invalid option + method_lookup_attribute_qualitative_option_id: '123-456-77' + } + ]; + + try { + await service._areAttributesValidForTechnique(methodTechniqueId, attributes); + } catch (error) { + expect(getAttributeDefinitionsByTechniqueIdStub).to.have.been.calledOnceWith(methodTechniqueId); + + expect((error as Error).message).to.equal('Invalid attributes for method_lookup_id'); + } + }); + + it('throws if a quantitative attribute is not valid', async () => { + const mockRecord: TechniqueAttributesLookupObject = { + method_lookup_id: 1, + quantitative_attributes: [ + { + method_lookup_attribute_quantitative_id: '123-456-22', + name: 'quant definition 1', + description: 'quant desc 1', + unit: 'unit 1', + min: 0, + max: null + } + ], + qualitative_attributes: [ + { + method_lookup_attribute_qualitative_id: '123-456-66', + name: 'qual definition 1', + description: 'qual desc 1', + options: [ + { + method_lookup_attribute_qualitative_option_id: '123-456-77', + name: 'option 1', + description: 'option desc 1' + } + ] + } + ] + }; + + const getAttributeDefinitionsByTechniqueIdStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'getAttributeDefinitionsByTechniqueId') + .resolves(mockRecord); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const methodTechniqueId = 2; + const attributes: (IQualitativeAttributePostData | IQuantitativeAttributePostData)[] = [ + { + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-99', // invalid option + value: 3 + }, + { + method_technique_attribute_qualitative_id: 3, + method_lookup_attribute_qualitative_id: '123-456-66', + method_lookup_attribute_qualitative_option_id: '123-456-77' + } + ]; + + try { + await service._areAttributesValidForTechnique(methodTechniqueId, attributes); + } catch (error) { + expect(getAttributeDefinitionsByTechniqueIdStub).to.have.been.calledOnceWith(methodTechniqueId); + + expect((error as Error).message).to.equal('Invalid attributes for method_lookup_id'); + } + }); + + it('should not throw if the attributes are valid', async () => { + const mockRecord: TechniqueAttributesLookupObject = { + method_lookup_id: 1, + quantitative_attributes: [ + { + method_lookup_attribute_quantitative_id: '123-456-22', + name: 'quant definition 1', + description: 'quant desc 1', + unit: 'unit 1', + min: 0, + max: null + } + ], + qualitative_attributes: [ + { + method_lookup_attribute_qualitative_id: '123-456-66', + name: 'qual definition 1', + description: 'qual desc 1', + options: [ + { + method_lookup_attribute_qualitative_option_id: '123-456-77', + name: 'option 1', + description: 'option desc 1' + } + ] + } + ] + }; + + const getAttributeDefinitionsByTechniqueIdStub = sinon + .stub(TechniqueAttributeRepository.prototype, 'getAttributeDefinitionsByTechniqueId') + .resolves(mockRecord); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueAttributeService(dbConnection); + + const methodTechniqueId = 2; + const attributes: (IQualitativeAttributePostData | IQuantitativeAttributePostData)[] = [ + { + method_technique_attribute_quantitative_id: 1, + method_lookup_attribute_quantitative_id: '123-456-22', + value: 3 + }, + { + method_technique_attribute_qualitative_id: 3, + method_lookup_attribute_qualitative_id: '123-456-66', + method_lookup_attribute_qualitative_option_id: '123-456-77' + } + ]; + + const response = await service._areAttributesValidForTechnique(methodTechniqueId, attributes); + + expect(getAttributeDefinitionsByTechniqueIdStub).to.have.been.calledOnceWith(methodTechniqueId); + + expect(response).to.be.undefined; + }); + }); +}); diff --git a/api/src/services/technique-attributes-service.ts b/api/src/services/technique-attributes-service.ts new file mode 100644 index 0000000000..961c77ef5b --- /dev/null +++ b/api/src/services/technique-attributes-service.ts @@ -0,0 +1,338 @@ +import { IDBConnection } from '../database/db'; +import { + IQualitativeAttributePostData, + IQuantitativeAttributePostData, + TechniqueAttributeRepository, + TechniqueAttributesLookupObject, + TechniqueAttributesObject +} from '../repositories/technique-attribute-repository'; +import { DBService } from './db-service'; + +/** + * Service layer for technique attributes. + * + * @export + * @class TechniqueAttributeService + * @extends {DBService} + */ +export class TechniqueAttributeService extends DBService { + techniqueAttributeRepository: TechniqueAttributeRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.techniqueAttributeRepository = new TechniqueAttributeRepository(connection); + } + + /** + * Get quantitative and qualitative attribute definition records for method lookup ids. + * + * @param {number[]} methodLookupIds + * @return {*} {Promise} + * @memberof TechniqueAttributeService + */ + async getAttributeDefinitionsByMethodLookupIds( + methodLookupIds: number[] + ): Promise { + return this.techniqueAttributeRepository.getAttributeDefinitionsByMethodLookupIds(methodLookupIds); + } + + /** + * Get quantitative and qualitative attribute records for a technique id. + * + * @param {number} methodTechniqueId + * @return {*} {Promise} + * @memberof TechniqueAttributeService + */ + async getAttributesByTechniqueId(methodTechniqueId: number): Promise { + return this.techniqueAttributeRepository.getAttributesByTechniqueId(methodTechniqueId); + } + + /** + * Insert quantitative attribute records for a technique. + * + * @param {number} methodTechniqueId + * @param {IQuantitativeAttributePostData[]} attributes + * @return {*} {(Promise<{ method_technique_attribute_quantitative_id: number }[] | undefined>)} + * @memberof TechniqueAttributeService + */ + async insertQuantitativeAttributesForTechnique( + methodTechniqueId: number, + attributes: IQuantitativeAttributePostData[] + ): Promise<{ method_technique_attribute_quantitative_id: number }[] | undefined> { + // Validate that the method lookup id can have the incoming attributes + await this._areAttributesValidForTechnique(methodTechniqueId, attributes); + + return this.techniqueAttributeRepository.insertQuantitativeAttributesForTechnique(methodTechniqueId, attributes); + } + + /** + * Insert qualitative attribute records for a technique. + * + * @param {number} methodTechniqueId + * @param {IQualitativeAttributePostData[]} attributes + * @return {*} {(Promise<{ method_technique_attribute_qualitative_id: number }[] | undefined>)} + * @memberof TechniqueAttributeService + */ + async insertQualitativeAttributesForTechnique( + methodTechniqueId: number, + attributes: IQualitativeAttributePostData[] + ): Promise<{ method_technique_attribute_qualitative_id: number }[] | undefined> { + // Validate that the method lookup id can have the incoming attributes + await this._areAttributesValidForTechnique(methodTechniqueId, attributes); + + return this.techniqueAttributeRepository.insertQualitativeAttributesForTechnique(methodTechniqueId, attributes); + } + + /** + * Delete quantitative attribute records for a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @param {number[]} methodLookupAttributeQuantitativeIds + * @return {*} {Promise<{ method_technique_attribute_quantitative_id: number }[]>} + * @memberof TechniqueAttributeService + */ + async deleteQuantitativeAttributesForTechnique( + surveyId: number, + methodTechniqueId: number, + methodLookupAttributeQuantitativeIds: number[] + ): Promise<{ method_technique_attribute_quantitative_id: number }[]> { + return this.techniqueAttributeRepository.deleteQuantitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + methodLookupAttributeQuantitativeIds + ); + } + + /** + * Delete qualitative attribute records for a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @param {number[]} methodLookupAttributeQualitativeIds + * @return {*} {Promise<{ method_technique_attribute_qualitative_id: number }[]>} + * @memberof TechniqueAttributeService + */ + async deleteQualitativeAttributesForTechnique( + surveyId: number, + methodTechniqueId: number, + methodLookupAttributeQualitativeIds: number[] + ): Promise<{ method_technique_attribute_qualitative_id: number }[]> { + return this.techniqueAttributeRepository.deleteQualitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + methodLookupAttributeQualitativeIds + ); + } + + /** + * Delete all quantitative and qualitative attribute records for a technique. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise<{ + * qualitative_attributes: { method_technique_attribute_qualitative_id: number }[]; + * quantitative_attributes: { method_technique_attribute_quantitative_id: number }[]; + * }>} + * @memberof TechniqueAttributeService + */ + async deleteAllTechniqueAttributes( + surveyId: number, + methodTechniqueId: number + ): Promise<{ + qualitative_attributes: { method_technique_attribute_qualitative_id: number }[]; + quantitative_attributes: { method_technique_attribute_quantitative_id: number }[]; + }> { + return this.techniqueAttributeRepository.deleteAllTechniqueAttributes(surveyId, methodTechniqueId); + } + + /** + * Update quantitative attribute records for a technique. + * + * Inserts new records, updates existing records, and deletes records that are not in the incoming list. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @param {IQuantitativeAttributePostData[]} attributes + * @return {*} {Promise} + * @memberof TechniqueAttributeService + */ + async insertUpdateDeleteQuantitativeAttributesForTechnique( + surveyId: number, + methodTechniqueId: number, + attributes: IQuantitativeAttributePostData[] + ): Promise { + // Validate that the method lookup id can have the incoming attributes + await this._areAttributesValidForTechnique(methodTechniqueId, attributes); + + // Get existing attributes associated with the technique + const allTechniqueAttributes = await this.techniqueAttributeRepository.getAttributesByTechniqueId( + methodTechniqueId + ); + const existingQuantitativeAttributes = allTechniqueAttributes.quantitative_attributes; + + // Find existing attributes to delete + const attributesToDelete = existingQuantitativeAttributes.filter( + (existing) => + !attributes.some( + (incoming) => + incoming.method_technique_attribute_quantitative_id === existing.method_technique_attribute_quantitative_id + ) + ); + + // Delete existing attributes that are not in the new list + if (attributesToDelete.length > 0) { + const attributeIdsToDelete = attributesToDelete.map( + (attribute) => attribute.method_technique_attribute_quantitative_id + ); + + await this.techniqueAttributeRepository.deleteQuantitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + attributeIdsToDelete + ); + } + + // If the incoming data does not have method_technique_attribute_quantitative_id, record is for insert + const attributesForInsert = attributes.filter((attribute) => !attribute.method_technique_attribute_quantitative_id); + + if (attributesForInsert.length > 0) { + await this.techniqueAttributeRepository.insertQuantitativeAttributesForTechnique( + methodTechniqueId, + attributesForInsert + ); + } + + // If the incoming data does have method_technique_attribute_quantitative_id, record is for update + const attributesForUpdate = attributes.filter((attribute) => attribute.method_technique_attribute_quantitative_id); + + const promises = []; + + if (attributesForUpdate.length > 0) { + promises.push( + attributesForUpdate.map((attribute) => + this.techniqueAttributeRepository.updateQuantitativeAttributeForTechnique(methodTechniqueId, attribute) + ) + ); + } + + await Promise.all(promises); + } + + /** + * Update qualitative attribute records for a technique. + * + * Inserts new records, updates existing records, and deletes records that are not in the incoming list. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @param {IQualitativeAttributePostData[]} attributes + * @return {*} {Promise} + * @memberof TechniqueAttributeService + */ + async insertUpdateDeleteQualitativeAttributesForTechnique( + surveyId: number, + methodTechniqueId: number, + attributes: IQualitativeAttributePostData[] + ): Promise { + // Validate that the method lookup id can have the incoming attributes + await this._areAttributesValidForTechnique(methodTechniqueId, attributes); + + // Get existing attributes associated with the technique + const techniqueAttributes = await this.techniqueAttributeRepository.getAttributesByTechniqueId(methodTechniqueId); + const existingQualitativeAttributes = techniqueAttributes.qualitative_attributes; + + // Find existing attributes to delete + const attributesToDelete = existingQualitativeAttributes.filter( + (existing) => + !attributes.some( + (incoming) => + incoming.method_technique_attribute_qualitative_id === existing.method_technique_attribute_qualitative_id + ) + ); + + // Delete existing attributes that are not in the new list + if (attributesToDelete.length > 0) { + const attributeIdsToDelete = attributesToDelete.map( + (attribute) => attribute.method_technique_attribute_qualitative_id + ); + + await this.techniqueAttributeRepository.deleteQualitativeAttributesForTechnique( + surveyId, + methodTechniqueId, + attributeIdsToDelete + ); + } + + // If the incoming data does not have method_technique_attribute_qualitative_id, record is for insert + const attributesForInsert = attributes.filter((attribute) => !attribute.method_technique_attribute_qualitative_id); + + if (attributesForInsert.length > 0) { + await this.techniqueAttributeRepository.insertQualitativeAttributesForTechnique( + methodTechniqueId, + attributesForInsert + ); + } + + // If the incoming data does have method_technique_attribute_qualitative_id, record is for update + const attributesForUpdate = attributes.filter((attribute) => attribute.method_technique_attribute_qualitative_id); + + const promises = []; + + if (attributesForUpdate.length > 0) { + promises.push( + attributesForUpdate.map((attribute) => + this.techniqueAttributeRepository.updateQualitativeAttributeForTechnique(methodTechniqueId, attribute) + ) + ); + } + + await Promise.all(promises); + } + + /** + * Validate that the incoming attributes are valid for the provided method lookup id. + * + * @param {number} methodTechniqueId The method technique id used to fetch the allowed attributes, against which + * the incoming attributes will be validated. + * @param {((IQualitativeAttributePostData | IQuantitativeAttributePostData)[])} incomingAttributes The incoming + * attributes to validate against the reference data in the database. + * @return {*} {Promise} + * @throws {Error} If any of the incoming attributes are not valid for the provided method lookup id. + * @memberof TechniqueAttributeService + */ + async _areAttributesValidForTechnique( + methodTechniqueId: number, + incomingAttributes: (IQualitativeAttributePostData | IQuantitativeAttributePostData)[] + ): Promise { + // Validate that the method lookup id can have the incoming attributes + const validAttributes = await this.techniqueAttributeRepository.getAttributeDefinitionsByTechniqueId( + methodTechniqueId + ); + + for (const incomingAttribute of incomingAttributes) { + if ('method_lookup_attribute_quantitative_id' in incomingAttribute) { + if ( + !validAttributes.quantitative_attributes.some( + (allowedAttribute) => + allowedAttribute.method_lookup_attribute_quantitative_id === + incomingAttribute.method_lookup_attribute_quantitative_id + ) + ) { + throw new Error('Invalid attributes for method_lookup_id'); + } + } else if ('method_lookup_attribute_qualitative_id' in incomingAttribute) { + if ( + !validAttributes.qualitative_attributes.some( + (allowedAttribute) => + allowedAttribute.method_lookup_attribute_qualitative_id === + incomingAttribute.method_lookup_attribute_qualitative_id + ) + ) { + throw new Error('Invalid attributes for method_lookup_id'); + } + } + } + } +} diff --git a/api/src/services/technique-service.test.ts b/api/src/services/technique-service.test.ts new file mode 100644 index 0000000000..e31a7ef8cc --- /dev/null +++ b/api/src/services/technique-service.test.ts @@ -0,0 +1,227 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; + +import { + ITechniquePostData, + ITechniqueRowDataForUpdate, + TechniqueObject, + TechniqueRepository +} from '../repositories/technique-repository'; +import { AttractantService } from './attractants-service'; +import { TechniqueAttributeService } from './technique-attributes-service'; +import { TechniqueService } from './technique-service'; + +chai.use(sinonChai); + +describe('TechniqueService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getTechniqueById', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueObject = { + method_technique_id: 1, + method_lookup_id: 2, + name: 'name', + description: 'desc', + distance_threshold: 200, + attractants: [], + attributes: { + qualitative_attributes: [], + quantitative_attributes: [] + } + }; + + sinon.stub(TechniqueRepository.prototype, 'getTechniqueById').resolves(mockRecord); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueService(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + + const response = await service.getTechniqueById(surveyId, methodTechniqueId); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('getTechniquesForSurveyId', () => { + it('should run successfully', async () => { + const mockRecord: TechniqueObject = { + method_technique_id: 1, + method_lookup_id: 2, + name: 'name', + description: 'desc', + distance_threshold: 200, + attractants: [], + attributes: { + qualitative_attributes: [], + quantitative_attributes: [] + } + }; + + sinon.stub(TechniqueRepository.prototype, 'getTechniquesForSurveyId').resolves([mockRecord]); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueService(dbConnection); + + const surveyId = 1; + const pagination = undefined; + + const response = await service.getTechniquesForSurveyId(surveyId, pagination); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('getTechniquesCountForSurveyId', () => { + it('should run successfully', async () => { + const count = 10; + + sinon.stub(TechniqueRepository.prototype, 'getTechniquesCountForSurveyId').resolves(count); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueService(dbConnection); + + const surveyId = 1; + + const response = await service.getTechniquesCountForSurveyId(surveyId); + + expect(response).to.eql(count); + }); + }); + + describe('insertTechniquesForSurvey', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_id: 11 }; + + const insertTechniqueStub = sinon.stub(TechniqueRepository.prototype, 'insertTechnique').resolves(mockRecord); + const insertTechniqueAttractantsStub = sinon + .stub(AttractantService.prototype, 'insertTechniqueAttractants') + .resolves(); + const insertQualitativeAttributesForTechniqueStub = sinon + .stub(TechniqueAttributeService.prototype, 'insertQualitativeAttributesForTechnique') + .resolves(); + const insertQuantitativeAttributesForTechniqueStub = sinon + .stub(TechniqueAttributeService.prototype, 'insertQuantitativeAttributesForTechnique') + .resolves(); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueService(dbConnection); + + const surveyId = 1; + const techniques: ITechniquePostData[] = [ + { + name: 'name', + description: 'desc', + distance_threshold: 200, + method_lookup_id: 2, + attractants: [ + { + attractant_lookup_id: 111 + } + ], + attributes: { + quantitative_attributes: [ + { + method_technique_attribute_quantitative_id: 44, + method_lookup_attribute_quantitative_id: '123-456-55', + value: 66 + } + ], + qualitative_attributes: [ + { + method_technique_attribute_qualitative_id: 77, + method_lookup_attribute_qualitative_id: '123-456-88', + method_lookup_attribute_qualitative_option_id: '123-456-99' + } + ] + } + } + ]; + + const response = await service.insertTechniquesForSurvey(surveyId, techniques); + + expect(insertTechniqueStub).to.have.been.calledOnceWith(surveyId, { + name: 'name', + description: 'desc', + distance_threshold: 200, + method_lookup_id: 2 + }); + expect(insertTechniqueAttractantsStub).to.have.been.calledOnceWith(surveyId, 11, techniques[0].attractants); + expect(insertQualitativeAttributesForTechniqueStub).to.have.been.calledOnceWith( + 11, + techniques[0].attributes.qualitative_attributes + ); + expect(insertQuantitativeAttributesForTechniqueStub).to.have.been.calledOnceWith( + 11, + techniques[0].attributes.quantitative_attributes + ); + + expect(response).to.eql([mockRecord]); + }); + }); + + describe('updateTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_id: 1 }; + + sinon.stub(TechniqueRepository.prototype, 'updateTechnique').resolves(mockRecord); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueService(dbConnection); + + const surveyId = 1; + const techniqueObject: ITechniqueRowDataForUpdate = { + method_technique_id: 1, + name: 'name', + description: 'desc', + distance_threshold: 200, + method_lookup_id: 2 + }; + + const response = await service.updateTechnique(surveyId, techniqueObject); + + expect(response).to.eql(mockRecord); + }); + }); + + describe('deleteTechnique', () => { + it('should run successfully', async () => { + const mockRecord = { method_technique_id: 1 }; + + const deleteAllTechniqueAttractantsStub = sinon + .stub(AttractantService.prototype, 'deleteAllTechniqueAttractants') + .resolves(); + const deleteAllTechniqueAttributesStub = sinon + .stub(TechniqueAttributeService.prototype, 'deleteAllTechniqueAttributes') + .resolves(); + const deleteTechniqueStub = sinon.stub(TechniqueRepository.prototype, 'deleteTechnique').resolves(mockRecord); + + const dbConnection = getMockDBConnection(); + + const service = new TechniqueService(dbConnection); + + const surveyId = 1; + const methodTechniqueId = 2; + + const response = await service.deleteTechnique(surveyId, methodTechniqueId); + + expect(deleteAllTechniqueAttractantsStub).to.have.been.calledOnceWith(surveyId, methodTechniqueId); + expect(deleteAllTechniqueAttributesStub).to.have.been.calledOnceWith(surveyId, methodTechniqueId); + expect(deleteTechniqueStub).to.have.been.calledOnceWith(surveyId, methodTechniqueId); + + expect(response).to.eql(mockRecord); + }); + }); +}); diff --git a/api/src/services/technique-service.ts b/api/src/services/technique-service.ts new file mode 100644 index 0000000000..44e4435d1d --- /dev/null +++ b/api/src/services/technique-service.ts @@ -0,0 +1,163 @@ +import { IDBConnection } from '../database/db'; +import { + ITechniquePostData, + ITechniqueRowDataForInsert, + ITechniqueRowDataForUpdate, + TechniqueObject, + TechniqueRepository +} from '../repositories/technique-repository'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; +import { AttractantService } from './attractants-service'; +import { DBService } from './db-service'; +import { TechniqueAttributeService } from './technique-attributes-service'; + +/** + * Service layer for techniques. + * + * @export + * @class TechniqueService + * @extends {DBService} + */ +export class TechniqueService extends DBService { + techniqueRepository: TechniqueRepository; + attractantService: AttractantService; + techniqueAttributeService: TechniqueAttributeService; + + constructor(connection: IDBConnection) { + super(connection); + + this.techniqueRepository = new TechniqueRepository(connection); + this.attractantService = new AttractantService(connection); + this.techniqueAttributeService = new TechniqueAttributeService(connection); + } + + /** + * Get a technique by id. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise} + * @memberof TechniqueService + */ + async getTechniqueById(surveyId: number, methodTechniqueId: number): Promise { + return this.techniqueRepository.getTechniqueById(surveyId, methodTechniqueId); + } + + /** + * Get a paginated list of technique records for a survey. + * + * @param {number} surveyId + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof TechniqueService + */ + async getTechniquesForSurveyId(surveyId: number, pagination?: ApiPaginationOptions): Promise { + return this.techniqueRepository.getTechniquesForSurveyId(surveyId, pagination); + } + + /** + * Get the count of all technique records for a survey. + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof TechniqueService + */ + async getTechniquesCountForSurveyId(surveyId: number): Promise { + return this.techniqueRepository.getTechniquesCountForSurveyId(surveyId); + } + + /** + * Insert technique records. + * + * @param {number} surveyId + * @param {ITechniquePostData[]} techniques + * @return {*} {Promise<{ method_technique_id: number }[]>} + * @memberof TechniqueService + */ + async insertTechniquesForSurvey( + surveyId: number, + techniques: ITechniquePostData[] + ): Promise<{ method_technique_id: number }[]> { + // Insert each technique record + const promises = techniques.map(async (technique) => { + const rowForInsert: ITechniqueRowDataForInsert = { + name: technique.name, + description: technique.description, + method_lookup_id: technique.method_lookup_id, + distance_threshold: technique.distance_threshold + }; + + // Insert root technique record + const { method_technique_id } = await this.techniqueRepository.insertTechnique(surveyId, rowForInsert); + + const promises = []; + + // Insert attractants + if (technique.attractants.length) { + promises.push( + this.attractantService.insertTechniqueAttractants(surveyId, method_technique_id, technique.attractants) + ); + } + + // Insert qualitative attributes + if (technique.attributes.qualitative_attributes.length) { + promises.push( + this.techniqueAttributeService.insertQualitativeAttributesForTechnique( + method_technique_id, + technique.attributes.qualitative_attributes + ) + ); + } + + // Insert quantitative attributes + if (technique.attributes.quantitative_attributes.length) { + promises.push( + this.techniqueAttributeService.insertQuantitativeAttributesForTechnique( + method_technique_id, + technique.attributes.quantitative_attributes + ) + ); + } + + await Promise.all(promises); + + return { method_technique_id }; + }); + + return Promise.all(promises); + } + + /** + * Update a technique record. + * + * @param {number} surveyId + * @param {ITechniqueRowDataForUpdate} technique + * @return {*} {Promise<{ method_technique_id: number }>} + * @memberof TechniqueService + */ + async updateTechnique( + surveyId: number, + technique: ITechniqueRowDataForUpdate + ): Promise<{ method_technique_id: number }> { + return this.techniqueRepository.updateTechnique(surveyId, technique); + } + + /** + * Delete a technique record. + * + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise<{ method_technique_id: number }>} + * @memberof TechniqueService + */ + async deleteTechnique(surveyId: number, methodTechniqueId: number): Promise<{ method_technique_id: number }> { + // Delete any attractants on the technique + await this.attractantService.deleteAllTechniqueAttractants(surveyId, methodTechniqueId); + + // Delete any attributes on the technique + await this.techniqueAttributeService.deleteAllTechniqueAttributes(surveyId, methodTechniqueId); + + // Delete the technique + return this.techniqueRepository.deleteTechnique(surveyId, methodTechniqueId); + } +} diff --git a/app/src/assets/images/observations-overlay.png b/app/src/assets/images/observations-overlay.png new file mode 100644 index 0000000000..a28227b894 Binary files /dev/null and b/app/src/assets/images/observations-overlay.png differ diff --git a/app/src/assets/images/sample-site-overlay.png b/app/src/assets/images/sample-site-overlay.png new file mode 100644 index 0000000000..38d89fca0a Binary files /dev/null and b/app/src/assets/images/sample-site-overlay.png differ diff --git a/app/src/components/accordion/AccordionCard.tsx b/app/src/components/accordion/AccordionCard.tsx new file mode 100644 index 0000000000..2a604276b7 --- /dev/null +++ b/app/src/components/accordion/AccordionCard.tsx @@ -0,0 +1,92 @@ +import { mdiChevronDown, mdiDotsVertical } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Paper from '@mui/material/Paper'; + +interface IAccordionCardProps { + /** + * The content to display in the non-collapsible portion of the card. + */ + summaryContent: JSX.Element; + /** + * The content to display in the collapsible portion of the card, when expanded. + */ + detailsContent: JSX.Element; + /** + * Callback for when the menu button is clicked. + * If not provided, the menu button will not be rendered. + */ + onMenuClick?: (event: React.MouseEvent) => void; + /** + * Icon to display in the menu button. + * Defaults to three vertical dots. + */ + menuIcon?: JSX.Element; + /** + * If true, the accordion will be expanded by default. + */ + expanded?: boolean; +} + +/** + * General purpose accordion card component. + * + * @param {IAccordionCardProps} props + * @return {*} + */ +export const AccordionCard = (props: IAccordionCardProps) => { + const { summaryContent, detailsContent, onMenuClick, menuIcon, expanded } = props; + + return ( + + + } + aria-controls="panel1bh-content" + sx={{ + flex: '1 1 auto', + mr: 1, + pr: 8.5, + minHeight: 55, + overflow: 'hidden', + border: 0, + '& .MuiAccordionSummary-content': { + flex: '1 1 auto', + py: 0, + pl: 0, + overflow: 'hidden', + whiteSpace: 'nowrap' + } + }}> + {summaryContent} + + {onMenuClick && ( + + {menuIcon || } + + )} + + {detailsContent} + + ); +}; diff --git a/app/src/components/data-grid/StyledDataGrid.tsx b/app/src/components/data-grid/StyledDataGrid.tsx index 728b29493d..ba283d3a8c 100644 --- a/app/src/components/data-grid/StyledDataGrid.tsx +++ b/app/src/components/data-grid/StyledDataGrid.tsx @@ -1,25 +1,28 @@ import { grey } from '@mui/material/colors'; import { DataGrid, DataGridProps, GridValidRowModel } from '@mui/x-data-grid'; -import { SkeletonList } from 'components/loading/SkeletonLoaders'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; import { useCallback } from 'react'; import StyledDataGridOverlay from './StyledDataGridOverlay'; -const StyledLoadingOverlay = () => ; export type StyledDataGridProps = DataGridProps & { noRowsMessage?: string; + noRowsOverlay?: JSX.Element; }; export const StyledDataGrid = (props: StyledDataGridProps) => { + const loadingOverlay = () => ; + const noRowsOverlay = useCallback( - () => , - [props.noRowsMessage] + () => props.noRowsOverlay ?? , + [props.noRowsMessage, props.noRowsOverlay] ); return ( autoHeight {...props} + disableColumnMenu slots={{ - loadingOverlay: StyledLoadingOverlay, + loadingOverlay: loadingOverlay, noRowsOverlay: noRowsOverlay, ...props.slots }} diff --git a/app/src/components/dialog/ErrorDialog.tsx b/app/src/components/dialog/ErrorDialog.tsx index f477cf07b6..4484801de0 100644 --- a/app/src/components/dialog/ErrorDialog.tsx +++ b/app/src/components/dialog/ErrorDialog.tsx @@ -1,3 +1,5 @@ +import { ListItem } from '@mui/material'; +import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Collapse from '@mui/material/Collapse'; import Dialog from '@mui/material/Dialog'; @@ -5,6 +7,9 @@ import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; +import Link from '@mui/material/Link'; +import List from '@mui/material/List'; +import Stack from '@mui/material/Stack'; import React from 'react'; export interface IErrorDialogProps { @@ -68,15 +73,13 @@ export const ErrorDialog = (props: IErrorDialogProps) => { const [isExpanded, setIsExpanded] = React.useState(false); const ErrorDetailsList = (errorProps: { errors: (string | object)[] }) => { - const items = errorProps.errors.map((error, index) => { - if (typeof error === 'string') { - return
  • {error}
  • ; - } + const items = errorProps.errors.map((error, index) => ( + + {JSON.stringify(error, null, 2)} + + )); - return
  • {JSON.stringify(error)}
  • ; - }); - - return
      {items}
    ; + return {items}; }; if (!props.open) { @@ -93,21 +96,23 @@ export const ErrorDialog = (props: IErrorDialogProps) => { {props.dialogTitle} - {props.dialogText} - {props.dialogError && {props.dialogError}} + + {props.dialogText} + {props.dialogError && ( + {props.dialogError} + )} - {props?.dialogErrorDetails?.length ? ( - <> - - - - - - ) : ( - <> - )} + {props?.dialogErrorDetails?.length ? ( + + setIsExpanded(!isExpanded)}> + {isExpanded ? 'Hide detailed error message' : 'Show detailed error message'} + + + + + + ) : null} + diff --git a/app/src/components/fields/AutocompleteField.tsx b/app/src/components/fields/AutocompleteField.tsx index e2c39a106d..974d0ccddd 100644 --- a/app/src/components/fields/AutocompleteField.tsx +++ b/app/src/components/fields/AutocompleteField.tsx @@ -1,6 +1,9 @@ import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; +import grey from '@mui/material/colors/grey'; import TextField, { TextFieldProps } from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; import { useFormikContext } from 'formik'; import get from 'lodash-es/get'; import { SyntheticEvent } from 'react'; @@ -8,6 +11,7 @@ import { SyntheticEvent } from 'react'; export interface IAutocompleteFieldOption { value: T; label: string; + description?: string | null; } export interface IAutocompleteField { @@ -20,9 +24,12 @@ export interface IAutocompleteField { sx?: TextFieldProps['sx']; //https://github.com/TypeStrong/fork-ts-checker-webpack-plugin/issues/271#issuecomment-1561891271 required?: boolean; filterLimit?: number; + showValue?: boolean; optionFilter?: 'value' | 'label'; // used to filter existing/ set data for the AutocompleteField, defaults to value in getExistingValue function getOptionDisabled?: (option: IAutocompleteFieldOption) => boolean; onChange?: (event: SyntheticEvent, option: IAutocompleteFieldOption | null) => void; + renderOption?: (params: React.HTMLAttributes, option: IAutocompleteFieldOption) => React.ReactNode; + onInputChange?: (event: React.SyntheticEvent, value: string, reason: string) => void; } // To be used when you want an autocomplete field with no freesolo allowed but only one option can be selected @@ -66,41 +73,75 @@ const AutocompleteField = (props: IAutocompleteField< disabled={props?.disabled || false} sx={props.sx} loading={props.loading} + onInputChange={(_event, _value, reason) => { + if (reason === 'reset') { + return; + } + + if (reason === 'clear') { + setFieldValue(props.name, null); + return; + } + }} onChange={(event, option) => { if (props.onChange) { props.onChange(event, option); return; } - setFieldValue(props.name, option?.value); + if (option?.value) { + setFieldValue(props.name, option?.value); + } }} - onInputChange={(_event, _value, reason) => { - if (reason === 'clear') { - setFieldValue(props.name, ''); + renderOption={(params, option) => { + if (props.renderOption) { + return props.renderOption(params, option); } + + return ( + + + {option.label} + {option.description && ( + + {option.description} + + )} + + + ); + }} + renderInput={(params) => { + return ( + + {props.loading ? : null} + {params.InputProps.endAdornment} + + ) + }} + /> + ); }} - renderInput={(params) => ( - - {props.loading ? : null} - {params.InputProps.endAdornment} - - ) - }} - /> - )} /> ); }; diff --git a/app/src/components/fields/CustomTextField.tsx b/app/src/components/fields/CustomTextField.tsx index 012c1477c4..38744291fd 100644 --- a/app/src/components/fields/CustomTextField.tsx +++ b/app/src/components/fields/CustomTextField.tsx @@ -2,11 +2,29 @@ import TextField from '@mui/material/TextField'; import { useFormikContext } from 'formik'; import get from 'lodash-es/get'; export interface ICustomTextField { + /** + * Label for the text field + * + * @type {string} + * @memberof ICustomTextField + */ label: string; + /** + * Name of the text field, typically this is used to identify the field in the formik context. + * + * @type {string} + * @memberof ICustomTextField + */ name: string; + /** + * Optional maxLength for the text field. + * + * @type {number} + * @memberof ICustomTextField + */ maxLength?: number; /* - * Needed fix: Add correct hardcoded type + * TODO: Needed fix: Add correct hardcoded type * Note: TextFieldProps causes build compile issue * https://github.com/mui/material-ui/issues/30038 */ diff --git a/app/src/components/fields/DateTimeFields.tsx b/app/src/components/fields/DateTimeFields.tsx index 9e21d26c55..959087cb7d 100644 --- a/app/src/components/fields/DateTimeFields.tsx +++ b/app/src/components/fields/DateTimeFields.tsx @@ -15,6 +15,14 @@ interface IDateTimeFieldsProps { dateId: string; dateRequired: boolean; dateIcon: string; + /** + * Boolean flag to indicate if the date field is invalid, instead of the default formik value. + */ + dateError?: boolean; + /** + * Helper text to display when the date field is invalid, instead of the default formik value. + */ + dateHelperText?: string; }; time: { timeLabel: string; @@ -22,6 +30,14 @@ interface IDateTimeFieldsProps { timeId: string; timeRequired: boolean; timeIcon: string; + /** + * Boolean flag to indicate if the time field is invalid, instead of the default formik value. + */ + timeError?: boolean; + /** + * Helper text to display when the time field is invalid, instead of the default formik value. + */ + timeHelperText?: string; }; formikProps: FormikContextType; } @@ -29,8 +45,8 @@ interface IDateTimeFieldsProps { export const DateTimeFields = (props: IDateTimeFieldsProps) => { const { formikProps: { values, errors, touched, setFieldValue }, - date: { dateLabel, dateName, dateId, dateRequired, dateIcon }, - time: { timeLabel, timeName, timeId, timeRequired, timeIcon } + date: { dateLabel, dateName, dateId, dateRequired, dateIcon, dateError, dateHelperText }, + time: { timeLabel, timeName, timeId, timeRequired, timeIcon, timeError, timeHelperText } } = props; const DateIcon = () => { @@ -75,8 +91,8 @@ export const DateTimeFields = (props: IDateTimeFieldsProps(props: IDateTimeFieldsProps { - const { title, summary, component } = props; +const HorizontalSplitFormComponent = (props: PropsWithChildren) => { + const { title, summary, component, children } = props; return ( @@ -50,7 +51,7 @@ const HorizontalSplitFormComponent = (props: IHorizontalSplitFormComponentProps) )} - {component} + {component || children} ); diff --git a/app/src/components/loading/LoadingGuard.tsx b/app/src/components/loading/LoadingGuard.tsx new file mode 100644 index 0000000000..3ab107dc81 --- /dev/null +++ b/app/src/components/loading/LoadingGuard.tsx @@ -0,0 +1,43 @@ +import { PropsWithChildren, useEffect, useState } from 'react'; + +export interface ILoadingGuardProps { + isLoading: boolean; + fallback: JSX.Element; + delay?: number; +} + +/** + * Renders `props.children` if `isLoading` is false, otherwise renders `fallback`. + * + * If `delay` is provided, the fallback will be shown for at least `delay` milliseconds. + * + * Fallback should be a loading spinner or skeleton component, etc. + * + * @param {PropsWithChildren} props + * @return {*} + */ +export const LoadingGuard = (props: PropsWithChildren) => { + const { isLoading, fallback, delay, children } = props; + + const [showFallback, setShowFallback] = useState(isLoading); + + useEffect(() => { + if (!isLoading) { + // If the loading state changes to false, hide the fallback + if (delay) { + setTimeout(() => { + // Show the fallback for at least `delay` milliseconds + setShowFallback(false); + }, delay); + } else { + setShowFallback(false); + } + } + }, [isLoading, delay]); + + if (showFallback) { + return <>{fallback}; + } + + return <>{children}; +}; diff --git a/app/src/components/loading/SkeletonLoaders.tsx b/app/src/components/loading/SkeletonLoaders.tsx index 824b204fed..032477d6b5 100644 --- a/app/src/components/loading/SkeletonLoaders.tsx +++ b/app/src/components/loading/SkeletonLoaders.tsx @@ -70,17 +70,7 @@ const SkeletonHorizontalStack = (props: IMultipleSkeletonProps) => ( ); const SkeletonTable = (props: IMultipleSkeletonProps) => ( - + {Array.from(Array(props.numberOfLines ?? 3).keys()).map((key: number) => ( @@ -120,12 +110,6 @@ const SkeletonRow = () => ( const SkeletonMap = () => ( ( color: grey[300] } }}> - - + + + + ); diff --git a/app/src/constants/i18n.ts b/app/src/constants/i18n.ts index c21e3411ba..c949a9b6b5 100644 --- a/app/src/constants/i18n.ts +++ b/app/src/constants/i18n.ts @@ -322,6 +322,36 @@ export const CreateSamplingSiteI18N = { 'An error has occurred while attempting to create your sampling site(s). Please try again. If the error persists, please contact your system administrator.' }; +export const CreateTechniqueI18N = { + cancelTitle: 'Discard changes and exit?', + cancelText: 'Any changes you have made will not be saved. Do you want to proceed?', + createErrorTitle: 'Error Creating Technique', + createErrorText: + 'An error has occurred while attempting to create your technique. Please try again. If the error persists, please contact your system administrator.' +}; + +export const EditTechniqueI18N = { + cancelTitle: 'Discard changes and exit?', + cancelText: 'Any changes you have made will not be saved. Do you want to proceed?', + createErrorTitle: 'Error Editing Technique', + createErrorText: + 'An error has occurred while attempting to edit your technique. Please try again. If the error persists, please contact your system administrator.' +}; + +export const DeleteTechniqueI18N = { + deleteTitle: 'Delete Technique?', + deleteText: 'Are you sure you want to delete this technique?', + yesButtonLabel: 'Delete Technique', + noButtonLabel: 'Cancel' +}; + +export const DeleteTechniquesBulkI18N = { + deleteTitle: 'Delete Techniques?', + deleteText: 'Are you sure you want to delete these techniques?', + yesButtonLabel: 'Delete Techniques', + noButtonLabel: 'Cancel' +}; + export const ObservationsTableI18N = { removeAllDialogTitle: 'Discard changes?', removeAllDialogText: 'Are you sure you want to discard all your changes? This action cannot be undone.', diff --git a/app/src/contexts/surveyContext.tsx b/app/src/contexts/surveyContext.tsx index 5c56f9843b..1cc7d9c15f 100644 --- a/app/src/contexts/surveyContext.tsx +++ b/app/src/contexts/surveyContext.tsx @@ -8,6 +8,7 @@ import { IGetSurveyForViewResponse, ISimpleCritterWithInternalId } from 'interfaces/useSurveyApi.interface'; +import { IGetTechniquesResponse } from 'interfaces/useTechniqueApi.interface'; import { createContext, PropsWithChildren, useEffect, useMemo } from 'react'; import { useParams } from 'react-router'; @@ -42,8 +43,22 @@ export interface ISurveyContext { */ sampleSiteDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>; + /** + * The Data Loader used to load telemetry device deployments + * + * @type {DataLoader<[project_id: number, survey_id: number], IAnimalDeployment, unknown>} + * @memberof ISurveyContext + */ deploymentDataLoader: DataLoader<[project_id: number, survey_id: number], IAnimalDeployment[], unknown>; + /** + * The Data Loader used to load survey techniques + * + * @type {DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>} + * @memberof ISurveyContext + */ + techniqueDataLoader: DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>; + /** * The Data Loader used to load critters for a given survey * @@ -81,6 +96,7 @@ export const SurveyContext = createContext({ surveyDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSurveyForViewResponse, unknown>, artifactDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSurveyAttachmentsResponse, unknown>, sampleSiteDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>, + techniqueDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>, deploymentDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IAnimalDeployment[], unknown>, critterDataLoader: {} as DataLoader<[project_id: number, survey_id: number], ISimpleCritterWithInternalId[], unknown>, critterDeployments: [], @@ -95,6 +111,7 @@ export const SurveyContextProvider = (props: PropsWithChildren = useParams(); @@ -156,6 +173,7 @@ export const SurveyContextProvider = (props: PropsWithChildren { - {/* Sample Site Routes TODO: Remove unused path and page */} - - - - - - - + {/* Sampling routes */} + - - - + diff --git a/app/src/features/surveys/observations/SurveyObservationPage.tsx b/app/src/features/surveys/observations/SurveyObservationPage.tsx index db48531090..775702eccd 100644 --- a/app/src/features/surveys/observations/SurveyObservationPage.tsx +++ b/app/src/features/surveys/observations/SurveyObservationPage.tsx @@ -9,7 +9,7 @@ import { SurveyContext } from 'contexts/surveyContext'; import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; import { useContext } from 'react'; import ObservationsTableContainer from './observations-table/ObservationsTableContainer'; -import SamplingSiteList from './sampling-sites/list/SamplingSiteList'; +import { SamplingSiteListContainer } from './sampling-sites/SamplingSiteListContainer'; import SurveyObservationHeader from './SurveyObservationHeader'; export const SurveyObservationPage = () => { @@ -48,7 +48,7 @@ export const SurveyObservationPage = () => { {/* Sampling Site List */} - + diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx index 970f794743..e523621720 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTable.tsx @@ -27,8 +27,6 @@ const ObservationsTable = (props: ISpeciesObservationTableProps) => { return ( <> - {props.isLoading && } - { rowHeight={56} getRowHeight={() => 'auto'} getRowClassName={(params) => (has(observationsTableContext.validationModel, params.row.id) ? 'error' : '')} + // Loading + loading={observationsTableContext.isLoading} + slots={{ + loadingOverlay: SkeletonTable + }} + // Styles sx={{ border: 'none', borderRadius: 0, diff --git a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx index f2311fb843..c7ac13bbde 100644 --- a/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx +++ b/app/src/features/surveys/observations/observations-table/ObservationsTableContainer.tsx @@ -36,7 +36,7 @@ import ObservationsTable from 'features/surveys/observations/observations-table/ import { useCodesContext, useObservationsPageContext, useObservationsTableContext } from 'hooks/useContext'; import { IGetSampleLocationDetails, - IGetSampleMethodRecord, + IGetSampleMethodDetails, IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; import { useContext } from 'react'; @@ -48,7 +48,7 @@ import { getMeasurementColumnDefinitions } from './grid-column-definitions/GridColumnDefinitionsUtils'; -const ObservationComponent = () => { +const ObservationsTableContainer = () => { const codesContext = useCodesContext(); const surveyContext = useContext(SurveyContext); @@ -65,15 +65,14 @@ const ObservationComponent = () => { })) ?? []; // Collect sample methods - const surveySampleMethods: IGetSampleMethodRecord[] = surveySampleSites + const surveySampleMethods: IGetSampleMethodDetails[] = surveySampleSites .filter((sampleSite) => Boolean(sampleSite.sample_methods)) - .map((sampleSite) => sampleSite.sample_methods as IGetSampleMethodRecord[]) + .map((sampleSite) => sampleSite.sample_methods as IGetSampleMethodDetails[]) .flat(2); const sampleMethodOptions: ISampleMethodOption[] = surveySampleMethods.map((method) => ({ survey_sample_method_id: method.survey_sample_method_id, survey_sample_site_id: method.survey_sample_site_id, - sample_method_name: - getCodesName(codesContext.codesDataLoader.data, 'sample_methods', method.method_lookup_id) ?? '', + sample_method_name: method.technique.name, response_metric: getCodesName(codesContext.codesDataLoader.data, 'method_response_metrics', method.method_response_metric_id) ?? '' })); @@ -192,4 +191,4 @@ const ObservationComponent = () => { ); }; -export default ObservationComponent; +export default ObservationsTableContainer; diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteList.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx similarity index 98% rename from app/src/features/surveys/observations/sampling-sites/list/SamplingSiteList.tsx rename to app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx index 38f6959ede..6212c09039 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteList.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx @@ -1,4 +1,4 @@ -import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiCog, mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -17,7 +17,7 @@ import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { SkeletonList } from 'components/loading/SkeletonLoaders'; -import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/list/SamplingSiteListSite'; +import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListSite'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useCodesContext, useDialogContext, useObservationsPageContext, useSurveyContext } from 'hooks/useContext'; import { useEffect, useState } from 'react'; @@ -28,7 +28,7 @@ import { Link as RouterLink } from 'react-router-dom'; * * @return {*} */ -const SamplingSiteList = () => { +export const SamplingSiteListContainer = () => { const surveyContext = useSurveyContext(); const codesContext = useCodesContext(); const dialogContext = useDialogContext(); @@ -276,9 +276,9 @@ const SamplingSiteList = () => { color="primary" component={RouterLink} to={'sampling'} - startIcon={} + startIcon={} disabled={observationsPageContext.isDisabled}> - Add + Manage { ); }; - -export default SamplingSiteList; diff --git a/app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx b/app/src/features/surveys/observations/sampling-sites/components/ImportObservationsButton.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/list/import-observations/ImportObservationsButton.tsx rename to app/src/features/surveys/observations/sampling-sites/components/ImportObservationsButton.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteGroupingsForm.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteGroupingsForm.tsx deleted file mode 100644 index 1def301da1..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteGroupingsForm.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import Box from '@mui/material/Box'; -import SamplingBlockForm from '../edit/form/SamplingBlockForm'; -import SamplingStratumForm from '../edit/form/SamplingStratumForm'; - -const SamplingSiteGroupingsForm = () => { - return ( - <> - - - - - - ); -}; - -export default SamplingSiteGroupingsForm; diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx similarity index 77% rename from app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx rename to app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx index 4675d03c52..aed4ad87ba 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListMethod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx @@ -1,14 +1,14 @@ +import grey from '@mui/material/colors/grey'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; +import { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod'; import { useCodesContext, useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; -import { IGetSampleMethodRecord } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleMethodDetails } from 'interfaces/useSamplingSiteApi.interface'; import { useEffect } from 'react'; -import { getCodesName } from 'utils/Utils'; -import SamplingSiteListPeriod from './SamplingSiteListPeriod'; export interface ISamplingSiteListMethodProps { - sampleMethod: IGetSampleMethodRecord; + sampleMethod: IGetSampleMethodDetails; } /** @@ -39,14 +39,15 @@ export const SamplingSiteListMethod = (props: ISamplingSiteListMethodProps) => { }}> {sampleMethod.sample_periods.length > 0 && ( diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx similarity index 94% rename from app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx rename to app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx index a9d8774a08..fee52ec6e2 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListPeriod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx @@ -7,9 +7,9 @@ import Typography from '@mui/material/Typography'; import { IObservationsContext } from 'contexts/observationsContext'; import { IObservationsPageContext } from 'contexts/observationsPageContext'; import dayjs from 'dayjs'; +import { ImportObservationsButton } from 'features/surveys/observations/sampling-sites/components/ImportObservationsButton'; +import { ISurveySampleMethodPeriodData } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; -import { ISurveySampleMethodPeriodData } from '../create/form/MethodForm'; -import { ImportObservationsButton } from './import-observations/ImportObservationsButton'; interface ISamplingSiteListPeriodProps { samplePeriods: (IGetSamplePeriodRecord | ISurveySampleMethodPeriodData)[]; @@ -21,7 +21,7 @@ interface ISamplingSiteListPeriodProps { * @param props {ISamplingSiteListPeriodProps} * @returns */ -const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { +export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { const formatDate = (dt: Date, time: boolean) => dayjs(dt).format(time ? 'MMM D, YYYY h:mm A' : 'MMM D, YYYY'); const { observationsPageContext, observationsContext } = props; @@ -150,5 +150,3 @@ const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { ); }; - -export default SamplingSiteListPeriod; diff --git a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx similarity index 97% rename from app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx rename to app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx index 51a162e42f..cf9ed37ad9 100644 --- a/app/src/features/surveys/observations/sampling-sites/list/SamplingSiteListSite.tsx +++ b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx @@ -12,10 +12,10 @@ import List from '@mui/material/List'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { IStaticLayer } from 'components/map/components/StaticLayers'; -import { SamplingSiteListMethod } from 'features/surveys/observations/sampling-sites/list/SamplingSiteListMethod'; +import { SamplingSiteListMethod } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListMethod'; +import { SamplingStratumChips } from 'features/surveys/sampling-information/sites/edit/form/SamplingStratumChips'; import SurveyMap from 'features/surveys/view/SurveyMap'; import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; -import SamplingStratumChips from '../edit/form/SamplingStratumChips'; export interface ISamplingSiteListSiteProps { sampleSite: IGetSampleLocationDetails; diff --git a/app/src/features/surveys/observations/sampling-sites/create/form/CreateSamplingMethod.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/CreateSamplingMethod.tsx deleted file mode 100644 index 519a0ddc23..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/create/form/CreateSamplingMethod.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import EditDialog from 'components/dialog/EditDialog'; -import MethodForm, { - ISurveySampleMethodData, - SamplingSiteMethodYupSchema, - SurveySampleMethodDataInitialValues -} from './MethodForm'; - -interface ISamplingMethodProps { - open: boolean; - onSubmit: (data: ISurveySampleMethodData) => void; - onClose: () => void; -} - -/** - * Returns a form for creating a sampling method - * - * @returns - */ -const CreateSamplingMethod = (props: ISamplingMethodProps) => { - const handleSubmit = (values: ISurveySampleMethodData) => { - props.onSubmit(values); - }; - - return ( - <> - , - initialValues: SurveySampleMethodDataInitialValues, - validationSchema: SamplingSiteMethodYupSchema - }} - dialogSaveButtonLabel="Add" - onCancel={() => props.onClose()} - onSave={(formValues) => { - handleSubmit(formValues); - }} - /> - - ); -}; - -export default CreateSamplingMethod; diff --git a/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx deleted file mode 100644 index cba48b6148..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/create/form/MethodForm.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { mdiArrowRightThin, mdiCalendarMonthOutline, mdiClockOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import Alert from '@mui/material/Alert'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import CustomTextField from 'components/fields/CustomTextField'; -import { DateTimeFields } from 'components/fields/DateTimeFields'; -import SelectWithSubtextField, { ISelectWithSubtextFieldOption } from 'components/fields/SelectWithSubtext'; -import { CodesContext } from 'contexts/codesContext'; -import { default as dayjs } from 'dayjs'; -import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; -import { useContext, useEffect } from 'react'; -import yup from 'utils/YupSchema'; - -export interface ISurveySampleMethodPeriodData { - survey_sample_period_id: number | null; - survey_sample_method_id: number | null; - start_date: string; - end_date: string; - start_time: string | null; - end_time: string | null; -} - -export interface ISurveySampleMethodData { - survey_sample_method_id: number | null; - survey_sample_site_id: number | null; - method_lookup_id: number | null; - description: string; - sample_periods: ISurveySampleMethodPeriodData[]; - method_response_metric_id: number | null; -} - -export const SurveySampleMethodPeriodArrayItemInitialValues = { - method_lookup_id: null, - survey_sample_period_id: null, - survey_sample_method_id: null, - start_date: '', - end_date: '', - start_time: '', - end_time: '' -}; - -export const SurveySampleMethodDataInitialValues = { - survey_sample_method_id: null, - survey_sample_site_id: null, - method_lookup_id: null, - description: '', - sample_periods: [SurveySampleMethodPeriodArrayItemInitialValues], - method_response_metric_id: '' as unknown as null -}; - -export const SamplingSiteMethodYupSchema = yup.object({ - method_lookup_id: yup.number().typeError('Method is required').required('Method is required'), - method_response_metric_id: yup - .number() - .typeError('Response Metric is required') - .required('Response Metric is required'), - description: yup.string().max(250, 'Maximum 250 characters'), - sample_periods: yup - .array( - yup - .object({ - start_date: yup - .string() - .typeError('Start Date is required') - .isValidDateString() - .required('Start Date is required'), - end_date: yup - .string() - .typeError('End Date is required') - .isValidDateString() - .required('End Date is required') - .isEndDateSameOrAfterStartDate('start_date'), - start_time: yup.string().when('end_time', { - is: (val: string | null) => val && val !== null, - then: yup.string().typeError('Start Time is required').required('Start Time is required'), - otherwise: yup.string().nullable() - }), - end_time: yup.string().nullable() - }) - .test('checkDatesAreSameAndEndTimeIsAfterStart', 'End date must be after start date', function (value) { - const { start_date, end_date, start_time, end_time } = value; - - if (start_date === end_date && start_time && end_time) { - return dayjs(`${start_date} ${start_time}`, 'YYYY-MM-DD HH:mm:ss').isBefore( - dayjs(`${end_date} ${end_time}`, 'YYYY-MM-DD HH:mm:ss') - ); - } - return true; - }) - ) - .hasUniqueDateRanges('Periods cannot overlap', 'start_date', 'end_date') - .min(1, 'At least one time period is required') -}); - -/** - * Returns a form for editing a sampling method - * - * @returns - */ -const MethodForm = () => { - const formikProps = useFormikContext(); - const { values, errors } = formikProps; - - const codesContext = useContext(CodesContext); - - const methodResponseMetricOptions: ISelectWithSubtextFieldOption[] = - codesContext.codesDataLoader.data?.method_response_metrics.map((option) => ({ - value: option.id, - label: option.name, - subText: option.description - })) ?? []; - - const methodOptions: ISelectWithSubtextFieldOption[] = - codesContext.codesDataLoader.data?.sample_methods.map((option) => ({ - value: option.id, - label: option.name, - subText: option.description - })) ?? []; - - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - - if (!codesContext.codesDataLoader.data) { - return ; - } - - return ( -
    - - - Details - - - - - - - - Periods - - - ( - - {errors.sample_periods && typeof errors.sample_periods === 'string' && ( - - {String(errors.sample_periods)} - - )} - - - {values.sample_periods?.map((period, index) => { - return ( - - - - - - {errors.sample_periods && - typeof errors.sample_periods !== 'string' && - errors.sample_periods[index] && - typeof errors.sample_periods[index] === 'string' && ( - - {String(errors.sample_periods[index])} - - )} - - - - - - - - - {errors.sample_periods && - typeof errors.sample_periods !== 'string' && - errors.sample_periods[index] && - typeof errors.sample_periods[index] === 'string' && ( - - {String(errors.sample_periods[index])} - - )} - - - arrayHelpers.remove(index)}> - - - - - ); - })} - - - - - )} - /> - - - -
    - ); -}; - -export default MethodForm; diff --git a/app/src/features/surveys/observations/sampling-sites/create/form/SampleSiteCreateForm.tsx b/app/src/features/surveys/observations/sampling-sites/create/form/SampleSiteCreateForm.tsx deleted file mode 100644 index 09e0e9029f..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/create/form/SampleSiteCreateForm.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import LoadingButton from '@mui/lab/LoadingButton/LoadingButton'; -import Button from '@mui/material/Button'; -import Container from '@mui/material/Container'; -import Divider from '@mui/material/Divider'; -import Paper from '@mui/material/Paper'; -import Stack from '@mui/material/Stack'; -import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; -import SurveySamplingSiteImportForm from 'features/surveys/components/locations/SurveySamplingSiteImportForm'; -import SamplingMethodForm from 'features/surveys/observations/sampling-sites/create/form/SamplingMethodForm'; -import { useFormikContext } from 'formik'; -import { useSurveyContext } from 'hooks/useContext'; -import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; -import { useHistory } from 'react-router'; -import SamplingSiteGroupingsForm from '../../components/SamplingSiteGroupingsForm'; - -interface ISampleSiteCreateFormProps { - isSubmitting: boolean; -} - -const SampleSiteCreateForm = (props: ISampleSiteCreateFormProps) => { - const { isSubmitting } = props; - - const history = useHistory(); - const { submitForm } = useFormikContext(); - - const surveyContext = useSurveyContext(); - - return ( - - - - }> - - - - }> - - - - }> - - - - - { - submitForm(); - }}> - Save and Exit - - - - - - - ); -}; - -export default SampleSiteCreateForm; diff --git a/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx b/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx deleted file mode 100644 index 3d6d1a195c..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/edit/form/SampleMethodEditForm.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; -import Alert from '@mui/material/Alert'; -import AlertTitle from '@mui/material/AlertTitle'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; -import CardHeader from '@mui/material/CardHeader'; -import Collapse from '@mui/material/Collapse'; -import grey from '@mui/material/colors/grey'; -import Divider from '@mui/material/Divider'; -import IconButton from '@mui/material/IconButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import Menu, { MenuProps } from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import { CodesContext } from 'contexts/codesContext'; -import CreateSamplingMethod from 'features/surveys/observations/sampling-sites/create/form/CreateSamplingMethod'; -import EditSamplingMethod from 'features/surveys/observations/sampling-sites/edit/form/EditSamplingMethod'; -import { useFormikContext } from 'formik'; -import { IGetSampleLocationDetailsForUpdate } from 'interfaces/useSamplingSiteApi.interface'; -import { useContext, useEffect, useState } from 'react'; -import { TransitionGroup } from 'react-transition-group'; -import { getCodesName } from 'utils/Utils'; -import { ISurveySampleMethodData } from '../../create/form/MethodForm'; -import SamplingSiteListPeriod from '../../list/SamplingSiteListPeriod'; - -export interface SampleMethodEditFormProps { - name: string; -} - -/** - * Returns a form for editing a sampling method - * - * @param props {SampleMethodEditFormProps} - * @returns - */ -const SampleMethodEditForm = (props: SampleMethodEditFormProps) => { - const { name } = props; - - const { values, errors, setFieldValue } = useFormikContext(); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [anchorEl, setAnchorEl] = useState(null); - const [editData, setEditData] = useState<{ data: ISurveySampleMethodData; index: number } | undefined>(undefined); - - const codesContext = useContext(CodesContext); - useEffect(() => { - codesContext.codesDataLoader.load(); - }, [codesContext.codesDataLoader]); - - const handleMenuClick = (event: React.MouseEvent, index: number) => { - setAnchorEl(event.currentTarget); - - setEditData({ - data: values.sample_methods[index], - index - }); - }; - - const handleDelete = () => { - if (editData) { - const methods = values.sample_methods; - methods.splice(editData.index, 1); - setFieldValue(name, methods); - } - setAnchorEl(null); - }; - - return ( - <> - {/* CREATE SAMPLE METHOD DIALOG */} - { - setFieldValue(`${name}[${values.sample_methods.length}]`, data); - setAnchorEl(null); - setIsCreateModalOpen(false); - }} - onClose={() => { - setAnchorEl(null); - setIsCreateModalOpen(false); - }} - /> - - {/* EDIT SAMPLE METHOD DIALOG */} - {editData?.data && ( - { - setFieldValue(`${name}[${editData?.index}]`, data); - setAnchorEl(null); - setIsEditModalOpen(false); - }} - onClose={() => { - setAnchorEl(null); - setIsEditModalOpen(false); - }} - /> - )} - - setAnchorEl(null)} - anchorEl={anchorEl} - anchorOrigin={{ - vertical: 'top', - horizontal: 'right' - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'right' - }}> - { - setIsEditModalOpen(true); - }}> - - - - Edit Details - - handleDelete()}> - - - - Remove - - - - -
    - Add Sampling Methods - - Methods added here will be applied to ALL sampling locations. These can be modified later if required. - - {errors.sample_methods && !Array.isArray(errors.sample_methods) && ( - - Missing sampling method - {errors.sample_methods} - - )} - - - {values.sample_methods.map((item, index) => ( - - - - {getCodesName( - codesContext.codesDataLoader.data, - 'sample_methods', - item.method_lookup_id || 0 - )} - - {getCodesName( - codesContext.codesDataLoader.data, - 'method_response_metrics', - item.method_response_metric_id || 0 - )} - - - } - action={ - ) => - handleMenuClick(event, index) - } - aria-label="settings"> - - - } - /> - - - {item.description && ( - - {item.description} - - )} - - - Periods - - - - - - - - - - - ))} - - - -
    -
    - - ); -}; - -export default SampleMethodEditForm; diff --git a/app/src/features/surveys/observations/sampling-sites/edit/form/SamplingStratumChips.tsx b/app/src/features/surveys/observations/sampling-sites/edit/form/SamplingStratumChips.tsx deleted file mode 100644 index 2191cb1283..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/edit/form/SamplingStratumChips.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Color, colors } from '@mui/material'; -import Stack from '@mui/material/Stack'; -import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; -import { SurveyContext } from 'contexts/surveyContext'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; -import { useContext } from 'react'; - -interface IStratumChipColours { - stratum: string; - colour: Color; -} - -interface ISamplingStratumChips { - sampleSite: IGetSampleLocationDetails; -} - -/** - * Returns horizontal stack of ColouredRectangleChip for displaying sample stratums - * - * @param props - * @returns - */ -const SamplingStratumChips = (props: ISamplingStratumChips) => { - const surveyContext = useContext(SurveyContext); - - // Determine colours for stratum labels - const orderedColours = [colors.purple, colors.blue, colors.pink, colors.teal, colors.cyan, colors.orange]; - const stratums = surveyContext.surveyDataLoader.data?.surveyData.site_selection.stratums; - const stratumChipColours: IStratumChipColours[] = - stratums?.map((stratum, index) => ({ - stratum: stratum.name, - colour: orderedColours[index % orderedColours.length] - })) ?? []; - - return ( - - {props.sampleSite.stratums.map((stratum, index) => ( - colour.stratum === stratum.name)?.colour ?? colors.grey} - label={stratum.name} - title="Stratum" - /> - ))} - - ); -}; - -export default SamplingStratumChips; diff --git a/app/src/features/surveys/sampling-information/SamplingRouter.tsx b/app/src/features/surveys/sampling-information/SamplingRouter.tsx new file mode 100644 index 0000000000..0917b2df2c --- /dev/null +++ b/app/src/features/surveys/sampling-information/SamplingRouter.tsx @@ -0,0 +1,71 @@ +import { ProjectRoleRouteGuard } from 'components/security/RouteGuards'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; +import { DialogContextProvider } from 'contexts/dialogContext'; +import { SamplingSiteManagePage } from 'features/surveys/sampling-information/manage/SamplingSiteManagePage'; +import { CreateSamplingSitePage } from 'features/surveys/sampling-information/sites/create/CreateSamplingSitePage'; +import { EditSamplingSitePage } from 'features/surveys/sampling-information/sites/edit/EditSamplingSitePage'; +import { CreateTechniquePage } from 'features/surveys/sampling-information/techniques/form/create/CreateTechniquePage'; +import { EditTechniquePage } from 'features/surveys/sampling-information/techniques/form/edit/EditTechniquePage'; +import { Switch } from 'react-router'; +import RouteWithTitle from 'utils/RouteWithTitle'; +import { getTitle } from 'utils/Utils'; + +/** + * Router for all `/admin/projects/:id/surveys/:survey_id/sampling/*` pages. + * + * @return {*} + */ +export const SamplingRouter = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/manage/SamplingSiteManageHeader.tsx b/app/src/features/surveys/sampling-information/manage/SamplingSiteManageHeader.tsx new file mode 100644 index 0000000000..1bc8bf0045 --- /dev/null +++ b/app/src/features/surveys/sampling-information/manage/SamplingSiteManageHeader.tsx @@ -0,0 +1,44 @@ +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; +import PageHeader from 'components/layout/PageHeader'; +import { Link as RouterLink } from 'react-router-dom'; + +export interface SamplingSiteManageHeaderProps { + project_id: number; + project_name: string; + survey_id: number; + survey_name: string; +} + +/** + * Header for the sampling site manage page. + * + * @param {SamplingSiteManageHeaderProps} props + * @return {*} + */ +export const SamplingSiteManageHeader = (props: SamplingSiteManageHeaderProps) => { + const { project_id, project_name, survey_id, survey_name } = props; + + return ( + '}> + + {project_name} + + + {survey_name} + + + Manage Sampling Information + + + } + /> + ); +}; diff --git a/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx b/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx new file mode 100644 index 0000000000..f38c90653e --- /dev/null +++ b/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx @@ -0,0 +1,37 @@ +import Container from '@mui/material/Container'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import { SamplingSiteManageHeader } from 'features/surveys/sampling-information/manage/SamplingSiteManageHeader'; +import { SamplingSiteManageSiteList } from 'features/surveys/sampling-information/sites/manage/SamplingSiteManageSiteList'; +import { SamplingTechniqueContainer } from 'features/surveys/sampling-information/techniques/SamplingTechniqueContainer'; +import { useProjectContext, useSurveyContext } from 'hooks/useContext'; + +/** + * Page for managing sampling information (sampling techniques and sites). + * + * @return {*} + */ +export const SamplingSiteManagePage = () => { + const projectContext = useProjectContext(); + const surveyContext = useSurveyContext(); + + return ( + + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx b/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx similarity index 68% rename from app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx rename to app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx index b8e43e62e7..3d0ecf86e0 100644 --- a/app/src/features/surveys/observations/sampling-sites/create/form/SamplingMethodForm.tsx +++ b/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx @@ -9,6 +9,7 @@ import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import CardHeader from '@mui/material/CardHeader'; import Collapse from '@mui/material/Collapse'; +import blueGrey from '@mui/material/colors/blueGrey'; import grey from '@mui/material/colors/grey'; import Divider from '@mui/material/Divider'; import IconButton from '@mui/material/IconButton'; @@ -17,28 +18,35 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { CodesContext } from 'contexts/codesContext'; -import { ISurveySampleMethodData } from 'features/surveys/observations/sampling-sites/create/form/MethodForm'; +import { ISurveySampleMethodFormData } from 'features/surveys/sampling-information/methods/components/SamplingMethodForm'; +import { CreateSamplingMethodFormDialog } from 'features/surveys/sampling-information/methods/create/CreateSamplingMethodFormDialog'; +import { EditSamplingMethodFormDialog } from 'features/surveys/sampling-information/methods/edit/EditSamplingMethodFormDialog'; +import { SamplingPeriodFormContainer } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; +import { ICreateSampleSiteFormData } from 'features/surveys/sampling-information/sites/create/CreateSamplingSitePage'; +import { IEditSampleSiteFormData } from 'features/surveys/sampling-information/sites/edit/EditSamplingSitePage'; import { useFormikContext } from 'formik'; -import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; +import { useSurveyContext } from 'hooks/useContext'; import { useContext, useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import { getCodesName } from 'utils/Utils'; -import EditSamplingMethod from '../../edit/form/EditSamplingMethod'; -import SamplingSiteListPeriod from '../../list/SamplingSiteListPeriod'; -import CreateSamplingMethod from './CreateSamplingMethod'; /** * Returns a form for creating and editing a sampling method * * @returns */ -const SamplingMethodForm = () => { - const { values, errors, setFieldValue, setFieldTouched } = useFormikContext(); +export const SamplingMethodFormContainer = () => { + const { values, errors, setFieldValue, setFieldTouched } = useFormikContext< + ICreateSampleSiteFormData | IEditSampleSiteFormData + >(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); - const [editData, setEditData] = useState<{ data: ISurveySampleMethodData; index: number } | undefined>(undefined); + const [editData, setEditData] = useState<{ data: ISurveySampleMethodFormData; index: number } | undefined>(undefined); + + const surveyContext = useSurveyContext(); const codesContext = useContext(CodesContext); useEffect(() => { @@ -62,7 +70,7 @@ const SamplingMethodForm = () => { return ( <> {/* CREATE SAMPLE METHOD DIALOG */} - { setFieldValue(`sample_methods[${values.sample_methods.length}]`, data); @@ -78,7 +86,7 @@ const SamplingMethodForm = () => { {/* EDIT SAMPLE METHOD DIALOG */} {editData?.data && ( - { @@ -142,34 +150,47 @@ const SamplingMethodForm = () => { {errors.sample_methods} )} - - - {values.sample_methods.map((item, index) => ( - + + {values.sample_methods.map((sampleMethod, index) => { + return ( + - {getCodesName( - codesContext.codesDataLoader.data, - 'sample_methods', - item.method_lookup_id || 0 - )} - - {getCodesName( - codesContext.codesDataLoader.data, - 'method_response_metrics', - item.method_response_metric_id || 0 - )} + + + { + surveyContext.techniqueDataLoader.data?.techniques.find( + (technique) => + technique.method_technique_id === sampleMethod.technique.method_technique_id + )?.name + } - + + + + } action={ { pb: '6px !important' }}> - {item.description && ( + {sampleMethod.description && ( { overflow: 'hidden', textOverflow: 'ellipsis' }}> - {item.description} + {sampleMethod.description} )} - - - Periods - - - - - + + + {sampleMethod.technique.method_technique_id && ( + + )} - ))} - + ); + })} + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/periods/components/SamplingPeriodForm.tsx b/app/src/features/surveys/sampling-information/periods/components/SamplingPeriodForm.tsx new file mode 100644 index 0000000000..a7a87d555a --- /dev/null +++ b/app/src/features/surveys/sampling-information/periods/components/SamplingPeriodForm.tsx @@ -0,0 +1,97 @@ +import { mdiArrowRightThin, mdiCalendarMonthOutline, mdiClockOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { DateTimeFields } from 'components/fields/DateTimeFields'; +import { ISurveySampleMethodPeriodData } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; +import { useFormikContext } from 'formik'; + +export const SamplingPeriodForm = () => { + const formikProps = useFormikContext(); + + const { errors } = formikProps; + + return ( +
    + + + + + + {errors && typeof errors !== 'string' && errors && typeof errors === 'string' && ( + + {String(errors)} + + )} + + + + + + + + + {errors && typeof errors !== 'string' && errors && typeof errors === 'string' && ( + + {String(errors)} + + )} + + + + +
    + ); +}; diff --git a/app/src/features/surveys/sampling-information/periods/create/CreateSamplingPeriodFormDialog.tsx b/app/src/features/surveys/sampling-information/periods/create/CreateSamplingPeriodFormDialog.tsx new file mode 100644 index 0000000000..6b936c2ab7 --- /dev/null +++ b/app/src/features/surveys/sampling-information/periods/create/CreateSamplingPeriodFormDialog.tsx @@ -0,0 +1,42 @@ +import EditDialog from 'components/dialog/EditDialog'; +import { SamplingPeriodForm } from 'features/surveys/sampling-information/periods/components/SamplingPeriodForm'; +import { + ISurveySampleMethodPeriodData, + SamplingSiteMethodPeriodYupSchema, + SurveySampleMethodPeriodArrayItemInitialValues +} from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; + +interface ICreateSamplingPeriodFormDialogProps { + open: boolean; + onSubmit: (data: ISurveySampleMethodPeriodData) => void; + onClose: () => void; +} + +/** + * Returns a form for creating a sampling Period + * + * @returns {*} + */ +export const CreateSamplingPeriodFormDialog = (props: ICreateSamplingPeriodFormDialogProps) => { + const handleSubmit = (values: ISurveySampleMethodPeriodData) => { + props.onSubmit(values); + }; + + return ( + , + initialValues: SurveySampleMethodPeriodArrayItemInitialValues, + validationSchema: SamplingSiteMethodPeriodYupSchema + }} + dialogSaveButtonLabel="Add" + onCancel={() => props.onClose()} + onSave={(formValues) => { + handleSubmit(formValues); + }} + /> + ); +}; diff --git a/app/src/features/surveys/sampling-information/periods/edit/EditSamplingPeriodFormDialog.tsx b/app/src/features/surveys/sampling-information/periods/edit/EditSamplingPeriodFormDialog.tsx new file mode 100644 index 0000000000..890f3a8713 --- /dev/null +++ b/app/src/features/surveys/sampling-information/periods/edit/EditSamplingPeriodFormDialog.tsx @@ -0,0 +1,42 @@ +import { EditDialog } from 'components/dialog/EditDialog'; +import { SamplingPeriodForm } from 'features/surveys/sampling-information/periods/components/SamplingPeriodForm'; +import { + ISurveySampleMethodPeriodData, + SamplingSiteMethodPeriodYupSchema +} from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; +import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; + +interface IEditSamplingPeriodFormDialogProps { + open: boolean; + initialData: IGetSamplePeriodRecord | ISurveySampleMethodPeriodData; + onSubmit: (data: IGetSamplePeriodRecord | ISurveySampleMethodPeriodData, index?: number) => void; + onClose: () => void; +} + +/** + * Renders a form for editing a sampling period. + * + * @param {IEditSamplingPeriodFormDialogProps} props + * @returns {*} + */ +export const EditSamplingPeriodFormDialog = (props: IEditSamplingPeriodFormDialogProps) => { + const { open, initialData, onSubmit, onClose } = props; + + return ( + , + initialValues: initialData, + validationSchema: SamplingSiteMethodPeriodYupSchema + }} + dialogSaveButtonLabel="Update" + onCancel={onClose} + onSave={(formValues) => { + onSubmit(formValues); + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteHeader.tsx b/app/src/features/surveys/sampling-information/sites/components/SamplingSiteHeader.tsx similarity index 83% rename from app/src/features/surveys/observations/sampling-sites/components/SamplingSiteHeader.tsx rename to app/src/features/surveys/sampling-information/sites/components/SamplingSiteHeader.tsx index 4b7b314e36..f1e90252bc 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteHeader.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/SamplingSiteHeader.tsx @@ -8,7 +8,6 @@ import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { useFormikContext } from 'formik'; -import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; import { useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; @@ -16,6 +15,7 @@ export interface ISamplingSiteHeaderProps { project_id: number; survey_id: number; survey_name: string; + project_name: string; is_submitting: boolean; title: string; breadcrumb: string; @@ -24,20 +24,20 @@ export interface ISamplingSiteHeaderProps { /** * Renders the header of the Sampling Site page. * - * @param {*} props + * @param {ISamplingSiteHeaderProps} props * @return {*} */ -export const SamplingSiteHeader: React.FC = (props) => { +export const SamplingSiteHeader = (props: ISamplingSiteHeaderProps) => { const history = useHistory(); - const formikProps = useFormikContext(); + const formikProps = useFormikContext(); - const { project_id, survey_id, survey_name, is_submitting, title, breadcrumb } = props; + const { project_id, survey_id, survey_name, project_name, is_submitting, title, breadcrumb } = props; return ( <> = (props) => sx={{ typography: 'body2' }}> + + {project_name} + = (props) => - Manage Survey Observations + Manage Sampling Information {breadcrumb} @@ -90,7 +93,7 @@ export const SamplingSiteHeader: React.FC = (props) => variant="outlined" color="primary" onClick={() => { - history.push(`/admin/projects/${project_id}/surveys/${survey_id}/observations`); + history.push(`/admin/projects/${project_id}/surveys/${survey_id}/sampling`); }}> Cancel diff --git a/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteEditMapControl.tsx b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteEditMapControl.tsx similarity index 96% rename from app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteEditMapControl.tsx rename to app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteEditMapControl.tsx index 21d814c03f..2273cbe10d 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteEditMapControl.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteEditMapControl.tsx @@ -16,9 +16,9 @@ import FullScreenScrollingEventHandler from 'components/map/components/FullScree import StaticLayers, { IStaticLayer } from 'components/map/components/StaticLayers'; import { MapBaseCss } from 'components/map/styles/MapBaseCss'; import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; -import SampleSiteFileUploadItemActionButton from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemActionButton'; -import SampleSiteFileUploadItemProgressBar from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar'; -import SampleSiteFileUploadItemSubtext from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemSubtext'; +import SampleSiteFileUploadItemActionButton from 'features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemActionButton'; +import SampleSiteFileUploadItemProgressBar from 'features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar'; +import SampleSiteFileUploadItemSubtext from 'features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemSubtext'; import { FormikContextType } from 'formik'; import { Feature } from 'geojson'; import { useBiohubApi } from 'hooks/useBioHubApi'; diff --git a/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx similarity index 95% rename from app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx rename to app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx index 4c3ef1c314..2422f0fd58 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx @@ -17,12 +17,12 @@ import StaticLayers from 'components/map/components/StaticLayers'; import { MapBaseCss } from 'components/map/styles/MapBaseCss'; import { MAP_DEFAULT_CENTER, MAP_DEFAULT_ZOOM } from 'constants/spatial'; import { SurveyContext } from 'contexts/surveyContext'; -import SampleSiteFileUploadItemActionButton from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemActionButton'; -import SampleSiteFileUploadItemProgressBar from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar'; -import SampleSiteFileUploadItemSubtext from 'features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemSubtext'; +import SampleSiteFileUploadItemActionButton from 'features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemActionButton'; +import SampleSiteFileUploadItemProgressBar from 'features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar'; +import SampleSiteFileUploadItemSubtext from 'features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemSubtext'; import { FormikContextType } from 'formik'; import { Feature } from 'geojson'; -import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; +import { ICreateSamplingSiteRequest, ISurveySampleSite } from 'interfaces/useSamplingSiteApi.interface'; import { DrawEvents, LatLngBoundsExpression } from 'leaflet'; import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; import 'leaflet-fullscreen/dist/Leaflet.fullscreen.js'; @@ -32,7 +32,6 @@ import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { FeatureGroup, LayersControl, MapContainer as LeafletMapContainer } from 'react-leaflet'; import { boundaryUploadHelper, calculateUpdatedMapBounds } from 'utils/mapBoundaryUploadHelpers'; import { pluralize, shapeFileFeatureDesc, shapeFileFeatureName } from 'utils/Utils'; -import { ISurveySampleSite } from '../../create/SamplingSitePage'; const useStyles = () => { return { diff --git a/app/src/features/surveys/observations/sampling-sites/components/map/SurveySampleSiteEditForm.tsx b/app/src/features/surveys/sampling-information/sites/components/map/SurveySampleSiteEditForm.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/components/map/SurveySampleSiteEditForm.tsx rename to app/src/features/surveys/sampling-information/sites/components/map/SurveySampleSiteEditForm.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemActionButton.tsx b/app/src/features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemActionButton.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemActionButton.tsx rename to app/src/features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemActionButton.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar.tsx b/app/src/features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar.tsx rename to app/src/features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemProgressBar.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemSubtext.tsx b/app/src/features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemSubtext.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/components/map/file-upload/SampleSiteFileUploadItemSubtext.tsx rename to app/src/features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemSubtext.tsx diff --git a/app/src/features/surveys/observations/sampling-sites/components/BlockStratumCard.tsx b/app/src/features/surveys/sampling-information/sites/components/site-groupings/BlockStratumCard.tsx similarity index 82% rename from app/src/features/surveys/observations/sampling-sites/components/BlockStratumCard.tsx rename to app/src/features/surveys/sampling-information/sites/components/site-groupings/BlockStratumCard.tsx index 19fcf4b960..82abb4d63d 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/BlockStratumCard.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/site-groupings/BlockStratumCard.tsx @@ -6,7 +6,7 @@ interface IBlockStratumCard { description: string; } -const BlockStratumCard: React.FC = (props) => { +export const BlockStratumCard: React.FC = (props) => { return ( @@ -22,5 +22,3 @@ const BlockStratumCard: React.FC = (props) => { ); }; - -export default BlockStratumCard; diff --git a/app/src/features/surveys/observations/sampling-sites/edit/form/SamplingBlockForm.tsx b/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingBlockForm.tsx similarity index 96% rename from app/src/features/surveys/observations/sampling-sites/edit/form/SamplingBlockForm.tsx rename to app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingBlockForm.tsx index e9f5ff495f..5c087069c7 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/form/SamplingBlockForm.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingBlockForm.tsx @@ -10,19 +10,19 @@ import IconButton from '@mui/material/IconButton'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import { SurveyContext } from 'contexts/surveyContext'; +import { BlockStratumCard } from 'features/surveys/sampling-information/sites/components/site-groupings/BlockStratumCard'; import { useFormikContext } from 'formik'; import { IGetSampleBlockDetails, IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; import { IGetSurveyBlock } from 'interfaces/useSurveyApi.interface'; import { useContext, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; -import BlockStratumCard from '../../components/BlockStratumCard'; /** * Returns a form for creating and editing which survey blocks are associated to a sampling site * * @returns */ -const SamplingBlockForm = () => { +export const SamplingBlockForm = () => { const { values, setFieldValue } = useFormikContext(); const surveyContext = useContext(SurveyContext); @@ -151,5 +151,3 @@ const SamplingBlockForm = () => { ); }; - -export default SamplingBlockForm; diff --git a/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingSiteGroupingsForm.tsx b/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingSiteGroupingsForm.tsx new file mode 100644 index 0000000000..95834167f1 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingSiteGroupingsForm.tsx @@ -0,0 +1,19 @@ +import Box from '@mui/material/Box'; +import { SamplingBlockForm } from 'features/surveys/sampling-information/sites/components/site-groupings/SamplingBlockForm'; +import { SamplingStratumForm } from 'features/surveys/sampling-information/sites/components/site-groupings/SamplingStratumForm'; + +/** + * Renders a sampling site grouping for related forms. + * + * @returns {*} + */ +export const SamplingSiteGroupingsForm = () => { + return ( + <> + + + + + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/edit/form/SamplingStratumForm.tsx b/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingStratumForm.tsx similarity index 96% rename from app/src/features/surveys/observations/sampling-sites/edit/form/SamplingStratumForm.tsx rename to app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingStratumForm.tsx index e25c172433..967fc74b0d 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/form/SamplingStratumForm.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/site-groupings/SamplingStratumForm.tsx @@ -10,19 +10,19 @@ import IconButton from '@mui/material/IconButton'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import { SurveyContext } from 'contexts/surveyContext'; +import { BlockStratumCard } from 'features/surveys/sampling-information/sites/components/site-groupings/BlockStratumCard'; import { useFormikContext } from 'formik'; import { IGetSampleLocationDetails, IGetSampleStratumDetails } from 'interfaces/useSamplingSiteApi.interface'; import { IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; import { useContext, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; -import BlockStratumCard from '../../components/BlockStratumCard'; /** * Returns a form for creating and editing which survey stratums are associated to a sampling site * * @returns */ -const SamplingStratumForm = () => { +export const SamplingStratumForm = () => { const { values, setFieldValue } = useFormikContext(); const surveyContext = useContext(SurveyContext); @@ -150,5 +150,3 @@ const SamplingStratumForm = () => { ); }; - -export default SamplingStratumForm; diff --git a/app/src/features/surveys/observations/sampling-sites/create/SamplingSitePage.tsx b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx similarity index 60% rename from app/src/features/surveys/observations/sampling-sites/create/SamplingSitePage.tsx rename to app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx index 2dce66fb8d..8fe0550e6e 100644 --- a/app/src/features/surveys/observations/sampling-sites/create/SamplingSitePage.tsx +++ b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx @@ -2,24 +2,31 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import { CreateSamplingSiteI18N } from 'constants/i18n'; -import { SamplingSiteMethodYupSchema } from 'features/surveys/observations/sampling-sites/create/form/MethodForm'; +import { ISurveySampleMethodFormData } from 'features/surveys/sampling-information/methods/components/SamplingMethodForm'; import { Formik, FormikProps } from 'formik'; -import { Feature } from 'geojson'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import { useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; -import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; +import { ICreateSamplingSiteRequest, ISurveySampleSite } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSurveyBlock, IGetSurveyStratum } from 'interfaces/useSurveyApi.interface'; import { useRef, useState } from 'react'; import { Prompt, useHistory } from 'react-router'; -import yup from 'utils/YupSchema'; import SamplingSiteHeader from '../components/SamplingSiteHeader'; -import SampleSiteCreateForm from './form/SampleSiteCreateForm'; +import SampleSiteCreateForm, { SampleSiteCreateFormYupSchema } from './form/SampleSiteCreateForm'; -export interface ISurveySampleSite { - name: string; - description: string; - geojson: Feature; +/** + * Interface for the form data used in the Create Sampling Site form. + * + * @export + * @interface ICreateSampleSiteFormData + */ +export interface ICreateSampleSiteFormData { + survey_id: number; + survey_sample_sites: ISurveySampleSite[]; // extracted list from shape files + sample_methods: ISurveySampleMethodFormData[]; + blocks: IGetSurveyBlock[]; + stratums: IGetSurveyStratum[]; } /** @@ -27,39 +34,23 @@ export interface ISurveySampleSite { * * @return {*} */ -const SamplingSitePage = () => { +export const CreateSamplingSitePage = () => { const history = useHistory(); const biohubApi = useBiohubApi(); const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); const dialogContext = useDialogContext(); - const formikRef = useRef>(null); + const formikRef = useRef>(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [enableCancelCheck, setEnableCancelCheck] = useState(true); - const { locationChangeInterceptor } = useUnsavedChangesDialog(); - if (!surveyContext.surveyDataLoader.data) { + if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data) { return ; } - const samplingSiteYupSchema = yup.object({ - survey_sample_sites: yup - .array( - yup.object({ - name: yup.string().default(''), - description: yup.string().default(''), - geojson: yup.object({}) - }) - ) - .min(1, 'At least one sampling site location is required'), - sample_methods: yup - .array(yup.object().concat(SamplingSiteMethodYupSchema)) - .min(1, 'At least one sampling method is required') - }); - const showCreateErrorDialog = (textDialogProps?: Partial) => { dialogContext.setErrorDialog({ dialogTitle: CreateSamplingSiteI18N.createErrorTitle, @@ -75,21 +66,33 @@ const SamplingSitePage = () => { }); }; - const handleSubmit = async (values: ICreateSamplingSiteRequest) => { + const handleSubmit = async (values: ICreateSampleSiteFormData) => { try { setIsSubmitting(true); - await biohubApi.samplingSite.createSamplingSites(surveyContext.projectId, surveyContext.surveyId, values); + // Remove internal _id property of newly created sample_methods used only as a unique key prop + const { sample_methods, ...otherValues } = values; + + const data: ICreateSamplingSiteRequest = { + ...otherValues, + sample_methods: sample_methods.map((method) => ({ + survey_sample_method_id: method.survey_sample_method_id, + survey_sample_site_id: method.survey_sample_site_id, + method_technique_id: method.technique.method_technique_id, + description: method.description, + sample_periods: method.sample_periods, + method_response_metric_id: method.method_response_metric_id + })) + }; - // Disable cancel prompt so we can navigate away from the page after saving - setEnableCancelCheck(false); + await biohubApi.samplingSite.createSamplingSites(surveyContext.projectId, surveyContext.surveyId, data); // Refresh the context, so the next page loads with the latest data surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); // create complete, navigate back to observations page history.push( - `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/observations`, + `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, SKIP_CONFIRMATION_DIALOG ); } catch (error) { @@ -105,7 +108,7 @@ const SamplingSitePage = () => { return ( <> - + { blocks: [], stratums: [] }} - validationSchema={samplingSiteYupSchema} + validationSchema={SampleSiteCreateFormYupSchema} validateOnBlur={true} validateOnChange={false} onSubmit={handleSubmit}> @@ -126,6 +129,7 @@ const SamplingSitePage = () => { project_id={surveyContext.projectId} survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} + project_name={projectContext.projectDataLoader.data.projectData.project.project_name} is_submitting={isSubmitting} title="Add Sampling Site" breadcrumb="Add Sampling Sites" @@ -138,5 +142,3 @@ const SamplingSitePage = () => { ); }; - -export default SamplingSitePage; diff --git a/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteCreateForm.tsx b/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteCreateForm.tsx new file mode 100644 index 0000000000..111d6f357b --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteCreateForm.tsx @@ -0,0 +1,117 @@ +import LoadingButton from '@mui/lab/LoadingButton/LoadingButton'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; +import { SamplingSiteMethodYupSchema } from 'features/surveys/sampling-information/methods/components/SamplingMethodForm'; +import { SamplingMethodFormContainer } from 'features/surveys/sampling-information/methods/SamplingMethodFormContainer'; +import { SamplingSiteMethodPeriodYupSchema } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; +import { SamplingSiteGroupingsForm } from 'features/surveys/sampling-information/sites/components/site-groupings/SamplingSiteGroupingsForm'; +import { ICreateSampleSiteFormData } from 'features/surveys/sampling-information/sites/create/CreateSamplingSitePage'; +import { SampleSiteImportForm } from 'features/surveys/sampling-information/sites/create/form/SampleSiteImportForm'; +import { useFormikContext } from 'formik'; +import { useSurveyContext } from 'hooks/useContext'; +import { useHistory } from 'react-router'; +import yup from 'utils/YupSchema'; + +export const SampleSiteCreateFormYupSchema = yup.object({ + survey_sample_sites: yup + .array( + yup.object({ + name: yup.string().default(''), + description: yup.string().default(''), + geojson: yup.object({}) + }) + ) + .min(1, 'At least one sampling site location is required'), + sample_methods: yup + .array() + .of( + SamplingSiteMethodYupSchema.shape({ + sample_periods: yup + .array() + .of(SamplingSiteMethodPeriodYupSchema) + .min( + 1, + 'At least one sampling period is required for each method, describing when exactly this method was done' + ) + }) + ) // Ensure each item in the array conforms to SamplingSiteMethodYupSchema + .min(1, 'At least one sampling method is required') // Add check for at least one item in the array +}); + +interface ISampleSiteCreateFormProps { + isSubmitting: boolean; +} + +/** + * Renders sampling site create form. + * + * @param {ISampleSiteCreateFormProps} props + * @returns {*} + */ +const SampleSiteCreateForm = (props: ISampleSiteCreateFormProps) => { + const { isSubmitting } = props; + + const history = useHistory(); + const { submitForm } = useFormikContext(); + + const surveyContext = useSurveyContext(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + { + submitForm(); + }}> + Save and Exit + + + + + + + ); +}; + +export default SampleSiteCreateForm; diff --git a/app/src/features/surveys/components/locations/SurveySamplingSiteImportForm.tsx b/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteImportForm.tsx similarity index 82% rename from app/src/features/surveys/components/locations/SurveySamplingSiteImportForm.tsx rename to app/src/features/surveys/sampling-information/sites/create/form/SampleSiteImportForm.tsx index db23b95c17..ed6d37f2aa 100644 --- a/app/src/features/surveys/components/locations/SurveySamplingSiteImportForm.tsx +++ b/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteImportForm.tsx @@ -1,10 +1,15 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import SamplingSiteMapControl from 'features/surveys/observations/sampling-sites/components/map/SamplingSiteMapControl'; +import SamplingSiteMapControl from 'features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl'; import { useFormikContext } from 'formik'; import { ICreateSamplingSiteRequest } from 'interfaces/useSamplingSiteApi.interface'; -const SurveySamplingSiteImportForm = () => { +/** + * Renders the sampling site import form. + * + * @returns {*} + */ +export const SampleSiteImportForm = () => { const formikProps = useFormikContext(); return ( @@ -34,5 +39,3 @@ const SurveySamplingSiteImportForm = () => { ); }; - -export default SurveySamplingSiteImportForm; diff --git a/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx b/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx similarity index 66% rename from app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx rename to app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx index 45ee09af1c..f82a081ed7 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/SamplingSiteEditPage.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx @@ -1,44 +1,67 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import Typography from '@mui/material/Typography'; +import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import { CreateSamplingSiteI18N } from 'constants/i18n'; -import { DialogContext } from 'contexts/dialogContext'; -import { SurveyContext } from 'contexts/surveyContext'; +import { ISurveySampleMethodFormData } from 'features/surveys/sampling-information/methods/components/SamplingMethodForm'; import { Formik, FormikProps } from 'formik'; import { Feature } from 'geojson'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; -import { IEditSamplingSiteRequest, IGetSampleLocationDetailsForUpdate } from 'interfaces/useSamplingSiteApi.interface'; -import { useContext, useEffect, useRef, useState } from 'react'; +import { + IEditSampleSiteRequest, + IGetSampleBlockDetails, + IGetSampleMethodDetails, + IGetSampleStratumDetails +} from 'interfaces/useSamplingSiteApi.interface'; +import { useEffect, useRef, useState } from 'react'; import { Prompt, useHistory, useParams } from 'react-router'; import SamplingSiteHeader from '../components/SamplingSiteHeader'; -import SampleSiteEditForm, { samplingSiteYupSchema } from './form/SampleSiteEditForm'; +import SampleSiteEditForm, { SampleSiteEditFormYupSchema } from './form/SampleSiteEditForm'; + +/** + * Interface for the form data used in the Edit Sampling Site form. + * + * @export + * @interface IEditSampleSiteFormData + */ +export interface IEditSampleSiteFormData { + survey_sample_site_id: number | null; + survey_id: number; + name: string; + description: string; + geojson: Feature; + sample_methods: (IGetSampleMethodDetails | ISurveySampleMethodFormData)[]; + blocks: IGetSampleBlockDetails[]; + stratums: IGetSampleStratumDetails[]; +} /** * Page to edit a sampling site. * * @return {*} */ -const SamplingSiteEditPage = () => { +export const EditSamplingSitePage = () => { const history = useHistory(); const biohubApi = useBiohubApi(); const urlParams: Record = useParams(); const surveySampleSiteId = Number(urlParams['survey_sample_site_id']); - const [initialFormValues, setInitialFormValues] = useState(); + const [initialFormValues, setInitialFormValues] = useState(); - const { locationChangeInterceptor } = useUnsavedChangesDialog(); + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); - const surveyContext = useContext(SurveyContext); - const dialogContext = useContext(DialogContext); + const { locationChangeInterceptor } = useUnsavedChangesDialog(); - const formikRef = useRef>(null); + const formikRef = useRef>(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [enableCancelCheck, setEnableCancelCheck] = useState(true); const projectId = surveyContext.projectId; const surveyId = surveyContext.surveyId; @@ -52,10 +75,11 @@ const SamplingSiteEditPage = () => { } useEffect(() => { - if (samplingSiteDataLoader.data) { - setInitialFormValues(samplingSiteDataLoader.data); - formikRef.current?.setValues(samplingSiteDataLoader.data); + if (!samplingSiteDataLoader.data) { + return; } + + setInitialFormValues(samplingSiteDataLoader.data); }, [samplingSiteDataLoader.data]); const showCreateErrorDialog = (textDialogProps?: Partial) => { @@ -73,19 +97,26 @@ const SamplingSiteEditPage = () => { }); }; - const handleSubmit = async (values: IGetSampleLocationDetailsForUpdate) => { + const handleSubmit = async (values: IEditSampleSiteFormData) => { try { setIsSubmitting(true); - // create edit request - const editSampleSite: IEditSamplingSiteRequest = { + // Format raw form values into the expected request format + const editSampleSite: IEditSampleSiteRequest = { sampleSite: { name: values.name, description: values.description, survey_id: values.survey_id, survey_sample_sites: [values.geojson as Feature], geojson: values.geojson, - methods: values.sample_methods, + methods: values.sample_methods.map((method) => ({ + survey_sample_method_id: method.survey_sample_method_id, + survey_sample_site_id: method.survey_sample_site_id, + method_response_metric_id: method.method_response_metric_id, + description: method.description, + method_technique_id: method.technique.method_technique_id, + sample_periods: method.sample_periods + })), blocks: values.blocks.map((block) => ({ survey_block_id: block.survey_block_id })), stratums: values.stratums.map((stratum) => ({ survey_stratum_id: stratum.survey_stratum_id })) } @@ -95,8 +126,6 @@ const SamplingSiteEditPage = () => { await biohubApi.samplingSite .editSampleSite(surveyContext.projectId, surveyContext.surveyId, surveySampleSiteId, editSampleSite) .then(() => { - // Disable cancel prompt so we can navigate away from the page after saving - setEnableCancelCheck(false); setIsSubmitting(false); // Refresh the context, so the next page loads with the latest data @@ -104,7 +133,7 @@ const SamplingSiteEditPage = () => { // create complete, navigate back to observations page history.push( - `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/observations`, + `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, SKIP_CONFIRMATION_DIALOG ); surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); @@ -138,26 +167,28 @@ const SamplingSiteEditPage = () => { } }; - if (!surveyContext.surveyDataLoader.data || !initialFormValues) { + if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data || !initialFormValues) { return ; } return ( <> - + + { ); }; - -export default SamplingSiteEditPage; diff --git a/app/src/features/surveys/observations/sampling-sites/edit/form/SampleSiteEditForm.tsx b/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteEditForm.tsx similarity index 77% rename from app/src/features/surveys/observations/sampling-sites/edit/form/SampleSiteEditForm.tsx rename to app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteEditForm.tsx index 94dd1851d6..e984e10763 100644 --- a/app/src/features/surveys/observations/sampling-sites/edit/form/SampleSiteEditForm.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteEditForm.tsx @@ -6,22 +6,18 @@ import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { SurveyContext } from 'contexts/surveyContext'; -import { SamplingSiteMethodYupSchema } from 'features/surveys/observations/sampling-sites/create/form/MethodForm'; +import { SamplingSiteMethodYupSchema } from 'features/surveys/sampling-information/methods/components/SamplingMethodForm'; +import { SamplingMethodFormContainer } from 'features/surveys/sampling-information/methods/SamplingMethodFormContainer'; +import { SamplingSiteGroupingsForm } from 'features/surveys/sampling-information/sites/components/site-groupings/SamplingSiteGroupingsForm'; import { useFormikContext } from 'formik'; import { IGetSampleLocationDetailsForUpdate } from 'interfaces/useSamplingSiteApi.interface'; import { useContext } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import yup from 'utils/YupSchema'; import SurveySamplingSiteEditForm from '../../components/map/SurveySampleSiteEditForm'; -import SamplingSiteGroupingsForm from '../../components/SamplingSiteGroupingsForm'; -import SampleMethodEditForm from './SampleMethodEditForm'; import SampleSiteGeneralInformationForm from './SampleSiteGeneralInformationForm'; -export interface ISampleSiteEditFormProps { - isSubmitting: boolean; -} - -export const samplingSiteYupSchema = yup.object({ +export const SampleSiteEditFormYupSchema = yup.object({ name: yup.string().default('').min(1, 'Minimum 1 character.').max(50, 'Maximum 50 characters.'), description: yup.string().default('').nullable(), survey_sample_sites: yup @@ -33,6 +29,10 @@ export const samplingSiteYupSchema = yup.object({ .min(1, 'At least one sampling method is required') }); +export interface ISampleSiteEditFormProps { + isSubmitting: boolean; +} + /** * Returns a form for editing a sampling site * @@ -49,29 +49,33 @@ const SampleSiteEditForm = (props: ISampleSiteEditFormProps) => { }> + summary="Specify the name and description for this sampling site"> + + }> + summary="Import or draw sampling site locations used for this survey."> + + }> + summary="Specify sampling methods that were used to collect data."> + + }> + summary="Enter the stratum or group to which this site belongs."> + + @@ -90,7 +94,7 @@ const SampleSiteEditForm = (props: ISampleSiteEditFormProps) => { variant="outlined" color="primary" component={RouterLink} - to={`/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/observations`}> + to={`/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`}> Cancel diff --git a/app/src/features/surveys/observations/sampling-sites/edit/form/SampleSiteGeneralInformationForm.tsx b/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteGeneralInformationForm.tsx similarity index 100% rename from app/src/features/surveys/observations/sampling-sites/edit/form/SampleSiteGeneralInformationForm.tsx rename to app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteGeneralInformationForm.tsx diff --git a/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx b/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx new file mode 100644 index 0000000000..fe02b40205 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx @@ -0,0 +1,34 @@ +import { blue, cyan, orange, pink, purple, teal } from '@mui/material/colors'; +import Stack from '@mui/material/Stack'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; + +const SAMPLING_SITE_CHIP_COLOURS = [purple, blue, pink, teal, cyan, orange]; + +interface ISamplingStratumChipsProps { + sampleSite: IGetSampleLocationDetails; +} + +/** + * Returns horizontal stack of ColouredRectangleChip for displaying sample stratums + * + * @param {ISamplingStratumChipsProps} props + * @returns {*} + */ +export const SamplingStratumChips = (props: ISamplingStratumChipsProps) => { + return ( + + {props.sampleSite.stratums.map((stratum, index) => ( + + ))} + + ); +}; diff --git a/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteCard.tsx b/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteCard.tsx new file mode 100644 index 0000000000..865eff4f32 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteCard.tsx @@ -0,0 +1,54 @@ +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { AccordionCard } from 'components/accordion/AccordionCard'; +import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; + +interface ISamplingSiteCardProps { + sampleSite: IGetSampleLocationDetails; + handleMenuClick: (event: React.MouseEvent) => void; + isChecked?: boolean; + handleCheckboxChange?: (sampleSiteId: number) => void; +} + +export const SamplingSiteCard = (props: ISamplingSiteCardProps) => { + const { sampleSite, handleMenuClick, handleCheckboxChange, isChecked } = props; + + return ( + + + {handleCheckboxChange && ( + + { + event.stopPropagation(); + handleCheckboxChange(sampleSite.survey_sample_site_id); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + + )} + + {sampleSite.name} + + + + {sampleSite.description} + +
    + } + detailsContent={ + + {sampleSite.description} + {sampleSite.name} + + } + onMenuClick={handleMenuClick} + /> + ); +}; diff --git a/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteManageSiteList.tsx b/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteManageSiteList.tsx new file mode 100644 index 0000000000..2766f741fb --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteManageSiteList.tsx @@ -0,0 +1,367 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import grey from '@mui/material/colors/grey'; +import Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonMap, SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { SamplingSiteCard } from 'features/surveys/sampling-information/sites/manage/SamplingSiteCard'; +import { SamplingSiteMapContainer } from 'features/surveys/sampling-information/sites/manage/SamplingSiteMapContainer'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext, useSurveyContext } from 'hooks/useContext'; +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +/** + * Renders a list of sampling sites. + * + * @return {*} + */ +export const SamplingSiteManageSiteList = () => { + const surveyContext = useSurveyContext(); + const codesContext = useCodesContext(); + const dialogContext = useDialogContext(); + const biohubApi = useBiohubApi(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + useEffect(() => { + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const [sampleSiteAnchorEl, setSampleSiteAnchorEl] = useState(null); + const [headerAnchorEl, setHeaderAnchorEl] = useState(null); + const [selectedSampleSiteId, setSelectedSampleSiteId] = useState(); + const [checkboxSelectedIds, setCheckboxSelectedIds] = useState([]); + + const sampleSites = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleSampleSiteMenuClick = ( + event: React.MouseEvent, + sample_site_id: number + ) => { + setSampleSiteAnchorEl(event.currentTarget); + setSelectedSampleSiteId(sample_site_id); + }; + + const handleHeaderMenuClick = (event: React.MouseEvent) => { + setHeaderAnchorEl(event.currentTarget); + }; + + /** + * Handle the delete sampling site API call. + * + */ + const handleDeleteSampleSite = async () => { + await biohubApi.samplingSite + .deleteSampleSite(surveyContext.projectId, surveyContext.surveyId, Number(selectedSampleSiteId)) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + setSampleSiteAnchorEl(null); + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setSampleSiteAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Sampling Site + + + {String(error)} + + + ), + open: true + }); + }); + }; + + /** + * Display the delete sampling site dialog. + * + */ + const deleteSampleSiteDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Sampling Site?', + dialogContent: ( + + Are you sure you want to delete this sampling site? + + ), + yesButtonLabel: 'Delete Sampling Site', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + handleDeleteSampleSite(); + } + }); + }; + + const handleBulkDeleteSampleSites = async () => { + await biohubApi.samplingSite + .deleteSampleSites(surveyContext.projectId, surveyContext.surveyId, checkboxSelectedIds) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + setCheckboxSelectedIds([]); + setHeaderAnchorEl(null); + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setCheckboxSelectedIds([]); + setHeaderAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Sampling Sites + + + {String(error)} + + + ), + open: true + }); + }); + }; + + const handlePromptConfirmBulkDelete = () => { + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Sampling Sites?', + dialogContent: ( + + Are you sure you want to delete the selected sampling sites? + + ), + yesButtonLabel: 'Delete Sampling Sites', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + handleBulkDeleteSampleSites(); + } + }); + }; + + const handleCheckboxChange = (sampleSiteId: number) => { + setCheckboxSelectedIds((prev) => { + if (prev.includes(sampleSiteId)) { + return prev.filter((item) => item !== sampleSiteId); + } else { + return [...prev, sampleSiteId]; + } + }); + }; + + const samplingSiteCount = sampleSites.length ?? 0; + + return ( + <> + setSampleSiteAnchorEl(null)} + anchorEl={sampleSiteAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + + + + + + Edit Details + + + + + + + Delete + + + + setHeaderAnchorEl(null)} + anchorEl={headerAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + + + + + Delete + + + + + + + Sites ‌ + + ({samplingSiteCount}) + + + + + + + + + + + + + + } + delay={200}> + + + + + + Select All + + } + control={ + 0 && checkboxSelectedIds.length === samplingSiteCount} + indeterminate={ + checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < samplingSiteCount + } + onClick={() => { + if (checkboxSelectedIds.length === samplingSiteCount) { + setCheckboxSelectedIds([]); + return; + } + + const sampleSiteIds = sampleSites.map((sampleSite) => sampleSite.survey_sample_site_id); + setCheckboxSelectedIds(sampleSiteIds); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + } + /> + + + + + {surveyContext.sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { + return ( + { + setSampleSiteAnchorEl(event.currentTarget); + setSelectedSampleSiteId(sampleSite.survey_sample_site_id); + }} + key={sampleSite.survey_sample_site_id} + /> + ); + })} + + + + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteMapContainer.tsx b/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteMapContainer.tsx new file mode 100644 index 0000000000..3efe5e4094 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/manage/SamplingSiteMapContainer.tsx @@ -0,0 +1,28 @@ +import Box from '@mui/material/Box'; +import blue from '@mui/material/colors/blue'; +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import SurveyMap from 'features/surveys/view/SurveyMap'; +import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; + +interface ISamplingSitesMapContainerProps { + samplingSites: IGetSampleLocationDetails[]; +} + +export const SamplingSiteMapContainer = (props: ISamplingSitesMapContainerProps) => { + const staticLayers: IStaticLayer[] = props.samplingSites.map((sampleSite) => ({ + layerName: 'Sample Sites', + layerColors: { color: blue[500], fillColor: blue[500] }, + features: [ + { + key: sampleSite.survey_sample_site_id, + geoJSON: sampleSite.geojson + } + ] + })); + + return ( + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx b/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx new file mode 100644 index 0000000000..a34f1facfb --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx @@ -0,0 +1,170 @@ +import { mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { GridRowSelectionModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { DeleteTechniquesBulkI18N } from 'constants/i18n'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import { useEffect, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { SamplingTechniqueCardContainer } from './components/SamplingTechniqueCardContainer'; + +/** + * Renders a list of techniques. + * + * @return {*} + */ +export const SamplingTechniqueContainer = () => { + const surveyContext = useSurveyContext(); + const dialogContext = useDialogContext(); + + const biohubApi = useBiohubApi(); + + // Multi-select row action menu + const [bulkActionTechniques, setBulkActionTechniques] = useState([]); + const [bulkActionMenuAnchorEl, setBulkActionMenuAnchorEl] = useState(null); + + useEffect(() => { + surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [surveyContext.projectId, surveyContext.surveyId]); + + const techniqueCount = surveyContext.techniqueDataLoader.data?.count ?? 0; + const techniques = surveyContext.techniqueDataLoader.data?.techniques ?? []; + + const handleBulkDeleteTechniques = async () => { + await biohubApi.technique + .deleteTechniques(surveyContext.projectId, surveyContext.surveyId, bulkActionTechniques.map(Number)) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + setBulkActionTechniques([]); + setBulkActionMenuAnchorEl(null); + surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setBulkActionTechniques([]); + setBulkActionMenuAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Sampling Sites + + + {String(error)} + + + ), + open: true + }); + }); + }; + + const deleteBulkTechniquesDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: DeleteTechniquesBulkI18N.deleteTitle, + dialogText: DeleteTechniquesBulkI18N.deleteText, + yesButtonLabel: DeleteTechniquesBulkI18N.yesButtonLabel, + noButtonLabel: DeleteTechniquesBulkI18N.noButtonLabel, + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + handleBulkDeleteTechniques(); + } + }); + }; + + return ( + + setBulkActionMenuAnchorEl(null)} + anchorEl={bulkActionMenuAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + + + + + Delete + + + + + + Techniques ‌ + + ({techniqueCount}) + + + + setBulkActionMenuAnchorEl(event.currentTarget)} + title="Bulk Actions"> + + + + + + + } + delay={200}> + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/components/NoTechniquesOverlay.tsx b/app/src/features/surveys/sampling-information/techniques/components/NoTechniquesOverlay.tsx new file mode 100644 index 0000000000..c47fde60b3 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/components/NoTechniquesOverlay.tsx @@ -0,0 +1,28 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Typography from '@mui/material/Typography'; + +export const NoTechniquesOverlay = () => { + return ( + + + + Add a technique  + + + + Techniques describe how you collected data. You can apply your techniques to sampling sites, during which + you'll also create sampling periods that describe when a technique was conducted. + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCard.tsx b/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCard.tsx new file mode 100644 index 0000000000..9a382b6ec1 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCard.tsx @@ -0,0 +1,72 @@ +import Box from '@mui/material/Box'; +import { blue } from '@mui/material/colors'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { AccordionCard } from 'components/accordion/AccordionCard'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; + +interface ISamplingTechniqueCardProps { + technique: IGetTechniqueResponse; + method_lookup_name: string; + handleMenuClick?: (event: React.MouseEvent) => void; +} + +const SamplingTechniqueCard = (props: ISamplingTechniqueCardProps) => { + const { technique, method_lookup_name, handleMenuClick } = props; + + const attributes = [...technique.attributes.qualitative_attributes, ...technique.attributes.qualitative_attributes]; + + return ( + + + {technique.name} + + + + {technique.description} + + + } + detailsContent={ + + + + + Release time + + + + + {technique.description && ( + + + Technique comment + + + {technique.description} + + + )} + + {attributes.map((attribute) => ( + {attribute.method_technique_attribute_qualitative_id} + ))} + + } + onMenuClick={handleMenuClick} + /> + ); +}; + +export default SamplingTechniqueCard; diff --git a/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCardContainer.tsx b/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCardContainer.tsx new file mode 100644 index 0000000000..eda2fc5012 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/components/SamplingTechniqueCardContainer.tsx @@ -0,0 +1,275 @@ +import { mdiArrowTopRight, mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import blueGrey from '@mui/material/colors/blueGrey'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import { GridRowSelectionModel } from '@mui/x-data-grid'; +import { GridOverlay } from '@mui/x-data-grid/components/containers/GridOverlay'; +import { GridColDef } from '@mui/x-data-grid/models/colDef/gridColDef'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { DeleteTechniqueI18N } from 'constants/i18n'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext, useSurveyContext } from 'hooks/useContext'; +import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; +import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { getCodesName } from 'utils/Utils'; + +interface ITechniqueRowData { + id: number; + method_lookup: string; + name: string; + description: string | null; +} + +interface ISamplingTechniqueCardContainer { + techniques: IGetTechniqueResponse[]; + bulkActionTechniques: GridRowSelectionModel; + setBulkActionTechniques: (selection: GridRowSelectionModel) => void; +} + +/** + * Returns accordian cards for displaying technique technique details on the technique profile page + * + * @returns + */ +export const SamplingTechniqueCardContainer = (props: ISamplingTechniqueCardContainer) => { + const { techniques, bulkActionTechniques, setBulkActionTechniques } = props; + + // Individual row action menu + const [actionMenuTechnique, setActionMenuTechnique] = useState(null); + const [actionMenuAnchorEl, setActionMenuAnchorEl] = useState(null); + + const surveyContext = useSurveyContext(); + const dialogContext = useDialogContext(); + const codesContext = useCodesContext(); + const biohubApi = useBiohubApi(); + + /** + * Handle the delete technique API call. + * + */ + const handleDeleteTechnique = async () => { + await biohubApi.technique + .deleteTechnique(surveyContext.projectId, surveyContext.surveyId, Number(actionMenuTechnique)) + .then(() => { + dialogContext.setYesNoDialog({ open: false }); + setActionMenuAnchorEl(null); + surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + }) + .catch((error: any) => { + dialogContext.setYesNoDialog({ open: false }); + setActionMenuAnchorEl(null); + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Technique + + + {String(error)} + + + ), + open: true + }); + }); + }; + + /** + * Display the delete technique dialog. + * + */ + const deleteTechniqueDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: DeleteTechniqueI18N.deleteTitle, + dialogText: DeleteTechniqueI18N.deleteText, + yesButtonLabel: DeleteTechniqueI18N.yesButtonLabel, + noButtonLabel: DeleteTechniqueI18N.noButtonLabel, + yesButtonProps: { color: 'error' }, + onClose: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + onNo: () => { + dialogContext.setYesNoDialog({ open: false }); + }, + open: true, + onYes: () => { + handleDeleteTechnique(); + } + }); + }; + + const rows: ITechniqueRowData[] = + techniques.map((technique) => ({ + id: technique.method_technique_id, + method_lookup: + getCodesName(codesContext.codesDataLoader.data, 'sample_methods', technique.method_lookup_id) ?? '', + name: technique.name, + description: technique.description + })) || []; + + const columns: GridColDef[] = [ + { field: 'name', headerName: 'Name', flex: 0.4 }, + { + field: 'method_lookup_id', + flex: 0.4, + headerName: 'Method', + renderCell: (params) => ( + + + + ) + }, + { + field: 'description', + headerName: 'Description', + flex: 1, + renderCell: (params) => { + return ( + + + {params.row.description} + + + ); + } + }, + { + field: 'actions', + type: 'actions', + sortable: false, + flex: 1, + align: 'right', + renderCell: (params) => { + return ( + + { + setActionMenuTechnique(params.row.id); + setActionMenuAnchorEl(event.currentTarget); + }}> + + + + ); + } + } + ]; + + return ( + <> + setActionMenuAnchorEl(null)} + anchorEl={actionMenuAnchorEl} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }}> + + + + + + Edit Details + + + { + setActionMenuAnchorEl(null); + deleteTechniqueDialog(); + }}> + + + + Delete + + + + + 'auto'} + rows={rows} + columns={columns} + disableRowSelectionOnClick + disableColumnMenu + checkboxSelection + rowSelectionModel={bulkActionTechniques} + onRowSelectionModelChange={setBulkActionTechniques} + noRowsOverlay={ + + + + Start by adding sampling information  + + + + Add techniques, then apply your techniques to sampling sites + + + + } + sx={{ + '& .MuiDataGrid-virtualScroller': { + height: rows.length === 0 ? '250px' : 'unset', + overflowY: 'auto !important', + overflowX: 'hidden' + }, + '& .MuiDataGrid-overlay': { + height: '250px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + }, + '& .MuiDataGrid-columnHeaderDraggableContainer': { + minWidth: '50px' + }, + // '& .MuiDataGrid-cell--textLeft': { justifyContent: 'flex-end' } + '& .MuiDataGrid-cell--textLeft:last-child': { + // justifyContent: 'flex-end !important' + } + }} + /> + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueForm.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueForm.tsx new file mode 100644 index 0000000000..cbe5eb6e2f --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueForm.tsx @@ -0,0 +1,78 @@ +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; +import { TechniqueAttributesForm } from 'features/surveys/sampling-information/techniques/form/components/attributes/TechniqueAttributesForm'; +import { + CreateTechniqueFormValues, + UpdateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo } from 'react'; +import { TechniqueAttractantsForm } from './attractants/TechniqueAttractantsForm'; +import { TechniqueDetailsForm } from './details/TechniqueDetailsForm'; +import { TechniqueGeneralInformationForm } from './general-information/TechniqueGeneralInformationForm'; + +/** + * Technique form. + * + * Handles creating and editing a technique. + * + * @template FormValues + * @return {*} + */ +export const TechniqueForm = () => { + const { values } = useFormikContext(); + + const biohubApi = useBiohubApi(); + + const attributeTypeDefinitionDataLoader = useDataLoader((method_lookup_id: number) => + biohubApi.reference.getTechniqueAttributes([method_lookup_id]) + ); + + useEffect(() => { + if (values.method_lookup_id) { + // Fetch attribute type definitions based on the selected method + attributeTypeDefinitionDataLoader.refresh(values.method_lookup_id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.method_lookup_id]); + + const attributeTypeDefinitions = useMemo( + () => + attributeTypeDefinitionDataLoader.data?.flatMap((attribute) => [ + ...(attribute.qualitative_attributes ?? []), + ...(attribute.quantitative_attributes ?? []) + ]) ?? [], + [attributeTypeDefinitionDataLoader.data] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer.tsx new file mode 100644 index 0000000000..eecf6dd6a7 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer.tsx @@ -0,0 +1,106 @@ +import Stack from '@mui/material/Stack'; +import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; +import { TechniqueForm } from 'features/surveys/sampling-information/techniques/form/components/TechniqueForm'; +import { Formik, FormikProps } from 'formik'; +import { ICreateTechniqueRequest, IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; +import { isDefined } from 'utils/Utils'; +import yup from 'utils/YupSchema'; + +/** + * Type of the values of the Technique Attribute form controls. + */ +export type TechniqueAttributeFormValues = + | { + // internal ID used for form control keys. Not to be sent to API. + _id?: string; + // Primary key of the record. Will be null for new records. + attribute_id: number | null; + // Lookup Id + attribute_lookup_id: string; + attribute_value: string; + attribute_type: 'qualitative'; // discriminator + } + | { + // internal ID used for form control keys. Not to be sent to API. + _id?: string; + // Primary key of the record. Will be null for new records. + attribute_id: number | null; + // Lookup Id + attribute_lookup_id: string; + attribute_value: number; + attribute_type: 'quantitative'; // discriminator + }; + +export type CreateTechniqueFormValues = Omit & { + // Overwrite the default attributes field to include additional fields used only by the form controls + attributes: TechniqueAttributeFormValues[]; +}; + +export type UpdateTechniqueFormValues = Omit & { + // Overwrite the default attributes field to include additional fields used only by the form controls + attributes: TechniqueAttributeFormValues[]; +}; + +type ITechniqueFormProps = { + initialData: FormValues; + handleSubmit: (formikData: FormValues) => void; + formikRef: React.RefObject>; +}; + +/** + * Container for the Technique Form. + * + * Handles formik state, validation, and submission. + * + * @param {ITechniqueFormProps} props + * @return {*} + */ +const TechniqueFormContainer = ( + props: ITechniqueFormProps +) => { + const { initialData, handleSubmit, formikRef } = props; + + const techniqueYupSchema = yup.object({ + name: yup.string().required('Name is required.'), + description: yup.string().nullable(), + method_lookup_id: yup.number().required('A method type is required.'), + attributes: yup.array( + yup.object().shape({ + attribute_lookup_id: yup.string().required('Attribute type is required.'), + attribute_value: yup.mixed().test('is-valid-attribute', 'Attribute value is required.', function (value) { + const { attribute_type } = this.parent; + + if (!isDefined(value)) { + return false; + } + + if (attribute_type === 'qualitative') { + // Field is qualitative, check if it is a string + return yup.string().isValidSync(value); + } + + // Field is quantitative, check if it is a number + return yup.number().isValidSync(value); + }) + }) + ), + distance_threshold: yup.number().nullable() + }); + + return ( + + + + + + + ); +}; + +export default TechniqueFormContainer; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attractants/TechniqueAttractantsForm.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/attractants/TechniqueAttractantsForm.tsx new file mode 100644 index 0000000000..847fab3806 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/attractants/TechniqueAttractantsForm.tsx @@ -0,0 +1,116 @@ +import { mdiClose } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Grid from '@mui/material/Grid'; +import IconButton from '@mui/material/IconButton'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import { + CreateTechniqueFormValues, + UpdateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; + +import { useFormikContext } from 'formik'; +import { useCodesContext } from 'hooks/useContext'; +import { useEffect } from 'react'; +import { TransitionGroup } from 'react-transition-group'; + +/** + * Technique attractants form. + * + * @template FormValues + * @return {*} + */ +export const TechniqueAttractantsForm = < + FormValues extends CreateTechniqueFormValues | UpdateTechniqueFormValues +>() => { + const codesContext = useCodesContext(); + + const { values, setFieldValue } = useFormikContext(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const attractants = codesContext.codesDataLoader.data?.attractants ?? []; + + return ( + <> + + + Attractants (optional) + ({ + value: option.id, + label: option.name, + description: option.description + })) + .filter( + (option) => !values.attractants.some((attractant) => attractant.attractant_lookup_id === option.value) + ) ?? [] + } + onChange={(_, value) => { + if (value?.value) { + setFieldValue('attractants', [...values.attractants, { attractant_lookup_id: value.value }]); + } + }} + /> + + + + {values.attractants.map((attractant, index) => { + const lookup = attractants.find((option) => option.id === attractant.attractant_lookup_id); + return ( + + + + {lookup?.name} + + {lookup?.description} + + + + { + setFieldValue( + 'attractants', + values.attractants.length > 1 ? values.attractants.filter((id) => id !== attractant) : [] + ); + }}> + + + + + + ); + })} + + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/TechniqueAttributesForm.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/attributes/TechniqueAttributesForm.tsx new file mode 100644 index 0000000000..db8c22f651 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/attributes/TechniqueAttributesForm.tsx @@ -0,0 +1,86 @@ +import { mdiPlus } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import { + CreateTechniqueFormValues, + TechniqueAttributeFormValues, + UpdateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; +import { ITechniqueAttributeQualitative, ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; +import { TransitionGroup } from 'react-transition-group'; +import { v4 } from 'uuid'; +import { TechniqueAttributeForm } from './components/TechniqueAttributeForm'; + +const initialAttributeFormValues: Partial< + Pick +> = { + // The attribute id (method_lookup_attribute_quantitative_id or method_lookup_attribute_qualitative_id) + attribute_lookup_id: undefined, + // The attribute value (quantitative value or method_lookup_attribute_qualitative_option_id) + attribute_value: undefined, + // The attribute type discriminator ('quantitative' or 'qualitative') + attribute_type: undefined +}; + +interface ITechniqueAttributesFormProps { + attributeTypeDefinitions: (ITechniqueAttributeQualitative | ITechniqueAttributeQuantitative)[]; +} + +/** + * Technique attributes form. + * + * Handles creating and editing a technique's attributes. + * + * @template FormValues + * @param {ITechniqueAttributesFormProps} props + * @return {*} + */ +export const TechniqueAttributesForm = ( + props: ITechniqueAttributesFormProps +) => { + const { attributeTypeDefinitions } = props; + + const { values } = useFormikContext(); + + return ( + ( + <> + + {values.attributes.map((attribute, index) => { + return ( + // Quantitative and qualitative measurements might have the same attribute_id, so use temporary _id + + + + + + ); + })} + + + + )} + /> + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeForm.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeForm.tsx new file mode 100644 index 0000000000..f45bd05ee8 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeForm.tsx @@ -0,0 +1,129 @@ +import { mdiClose } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import { TechniqueAttributeValueControl } from 'features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeValueControl'; +import { + formatAttributesForAutoComplete, + getAttributeId, + getAttributeType, + getRemainingAttributes +} from 'features/surveys/sampling-information/techniques/form/components/attributes/components/utils'; +import { + CreateTechniqueFormValues, + UpdateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +import { FieldArrayRenderProps, useFormikContext } from 'formik'; +import { ITechniqueAttributeQualitative, ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; +import { useMemo } from 'react'; + +interface ITechniqueAttributeFormProps { + attributeTypeDefinitions: (ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative)[]; + arrayHelpers: FieldArrayRenderProps; + index: number; +} + +/** + * Technique attribute form. + * + * @template FormValues + * @param {ITechniqueAttributeFormProps} props + * @return {*} + */ +export const TechniqueAttributeForm = ( + props: ITechniqueAttributeFormProps +) => { + const { arrayHelpers, attributeTypeDefinitions, index } = props; + + const { values, setFieldValue } = useFormikContext(); + + // The type definition for the currently selected attribute, if one has been selected + const selectedAttributeTypeDefinition = useMemo( + () => + values.attributes[index] + ? attributeTypeDefinitions.find((attribute) => { + if ('method_lookup_attribute_qualitative_id' in attribute) { + return attribute.method_lookup_attribute_qualitative_id === values.attributes[index].attribute_lookup_id; + } + + return attribute.method_lookup_attribute_quantitative_id === values.attributes[index].attribute_lookup_id; + }) + : undefined, + [attributeTypeDefinitions, index, values.attributes] + ); + + // The IDs of the attributes that have already been selected, and should not be available for selection again + const unavailableAttributeIds = useMemo(() => { + return values.attributes.map((attribute) => attribute.attribute_lookup_id); + }, [values.attributes]); + + // The ID of the currently selected attribute + const selectedAttributeId = selectedAttributeTypeDefinition && getAttributeId(selectedAttributeTypeDefinition); + + // The remaining attributes that are available for selection + const remainingAttributeTypeDefinitions = useMemo(() => { + return getRemainingAttributes(attributeTypeDefinitions, unavailableAttributeIds, selectedAttributeId); + }, [attributeTypeDefinitions, selectedAttributeId, unavailableAttributeIds]); + + // The remaining attributes formatted for use by the autocomplete component + const attributesOptionsForAutocomplete = useMemo(() => { + return formatAttributesForAutoComplete(remainingAttributeTypeDefinitions); + }, [remainingAttributeTypeDefinitions]); + + return ( + + { + if (!option?.value) { + return; + } + + setFieldValue(`attributes.[${index}]`, { + ...values.attributes[index], + attribute_lookup_id: option.value, + attribute_type: getAttributeType(remainingAttributeTypeDefinitions, option.value) + }); + }} + required + sx={{ + flex: '0.5' + }} + /> + + + + + + arrayHelpers.remove(index)} + sx={{ mt: 1.125 }}> + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeValueControl.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeValueControl.tsx new file mode 100644 index 0000000000..70ea10f4f8 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/TechniqueAttributeValueControl.tsx @@ -0,0 +1,87 @@ +import AutocompleteField from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { + CreateTechniqueFormValues, + UpdateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +import { FieldArrayRenderProps, useFormikContext } from 'formik'; +import { ITechniqueAttributeQualitative, ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; + +interface ITechniqueAttributeValueControlProps { + selectedAttributeTypeDefinition?: ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative; + arrayHelpers: FieldArrayRenderProps; + index: number; +} + +/** + * Returns a form control for either selecting a qualitative attribute option or entering a quantitative attribute + * value. + * + * Returns null if the selected attribute type definition is not provided. + * + * @template FormValues + * @param {ITechniqueAttributeValueControlProps} props + * @return {*} + */ +export const TechniqueAttributeValueControl = < + FormValues extends CreateTechniqueFormValues | UpdateTechniqueFormValues +>( + props: ITechniqueAttributeValueControlProps +) => { + const { selectedAttributeTypeDefinition, index } = props; + + const { setFieldValue } = useFormikContext(); + + if (!selectedAttributeTypeDefinition) { + return ( + + ); + } + + if ('method_lookup_attribute_qualitative_id' in selectedAttributeTypeDefinition) { + // Return the qualitative attribute option select component + return ( + ({ + label: option.name, + value: option.method_lookup_attribute_qualitative_option_id + }))} + onChange={(_, option) => { + if (!option?.value) { + return; + } + + setFieldValue(`attributes.[${index}].attribute_value`, option.value); + }} + required + sx={{ + flex: '1 1 auto' + }} + /> + ); + } + + // Return the quantitative attribute value component + return ( + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/utils.ts b/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/utils.ts new file mode 100644 index 0000000000..7ee49a41b4 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/attributes/components/utils.ts @@ -0,0 +1,105 @@ +import { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import { TechniqueAttributeFormValues } from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +import { ITechniqueAttributeQualitative, ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; + +/** + * Given a list of mixed type attributes (qualitative and quantitative), and a list of unavailable attribute IDs, return + * a list of mixed attribute types that do not include the unavailable attributes. + * + * @param {((ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative)[])} allAttributeTypeDefinitions All + * attribute type definitions. + * @param {string[]} [unavailableAttributeIds=[]] A list of attribute IDs that should not be included in the returned + * list of attribute type definitions. + * @param {string} [selectedAttributeId] The ID of the selected attribute type definition, if one has been selected, + * which should be included in the returned list of attribute type definitions. + * @return {*} {((ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative)[])} + */ +export const getRemainingAttributes = ( + allAttributeTypeDefinitions: (ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative)[], + unavailableAttributeIds: string[] = [], + selectedAttributeId?: string +): (ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative)[] => { + const remainingAttributeTypeDefinitions = allAttributeTypeDefinitions.filter((attributeTypeDefinition) => { + if ('method_lookup_attribute_qualitative_id' in attributeTypeDefinition) { + return ( + selectedAttributeId === attributeTypeDefinition.method_lookup_attribute_qualitative_id || + !unavailableAttributeIds.includes(attributeTypeDefinition.method_lookup_attribute_qualitative_id) + ); + } + + return ( + selectedAttributeId === attributeTypeDefinition.method_lookup_attribute_quantitative_id || + !unavailableAttributeIds.includes(attributeTypeDefinition.method_lookup_attribute_quantitative_id) + ); + }); + + return remainingAttributeTypeDefinitions; +}; + +/** + * Given a list of mixed type attributes (qualitative and quantitative), return an array of objects that can be used + * with the AutoComplete component. + * + * @param {((ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative)[])} attributes + * @return {*} {IAutocompleteFieldOption[]} + */ +export const formatAttributesForAutoComplete = ( + attributes: (ITechniqueAttributeQuantitative | ITechniqueAttributeQualitative)[] +): IAutocompleteFieldOption[] => { + return attributes.map((option) => { + if ('method_lookup_attribute_qualitative_id' in option) { + return { + value: option.method_lookup_attribute_qualitative_id, + label: option.name, + description: option.description + }; + } + + return { + value: option.method_lookup_attribute_quantitative_id, + label: option.name, + description: option.description + }; + }); +}; + +/** + * Given a list of mixed type attributes (qualitative and quantitative), return the type of the attribute that matches + * the provided attribute ID. + * + * @param {((ITechniqueAttributeQualitative | ITechniqueAttributeQuantitative)[])} allAttributes + * @param {string} attributeId + * @return {*} {(TechniqueAttributeFormValues['attribute_type'] | undefined)} + */ +export const getAttributeType = ( + allAttributes: (ITechniqueAttributeQualitative | ITechniqueAttributeQuantitative)[], + attributeId: string +): TechniqueAttributeFormValues['attribute_type'] | undefined => { + for (const attribute of allAttributes) { + if ('method_lookup_attribute_qualitative_id' in attribute) { + if (attribute.method_lookup_attribute_qualitative_id === attributeId) { + return 'qualitative'; + } + } else { + if (attribute.method_lookup_attribute_quantitative_id === attributeId) { + return 'quantitative'; + } + } + } +}; + +/** + * Given a mixed type attribute (qualitative or quantitative), return the ID of the attribute. + * + * @param {(ITechniqueAttributeQualitative | ITechniqueAttributeQuantitative)} attributeTypeDefinition + * @return {*} {string} + */ +export const getAttributeId = ( + attributeTypeDefinition: ITechniqueAttributeQualitative | ITechniqueAttributeQuantitative +): string => { + if ('method_lookup_attribute_qualitative_id' in attributeTypeDefinition) { + return attributeTypeDefinition.method_lookup_attribute_qualitative_id; + } + + return attributeTypeDefinition.method_lookup_attribute_quantitative_id; +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/details/TechniqueDetailsForm.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/details/TechniqueDetailsForm.tsx new file mode 100644 index 0000000000..5ef490d454 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/details/TechniqueDetailsForm.tsx @@ -0,0 +1,24 @@ +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import CustomTextField from 'components/fields/CustomTextField'; + +/** + * Technique details form. + * + * @return {*} + */ +export const TechniqueDetailsForm = () => { + return ( + + + Detection distance (optional) + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/components/general-information/TechniqueGeneralInformationForm.tsx b/app/src/features/surveys/sampling-information/techniques/form/components/general-information/TechniqueGeneralInformationForm.tsx new file mode 100644 index 0000000000..50f685b9e3 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/components/general-information/TechniqueGeneralInformationForm.tsx @@ -0,0 +1,90 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import Grid from '@mui/material/Grid'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { ISelectWithSubtextFieldOption } from 'components/fields/SelectWithSubtext'; +import { + CreateTechniqueFormValues, + UpdateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; + +import { useFormikContext } from 'formik'; +import { useCodesContext } from 'hooks/useContext'; +import { useEffect } from 'react'; + +/** + * Technique general information form. + * + * @template FormValues + * @return {*} + */ +export const TechniqueGeneralInformationForm = < + FormValues extends CreateTechniqueFormValues | UpdateTechniqueFormValues +>() => { + const { setFieldValue } = useFormikContext(); + + const codesContext = useCodesContext(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const methodOptions: ISelectWithSubtextFieldOption[] = + codesContext.codesDataLoader.data?.sample_methods.map((option) => ({ + value: option.id, + label: option.name, + subText: option.description + })) ?? []; + + if (!codesContext.codesDataLoader.data) { + return ; + } + + return ( + <> + + + + + + ({ + value: option.value as number, + label: option.label, + description: option.subText + }))} + onChange={(_, value) => { + if (value?.value) { + setFieldValue('method_lookup_id', value.value); + } + }} + /> + + + + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/create/CreateTechniquePage.tsx b/app/src/features/surveys/sampling-information/techniques/form/create/CreateTechniquePage.tsx new file mode 100644 index 0000000000..414390df00 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/create/CreateTechniquePage.tsx @@ -0,0 +1,201 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import PageHeader from 'components/layout/PageHeader'; +import { CreateTechniqueI18N } from 'constants/i18n'; +import TechniqueFormContainer, { + CreateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +import { FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { ICreateTechniqueRequest } from 'interfaces/useTechniqueApi.interface'; +import { useRef, useState } from 'react'; +import { Prompt, useHistory } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; + +const initialTechniqueFormValues: CreateTechniqueFormValues = { + name: '', + description: '', + distance_threshold: null, + method_lookup_id: null, + attractants: [], + attributes: [] +}; + +/** + * Renders the body content of the create technique page. + * + * @return {*} + */ +export const CreateTechniquePage = () => { + const history = useHistory(); + const biohubApi = useBiohubApi(); + + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const [isSubmitting, setIsSubmitting] = useState(false); + + const formikRef = useRef>(null); + + if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data) { + return ; + } + + const handleSubmit = async (values: CreateTechniqueFormValues) => { + try { + setIsSubmitting(true); + + // Parse the form data into the request format + const createTechniqueRequestData: ICreateTechniqueRequest = { + ...values, + attributes: { + qualitative_attributes: values.attributes + .filter(({ attribute_type }) => attribute_type === 'qualitative') + .map((item) => ({ + method_technique_attribute_qualitative_id: null, + method_lookup_attribute_qualitative_id: item.attribute_lookup_id, + method_lookup_attribute_qualitative_option_id: item.attribute_value as string + })), + quantitative_attributes: values.attributes + .filter(({ attribute_type }) => attribute_type === 'quantitative') + .map((item) => ({ + method_technique_attribute_quantitative_id: null, + method_lookup_attribute_quantitative_id: item.attribute_lookup_id, + value: item.attribute_value as number + })) + } + }; + + // Create the technique + await biohubApi.technique.createTechniques(surveyContext.projectId, surveyContext.surveyId, [ + createTechniqueRequestData + ]); + + // Refresh the context, so the next page loads with the latest data + surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + + // Success, navigate back to the manage sampling information page + history.push( + `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, + SKIP_CONFIRMATION_DIALOG + ); + } catch (error) { + setIsSubmitting(false); + dialogContext.setErrorDialog({ + dialogTitle: CreateTechniqueI18N.createErrorTitle, + dialogText: CreateTechniqueI18N.createErrorText, + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError)?.errors, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + }, + open: true + }); + } + }; + + return ( + <> + + + + {projectContext.projectDataLoader.data?.projectData.project.project_name} + + + {surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name} + + + Manage Sampling Information + + + Create New Technique + + + } + buttonJSX={ + + formikRef.current?.submitForm()}> + Save and Exit + + + + } + /> + + + + handleSubmit(formikData)} + formikRef={formikRef} + /> + + { + formikRef.current?.submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/techniques/form/edit/EditTechniquePage.tsx b/app/src/features/surveys/sampling-information/techniques/form/edit/EditTechniquePage.tsx new file mode 100644 index 0000000000..45c742c015 --- /dev/null +++ b/app/src/features/surveys/sampling-information/techniques/form/edit/EditTechniquePage.tsx @@ -0,0 +1,239 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import PageHeader from 'components/layout/PageHeader'; +import { EditTechniqueI18N } from 'constants/i18n'; +import TechniqueFormContainer, { + UpdateTechniqueFormValues +} from 'features/surveys/sampling-information/techniques/form/components/TechniqueFormContainer'; +import { FormikProps } from 'formik'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { SKIP_CONFIRMATION_DIALOG, useUnsavedChangesDialog } from 'hooks/useUnsavedChangesDialog'; +import { IUpdateTechniqueRequest } from 'interfaces/useTechniqueApi.interface'; +import { useEffect, useRef, useState } from 'react'; +import { Prompt, useHistory, useParams } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; +import { v4 } from 'uuid'; + +/** + * Renders the body content of the Technique page. + * + * @return {*} + */ +export const EditTechniquePage = () => { + const history = useHistory(); + const biohubApi = useBiohubApi(); + + const urlParams: Record = useParams(); + const methodTechniqueId = Number(urlParams['method_technique_id']); + + const surveyContext = useSurveyContext(); + const projectContext = useProjectContext(); + const dialogContext = useDialogContext(); + + const { locationChangeInterceptor } = useUnsavedChangesDialog(); + + const [isSubmitting, setIsSubmitting] = useState(false); + + const formikRef = useRef>(null); + + const techniqueDataLoader = useDataLoader(() => + biohubApi.technique.getTechniqueById(surveyContext.projectId, surveyContext.surveyId, methodTechniqueId) + ); + + useEffect(() => { + techniqueDataLoader.load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!surveyContext.surveyDataLoader.data || !projectContext.projectDataLoader.data) { + return ; + } + + const technique = techniqueDataLoader.data; + + if (!technique) { + return ; + } + + const initialTechniqueValues: UpdateTechniqueFormValues = { + method_technique_id: technique.method_technique_id, + name: technique.name ?? null, + description: technique?.description ?? null, + distance_threshold: technique?.distance_threshold ?? null, + method_lookup_id: technique?.method_lookup_id ?? null, + attractants: technique?.attractants, + attributes: [ + ...(technique?.attributes.qualitative_attributes.map((attribute) => ({ + _id: v4(), // A temporary unique id for react keys, etc, as the attribute_id is not unique + attribute_id: attribute.method_technique_attribute_qualitative_id ?? null, + attribute_lookup_id: attribute.method_lookup_attribute_qualitative_id, + attribute_value: attribute.method_lookup_attribute_qualitative_option_id, + attribute_type: 'qualitative' as const + })) ?? []), + ...(technique?.attributes.quantitative_attributes.map((attribute) => ({ + _id: v4(), // A temporary unique id for react keys, etc, as the attribute_id is not unique + attribute_id: attribute.method_technique_attribute_quantitative_id ?? null, + attribute_lookup_id: attribute.method_lookup_attribute_quantitative_id, + attribute_value: attribute.value, + attribute_type: 'quantitative' as const + })) ?? []) + ] + }; + + const handleSubmit = async (values: UpdateTechniqueFormValues) => { + try { + setIsSubmitting(true); + + const formattedTechniqueObject: IUpdateTechniqueRequest = { + ...values, + attributes: { + quantitative_attributes: values.attributes + .filter((attribute) => attribute.attribute_type === 'quantitative') + .map((attribute) => ({ + method_technique_attribute_quantitative_id: attribute.attribute_id, + method_lookup_attribute_quantitative_id: attribute.attribute_lookup_id, + value: attribute.attribute_value as number + })), + qualitative_attributes: values.attributes + .filter((attribute) => attribute.attribute_type === 'qualitative') + .map((attribute) => ({ + method_technique_attribute_qualitative_id: attribute.attribute_id, + method_lookup_attribute_qualitative_id: attribute.attribute_lookup_id, + method_lookup_attribute_qualitative_option_id: attribute.attribute_value as string + })) + } + }; + + // Update the technique + await biohubApi.technique.updateTechnique( + surveyContext.projectId, + surveyContext.surveyId, + methodTechniqueId, + formattedTechniqueObject + ); + + // Refresh the context, so the next page loads with the latest data + surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + + // Success, navigate back to the manage sampling information page + history.push( + `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, + SKIP_CONFIRMATION_DIALOG + ); + } catch (error) { + setIsSubmitting(false); + dialogContext.setErrorDialog({ + dialogTitle: EditTechniqueI18N.createErrorTitle, + dialogText: EditTechniqueI18N.createErrorText, + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError)?.errors, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + }, + open: true + }); + } + }; + + return ( + <> + + + + {projectContext.projectDataLoader.data?.projectData.project.project_name} + + + {surveyContext.surveyDataLoader.data?.surveyData.survey_details.survey_name} + + + Manage Sampling Information + + + Edit Technique + + + } + buttonJSX={ + + formikRef.current?.submitForm()}> + Save and Exit + + + + } + /> + + + + handleSubmit(formikData)} + formikRef={formikRef} + /> + + { + formikRef.current?.submitForm(); + }}> + Save and Exit + + + + + + + ); +}; diff --git a/app/src/features/surveys/view/SurveyDetails.test.tsx b/app/src/features/surveys/view/SurveyDetails.test.tsx index a3d52d50af..51999f09c1 100644 --- a/app/src/features/surveys/view/SurveyDetails.test.tsx +++ b/app/src/features/surveys/view/SurveyDetails.test.tsx @@ -54,6 +54,7 @@ describe('SurveyDetails', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; it('renders correctly', async () => { const { getByText } = render( @@ -66,6 +67,7 @@ describe('SurveyDetails', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader, deploymentDataLoader: mockDeploymentDataLoader }}> diff --git a/app/src/features/surveys/view/SurveyHeader.test.tsx b/app/src/features/surveys/view/SurveyHeader.test.tsx index 8e06ac5f95..0bcb3a8342 100644 --- a/app/src/features/surveys/view/SurveyHeader.test.tsx +++ b/app/src/features/surveys/view/SurveyHeader.test.tsx @@ -44,6 +44,9 @@ const mockSurveyContext: ISurveyContext = { deploymentDataLoader: { data: null } as DataLoader, + techniqueDataLoader: { + data: [] + } as DataLoader, critterDeployments: [], surveyId: 1, projectId: 1 diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index 5d5c234a50..26502c5ccd 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -7,6 +7,7 @@ import { SurveyContext } from 'contexts/surveyContext'; import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; import SurveyDetails from 'features/surveys/view/SurveyDetails'; import React, { useContext, useEffect } from 'react'; +import { SurveySamplingContainer } from './components/sampling-data/SurveySamplingContainer'; import SurveySpatialData from './components/spatial-data/SurveySpatialData'; import SurveyStudyArea from './components/SurveyStudyArea'; import SurveyAnimals from './SurveyAnimals'; @@ -35,6 +36,10 @@ const SurveyPage: React.FC = () => { + + + + diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx index afe0e8ef1f..91b33810fb 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx @@ -23,6 +23,7 @@ describe('SurveyGeneralInformation', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; @@ -35,6 +36,7 @@ describe('SurveyGeneralInformation', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader, deploymentDataLoader: mockDeploymentDataLoader, critterDeployments: [] @@ -62,6 +64,7 @@ describe('SurveyGeneralInformation', () => { } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; @@ -75,6 +78,7 @@ describe('SurveyGeneralInformation', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, deploymentDataLoader: mockDeploymentDataLoader, critterDeployments: [] }}> @@ -90,6 +94,7 @@ describe('SurveyGeneralInformation', () => { const mockSurveyDataLoader = { data: undefined } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; const mockSampleSiteDataLoader = { data: null } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; @@ -103,6 +108,7 @@ describe('SurveyGeneralInformation', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader, deploymentDataLoader: mockDeploymentDataLoader, critterDeployments: [] diff --git a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx index 73b221c4ba..c9f7f79040 100644 --- a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx @@ -16,6 +16,7 @@ describe('SurveyProprietaryData', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( { critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, deploymentDataLoader: mockDeploymentDataLoader @@ -46,6 +48,7 @@ describe('SurveyProprietaryData', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( { critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, deploymentDataLoader: mockDeploymentDataLoader @@ -74,6 +78,7 @@ describe('SurveyProprietaryData', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container } = render( { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + deploymentDataLoader: mockDeploymentDataLoader, + techniqueDataLoader: mockTechniqueDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx index 5df5d153bf..a45560e2c3 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx @@ -24,6 +24,7 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId, getAllByTestId } = render( @@ -36,6 +37,7 @@ describe('SurveyPurposeAndMethodologyData', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, deploymentDataLoader: mockDeploymentDataLoader }}> @@ -74,6 +76,7 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId, getAllByTestId, queryByTestId } = render( @@ -86,7 +89,8 @@ describe('SurveyPurposeAndMethodologyData', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + deploymentDataLoader: mockDeploymentDataLoader, + techniqueDataLoader: mockTechniqueDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx index b702810c84..0817cb6a2b 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx @@ -45,6 +45,7 @@ describe.skip('SurveyStudyArea', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container } = render( { critterDeployments: [], surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, deploymentDataLoader: mockDeploymentDataLoader @@ -82,6 +84,7 @@ describe.skip('SurveyStudyArea', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container, queryByTestId } = render( { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader, deploymentDataLoader: mockDeploymentDataLoader }}> @@ -111,6 +115,7 @@ describe.skip('SurveyStudyArea', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container, getByTestId } = render( { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + deploymentDataLoader: mockDeploymentDataLoader, + techniqueDataLoader: mockTechniqueDataLoader }}> @@ -144,6 +150,7 @@ describe.skip('SurveyStudyArea', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, @@ -165,7 +172,8 @@ describe.skip('SurveyStudyArea', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, - deploymentDataLoader: mockDeploymentDataLoader + deploymentDataLoader: mockDeploymentDataLoader, + techniqueDataLoader: mockTechniqueDataLoader }}> @@ -237,6 +245,7 @@ describe.skip('SurveyStudyArea', () => { const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockDeploymentDataLoader = { data: [] } as DataLoader; + const mockTechniqueDataLoader = { data: [] } as DataLoader; mockUseApi.survey.getSurveyForView.mockResolvedValue({ surveyData: { @@ -276,6 +285,7 @@ describe.skip('SurveyStudyArea', () => { artifactDataLoader: mockArtifactDataLoader, sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, deploymentDataLoader: mockDeploymentDataLoader }}> diff --git a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx b/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx new file mode 100644 index 0000000000..5e890b6aaf --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx @@ -0,0 +1,24 @@ +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import { SurveySamplingTabs } from 'features/surveys/view/components/sampling-data/components/SurveySamplingTabs'; +import { SurveySamplingHeader } from './components/SurveySamplingHeader'; + +export const SurveySamplingContainer = () => { + return ( + + + + + + + + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingHeader.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingHeader.tsx new file mode 100644 index 0000000000..c9a3d0f698 --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingHeader.tsx @@ -0,0 +1,34 @@ +import { mdiCog } from '@mdi/js'; +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { ProjectRoleGuard } from 'components/security/Guards'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; +import { Link as RouterLink } from 'react-router-dom'; + +export const SurveySamplingHeader = () => { + return ( + + + Sampling Information + + + + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx new file mode 100644 index 0000000000..320a95eeb0 --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx @@ -0,0 +1,127 @@ +import { mdiAutoFix, mdiMapMarker } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { SurveySitesTable } from 'features/surveys/view/components/sampling-data/components/SurveySitesTable'; +import { SurveyTechniquesTable } from 'features/surveys/view/components/sampling-data/components/SurveyTechniquesTable'; +import { useSurveyContext } from 'hooks/useContext'; +import { useEffect, useState } from 'react'; + +export enum SurveySamplingView { + TECHNIQUES = 'TECHNIQUES', + SITES = 'SITES' +} + +export const SurveySamplingTabs = () => { + const surveyContext = useSurveyContext(); + + const [activeView, setActiveView] = useState(SurveySamplingView.TECHNIQUES); + + useEffect(() => { + // Refresh the data for the active view if the project or survey ID changes + if (activeView === SurveySamplingView.TECHNIQUES) { + surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + } + if (activeView === SurveySamplingView.SITES) { + surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView]); + + useEffect(() => { + // Load the data initially once per tab, if/when the active view changes + if (activeView === SurveySamplingView.TECHNIQUES) { + surveyContext.techniqueDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + } + if (activeView === SurveySamplingView.SITES) { + surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); + } + }, [ + activeView, + surveyContext.techniqueDataLoader, + surveyContext.sampleSiteDataLoader, + surveyContext.projectId, + surveyContext.surveyId + ]); + + return ( + <> + + { + if (!view) { + // An active view must be selected at all times + return; + } + + setActiveView(view); + }} + exclusive + sx={{ + display: 'flex', + gap: 1, + '& Button': { + py: 0.25, + px: 1.5, + border: 'none', + borderRadius: '4px !important', + fontSize: '0.875rem', + fontWeight: 700, + letterSpacing: '0.02rem' + } + }}> + } + value={SurveySamplingView.TECHNIQUES}> + {`Techniques (${surveyContext.techniqueDataLoader.data?.count ?? 0})`} + + } + value={SurveySamplingView.SITES}> + {`Sites (${surveyContext.sampleSiteDataLoader.data?.sampleSites.length ?? 0})`} + + + + + + + + + {activeView === SurveySamplingView.TECHNIQUES && ( + + } + delay={200}> + + + + )} + + {activeView === SurveySamplingView.SITES && ( + + } + delay={200}> + + + + )} + + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx new file mode 100644 index 0000000000..3fd7f9022e --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx @@ -0,0 +1,95 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridOverlay } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { IGetSampleSiteResponse } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; + +export interface ISurveySitesTableProps { + sites?: IGetSampleSiteResponse; +} + +export const SurveySitesTable = (props: ISurveySitesTableProps) => { + const { sites } = props; + + const rows = + sites?.sampleSites.map((site) => ({ + id: site.survey_sample_site_id, + name: site.name, + description: site.description + })) || []; + + const columns: GridColDef[] = [ + { + field: 'name', + headerName: 'Name', + flex: 0.3 + }, + { + field: 'description', + headerName: 'Description', + flex: 1, + renderCell: (params) => { + return ( + + + {params.row.description} + + + ); + } + } + ]; + + return ( + 'auto'} + rows={rows} + getRowId={(row) => row.id} + columns={columns} + disableRowSelectionOnClick + noRowsOverlay={ + + + + Start by adding sampling information  + + + + Add Techniques, then apply your techniques to Sites + + + + } + sx={{ + '& .MuiDataGrid-virtualScroller': { + height: rows.length === 0 ? '250px' : 'unset', + overflowY: 'auto !important', + overflowX: 'hidden' + }, + '& .MuiDataGrid-overlay': { + height: '250px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + } + }} + /> + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx new file mode 100644 index 0000000000..144be73dd8 --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx @@ -0,0 +1,112 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import blueGrey from '@mui/material/colors/blueGrey'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridOverlay } from '@mui/x-data-grid'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { useCodesContext } from 'hooks/useContext'; +import { IGetTechniqueResponse, IGetTechniquesResponse } from 'interfaces/useTechniqueApi.interface'; +import { getCodesName } from 'utils/Utils'; + +export interface ISurveyTechniquesTableProps { + techniques?: IGetTechniquesResponse; +} + +export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { + const { techniques } = props; + + const codesContext = useCodesContext(); + + const rows = + techniques?.techniques.map((technique) => ({ + id: technique.method_technique_id, + name: getCodesName(codesContext.codesDataLoader.data, 'sample_methods', technique.method_lookup_id) ?? '', + method_lookup_id: technique.method_lookup_id, + description: technique.description + })) || []; + + const columns: GridColDef[] = [ + { + field: 'name', + headerName: 'Name', + flex: 0.3 + }, + { + field: 'method_lookup_id', + flex: 0.3, + headerName: 'Method', + renderCell: (params) => ( + + ) + }, + { + field: 'description', + headerName: 'Description', + flex: 1, + renderCell: (params) => { + return ( + + + {params.row.description} + + + ); + } + } + ]; + + return ( + 'auto'} + rows={rows} + getRowId={(row) => row.id} + columns={columns} + disableRowSelectionOnClick + noRowsOverlay={ + + + + Start by adding sampling information  + + + + Add Techniques, then apply your techniques to Sites + + + + } + sx={{ + '& .MuiDataGrid-virtualScroller': { + height: rows.length === 0 ? '250px' : 'unset', + overflowY: 'auto !important', + overflowX: 'hidden' + }, + '& .MuiDataGrid-overlay': { + height: '250px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + } + }} + /> + ); +}; diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx index 9aabf789b5..f4722d6ada 100644 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx +++ b/app/src/features/surveys/view/components/spatial-data/SurveySpatialData.tsx @@ -36,14 +36,15 @@ const SurveySpatialData = () => { const biohubApi = useBiohubApi(); + const [mapPointMetadata, setMapPointMetadata] = useState>({}); + + // Fetch observations spatial data const observationsGeometryDataLoader = useDataLoader(() => biohubApi.observation.getObservationsGeometry(projectId, surveyId) ); - observationsGeometryDataLoader.load(); - const [mapPointMetadata, setMapPointMetadata] = useState>({}); - + // Fetch study area data const studyAreaLocations = useMemo( () => surveyContext.surveyDataLoader.data?.surveyData.locations ?? [], [surveyContext.surveyDataLoader.data] @@ -282,7 +283,11 @@ const SurveySpatialData = () => { value: (sampleSite.sample_methods ?? []) .map( (method) => - getCodesName(codesContext.codesDataLoader.data, 'sample_methods', method.method_lookup_id) ?? '' + getCodesName( + codesContext.codesDataLoader.data, + 'sample_methods', + method.technique.method_technique_id + ) ?? '' ) .filter(Boolean) .join(', ') diff --git a/app/src/features/surveys/view/components/spatial-data/SurveySpatialObservationDataTable.tsx b/app/src/features/surveys/view/components/spatial-data/SurveySpatialObservationDataTable.tsx index 19ecabc974..0605ddd16a 100644 --- a/app/src/features/surveys/view/components/spatial-data/SurveySpatialObservationDataTable.tsx +++ b/app/src/features/surveys/view/components/spatial-data/SurveySpatialObservationDataTable.tsx @@ -1,7 +1,11 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; import grey from '@mui/material/colors/grey'; import Skeleton from '@mui/material/Skeleton'; import Stack from '@mui/material/Stack'; -import { GridColDef, GridSortModel } from '@mui/x-data-grid'; +import Typography from '@mui/material/Typography'; +import { GridColDef, GridOverlay, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { SurveyContext } from 'contexts/surveyContext'; import dayjs from 'dayjs'; @@ -222,6 +226,31 @@ const SurveySpatialObservationDataTable = (props: ISurveySpatialObservationDataT disableColumnMenu disableVirtualization data-testid="survey-spatial-observation-data-table" + noRowsOverlay={ + + + + Add observations after sampling information  + + + + After adding sampling information, add observations and assign them to a sampling period + + + + } + sx={{ + '& .MuiDataGrid-virtualScroller': { + height: '250px', + overflowY: 'auto !important' + }, + '& .MuiDataGrid-overlay': { + height: '250px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + } + }} /> )} diff --git a/app/src/hooks/api/useReferenceApi.ts b/app/src/hooks/api/useReferenceApi.ts index 3bc44f35af..4bf0d22ea8 100644 --- a/app/src/hooks/api/useReferenceApi.ts +++ b/app/src/hooks/api/useReferenceApi.ts @@ -1,5 +1,6 @@ import { AxiosInstance } from 'axios'; -import { EnvironmentType } from 'interfaces/useReferenceApi.interface'; +import { EnvironmentType, IGetTechniqueAttributes } from 'interfaces/useReferenceApi.interface'; +import qs from 'qs'; /** * Returns a set of supported api methods for working with reference data. @@ -20,8 +21,26 @@ const useReferenceApi = (axios: AxiosInstance) => { return data; }; + /** + * Get attributes available for a method_lookup_id + * + * @param {number[]} methodLookupIds + * @return {*} {Promise} + */ + const getTechniqueAttributes = async (methodLookupIds: number[]): Promise => { + const { data } = await axios.get('/api/reference/get/technique-attribute', { + params: { methodLookupId: methodLookupIds }, + paramsSerializer: (params: any) => { + return qs.stringify(params); + } + }); + + return data; + }; + return { - findSubcountEnvironments + findSubcountEnvironments, + getTechniqueAttributes }; }; diff --git a/app/src/hooks/api/useSamplingSiteApi.ts b/app/src/hooks/api/useSamplingSiteApi.ts index 3cc0628149..a8f7783595 100644 --- a/app/src/hooks/api/useSamplingSiteApi.ts +++ b/app/src/hooks/api/useSamplingSiteApi.ts @@ -1,7 +1,7 @@ import { AxiosInstance } from 'axios'; import { ICreateSamplingSiteRequest, - IEditSamplingSiteRequest, + IEditSampleSiteRequest, IGetSampleLocationDetails, IGetSampleSiteResponse } from 'interfaces/useSamplingSiteApi.interface'; @@ -72,7 +72,7 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { projectId: number, surveyId: number, sampleSiteId: number, - sampleSite: IEditSamplingSiteRequest + sampleSite: IEditSampleSiteRequest ): Promise => { await axios.put(`/api/project/${projectId}/survey/${surveyId}/sample-site/${sampleSiteId}`, sampleSite); }; diff --git a/app/src/hooks/api/useTechniqueApi.ts b/app/src/hooks/api/useTechniqueApi.ts new file mode 100644 index 0000000000..52a1eaa9a9 --- /dev/null +++ b/app/src/hooks/api/useTechniqueApi.ts @@ -0,0 +1,142 @@ +import { AxiosInstance } from 'axios'; +import { + ICreateTechniqueRequest, + IGetTechniqueResponse, + IGetTechniquesResponse, + IUpdateTechniqueRequest +} from 'interfaces/useTechniqueApi.interface'; +import qs from 'qs'; +import { ApiPaginationRequestOptions } from 'types/misc'; + +/** + * Returns a set of supported api methods for working with techniques. + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +const useTechniqueApi = (axios: AxiosInstance) => { + /** + * Get all techniques for a survey. + * + * @param {number} projectId + * @param {number} surveyId + * @param {ApiPaginationRequestOptions} [pagination] + * @return {*} {Promise} + */ + const getTechniquesForSurvey = async ( + projectId: number, + surveyId: number, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { + ...pagination + }; + + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/technique`, { + params, + paramsSerializer: (params) => qs.stringify(params) + }); + + return data; + }; + + /** + * Get a technique. + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} {Promise} + */ + const getTechniqueById = async ( + projectId: number, + surveyId: number, + methodTechniqueId: number + ): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/technique/${methodTechniqueId}`); + + return data; + }; + + /** + * Create a new technique. + * + * @param {number} projectId + * @param {number} surveyId + * @param {ICreateTechniqueRequest[]} techniques + * @return {*} {Promise} + */ + const createTechniques = async ( + projectId: number, + surveyId: number, + techniques: ICreateTechniqueRequest[] + ): Promise => { + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/technique`, { + techniques + }); + + return data; + }; + + /** + * Update an existing technique. + * + * @param {number} projectId + * @param {number} surveyId + * @param {IUpdateTechniqueRequest} technique + * @return {*} {Promise} + */ + const updateTechnique = async ( + projectId: number, + surveyId: number, + methodTechniqueId: number, + technique: IUpdateTechniqueRequest + ): Promise => { + const { data } = await axios.put(`/api/project/${projectId}/survey/${surveyId}/technique/${methodTechniqueId}`, { + technique + }); + + return data; + }; + + /** + * Delete a technique. + * + * @param {number} projectId + * @param {number} surveyId + * @param {number} methodTechniqueId + * @return {*} + */ + const deleteTechnique = async (projectId: number, surveyId: number, methodTechniqueId: number): Promise => { + const { data } = await axios.delete(`/api/project/${projectId}/survey/${surveyId}/technique/${methodTechniqueId}`); + + return data; + }; + + /** + * Delete techniques. + * + * @param {number} projectId + * @param {number} surveyId + * @param {number[]} methodTechniqueIds[] + * @return {*} {Promise} + */ + const deleteTechniques = async (projectId: number, surveyId: number, methodTechniqueIds: number[]): Promise => { + const { data } = await axios.post(`/api/project/${projectId}/survey/${surveyId}/technique/delete`, { + methodTechniqueIds + }); + + return data; + }; + + return { + createTechniques, + updateTechnique, + getTechniqueById, + getTechniquesForSurvey, + deleteTechnique, + deleteTechniques + }; +}; + +export default useTechniqueApi; diff --git a/app/src/hooks/useAsync.tsx b/app/src/hooks/useAsync.tsx index c7688adbda..e5f98fd50b 100644 --- a/app/src/hooks/useAsync.tsx +++ b/app/src/hooks/useAsync.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; export type AsyncFunction = (...args: AFArgs) => Promise; @@ -33,28 +33,31 @@ export const useAsync = ( const isPending = useRef(false); - const wrappedAsyncFunction: AsyncFunction = async (...args) => { - if (ref.current && isPending.current) { - return ref.current; - } + const wrappedAsyncFunction: AsyncFunction = useCallback( + async (...args) => { + if (ref.current && isPending.current) { + return ref.current; + } - isPending.current = true; + isPending.current = true; - ref.current = asyncFunction(...args).then( - (response: AFResponse) => { - isPending.current = false; + ref.current = asyncFunction(...args).then( + (response: AFResponse) => { + isPending.current = false; - return response; - }, - (error) => { - isPending.current = false; + return response; + }, + (error) => { + isPending.current = false; - throw error; - } - ); + throw error; + } + ); - return ref.current; - }; + return ref.current; + }, + [asyncFunction] + ); return wrappedAsyncFunction; }; diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index aff98ea3ce..2443e19f5e 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -18,6 +18,7 @@ import useSpatialApi from './api/useSpatialApi'; import useStandardsApi from './api/useStandardsApi'; import useSurveyApi from './api/useSurveyApi'; import useTaxonomyApi from './api/useTaxonomyApi'; +import useTechniqueApi from './api/useTechniqueApi'; import useTelemetryApi from './api/useTelemetryApi'; import useUserApi from './api/useUserApi'; @@ -58,6 +59,8 @@ export const useBiohubApi = () => { const samplingSite = useSamplingSiteApi(apiAxios); + const technique = useTechniqueApi(apiAxios); + const standards = useStandardsApi(apiAxios); const reference = useReferenceApi(apiAxios); @@ -81,6 +84,7 @@ export const useBiohubApi = () => { external, publish, spatial, + technique, funding, samplingSite, standards, diff --git a/app/src/hooks/useDataLoader.ts b/app/src/hooks/useDataLoader.ts index ea16873561..6ddf8c17ee 100644 --- a/app/src/hooks/useDataLoader.ts +++ b/app/src/hooks/useDataLoader.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { AsyncFunction, useAsync } from './useAsync'; import useIsMounted from './useIsMounted'; @@ -85,64 +85,89 @@ export default function useDataLoader => { - try { - setIsLoading(true); + const loadData = useCallback( + async (...args: AFArgs): Promise => { + try { + setIsLoading(true); - const response = await getData(...args); + const response = await getData(...args); - if (!isMounted()) { - return; - } + if (!isMounted()) { + return; + } - setData(response); + setData(response); - return response; - } catch (error) { - if (!isMounted()) { - return; - } + return response; + } catch (error) { + if (!isMounted()) { + return; + } - setError(error); + setError(error); - onError?.(error); - } finally { - if (isMounted()) { - setIsLoading(false); - setIsReady(true); - setHasLoaded(true); + onError?.(error); + } finally { + if (isMounted()) { + setIsLoading(false); + setIsReady(true); + setHasLoaded(true); + } + } + }, + [getData, isMounted, onError] + ); + + const load = useCallback( + async (...args: AFArgs) => { + if (oneTimeLoad) { + return data; } - } - }; - - const load = async (...args: AFArgs) => { - if (oneTimeLoad) { - return data; - } - - setOneTimeLoad(true); - return loadData(...args); - }; - - const refresh = async (...args: AFArgs) => { - setError(undefined); - setIsLoading(false); - setIsReady(false); - return loadData(...args); - }; - const clearError = () => { + setOneTimeLoad(true); + return loadData(...args); + }, + [data, loadData, oneTimeLoad] + ); + + const refresh = useCallback( + async (...args: AFArgs) => { + // Clear previous error/loading state + setError(undefined); + setIsLoading(false); + setIsReady(false); + + // Call loadData to fetch new data + return loadData(...args); + }, + [loadData] + ); + + const clearError = useCallback(() => { setError(undefined); - }; + }, []); - const clearData = () => { + const clearData = useCallback(() => { setData(undefined); setError(undefined); setIsLoading(false); setIsReady(false); setHasLoaded(false); setOneTimeLoad(false); - }; - - return { data, error, isLoading, isReady, hasLoaded, load, refresh, clearError, clearData }; + }, []); + + return useMemo( + () => ({ + data, + error, + isLoading, + isReady, + hasLoaded, + load, + refresh, + clearError, + clearData + }), + [clearData, clearError, data, error, hasLoaded, isLoading, isReady, load, refresh] + ); } diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index 9df48a4aa8..eed3a36d6c 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -40,4 +40,5 @@ export interface IGetAllCodeSetsResponse { survey_progress: CodeSet<{ id: number; name: string; description: string }>; sample_methods: CodeSet<{ id: number; name: string; description: string }>; method_response_metrics: CodeSet<{ id: number; name: string; description: string }>; + attractants: CodeSet<{ id: number; name: string; description: string }>; } diff --git a/app/src/interfaces/useReferenceApi.interface.ts b/app/src/interfaces/useReferenceApi.interface.ts index b4ab9f522c..f7466efa09 100644 --- a/app/src/interfaces/useReferenceApi.interface.ts +++ b/app/src/interfaces/useReferenceApi.interface.ts @@ -47,3 +47,43 @@ export type EnvironmentTypeIds = { qualitative_environments: EnvironmentQualitativeTypeDefinition['environment_qualitative_id'][]; quantitative_environments: EnvironmentQuantitativeTypeDefinition['environment_quantitative_id'][]; }; + +/** + * Technique quantitative attributes + */ +export interface ITechniqueAttributeQuantitative { + method_lookup_attribute_quantitative_id: string; + name: string; + description: string | null; + unit: string | null; + min: number | null; + max: number | null; +} + +/** + * Technique qualitative attributes + */ +export interface ITechniqueAttributeQualitativeOption { + method_lookup_attribute_qualitative_option_id: string; + name: string; + description: string | null; +} + +/** + * Technique qualitative attributes + */ +export interface ITechniqueAttributeQualitative { + method_lookup_attribute_qualitative_id: string; + name: string; + description: string | null; + options: ITechniqueAttributeQualitativeOption[]; +} + +/** + * Response for fetching technique attributes for a method lookup id + */ +export interface IGetTechniqueAttributes { + method_lookup_id: number; + quantitative_attributes: ITechniqueAttributeQuantitative[]; + qualitative_attributes: ITechniqueAttributeQualitative[]; +} diff --git a/app/src/interfaces/useSamplingSiteApi.interface.ts b/app/src/interfaces/useSamplingSiteApi.interface.ts index 9a63d25bb4..675839738c 100644 --- a/app/src/interfaces/useSamplingSiteApi.interface.ts +++ b/app/src/interfaces/useSamplingSiteApi.interface.ts @@ -1,31 +1,48 @@ -import { ISurveySampleMethodData } from 'features/surveys/observations/sampling-sites/create/form/MethodForm'; -import { ISurveySampleSite } from 'features/surveys/observations/sampling-sites/create/SamplingSitePage'; +import { ISurveySampleMethodFormData } from 'features/surveys/sampling-information/methods/components/SamplingMethodForm'; +import { ISurveySampleMethodPeriodData } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; import { Feature } from 'geojson'; +import { ApiPaginationResponseParams } from 'types/misc'; import { IGetSurveyBlock, IGetSurveyStratum } from './useSurveyApi.interface'; -export interface IEditSamplingSiteRequest { +export interface ISurveySampleSite { + name: string; + description: string; + geojson: Feature; +} + +export interface ISurveySampleMethod { + survey_sample_method_id: number | null; + survey_sample_site_id: number | null; + method_response_metric_id: number | null; + description: string; + method_technique_id: number | null; + sample_periods: ISurveySampleMethodPeriodData[]; +} + +export interface ICreateSamplingSiteRequest { + survey_id: number; + survey_sample_sites: ISurveySampleSite[]; // extracted list from shape files + sample_methods: ISurveySampleMethod[]; + blocks: IGetSurveyBlock[]; + stratums: IGetSurveyStratum[]; +} + +export interface IEditSampleSiteRequest { sampleSite: { name: string; description: string; survey_id: number; survey_sample_sites: Feature[]; // extracted list from shape files (used for formik loading) geojson?: Feature; // geojson object from map (used for sending to api) - methods: ISurveySampleMethodData[]; + methods: ISurveySampleMethod[]; blocks: { survey_block_id: number }[]; stratums: { survey_stratum_id: number }[]; }; } -export interface ICreateSamplingSiteRequest { - survey_id: number; - survey_sample_sites: ISurveySampleSite[]; // extracted list from shape files - sample_methods: ISurveySampleMethodData[]; - blocks: IGetSurveyBlock[]; - stratums: IGetSurveyStratum[]; -} - export interface IGetSampleSiteResponse { sampleSites: IGetSampleLocationDetails[]; + pagination: ApiPaginationResponseParams; } export interface IGetSampleLocationRecord { @@ -34,7 +51,6 @@ export interface IGetSampleLocationRecord { name: string; description: string; geojson: Feature; - geography: string; create_date: string; create_user: number; update_date: string | null; @@ -48,13 +64,7 @@ export interface IGetSampleLocationDetails { name: string; description: string; geojson: Feature; - geography: string; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; - sample_methods: IGetSampleMethodRecord[]; + sample_methods: IGetSampleMethodDetails[]; blocks: IGetSampleBlockDetails[]; stratums: IGetSampleStratumDetails[]; } @@ -65,9 +75,7 @@ export interface IGetSampleLocationDetailsForUpdate { name: string; description: string; geojson: Feature; - geography: string; - revision_count: number; - sample_methods: IGetSampleMethodRecord[] | ISurveySampleMethodData[]; + sample_methods: (IGetSampleMethodDetails | ISurveySampleMethodFormData)[]; blocks: IGetSampleBlockDetails[]; stratums: IGetSampleStratumDetails[]; } @@ -101,17 +109,20 @@ export interface IGetSampleStratumDetails { export interface IGetSampleMethodRecord { survey_sample_method_id: number; survey_sample_site_id: number; - method_lookup_id: number; method_response_metric_id: number; description: string; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; sample_periods: IGetSamplePeriodRecord[]; } +export interface IGetSampleMethodDetails extends IGetSampleMethodRecord { + technique: { + method_technique_id: number; + name: string; + description: string; + attractants: number[]; + }; +} + export interface IGetSamplePeriodRecord { survey_sample_period_id: number; survey_sample_method_id: number; diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index e775058042..6059106f18 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -129,7 +129,6 @@ export interface IGetSurveyLocation { name: string; description: string; geometry: Feature[]; - geography: string | null; geojson: Feature[]; revision_count: number; } diff --git a/app/src/interfaces/useTechniqueApi.interface.ts b/app/src/interfaces/useTechniqueApi.interface.ts new file mode 100644 index 0000000000..8d8832b9ba --- /dev/null +++ b/app/src/interfaces/useTechniqueApi.interface.ts @@ -0,0 +1,52 @@ +import { ApiPaginationResponseParams } from 'types/misc'; + +export type TechniqueAttractant = { + attractant_lookup_id: number; +}; + +type TechniqueQualitativeAttribute = { + method_technique_attribute_qualitative_id: number | null; + method_lookup_attribute_qualitative_id: string; + method_lookup_attribute_qualitative_option_id: string; +}; + +type TechniqueQuantitativeAttribute = { + method_technique_attribute_quantitative_id: number | null; + method_lookup_attribute_quantitative_id: string; + value: number; +}; + +export interface ICreateTechniqueRequest { + name: string; + description: string | null; + distance_threshold: number | null; + method_lookup_id: number | null; + attractants: TechniqueAttractant[]; + attributes: { + qualitative_attributes: TechniqueQualitativeAttribute[]; + quantitative_attributes: TechniqueQuantitativeAttribute[]; + }; +} + +export interface IUpdateTechniqueRequest extends ICreateTechniqueRequest { + method_technique_id: number; +} + +export interface IGetTechniqueResponse { + method_technique_id: number; + name: string; + description: string | null; + method_lookup_id: number; + distance_threshold: number | null; + attractants: TechniqueAttractant[]; + attributes: { + quantitative_attributes: TechniqueQuantitativeAttribute[]; + qualitative_attributes: TechniqueQualitativeAttribute[]; + }; +} + +export interface IGetTechniquesResponse { + techniques: IGetTechniqueResponse[]; + count: number; + pagination: ApiPaginationResponseParams; +} diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 63212d6756..ea54c4efa8 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -62,5 +62,9 @@ export const codes: IGetAllCodeSetsResponse = { method_response_metrics: [ { id: 1, name: 'Count', description: 'Description 1' }, { id: 2, name: 'Presence-absence', description: 'Description 2' } + ], + attractants: [ + { id: 1, name: 'Bait', description: 'Consumable bait or food used as a lure.' }, + { id: 1, name: 'Scent', description: 'A scent used as a lure.' } ] }; diff --git a/app/src/test-helpers/survey-helpers.ts b/app/src/test-helpers/survey-helpers.ts index 533bd89a37..b8d0dff493 100644 --- a/app/src/test-helpers/survey-helpers.ts +++ b/app/src/test-helpers/survey-helpers.ts @@ -97,7 +97,6 @@ export const surveyObject: SurveyViewObject = { name: 'study area', description: 'study area description', geometry: [geoJsonFeature], - geography: null, geojson: [geoJsonFeature], revision_count: 0 } diff --git a/app/src/themes/appTheme.ts b/app/src/themes/appTheme.ts index 228668031f..1afe273e4c 100644 --- a/app/src/themes/appTheme.ts +++ b/app/src/themes/appTheme.ts @@ -200,7 +200,10 @@ const appTheme = createTheme({ MuiDialog: { styleOverrides: { paperWidthXl: { - minWidth: '800px' + minWidth: '600px' + }, + paperFullScreen: { + minWidth: '100%' } } }, @@ -283,6 +286,13 @@ const appTheme = createTheme({ } } }, + MuiListItemText: { + styleOverrides: { + root: { + borderRadius: '3px' + } + } + }, MuiListItemIcon: { styleOverrides: { root: { diff --git a/app/tsconfig.json b/app/tsconfig.json index 14488a203a..a156d42a62 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -14,7 +14,7 @@ "noImplicitAny": true, "suppressImplicitAnyIndexErrors": true, "allowSyntheticDefaultImports": true, - "noUnusedLocals": true, + "noUnusedLocals": false, "esModuleInterop": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, diff --git a/database/package-lock.json b/database/package-lock.json index 47e60d9423..98828d2ca1 100644 --- a/database/package-lock.json +++ b/database/package-lock.json @@ -300,141 +300,6 @@ } } }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.5.0.tgz", - "integrity": "sha512-dyA25zQjm3xmMFsRPFgBpSqWSW9TITnkndZkZAiPYLjBxH9oTNMa0l09BePsaqEeXySY++tUgAeYu/9onsHLbg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.5.0.tgz", - "integrity": "sha512-cO7kZMMA/fcQIBT31LBzcVNSk3AZGVYLqvEPnJhFImjPm3mGKUd6kWpARUEGR68MyRU2VsWhE6eCjMcM+G7bxw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.5.0.tgz", - "integrity": "sha512-BXaXytS4y9lBFRO6vwA6ovvy1d2ZIzS02i2R1oegoZzzNu89CJDpkYXYS9bId0GvK2m9Q9y2ofoZzKE2Rp3PqQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.5.0.tgz", - "integrity": "sha512-Bu4/41pGadXKnRsUbox0ig63xImATVH704oPCXcoOvNGkDyMjWgIAhzIi111vrwFNpj9utabgUE4AtlUa2tAOQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.5.0.tgz", - "integrity": "sha512-lUFFvC8tsepNcTnKEHNrePWanVVef6PQ82Rv9wIeebgGHRUqDh6+CyCqodXez+aKz6NyE/PBIfp0r+jPx4hoJA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.5.0.tgz", - "integrity": "sha512-c6LegFU1qdyMfk+GzNIOvrX61+mksm21Q01FBnXSy1nf1ACj/a86jmr3zkPl0zpNVHfPOw3Ry1QIuLQKD+67YA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.5.0.tgz", - "integrity": "sha512-I/V8aWBmfDWwjtM1bS8ASG+6PcO/pVFYyPP5g2ok46Vz1o1MnAUd18mHnWX43nqVJokaW+BD/G4ZMZ+gXRl4zQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.5.0.tgz", - "integrity": "sha512-nN685BvI7iM58xabrSOSQHUvIY10pcXh5H9DmS8LeYqG6Dkq7QZ8AwYqqonOitIS5C35MUfhSMLpOTzKoLdUqA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.5.0.tgz", - "integrity": "sha512-3YjltmEHljI+TvuDOC4lspUzjBUoB3X5BhftRBprSTJx/czuMl0vdoZKs2Snzb5Eqqesp0Rl8q+iQ1E1oJ6dEA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, "node_modules/@swc/core-win32-x64-msvc": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.5.0.tgz", diff --git a/database/src/knexfile.ts b/database/src/knexfile.ts index 30fd955660..9cbfb1a8e7 100644 --- a/database/src/knexfile.ts +++ b/database/src/knexfile.ts @@ -18,7 +18,7 @@ export default { directory: './migrations' }, seeds: { - directory: ['./seeds', './procedures'] + directory: ['./procedures', './seeds'] } }, production: { diff --git a/database/src/migrations/20240527000000_method_technique.ts b/database/src/migrations/20240527000000_method_technique.ts new file mode 100644 index 0000000000..cbde43124a --- /dev/null +++ b/database/src/migrations/20240527000000_method_technique.ts @@ -0,0 +1,675 @@ +import { Knex } from 'knex'; + +/** + * Create 10 new tables: + * + * ATTRACTANT LOOKUP + * - attractant_lookup + * + * ATTRIBUTE LOOKUPS + * - technique_attribute_quantitative + * - technique_attribute_qualitative + * + * JOINS BETWEEN ATTRIBUTE LOOKUPS AND METHOD_LOOKUP_ID + * - method_lookup_attribute_quantitative + * - method_lookup_attribute_qualitative + * + * QUALITATIVE OPTIONS LOOKUP (depends on method_lookup_attribute_qualitative, not technique_attribute_qualitative) + * - method_lookup_attribute_qualitative_option + * + * METHOD TECHNIQUE TABLE + * - method_technique + * + * JOINS BETWEEN METHOD TECHNIQUE AND METHOD_LOOKUP_ATTRIBUTE_* + * - method_technique_attribute_quantitative + * - method_technique_attribute_qualitative + * + * JOIN BETWEEN METHOD TECHNIQUE AND ATTRACTANT_LOOKUP + * - method_technique_attractant + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + ---------------------------------------------------------------------------------------- + -- Create technique lookup tables + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + DROP VIEW IF EXISTS survey_sample_method; + + SET SEARCH_PATH=biohub, public; + + ---------------------------------------------------------------------------------------- + + CREATE TABLE technique_attribute_quantitative ( + technique_attribute_quantitative_id uuid DEFAULT public.gen_random_uuid(), + name varchar(100) NOT NULL, + description varchar(250), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT technique_attribute_quantitative_pk PRIMARY KEY (technique_attribute_quantitative_id) + ); + + COMMENT ON TABLE technique_attribute_quantitative IS 'Quantitative technique attributes.'; + COMMENT ON COLUMN technique_attribute_quantitative.technique_attribute_quantitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN technique_attribute_quantitative.name IS 'The name of the technique attribute.'; + COMMENT ON COLUMN technique_attribute_quantitative.description IS 'The description of the technique attribute.'; + COMMENT ON COLUMN technique_attribute_quantitative.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN technique_attribute_quantitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN technique_attribute_quantitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN technique_attribute_quantitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN technique_attribute_quantitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN technique_attribute_quantitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint + CREATE UNIQUE INDEX technique_attribute_quantitative_nuk1 ON technique_attribute_quantitative(name, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + ---------------------------------------------------------------------------------------- + + CREATE TABLE technique_attribute_qualitative ( + technique_attribute_qualitative_id uuid DEFAULT public.gen_random_uuid(), + name varchar(100) NOT NULL, + description varchar(400), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT technique_attribute_qualitative_pk PRIMARY KEY (technique_attribute_qualitative_id) + ); + + COMMENT ON TABLE technique_attribute_qualitative IS 'Qualitative technique attributes.'; + COMMENT ON COLUMN technique_attribute_qualitative.technique_attribute_qualitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN technique_attribute_qualitative.name IS 'The name of the technique attribute.'; + COMMENT ON COLUMN technique_attribute_qualitative.description IS 'The description of the technique attribute.'; + COMMENT ON COLUMN technique_attribute_qualitative.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN technique_attribute_qualitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN technique_attribute_qualitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN technique_attribute_qualitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN technique_attribute_qualitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN technique_attribute_qualitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint + CREATE UNIQUE INDEX technique_attribute_qualitative_nuk1 ON technique_attribute_qualitative(name, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + ---------------------------------------------------------------------------------------- + -- Create technique join tables + ---------------------------------------------------------------------------------------- + + CREATE TABLE method_lookup_attribute_qualitative ( + method_lookup_attribute_qualitative_id uuid DEFAULT public.gen_random_uuid(), + technique_attribute_qualitative_id uuid NOT NULL, + method_lookup_id integer NOT NULL, + description varchar(400), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_lookup_attribute_qualitative_pk PRIMARY KEY (method_lookup_attribute_qualitative_id) + ); + + COMMENT ON TABLE method_lookup_attribute_qualitative IS 'Qualitative attributes available for a method lookup id.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.method_lookup_attribute_qualitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.technique_attribute_qualitative_id IS 'Foreign key to the technique_attribute_qualitative table.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.method_lookup_id IS 'Foreign key to the method_lookup table.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.description IS 'The description of the attribute.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint (don't allow 2 records with the same technique_attribute_qualitative_id and method_lookup_id and a NULL record_end_date) + CREATE UNIQUE INDEX method_lookup_attribute_qualitative_nuk1 ON method_lookup_attribute_qualitative(technique_attribute_qualitative_id, method_lookup_id, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + -- Add unique composite key constraint + ALTER TABLE method_lookup_attribute_qualitative + ADD CONSTRAINT method_lookup_attribute_qualitative_uk1 + UNIQUE (method_lookup_attribute_qualitative_id, technique_attribute_qualitative_id); + + -- Add foreign key constraint + ALTER TABLE method_lookup_attribute_qualitative + ADD CONSTRAINT method_lookup_attribute_qualitative_fk1 + FOREIGN KEY (technique_attribute_qualitative_id) + REFERENCES technique_attribute_qualitative(technique_attribute_qualitative_id); + + ALTER TABLE method_lookup_attribute_qualitative + ADD CONSTRAINT method_lookup_attribute_qualitative_fk2 + FOREIGN KEY (method_lookup_id) + REFERENCES method_lookup(method_lookup_id); + + -- Add indexes for foreign keys + CREATE INDEX method_lookup_attribute_qualitative_idx1 ON method_lookup_attribute_qualitative(technique_attribute_qualitative_id); + + CREATE INDEX method_lookup_attribute_qualitative_idx2 ON method_lookup_attribute_qualitative(method_lookup_id); + + ---------------------------------------------------------------------------------------- + + CREATE TABLE method_lookup_attribute_quantitative ( + method_lookup_attribute_quantitative_id uuid DEFAULT public.gen_random_uuid(), + technique_attribute_quantitative_id uuid NOT NULL, + method_lookup_id integer NOT NULL, + min numeric, + max numeric, + unit environment_unit, + description varchar(400), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_lookup_attribute_quantitative_pk PRIMARY KEY (method_lookup_attribute_quantitative_id) + ); + + COMMENT ON TABLE method_lookup_attribute_quantitative IS 'quantitative attributes available for a method lookup id.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.method_lookup_attribute_quantitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.technique_attribute_quantitative_id IS 'Foreign key to the technique_attribute_quantitative table.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.method_lookup_id IS 'Foreign key to the method_lookup table.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.min IS 'The minimum allowed value (inclusive).'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.max IS 'The maximum allowed value (inclusive).'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.unit IS 'The unit of measure for the value.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.description IS 'The description of the attribute.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_lookup_attribute_quantitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint (don't allow 2 records with the same technique_attribute_quantitative_id and method_lookup_id and a NULL record_end_date) + CREATE UNIQUE INDEX method_lookup_attribute_quantitative_nuk1 ON method_lookup_attribute_quantitative(technique_attribute_quantitative_id, method_lookup_id, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + -- Add unique composite key constraint + ALTER TABLE method_lookup_attribute_quantitative + ADD CONSTRAINT method_lookup_attribute_quantitative_uk1 + UNIQUE (method_lookup_attribute_quantitative_id, technique_attribute_quantitative_id); + + -- Add foreign key constraint + ALTER TABLE method_lookup_attribute_quantitative + ADD CONSTRAINT method_lookup_attribute_quantitative_fk1 + FOREIGN KEY (technique_attribute_quantitative_id) + REFERENCES technique_attribute_quantitative(technique_attribute_quantitative_id); + + + ALTER TABLE method_lookup_attribute_quantitative + ADD CONSTRAINT method_lookup_attribute_quantitative_fk2 + FOREIGN KEY (method_lookup_id) + REFERENCES method_lookup(method_lookup_id); + + -- Add indexes for foreign keys + CREATE INDEX method_lookup_attribute_quantitative_idx1 ON method_lookup_attribute_quantitative(technique_attribute_quantitative_id); + + CREATE INDEX method_lookup_attribute_quantitative_idx2 ON method_lookup_attribute_quantitative(method_lookup_id); + + ---------------------------------------------------------------------------------------- + -- Create technique attractant lookup table + ---------------------------------------------------------------------------------------- + + CREATE TABLE attractant_lookup ( + attractant_lookup_id integer NOT NULL GENERATED ALWAYS AS IDENTITY, + name varchar(100) NOT NULL, + description varchar(400), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT attractant_lookup_pk PRIMARY KEY (attractant_lookup_id) + ); + + COMMENT ON TABLE attractant_lookup IS 'Attractant lookup options.'; + COMMENT ON COLUMN attractant_lookup.attractant_lookup_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN attractant_lookup.name IS 'The name of the attractant.'; + COMMENT ON COLUMN attractant_lookup.description IS 'The description of the attractant.'; + COMMENT ON COLUMN attractant_lookup.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN attractant_lookup.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN attractant_lookup.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN attractant_lookup.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN attractant_lookup.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN attractant_lookup.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint + CREATE UNIQUE INDEX attractant_lookup_nuk1 ON attractant_lookup(name, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + ---------------------------------------------------------------------------------------- + -- Create qualitative options table + ---------------------------------------------------------------------------------------- + + CREATE TABLE method_lookup_attribute_qualitative_option ( + method_lookup_attribute_qualitative_option_id uuid DEFAULT public.gen_random_uuid(), + method_lookup_attribute_qualitative_id uuid NOT NULL, + name varchar(100) NOT NULL, + description varchar(400), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_lookup_attribute_qualitative_option_pk PRIMARY KEY (method_lookup_attribute_qualitative_option_id) + ); + + COMMENT ON TABLE method_lookup_attribute_qualitative_option IS 'qualitative technique attribute options.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.method_lookup_attribute_qualitative_option_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.method_lookup_attribute_qualitative_id IS 'Foreign key to the method_lookup_attribute_qualitative table.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.name IS 'The name of the option.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.description IS 'The description of the option.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint (don't allow 2 records with the same method_lookup_attribute_qualitative_id and name and a NULL record_end_date) + CREATE UNIQUE INDEX method_lookup_attribute_qualitative_option_nuk1 ON method_lookup_attribute_qualitative_option(method_lookup_attribute_qualitative_id, name, (record_end_date IS NULL)) WHERE record_end_date IS NULL; + + -- Add unique composite key constraint + ALTER TABLE method_lookup_attribute_qualitative_option + ADD CONSTRAINT method_lookup_attribute_qualitative_option_uk1 + UNIQUE (method_lookup_attribute_qualitative_option_id, method_lookup_attribute_qualitative_id); + + -- Add foreign key constraint + ALTER TABLE method_lookup_attribute_qualitative_option + ADD CONSTRAINT method_lookup_attribute_qualitative_option_fk1 + FOREIGN KEY (method_lookup_attribute_qualitative_id) + REFERENCES method_lookup_attribute_qualitative(method_lookup_attribute_qualitative_id); + + -- Add indexes for foreign keys + CREATE INDEX method_lookup_attribute_qualitative_option_idx1 ON method_lookup_attribute_qualitative_option(method_lookup_attribute_qualitative_id); + + ---------------------------------------------------------------------------------------- + -- Create method technique tables + ---------------------------------------------------------------------------------------- + + CREATE TABLE method_technique ( + method_technique_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + survey_id integer NOT NULL, + name varchar(64) NOT NULL, + description varchar(1048), + distance_threshold decimal, + method_lookup_id integer NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_technique_pk PRIMARY KEY (method_technique_id) + ); + + COMMENT ON TABLE method_technique IS 'This table is intended to track method techniques created within a survey.'; + COMMENT ON COLUMN method_technique.method_technique_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN method_technique.survey_id IS 'Foreign key to the survey table.'; + COMMENT ON COLUMN method_technique.name IS 'Name of the method technique.'; + COMMENT ON COLUMN method_technique.description IS 'Description of the method technique.'; + COMMENT ON COLUMN method_technique.distance_threshold IS 'Maximum distance under which data were collected. Data beyond the distance threshold are not included.'; + COMMENT ON COLUMN method_technique.method_lookup_id IS 'Foreign key to the method_lookup table.'; + COMMENT ON COLUMN method_technique.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_technique.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_technique.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique constraint + CREATE UNIQUE INDEX method_technique_uk1 ON method_technique(method_technique_id); + + -- Add foreign key constraint + ALTER TABLE method_technique + ADD CONSTRAINT method_technique_fk1 + FOREIGN KEY (survey_id) + REFERENCES survey(survey_id); + + ALTER TABLE method_technique + ADD CONSTRAINT method_technique_fk2 + FOREIGN KEY (method_lookup_id) + REFERENCES method_lookup(method_lookup_id); + + -- Add indexes for foreign keys + CREATE INDEX method_technique_idx1 ON method_technique(survey_id); + + CREATE INDEX method_technique_idx2 ON method_technique(method_lookup_id); + + ---------------------------------------------------------------------------------------- + + CREATE TABLE method_technique_attribute_quantitative ( + method_technique_attribute_quantitative_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + method_technique_id integer NOT NULL, + method_lookup_attribute_quantitative_id uuid NOT NULL, + value numeric NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_technique_attribute_quantitative_pk PRIMARY KEY (method_technique_attribute_quantitative_id) + ); + + COMMENT ON TABLE method_technique_attribute_quantitative IS 'This table is intended to track quantitative technique attributes applied to a particular technique.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.method_technique_attribute_quantitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.method_technique_id IS 'Foreign key to the method_technique table.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.method_lookup_attribute_quantitative_id IS 'Foreign key to the technique_attribute_quantitative table.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.value IS 'Quantitative data value.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique_attribute_quantitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique constraint + CREATE UNIQUE INDEX method_technique_attribute_quantitative_uk1 ON method_technique_attribute_quantitative(method_technique_id, method_lookup_attribute_quantitative_id); + + -- Add foreign key constraint + ALTER TABLE method_technique_attribute_quantitative + ADD CONSTRAINT method_technique_attribute_quantitative_fk1 + FOREIGN KEY (method_technique_id) + REFERENCES method_technique(method_technique_id); + + ALTER TABLE method_technique_attribute_quantitative + ADD CONSTRAINT method_technique_attribute_quantitative_fk2 + FOREIGN KEY (method_lookup_attribute_quantitative_id) + REFERENCES method_lookup_attribute_quantitative(method_lookup_attribute_quantitative_id); + + -- Add indexes for foreign keys + CREATE INDEX method_technique_attribute_quantitative_idx1 ON method_technique_attribute_quantitative(method_technique_id); + + CREATE INDEX method_technique_attribute_quantitative_idx2 ON method_technique_attribute_quantitative(method_lookup_attribute_quantitative_id); + + ---------------------------------------------------------------------------------------- + + CREATE TABLE method_technique_attractant ( + method_technique_attractant_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + method_technique_id integer NOT NULL, + attractant_lookup_id integer NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_technique_attractant_pk PRIMARY KEY (method_technique_attractant_id) + ); + + COMMENT ON TABLE method_technique_attractant IS 'This table is intended to track attractants applied to a particular technique.'; + COMMENT ON COLUMN method_technique_attractant.method_technique_attractant_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN method_technique_attractant.method_technique_id IS 'Foreign key to the method_technique table.'; + COMMENT ON COLUMN method_technique_attractant.attractant_lookup_id IS 'Foreign key to the attractant_lookup table.'; + COMMENT ON COLUMN method_technique_attractant.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_technique_attractant.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique_attractant.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_technique_attractant.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique_attractant.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique constraint + CREATE UNIQUE INDEX method_technique_attractant_uk1 ON method_technique_attractant(method_technique_id, attractant_lookup_id); + + -- Add foreign key constraint + ALTER TABLE method_technique_attractant + ADD CONSTRAINT method_technique_attractant_fk1 + FOREIGN KEY (method_technique_id) + REFERENCES method_technique(method_technique_id); + + ALTER TABLE method_technique_attractant + ADD CONSTRAINT method_technique_attractant_fk2 + FOREIGN KEY (attractant_lookup_id) + REFERENCES attractant_lookup(attractant_lookup_id); + + -- Add indexes for foreign keys + CREATE INDEX method_technique_attractant_idx1 ON method_technique_attractant(method_technique_id); + + CREATE INDEX method_technique_attractant_idx2 ON method_technique_attractant(attractant_lookup_id); + + ---------------------------------------------------------------------------------------- + + CREATE TABLE method_technique_attribute_qualitative ( + method_technique_attribute_qualitative_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + method_technique_id integer NOT NULL, + method_lookup_attribute_qualitative_id uuid NOT NULL, + method_lookup_attribute_qualitative_option_id uuid NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_technique_attribute_qualitative_pk PRIMARY KEY (method_technique_attribute_qualitative_id) + ); + + COMMENT ON TABLE method_technique_attribute_qualitative IS 'This table is intended to track qualitative technique attributes applied to a particular method_technique.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.method_technique_attribute_qualitative_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.method_technique_id IS 'Foreign key to the method_technique table.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.method_lookup_attribute_qualitative_id IS 'Foreign key to the method_lookup_attribute_qualitative table.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.method_lookup_attribute_qualitative_option_id IS 'Foreign key to the method_lookup_attribute_qualitative_option table.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique_attribute_qualitative.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique constraint + CREATE UNIQUE INDEX method_technique_attribute_qualitative_uk1 ON method_technique_attribute_qualitative(method_technique_id, method_lookup_attribute_qualitative_id, method_lookup_attribute_qualitative_option_id); + + -- Add foreign key constraint + ALTER TABLE method_technique_attribute_qualitative + ADD CONSTRAINT method_technique_attribute_qualitative_fk1 + FOREIGN KEY (method_technique_id) + REFERENCES method_technique(method_technique_id); + + ALTER TABLE method_technique_attribute_qualitative + ADD CONSTRAINT method_technique_attribute_qualitative_fk2 + FOREIGN KEY (method_lookup_attribute_qualitative_id) + REFERENCES method_lookup_attribute_qualitative(method_lookup_attribute_qualitative_id); + + ALTER TABLE method_technique_attribute_qualitative + ADD CONSTRAINT method_technique_attribute_qualitative_fk3 + FOREIGN KEY (method_lookup_attribute_qualitative_option_id) + REFERENCES method_lookup_attribute_qualitative_option(method_lookup_attribute_qualitative_option_id); + + -- Foreign key on both method_lookup_attribute_qualitative_id and method_lookup_attribute_qualitative_option_id of + -- method_lookup_attribute_qualitative_option to ensure that the combination of those ids in this table has a valid match. + ALTER TABLE method_technique_attribute_qualitative + ADD CONSTRAINT method_technique_attribute_qualitative_fk4 + FOREIGN KEY (method_lookup_attribute_qualitative_id, method_lookup_attribute_qualitative_option_id) + REFERENCES method_lookup_attribute_qualitative_option(method_lookup_attribute_qualitative_id, method_lookup_attribute_qualitative_option_id); + + -- Foreign key on both method_lookup_attribute_qualitative_id and method_lookup_id + -- to ensure that the combination of those ids in this table is allowed. + ALTER TABLE method_technique_attribute_qualitative + ADD CONSTRAINT method_technique_attribute_qualitative_fk5 + FOREIGN KEY (method_lookup_attribute_qualitative_id, method_lookup_attribute_qualitative_option_id) + REFERENCES method_lookup_attribute_qualitative_option(method_lookup_attribute_qualitative_id, method_lookup_attribute_qualitative_option_id); + + + -- Add indexes for foreign keys + CREATE INDEX method_technique_attribute_qualitative_idx1 ON method_technique_attribute_qualitative(method_technique_id); + + CREATE INDEX method_technique_attribute_qualitative_idx2 ON method_technique_attribute_qualitative(method_lookup_attribute_qualitative_id); + + CREATE INDEX method_technique_attribute_qualitative_idx3 ON method_technique_attribute_qualitative(method_lookup_attribute_qualitative_option_id); + + ---------------------------------------------------------------------------------------- + -- Alter method table to include technique ID + ---------------------------------------------------------------------------------------- + ALTER TABLE survey_sample_method ADD COLUMN method_technique_id INTEGER; + + COMMENT ON COLUMN survey_sample_method.method_technique_id IS 'The technique of the method.'; + + ALTER TABLE survey_sample_method ADD CONSTRAINT "survey_sample_method_fk3" + FOREIGN KEY (method_technique_id) + REFERENCES method_technique(method_technique_id); + + CREATE INDEX survey_sample_method_idx2 ON survey_sample_method(method_technique_id); + + ---------------------------------------------------------------------------------------- + -- Alter method table to drop method lookup ID + ---------------------------------------------------------------------------------------- + + -- Insert method techniques based on survey_sample_method + INSERT INTO method_technique (survey_id, name, description, method_lookup_id) + SELECT + sss.survey_id, + ml.name, + NULL, + ssm.method_lookup_id + FROM + survey_sample_method ssm + JOIN + method_lookup ml ON ssm.method_lookup_id = ml.method_lookup_id + JOIN + survey_sample_site sss ON ssm.survey_sample_site_id = sss.survey_sample_site_id; + + -- Update survey_sample_method with the generated method_technique_ids + UPDATE survey_sample_method + SET + method_technique_id = mt.method_technique_id + FROM + survey_sample_method ssm + JOIN + method_technique mt ON ssm.method_lookup_id = mt.method_lookup_id + JOIN + survey_sample_site sss ON sss.survey_sample_site_id = ssm.survey_sample_site_id + WHERE + sss.survey_id = mt.survey_id + AND + mt.method_lookup_id = ssm.method_lookup_id; + + -- Drop method_lookup_id from survey_sample_method + ALTER TABLE survey_sample_method DROP COLUMN method_lookup_id; + + -- Add NOT NULL constraint to method_technique_id column + ALTER TABLE survey_sample_method ALTER COLUMN method_technique_id SET NOT NULL; + + ---------------------------------------------------------------------------------------- + -- Create audit/journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_technique_attribute_quantitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.technique_attribute_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_technique_attribute_quantitative AFTER INSERT OR UPDATE OR DELETE ON biohub.technique_attribute_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_technique_attribute_qualitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.technique_attribute_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_technique_attribute_qualitative AFTER INSERT OR UPDATE OR DELETE ON biohub.technique_attribute_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_method_lookup_attribute_qualitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.method_lookup_attribute_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_lookup_attribute_qualitative AFTER INSERT OR UPDATE OR DELETE ON biohub.method_lookup_attribute_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_method_lookup_attribute_quantitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.method_lookup_attribute_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_lookup_attribute_quantitative AFTER INSERT OR UPDATE OR DELETE ON biohub.method_lookup_attribute_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_method_lookup_attribute_qualitative_option BEFORE INSERT OR UPDATE OR DELETE ON biohub.method_lookup_attribute_qualitative_option FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_lookup_attribute_qualitative_option AFTER INSERT OR UPDATE OR DELETE ON biohub.method_lookup_attribute_qualitative_option FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_method_technique_attribute_quantitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.method_technique_attribute_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_technique_attribute_quantitative AFTER INSERT OR UPDATE OR DELETE ON biohub.method_technique_attribute_quantitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_method_technique_attribute_qualitative BEFORE INSERT OR UPDATE OR DELETE ON biohub.method_technique_attribute_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_technique_attribute_qualitative AFTER INSERT OR UPDATE OR DELETE ON biohub.method_technique_attribute_qualitative FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_method_technique BEFORE INSERT OR UPDATE OR DELETE ON biohub.method_technique FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_technique AFTER INSERT OR UPDATE OR DELETE ON biohub.method_technique FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_attractant_lookup BEFORE INSERT OR UPDATE OR DELETE ON biohub.attractant_lookup FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_attractant_lookup AFTER INSERT OR UPDATE OR DELETE ON biohub.attractant_lookup FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_method_technique_attractant BEFORE INSERT OR UPDATE OR DELETE ON biohub.method_technique_attractant FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_technique_attractant AFTER INSERT OR UPDATE OR DELETE ON biohub.method_technique_attractant FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Triggers for validating technique attributes + ---------------------------------------------------------------------------------------- + + CREATE OR REPLACE FUNCTION biohub.tr_technique_qual_attribute_check() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY invoker + AS + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM method_technique_attribute_qualitative mtaq + INNER JOIN method_lookup_attribute_qualitative mlaq + ON mlaq.method_lookup_attribute_qualitative_id = mtaq.method_lookup_attribute_qualitative_id + INNER JOIN method_technique mt + ON mlaq.method_technique_id = mt.method_technique_id + WHERE mtaq.method_lookup_attribute_qualitative_id = NEW.method_lookup_attribute_qualitative_id + ) THEN + RAISE EXCEPTION 'The method_lookup_id does not support the incoming attribute.'; + END IF; + RETURN NEW; + END; + $$; + + CREATE OR REPLACE FUNCTION biohub.tr_technique_quant_attribute_check() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY invoker + AS + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM method_technique_attribute_quantitative mtaq + INNER JOIN method_lookup_attribute_quantitative mlaq + ON mlaq.method_lookup_attribute_quantitative_id = mtaq.method_lookup_attribute_quantitative_id + INNER JOIN method_technique mt + ON mlaq.method_technique_id = mt.method_technique_id + WHERE mtaq.method_lookup_attribute_quantitative_id = NEW.method_lookup_attribute_quantitative_id + ) THEN + RAISE EXCEPTION 'The method_lookup_id does not support the incoming attribute.'; + END IF; + RETURN NEW; + END; + $$; + + + DROP TRIGGER IF EXISTS technique_qual_attribute_val ON biohub.method_technique_qualitative_attribute; + CREATE TRIGGER technique_qual_attribute_val BEFORE INSERT OR UPDATE ON biohub.method_technique_attribute_qualitative FOR EACH ROW EXECUTE FUNCTION biohub.tr_technique_qual_attribute_check(); + + DROP TRIGGER IF EXISTS technique_quant_attribute_val ON biohub.method_technique_quantitative_attribute; + CREATE TRIGGER technique_quant_attribute_val BEFORE INSERT OR UPDATE ON biohub.method_technique_attribute_quantitative FOR EACH ROW EXECUTE FUNCTION biohub.tr_technique_quant_attribute_check(); + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW technique_attribute_quantitative AS SELECT * FROM biohub.technique_attribute_quantitative; + + CREATE OR REPLACE VIEW technique_attribute_qualitative AS SELECT * FROM biohub.technique_attribute_qualitative; + + CREATE OR REPLACE VIEW method_lookup_attribute_quantitative AS SELECT * FROM biohub.method_lookup_attribute_quantitative; + + CREATE OR REPLACE VIEW method_lookup_attribute_qualitative AS SELECT * FROM biohub.method_lookup_attribute_qualitative; + + CREATE OR REPLACE VIEW method_lookup_attribute_qualitative_option AS SELECT * FROM biohub.method_lookup_attribute_qualitative_option; + + CREATE OR REPLACE VIEW technique_attribute_quantitative AS SELECT * FROM biohub.technique_attribute_quantitative; + + CREATE OR REPLACE VIEW technique_attribute_qualitative AS SELECT * FROM biohub.technique_attribute_qualitative; + + CREATE OR REPLACE VIEW method_technique AS SELECT * FROM biohub.method_technique; + + CREATE OR REPLACE VIEW attractant_lookup AS SELECT * FROM biohub.attractant_lookup; + + CREATE OR REPLACE VIEW method_technique_attractant AS SELECT * FROM biohub.method_technique_attractant; + + CREATE OR REPLACE VIEW survey_sample_method AS SELECT * FROM biohub.survey_sample_method; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20240528111700_insert_technique_tables.ts b/database/src/migrations/20240528111700_insert_technique_tables.ts new file mode 100644 index 0000000000..da597faab8 --- /dev/null +++ b/database/src/migrations/20240528111700_insert_technique_tables.ts @@ -0,0 +1,1536 @@ +import { Knex } from 'knex'; + +/** + * Populate lookup values for the environment_quantitative, environment_qualitative, and + * environment_qualitative_option tables. + * + * This migration file inserts values into method lookup table, technique attribute qual and quant tables, + * method lookup quant and qual and qualitative options and attractants + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH=biohub, public; + + ---------------------------------------------------------------------------------------- + --Adding more values to the method lookup table. + ---------------------------------------------------------------------------------------- + INSERT into method_lookup (name, description) + VALUES + ( + 'Gill net', + 'A fishing net that hangs vertically in the water, trapping fish by their gills when they try to swim through its mesh openings.' + ), + ( + 'Trawling', + 'Trawling is a fishing method where a large net is dragged along the sea floor or through the water column to catch fish and other marine organisms.' + ), + ( + 'Trap net', + 'A trap net is a stationary fishing device consisting of a series of nets and funnels designed to guide and capture fish or other aquatic animals as they swim through it.' + ), + ( + 'Rotary screw trap', + 'A rotary screw trap is a cylindrical, cone-shaped device used in rivers and streams to capture juvenile fish, particularly salmonids, as they are carried downstream by the current.' + ), + ( + 'Handheld net', + 'Using a handheld net as a sampling method involves manually capturing aquatic organisms from water bodies for research or monitoring by sweeping or dipping the net through the water.' + ), + ( + 'Angling', + 'Angling is a method of fishing that involves using a rod, line, and hook to catch fish.' + ), + ( + 'Radio signal tower', + 'Using a radio signal tower as a sampling method involves tracking the movements and behavior of tagged animals by receiving signals from radio transmitters attached to the animals.' + ), + ( + 'Radar', + 'Using radar as a sampling method involves detecting and tracking the movement and behavior of animals, particularly birds and bats, by emitting radio waves and analyzing the reflected signals.' + ), + ( + 'Seine net', + 'A seine net is used in sample collection to capture fish and other aquatic organisms by encircling them with a vertical net wall, allowing for efficient population and biodiversity assessments in marine and freshwater environments.' + ), + ( + 'Fish weir', + 'A fish weir is used in sample collection to guide and trap fish by directing their movement through a series of barriers or enclosures, enabling researchers to monitor fish populations and migrations in rivers and streams.' + ), + ( + 'Fish wheel', + 'A fish wheel is used in sample collection to automatically capture fish as they swim into rotating baskets or compartments, allowing for continuous and passive monitoring of fish populations and migrations in rivers.' + ), + ( + 'Egg mats', + 'Egg mats are used in sample collection to provide a substrate for fish eggs to adhere to, enabling researchers to monitor and assess spawning activities and egg deposition in aquatic environments.' + ), + ( + 'Setline', + 'Setlines are used in sample collection to capture fish by baiting hooks attached to a long fishing line anchored in place, allowing for targeted sampling of specific fish species over a period of time.' + ), + ( + 'Visual encounter', + 'An observer systematically watches and records species seen within a defined area or along a specific transect, often used to estimate population size and distribution.' + ); + + ---------------------------------------------------------------------------------------- + --Deleting and editing previous rows that no longer apply to the method_technique table + ---------------------------------------------------------------------------------------- + UPDATE method_technique + SET method_lookup_id = (SELECT method_lookup_id FROM method_lookup WHERE name = 'Visual encounter') + WHERE method_lookup_id IN (SELECT method_lookup_id FROM method_lookup WHERE name IN ('Aerial transect', 'Ground transect', 'Aquatic transect', 'Underwater transect')); + + DELETE FROM method_lookup + WHERE name IN ('Aerial transect', 'Ground transect', 'Aquatic transect', 'Underwater transect'); + + + ---------------------------------------------------------------------------------------- + -- Edit enum list + ---------------------------------------------------------------------------------------- + + ALTER TYPE environment_unit ADD VALUE 'seconds'; + ALTER TYPE environment_unit ADD VALUE 'meters squared'; + ALTER TYPE environment_unit ADD VALUE 'count'; + ALTER TYPE environment_unit ADD VALUE 'GHz'; + ALTER TYPE environment_unit ADD VALUE 'Hz'; + ALTER TYPE environment_unit ADD VALUE 'amps'; + ALTER TYPE environment_unit ADD VALUE 'volts'; + ALTER TYPE environment_unit ADD VALUE 'megapixels'; + COMMIT; + + ---------------------------------------------------------------------------------------- + -- Populate lookup tables. + ---------------------------------------------------------------------------------------- + + INSERT INTO technique_attribute_quantitative + ( + name, + description + ) + VALUES + ( + 'Height above ground', + 'The height above ground.'), + ( + 'Images per trigger', + 'The number of images captured per trigger.'), + ( + 'Trigger speed', + 'The time it takes for a camera trap to capture an image or start recording after detecting motion.'), + ( + 'Detection distance', + 'The maximum range at which a camera trap can detect motion to trigger a photo or video capture.'), + ( + 'Field of view', + 'The extent of the observable area that a camera trap can capture.'), + ( + 'Length', + 'The measurement from the front to the back fo the device'), + ( + 'Width', + 'The measurement across the trap from one side to the other, determining the horizontal space available for capturing and containing wildlife.'), + ( + 'Height', + 'The measurement from the bottom to the top of the trap, determining the vertical space available for capturing and containing wildlife.'), + ( + 'Net size', + 'The overall dimensions calculated as length times height, determining the total area available for capturing aquatic species.'), + ( + 'Mesh size', + 'The measurement of the distance between two opposite knots when the net is pulled taut, determining the maximum opening for capturing species.'), + ( + 'Set depth', + 'The vertical distance from the water surface to the position where the net is deployed, indicating how deep the net is placed in the water column.'), + ( + 'Trawling depth', + 'The vertical distance from the water surface to the position where the trawl net is towed, indicating the depth at which the net is operating in the water column.'), + ( + 'Shelves', + 'The number of horizontal tiers or pockets in the net, which help entangle and hold captured animals.'), + ( + 'Depth', + 'The vertical measurement from the top to the bottom.'), + ( + 'Diameter of opening', + 'The size of the entry point of the capture mechanism.'), + ( + 'Number of entrances', + 'The count of entry points into the capture mechanism.'), + ( + 'Leader length', + 'The length of the guiding structure that directs fish into the trap net.'), + ( + 'Hook size', + 'Numerical scale where smaller numbers indicate larger hooks based on the gap and shank length.'), + ( + 'Radio frequency', + 'Frequency refers to the specific radio wave band at which the transmitter on the animal and the receiver on the tower communicate to ensure accurate tracking and data transmission.'), + ( + 'Pulse repetition frequency', + 'The number of radar pulses transmitted per second, measured in hertz (Hz), and is a key parameter that affects the range resolution and target detection capabilities.'), + ( + 'Range resolution', + 'Measured in meters and indicates the minimum distance between two distinct targets that the radar can differentiate. It is determined by the pulse width, with shorter pulses providing better (smaller) range resolution.'), + ( + 'Current', + 'Current is the flow of electric charge through a conductor, typically measured in amperes.'), + ( + 'Voltage', + 'Voltage is the electric potential difference between two points in a circuit which drives the flow of electric current.'), + ( + 'Electrical frequency', + 'The frequency of the electrical pulses, measured in Hz.'), + ( + 'Duty cycle', + 'The duty cycle is measured as a percentage (%), representing the proportion of time the electrical current is active (on) versus the total time of the cycle.'), + ( + 'Number of hooks', + 'The number of hooks included in the device'), + ( + 'Surface area', + 'Width x Height of the device'), + ( + 'Camera resolution', + 'The level of detail captured in a photo.' + ); + + INSERT INTO technique_attribute_qualitative + ( + name, + description + ) + VALUES + ( + 'Model', + 'The model of the device.' + ), + ( + 'Infrared type', + 'The kind of infrared illumination used for night-time images such as low-glow no-glow or white flash which affects visibility to wildlife and image quality in darkness.' + ), + ( + 'Material', + 'The type of material (e.g., metal, plastic, wood) used to construct the item, affecting its durability and suitability for different environments.' + ), + ( + 'Trap entrance mechanism', + 'The design and type of door or opening (e.g., gravity-operated, spring-loaded) that allows animals to enter the trap but prevents them from escaping.' + ), + ( + 'Trigger mechanism', + 'The mechanism (e.g., pressure plate, trip wire) that activates the closing of the entrance, ensuring the animal is securely captured once inside.' + ), + ( + 'Trawl net type', + 'The specific design or style of the trawl net (e.g., bottom trawl, midwater trawl) tailored to target particular species and habitats.' + ), + ( + 'Otter board type', + 'The type of otter boards (e.g., rectangular, oval) used to keep the trawl net open horizontally while it is being towed, affecting the efficiency and spread of the net.' + ), + ( + 'Orientation', + 'The direction in which a mist net is positioned relative to cardinal points (e.g., north-south, east-west), influencing capture success based on factors like prevailing winds and animal movement patterns.' + ), + ( + 'Type of trap cover', + 'The design and material of any cover used to protect the trap (e.g., to prevent rainwater entry or minimize disturbance), impacting the trap functionality and animal welfare.' + ), + ( + 'Angling tool', + 'Device or implement used in the sport of fishing, such as a rod, pole, or lure, designed to assist in the capture of fish.' + ), + ( + 'Bait', + 'Substance used to attract and catch wildlife.' + ), + ( + 'Transmitter make and model', + 'The specific manufacturer and the particular design or version of the device, which determine its features, capabilities, and compatibility with other equipment in wildlife tracking systems.' + ), + ( + 'Radar make and model', + 'The specific brand and version of the radar equipment used, which determine its technical specifications, capabilities, and suitability for tracking and monitoring wildlife.' + ), + ( + 'Fishing technique', + 'The specific method or approach used in fishing, such as float fishing, lure fishing, or bottom fishing, each involving distinct equipment and strategies to catch fish.' + ), + ( + 'Anchored or floating', + 'Whether or not the device is anchored or floating.' + ), + ( + 'Substrate type', + 'Substrate type refers to the material on which the egg mats are placed, affecting the adherence and development of fish or amphibian eggs.' + ), + ( + 'Net type', + 'The type of net used.' + ); + + ---------------------------------------------------------------------------------------- + + INSERT INTO method_lookup_attribute_quantitative + ( + technique_attribute_quantitative_id, + method_lookup_id, + min, + max, + unit + ) + VALUES + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Height above ground'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Camera trap'), + 0, + 10000, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Images per trigger'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Camera trap'), + 0, + 50, + 'count' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Field of view'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Camera trap'), + 0, + 360, + 'degrees' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Length'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Box or live trap'), + 0, + 10000, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Width'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Box or live trap'), + 0, + 10000, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Height'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Box or live trap'), + 0, + 10000, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Net size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Gill net'), + 0, + 10000, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Mesh size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Gill net'), + 0, + 100, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Set depth'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Gill net'), + 0, + 10000, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Net size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trawling'), + 0, + 500, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Mesh size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trawling'), + 0, + 100, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Trawling depth'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trawling'), + 0, + 10000, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Length'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Mist net'), + 0, + 250, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Height'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Mist net'), + 0, + 100, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Mesh size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Mist net'), + 0, + 100, + 'millimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Width'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Pitfall trap'), + 0, + 100, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Depth'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Pitfall trap'), + 0, + 100, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Length'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net'), + 0, + 100, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Height'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net'), + 0, + 10000, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Width'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net'), + 0, + 10000, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Mesh size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net'), + 0, + 100, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Diameter of opening'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net'), + 0, + 200, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Number of entrances'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net'), + 0, + 10, + 'count' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Leader length'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net'), + 0, + 10, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Depth'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net'), + 0, + 10000, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Diameter of opening'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Rotary screw trap'), + 0, + 10, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Depth'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Handheld net'), + 0, + 30, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Diameter of opening'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Handheld net'), + 0, + 1000, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Hook size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Angling'), + 0, + 64, + 'count' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Radio frequency'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Radio signal tower'), + 0, + 30, + 'GHz' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Pulse repetition frequency'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Radar'), + 0, + 10000, + 'Hz' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Range resolution'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Radar'), + 0, + 1000, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Current'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Electrofishing'), + 5, + 0, + 'amps' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Voltage'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Electrofishing'), + 0, + 1000, + 'volts' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Electrical frequency'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Electrofishing'), + 0, + 1000, + 'Hz' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Duty cycle'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Electrofishing'), + 0, + 100, + 'seconds' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Length'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Seine net'), + 0, + 10000, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Height'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Seine net'), + 0, + 1000, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Mesh size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Seine net'), + 0, + 100, + 'centimeter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Width'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Fish weir'), + 0, + 10, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Surface area'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Egg mats'), + 0, + 400, + 'meters squared' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Length'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Setline'), + 0, + 100000, + 'meter' + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Hook size'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Setline'), + 0, + 64, + NULL + ), + ( + (SELECT technique_attribute_quantitative_id FROM technique_attribute_quantitative WHERE name = 'Number of hooks'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Setline'), + 0, + 1000, + 'count' + ); + + ---------------------------------------------------------------------------------------- + INSERT INTO method_lookup_attribute_qualitative + ( + technique_attribute_qualitative_id, + method_lookup_id + ) + VALUES + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Model'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Camera trap') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Infrared type'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Camera trap') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Box or live trap') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Trap entrance mechanism'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Box or live trap') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Trigger mechanism'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Box or live trap') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Gill net') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trawling') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Trawl net type'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trawling') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Otter board type'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trawling') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Pitfall trap') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Type of trap cover'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Pitfall trap') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Mist net') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Orientation'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Mist net') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Trap net') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Rotary screw trap') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Handheld net') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Angling tool'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Angling') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Bait'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Angling') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Fishing technique'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Angling') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Transmitter make and model'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Radio signal tower') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Radar make and model'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Radar') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Seine net') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Fish weir') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Anchored or floating'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Fish wheel') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Fish wheel') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Egg mats') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Substrate type'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Egg mats') + ), + ( + (SELECT technique_attribute_qualitative_id FROM technique_attribute_qualitative WHERE name = 'Material'), + (SELECT method_lookup_id FROM method_lookup WHERE name = 'Setline') + ); + + + + ---------------------------------------------------------------------------------------- + + INSERT INTO method_lookup_attribute_qualitative_option + ( + method_lookup_attribute_qualitative_id, + name, + description + ) + VALUES + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Model' AND ml.name = 'Camera trap' + + ), + 'Reconyx Hyperfire', + 'Hyperfire manufactured by Reconyx.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Model' AND ml.name = 'Camera trap' + + ), + 'Bushnell', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Model' AND ml.name = 'Camera trap' + + ), + 'Spypoint', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Model' AND ml.name = 'Camera trap' + + ), + 'Browning', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Model' AND ml.name = 'Camera trap' + + ), + 'Stealth Cam', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Model' AND ml.name = 'Camera trap' + + ), + 'Other', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Infrared type' AND ml.name = 'Camera trap' + + ), + 'None', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Infrared type' AND ml.name = 'Camera trap' + + ), + 'No-glow', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Infrared type' AND ml.name = 'Camera trap' + + ), + 'Low-glow', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Infrared type' AND ml.name = 'Camera trap' + + ), + 'White flash', + NULL + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Box or live trap' + ), + 'Wood', + 'A natural, organic material derived from trees, known for its strength and ease of manipulation. Often used in construction due to its durability and availability.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Box or live trap' + ), + 'Metal', + 'A hard, solid material typically derived from ores. It is known for its high strength, durability, and resistance to damage and deformation.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Box or live trap' + ), + 'Plastic', + 'A synthetic material made from polymers. It is lightweight, versatile, resistant to corrosion, and can withstand various weather conditions.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trap entrance mechanism' AND ml.name = 'Box or live trap' + ), + 'One-way door', + 'A door mechanism that allows entry but not exit. It typically uses a hinge or flap that moves in only one direction, preventing the animal from leaving once inside.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trap entrance mechanism' AND ml.name = 'Box or live trap' + ), + 'Drop door', + 'A door that is held open and drops down to close when triggered. This mechanism relies on gravity and a trigger system to release the door quickly, trapping the animal inside.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trap entrance mechanism' AND ml.name = 'Box or live trap' + ), + 'Swing door', + 'A door that swings shut when an animal enters. It uses hinges to move back and forth, and locks into place once closed, preventing the animal from exiting.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trap entrance mechanism' AND ml.name = 'Box or live trap' + ), + 'Slide door', + 'A door that slides horizontally or vertically into place to close the trap. It often uses rails or grooves to guide the door movement, activated by a trigger mechanism.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trap entrance mechanism' AND ml.name = 'Box or live trap' + ), + 'Funnel entrance', + 'An entrance that tapers inward, making it easy for the animal to enter but difficult to exit. The design typically narrows towards the interior, creating a one-way path.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trigger mechanism' AND ml.name = 'Box or live trap' + ), + 'Pressure plate', + 'A flat surface that activates the trap when enough weight is applied. The plate is connected to a trigger mechanism that releases the trap door or closure system upon activation.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trigger mechanism' AND ml.name = 'Box or live trap' + ), + 'Trip wire', + 'A thin wire or string that, when disturbed or pulled, activates the trap. The wire is typically connected to a trigger mechanism that sets off the trap closing mechanism.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trigger mechanism' AND ml.name = 'Box or live trap' + ), + 'Bait hook', + 'A hook or holder for bait that activates the trap when the bait is moved or taken by the animal. The movement of the bait triggers the mechanism that closes the trap.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trigger mechanism' AND ml.name = 'Box or live trap' + ), + 'Infrared sensor', + 'An electronic device that detects the presence of an animal through infrared radiation. When an animal breaks the infrared beam, the sensor activates the trap mechanism.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trigger mechanism' AND ml.name = 'Box or live trap' + ), + 'String pull', + 'A trigger mechanism that relies on the animal pulling a string or cord. The tension or movement of the string activates the trap, causing it to close.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Gill net' + ), + 'Nylon', + 'A synthetic polymer known for its strength, durability, and resistance to wear and tear.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Gill net' + ), + 'Polyethylene', + 'A lightweight, flexible material resistant to UV radiation and chemical damage.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Gill net' + ), + 'Polypropylene', + 'A robust and inexpensive material, often used for its buoyancy and resistance to moisture.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Gill net' + ), + 'Monofilament', + 'A single, continuous strand of synthetic fiber, offering transparency and strength.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Trawling' + ), + 'Nylon', + 'Durable and strong, often used in commercial fishing nets for its resilience.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Trawling' + ), + 'Polyethylene', + 'Light and flexible, commonly used for its resistance to chemicals and UV light.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Trawling' + ), + 'Polypropylene', + 'Known for its toughness and buoyancy, frequently used in fishing gear.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Trawling' + ), + 'Dyneema', + 'A high-performance polyethylene fiber known for its exceptional strength-to-weight ratio.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trawl net type' AND ml.name = 'Trawling' + ), + 'Beam trawl', + 'A trawl net held open by a rigid beam used in shallow waters for catching bottom dwelling species' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trawl net type' AND ml.name = 'Trawling' + ), + 'Otter Trawl', + 'A trawl net held open by otter boards (doors) that spread the net horizontally.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trawl net type' AND ml.name = 'Trawling' + ), + 'Pair Trawl', + 'A trawl net towed by two boats, used to cover a wider area.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Trawl net type' AND ml.name = 'Trawling' + ), + 'Midwater Trawl', + 'A trawl net designed to operate in the midwater column, targeting pelagic species.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Otter board type' AND ml.name = 'Trawling' + ), + 'Rectangular Boards', + 'Simple, flat boards with a rectangular shape, used to spread the net horizontally.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id FROM method_lookup_attribute_qualitative mlaq INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Otter board type' AND ml.name = 'Trawling' + ), + 'V-Shaped Boards', + 'Boards with a V-shaped design to provide more efficient spreading and stability.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Otter board type' AND ml.name = 'Trawling' + ), + 'Oval Boards', + 'Oval-shaped boards that offer a balance between spreading force and stability.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Otter board type' AND ml.name = 'Trawling' + ), + 'High Aspect Ratio Boards', + 'Designed for deep-sea trawling, these boards have a higher aspect ratio for better performance in strong currents.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Mist net' + ), + 'Nylon', + 'A synthetic polymer known for its strength, durability, and resistance to wear and tear.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Mist net' + ), + 'Polyester', + 'A strong, lightweight, and UV-resistant material, commonly used in mist nets for bird capture.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Orientation' AND ml.name = 'Mist net' + ), + 'Vertical', + 'Positioned upright to intercept flying birds and bats.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Orientation' AND ml.name = 'Mist net' + ), + 'Horizontal', + 'Positioned horizontally to intercept birds and bats flying at low heights.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Pitfall trap' + ), + 'Plastic', + 'A lightweight, durable material commonly used for constructing pitfall traps.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Pitfall trap' + ), + 'Metal', + 'A strong, durable material used for making long-lasting pitfall traps.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Type of trap cover' AND ml.name = 'Pitfall trap' + ), + 'Lid', + 'A removable cover used to protect the trap from debris and prevent non-target animals from entering.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Type of trap cover' AND ml.name = 'Pitfall trap' + ), + 'Mesh', + 'A mesh cover that allows air flow while preventing larger animals from entering the trap.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Trap net' + ), + 'Nylon', + 'A synthetic polymer known for its strength, durability, and resistance to wear and tear.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Rotary screw trap' + ), + 'Metal', + 'A strong, durable material used for making long-lasting rotary screw traps.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Handheld net' + ), + 'Polyester', + 'A strong, lightweight, and UV-resistant material, commonly used in handheld nets.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Angling tool' AND ml.name = 'Angling' + ), + 'Graphite Rod', + 'A lightweight, strong, and sensitive rod material used for a variety of fishing techniques.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Angling tool' AND ml.name = 'Angling' + ), + 'Telescopic pole', + 'Designed to collapse down to a shorter length for easy transportation and storage, then extend to a full-length rod for fishing, offering convenience and portability for anglers.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Bait' AND ml.name = 'Angling' + ), + 'Worms', + 'Bait commonly used for attracting a variety of fish species.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Bait' AND ml.name = 'Angling' + ), + 'Maggots', + 'Bait commonly used for attracting a variety of fish species.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Bait' AND ml.name = 'Angling' + ), + 'Fly', + 'An artificial lure designed to imitate insects or other prey, typically made with feathers, thread, and other materials, used primarily in fly fishing to attract fish.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Fishing technique' AND ml.name = 'Angling' + ), + 'Float fishing', + 'A floating device used to keep bait at a desired depth and indicate when a fish bites.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Fishing technique' AND ml.name = 'Angling' + ), + 'Bottom fishing', + 'A weight used to take the bait to the bottom or to a specific depth in the water.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Transmitter make and model' AND ml.name = 'Radio signal tower' + ), + 'Model X1000', + 'A high-performance transmitter known for its reliability and long-range signal transmission.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Radar make and model' AND ml.name = 'Radar' + ), + 'Radar Model Z200', + 'A state-of-the-art radar system with advanced detection and tracking capabilities.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Seine net' + ), + 'Nylon', + 'A synthetic polymer known for its strength, durability, and resistance to wear and tear.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Seine net' + ), + 'Polyethylene', + 'A lightweight, flexible material resistant to UV radiation and chemical damage.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Fish weir' + ), + 'Wood', + 'A traditional material used for constructing durable and effective fish weirs.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Fish weir' + ), + 'Metal', + 'A strong, corrosion-resistant material used for modern fish weirs, providing durability and longevity.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Anchored or floating' AND ml.name = 'Fish wheel' + ), + 'Anchored', + 'A fish wheel that is secured to the riverbed or bank to prevent movement.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Anchored or floating' AND ml.name = 'Fish wheel' + ), + 'Floating', + 'A fish wheel that is allowed to move with the current, usually secured by ropes or chains.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Fish wheel' + ), + 'Wood', + 'A traditional material used for constructing fish wheels, known for its buoyancy and ease of construction.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Fish wheel' + ), + 'Metal', + 'A durable and strong material used in modern fish wheels, offering longevity and resistance to water damage.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Egg mats' + ), + 'Synthetic Fiber', + 'A man-made material known for its durability and resistance to water, commonly used in egg mats.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Egg mats' + ), + 'Metal', + ' A strong, durable material used in egg mats to provide structural support and longevity' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Egg mats' + ), + 'Plastic', + 'A lightweight, flexible material used in egg mats for its resistance to water and ease of cleaning.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Egg mats' + ), + 'Wood', + 'A traditional material used in egg mats for its natural properties and ability to blend into aquatic environments.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Substrate type' AND ml.name = 'Egg mats' + ), + 'Gravel', + 'Small stones and pebbles used as a substrate for egg mats to mimic natural riverbed conditions.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Substrate type' AND ml.name = 'Egg mats' + ), + 'Sand', + 'Fine particles used as a substrate for egg mats to provide a soft and supportive environment for eggs.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Setline' + ), + 'Nylon', + 'A synthetic polymer used in setlines for its strength and resistance to abrasion.' + ), + ( + ( + SELECT method_lookup_attribute_qualitative_id + FROM method_lookup_attribute_qualitative mlaq + INNER JOIN technique_attribute_qualitative taq ON taq.technique_attribute_qualitative_id = mlaq.technique_attribute_qualitative_id + INNER JOIN method_lookup ml ON ml.method_lookup_id = mlaq.method_lookup_id + WHERE taq.name = 'Material' AND ml.name = 'Setline' + ), + 'Polyethylene', + 'A lightweight, durable material used in setlines for its flexibility and resistance to UV light.' + ); + ---------------------------------------------------------------------------------------- + + INSERT INTO attractant_lookup + ( + name, + description + ) + VALUES + ( + 'Call playback', + 'An audio recording of a species used to attract species.' + ), + ( + 'Food bait', + 'Using specific foods to attract animals. This can include fruits, vegetables, seeds, fish, meat, or other food items depending on the target species.' + ), + ( + 'Scent bait', + 'Using scents or pheromones to attract animals. Scents can be from natural sources like animal urine or commercial scent lures.' + ), + ( + 'Salt or mineral bait', + 'Providing mineral licks to attract herbivores and other animals needing minerals.' + ), + ( + 'Decoys', + 'Using life-like models of animals to attract specific species.' + ), + ( + 'Reflective materials', + 'Using shiny or reflective objects to catch the attention of curious animals.' + ), + ( + 'Light', + 'Using artificial lights to attract nocturnal insects or other animals.' + ); + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/procedures/tr_technique_attribute_check.ts b/database/src/procedures/tr_technique_attribute_check.ts new file mode 100644 index 0000000000..7f311ec75d --- /dev/null +++ b/database/src/procedures/tr_technique_attribute_check.ts @@ -0,0 +1,72 @@ +import { Knex } from 'knex'; + +/** + * Create triggers for validating technique attributes. + * + * These ensure that the incoming attribute is valid for the method_lookup_id. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function seed(knex: Knex): Promise { + await knex.raw(`--sql + -- Validate qualitative attributes + CREATE OR REPLACE FUNCTION biohub.tr_technique_qual_attribute_check() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY invoker + AS + $$ + BEGIN + IF NOT EXISTS ( + SELECT + 1 + FROM + method_technique mt + INNER JOIN + method_lookup_attribute_qualitative mlaq ON mt.method_lookup_id = mlaq.method_lookup_id + WHERE + mt.method_technique_id = NEW.method_technique_id + AND + mlaq.method_lookup_attribute_qualitative_id = NEW.method_lookup_attribute_qualitative_id + ) THEN + RAISE EXCEPTION 'The method_lookup_id does not support the incoming attribute.'; + END IF; + RETURN NEW; + END; + $$; + + -- Validate quantitative attributes + CREATE OR REPLACE FUNCTION biohub.tr_technique_quant_attribute_check() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY invoker + AS + $$ + BEGIN + IF NOT EXISTS ( + SELECT + 1 + FROM + method_technique mt + INNER JOIN + method_lookup_attribute_quantitative mlaq ON mt.method_lookup_id = mlaq.method_lookup_id + WHERE + mt.method_technique_id = NEW.method_technique_id + AND + mlaq.method_lookup_attribute_quantitative_id = NEW.method_lookup_attribute_quantitative_id + ) THEN + RAISE EXCEPTION 'The method_lookup_id does not support the incoming attribute.'; + END IF; + RETURN NEW; + END; + $$; + + DROP TRIGGER IF EXISTS technique_qual_attribute_val ON biohub.method_technique_attribute_qualitative; + CREATE TRIGGER technique_qual_attribute_val BEFORE INSERT OR UPDATE ON biohub.method_technique_attribute_qualitative FOR EACH ROW EXECUTE FUNCTION biohub.tr_technique_qual_attribute_check(); + + DROP TRIGGER IF EXISTS technique_quant_attribute_val ON biohub.method_technique_attribute_quantitative; + CREATE TRIGGER technique_quant_attribute_val BEFORE INSERT OR UPDATE ON biohub.method_technique_attribute_quantitative FOR EACH ROW EXECUTE FUNCTION biohub.tr_technique_quant_attribute_check(); + `); +} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index 0466b7e3c6..b98018b086 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -86,6 +86,7 @@ export async function seed(knex: Knex): Promise { ${insertSurveySiteStrategy(surveyId)} ${insertSurveyIntendedOutcome(surveyId)} ${insertSurveySamplingSiteData(surveyId)} + ${insertMethodTechnique(surveyId)} ${insertSurveySamplingMethodData(surveyId)} ${insertSurveySamplePeriodData(surveyId)} `); @@ -559,6 +560,30 @@ const insertSurveySamplingSiteData = (surveyId: number) => ) );`; +/** + * SQL to insert method_technique. Requires method lookup. + * + */ +const insertMethodTechnique = (surveyId: number) => + ` + INSERT INTO method_technique + ( + survey_id, + method_lookup_id, + name, + description, + distance_threshold + ) + VALUES + ( + ${surveyId}, + (SELECT method_lookup_id FROM method_lookup ORDER BY random() LIMIT 1), + $$${faker.lorem.word(10)}$$, + $$${faker.lorem.sentences(2)}$$, + $$${faker.number.int({ min: 1, max: 50 })}$$ + ); +`; + /** * SQL to insert survey sampling method data. Requires sampling site. * @@ -568,16 +593,16 @@ const insertSurveySamplingMethodData = (surveyId: number) => INSERT INTO survey_sample_method ( survey_sample_site_id, - method_lookup_id, description, - method_response_metric_id + method_response_metric_id, + method_technique_id ) VALUES ( (SELECT survey_sample_site_id FROM survey_sample_site WHERE survey_id = ${surveyId} LIMIT 1), - (SELECT method_lookup_id FROM method_lookup ORDER BY random() LIMIT 1), $$${faker.lorem.sentences(2)}$$, - $$${faker.number.int({ min: 1, max: 4 })}$$ + $$${faker.number.int({ min: 1, max: 4 })}$$, + (SELECT method_technique_id FROM method_technique WHERE survey_id = ${surveyId} LIMIT 1) ); `;