diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index deda7421d7..82dd6713db 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -121,6 +121,16 @@ parameters: description: S3 key optional prefix required: false value: 'sims' + # Request limits + - name: MAX_REQ_BODY_SIZE + description: Maximum request body size in bytes + value: '52428800' + - name: MAX_UPLOAD_NUM_FILES + description: Maximum number of files uploaded in a single request + value: '10' + - name: MAX_UPLOAD_FILE_SIZE + description: Maximum upload file size in bytes + value: '52428800' # Logging - name: LOG_LEVEL value: 'silent' @@ -162,12 +172,12 @@ parameters: - name: GCNOTIFY_ONBOARDING_REQUEST_EMAIL_TEMPLATE description: gcnotify email template id value: 7779a104-b863-40ac-902f-1aa607d2071a + - name: GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE + description: gcnotify email resubmit template id + value: c973da33-1f2b-435a-9429-d8ab4fd273c5 - name: GCNOTIFY_ONBOARDING_REQUEST_SMS_TEMPLATE description: gcnotify sms template id value: af2f1e40-bd72-4612-9c5a-567ee5b26ca5 - - name: GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE - description: gcnotify request resubmit email template - value: c973da33-1f2b-435a-9429-d8ab4fd273c5 - name: GCNOTIFY_EMAIL_URL value: https://api.notification.canada.ca/v2/notifications/email - name: GCNOTIFY_SMS_URL @@ -336,6 +346,13 @@ objects: value: ${KEYCLOAK_API_HOST} - name: KEYCLOAK_API_ENVIRONMENT value: ${KEYCLOAK_API_ENVIRONMENT} + # Request limits + - name: MAX_REQ_BODY_SIZE + value: ${MAX_REQ_BODY_SIZE} + - name: MAX_UPLOAD_NUM_FILES + value: ${MAX_UPLOAD_NUM_FILES} + - name: MAX_UPLOAD_FILE_SIZE + value: ${MAX_UPLOAD_FILE_SIZE} # Object Store (S3) - name: OBJECT_STORE_URL valueFrom: @@ -357,6 +374,8 @@ objects: secretKeyRef: key: object_store_bucket_name name: ${OBJECT_STORE_SECRETS} + - name: S3_KEY_PREFIX + value: ${S3_KEY_PREFIX} # Logging - name: LOG_LEVEL value: ${LOG_LEVEL} @@ -387,6 +406,8 @@ objects: value: ${GCNOTIFY_ADMIN_EMAIL} - name: GCNOTIFY_ONBOARDING_REQUEST_EMAIL_TEMPLATE value: ${GCNOTIFY_ONBOARDING_REQUEST_EMAIL_TEMPLATE} + - name: GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE + value: ${GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE} - name: GCNOTIFY_ONBOARDING_REQUEST_SMS_TEMPLATE value: ${GCNOTIFY_ONBOARDING_REQUEST_SMS_TEMPLATE} - name: GCNOTIFY_EMAIL_URL diff --git a/api/src/app.ts b/api/src/app.ts index a105802b8f..42a1de6a02 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -13,20 +13,24 @@ import { } from './middleware/critterbase-proxy'; import { rootAPIDoc } from './openapi/root-api-doc'; import { authenticateRequest, authenticateRequestOptional } from './request-handlers/security/authentication'; +import { loadEvironmentVariables } from './utils/env-config'; import { scanFileForVirus } from './utils/file-utils'; import { getLogger } from './utils/logger'; +// Load and validate the environment variables +loadEvironmentVariables(); + const defaultLog = getLogger('app'); const HOST = process.env.API_HOST; -const PORT = Number(process.env.API_PORT); +const PORT = process.env.API_PORT; // Max size of the body of the request (bytes) -const MAX_REQ_BODY_SIZE = Number(process.env.MAX_REQ_BODY_SIZE) || 52428800; +const MAX_REQ_BODY_SIZE = process.env.MAX_REQ_BODY_SIZE; // Max number of files in a single request -const MAX_UPLOAD_NUM_FILES = Number(process.env.MAX_UPLOAD_NUM_FILES) || 10; +const MAX_UPLOAD_NUM_FILES = process.env.MAX_UPLOAD_NUM_FILES; // Max size of a single file (bytes) -const MAX_UPLOAD_FILE_SIZE = Number(process.env.MAX_UPLOAD_FILE_SIZE) || 52428800; +const MAX_UPLOAD_FILE_SIZE = process.env.MAX_UPLOAD_FILE_SIZE; // Get initial express app const app: express.Express = express(); diff --git a/api/src/constants/dates.ts b/api/src/constants/dates.ts new file mode 100644 index 0000000000..00722e6895 --- /dev/null +++ b/api/src/constants/dates.ts @@ -0,0 +1,22 @@ +/* + * Date formats. + * + * See BC Gov standards: https://www2.gov.bc.ca/gov/content/governments/services-for-government/policies-procedures/web-content-development-guides/writing-for-the-web/web-style-guide/numbers + */ +export const DefaultDateFormat = 'YYYY-MM-DD'; // 2020-01-05 + +export const DefaultDateFormatReverse = 'DD-MM-YYYY'; // 05-01-2020 + +export const AltDateFormat = 'YYYY/MM/DD'; // 2020/01/05 + +export const AltDateFormatReverse = 'DD/MM/YYYY'; // 05/01/2020 + +/* + * Time formats. + */ +export const DefaultTimeFormat = 'HH:mm:ss'; // 23:00:00 + +/* + * Datetime formats. + */ +export const DefaultDateTimeFormat = `${DefaultDateFormat}T${DefaultTimeFormat}`; // 2020-01-05T23:00:00 diff --git a/api/src/database/db.ts b/api/src/database/db.ts index a213567c86..8d598f1981 100644 --- a/api/src/database/db.ts +++ b/api/src/database/db.ts @@ -18,14 +18,14 @@ import { asyncErrorWrapper, getGenericizedKeycloakUserInformation, syncErrorWrap const defaultLog = getLogger('database/db'); const getDbHost = () => process.env.DB_HOST; -const getDbPort = () => Number(process.env.DB_PORT); +const getDbPort = () => process.env.DB_PORT; const getDbUsername = () => process.env.DB_USER_API; const getDbPassword = () => process.env.DB_USER_API_PASS; const getDbDatabase = () => process.env.DB_DATABASE; -const DB_POOL_SIZE: number = Number(process.env.DB_POOL_SIZE) || 20; -const DB_CONNECTION_TIMEOUT: number = Number(process.env.DB_CONNECTION_TIMEOUT) || 0; -const DB_IDLE_TIMEOUT: number = Number(process.env.DB_IDLE_TIMEOUT) || 10000; +const DB_POOL_SIZE = 20; +const DB_CONNECTION_TIMEOUT = 0; +const DB_IDLE_TIMEOUT = 10000; export const DB_CLIENT = 'pg'; diff --git a/api/src/models/alert-view.ts b/api/src/models/alert-view.ts new file mode 100644 index 0000000000..f57e2fffa5 --- /dev/null +++ b/api/src/models/alert-view.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +// Define the alert schema +export const IAlert = z.object({ + alert_id: z.number(), + alert_type_id: z.number().int(), + name: z.string(), + message: z.string(), + severity: z.enum(['info', 'success', 'error', 'warning']), + data: z.object({}).nullable(), + record_end_date: z.string().nullable(), + status: z.enum(['active', 'expired']) +}); + +// Infer types from the schema +export type IAlert = z.infer; +export type IAlertCreateObject = Omit; +export type IAlertUpdateObject = Omit; + +// Filter object for viewing alerts +export interface IAlertFilterObject { + expiresBefore?: string; + expiresAfter?: string; + types?: string[]; +} + +// Define severity and status types +export type IAlertSeverity = 'info' | 'success' | 'error' | 'warning'; +export type IAlertStatus = 'active' | 'expired'; diff --git a/api/src/models/project-survey-attachments.ts b/api/src/models/project-survey-attachments.ts index 7f025337f2..f7eef80b3c 100644 --- a/api/src/models/project-survey-attachments.ts +++ b/api/src/models/project-survey-attachments.ts @@ -1,4 +1,4 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import { ATTACHMENT_TYPE } from '../constants/attachments'; import { getLogger } from '../utils/logger'; import { SurveySupplementaryData } from './survey-view'; diff --git a/api/src/models/sampling-locations-view.ts b/api/src/models/sampling-locations-view.ts new file mode 100644 index 0000000000..59cf1e842c --- /dev/null +++ b/api/src/models/sampling-locations-view.ts @@ -0,0 +1,19 @@ +export interface ISiteAdvancedFilters { + survey_id?: number; + keyword?: string; + system_user_id?: number; +} + +export interface IMethodAdvancedFilters { + survey_id?: number; + sample_site_id?: number; + keyword?: string; + system_user_id?: number; +} + +export interface IPeriodAdvancedFilters { + survey_id?: number; + sample_site_id?: number; + sample_method_id?: number; + system_user_id?: number; +} diff --git a/api/src/openapi/schemas/alert.ts b/api/src/openapi/schemas/alert.ts new file mode 100644 index 0000000000..312681feb9 --- /dev/null +++ b/api/src/openapi/schemas/alert.ts @@ -0,0 +1,83 @@ +import { OpenAPIV3 } from 'openapi-types'; + +/** + * Base schema for system alerts + */ +const baseSystemAlertSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'Schema defining alerts created by system administrators.', + additionalProperties: false, + properties: { + name: { + description: 'Name to display as the title of the alert', + type: 'string' + }, + message: { + description: 'Message to display on the alert', + type: 'string' + }, + alert_type_id: { + description: 'Type of the alert, controlling how it is displayed.', + type: 'number' + }, + severity: { + description: 'Severity level of the alert', + type: 'string', + enum: ['info', 'success', 'warning', 'error'] + }, + data: { + description: 'Data associated with the alert', + type: 'object', + nullable: true + }, + record_end_date: { + description: 'End date of the alert', + type: 'string', + nullable: true + } + } +}; + +/** + * Schema for updating system alerts + */ +export const systemAlertPutSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + required: ['alert_id', 'name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity'], + additionalProperties: false, + properties: { + ...baseSystemAlertSchema.properties, + alert_id: { + type: 'integer', + minimum: 1, + description: 'Primary key of the alert' + } + } +}; + +/** + * Schema for getting system alerts + */ +export const systemAlertGetSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + required: ['alert_id', 'name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity', 'status'], + additionalProperties: false, + properties: { + ...systemAlertPutSchema.properties, + status: { + type: 'string', + enum: ['active', 'expired'], + description: + 'Status of the alert based on comparing the current date to record_end_date, calculated in the get query.' + } + } +}; + +/** + * Schema for creating system alerts + */ +export const systemAlertCreateSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + additionalProperties: false, + required: ['name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity'] +}; diff --git a/api/src/openapi/schemas/observation.ts b/api/src/openapi/schemas/observation.ts index 6d67b3c8ad..00c8579f24 100644 --- a/api/src/openapi/schemas/observation.ts +++ b/api/src/openapi/schemas/observation.ts @@ -1,5 +1,6 @@ import { OpenAPIV3 } from 'openapi-types'; import { paginationResponseSchema } from './pagination'; +import { SampleLocationSchema } from './sample-site'; export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { type: 'object', @@ -60,19 +61,27 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { nullable: true }, latitude: { - type: 'number' + type: 'number', + nullable: true, + minimum: -90, + maximum: 90 }, longitude: { - type: 'number' + type: 'number', + nullable: true, + minimum: -180, + maximum: 180 }, count: { type: 'integer' }, observation_date: { - type: 'string' + type: 'string', + nullable: true }, observation_time: { - type: 'string' + type: 'string', + nullable: true }, survey_sample_site_name: { type: 'string', @@ -217,7 +226,8 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { 'qualitative_measurements', 'quantitative_measurements', 'qualitative_environments', - 'quantitative_environments' + 'quantitative_environments', + 'sample_sites' ], properties: { observationCount: { @@ -404,7 +414,8 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { } } } - } + }, + sample_sites: SampleLocationSchema } }, pagination: { ...paginationResponseSchema } diff --git a/api/src/openapi/schemas/sample-site.ts b/api/src/openapi/schemas/sample-site.ts new file mode 100644 index 0000000000..8e6582892b --- /dev/null +++ b/api/src/openapi/schemas/sample-site.ts @@ -0,0 +1,156 @@ +import { OpenAPIV3 } from 'openapi-types'; +import { techniqueSimpleViewSchema } from './technique'; + +export const SampleLocationSchema: OpenAPIV3.SchemaObject = { + type: 'array', + description: 'Sample location response object (includes sites, techniques, periods, stratums, blocks).', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 50 + }, + description: { + type: 'string', + maxLength: 250 + }, + geometry_type: { + type: 'string', + maxLength: 50 + }, + sample_methods: { + type: 'array', + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'technique', + 'method_response_metric_id', + 'sample_periods' + ], + items: { + type: 'object', + additionalProperties: false, + properties: { + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + technique: techniqueSimpleViewSchema, + method_response_metric_id: { + type: 'integer', + minimum: 1 + }, + description: { + type: 'string', + maxLength: 250 + }, + sample_periods: { + type: 'array', + required: [ + 'survey_sample_period_id', + 'survey_sample_method_id', + 'start_date', + 'start_time', + 'end_date', + 'end_time' + ], + items: { + type: 'object', + additionalProperties: false, + properties: { + survey_sample_period_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + start_date: { + type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_date: { + type: 'string' + }, + end_time: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + blocks: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_block_id', 'survey_sample_site_id', 'survey_block_id'], + properties: { + survey_sample_block_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_block_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_stratum_id', 'survey_sample_site_id', 'survey_stratum_id'], + properties: { + survey_sample_stratum_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_stratum_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + } + } + } +}; diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index 6e9a35a298..28d62e4026 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -483,7 +483,7 @@ export const surveyBlockSchema: OpenAPIV3.SchemaObject = { title: 'Survey Block', type: 'object', additionalProperties: false, - required: ['name', 'description'], + required: ['name', 'description', 'survey_id', 'geojson'], properties: { survey_block_id: { description: 'Survey block id', @@ -494,18 +494,21 @@ export const surveyBlockSchema: OpenAPIV3.SchemaObject = { survey_id: { description: 'Survey id', type: 'integer', - nullable: true + minimum: 1 }, name: { description: 'Name', - type: 'string', - nullable: true + type: 'string' }, description: { description: 'Description', type: 'string', nullable: true }, + geojson: { + description: 'Geojson', + type: 'object' + }, sample_block_count: { description: 'Sample block count', type: 'number' diff --git a/api/src/paths/alert/index.test.ts b/api/src/paths/alert/index.test.ts new file mode 100644 index 0000000000..82243c1846 --- /dev/null +++ b/api/src/paths/alert/index.test.ts @@ -0,0 +1,141 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { IAlertSeverity, IAlertStatus } from '../../models/alert-view'; +import { AlertService } from '../../services/alert-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { createAlert, getAlerts } from '../alert'; + +chai.use(sinonChai); + +describe('getAlerts', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('returns a list of system alerts', async () => { + const mockAlerts = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + }, + { + alert_id: 2, + name: 'Alert 2', + message: 'Message 2', + alert_type_id: 2, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + } + ]; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlerts').resolves(mockAlerts); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [3], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = getAlerts(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alerts: mockAlerts }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlerts').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getAlerts(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('createAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + it('creates a new alert', async () => { + const mockAlert = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + severity: 'medium' + }; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'createAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = mockAlert; + + const requestHandler = createAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'createAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = createAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/alert/index.ts b/api/src/paths/alert/index.ts new file mode 100644 index 0000000000..df67c3fb3d --- /dev/null +++ b/api/src/paths/alert/index.ts @@ -0,0 +1,244 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { IAlertFilterObject } from '../../models/alert-view'; +import { systemAlertCreateSchema, systemAlertGetSchema } from '../../openapi/schemas/alert'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { AlertService } from '../../services/alert-service'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('paths/alert/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.PROJECT_CREATOR, SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getAlerts() +]; + +GET.apiDoc = { + description: 'Gets a list of system alerts.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'types', + required: false, + schema: { + type: 'array', + items: { + type: 'string' + } + } + }, + { + in: 'query', + name: 'expiresBefore', + required: false, + schema: { + type: 'string' + } + }, + { + in: 'query', + name: 'expiresAfter', + required: false, + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + description: 'Response object containing system alerts', + additionalProperties: false, + required: ['alerts'], + properties: { + alerts: { type: 'array', description: 'Array of system alerts', items: systemAlertGetSchema } + } + } + } + } + }, + 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 system alerts created by system administrators describing important information, deadlines, etc. + * + * @returns {RequestHandler} + */ +export function getAlerts(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getAlerts' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const filterObject = parseQueryParams(req); + + const alertService = new AlertService(connection); + + const alerts = await alertService.getAlerts(filterObject); + + await connection.commit(); + + return res.status(200).json({ alerts: alerts }); + } catch (error) { + defaultLog.error({ label: 'getAlerts', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IAlertFilterObject} + */ +function parseQueryParams(req: Request): IAlertFilterObject { + return { + expiresBefore: req.query.expiresBefore ?? undefined, + expiresAfter: req.query.expiresAfter ?? undefined, + types: req.query.types ?? [] + }; +} + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + createAlert() +]; + +POST.apiDoc = { + description: 'Create an alert.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + description: 'Alert post request object.', + required: true, + content: { + 'application/json': { + schema: systemAlertCreateSchema + } + } + }, + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Creates a new system alert + * + * @returns {RequestHandler} + */ +export function createAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'createAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alert = req.body; + + const alertService = new AlertService(connection); + + const id = await alertService.createAlert(alert); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'createAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/alert/{alertId}/index.test.ts b/api/src/paths/alert/{alertId}/index.test.ts new file mode 100644 index 0000000000..e10b9c1d78 --- /dev/null +++ b/api/src/paths/alert/{alertId}/index.test.ts @@ -0,0 +1,246 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { deleteAlert, getAlertById, updateAlert } from '.'; +import { SYSTEM_IDENTITY_SOURCE } from '../../../constants/database'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import * as db from '../../../database/db'; +import { HTTPError } from '../../../errors/http-error'; +import { IAlertSeverity, IAlertStatus } from '../../../models/alert-view'; +import { AlertService } from '../../../services/alert-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('getAlerts', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('returns a single system alert', async () => { + const mockAlert = { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + }; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlertById').resolves(mockAlert); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [3], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = getAlertById(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockAlert); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlertById').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + const requestHandler = getAlertById(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('deleteAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('rejects an unauthorized request', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.PROJECT_CREATOR], // Creators cannot delete alerts + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = deleteAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); + + describe('as a system admin user', () => { + it('deletes an alert and returns the alert id', async () => { + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = deleteAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + + const requestHandler = deleteAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('updateAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system admin user', () => { + it('updates an alert', async () => { + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + sinon.stub(AlertService.prototype, 'updateAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = updateAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'updateAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + + const requestHandler = updateAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); diff --git a/api/src/paths/alert/{alertId}/index.ts b/api/src/paths/alert/{alertId}/index.ts new file mode 100644 index 0000000000..141f52155e --- /dev/null +++ b/api/src/paths/alert/{alertId}/index.ts @@ -0,0 +1,317 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { systemAlertGetSchema, systemAlertPutSchema } from '../../../openapi/schemas/alert'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { AlertService } from '../../../services/alert-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/alert/{alertId}/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getAlertById() +]; + +GET.apiDoc = { + description: 'Gets a specific system alert.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to get' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: systemAlertGetSchema + } + } + }, + 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 specific system alert by its id + * + * @returns {RequestHandler} + */ +export function getAlertById(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getAlertById' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + + const alertService = new AlertService(connection); + + const alert = await alertService.getAlertById(alertId); + + await connection.commit(); + + return res.status(200).json(alert); + } catch (error) { + defaultLog.error({ label: 'getAlertById', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const PUT: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + updateAlert() +]; + +PUT.apiDoc = { + description: 'Update an alert by its id.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to update' + } + } + ], + requestBody: { + description: 'Alert put request object.', + required: true, + content: { + 'application/json': { + schema: systemAlertPutSchema + } + } + }, + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Updates a system alert by its id + * + * @returns {RequestHandler} + */ +export function updateAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'updateAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + const alert = req.body; + + const alertService = new AlertService(connection); + + const id = await alertService.updateAlert({ ...alert, alert_id: alertId }); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'updateAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const DELETE: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteAlert() +]; + +DELETE.apiDoc = { + description: 'Delete an alert by its id.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to delete' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Deletes a system alert by its id + * + * @returns {RequestHandler} + */ +export function deleteAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'deleteAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + + const alertService = new AlertService(connection); + + const id = await alertService.deleteAlert(alertId); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'deleteAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index eebad437bc..0f60bb0406 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -382,6 +382,27 @@ GET.apiDoc = { } } } + }, + alert_types: { + type: 'array', + description: 'Alert type options for system administrators managing alert messages.', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name', 'description'], + properties: { + id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/paths/observation/index.test.ts b/api/src/paths/observation/index.test.ts index 46b46e9fe7..c366134e4c 100644 --- a/api/src/paths/observation/index.test.ts +++ b/api/src/paths/observation/index.test.ts @@ -104,7 +104,8 @@ describe('findObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }); expect(mockRes.jsonValue.pagination).not.to.be.null; diff --git a/api/src/paths/observation/index.ts b/api/src/paths/observation/index.ts index f90f83be54..c7c21917c5 100644 --- a/api/src/paths/observation/index.ts +++ b/api/src/paths/observation/index.ts @@ -209,7 +209,8 @@ export function findObservations(): RequestHandler { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: makePaginationResponse(observationsTotalCount, paginationOptions) }; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts index 6bee8d9702..5ce785b665 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts @@ -1,6 +1,7 @@ import dayjs from 'dayjs'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; +import { DefaultDateFormat, DefaultTimeFormat } from '../../../../../../constants/dates'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; import { getDeploymentSchema } from '../../../../../../openapi/schemas/deployment'; @@ -204,16 +205,16 @@ export function getDeploymentsInSurvey(): RequestHandler { assignment_id: matchingBctwDeployments[0].assignment_id, collar_id: matchingBctwDeployments[0].collar_id, attachment_start_date: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultDateFormat) : null, attachment_start_time: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultTimeFormat) : null, attachment_end_date: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultDateFormat) : null, attachment_end_time: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultTimeFormat) : null, bctw_deployment_id: matchingBctwDeployments[0].deployment_id, device_id: matchingBctwDeployments[0].device_id, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts index 7cf78b92cd..f4bfe664c1 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts @@ -2,6 +2,7 @@ import { AxiosError } from 'axios'; import dayjs from 'dayjs'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; +import { DefaultDateFormat, DefaultTimeFormat } from '../../../../../../../constants/dates'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; import { HTTP400 } from '../../../../../../../errors/http-error'; @@ -211,16 +212,16 @@ export function getDeploymentById(): RequestHandler { assignment_id: matchingBctwDeployments[0].assignment_id, collar_id: matchingBctwDeployments[0].collar_id, attachment_start_date: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultDateFormat) : null, attachment_start_time: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultTimeFormat) : null, attachment_end_date: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultDateFormat) : null, attachment_end_time: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultTimeFormat) : null, bctw_deployment_id: matchingBctwDeployments[0].deployment_id, device_id: matchingBctwDeployments[0].device_id, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index 744c7706a4..758016cbba 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -160,7 +160,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -190,7 +191,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 59, @@ -220,7 +222,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -248,7 +251,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 50, @@ -278,7 +282,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -301,7 +306,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 2, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index 21a471b793..8bae38b00d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -9,8 +9,11 @@ import { CritterbaseService, getCritterbaseUser } from '../../../../../../servic import { InsertUpdateObservations, ObservationService } from '../../../../../../services/observation-service'; import { ObservationSubCountEnvironmentService } from '../../../../../../services/observation-subcount-environment-service'; import { getLogger } from '../../../../../../utils/logger'; -import { ensureCompletePaginationOptions, makePaginationResponse } from '../../../../../../utils/pagination'; -import { ApiPaginationOptions } from '../../../../../../zod-schema/pagination'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../../../../utils/pagination'; const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation'); @@ -375,19 +378,11 @@ export function getSurveyObservations(): RequestHandler { const surveyId = Number(req.params.surveyId); defaultLog.debug({ label: 'getSurveyObservations', surveyId }); - const page: number | undefined = req.query.page ? Number(req.query.page) : undefined; - const limit: number | undefined = req.query.limit ? Number(req.query.limit) : undefined; - const order: 'asc' | 'desc' | undefined = req.query.order ? (String(req.query.order) as 'asc' | 'desc') : undefined; - - const sortQuery: string | undefined = req.query.sort ? String(req.query.sort) : undefined; - let sort = sortQuery; - - if (sortQuery && samplingSiteSortingColumnName[sortQuery]) { - sort = samplingSiteSortingColumnName[sortQuery]; + const paginationOptions = makePaginationOptionsFromRequest(req); + if (paginationOptions.sort && samplingSiteSortingColumnName[paginationOptions.sort]) { + paginationOptions.sort = samplingSiteSortingColumnName[paginationOptions.sort]; } - const paginationOptions: Partial = { page, limit, order, sort }; - const connection = getDBConnection(req.keycloak_token); try { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts index cc423f2d94..ab1516617e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts @@ -68,7 +68,7 @@ POST.apiDoc = { surveySamplePeriodId: { type: 'integer', description: - 'The optional ID of a survey sample period to associate the parsed observation records with.' + 'The optional ID of a survey sample period to associate the parsed observation records with. This is used when uploading all observations to a specific sample period, not when each record is for a different sample period.' } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts index 2e6629ef53..f917c6e264 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts @@ -1,4 +1,3 @@ -import { SchemaObject } from 'ajv'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; @@ -32,123 +31,6 @@ export const GET: Operation = [ getSurveyObservationsGeometry() ]; -export const surveyObservationsSupplementaryData: SchemaObject = { - type: 'object', - additionalProperties: false, - required: ['observationCount'], - properties: { - observationCount: { - type: 'integer', - minimum: 0 - }, - measurementColumns: { - type: 'array', - items: { - anyOf: [ - { - description: 'A quantitative (number) measurement, with possible min/max constraint.', - type: 'object', - additionalProperties: false, - required: [ - 'itis_tsn', - 'taxon_measurement_id', - 'measurement_name', - 'measurement_desc', - 'min_value', - 'max_value', - 'unit' - ], - properties: { - itis_tsn: { - type: 'number', - nullable: true - }, - taxon_measurement_id: { - type: 'string' - }, - measurement_name: { - type: 'string' - }, - measurement_desc: { - type: 'string', - nullable: true - }, - min_value: { - type: 'number', - nullable: true - }, - max_value: { - type: 'number', - nullable: true - }, - unit: { - type: 'string', - nullable: true - } - } - }, - { - description: 'A qualitative (string) measurement, with array of valid/accepted options', - type: 'object', - additionalProperties: false, - required: ['itis_tsn', 'taxon_measurement_id', 'measurement_name', 'measurement_desc', 'options'], - properties: { - itis_tsn: { - type: 'number', - nullable: true - }, - taxon_measurement_id: { - type: 'string' - }, - measurement_name: { - type: 'string' - }, - measurement_desc: { - type: 'string', - nullable: true - }, - options: { - description: 'Valid options for the measurement.', - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'taxon_measurement_id', - 'qualitative_option_id', - 'option_label', - 'option_value', - 'option_desc' - ], - properties: { - taxon_measurement_id: { - type: 'string' - }, - qualitative_option_id: { - type: 'string' - }, - option_label: { - type: 'string', - nullable: true - }, - option_value: { - type: 'number' - }, - option_desc: { - type: 'string', - nullable: true - } - } - } - } - } - } - ] - } - } - } -}; - GET.apiDoc = { description: 'Get all observations for the survey.', tags: ['observation'], diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts index 4be0d3eea6..46f77143c1 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts @@ -16,23 +16,6 @@ describe('getSurveySampleLocationRecord', () => { 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 = get_survey_sample_site_record.getSurveySampleLocationRecord(); - 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 param `surveyId`'); - } - }); - it('should catch and re-throw an error if SampleLocationService throws an error', async () => { const dbConnectionObj = getMockDBConnection(); @@ -109,37 +92,10 @@ describe('getSurveySampleLocationRecord', () => { describe('createSurveySampleSiteRecord', () => { const dbConnectionObj = getMockDBConnection(); - const sampleReq = { - keycloak_token: {}, - body: { - participants: [[1, 1, 'job']] - }, - params: { - surveyId: 1 - } - } as any; - afterEach(() => { sinon.restore(); }); - it('should throw a 400 error when no surveyId in the param', async () => { - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - try { - const result = create_survey_sample_site_record.createSurveySampleSiteRecord(); - await result( - { ...sampleReq, params: { ...sampleReq.params, surveyId: null } }, - null as unknown as any, - null as unknown as any - ); - 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 work', async () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); 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 3d598335d0..597516446c 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 @@ -2,7 +2,6 @@ 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 { GeoJSONFeature } from '../../../../../../openapi/schemas/geoJson'; import { paginationRequestQueryParamSchema, @@ -44,7 +43,7 @@ export const GET: Operation = [ ]; GET.apiDoc = { - description: 'Get all survey sample sites.', + description: 'Get survey sample sites.', tags: ['survey'], security: [ { @@ -70,6 +69,16 @@ GET.apiDoc = { }, required: true }, + { + in: 'query', + name: 'keyword', + schema: { + type: 'string', + description: + 'A keyword to search for in the sample site name or description. If provided, pagination will be ignored.' + }, + required: false + }, ...paginationRequestQueryParamSchema ], responses: { @@ -86,7 +95,7 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geojson'], + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], properties: { survey_sample_site_id: { type: 'integer', @@ -104,8 +113,9 @@ GET.apiDoc = { type: 'string', maxLength: 250 }, - geojson: { - ...(GeoJSONFeature as object) + geometry_type: { + type: 'string', + maxLength: 50 }, sample_methods: { type: 'array', @@ -257,29 +267,28 @@ GET.apiDoc = { }; /** - * Get all survey sample sites. + * Get all survey sample sites, paginated or filtered by keyword. * * @returns {RequestHandler} */ export function getSurveySampleLocationRecord(): 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 keyword = req.query.keyword as string | undefined; + const paginationOptions = makePaginationOptionsFromRequest(req); + await connection.open(); + const sampleLocationService = new SampleLocationService(connection); - const sampleSites = await sampleLocationService.getSampleLocationsForSurveyId( - surveyId, - ensureCompletePaginationOptions(paginationOptions) - ); + const sampleSites = await sampleLocationService.getSampleLocationsForSurveyId(surveyId, { + keyword: keyword, + pagination: ensureCompletePaginationOptions(paginationOptions) + }); const sampleSitesTotalCount = await sampleLocationService.getSampleLocationsCountBySurveyId(surveyId); @@ -560,10 +569,6 @@ POST.apiDoc = { export function createSurveySampleSiteRecord(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required path param `surveyId`'); - } - const connection = getDBConnection(req.keycloak_token); try { @@ -582,7 +587,7 @@ export function createSurveySampleSiteRecord(): RequestHandler { return res.status(201).send(); } catch (error) { - defaultLog.error({ label: 'insertProjectParticipants', message: 'error', error }); + defaultLog.error({ label: 'createSurveySampleSiteRecord', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts new file mode 100644 index 0000000000..e64f42ca3d --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts @@ -0,0 +1,72 @@ +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 { SampleLocationService } from '../../../../../../services/sample-location-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; +import { getSurveySampleSitesGeometry } from './spatial'; + +chai.use(sinonChai); + +describe('getSurveySampleSitesGeometry', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should catch and re-throw an error if SampleLocationService throws an error', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + sinon.stub(SampleLocationService.prototype, 'getSampleLocationsGeometryBySurveyId').rejects(new Error('an error')); + + try { + const requestHandler = getSurveySampleSitesGeometry(); + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('an error'); + } + }); + + it('should return sampleSites on success', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + const sampleSiteData = [ + { + survey_sample_site_id: 1, + geojson: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] } } + }, + { + survey_sample_site_id: 2, + geojson: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [1, 1] } } + } + ]; + + sinon.stub(SampleLocationService.prototype, 'getSampleLocationsGeometryBySurveyId').resolves(sampleSiteData); + + const requestHandler = getSurveySampleSitesGeometry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ sampleSites: sampleSiteData }); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts new file mode 100644 index 0000000000..cdcd793460 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts @@ -0,0 +1,143 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { GeoJSONFeature } from '../../../../../../openapi/schemas/geoJson'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../../../../services/sample-location-service'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/sample-site/spatial'); + +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' + } + ] + }; + }), + getSurveySampleSitesGeometry() +]; + +GET.apiDoc = { + description: 'Get spatial information for all sample sites in the survey.', + tags: ['survey'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Survey sample sites spatial get response.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + nullable: true, + required: ['sampleSites'], + properties: { + sampleSites: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_site_id', 'geojson'], + properties: { + survey_sample_site_id: { + type: 'integer' + }, + geojson: { ...(GeoJSONFeature as object) } + } + } + } + } + } + } + } + }, + 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' + } + } +}; + +/** + * Fetch geometry for all sampling sites in the survey + * + * @export + * @return {*} {RequestHandler} + */ +export function getSurveySampleSitesGeometry(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + + defaultLog.debug({ label: 'getSurveySampleSitesGeometry', surveyId }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const sampleSiteService = new SampleLocationService(connection); + + const sampleSiteData = await sampleSiteService.getSampleLocationsGeometryBySurveyId(surveyId); + + await connection.commit(); + + return res.status(200).json({ sampleSites: sampleSiteData }); + } catch (error) { + defaultLog.error({ label: 'getSurveySampleSitesGeometry', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} 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 310e7007a0..5008d9bf7e 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 @@ -5,7 +5,7 @@ import sinonChai from 'sinon-chai'; import { deleteSurveySampleSiteRecord, getSurveySampleLocationRecord, updateSurveySampleSite } from '.'; import * as db from '../../../../../../../database/db'; import { HTTPError } from '../../../../../../../errors/http-error'; -import { UpdateSampleSiteRecord } from '../../../../../../../repositories/sample-location-repository'; +import { UpdateSampleSiteRecord } from '../../../../../../../repositories/sample-location-repository/sample-location-repository'; import { ObservationService } from '../../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../../services/sample-location-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; @@ -212,38 +212,12 @@ describe('deleteSurveySampleSiteRecord', () => { }); describe('getSurveySampleLocationRecord', () => { - const dbConnectionObj = getMockDBConnection(); - - const sampleReq = { - keycloak_token: {}, - params: { - surveyId: 1, - surveySampleSiteId: 1 - } - } as any; - afterEach(() => { sinon.restore(); }); - it('should throw a 400 error when no surveySampleSiteId in the param', async () => { - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - try { - const result = getSurveySampleLocationRecord(); - await result( - { ...sampleReq, params: { ...sampleReq.params, surveySampleSiteId: null } }, - null as unknown as any, - null as unknown as any - ); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required param `surveySampleSiteId`'); - } - }); - it('should successfully get a sample location record', async () => { + const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); const getSurveySampleLocationBySiteIdStub = sinon @@ -264,4 +238,35 @@ describe('getSurveySampleLocationRecord', () => { expect(mockRes.status).to.have.been.calledWith(200); expect(getSurveySampleLocationBySiteIdStub).to.have.been.calledOnce; }); + + it('catches and re-throws error', async () => { + const dbConnectionObj = getMockDBConnection({ + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockError = new Error('a test error'); + + sinon.stub(SampleLocationService.prototype, 'getSurveySampleLocationBySiteId').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + surveyId: '1', + surveySampleSiteId: '2' + }; + + const requestHandler = getSurveySampleLocationRecord(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('a test error'); + + expect(dbConnectionObj.rollback).to.have.been.calledOnce; + expect(dbConnectionObj.release).to.have.been.calledOnce; + } + }); }); 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 4723a69b78..91be150913 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 @@ -5,7 +5,7 @@ import { getDBConnection } from '../../../../../../../database/db'; 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 { UpdateSampleLocationRecord } from '../../../../../../../repositories/sample-location-repository/sample-location-repository'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { ObservationService } from '../../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../../services/sample-location-service'; @@ -427,6 +427,15 @@ GET.apiDoc = { minimum: 1 }, required: true + }, + { + in: 'path', + name: 'surveySampleSiteId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true } ], responses: { @@ -639,13 +648,6 @@ GET.apiDoc = { */ export function getSurveySampleLocationRecord(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required param `surveyId`'); - } - if (!req.params.surveySampleSiteId) { - throw new HTTP400('Missing required param `surveySampleSiteId`'); - } - const connection = getDBConnection(req.keycloak_token); try { diff --git a/api/src/paths/sampling-locations/methods/index.ts b/api/src/paths/sampling-locations/methods/index.ts new file mode 100644 index 0000000000..7d1c355545 --- /dev/null +++ b/api/src/paths/sampling-locations/methods/index.ts @@ -0,0 +1,297 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { IMethodAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { ensureCompletePaginationOptions, makePaginationOptionsFromRequest } from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/method/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findMethods() +]; + +GET.apiDoc = { + description: "Gets a list of methods based on the user's permissions and filter criteria.", + tags: ['methods'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'start_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'start_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'min_count', + description: 'Minimum method count (inclusive).', + required: false, + schema: { + type: 'number', + minimum: 0, + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'methods response object.', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + methods: { + type: 'array', + items: { + type: 'object', + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'description', + 'method_response_metric_id', + 'technique' + ], + additionalProperties: false, + properties: { + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + description: { + type: 'string', + nullable: true + }, + method_response_metric_id: { + type: 'integer', + minimum: 1 + }, + technique: { + type: 'object', + required: ['method_technique_id', 'name', 'description', 'attractants'], + additionalProperties: false, + properties: { + method_technique_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + attractants: { + type: 'array', + required: ['attractant_lookup_id'], + additionalProperties: false, + items: { + type: 'object', + properties: { + attractant_lookup_id: { + type: 'integer', + minimum: 1 + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + 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 methods for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findMethods(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findMethods' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const methods = await sampleLocationService.findMethods( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ); + + await connection.commit(); + + const response = { + methods: methods + // TODO NICK add count and pagination to response and openapi schema? + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getMethods', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IMethodAdvancedFilters} + */ +function parseQueryParams(req: Request): IMethodAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + sample_site_id: (req.query.sample_site_id && Number(req.query.sample_site_id)) ?? undefined, + keyword: req.query.keyword ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/sampling-locations/periods/index.ts b/api/src/paths/sampling-locations/periods/index.ts new file mode 100644 index 0000000000..e0553c2a2e --- /dev/null +++ b/api/src/paths/sampling-locations/periods/index.ts @@ -0,0 +1,271 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { IPeriodAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/period/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findPeriods() +]; + +GET.apiDoc = { + description: "Gets a list of periods based on the user's permissions and filter criteria.", + tags: ['periods'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'survey_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'sample_site_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'sample_method_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'periods response object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['periods', 'pagination'], + additionalProperties: false, + properties: { + periods: { + type: 'array', + items: { + type: 'object', + required: [ + 'survey_sample_period_id', + 'survey_sample_method_id', + 'start_date', + 'start_time', + 'end_date', + 'end_time', + 'sample_method', + 'method_technique', + 'sample_site' + ], + additionalProperties: false, + properties: { + survey_sample_period_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + start_date: { + type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_date: { + type: 'string' + }, + end_time: { + type: 'string', + nullable: true + }, + sample_method: { + type: 'object', + required: ['method_response_metric_id'], + additionalProperties: false, + properties: { + method_response_metric_id: { + type: 'integer', + minimum: 1 + } + } + }, + method_technique: { + type: 'object', + required: ['method_technique_id', 'name'], + additionalProperties: false, + properties: { + method_technique_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + } + } + }, + sample_site: { + type: 'object', + required: ['survey_sample_site_id', 'name'], + additionalProperties: false, + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + } + } + } + } + } + }, + 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 periods for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findPeriods(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findPeriods' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const [periods, periodsCount] = await Promise.all([ + sampleLocationService.findPeriods( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + sampleLocationService.findPeriodsCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + const response = { + periods: periods, + pagination: makePaginationResponse(periodsCount, paginationOptions) + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getPeriods', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IPeriodAdvancedFilters} + */ +function parseQueryParams(req: Request): IPeriodAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + sample_site_id: (req.query.sample_site_id && Number(req.query.sample_site_id)) ?? undefined, + sample_method_id: (req.query.sample_method_id && Number(req.query.sample_method_id)) ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/sampling-locations/sites/index.ts b/api/src/paths/sampling-locations/sites/index.ts new file mode 100644 index 0000000000..48890bd5da --- /dev/null +++ b/api/src/paths/sampling-locations/sites/index.ts @@ -0,0 +1,257 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { ISiteAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/site/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findSites() +]; + +GET.apiDoc = { + description: "Gets a list of sites based on the user's permissions and filter criteria.", + tags: ['sites'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'survey_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Sites response object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['sites'], + additionalProperties: false, + properties: { + sites: { + type: 'array', + items: { + type: 'object', + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], + additionalProperties: false, + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + geometry_type: { + type: 'string' + }, + blocks: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_block_id', 'survey_sample_site_id', 'survey_block_id'], + properties: { + survey_sample_block_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_block_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_stratum_id', 'survey_sample_site_id', 'survey_stratum_id'], + properties: { + survey_sample_stratum_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_stratum_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + } + } + } + }, + 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 sites for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findSites(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findSites' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const [sites, sitesCount] = await Promise.all([ + sampleLocationService.findSites( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + sampleLocationService.findSitesCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + const response = { + sites: sites, + pagination: makePaginationResponse(sitesCount, paginationOptions) + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + console.log('response', response); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'findSites', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {ISiteAdvancedFilters} + */ +function parseQueryParams(req: Request): ISiteAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + keyword: req.query.keyword ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/repositories/alert-repository.test.ts b/api/src/repositories/alert-repository.test.ts new file mode 100644 index 0000000000..0f6c05950e --- /dev/null +++ b/api/src/repositories/alert-repository.test.ts @@ -0,0 +1,169 @@ +import chai, { expect } from 'chai'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { IAlertSeverity } from '../models/alert-view'; +import { getMockDBConnection } from '../__mocks__/db'; +import { AlertRepository } from './alert-repository'; + +chai.use(sinonChai); + +describe('AlertRepository', () => { + it('should construct', () => { + const mockDBConnection = getMockDBConnection(); + const alertRepository = new AlertRepository(mockDBConnection); + + expect(alertRepository).to.be.instanceof(AlertRepository); + }); + + describe('getAlerts', () => { + it('should return an array of alerts with empty filters', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlerts({}); + + expect(response).to.be.an('array').that.is.not.empty; + expect(response[0]).to.have.property('alert_id', 1); + }); + + it('should apply filters when provided', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error', + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlerts({ expiresBefore: '2024-01-01', types: ['type1'] }); + + expect(response).to.equal(mockRows); + }); + }); + + describe('getAlertById', () => { + it('should return a specific alert by its Id', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error', + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlertById(1); + + expect(response).to.have.property('alert_id', 1); + }); + }); + + describe('updateAlert', () => { + it('should update an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + const alert = { + alert_id: 1, + name: 'Updated Alert', + message: 'Updated message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + const response = await alertRepository.updateAlert(alert); + + expect(response).to.equal(1); + }); + }); + + describe('createAlert', () => { + it('should create an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + const alert = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + const response = await alertRepository.createAlert(alert); + + expect(response).to.equal(1); + }); + }); + + describe('deleteAlert', () => { + it('should delete an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.deleteAlert(1); + + expect(response).to.equal(1); + }); + }); +}); diff --git a/api/src/repositories/alert-repository.ts b/api/src/repositories/alert-repository.ts new file mode 100644 index 0000000000..46ca743e86 --- /dev/null +++ b/api/src/repositories/alert-repository.ts @@ -0,0 +1,189 @@ +import { Knex } from 'knex'; +import SQL from 'sql-template-strings'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view'; +import { BaseRepository } from './base-repository'; + +/** + * A repository class for accessing alert data. + * + * @export + * @class AlertRepository + * @extends {BaseRepository} + */ +export class AlertRepository extends BaseRepository { + /** + * Builds query for all alert records without filtering any records, and adds a status field based on record_end_date + * + * @return {*} {Knex.QueryBuilder} + * @memberof AlertRepository + */ + _getAlertBaseQuery(): Knex.QueryBuilder { + const knex = getKnex(); + + return knex + .select( + 'alert.alert_id', + 'alert.name', + 'alert.message', + 'alert.alert_type_id', + 'alert.data', + 'alert.severity', + 'alert.record_end_date', + knex.raw(` + CASE + WHEN alert.record_end_date < NOW() THEN 'expired' + ELSE 'active' + END AS status + `) + ) + .from('alert') + .orderBy('alert.create_date', 'DESC'); + } + + /** + * Get alert records with optional filters applied + * + * @param {IAlertFilterObject} filterObject + * @return {*} {Promise} + * @memberof AlertRepository + */ + async getAlerts(filterObject: IAlertFilterObject): Promise { + const queryBuilder = this._getAlertBaseQuery(); + + if (filterObject.expiresAfter) { + queryBuilder.where((qb) => { + qb.whereRaw(`alert.record_end_date >= ?`, [filterObject.expiresAfter]).orWhereNull('alert.record_end_date'); + }); + } + + if (filterObject.expiresBefore) { + queryBuilder.where((qb) => { + qb.whereRaw(`alert.record_end_date < ?`, [filterObject.expiresBefore]); + }); + } + + if (filterObject.types && filterObject.types.length > 0) { + queryBuilder + .join('alert_type as at', 'at.alert_type_id', 'alert.alert_type_id') + .whereRaw('lower(at.name) = ANY(?)', [filterObject.types.map((type) => type.toLowerCase())]); + } + + const response = await this.connection.knex(queryBuilder, IAlert); + + return response.rows; + } + + /** + * Get a specific alert by its Id + * + * @param {number} alertId + * @return {*} {Promise} + * @memberof AlertRepository + */ + async getAlertById(alertId: number): Promise { + const queryBuilder = this._getAlertBaseQuery(); + + queryBuilder.where('alert_id', alertId); + + const response = await this.connection.knex(queryBuilder, IAlert); + + return response.rows[0]; + } + + /** + * Update system alert. + * + * @param {IAlertUpdateObject} alert + * @return {*} Promise + * @memberof AlertRepository + */ + async updateAlert(alert: IAlertUpdateObject): Promise { + const sqlStatement = SQL` + UPDATE alert + SET + name = ${alert.name}, + message = ${alert.message}, + alert_type_id = ${alert.alert_type_id}, + severity = ${alert.severity}, + data = ${JSON.stringify(alert.data)}::json, + record_end_date = ${alert.record_end_date} + WHERE + alert_id = ${alert.alert_id} + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to update alert', [ + 'AlertRepository->updateAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } + + /** + * Create system alert. + * + * @param {IAlertCreateObject} alert + * @return {*} Promise + * @memberof AlertRepository + */ + async createAlert(alert: IAlertCreateObject): Promise { + const sqlStatement = SQL` + INSERT INTO + alert (name, message, alert_type_id, data, severity, record_end_date) + VALUES + (${alert.name}, ${alert.message}, ${alert.alert_type_id}, ${JSON.stringify(alert.data)}, ${alert.severity}, ${ + alert.record_end_date + }) + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to create alert', [ + 'AlertRepository->createAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } + + /** + * Delete system alert. + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertRepository + */ + async deleteAlert(alertId: number): Promise { + const sqlStatement = SQL` + DELETE FROM + alert + WHERE + alert_id = ${alertId} + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to delete alert', [ + 'AlertRepository->deleteAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } +} diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 07c079cda4..228bd6bdee 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -25,6 +25,7 @@ const SurveyProgressCode = ICode.extend({ description: z.string() }); const MethodResponseMetricsCode = ICode.extend({ description: z.string() }); const AttractantCode = ICode.extend({ description: z.string() }); const ObservationSubcountSignCode = ICode.extend({ description: z.string() }); +const AlertTypeCode = ICode.extend({ description: z.string() }); export const IAllCodeSets = z.object({ management_action_type: CodeSet(), @@ -46,7 +47,8 @@ export const IAllCodeSets = z.object({ survey_progress: CodeSet(SurveyProgressCode.shape), method_response_metrics: CodeSet(MethodResponseMetricsCode.shape), attractants: CodeSet(AttractantCode.shape), - observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape) + observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape), + alert_types: CodeSet(AlertTypeCode.shape) }); export type IAllCodeSets = z.infer; @@ -466,4 +468,26 @@ export class CodeRepository extends BaseRepository { return response.rows; } + + /** + * Fetch alert type codes + * + * @return {*} + * @memberof CodeRepository + */ + async getAlertTypes() { + const sqlStatement = SQL` + SELECT + alert_type_id AS id, + name, + description + FROM alert_type + WHERE record_end_date IS null + ORDER BY name ASC; + `; + + const response = await this.connection.sql(sqlStatement, AlertTypeCode); + + return response.rows; + } } diff --git a/api/src/repositories/observation-repository/observation-repository.ts b/api/src/repositories/observation-repository/observation-repository.ts index 56576379b5..e35472f3fa 100644 --- a/api/src/repositories/observation-repository/observation-repository.ts +++ b/api/src/repositories/observation-repository/observation-repository.ts @@ -31,11 +31,11 @@ export const ObservationRecord = z.object({ survey_sample_site_id: z.number().nullable(), survey_sample_method_id: z.number().nullable(), survey_sample_period_id: z.number().nullable(), - latitude: z.number(), - longitude: z.number(), + latitude: z.number().nullable(), + longitude: z.number().nullable(), count: z.number(), - observation_time: z.string(), - observation_date: z.string(), + observation_time: z.string().nullable(), + observation_date: z.string().nullable(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), @@ -320,10 +320,10 @@ export class ObservationRepository extends BaseRepository { observation.survey_sample_method_id ?? 'NULL', observation.survey_sample_period_id ?? 'NULL', observation.count, - observation.latitude, - observation.longitude, - `'${observation.observation_date}'`, - `'${observation.observation_time}'`, + observation.latitude ?? 'NULL', + observation.longitude ?? 'NULL', + observation.observation_date ? `'${observation.observation_date}'` : 'NULL', + observation.observation_time ? `'${observation.observation_time}'` : 'NULL', observation.itis_tsn ?? 'NULL', observation.itis_scientific_name ? `'${observation.itis_scientific_name}'` : 'NULL' ].join(', ')})`; @@ -373,6 +373,9 @@ export class ObservationRepository extends BaseRepository { knex.raw("JSON_BUILD_OBJECT('type', 'Point', 'coordinates', JSON_BUILD_ARRAY(longitude, latitude)) as geometry") ) .from('survey_observation') + // TODO: For observations without lat/lon, get a location from the sampling site? + .whereNotNull('latitude') + .whereNotNull('longitude') .where('survey_id', surveyId); const response = await this.connection.knex(query, ObservationGeometryRecord); diff --git a/api/src/repositories/sample-location-repository.test.ts b/api/src/repositories/sample-location-repository/sample-location-repository.test.ts similarity index 84% rename from api/src/repositories/sample-location-repository.test.ts rename to api/src/repositories/sample-location-repository/sample-location-repository.test.ts index 58fb9fa2f1..06ad389054 100644 --- a/api/src/repositories/sample-location-repository.test.ts +++ b/api/src/repositories/sample-location-repository/sample-location-repository.test.ts @@ -3,8 +3,8 @@ import { describe } from 'mocha'; import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { getMockDBConnection } from '../__mocks__/db'; +import { ApiExecuteSQLError } from '../../errors/api-error'; +import { getMockDBConnection } from '../../__mocks__/db'; import { InsertSampleSiteRecord, SampleLocationRepository, UpdateSampleSiteRecord } from './sample-location-repository'; chai.use(sinonChai); @@ -82,6 +82,37 @@ describe('SampleLocationRepository', () => { }); }); + describe('getBasicSurveySampleLocationsBySiteIds', () => { + it('should successfully return sampling location records with basic data', async () => { + const mockRows = [{ survey_sample_site_id: 1, name: '', sample_methods: [] }]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; + const dbConnectionObj = getMockDBConnection({ knex: () => mockResponse }); + + const surveySampleSiteIds = [1, 2]; + const surveyId = 2; + + const repo = new SampleLocationRepository(dbConnectionObj); + const response = await repo.getBasicSurveySampleLocationsBySiteIds(surveyId, surveySampleSiteIds); + + expect(response).to.eql(mockRows); + }); + }); + + describe('getSampleLocationsGeometryBySurveyId', () => { + it('should return sample site geometries', async () => { + const mockRows = [{ survey_sample_site_id: 1 }]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; + const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const surveyId = 2; + + const repo = new SampleLocationRepository(dbConnectionObj); + const response = await repo.getSampleLocationsGeometryBySurveyId(surveyId); + + expect(response).to.eql(mockRows); + }); + }); + describe('updateSampleSite', () => { it('should update the record and return a single row', async () => { const mockRow = {}; diff --git a/api/src/repositories/sample-location-repository.ts b/api/src/repositories/sample-location-repository/sample-location-repository.ts similarity index 52% rename from api/src/repositories/sample-location-repository.ts rename to api/src/repositories/sample-location-repository/sample-location-repository.ts index e8731c44bd..d48872f16f 100644 --- a/api/src/repositories/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository/sample-location-repository.ts @@ -1,28 +1,47 @@ import { Feature } from 'geojson'; import SQL from 'sql-template-strings'; import { z } from 'zod'; -import { getKnex } from '../database/db'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; -import { ApiPaginationOptions } from '../zod-schema/pagination'; -import { BaseRepository } from './base-repository'; -import { SampleBlockRecord, UpdateSampleBlockRecord } from './sample-blocks-repository'; -import { SampleMethodRecord, UpdateSampleMethodRecord } from './sample-method-repository'; -import { SamplePeriodRecord } from './sample-period-repository'; -import { SampleStratumRecord, UpdateSampleStratumRecord } from './sample-stratums-repository'; +import { MethodTechniqueRecord } from '../../database-models/method_technique'; +import { SurveyBlockRecord } from '../../database-models/survey_block'; +import { SurveySampleBlockRecord } from '../../database-models/survey_sample_block'; +import { SurveySampleMethodRecord } from '../../database-models/survey_sample_method'; +import { SurveySamplePeriodRecord } from '../../database-models/survey_sample_period'; +import { SurveySampleSiteModel, SurveySampleSiteRecord } from '../../database-models/survey_sample_site'; +import { SurveySampleStratumRecord } from '../../database-models/survey_sample_stratum'; +import { SurveyStratumRecord } from '../../database-models/survey_stratum'; +import { getKnex } from '../../database/db'; +import { ApiExecuteSQLError } from '../../errors/api-error'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../../models/sampling-locations-view'; +import { generateGeometryCollectionSQL } from '../../utils/spatial-utils'; +import { ApiPaginationOptions } from '../../zod-schema/pagination'; +import { BaseRepository } from '../base-repository'; +import { SampleBlockRecord, UpdateSampleBlockRecord } from '../sample-blocks-repository'; +import { UpdateSampleMethodRecord } from '../sample-method-repository'; +import { SampleStratumRecord, UpdateSampleStratumRecord } from '../sample-stratums-repository'; +import { + getSamplingLocationBaseQuery, + makeFindSamplingMethodBaseQuery, + makeFindSamplingPeriodBaseQuery, + makeFindSamplingSiteBaseQuery +} from './utils'; /** - * An aggregate record that includes a single sample site, all of its child sample methods, and for each child sample - * method, all of its child sample periods. Also includes any survey blocks or survey stratums that this site belongs to. + * An aggregate record of a sample site without spatial data, including all of the child sample methods, + * and for each child sample method, all of its child sample periods. Also includes any survey blocks or survey + * stratums that this site belongs to. */ -export const SampleLocationRecord = z.object({ +export const SampleLocationNonSpatialRecord = z.object({ survey_sample_site_id: z.number(), survey_id: z.number(), name: z.string(), description: z.string().nullable(), - geojson: z.any(), + geometry_type: z.string(), sample_methods: z.array( - SampleMethodRecord.pick({ + SurveySampleMethodRecord.pick({ survey_sample_method_id: true, survey_sample_site_id: true, description: true, @@ -40,7 +59,7 @@ export const SampleLocationRecord = z.object({ ) }), sample_periods: z.array( - SamplePeriodRecord.pick({ + SurveySamplePeriodRecord.pick({ survey_sample_period_id: true, survey_sample_method_id: true, start_date: true, @@ -73,40 +92,135 @@ export const SampleLocationRecord = z.object({ }) ) }); -export type SampleLocationRecord = z.infer; +export type SampleLocationNonSpatialRecord = z.infer; /** - * A survey_sample_site record. + * Basic sample location data retrieved for supplementary observations data */ -export const SampleSiteRecord = z.object({ +export const SampleLocationBasicRecord = z.object({ survey_sample_site_id: z.number(), - survey_id: z.number(), name: z.string(), - description: z.string().nullable(), - geometry: z.null(), - geography: z.any(), - geojson: z.any(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() + sample_methods: z.array( + SurveySampleMethodRecord.pick({ + survey_sample_method_id: true, + survey_sample_site_id: true, + method_response_metric_id: true + }).extend( + z.object({ + technique: z.object({ + method_technique_id: z.number(), + name: z.string() + }), + sample_periods: z.array( + SurveySamplePeriodRecord.pick({ + survey_sample_period_id: true, + survey_sample_method_id: true, + start_date: true, + start_time: true, + end_date: true, + end_time: true + }) + ) + }).shape + ) + ) }); -export type SampleSiteRecord = z.infer; +export type SampleLocationBasicRecord = z.infer; + +/** + * An aggregate record that includes a single sample site, its location, all of its child sample methods, and for each child sample + * method, all of its child sample periods. Also includes any survey blocks or survey stratums that this site belongs to. + */ +export const SampleLocationRecord = SampleLocationNonSpatialRecord.omit({ geometry_type: true }).extend({ + geojson: z.any() +}); +export type SampleLocationRecord = z.infer; + +/** + * A survey_sample_site geometry + */ +export const SampleSiteGeometryRecord = z.object({ + survey_sample_site_id: z.number(), + geojson: z.any() +}); +export type SampleSiteGeometryRecord = z.infer; /** * Insert object for a single sample site record. */ -export type InsertSampleSiteRecord = Pick; +export type InsertSampleSiteRecord = Pick; /** * Update object for a single sample site record. */ export type UpdateSampleSiteRecord = Pick< - SampleSiteRecord, + SurveySampleSiteRecord, 'survey_sample_site_id' | 'survey_id' | 'name' | 'description' | 'geojson' >; +export const FindSampleSiteRecord = SurveySampleSiteRecord.pick({ + survey_sample_site_id: true, + survey_id: true, + name: true, + description: true +}).extend({ + geometry_type: z.string(), + blocks: z.array( + SurveySampleBlockRecord.pick({ + survey_sample_block_id: true, + survey_sample_site_id: true, + survey_block_id: true + }).merge( + SurveyBlockRecord.pick({ + name: true, + description: true + }) + ) + ), + stratums: z.array( + SurveySampleStratumRecord.pick({ + survey_sample_stratum_id: true, + survey_sample_site_id: true, + survey_stratum_id: true + }).merge( + SurveyStratumRecord.pick({ + name: true, + description: true + }) + ) + ) +}); + +export type FindSampleSiteRecord = z.infer; + +export const FindSamplePeriodRecord = SurveySamplePeriodRecord.pick({ + survey_sample_period_id: true, + survey_sample_method_id: true, + start_date: true, + start_time: true, + end_date: true, + end_time: true +}) + .extend({ + sample_method: SurveySampleMethodRecord.pick({ + method_response_metric_id: true + }) + }) + .extend({ + method_technique: MethodTechniqueRecord.pick({ + method_technique_id: true, + name: true + }) + }) + .extend({ + sample_site: SurveySampleSiteRecord.pick({ + survey_sample_site_id: true, + name: true + }) + }); + +export type FindSamplePeriodRecord = z.infer; + /** * Update object for a sample site record, including all associated methods and periods. */ @@ -133,14 +247,26 @@ export class SampleLocationRepository extends BaseRepository { * Gets a paginated set of Sample Locations for the given survey for a given Survey * * @param {number} surveyId - * @return {*} {Promise} + * @param {{ + * keyword?: string; + * sampleSiteIds?: number[]; + * pagination?: ApiPaginationOptions; + * }} [options] + * @return {*} {Promise} * @memberof SampleLocationRepository */ async getSampleLocationsForSurveyId( surveyId: number, - pagination?: ApiPaginationOptions - ): Promise { + options?: { + keyword?: string; + sampleSiteIds?: number[]; + pagination?: ApiPaginationOptions; + } + ): Promise { + const { keyword, sampleSiteIds, pagination } = options || {}; + const knex = getKnex(); + const queryBuilder = knex .queryBuilder() .with('w_method_technique_attractant', (qb) => { @@ -246,7 +372,7 @@ export class SampleLocationRepository extends BaseRepository { 'sss.survey_id', 'sss.name', 'sss.description', - 'sss.geojson', + knex.raw(`sss.geojson->'geometry'->>'type' as geometry_type`), knex.raw(` COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, COALESCE(wssb.blocks, '[]'::json) as blocks, @@ -258,7 +384,18 @@ export class SampleLocationRepository extends BaseRepository { .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') .where('sss.survey_id', surveyId); - if (pagination) { + if (sampleSiteIds) { + // Filter results by sample site IDs + queryBuilder.whereIn('sss.survey_sample_site_id', sampleSiteIds); + } + + if (keyword) { + // Filter results by keyword + queryBuilder.andWhere((qb) => { + qb.orWhere('sss.name', 'ilike', `%${keyword}%`).orWhere('sss.description', 'ilike', `%${keyword}%`); + }); + } else if (pagination) { + // Filter results by pagination queryBuilder.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); if (pagination.sort && pagination.order) { @@ -266,7 +403,7 @@ export class SampleLocationRepository extends BaseRepository { } } - const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + const response = await this.connection.knex(queryBuilder, SampleLocationNonSpatialRecord); return response.rows; } @@ -305,10 +442,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { + async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { const sqlStatement = SQL` SELECT sss.* @@ -320,7 +457,7 @@ export class SampleLocationRepository extends BaseRepository { sss.survey_sample_site_id = ${surveySampleSiteId} `; - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to get sample site by ID', [ @@ -341,38 +478,42 @@ export class SampleLocationRepository extends BaseRepository { * @memberof SampleLocationService */ async getSurveySampleLocationBySiteId(surveyId: number, surveySampleSiteId: number): Promise { + const knex = getKnex(); + const queryBuilder = getSamplingLocationBaseQuery(knex) + .where('sss.survey_id', surveyId) + .where('sss.survey_sample_site_id', surveySampleSiteId); + + const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get sample site by ID', [ + 'SampleLocationRepository->getSurveySampleLocationBySiteId', + 'rowCount was < 1, expected rowCount > 0' + ]); + } + + return response.rows[0]; + } + + /** + * Gets basic data for survey sample sites for supplementary observations data + * + * @param {number} surveyId + * @param {number[]} surveySampleSiteIds + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getBasicSurveySampleLocationsBySiteIds( + surveyId: number, + surveySampleSiteIds: number[] + ): Promise { 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'); + qb.select('mt.method_technique_id', 'mt.name').from({ mt: 'method_technique' }); }) .with('w_survey_sample_period', (qb) => { - // Aggregate sample periods into an array of objects qb.select( 'ssp.survey_sample_method_id', knex.raw(` @@ -380,8 +521,8 @@ export class SampleLocationRepository extends BaseRepository { 'survey_sample_period_id', ssp.survey_sample_period_id, 'survey_sample_method_id', ssp.survey_sample_method_id, 'start_date', ssp.start_date, - 'start_time', ssp.start_time, 'end_date', ssp.end_date, + 'start_time', ssp.start_time, 'end_time', ssp.end_time ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods`) ) @@ -389,18 +530,18 @@ export class SampleLocationRepository extends BaseRepository { .groupBy('ssp.survey_sample_method_id'); }) .with('w_survey_sample_method', (qb) => { - // Aggregate sample methods into an array of objects and include the corresponding sample periods qb.select( 'ssm.survey_sample_site_id', knex.raw(` json_agg(json_build_object( 'survey_sample_method_id', ssm.survey_sample_method_id, 'survey_sample_site_id', ssm.survey_sample_site_id, - - 'technique', wmt.method_technique, - 'description', ssm.description, - 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), - 'method_response_metric_id', ssm.method_response_metric_id + 'method_response_metric_id', ssm.method_response_metric_id, + 'technique', json_build_object( + 'method_technique_id', wmt.method_technique_id, + 'name', wmt.name + ), + 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json) )) as sample_methods`) ) .from({ ssm: 'survey_sample_method' }) @@ -408,79 +549,226 @@ export class SampleLocationRepository extends BaseRepository { .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) => { - // Aggregate sample blocks into an array of objects - qb.select( - 'ssb.survey_sample_site_id', - knex.raw(` - json_agg(json_build_object( - 'survey_sample_block_id', ssb.survey_sample_block_id, - 'survey_sample_site_id', ssb.survey_sample_site_id, - 'survey_block_id', ssb.survey_block_id, - 'name', sb.name, - 'description', sb.description - )) as blocks`) - ) - .from({ ssb: 'survey_sample_block' }) - .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') - .groupBy('ssb.survey_sample_site_id'); - }) - .with('w_survey_sample_stratum', (qb) => { - // Aggregate sample stratums into an array of objects - qb.select( - 'ssst.survey_sample_site_id', - knex.raw(` - json_agg(json_build_object( - 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, - 'survey_sample_site_id', ssst.survey_sample_site_id, - 'survey_stratum_id', ssst.survey_stratum_id, - 'name', ss.name, - 'description', ss.description - )) as stratums`) - ) - .from({ ssst: 'survey_sample_stratum' }) - .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') - .groupBy('ssst.survey_sample_site_id'); - }) - // Fetch sample sites and include the corresponding sample methods, blocks, and stratums .select( 'sss.survey_sample_site_id', - 'sss.survey_id', '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`) + COALESCE(wssm.sample_methods, '[]'::json) as sample_methods + `) ) .from({ sss: 'survey_sample_site' }) .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') - .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') - .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') .where('sss.survey_id', surveyId) - .where('sss.survey_sample_site_id', surveySampleSiteId); + .whereIn('sss.survey_sample_site_id', surveySampleSiteIds); - const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + const response = await this.connection.knex(queryBuilder, SampleLocationBasicRecord); if (!response.rowCount) { - throw new ApiExecuteSQLError('Failed to get sample site by ID', [ - 'SampleLocationRepository->getSurveySampleSiteById', + throw new ApiExecuteSQLError('Failed to get sample sites by IDs', [ + 'SampleLocationRepository->getBasicSurveySampleLocationsBySiteIds', 'rowCount was < 1, expected rowCount > 0' ]); } - return response.rows[0]; + return response.rows; + } + + /** + * Gets geometry for sampling sites in the survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async getSampleLocationsGeometryBySurveyId(surveyId: number): Promise { + const sqlStatement = SQL` + SELECT + survey_sample_site_id, + geojson + FROM + survey_sample_site + WHERE + survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, SampleSiteGeometryRecord); + + return response.rows; + } + + /** + * Retrieve the list of sites that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {Promise} A promise resolving to the list of sites. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findSites( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + const query = makeFindSamplingSiteBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + console.log(query.toSQL().toNative().sql); + console.log(query.toSQL().toNative().bindings); + + const response = await this.connection.knex(query, FindSampleSiteRecord); + + return response.rows; + } + + /** + * Retrieve the count of sites that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findSitesCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters + ): Promise { + const knex = getKnex(); + + const findSitesQuery = makeFindSamplingSiteBaseQuery(isUserAdmin, systemUserId, filterFields); + + const query = knex.from(findSitesQuery.as('fsq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(query, z.object({ count: z.number() })); + + return response.rows[0].count; + } + + /** + * Retrieve the list of methods that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {*} + * @memberof SampleLocationRepository + */ + async findMethods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + const query = makeFindSamplingMethodBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex( + query, + z.object({ + survey_sample_method_id: z.number(), + survey_sample_site_id: z.number(), + description: z.string().nullable(), // TODO NICK nullable? + method_response_metric_id: z.number(), + technique: z.object({ + method_technique_id: z.number(), + name: z.string(), + description: z.string().nullable(), // TODO NICK nullable? + attractants: z.array( + z.object({ + attractant_lookup_id: z.number() + }) + ) + }) + }) + ); + + return response.rows; + } + + /** + * Retrieve the list of periods that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findPeriods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + const query = makeFindSamplingPeriodBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex(query, FindSamplePeriodRecord); + + return response.rows; + } + + /** + * Retrieve the count of periods that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findPeriodsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters + ): Promise { + const knex = getKnex(); + + const findPeriodsQuery = makeFindSamplingPeriodBaseQuery(isUserAdmin, systemUserId, filterFields); + + const query = knex.from(findPeriodsQuery.as('fpq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(query, z.object({ count: z.number() })); + + return response.rows[0].count; } /** * Updates a survey sample site record. * * @param {UpdateSampleSiteRecord} sample - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async updateSampleSite(sample: UpdateSampleSiteRecord): Promise { + async updateSampleSite(sample: UpdateSampleSiteRecord): Promise { const sql = SQL` UPDATE survey_sample_site @@ -502,7 +790,7 @@ export class SampleLocationRepository extends BaseRepository { RETURNING *;`); - const response = await this.connection.sql(sql, SampleSiteRecord); + const response = await this.connection.sql(sql, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to update sample location record', [ @@ -522,10 +810,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {InsertSampleSiteRecord} sampleSite - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async insertSampleSite(surveyId: number, sampleSite: InsertSampleSiteRecord): Promise { + async insertSampleSite(surveyId: number, sampleSite: InsertSampleSiteRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_site ( survey_id, @@ -559,7 +847,7 @@ export class SampleLocationRepository extends BaseRepository { *; `); - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample location', [ @@ -576,10 +864,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { + async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { const sqlStatement = SQL` DELETE FROM survey_sample_site @@ -591,7 +879,7 @@ export class SampleLocationRepository extends BaseRepository { *; `; - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response?.rowCount) { throw new ApiExecuteSQLError('Failed to delete survey block record', [ diff --git a/api/src/repositories/sample-location-repository/utils.test.ts b/api/src/repositories/sample-location-repository/utils.test.ts new file mode 100644 index 0000000000..4fde53dbf6 --- /dev/null +++ b/api/src/repositories/sample-location-repository/utils.test.ts @@ -0,0 +1,37 @@ +import { expect } from 'chai'; +import { Knex } from 'knex'; +import { describe, it } from 'mocha'; +import { getKnex } from '../../database/db'; +import { getSamplingLocationBaseQuery } from './utils'; + +describe('getSamplingLocationBaseQuery', () => { + let knex: Knex; + + before(() => { + knex = getKnex(); + }); + + it('should return a query builder object', async () => { + const query = getSamplingLocationBaseQuery(knex); + + expect(query).to.be.an('object'); + expect(query.toString()).to.be.a('string'); + }); + + it('should select survey sample site fields correctly', async () => { + const query = getSamplingLocationBaseQuery(knex).toString(); + + expect(query).to.include('select "sss"."survey_sample_site_id", "sss"."survey_id"'); + expect(query).to.include("COALESCE(wssm.sample_methods, '[]'::json)"); + expect(query).to.include("COALESCE(wssb.blocks, '[]'::json)"); + expect(query).to.include("COALESCE(wssst.stratums, '[]'::json)"); + }); + + it('should join the correct tables', async () => { + const query = getSamplingLocationBaseQuery(knex).toString(); + + expect(query).to.include('left join "w_survey_sample_method" as "wssm"'); + expect(query).to.include('left join "w_survey_sample_block" as "wssb"'); + expect(query).to.include('left join "w_survey_sample_stratum" as "wssst"'); + }); +}); diff --git a/api/src/repositories/sample-location-repository/utils.ts b/api/src/repositories/sample-location-repository/utils.ts new file mode 100644 index 0000000000..4b5dd8de1b --- /dev/null +++ b/api/src/repositories/sample-location-repository/utils.ts @@ -0,0 +1,471 @@ +import { Knex } from 'knex'; +import { getKnex } from '../../database/db'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../../models/sampling-locations-view'; + +/** + * Get the base query for retrieving survey sample locations + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample locations + */ +export function getSamplingLocationBaseQuery(knex: Knex): Knex.QueryBuilder { + return ( + 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( + 'ssp.survey_sample_method_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_period_id', ssp.survey_sample_period_id, + 'survey_sample_method_id', ssp.survey_sample_method_id, + 'start_date', ssp.start_date, + '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`) + ) + .from({ ssp: 'survey_sample_period' }) + .groupBy('ssp.survey_sample_method_id'); + }) + .with('w_survey_sample_method', (qb) => { + // Aggregate sample methods into an array of objects and include the corresponding sample periods + qb.select( + 'ssm.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_method_id', ssm.survey_sample_method_id, + 'survey_sample_site_id', ssm.survey_sample_site_id, + + 'technique', wmt.method_technique, + 'description', ssm.description, + 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), + '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) => { + // Aggregate sample blocks into an array of objects + qb.select( + 'ssb.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_block_id', ssb.survey_sample_block_id, + 'survey_sample_site_id', ssb.survey_sample_site_id, + 'survey_block_id', ssb.survey_block_id, + 'name', sb.name, + 'description', sb.description + )) as blocks`) + ) + .from({ ssb: 'survey_sample_block' }) + .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') + .groupBy('ssb.survey_sample_site_id'); + }) + .with('w_survey_sample_stratum', (qb) => { + // Aggregate sample stratums into an array of objects + qb.select( + 'ssst.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, + 'survey_sample_site_id', ssst.survey_sample_site_id, + 'survey_stratum_id', ssst.survey_stratum_id, + 'name', ss.name, + 'description', ss.description + )) as stratums`) + ) + .from({ ssst: 'survey_sample_stratum' }) + .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') + .groupBy('ssst.survey_sample_site_id'); + }) + // Fetch sample sites and include the corresponding sample methods, blocks, and stratums + .select( + 'sss.survey_sample_site_id', + 'sss.survey_id', + '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`) + ) + .from({ sss: 'survey_sample_site' }) + .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') + ); +} +/** + * Get the base query for retrieving survey sample sites. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample sites + */ +export function getSamplingSiteBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .with('w_survey_sample_block', (qb) => { + // Aggregate sample blocks into an array of objects + qb.select( + 'ssb.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_block_id', ssb.survey_sample_block_id, + 'survey_sample_site_id', ssb.survey_sample_site_id, + 'survey_block_id', ssb.survey_block_id, + 'name', sb.name, + 'description', sb.description + )) as blocks`) + ) + .from({ ssb: 'survey_sample_block' }) + .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') + .groupBy('ssb.survey_sample_site_id'); + }) + .with('w_survey_sample_stratum', (qb) => { + // Aggregate sample stratums into an array of objects + qb.select( + 'ssst.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, + 'survey_sample_site_id', ssst.survey_sample_site_id, + 'survey_stratum_id', ssst.survey_stratum_id, + 'name', ss.name, + 'description', ss.description + )) as stratums`) + ) + .from({ ssst: 'survey_sample_stratum' }) + .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') + .groupBy('ssst.survey_sample_site_id'); + }) + .select( + 'sss.survey_sample_site_id', + 'sss.survey_id', + 'sss.name', + 'sss.description', + knex.raw(`sss.geojson->'geometry'->>'type' as geometry_type`), + knex.raw(` + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) + ) + .from({ sss: 'survey_sample_site' }) + .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id'); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample sites, including blocks and stratums. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample sites + */ +export function makeFindSamplingSiteBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingSitesQuery = knex.queryBuilder(); + + // Add the base query + getSamplingSitesQuery.modify(getSamplingSiteBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingSitesQuery.whereIn('sss.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingSitesQuery.andWhere('sss.survey_id', filterFields.survey_id); + } + + if (filterFields.keyword) { + // Filter by keyword + getSamplingSitesQuery.where((subqueryBuilder) => { + subqueryBuilder + .orWhere('sss.name', 'ilike', `%${filterFields.keyword}%`) + .orWhere('sss.description', 'ilike', `%${filterFields.keyword}%`); + }); + } + + return getSamplingSitesQuery; +} + +/** + * Get the base query for retrieving survey sample methods, including the technique and attractants. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample methods + */ +export function getSamplingMethodBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + 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'); + }) + .select( + 'ssm.survey_sample_method_id', + 'ssm.survey_sample_site_id', + 'ssm.description', + 'ssm.method_response_metric_id', + 'wmt.method_technique as technique' + ) + .from({ ssm: 'survey_sample_method' }) + .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id'); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample methods, including the technique and attractants. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample methods + */ +export function makeFindSamplingMethodBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingMethodsQuery = knex.queryBuilder(); + + // Add the base query + getSamplingMethodsQuery.modify(getSamplingMethodBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingMethodsQuery.whereIn('ssm.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingMethodsQuery.andWhere('ssm.survey_id', filterFields.survey_id); + } + + if (filterFields.sample_site_id) { + // Filter by a specific sample site id + getSamplingMethodsQuery.andWhere('ssm.survey_sample_site_id', filterFields.sample_site_id); + } + + if (filterFields.keyword) { + // Filter by keyword + getSamplingMethodsQuery.where((subqueryBuilder) => { + subqueryBuilder + .orWhere('ssm.description', 'ilike', `%${filterFields.keyword}%`) + .orWhere('wmt.technique->name', 'ilike', `%${filterFields.keyword}%`) + .orWhere('wmt.technique->description', 'ilike', `%${filterFields.keyword}%`); + }); + } + + return getSamplingMethodsQuery; +} + +/** + * Get the base query for retrieving survey sample periods. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample periods + */ +export function getSamplingPeriodBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .select( + 'ssp.survey_sample_period_id', + 'ssp.survey_sample_method_id', + 'ssp.start_date', + 'ssp.start_time', + 'ssp.end_date', + 'ssp.end_time', + knex.raw(` + json_build_object( + 'method_response_metric_id', ssm.method_response_metric_id + ) as sample_method`), + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name + ) as method_technique`), + knex.raw(` + json_build_object( + 'survey_sample_site_id', sss.survey_sample_site_id, + 'name', sss.name + ) as sample_site`) + ) + .from({ ssp: 'survey_sample_period' }) + .join('survey_sample_method as ssm', 'ssm.survey_sample_method_id', 'ssp.survey_sample_method_id') + .join('method_technique as mt', 'mt.method_technique_id', 'ssm.method_technique_id') + .join('survey_sample_site as sss', 'sss.survey_sample_site_id', 'ssm.survey_sample_site_id'); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample periods. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample periods + */ +export function makeFindSamplingPeriodBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingPeriodsQuery = knex.queryBuilder(); + + // Add the base query + getSamplingPeriodsQuery.modify(getSamplingPeriodBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingPeriodsQuery.whereIn('sss.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingPeriodsQuery.andWhere('sss.survey_id', filterFields.survey_id); + } + + if (filterFields.sample_site_id) { + // Filter by a specific sample site id + getSamplingPeriodsQuery.andWhere('ssp.survey_sample_site_id', filterFields.sample_site_id); + } + + if (filterFields.sample_method_id) { + // Filter by a specific sample method id + getSamplingPeriodsQuery.andWhere('ssp.survey_sample_method_id', filterFields.sample_method_id); + } + + return getSamplingPeriodsQuery; +} diff --git a/api/src/repositories/sample-method-repository.ts b/api/src/repositories/sample-method-repository.ts index 882044c70b..406c271f01 100644 --- a/api/src/repositories/sample-method-repository.ts +++ b/api/src/repositories/sample-method-repository.ts @@ -1,5 +1,6 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { SurveySampleMethodModel, SurveySampleMethodRecord } from '../database-models/survey_sample_method'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { BaseRepository } from './base-repository'; @@ -9,7 +10,7 @@ import { InsertSamplePeriodRecord, UpdateSamplePeriodRecord } from './sample-per * Insert object for a single sample method record. */ export type InsertSampleMethodRecord = Pick< - SampleMethodRecord, + SurveySampleMethodRecord, 'survey_sample_site_id' | 'method_technique_id' | 'description' | 'method_response_metric_id' > & { sample_periods: InsertSamplePeriodRecord[] }; @@ -17,7 +18,7 @@ export type InsertSampleMethodRecord = Pick< * Update object for a single sample method record. */ export type UpdateSampleMethodRecord = Pick< - SampleMethodRecord, + SurveySampleMethodRecord, | 'survey_sample_method_id' | 'survey_sample_site_id' | 'method_technique_id' @@ -25,27 +26,10 @@ export type UpdateSampleMethodRecord = Pick< | 'method_response_metric_id' > & { sample_periods: UpdateSamplePeriodRecord[] }; -/** - * A survey_sample_method record. - */ -export const SampleMethodRecord = z.object({ - survey_sample_method_id: z.number(), - survey_sample_site_id: z.number(), - method_technique_id: z.number(), - method_response_metric_id: z.number(), - description: z.string(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() -}); -export type SampleMethodRecord = z.infer; - /** * A survey_sample_method detail object. */ -export const SampleMethodDetails = SampleMethodRecord.extend({ +export const SampleMethodDetails = SurveySampleMethodModel.extend({ technique: z.object({ method_technique_id: z.number(), name: z.string(), @@ -67,13 +51,13 @@ export class SampleMethodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ async getSampleMethodsForSurveySampleSiteId( surveyId: number, surveySampleSiteId: number - ): Promise { + ): Promise { const sql = SQL` SELECT * @@ -94,7 +78,7 @@ export class SampleMethodRepository extends BaseRepository { ; `; - const response = await this.connection.sql(sql, SampleMethodRecord); + const response = await this.connection.sql(sql, SurveySampleMethodModel); return response.rows; } @@ -122,10 +106,10 @@ export class SampleMethodRepository extends BaseRepository { * updates a survey Sample method. * * @param {UpdateSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { + async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { const sql = SQL` UPDATE survey_sample_method ssm SET @@ -158,10 +142,10 @@ export class SampleMethodRepository extends BaseRepository { * Inserts a new survey Sample method. * * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { + async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_method ( survey_sample_site_id, @@ -178,7 +162,7 @@ export class SampleMethodRepository extends BaseRepository { *; `; - const response = await this.connection.sql(sqlStatement, SampleMethodRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleMethodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample method', [ @@ -195,10 +179,10 @@ export class SampleMethodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { + async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { const sqlStatement = SQL` DELETE FROM survey_sample_method USING survey_sample_site sss @@ -209,7 +193,7 @@ export class SampleMethodRepository extends BaseRepository { RETURNING survey_sample_method.*; `; - const response = await this.connection.sql(sqlStatement, SampleMethodRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleMethodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample method', [ diff --git a/api/src/repositories/sample-period-repository.ts b/api/src/repositories/sample-period-repository.ts index 15279a85aa..2c95683688 100644 --- a/api/src/repositories/sample-period-repository.ts +++ b/api/src/repositories/sample-period-repository.ts @@ -1,5 +1,6 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { SurveySamplePeriodModel, SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { BaseRepository } from './base-repository'; @@ -8,7 +9,7 @@ import { BaseRepository } from './base-repository'; * Insert object for a single sample period record. */ export type InsertSamplePeriodRecord = Pick< - SamplePeriodRecord, + SurveySamplePeriodRecord, 'survey_sample_method_id' | 'start_date' | 'end_date' | 'start_time' | 'end_time' >; @@ -16,28 +17,10 @@ export type InsertSamplePeriodRecord = Pick< * Update object for a single sample period record. */ export type UpdateSamplePeriodRecord = Pick< - SamplePeriodRecord, + SurveySamplePeriodRecord, 'survey_sample_period_id' | 'survey_sample_method_id' | 'start_date' | 'end_date' | 'start_time' | 'end_time' >; -/** - * A survey_sample_period record. - */ -export const SamplePeriodRecord = z.object({ - survey_sample_period_id: z.number(), - survey_sample_method_id: z.number(), - start_date: z.string(), - end_date: z.string(), - start_time: z.string().nullable(), - end_time: z.string().nullable(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() -}); -export type SamplePeriodRecord = z.infer; - /** * The full hierarchy of sample_* ids for a sample period. */ @@ -61,13 +44,13 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ async getSamplePeriodsForSurveyMethodId( surveyId: number, surveySampleMethodId: number - ): Promise { + ): Promise { const sql = SQL` SELECT ssp.* @@ -87,7 +70,7 @@ export class SamplePeriodRepository extends BaseRepository { sss.survey_id = ${surveyId} ORDER BY ssp.start_date, ssp.start_time;`; - const response = await this.connection.sql(sql, SamplePeriodRecord); + const response = await this.connection.sql(sql, SurveySamplePeriodModel); return response.rows; } @@ -140,10 +123,10 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {UpdateSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { + async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { const sql = SQL` UPDATE survey_sample_period AS ssp SET @@ -167,7 +150,7 @@ export class SamplePeriodRepository extends BaseRepository { `; - const response = await this.connection.sql(sql, SamplePeriodRecord); + const response = await this.connection.sql(sql, SurveySamplePeriodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to update sample period', [ @@ -183,10 +166,10 @@ export class SamplePeriodRepository extends BaseRepository { * Inserts a new survey Sample Period. * * @param {InsertSamplePeriodRecord} sample - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async insertSamplePeriod(sample: InsertSamplePeriodRecord): Promise { + async insertSamplePeriod(sample: InsertSamplePeriodRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_period ( survey_sample_method_id, @@ -204,7 +187,7 @@ export class SamplePeriodRepository extends BaseRepository { RETURNING *;`; - const response = await this.connection.sql(sqlStatement, SamplePeriodRecord); + const response = await this.connection.sql(sqlStatement, SurveySamplePeriodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample period', [ @@ -221,10 +204,10 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySamplePeriodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { + async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { const sqlStatement = SQL` DELETE ssp @@ -245,7 +228,7 @@ export class SamplePeriodRepository extends BaseRepository { ; `; - const response = await this.connection.sql(sqlStatement, SamplePeriodRecord); + const response = await this.connection.sql(sqlStatement, SurveySamplePeriodModel); if (!response?.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample period', [ @@ -261,10 +244,10 @@ export class SamplePeriodRepository extends BaseRepository { * Deletes multiple Survey Sample Periods for a given array of period ids. * * @param {number[]} periodsToDelete an array of period ids to delete - * @returns {*} {Promise} an array of promises for the deleted periods + * @returns {*} {Promise} an array of promises for the deleted periods * @memberof SamplePeriodRepository */ - async deleteSamplePeriods(surveyId: number, periodsToDelete: number[]): Promise { + async deleteSamplePeriods(surveyId: number, periodsToDelete: number[]): Promise { const knex = getKnex(); const sqlStatement = knex @@ -277,7 +260,7 @@ export class SamplePeriodRepository extends BaseRepository { .andWhere('survey_id', surveyId) .returning('ssp.*'); - const response = await this.connection.knex(sqlStatement, SamplePeriodRecord); + const response = await this.connection.knex(sqlStatement, SurveySamplePeriodModel); if (!response?.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample periods', [ diff --git a/api/src/repositories/survey-block-repository.test.ts b/api/src/repositories/survey-block-repository.test.ts index e8920174f9..607c669fe2 100644 --- a/api/src/repositories/survey-block-repository.test.ts +++ b/api/src/repositories/survey-block-repository.test.ts @@ -23,6 +23,7 @@ describe('SurveyBlockRepository', () => { survey_id: 1, name: '', description: '', + geojson: '', create_date: '', create_user: 1, update_date: '', @@ -82,7 +83,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: 1, survey_id: 1, name: 'Updated name', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: 1, + survey_id: 1, + name: 'Updated name', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; const response = await repo.updateSurveyBlock(block); expect(response.survey_block_id).to.be.eql(1); expect(response.name).to.be.eql('Updated name'); @@ -98,7 +110,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: null, + survey_id: 1, + name: 'new', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; try { await repo.updateSurveyBlock(block); expect.fail(); @@ -131,7 +154,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: null, + survey_id: 1, + name: 'new', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; const response = await repo.insertSurveyBlock(block); expect(response.name).to.be.eql('new'); @@ -143,18 +177,29 @@ describe('SurveyBlockRepository', () => { rows: [], rowCount: 0 } as any as Promise>; + const dbConnection = getMockDBConnection({ sql: () => mockResponse }); + const repo = new SurveyBlockRepository(dbConnection); + try { const block = { survey_block_id: null, survey_id: 1, name: null, - description: null + description: null, + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } } as any as PostSurveyBlock; + await repo.insertSurveyBlock(block); + expect.fail(); } catch (error) { expect((error as any as ApiExecuteSQLError).message).to.be.eq('Failed to insert survey block'); diff --git a/api/src/repositories/survey-block-repository.ts b/api/src/repositories/survey-block-repository.ts index 83fe2acdba..0d9f37f3fc 100644 --- a/api/src/repositories/survey-block-repository.ts +++ b/api/src/repositories/survey-block-repository.ts @@ -1,6 +1,8 @@ +import { Feature } from 'geojson'; import SQL from 'sql-template-strings'; import { z } from 'zod'; import { ApiExecuteSQLError } from '../errors/api-error'; +import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; import { BaseRepository } from './base-repository'; export interface PostSurveyBlock { @@ -8,24 +10,22 @@ export interface PostSurveyBlock { survey_id: number; name: string; description: string; + geojson: Feature; } // This describes the a row in the database for Survey Block export const SurveyBlockRecord = z.object({ survey_block_id: z.number(), + survey_id: z.number(), name: z.string(), description: z.string(), + geojson: z.any(), revision_count: z.number() }); export type SurveyBlockRecord = z.infer; // This describes the a row in the database for Survey Block -export const SurveyBlockRecordWithCount = z.object({ - survey_block_id: z.number(), - survey_id: z.number(), - name: z.string(), - description: z.string(), - revision_count: z.number(), +export const SurveyBlockRecordWithCount = SurveyBlockRecord.extend({ sample_block_count: z.number() }); export type SurveyBlockRecordWithCount = z.infer; @@ -52,6 +52,7 @@ export class SurveyBlockRepository extends BaseRepository { sb.survey_id, sb.name, sb.description, + sb.geojson, sb.revision_count, COUNT(ssb.survey_block_id)::integer AS sample_block_count FROM @@ -65,6 +66,7 @@ export class SurveyBlockRepository extends BaseRepository { sb.survey_id, sb.name, sb.description, + sb.geojson, sb.revision_count; `; @@ -86,15 +88,23 @@ export class SurveyBlockRepository extends BaseRepository { SET name = ${block.name}, description = ${block.description}, - survey_id=${block.survey_id} + survey_id = ${block.survey_id}, + geojson = ${JSON.stringify(block.geojson)}, + geography = public.geography( + public.ST_Force2D( + public.ST_SetSRID(`.append(generateGeometryCollectionSQL(block.geojson)).append(`, 4326) + ) + ) WHERE survey_block_id = ${block.survey_block_id} RETURNING survey_block_id, + survey_id, name, description, + geojson, revision_count; - `; + `); const response = await this.connection.sql(sql, SurveyBlockRecord); if (!response.rowCount) { @@ -119,18 +129,29 @@ export class SurveyBlockRepository extends BaseRepository { INSERT INTO survey_block ( survey_id, name, - description + description, + geojson, + geography ) VALUES ( ${block.survey_id}, ${block.name}, - ${block.description} - ) + ${block.description}, + ${JSON.stringify(block.geojson)}, + public.geography( + public.ST_Force2D( + public.ST_SetSRID(`.append(generateGeometryCollectionSQL(block.geojson)).append(`, 4326) + ) + ) + ) RETURNING survey_block_id, + survey_id, name, description, + geojson, revision_count; - `; + `); + const response = await this.connection.sql(sql, SurveyBlockRecord); if (!response.rowCount) { diff --git a/api/src/services/administrative-activity-service.ts b/api/src/services/administrative-activity-service.ts index e1306e090c..873a28bf01 100644 --- a/api/src/services/administrative-activity-service.ts +++ b/api/src/services/administrative-activity-service.ts @@ -106,7 +106,7 @@ export class AdministrativeActivityService extends DBService { */ async sendAccessRequestNotificationEmailToAdmin(): Promise { const gcnotifyService = new GCNotifyService(); - const url = `${this.APP_HOST}/login?redirect=${encodeURIComponent('admin/users')}`; + const url = `${this.APP_HOST}/login?redirect=${encodeURIComponent('admin/manage/users')}`; const hrefUrl = `[click here.](${url})`; return gcnotifyService.sendEmailGCNotification(this.ADMIN_EMAIL, { diff --git a/api/src/services/alert-service.test.ts b/api/src/services/alert-service.test.ts new file mode 100644 index 0000000000..3c99559c01 --- /dev/null +++ b/api/src/services/alert-service.test.ts @@ -0,0 +1,130 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertSeverity } from '../models/alert-view'; +import { AlertRepository } from '../repositories/alert-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { AlertService } from './alert-service'; + +chai.use(sinonChai); + +describe('AlertService', () => { + let alertService: AlertService; + let mockAlertRepository: sinon.SinonStubbedInstance; + + afterEach(() => { + sinon.restore(); + }); + + beforeEach(() => { + const dbConnection = getMockDBConnection(); + alertService = new AlertService(dbConnection); + mockAlertRepository = sinon.createStubInstance(AlertRepository); + alertService.alertRepository = mockAlertRepository; // Inject the mocked repository + }); + + describe('getAlerts', () => { + it('returns an array of alerts', async () => { + const mockAlerts: IAlert[] = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + } + ]; + + mockAlertRepository.getAlerts.resolves(mockAlerts); + + const filterObject: IAlertFilterObject = {}; // Define your filter object as needed + + const response = await alertService.getAlerts(filterObject); + + expect(response).to.eql(mockAlerts); + expect(mockAlertRepository.getAlerts).to.have.been.calledOnceWith(filterObject); + }); + }); + + describe('getAlertById', () => { + it('returns a specific alert by its Id', async () => { + const mockAlert: IAlert = { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + }; + + mockAlertRepository.getAlertById.resolves(mockAlert); + + const response = await alertService.getAlertById(1); + + expect(response).to.eql(mockAlert); + expect(mockAlertRepository.getAlertById).to.have.been.calledOnceWith(1); + }); + }); + + describe('createAlert', () => { + it('creates an alert and returns its Id', async () => { + const mockAlertId = 1; + const mockAlert: IAlertCreateObject = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + mockAlertRepository.createAlert.resolves(mockAlertId); + + const response = await alertService.createAlert(mockAlert); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.createAlert).to.have.been.calledOnceWith(mockAlert); + }); + }); + + describe('updateAlert', () => { + it('updates an alert and returns its Id', async () => { + const mockAlertId = 1; + const mockAlert: IAlert = { + alert_id: mockAlertId, + name: 'Updated Alert', + message: 'Updated message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + }; + + mockAlertRepository.updateAlert.resolves(mockAlertId); + + const response = await alertService.updateAlert(mockAlert); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.updateAlert).to.have.been.calledOnceWith(mockAlert); + }); + }); + + describe('deleteAlert', () => { + it('deletes an alert and returns its Id', async () => { + const mockAlertId = 1; + mockAlertRepository.deleteAlert.resolves(mockAlertId); + + const response = await alertService.deleteAlert(mockAlertId); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.deleteAlert).to.have.been.calledOnceWith(mockAlertId); + }); + }); +}); diff --git a/api/src/services/alert-service.ts b/api/src/services/alert-service.ts new file mode 100644 index 0000000000..7c8174356a --- /dev/null +++ b/api/src/services/alert-service.ts @@ -0,0 +1,69 @@ +import { IDBConnection } from '../database/db'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view'; +import { AlertRepository } from '../repositories/alert-repository'; +import { DBService } from './db-service'; + +export class AlertService extends DBService { + alertRepository: AlertRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.alertRepository = new AlertRepository(connection); + } + + /** + * Get all alert records, including deactivated alerts + * + * @param {IAlertFilterObject} filterObject + * @return {*} Promise + * @memberof AlertService + */ + async getAlerts(filterObject: IAlertFilterObject): Promise { + return this.alertRepository.getAlerts(filterObject); + } + + /** + * Get a specific alert by its ID + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertService + */ + async getAlertById(alertId: number): Promise { + return this.alertRepository.getAlertById(alertId); + } + + /** + * Create a system alert. + * + * @param {IAlertCreateObjectt} alert + * @return {*} Promise + * @memberof AlertService + */ + async createAlert(alert: IAlertCreateObject): Promise { + return this.alertRepository.createAlert(alert); + } + + /** + * Update a system alert. + * + * @param {IAlertUpdateObject} alert + * @return {*} Promise + * @memberof AlertService + */ + async updateAlert(alert: IAlertUpdateObject): Promise { + return this.alertRepository.updateAlert(alert); + } + + /** + * Delete a system alert. + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertService + */ + async deleteAlert(alertId: number): Promise { + return this.alertRepository.deleteAlert(alertId); + } +} diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 9abf57fcee..12237b08bc 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -45,7 +45,8 @@ describe('CodeService', () => { 'sample_methods', 'survey_progress', 'method_response_metrics', - 'observation_subcount_signs' + 'observation_subcount_signs', + 'alert_types' ); }); }); diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 715193a3ba..9975056138 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -44,7 +44,8 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs + observation_subcount_signs, + alert_types ] = await Promise.all([ await this.codeRepository.getManagementActionType(), await this.codeRepository.getFirstNations(), @@ -65,7 +66,8 @@ export class CodeService extends DBService { await this.codeRepository.getSurveyProgress(), await this.codeRepository.getMethodResponseMetrics(), await this.codeRepository.getAttractants(), - await this.codeRepository.getObservationSubcountSigns() + await this.codeRepository.getObservationSubcountSigns(), + await this.codeRepository.getAlertTypes() ]); return { @@ -88,7 +90,8 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs + observation_subcount_signs, + alert_types }; } diff --git a/api/src/services/import-services/capture/import-captures-strategy.test.ts b/api/src/services/import-services/capture/import-captures-strategy.test.ts index b7df1d6eff..ec5d8059f3 100644 --- a/api/src/services/import-services/capture/import-captures-strategy.test.ts +++ b/api/src/services/import-services/capture/import-captures-strategy.test.ts @@ -22,18 +22,18 @@ describe('import-captures-service', () => { I1: { t: 's', v: 'RELEASE_LONGITUDE' }, J1: { t: 's', v: 'RELEASE_COMMENT' }, K1: { t: 's', v: 'CAPTURE_COMMENT' }, - A2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A2: { t: 's', v: '2024-10-11' }, B2: { t: 's', v: 'Carl' }, C2: { t: 's', v: '10:10:10' }, D2: { t: 'n', w: '90', v: 90 }, E2: { t: 'n', w: '100', v: 100 }, - F2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + F2: { t: 's', v: '2024-10-10' }, G2: { t: 's', v: '9:09' }, H2: { t: 'n', w: '90', v: 90 }, I2: { t: 'n', w: '90', v: 90 }, J2: { t: 's', v: 'release' }, K2: { t: 's', v: 'capture' }, - A3: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A3: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, B3: { t: 's', v: 'Carlita' }, D3: { t: 'n', w: '90', v: 90 }, E3: { t: 'n', w: '100', v: 100 }, diff --git a/api/src/services/import-services/marking/import-markings-strategy.test.ts b/api/src/services/import-services/marking/import-markings-strategy.test.ts index 4cfc6e192a..074cc472f2 100644 --- a/api/src/services/import-services/marking/import-markings-strategy.test.ts +++ b/api/src/services/import-services/marking/import-markings-strategy.test.ts @@ -24,7 +24,7 @@ describe('ImportMarkingsStrategy', () => { G1: { t: 's', v: 'PRIMARY_COLOUR' }, H1: { t: 's', v: 'SECONDARY_COLOUR' }, I1: { t: 's', v: 'DESCRIPTION' }, // testing alias works - A2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A2: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, B2: { t: 's', v: 'Carl' }, C2: { t: 's', v: '10:10:12' }, D2: { t: 's', v: 'Left ear' }, // testing case insensitivity @@ -100,8 +100,8 @@ describe('ImportMarkingsStrategy', () => { try { const data = await importCSV(new MediaFile('test', 'test', 'test' as unknown as Buffer), strategy); expect(data).to.deep.equal(2); - } catch (err: any) { - expect.fail(); + } catch (error: any) { + expect.fail(error); } }); }); diff --git a/api/src/services/import-services/measurement/import-measurements-strategy.test.ts b/api/src/services/import-services/measurement/import-measurements-strategy.test.ts index 9503e0569c..c60c76e989 100644 --- a/api/src/services/import-services/measurement/import-measurements-strategy.test.ts +++ b/api/src/services/import-services/measurement/import-measurements-strategy.test.ts @@ -26,12 +26,12 @@ describe('importMeasurementsStrategy', () => { E1: { t: 's', v: 'skull condition' }, F1: { t: 's', v: 'unknown' }, A2: { t: 's', v: 'carl' }, - B2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B2: { t: 's', v: '2024-10-10' }, C2: { t: 's', v: '10:10:12' }, D2: { t: 'n', w: '2', v: 2 }, E2: { t: 'n', w: '0', v: 'good' }, A3: { t: 's', v: 'carlita' }, - B3: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B3: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, C3: { t: 's', v: '10:10:12' }, D3: { t: 'n', w: '2', v: 2 }, E3: { t: 'n', w: '0', v: 'good' }, @@ -54,7 +54,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'carl', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:12' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:12' }] } as any ], [ @@ -63,7 +63,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'B', animal_id: 'carlita', itis_tsn: 'tsn2', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:12' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:12' }] } as any ] ]); @@ -143,8 +143,8 @@ describe('importMeasurementsStrategy', () => { } ] }); - } catch (e: any) { - expect.fail(); + } catch (error: any) { + expect.fail(error); } }); }); @@ -183,7 +183,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias', @@ -191,7 +191,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -205,7 +205,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias2', @@ -213,7 +213,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias2', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -227,7 +227,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias', @@ -235,7 +235,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '11/11/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-11-11', capture_time: '10:10:10' }] } as any ] ]); @@ -344,7 +344,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -372,7 +372,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -408,7 +408,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -444,7 +444,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -480,7 +480,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -514,7 +514,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: 'qualitative failed', optionId: undefined }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -548,7 +548,7 @@ describe('importMeasurementsStrategy', () => { ); validateQuantitativeMeasurementCellStub.returns({ error: 'quantitative failed', value: undefined }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -573,7 +573,7 @@ describe('importMeasurementsStrategy', () => { getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: 'C' }); getTsnMeasurementMapStub.resolves(new Map([['tsn1', { quantitative: [], qualitative: [] } as any]])); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -607,7 +607,7 @@ describe('importMeasurementsStrategy', () => { ]) ); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); diff --git a/api/src/services/import-services/utils/datetime.test.ts b/api/src/services/import-services/utils/datetime.test.ts index 24e0452a13..71eb5ac1c6 100644 --- a/api/src/services/import-services/utils/datetime.test.ts +++ b/api/src/services/import-services/utils/datetime.test.ts @@ -25,14 +25,56 @@ describe('formatTimeString', () => { }); describe('areDatesEqual', () => { + const date1 = '2024-10-11'; + const date2 = '24-10-11'; + const date3 = '11-10-2024'; + const date4 = '11-10-24'; + + const date5 = '2024/10/11'; + const date6 = '11/10/2024'; + const date7 = '24/10/11'; + const date8 = '11/10/24'; + it('should be true when dates are equal in all formats', () => { - expect(areDatesEqual('10-10-2024', '10-10-2024')).to.be.true; - expect(areDatesEqual('10-10-2024', '10/10/2024')).to.be.true; - expect(areDatesEqual('10-10-2024', '10/10/24')).to.be.true; - expect(areDatesEqual('10-10-2024', '2024-10-10')).to.be.true; + expect(areDatesEqual(date1, date5)).to.be.true; + + expect(areDatesEqual(date3, date4)).to.be.true; + expect(areDatesEqual(date3, date6)).to.be.true; + expect(areDatesEqual(date3, date8)).to.be.true; + + expect(areDatesEqual(date4, date6)).to.be.true; + expect(areDatesEqual(date4, date8)).to.be.true; + + expect(areDatesEqual(date6, date8)).to.be.true; }); it('should fail if dates are incorrect format', () => { - expect(areDatesEqual('BAD DATE BAD', '10/10/2024')).to.be.false; + expect(areDatesEqual(date1, date2)).to.be.false; + expect(areDatesEqual(date1, date3)).to.be.false; + expect(areDatesEqual(date1, date4)).to.be.false; + expect(areDatesEqual(date1, date6)).to.be.false; + expect(areDatesEqual(date1, date7)).to.be.false; + expect(areDatesEqual(date1, date8)).to.be.false; + expect(areDatesEqual(date2, date3)).to.be.false; + + expect(areDatesEqual(date2, date4)).to.be.false; + expect(areDatesEqual(date2, date5)).to.be.false; + expect(areDatesEqual(date2, date6)).to.be.false; + expect(areDatesEqual(date2, date7)).to.be.false; + expect(areDatesEqual(date2, date8)).to.be.false; + + expect(areDatesEqual(date3, date5)).to.be.false; + expect(areDatesEqual(date3, date7)).to.be.false; + + expect(areDatesEqual(date4, date5)).to.be.false; + expect(areDatesEqual(date4, date7)).to.be.false; + + expect(areDatesEqual(date5, date6)).to.be.false; + expect(areDatesEqual(date5, date7)).to.be.false; + expect(areDatesEqual(date5, date8)).to.be.false; + + expect(areDatesEqual(date6, date7)).to.be.false; + + expect(areDatesEqual(date7, date8)).to.be.false; }); }); diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index 245df3f0b2..016850d9b3 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -8,6 +8,7 @@ import { import * as file_utils from '../utils/file-utils'; import { getMockDBConnection } from '../__mocks__/db'; import { ObservationService } from './observation-service'; +import { SampleLocationService } from './sample-location-service'; import { SubCountService } from './subcount-service'; chai.use(sinonChai); @@ -73,7 +74,8 @@ describe('ObservationService', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }; const getSurveyObservationsStub = sinon @@ -92,6 +94,10 @@ describe('ObservationService', () => { .stub(SubCountService.prototype, 'getEnvironmentTypeDefinitionsForSurvey') .resolves({ qualitative_environments: [], quantitative_environments: [] }); + const getSampleLocationsForSurveyIdStub = sinon + .stub(SampleLocationService.prototype, 'getSampleLocationsForSurveyId') + .resolves([]); + const surveyId = 1; const observationService = new ObservationService(mockDBConnection); @@ -104,6 +110,7 @@ describe('ObservationService', () => { expect(getSurveyObservationCountStub).to.be.calledOnceWith(surveyId); expect(getMeasurementTypeDefinitionsForSurveyStub).to.be.calledOnceWith(surveyId); expect(getEnvironmentTypeDefinitionsForSurveyStub).to.be.calledOnceWith(surveyId); + expect(getSampleLocationsForSurveyIdStub).to.be.calledOnceWith(surveyId); expect(response).to.eql({ surveyObservations: [ { diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 4be3cc7e36..1f5b0f0c99 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -1,3 +1,5 @@ +import dayjs from 'dayjs'; +import { DefaultDateFormat } from '../constants/dates'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; import { IObservationAdvancedFilters } from '../models/observation-view'; @@ -22,6 +24,7 @@ import { InsertObservationSubCountQualitativeMeasurementRecord, InsertObservationSubCountQuantitativeMeasurementRecord } from '../repositories/observation-subcount-measurement-repository'; +import { SampleLocationRecord } from '../repositories/sample-location-repository/sample-location-repository'; import { SamplePeriodHierarchyIds } from '../repositories/sample-period-repository'; import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; @@ -65,10 +68,12 @@ import { DBService } from './db-service'; import { ObservationSubCountEnvironmentService } from './observation-subcount-environment-service'; import { ObservationSubCountMeasurementService } from './observation-subcount-measurement-service'; import { PlatformService } from './platform-service'; +import { SampleLocationService } from './sample-location-service'; import { SamplePeriodService } from './sample-period-service'; import { SubCountService } from './subcount-service'; const defaultLog = getLogger('services/observation-service'); +const defaultSubcountSign = 'direct sighting'; /** * An XLSX validation config for the standard columns of an Observation CSV. @@ -79,11 +84,14 @@ const defaultLog = getLogger('services/observation-service'); export const observationStandardColumnValidator = { ITIS_TSN: { type: 'number', aliases: CSV_COLUMN_ALIASES.ITIS_TSN }, COUNT: { type: 'number' }, - OBSERVATION_SUBCOUNT_SIGN: { type: 'code', aliases: CSV_COLUMN_ALIASES.OBSERVATION_SUBCOUNT_SIGN }, - DATE: { type: 'date' }, - TIME: { type: 'string' }, - LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE }, - LONGITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LONGITUDE }, + OBSERVATION_SUBCOUNT_SIGN: { type: 'code', aliases: CSV_COLUMN_ALIASES.OBSERVATION_SUBCOUNT_SIGN, optional: true }, + DATE: { type: 'date', optional: true }, + TIME: { type: 'string', optional: true }, + LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE, optional: true }, + LONGITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LONGITUDE, optional: true }, + SAMPLING_SITE: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_SITE, optional: true }, + SAMPLING_METHOD: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_METHOD, optional: true }, + SAMPLING_PERIOD: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_PERIOD, optional: true }, COMMENT: { type: 'string', aliases: CSV_COLUMN_ALIASES.COMMENT, optional: true } } satisfies IXLSXCSVValidator; @@ -128,8 +136,13 @@ export type ObservationMeasurementSupplementaryData = { quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; }; +export type ObservationSamplingSupplementaryData = { + sample_sites: SampleLocationRecord[]; +}; + export type AllObservationSupplementaryData = ObservationCountSupplementaryData & - ObservationMeasurementSupplementaryData; + ObservationMeasurementSupplementaryData & + ObservationSamplingSupplementaryData; export class ObservationService extends DBService { observationRepository: ObservationRepository; @@ -298,16 +311,22 @@ export class ObservationService extends DBService { surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; supplementaryObservationData: AllObservationSupplementaryData; }> { + const sampleLocationService = new SampleLocationService(this.connection); const surveyObservations = await this.observationRepository.getSurveyObservationsWithSamplingDataWithAttributesData( surveyId, pagination ); + const sampleSiteIds = surveyObservations + .filter((obs) => obs.survey_sample_site_id) + .map((observation) => observation.survey_sample_site_id!); + // Get supplementary observation data const observationCount = await this.observationRepository.getSurveyObservationCount(surveyId); const subCountService = new SubCountService(this.connection); const measurementTypeDefinitions = await subCountService.getMeasurementTypeDefinitionsForSurvey(surveyId); const environmentTypeDefinitions = await subCountService.getEnvironmentTypeDefinitionsForSurvey(surveyId); + const sampleLocations = await sampleLocationService.getSampleLocationsForSurveyId(surveyId, { sampleSiteIds }); return { surveyObservations: surveyObservations, @@ -316,7 +335,8 @@ export class ObservationService extends DBService { qualitative_measurements: measurementTypeDefinitions.qualitative_measurements, quantitative_measurements: measurementTypeDefinitions.quantitative_measurements, qualitative_environments: environmentTypeDefinitions.qualitative_environments, - quantitative_environments: environmentTypeDefinitions.quantitative_environments + quantitative_environments: environmentTypeDefinitions.quantitative_environments, + sample_sites: sampleLocations } }; } @@ -545,9 +565,7 @@ export class ObservationService extends DBService { }); // Fetch all measurement type definitions from Critterbase for all unique TSNs - const tsns = worksheetRowObjects.map((row) => - String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']) - ); + const tsns = worksheetRowObjects.map((row) => String(getColumnCellValue(row, 'ITIS_TSN').cell)); const tsnMeasurementTypeDefinitionMap = await getTsnMeasurementTypeDefinitionMap(tsns, critterBaseService); @@ -556,7 +574,7 @@ export class ObservationService extends DBService { const measurementsToValidate: IMeasurementDataToValidate[] = worksheetRowObjects.flatMap((row) => { return measurementColumnNames.map((columnName) => ({ - tsn: String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), + tsn: String(getColumnCellValue(row, 'ITIS_TSN').cell), key: columnName, value: row[columnName] })); @@ -601,8 +619,15 @@ export class ObservationService extends DBService { throw new Error('Failed to process file for importing observations. Environment column validator failed.'); } - // ----------------------------------------------------------------------------------------- + // SAMPLING INFORMATION ----------------------------------------------------------------------------------------- + const sampleLocationService = new SampleLocationService(this.connection); + // Get sampling information for the survey to later validate + const samplingLocations = await sampleLocationService.getSampleLocationsForSurveyId(surveyId); + + // -------------------------------------------------------------------------------------------------------------- + + // SamplePeriodHierarchyIds is only for when all records are being assigned to the same sampling period let samplePeriodHierarchyIds: SamplePeriodHierarchyIds; if (options?.surveySamplePeriodId) { @@ -613,15 +638,19 @@ export class ObservationService extends DBService { ); } + // Get subcount sign options and default option for when sign is null + const codeMap = new Map( + codeTypeDefinitions.OBSERVATION_SUBCOUNT_SIGN.map((option) => [option.name.toLowerCase(), option.id]) + ); + const defaultSubcountSignId = codeMap.get(defaultSubcountSign) || null; + // Merge all the table rows into an array of InsertUpdateObservations[] const newRowData: InsertUpdateObservations[] = worksheetRowObjects.map((row) => { - // TODO: This observationSubcountSignId logic is specifically catered to the observation_subcount_signs code set, - // as it is the only code set currently being used in the observation CSVs, and is required. This logic will need - // to be updated to be more generic if other code sets are used in the future, or if they can be nullable. - const observationSubcountSignId = codeTypeDefinitions.OBSERVATION_SUBCOUNT_SIGN.find( - (option) => - option.name.toLowerCase() === getColumnCellValue(row, 'OBSERVATION_SUBCOUNT_SIGN')?.cell?.toLowerCase() - )?.id; + const observationSubcountSignId = this._getCodeIdFromCellValue( + getColumnCellValue(row, 'OBSERVATION_SUBCOUNT_SIGN').cell, + codeMap, + defaultSubcountSignId + ); const newSubcount: InsertSubCount = { observation_subcount_id: null, @@ -650,14 +679,41 @@ export class ObservationService extends DBService { newSubcount.qualitative_environments = environments.qualitative_environments; newSubcount.quantitative_environments = environments.quantitative_environments; + // If surveySamplePeriodId was included in the initial request, assign all rows to that sampling period + if (options?.surveySamplePeriodId) { + return { + standardColumns: { + survey_id: surveyId, + itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, + itis_scientific_name: null, + survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, + survey_sample_method_id: samplePeriodHierarchyIds?.survey_sample_method_id ?? null, + survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, + latitude: getColumnCellValue(row, 'LATITUDE').cell as number, + longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, + count: getColumnCellValue(row, 'COUNT').cell as number, + observation_time: getColumnCellValue(row, 'TIME').cell as string, + observation_date: getColumnCellValue(row, 'DATE').cell as string + }, + subcounts: [newSubcount] + }; + } + + // PROCESS AND VALIDATE SAMPLING INFORMATION ----------------------------------------------------------------------------------------- + const samplingData = this._pullSamplingDataFromWorksheetRowObject(row, samplingLocations); + + if (!samplingData && getColumnCellValue(row, 'SAMPLING_SITE').cell) { + throw new Error('Failed to process file for importing observations. Sampling data validator failed.'); + } + return { standardColumns: { survey_id: surveyId, itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, itis_scientific_name: null, - survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, - survey_sample_method_id: samplePeriodHierarchyIds?.survey_sample_method_id ?? null, - survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, + survey_sample_site_id: samplingData?.sampleSiteId ?? null, + survey_sample_method_id: samplingData?.sampleMethodId ?? null, + survey_sample_period_id: samplingData?.samplePeriodId ?? null, latitude: getColumnCellValue(row, 'LATITUDE').cell as number, longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, count: getColumnCellValue(row, 'COUNT').cell as number, @@ -799,6 +855,136 @@ export class ObservationService extends DBService { return foundEnvironments; } + /** + * Extracts sampling data from the worksheet row object and maps site names, method techniques, and periods + * to their respective IDs using the provided samplingLocations. + * + * @param {Record} row - The current row of the worksheet being processed. + * @param {SampleLocationRecord[]} samplingLocations - The available sampling locations for the survey, used for mapping names to IDs. + * @return { { sampleSiteId: number, sampleMethodId: number, samplePeriodId: number } | null } The sampling data with IDs, or null if no valid data is found. + */ + _pullSamplingDataFromWorksheetRowObject( + row: Record, + samplingLocations: SampleLocationRecord[] + ): { sampleSiteId: number; sampleMethodId: number; samplePeriodId: number } | null { + // Extract site, method, and period data from the row + const siteName = getColumnCellValue(row, 'SAMPLING_SITE').cell as string | null; + const techniqueName = getColumnCellValue(row, 'SAMPLING_METHOD').cell as string | null; + const period = getColumnCellValue(row, 'SAMPLING_PERIOD').cell as string | null; + + if (!siteName) { + return null; + } + + // Find the site record by name + const siteRecord = samplingLocations.find((site) => site.name.toLowerCase() === siteName.toLowerCase()); + + // If there is no site, exit early because a site is required when specifying any sampling information for the row. + if (!siteRecord) { + return null; + } + + let methodRecord = null; + + // Find the method record by technique name + if (techniqueName) { + methodRecord = siteRecord.sample_methods.find( + (method) => method.technique.name.toLowerCase() === techniqueName.toLowerCase() + ); + } + + // If we failed to find a method record based on technique name, we will check whether that site has just 1 technique. + // If the site has 1 technique, we will assume that the row belongs to that technique. + // This is a convenience for users because they only need to specify the sampling site for sites with 1 technique. + if (siteRecord.sample_methods.length === 1) { + methodRecord = siteRecord.sample_methods[0]; + } + + // If there are multiple techniques for the site but no technique specified in the row, + // exit early because we cannot determine which method to use. + if (!methodRecord) { + return null; + } + + // If period is specified, parse the row value and find a matching record + if (period) { + // Format the period timestamp data + const [startDate, endDate] = period.split('-').map((date: string) => dayjs(date).format(DefaultDateFormat)); + const startTime = dayjs(period.split('-')[0]).format('HH:mm:ss'); + const endTime = dayjs(period.split('-')[1]).format('HH:mm:ss'); + + // Find matching periods by date + const matchingPeriods = methodRecord.sample_periods.filter( + (p) => p.start_date === startDate && p.end_date === endDate + ); + + // Return if exactly one period matches the date, + // meaning that we have successfully determined a single site, method, and period Id for each row. + if (matchingPeriods.length === 1) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: matchingPeriods[0].survey_sample_period_id + }; + } + + // If multiple periods match by date, try to match also by time + const matchingPeriod = matchingPeriods.find((p) => p.start_time === startTime && p.end_time === endTime); + + if (matchingPeriod) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: matchingPeriod.survey_sample_period_id + }; + } + } + + // If period is not specified, infer it from the row data + const observationDate = getColumnCellValue(row, 'DATE').cell as string | null; + const observationTime = getColumnCellValue(row, 'TIME').cell as string | null; + + const formattedDate = dayjs(observationDate); + const formattedTime = dayjs(`${observationDate} ${observationTime}`).format('HH:mm:ss'); + + // TODO: Fix timezone of the observation date. Observation date is assumed to be UTC instead of local time, + // so the observation date being imported from the csv is incorrectly offset by 1 day. eg. "July 28, 2024" is + // imported at July 27, 2024 + // + // If no periods match by date/time but the site and method is given, check if the observation date falls within a period. + // If true, we will infer the period based on the observation date. + const encompassingPeriod = methodRecord.sample_periods.find( + (p) => + (formattedDate.isAfter(dayjs(p.start_date)) || formattedDate.isSame(dayjs(p.start_date))) && + (formattedDate.isBefore(dayjs(p.end_date)) || formattedDate.isAfter(dayjs(p.end_date))) && + (!p.start_time || formattedTime >= dayjs(`${p.start_date} ${p.start_time}`).format('HH:mm:ss')) && + (!p.end_time || formattedTime <= dayjs(`${p.end_date} ${p.end_time}`).format('HH:mm:ss')) + ); + + if (encompassingPeriod) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: encompassingPeriod.survey_sample_period_id + }; + } + + // If there is no observation date and exactly 1 period for the matching method, and there is no period specified in the row, + // we will assume that the observation belongs to that period. This is a convenience for users since they don't need to specify + // the period if they have only 1 for the matching method. + // TODO: Might be worth checking if (!observationDate && methodRecord.sample_periods.length === 1), therefore + // failing if the specified date is not in a period + if (methodRecord.sample_periods.length === 1) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: methodRecord.sample_periods[0].survey_sample_period_id + }; + } + + return null; + } + /** * Maps over an array of inserted/updated observation records in order to update its scientific * name to match its ITIS TSN. @@ -1019,4 +1205,29 @@ export class ObservationService extends DBService { // Return true if both environments and measurements are valid return true; } + + /** + * Gets the code id value with a matching name from a pre-mapped set of options. If the function returns null, the + * request should probably throw an error. + * + * @param cellValue The name of a code to find the id for + * @param codeMap A Map where the key is the normalized code name and the value is the ID + * @param defaultCodeId A precomputed default code ID for cases where cellValue is null + * @returns The ID of the matching code, or the default ID, or null if no match is found + */ + _getCodeIdFromCellValue( + cellValue: string | null, + codeMap: Map, + defaultCodeId?: number | null + ): number | null { + const value = cellValue?.toLowerCase(); // Normalize the cell value + + // If no value exists, return the default code ID or null + if (!value) { + return defaultCodeId ?? null; + } + + // Return the ID from the map if it exists, otherwise return null + return codeMap.get(value) ?? null; + } } diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index 57a17b7dcc..399c2abad0 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -3,7 +3,7 @@ import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { UpdateSampleBlockRecord } from '../repositories/sample-blocks-repository'; -import { SampleLocationRepository } from '../repositories/sample-location-repository'; +import { SampleLocationRepository } from '../repositories/sample-location-repository/sample-location-repository'; import { UpdateSampleStratumRecord } from '../repositories/sample-stratums-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { SampleBlockService } from './sample-block-service'; @@ -84,7 +84,7 @@ describe('SampleLocationService', () => { name: 'Sample Site 1', description: '', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, @@ -112,7 +112,7 @@ describe('SampleLocationService', () => { survey_id: 1, name: 'Sample Site 1', description: '', - geojson: [], + geometry_type: 'Point', blocks: [], sample_methods: [], stratums: [] @@ -141,6 +141,50 @@ describe('SampleLocationService', () => { }); }); + describe('getSampleLocationsGeometryBySurveyId', () => { + it('should return the sample site geometries successfully', async () => { + const dbConnectionObj = getMockDBConnection(); + + const mockRows = [{ survey_sample_site_id: 1, geojson: {} }]; + + const repoStub = sinon + .stub(SampleLocationRepository.prototype, 'getSampleLocationsGeometryBySurveyId') + .resolves(mockRows); + + const sampleLocationService = new SampleLocationService(dbConnectionObj); + const response = await sampleLocationService.getSampleLocationsGeometryBySurveyId(1001); + + expect(repoStub).to.be.calledOnceWith(1001); + expect(response).to.eql(mockRows); + }); + }); + + describe('getBasicSurveySampleLocationsBySiteIds', () => { + it('should successfully return sampling location records with basic data', async () => { + const dbConnectionObj = getMockDBConnection(); + + const mockSurveySampleSiteIds = [1, 2]; + const mockRows = mockSurveySampleSiteIds.map((site) => ({ + survey_sample_site_id: site, + name: '', + sample_methods: [] + })); + + const repoStub = sinon + .stub(SampleLocationRepository.prototype, 'getBasicSurveySampleLocationsBySiteIds') + .resolves(mockRows); + + const sampleLocationService = new SampleLocationService(dbConnectionObj); + const response = await sampleLocationService.getBasicSurveySampleLocationsBySiteIds( + 1001, + mockSurveySampleSiteIds + ); + + expect(repoStub).to.be.calledOnceWith(1001); + expect(response).to.eql(mockRows); + }); + }); + describe('deleteSampleSiteRecord', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); @@ -183,7 +227,7 @@ describe('SampleLocationService', () => { name: 'Sample Site 1', description: '', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, @@ -263,7 +307,7 @@ describe('SampleLocationService', () => { name: 'Cool new site', description: 'Check out this description', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, diff --git a/api/src/services/sample-location-service.ts b/api/src/services/sample-location-service.ts index 0eb3f45d67..d31e3a2a6d 100644 --- a/api/src/services/sample-location-service.ts +++ b/api/src/services/sample-location-service.ts @@ -1,12 +1,21 @@ +import { SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; +import { SurveySampleSiteModel } from '../database-models/survey_sample_site'; import { IDBConnection } from '../database/db'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../models/sampling-locations-view'; import { InsertSampleBlockRecord } from '../repositories/sample-blocks-repository'; import { + FindSampleSiteRecord, InsertSampleSiteRecord, + SampleLocationBasicRecord, SampleLocationRecord, SampleLocationRepository, - SampleSiteRecord, + SampleSiteGeometryRecord, UpdateSampleLocationRecord -} from '../repositories/sample-location-repository'; +} from '../repositories/sample-location-repository/sample-location-repository'; import { InsertSampleMethodRecord } from '../repositories/sample-method-repository'; import { InsertSampleStratumRecord } from '../repositories/sample-stratums-repository'; import { getLogger } from '../utils/logger'; @@ -46,15 +55,23 @@ export class SampleLocationService extends DBService { * Gets a paginated set of survey Sample Locations for the given survey. * * @param {number} surveyId - * @param {ApiPaginationOptions} [pagination] + * @param {{ + * keyword?: string; + * sampleSiteIds?: number[]; + * pagination?: ApiPaginationOptions; + * }} [options] * @return {*} {Promise} * @memberof SampleLocationService */ async getSampleLocationsForSurveyId( surveyId: number, - pagination?: ApiPaginationOptions + options?: { + keyword?: string; + sampleSiteIds?: number[]; + pagination?: ApiPaginationOptions; + } ): Promise { - return this.sampleLocationRepository.getSampleLocationsForSurveyId(surveyId, pagination); + return this.sampleLocationRepository.getSampleLocationsForSurveyId(surveyId, options); } /** @@ -68,18 +85,44 @@ export class SampleLocationService extends DBService { return this.sampleLocationRepository.getSampleLocationsCountBySurveyId(surveyId); } + /** + * Returns the geometry for all sampling locations in the Survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getSampleLocationsGeometryBySurveyId(surveyId: number): Promise { + return this.sampleLocationRepository.getSampleLocationsGeometryBySurveyId(surveyId); + } + /** * Gets a sample site record by sample site ID. * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { + async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { return this.sampleLocationRepository.getSurveySampleSiteById(surveyId, surveySampleSiteId); } + /** + * Gets basic data for survey sample sites for supplementary observations data + * + * @param {number} surveyId + * @param {number[]} surveySampleSiteIds + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getBasicSurveySampleLocationsBySiteIds( + surveyId: number, + surveySampleSiteIds: number[] + ): Promise { + return this.sampleLocationRepository.getBasicSurveySampleLocationsBySiteIds(surveyId, surveySampleSiteIds); + } + /** * Gets a sample location by sample site ID. * @@ -92,15 +135,111 @@ export class SampleLocationService extends DBService { return this.sampleLocationRepository.getSurveySampleLocationBySiteId(surveyId, surveySampleSiteId); } + /** + * Retrieves the paginated list of all sites that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ISiteAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findSites( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + return this.sampleLocationRepository.findSites(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the count of all sites that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ISiteAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findSitesCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters + ): Promise { + return this.sampleLocationRepository.findSitesCount(isUserAdmin, systemUserId, filterFields); + } + + /** + * Retrieves the paginated list of all methods that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IMethodAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} + * @memberof SampleLocationService + */ + async findMethods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + return this.sampleLocationRepository.findMethods(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the paginated list of all periods that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IPeriodAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findPeriods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + return this.sampleLocationRepository.findPeriods(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the count of all periods that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IPeriodAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findPeriodsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters + ): Promise { + return this.sampleLocationRepository.findPeriodsCount(isUserAdmin, systemUserId, filterFields); + } + /** * Deletes a survey Sample Location. * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { + async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { const sampleMethodService = new SampleMethodService(this.connection); const sampleBlockService = new SampleBlockService(this.connection); const sampleStratumService = new SampleStratumService(this.connection); @@ -144,10 +283,10 @@ export class SampleLocationService extends DBService { * integer id + 1 in the db. * * @param {PostSampleLocations} sampleLocations - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async insertSampleLocations(sampleLocations: PostSampleLocations): Promise { + async insertSampleLocations(sampleLocations: PostSampleLocations): Promise { defaultLog.debug({ label: 'insertSampleLocations' }); // Create a sample site record for each feature found diff --git a/api/src/services/sample-method-service.test.ts b/api/src/services/sample-method-service.test.ts index 4ca9bf77b7..21c8c3b9f2 100644 --- a/api/src/services/sample-method-service.test.ts +++ b/api/src/services/sample-method-service.test.ts @@ -1,13 +1,13 @@ import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { SurveySampleMethodModel } from '../database-models/survey_sample_method'; +import { SurveySamplePeriodModel, SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; import { InsertSampleMethodRecord, - SampleMethodRecord, SampleMethodRepository, UpdateSampleMethodRecord } from '../repositories/sample-method-repository'; -import { SamplePeriodRecord } from '../repositories/sample-period-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { ObservationService } from './observation-service'; import { SampleMethodService } from './sample-method-service'; @@ -32,7 +32,7 @@ describe('SampleMethodService', () => { it('Gets a sample method by survey sample site ID', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecords: SampleMethodRecord[] = [ + const mockSampleMethodRecords: SurveySampleMethodModel[] = [ { survey_sample_method_id: 1, survey_sample_site_id: 2, @@ -98,7 +98,7 @@ describe('SampleMethodService', () => { const mockSamplePeriodId = 1; const mockSampleMethodId = 1; - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -116,7 +116,7 @@ describe('SampleMethodService', () => { sinon .stub(SamplePeriodService.prototype, 'getSamplePeriodsForSurveyMethodId') - .resolves([{ survey_sample_period_id: mockSamplePeriodId } as SamplePeriodRecord]); + .resolves([{ survey_sample_period_id: mockSamplePeriodId } as SurveySamplePeriodModel]); const deleteSamplePeriodRecordStub = sinon .stub(SamplePeriodService.prototype, 'deleteSamplePeriodRecords') .resolves(); @@ -138,7 +138,7 @@ describe('SampleMethodService', () => { it('Inserts a sample method successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -154,7 +154,7 @@ describe('SampleMethodService', () => { .stub(SampleMethodRepository.prototype, 'insertSampleMethod') .resolves(mockSampleMethodRecord); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_method_id: 1, survey_sample_period_id: 2, start_date: '2023-10-04', @@ -223,7 +223,7 @@ describe('SampleMethodService', () => { it('Updates a sample method successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -265,7 +265,7 @@ describe('SampleMethodService', () => { start_time: '12:00:00', end_time: '13:00:00', survey_sample_method_id: 1 - } as SamplePeriodRecord + } as SurveySamplePeriodRecord ] }; const sampleMethodService = new SampleMethodService(mockDBConnection); @@ -287,7 +287,7 @@ describe('SampleMethodService', () => { const mockSampleMethodId = 1; const surveySampleSiteId = 1; - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: mockSampleMethodId, survey_sample_site_id: 2, method_technique_id: 3, @@ -300,7 +300,7 @@ describe('SampleMethodService', () => { revision_count: 0 }; - const mockSampleMethodRecords: SampleMethodRecord[] = [mockSampleMethodRecord]; + const mockSampleMethodRecords: SurveySampleMethodModel[] = [mockSampleMethodRecord]; const getSampleMethodsForSurveySampleSiteIdStub = sinon .stub(SampleMethodRepository.prototype, 'getSampleMethodsForSurveySampleSiteId') .resolves(mockSampleMethodRecords); diff --git a/api/src/services/sample-method-service.ts b/api/src/services/sample-method-service.ts index ad6e2f7a0f..7de513b0aa 100644 --- a/api/src/services/sample-method-service.ts +++ b/api/src/services/sample-method-service.ts @@ -1,8 +1,8 @@ +import { SurveySampleMethodModel } from '../database-models/survey_sample_method'; import { IDBConnection } from '../database/db'; import { HTTP409 } from '../errors/http-error'; import { InsertSampleMethodRecord, - SampleMethodRecord, SampleMethodRepository, UpdateSampleMethodRecord } from '../repositories/sample-method-repository'; @@ -30,13 +30,13 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ async getSampleMethodsForSurveySampleSiteId( surveyId: number, surveySampleSiteId: number - ): Promise { + ): Promise { return this.sampleMethodRepository.getSampleMethodsForSurveySampleSiteId(surveyId, surveySampleSiteId); } @@ -56,10 +56,10 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { + async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { const samplePeriodService = new SamplePeriodService(this.connection); // Collect list of periods to delete @@ -78,10 +78,10 @@ export class SampleMethodService extends DBService { * Inserts survey Sample Method and associated Sample Periods. * * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { + async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { // Create new sample method const sampleMethodRecord = await this.sampleMethodRepository.insertSampleMethod(sampleMethod); @@ -156,10 +156,10 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { + async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { const samplePeriodService = new SamplePeriodService(this.connection); // Check for any sample periods to delete diff --git a/api/src/services/sample-period-service.test.ts b/api/src/services/sample-period-service.test.ts index 7306048d27..a59fc19ab4 100644 --- a/api/src/services/sample-period-service.test.ts +++ b/api/src/services/sample-period-service.test.ts @@ -1,10 +1,10 @@ import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { SurveySamplePeriodModel } from '../database-models/survey_sample_period'; import { InsertSamplePeriodRecord, SamplePeriodHierarchyIds, - SamplePeriodRecord, SamplePeriodRepository, UpdateSamplePeriodRecord } from '../repositories/sample-period-repository'; @@ -31,7 +31,7 @@ describe('SamplePeriodService', () => { it('Gets a sample period by survey method ID', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecords: SamplePeriodRecord[] = [ + const mockSamplePeriodRecords: SurveySamplePeriodModel[] = [ { survey_sample_period_id: 1, survey_sample_method_id: 2, @@ -97,7 +97,7 @@ describe('SamplePeriodService', () => { it('Deletes a sample period record', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -132,7 +132,7 @@ describe('SamplePeriodService', () => { it('Inserts a sample period successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -172,7 +172,7 @@ describe('SamplePeriodService', () => { it('Updates a sample period successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -211,7 +211,7 @@ describe('SamplePeriodService', () => { it('should delete sample sites not in array successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecords: SamplePeriodRecord[] = [ + const mockSamplePeriodRecords: SurveySamplePeriodModel[] = [ { survey_sample_period_id: 1, survey_sample_method_id: 2, @@ -242,7 +242,7 @@ describe('SamplePeriodService', () => { const surveySampleMethodId = 1; const samplePeriodService = new SamplePeriodService(mockDBConnection); const response = await samplePeriodService.deleteSamplePeriodsNotInArray(mockSurveyId, surveySampleMethodId, [ - { survey_sample_period_id: 2 } as SamplePeriodRecord + { survey_sample_period_id: 2 } as SurveySamplePeriodModel ]); expect(getSamplePeriodsForSurveyMethodIdStub).to.be.calledOnceWith(mockSurveyId, surveySampleMethodId); diff --git a/api/src/services/sample-period-service.ts b/api/src/services/sample-period-service.ts index 38cd96d8c7..8a7307fcca 100644 --- a/api/src/services/sample-period-service.ts +++ b/api/src/services/sample-period-service.ts @@ -1,9 +1,9 @@ +import { SurveySamplePeriodModel } from '../database-models/survey_sample_period'; import { IDBConnection } from '../database/db'; import { HTTP409 } from '../errors/http-error'; import { InsertSamplePeriodRecord, SamplePeriodHierarchyIds, - SamplePeriodRecord, SamplePeriodRepository, UpdateSamplePeriodRecord } from '../repositories/sample-period-repository'; @@ -30,13 +30,13 @@ export class SamplePeriodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ async getSamplePeriodsForSurveyMethodId( surveyId: number, surveySampleMethodId: number - ): Promise { + ): Promise { return this.samplePeriodRepository.getSamplePeriodsForSurveyMethodId(surveyId, surveySampleMethodId); } @@ -57,10 +57,10 @@ export class SamplePeriodService extends DBService { * * @param {number} surveyId * @param {number} surveySamplePeriodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { + async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { return this.samplePeriodRepository.deleteSamplePeriodRecord(surveyId, surveySamplePeriodId); } @@ -68,10 +68,10 @@ export class SamplePeriodService extends DBService { * Deletes multiple Survey Sample Periods for a given array of period ids. * * @param {number[]} periodsToDelete an array of period ids to delete - * @returns {*} {Promise} an array of promises for the deleted periods + * @returns {*} {Promise} an array of promises for the deleted periods * @memberof SamplePeriodService */ - async deleteSamplePeriodRecords(surveyId: number, periodsToDelete: number[]): Promise { + async deleteSamplePeriodRecords(surveyId: number, periodsToDelete: number[]): Promise { return this.samplePeriodRepository.deleteSamplePeriods(surveyId, periodsToDelete); } @@ -79,10 +79,10 @@ export class SamplePeriodService extends DBService { * Inserts survey Sample Period. * * @param {InsertSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async insertSamplePeriod(samplePeriod: InsertSamplePeriodRecord): Promise { + async insertSamplePeriod(samplePeriod: InsertSamplePeriodRecord): Promise { return this.samplePeriodRepository.insertSamplePeriod(samplePeriod); } @@ -90,10 +90,10 @@ export class SamplePeriodService extends DBService { * updates a survey Sample Period. * * @param {UpdateSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { + async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { return this.samplePeriodRepository.updateSamplePeriod(surveyId, samplePeriod); } diff --git a/api/src/services/survey-block-service.test.ts b/api/src/services/survey-block-service.test.ts index a2e1017c06..9a5dd094a4 100644 --- a/api/src/services/survey-block-service.test.ts +++ b/api/src/services/survey-block-service.test.ts @@ -68,8 +68,30 @@ describe('SurveyBlockService', () => { const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); const blocks: PostSurveyBlock[] = [ - { survey_block_id: null, survey_id: 1, name: 'Old Block', description: 'Updated' }, - { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + { + survey_block_id: null, + survey_id: 1, + name: 'Old Block', + description: 'Updated', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }, + { + survey_block_id: null, + survey_id: 1, + name: 'New Block', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + } ]; await service.upsertSurveyBlocks(1, blocks); @@ -106,8 +128,30 @@ describe('SurveyBlockService', () => { const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); const blocks: PostSurveyBlock[] = [ - { survey_block_id: 10, survey_id: 1, name: 'Old Block', description: 'Updated' }, - { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + { + survey_block_id: 10, + survey_id: 1, + name: 'Old Block', + description: 'Updated', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }, + { + survey_block_id: null, + survey_id: 1, + name: 'New Block', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + } ]; await service.upsertSurveyBlocks(1, blocks); diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts index 1d235ca823..86d39e521b 100644 --- a/api/src/services/telemetry-service.ts +++ b/api/src/services/telemetry-service.ts @@ -1,4 +1,5 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { DefaultDateFormat, DefaultTimeFormat } from '../constants/dates'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; import { IAllTelemetryAdvancedFilters } from '../models/telemetry-view'; @@ -149,13 +150,15 @@ export class TelemetryService extends DBService { if (foundDeployment) { itemsToAdd.push({ deployment_id: foundDeployment.deployment_id, - acquisition_date: dateTime.format('YYYY-MM-DD HH:mm:ss'), + acquisition_date: dateTime.format(`${DefaultDateFormat} ${DefaultTimeFormat}`), latitude: row['LATITUDE'], longitude: row['LONGITUDE'] }); } else { throw new ApiGeneralError( - `No deployment was found for device: ${deviceId} on: ${dateTime.format('YYYY-MM-DD HH:mm:ss')}` + `No deployment was found for device: ${deviceId} on: ${dateTime.format( + `${DefaultDateFormat} ${DefaultTimeFormat}` + )}` ); } }); diff --git a/api/src/utils/env-config.ts b/api/src/utils/env-config.ts new file mode 100644 index 0000000000..913afeedec --- /dev/null +++ b/api/src/utils/env-config.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; +import { getLogger } from './logger'; + +const defaultLog = getLogger('src/utils/env-config.ts'); + +const ZodEnvString = z.string().trim().min(1, { message: 'Required' }); // '' or ' ' are invalid +const ZodEnvNumber = z.coerce.number().min(1, { message: 'Required and must be a positive value.' }); // -1 is invalid + +// Schema for environment configuration +export const EnvSchema = z.object({ + // Environment + NODE_ENV: z.enum(['development', 'test', 'production']), + NODE_OPTIONS: ZodEnvString, + TZ: z.literal('America/Vancouver'), + + // API server + API_HOST: ZodEnvString, + API_PORT: ZodEnvNumber, + + // Database + DB_HOST: ZodEnvString, + DB_PORT: ZodEnvNumber, + DB_USER_API: ZodEnvString, + DB_USER_API_PASS: ZodEnvString, + DB_DATABASE: ZodEnvString, + + // Keycloak + KEYCLOAK_HOST: ZodEnvString, + KEYCLOAK_REALM: ZodEnvString, + KEYCLOAK_ADMIN_USERNAME: ZodEnvString, + KEYCLOAK_ADMIN_PASSWORD: ZodEnvString, + KEYCLOAK_API_TOKEN_URL: ZodEnvString, + KEYCLOAK_API_CLIENT_ID: ZodEnvString, + KEYCLOAK_API_CLIENT_SECRET: ZodEnvString, + KEYCLOAK_API_HOST: ZodEnvString, + KEYCLOAK_API_ENVIRONMENT: ZodEnvString, + + // Logging + LOG_LEVEL: z.enum(['silent', 'error', 'warn', 'info', 'debug', 'silly']), + LOG_LEVEL_FILE: z.enum(['silent', 'error', 'warn', 'info', 'debug', 'silly']), + LOG_FILE_DIR: ZodEnvString, + LOG_FILE_NAME: ZodEnvString, + LOG_FILE_DATE_PATTERN: ZodEnvString, + LOG_FILE_MAX_SIZE: ZodEnvString, + LOG_FILE_MAX_FILES: ZodEnvString, + + // Validation + API_RESPONSE_VALIDATION_ENABLED: z.enum(['true', 'false']), + DATABASE_RESPONSE_VALIDATION_ENABLED: z.enum(['true', 'false']), + + // File upload limits + MAX_REQ_BODY_SIZE: ZodEnvNumber, + MAX_UPLOAD_NUM_FILES: ZodEnvNumber, + MAX_UPLOAD_FILE_SIZE: ZodEnvNumber, + + // External Services + CB_API_HOST: ZodEnvString, + APP_HOST: ZodEnvString, + + // Biohub + BACKBONE_INTERNAL_API_HOST: ZodEnvString, + BACKBONE_INTAKE_PATH: ZodEnvString, + BACKBONE_ARTIFACT_INTAKE_PATH: ZodEnvString, + BIOHUB_TAXON_PATH: ZodEnvString, + BIOHUB_TAXON_TSN_PATH: ZodEnvString, + + // Object Store + OBJECT_STORE_URL: ZodEnvString, + OBJECT_STORE_ACCESS_KEY_ID: ZodEnvString, + OBJECT_STORE_SECRET_KEY_ID: ZodEnvString, + OBJECT_STORE_BUCKET_NAME: ZodEnvString, + S3_KEY_PREFIX: ZodEnvString, + + // GCNotify + GCNOTIFY_SECRET_API_KEY: ZodEnvString, + GCNOTIFY_ADMIN_EMAIL: ZodEnvString, + GCNOTIFY_ONBOARDING_REQUEST_EMAIL_TEMPLATE: z.string().uuid(), + GCNOTIFY_ONBOARDING_REQUEST_SMS_TEMPLATE: z.string().uuid(), + GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE: z.string().uuid(), + GCNOTIFY_EMAIL_URL: ZodEnvString, + GCNOTIFY_SMS_URL: ZodEnvString, + + // ClamAV + CLAMAV_PORT: ZodEnvNumber, + CLAMAV_HOST: ZodEnvString, + ENABLE_FILE_VIRUS_SCAN: z.enum(['true', 'false']), + + // Extra + FEATURE_FLAGS: z.string().trim().optional() // flagA,flagB,flagC +}); + +type Env = z.infer; + +/** + * Load Environment Variables and validate them against the Zod schema. + * + * @returns {*} {Env} Validated environment variables + */ +export const loadEvironmentVariables = (): Env => { + const parsed = EnvSchema.safeParse(process.env); + + if (!parsed.success) { + defaultLog.error({ + label: 'loadEvironmentVariables', + message: 'Environment variables validation check failed', + errors: parsed.error.flatten().fieldErrors + }); + + process.exit(1); + } + + return parsed.data; +}; + +// Extend NodeJS ProcessEnv to include the EnvSchema +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + interface ProcessEnv extends Env {} + } +} diff --git a/api/src/utils/file-utils.test.ts b/api/src/utils/file-utils.test.ts index 028f2ec0ed..48f53cf5c5 100644 --- a/api/src/utils/file-utils.test.ts +++ b/api/src/utils/file-utils.test.ts @@ -136,8 +136,7 @@ describe('getS3HostUrl', () => { }); it('should yield a default S3 host url', () => { - delete process.env.OBJECT_STORE_URL; - delete process.env.OBJECT_STORE_BUCKET_NAME; + Object.assign(process.env, { OBJECT_STORE_URL: undefined, OBJECT_STORE_BUCKET_NAME: undefined }); const result = getS3HostUrl(); @@ -193,7 +192,7 @@ describe('_getClamAvScanner', () => { it('should return a clamAv scanner client', () => { process.env.ENABLE_FILE_VIRUS_SCAN = 'true'; process.env.CLAMAV_HOST = 'host'; - process.env.CLAMAV_PORT = '1111'; + process.env.CLAMAV_PORT = 1111; const result = _getClamAvScanner(); expect(result).to.not.be.null; @@ -215,7 +214,7 @@ describe('_getObjectStoreBucketName', () => { }); it('should return its default value', () => { - delete process.env.OBJECT_STORE_BUCKET_NAME; + Object.assign(process.env, { OBJECT_STORE_BUCKET_NAME: undefined }); const result = _getObjectStoreBucketName(); expect(result).to.equal(''); @@ -251,7 +250,7 @@ describe('_getObjectStoreUrl', () => { }); it('should return its default value', () => { - delete process.env.OBJECT_STORE_URL; + Object.assign(process.env, { OBJECT_STORE_URL: undefined }); const result = _getObjectStoreUrl(); expect(result).to.equal('https://nrs.objectstore.gov.bc.ca'); @@ -273,7 +272,7 @@ describe('getS3KeyPrefix', () => { }); it('should return its default value', () => { - delete process.env.S3_KEY_PREFIX; + Object.assign(process.env, { S3_KEY_PREFIX: undefined }); const result = getS3KeyPrefix(); expect(result).to.equal('sims'); diff --git a/api/src/utils/file-utils.ts b/api/src/utils/file-utils.ts index 517260898d..67356cfe3a 100644 --- a/api/src/utils/file-utils.ts +++ b/api/src/utils/file-utils.ts @@ -31,7 +31,7 @@ export const _getClamAvScanner = async (): Promise => { return new NodeClam().init({ clamdscan: { host: process.env.CLAMAV_HOST, - port: Number(process.env.CLAMAV_PORT) + port: process.env.CLAMAV_PORT } }); }; diff --git a/api/src/utils/media/xlsx/xlsx-utils.ts b/api/src/utils/media/xlsx/xlsx-utils.ts index 2c3ad22eb9..72df06cfeb 100644 --- a/api/src/utils/media/xlsx/xlsx-utils.ts +++ b/api/src/utils/media/xlsx/xlsx-utils.ts @@ -1,5 +1,6 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import xlsx, { CellObject } from 'xlsx'; +import { DefaultDateFormat } from '../../../constants/dates'; import { safeTrim } from '../../string-utils'; /** @@ -106,7 +107,7 @@ export function replaceCellDates(cell: CellObject) { } if (isDateFormatCell(cell)) { - const DateFormat = 'YYYY-MM-DD'; + const DateFormat = DefaultDateFormat; cell.v = cellDate.format(DateFormat); return cell; } diff --git a/api/src/utils/xlsx-utils/cell-utils.ts b/api/src/utils/xlsx-utils/cell-utils.ts index eb2e75f704..bfc8c91daa 100644 --- a/api/src/utils/xlsx-utils/cell-utils.ts +++ b/api/src/utils/xlsx-utils/cell-utils.ts @@ -1,5 +1,11 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import { CellObject } from 'xlsx'; +import { + AltDateFormat, + AltDateFormatReverse, + DefaultDateFormat, + DefaultDateFormatReverse +} from '../../constants/dates'; import { safeTrim } from '../string-utils'; /** @@ -25,8 +31,8 @@ export function trimCellWhitespace(cell: CellObject) { } /** - * Attempts to update the cells value with a formatted date or time value if the cell is a date type cell that has a - * date or time format. + * Attempts to identify and update cells whose values are either date strings or date objects to a consistent date + * format. * * @see https://docs.sheetjs.com/docs/csf/cell for details on cell fields * @export @@ -34,28 +40,38 @@ export function trimCellWhitespace(cell: CellObject) { * @return {*} */ export function replaceCellDates(cell: CellObject) { - if (!isDateCell(cell)) { + if (!cell.v) { + // Cell has no value return cell; } - const cellDate = dayjs(cell.v as any); + // If the cell was already interpreted as a date, format it to the default date format, and return + if (isDateCell(cell) && cell.v instanceof Date) { + // Attempt to parse the date using the format and update the cell value + cell.v = dayjs((cell.v as Date).toISOString(), DefaultDateFormat).format(DefaultDateFormat); + // Update the format to desired default format + cell.z = DefaultDateFormat; + // Ensure the cell type is set to date + cell.t = 'd'; - if (!cellDate.isValid()) { return cell; } - if (isDateFormatCell(cell)) { - const DateFormat = 'YYYY-MM-DD'; - cell.v = cellDate.format(DateFormat); - return cell; - } + // If the cell is a string cell with a valid date value, update the cell value to a date type cell using the default + // format, and return + const matchingStringDateFormat = isStringCellWithDateValue(cell); + if (matchingStringDateFormat) { + // Attempt to parse the date using the format and update the cell value + cell.v = dayjs(cell.v as string, matchingStringDateFormat).format(DefaultDateFormat); + // Update the format to desired default format + cell.z = DefaultDateFormat; + // Ensure the cell type is set to date + cell.t = 'd'; - if (isTimeFormatCell(cell)) { - const TimeFormat = 'HH:mm:ss'; - cell.v = cellDate.format(TimeFormat); return cell; } + // The cell neither a date type cell nor a string type cell with a valid date string value return cell; } @@ -81,6 +97,27 @@ export function isDateCell(cell: CellObject): boolean { return cell.t === 'd'; } +/** + * Checks if the cell value is a date string in a known date format. + * + * @export + * @param {CellObject} cell + * @return {*} {(false | string)} Return the matched date format if the cell value is a date string matching one known + * date format, return `false` otherwise. + */ +export function isStringCellWithDateValue(cell: CellObject): false | string { + if (!isStringCell(cell)) { + return false; + } + + const matchedFormats = [DefaultDateFormat, DefaultDateFormatReverse, AltDateFormat, AltDateFormatReverse].filter( + (format) => dayjs(String(cell.v), format).isValid() + ); + + // Ensure only one format matched + return matchedFormats.length === 1 ? matchedFormats[0] : false; +} + /** * Checks if the cell has a format, and if the format is likely a date format. * @@ -88,7 +125,7 @@ export function isDateCell(cell: CellObject): boolean { * @param {CellObject} cell * @return {*} {boolean} `true` if the cell has a date format, `false` otherwise. */ -export function isDateFormatCell(cell: CellObject): boolean { +export function doesCellHaveDateFormat(cell: CellObject): boolean { if (!cell.z) { return false; } @@ -104,7 +141,7 @@ export function isDateFormatCell(cell: CellObject): boolean { * @param {CellObject} cell * @return {*} {boolean} `true` if the cell has a time format, `false` otherwise. */ -export function isTimeFormatCell(cell: CellObject): boolean { +export function doesCellHaveTimeFormat(cell: CellObject): boolean { if (!cell.z) { // Not a date cell and/or has no date format return false; diff --git a/api/src/utils/xlsx-utils/column-aliases.ts b/api/src/utils/xlsx-utils/column-aliases.ts index f2a0453376..48cf2a9a9b 100644 --- a/api/src/utils/xlsx-utils/column-aliases.ts +++ b/api/src/utils/xlsx-utils/column-aliases.ts @@ -6,5 +6,8 @@ export const CSV_COLUMN_ALIASES: Record, Uppercase[]> ALIAS: ['NICKNAME', 'ANIMAL'], MARKING_TYPE: ['TYPE'], OBSERVATION_SUBCOUNT_SIGN: ['SIGN'], + SAMPLING_SITE: ['SITE', 'SITE ID', 'LOCATION', 'SAMPLING SITE', 'STATION'], + SAMPLING_METHOD: ['METHOD', 'TECHNIQUE'], + SAMPLING_PERIOD: ['PERIOD', 'TIME PERIOD', 'SESSION'], COMMENT: ['COMMENTS', 'NOTE', 'NOTES'] }; diff --git a/api/src/utils/xlsx-utils/worksheet-utils.test.ts b/api/src/utils/xlsx-utils/worksheet-utils.test.ts index 067e1c9063..45517f713e 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.test.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.test.ts @@ -16,7 +16,7 @@ const xlsxWorksheet: xlsx.WorkSheet = { H1: { t: 's', v: 'Wind Direction' }, A2: { t: 'n', w: '180703', v: 180703 }, B2: { t: 'n', w: '1', v: 1 }, - C2: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C2: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D2: { t: 's', v: '9:01' }, E2: { t: 'n', w: '-58', v: -58 }, F2: { t: 'n', w: '-123', v: -123 }, @@ -24,14 +24,14 @@ const xlsxWorksheet: xlsx.WorkSheet = { H2: { t: 's', v: 'North' }, A3: { t: 'n', w: '180596', v: 180596 }, B3: { t: 'n', w: '2', v: 2 }, - C3: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C3: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D3: { t: 's', v: '9:02' }, E3: { t: 'n', w: '-57', v: -57 }, F3: { t: 'n', w: '-122', v: -122 }, H3: { t: 's', v: 'North' }, A4: { t: 'n', w: '180713', v: 180713 }, B4: { t: 'n', w: '3', v: 3 }, - C4: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C4: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D4: { t: 's', v: '9:03' }, E4: { t: 'n', w: '-56', v: -56 }, F4: { t: 'n', w: '-121', v: -121 }, diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts index e5d27a560c..38812641ae 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -1,4 +1,4 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import { intersection, isUndefined } from 'lodash'; import xlsx, { CellObject } from 'xlsx'; @@ -46,11 +46,16 @@ export interface IXLSXCSVValidator { * * @export * @param {MediaFile} file - * @param {xlsx.ParsingOptions} [options] * @return {*} {xlsx.WorkBook} */ -export const constructXLSXWorkbook = (file: MediaFile, options?: xlsx.ParsingOptions): xlsx.WorkBook => { - return xlsx.read(file.buffer, { cellDates: true, cellNF: true, cellHTML: false, ...options }); +export const constructXLSXWorkbook = (file: MediaFile): xlsx.WorkBook => { + return xlsx.read(file.buffer, { + cellDates: true, + cellNF: true, + cellHTML: false, + dateNF: '_', + raw: false + }); }; /** diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 5e09060113..954446e2eb 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -2,7 +2,7 @@ import { AuthenticatedRouteGuard, SystemRoleRouteGuard } from 'components/securi import { SYSTEM_ROLE } from 'constants/roles'; import { CodesContextProvider } from 'contexts/codesContext'; import { DialogContextProvider } from 'contexts/dialogContext'; -import AdminUsersRouter from 'features/admin/AdminUsersRouter'; +import AdminRouter from 'features/admin/AdminRouter'; import FundingSourcesRouter from 'features/funding-sources/FundingSourcesRouter'; import ProjectsRouter from 'features/projects/ProjectsRouter'; import ResourcesPage from 'features/resources/ResourcesPage'; @@ -78,13 +78,13 @@ const AppRouter: React.FC = () => { - + - + diff --git a/app/src/components/alert/AlertBar.tsx b/app/src/components/alert/AlertBar.tsx index ff7d46472f..fff13a5ff2 100644 --- a/app/src/components/alert/AlertBar.tsx +++ b/app/src/components/alert/AlertBar.tsx @@ -1,33 +1,40 @@ -import Alert from '@mui/material/Alert'; +import Alert, { AlertProps } from '@mui/material/Alert'; import AlertTitle from '@mui/material/AlertTitle'; -import Box from '@mui/material/Box'; -import React from 'react'; -interface IAlertBarProps { +interface IAlertBarProps extends AlertProps { severity: 'error' | 'warning' | 'info' | 'success'; variant: 'filled' | 'outlined' | 'standard'; title: string; text: string | JSX.Element; } -const AlertBar: React.FC = (props) => { - const { severity, variant, title, text } = props; +/** + * Returns an alert banner + * + * @param props {IAlertBarProps} + * @returns + */ +const AlertBar = (props: IAlertBarProps) => { + const { severity, variant, title, text, ...alertProps } = props; + + const defaultProps = { + severity: 'success', + variant: 'standard', + title: '', + text: '' + }; return ( - - - {title} - {text} - - + + {title} + {text} + ); }; -AlertBar.defaultProps = { - severity: 'success', - variant: 'standard', - title: '', - text: '' -}; - export default AlertBar; diff --git a/app/src/components/buttons/BreadcrumbNavButton.tsx b/app/src/components/buttons/BreadcrumbNavButton.tsx new file mode 100644 index 0000000000..ba5df84178 --- /dev/null +++ b/app/src/components/buttons/BreadcrumbNavButton.tsx @@ -0,0 +1,69 @@ +import { mdiChevronDown } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Button from '@mui/material/Button'; +import grey from '@mui/material/colors/grey'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import { PropsWithChildren, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +interface IBreadcrumbNavButtonProps { + menuItems: { label: string; to: string; icon?: string }[]; +} + +/** + * Returns a button that opens a menu of router links when clicked + * + * @param {PropsWithChildren} props + * @returns {*} + */ +export const BreadcrumbNavButton = (props: PropsWithChildren) => { + const { menuItems, children } = props; + + // State for managing the menu + const [anchorEl, setAnchorEl] = useState(null); + + // Handle menu opening + const handleMenuClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + // Handle menu closing + const handleMenuClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + {menuItems.map((item) => ( + { + handleMenuClose(); + }}> + {item.icon && } + {item.label} + + ))} + + + + + ); +}; diff --git a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx index 91a244b3b9..afd145f91f 100644 --- a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx @@ -28,6 +28,12 @@ export interface IAsyncAutocompleteDataGridEditCell< * @memberof IAsyncAutocompleteDataGridEditCell */ getCurrentOption: (value: ValueType) => Promise; + /** + * Initial options to display in the autocomplete, before the user types anything. + * + * @memberof IAsyncAutocompleteDataGridEditCell + */ + getInitialOptions?: () => AutocompleteOptionType[]; /** * Search function that returns an array of options to choose from. * @@ -46,6 +52,17 @@ export interface IAsyncAutocompleteDataGridEditCell< * Optional function to render the autocomplete option. */ renderOption?: AutocompleteProps['renderOption']; + /** + * Optional callback fired when an option is selected. + */ + onSelectOption?: (selectedOption: AutocompleteOptionType | null) => void; + /** + * Placeholder text for the input field. + * + * @type {string} + * @memberof IAsyncAutocompleteDataGridEditCell + */ + placeholder?: string; } /** @@ -64,7 +81,16 @@ const AsyncAutocompleteDataGridEditCell = < >( props: IAsyncAutocompleteDataGridEditCell ) => { - const { dataGridProps, getCurrentOption, getOptions, error, renderOption } = props; + const { + dataGridProps, + getCurrentOption, + getOptions, + getInitialOptions, + error, + renderOption, + onSelectOption, + placeholder + } = props; const ref = useRef(); @@ -80,14 +106,21 @@ const AsyncAutocompleteDataGridEditCell = < const [inputValue, setInputValue] = useState(''); // The currently selected option const [currentOption, setCurrentOption] = useState(null); + // Reference to disable search (used when selecting an option to prevent a redundant search) + const isSearchDisabled = useRef(false); // The array of options to choose from - const [options, setOptions] = useState([]); + const [options, setOptions] = useState(getInitialOptions?.() ?? []); // Is control loading (search in progress) const [isLoading, setIsLoading] = useState(false); useEffect(() => { let mounted = true; + if (isSearchDisabled.current) { + // Search is disabled + return; + } + if (!dataGridValue) { // No current value return; @@ -123,14 +156,20 @@ const AsyncAutocompleteDataGridEditCell = < useEffect(() => { let mounted = true; + if (isSearchDisabled.current) { + // Search is disabled + return; + } + if (inputValue === '') { - // No input value, nothing to search with - setOptions(currentOption ? [currentOption] : []); + // No search term, do not initiate search, cancel any existing search + setIsLoading(false); return; } - // Call async search function setIsLoading(true); + + // Call async search function getOptions(inputValue, (searchResults) => { if (!mounted) { return; @@ -166,8 +205,13 @@ const AsyncAutocompleteDataGridEditCell = < }} filterOptions={(item) => item} onChange={(_, selectedOption) => { - setOptions(selectedOption ? [selectedOption, ...options] : options); + // Disable search when selecting an option, to prevent a redundant search when the input field is updated + // with the user's selection + isSearchDisabled.current = true; + setCurrentOption(selectedOption); + onSelectOption?.(selectedOption); + setIsLoading(false); // Set the data grid cell value with selected options value dataGridProps.api.setEditCellValue({ @@ -176,7 +220,13 @@ const AsyncAutocompleteDataGridEditCell = < value: selectedOption?.value }); }} - onInputChange={(_, newInputValue) => { + onInputChange={(_, newInputValue, reason) => { + if (reason === 'clear' || reason === 'input') { + // Enable search when the user interacts with the input field + // A 'reset' event is created when the user selects an option, which should not trigger a search + isSearchDisabled.current = false; + } + setInputValue(newInputValue); }} renderInput={(params) => ( @@ -187,6 +237,7 @@ const AsyncAutocompleteDataGridEditCell = < variant="outlined" fullWidth error={error} + placeholder={placeholder} InputProps={{ color: error ? 'error' : undefined, ...params.InputProps, diff --git a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx index fadaca2fe0..55b1029a6b 100644 --- a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx @@ -16,14 +16,14 @@ export interface IConditionalAutocompleteDataGridEditCellProps< */ dataGridProps: GridRenderCellParams; /** - * + * All possible options for the autocomplete control. * * @type {OptionsType[]} * @memberof IConditionalAutocompleteDataGridEditCellProps */ allOptions: OptionsType[]; /** - * + * Given a row and list of all possible options, return the matching options for the autocomplete control. * * @memberof IConditionalAutocompleteDataGridEditCellProps */ diff --git a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx index 0fda933e5f..611e8e9c21 100644 --- a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx +++ b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx @@ -16,14 +16,14 @@ export interface IConditionalAutocompleteDataGridViewCellProps< */ dataGridProps: GridRenderCellParams; /** - * + * All possible options for the autocomplete control. * * @type {OptionsType[]} * @memberof IConditionalAutocompleteDataGridViewCellProps */ allOptions: OptionsType[]; /** - * + * Given a row and list of all possible options, return the matching options for the autocomplete control. * * @memberof IConditionalAutocompleteDataGridViewCellProps */ diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx index ba47cfd44f..c439286ddb 100644 --- a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx @@ -93,6 +93,7 @@ const TaxonomyDataGridEditCell = ( dataGridProps={dataGridProps} getCurrentOption={getCurrentOption} getOptions={getOptions} + placeholder="Search for a taxon" error={props.error} renderOption={(renderProps, renderOption) => ( { required?: boolean; filterLimit?: number; showValue?: boolean; + disableClearable?: 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; @@ -67,6 +68,7 @@ const AutocompleteField = (props: IAutocompleteField< value={getExistingValue(get(values, props.name))} options={props.options} getOptionLabel={(option) => option.label} + disableClearable={props.disableClearable} isOptionEqualToValue={handleGetOptionSelected} getOptionDisabled={props.getOptionDisabled} filterOptions={createFilterOptions({ limit: props.filterLimit })} diff --git a/app/src/components/fields/DateField.tsx b/app/src/components/fields/DateField.tsx index 83ff4f36e0..fd1a4b5f69 100644 --- a/app/src/components/fields/DateField.tsx +++ b/app/src/components/fields/DateField.tsx @@ -4,25 +4,19 @@ import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DATE_FORMAT, DATE_LIMIT } from 'constants/dateTimeFormats'; import dayjs from 'dayjs'; -import { FormikContextType } from 'formik'; +import { useFormikContext } from 'formik'; import { get } from 'lodash-es'; -interface IDateFieldProps { +interface IDateFieldProps { label: string; name: string; id: string; required: boolean; - formikProps: FormikContextType; } -export const DateField = (props: IDateFieldProps) => { - const { - formikProps: { values, errors, touched, setFieldValue }, - label, - name, - id, - required - } = props; +export const DateField = (props: IDateFieldProps) => { + const { values, errors, touched, setFieldValue, setFieldError } = useFormikContext(); + const { label, name, id, required } = props; const rawDateValue = get(values, name); const formattedDateValue = @@ -34,12 +28,6 @@ export const DateField = (props: IDateFieldProps }} @@ -49,7 +37,7 @@ export const DateField = (props: IDateFieldProps(props: IDateFieldProps { - if (!value || value === 'Invalid Date') { + if (!value || !dayjs(value).isValid()) { // The creation input value will be 'Invalid Date' when the date field is cleared (empty), and will // contain an actual date string value if the field is not empty but is invalid. setFieldValue(name, null); @@ -74,6 +62,7 @@ export const DateField = (props: IDateFieldProps diff --git a/app/src/components/fields/TimeField.tsx b/app/src/components/fields/TimeField.tsx index 23772bbfab..045f02a945 100644 --- a/app/src/components/fields/TimeField.tsx +++ b/app/src/components/fields/TimeField.tsx @@ -5,25 +5,19 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { TIME_FORMAT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; -import { FormikContextType } from 'formik'; +import { useFormikContext } from 'formik'; import get from 'lodash-es/get'; -interface ITimeFieldProps { +interface ITimeFieldProps { label: string; name: string; id: string; required: boolean; - formikProps: FormikContextType; } -export const TimeField = (props: ITimeFieldProps) => { - const { - formikProps: { values, errors, touched, setFieldValue }, - label, - name, - id, - required - } = props; +export const TimeField = (props: ITimeFieldProps) => { + const { values, errors, touched, setFieldValue } = useFormikContext(); + const { label, name, id, required } = props; const rawTimeValue = get(values, name); const formattedTimeValue = diff --git a/app/src/components/layout/Header.test.tsx b/app/src/components/layout/Header.test.tsx index 1f6af9a314..c9d5e5e9e5 100644 --- a/app/src/components/layout/Header.test.tsx +++ b/app/src/components/layout/Header.test.tsx @@ -21,7 +21,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); @@ -40,7 +40,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); @@ -59,7 +59,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); diff --git a/app/src/components/layout/Header.tsx b/app/src/components/layout/Header.tsx index 1c87ee0caa..953ec35810 100644 --- a/app/src/components/layout/Header.tsx +++ b/app/src/components/layout/Header.tsx @@ -266,8 +266,8 @@ const Header: React.FC = () => { - - Manage Users + + Admin { - - Manage Users + + Admin diff --git a/app/src/constants/i18n.ts b/app/src/constants/i18n.ts index 068bd3f0fb..6ee3b34128 100644 --- a/app/src/constants/i18n.ts +++ b/app/src/constants/i18n.ts @@ -498,3 +498,27 @@ export const SurveyExportI18N = { exportErrorText: 'An error has occurred while attempting to export survey data. Please try again. If the error persists, please contact your system administrator.' }; + +export const AlertI18N = { + cancelTitle: 'Discard changes and exit?', + cancelText: 'Any changes you have made will not be saved. Do you want to proceed?', + + createAlertDialogTitle: 'Create Alert', + createAlertDialogText: + 'Enter a name, message, and type for the alert. The name and message will be displayed on the alert banner.', + createErrorTitle: 'Error Creating Alert', + createErrorText: + 'An error has occurred while attempting to create your alert, please try again. If the error persists, please contact your system administrator.', + + updateAlertDialogTitle: 'Edit Alert Details', + updateAlertDialogText: 'Edit the name, description and effective dates for this alert.', + updateErrorTitle: 'Error Updating Alert', + updateErrorText: + 'An error has occurred while attempting to update your Alert, please try again. If the error persists, please contact your system administrator.', + + deleteAlertErrorTitle: 'Error Deleting a Alert', + deleteAlertErrorText: + 'An error has occurred while attempting to delete the Alerts, please try again. If the error persists, please contact your system administrator.', + deleteAlertDialogTitle: 'Delete Alert?', + deleteAlertDialogText: 'Are you sure you want to permanently delete this alert? This action cannot be undone.' +}; diff --git a/app/src/contexts/observationsTableContext.tsx b/app/src/contexts/observationsTableContext.tsx index a4f2453d56..189c2b0242 100644 --- a/app/src/contexts/observationsTableContext.tsx +++ b/app/src/contexts/observationsTableContext.tsx @@ -338,7 +338,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Pagination model const [paginationModel, setPaginationModel] = useState({ page: 0, - pageSize: 50 + pageSize: 25 }); // Sort model @@ -504,6 +504,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex row.itis_tsn && getTsnMeasurementTypeDefinitionMap(row.itis_tsn); } + // TODO: Either latitude/longitude OR sampling period is required, and either observation date OR sampling period is required const requiredStandardColumns: (keyof IObservationTableRow)[] = [ 'observation_subcount_sign_id', 'count', diff --git a/app/src/contexts/surveyContext.tsx b/app/src/contexts/surveyContext.tsx index 2443b62611..f1b5ed2a44 100644 --- a/app/src/contexts/surveyContext.tsx +++ b/app/src/contexts/surveyContext.tsx @@ -1,7 +1,6 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; -import { IGetSampleSiteResponse } from 'interfaces/useSamplingSiteApi.interface'; import { IGetSurveyAttachmentsResponse, IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; import { IGetTechniquesResponse } from 'interfaces/useTechniqueApi.interface'; import { createContext, PropsWithChildren, useEffect, useMemo } from 'react'; @@ -30,18 +29,10 @@ export interface ISurveyContext { */ artifactDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSurveyAttachmentsResponse, unknown>; - /** - * The Data Loader used to load survey sample site data - * - * @type {DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>} - * @memberof ISurveyContext - */ - sampleSiteDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>; - /** * The Data Loader used to load survey techniques * - * @type {DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>} + * @type {DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>} * @memberof ISurveyContext */ techniqueDataLoader: DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>; @@ -74,7 +65,6 @@ export interface ISurveyContext { 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>, critterDataLoader: {} as DataLoader<[project_id: number, survey_id: number], ICritterSimpleResponse[], unknown>, projectId: -1, @@ -85,7 +75,6 @@ export const SurveyContextProvider = (props: PropsWithChildren{props.children}; }; diff --git a/app/src/features/admin/AdminManagePage.tsx b/app/src/features/admin/AdminManagePage.tsx new file mode 100644 index 0000000000..07a7db5cbb --- /dev/null +++ b/app/src/features/admin/AdminManagePage.tsx @@ -0,0 +1,67 @@ +import Box from '@mui/material/Box'; +import Container from '@mui/material/Container'; +import PageHeader from 'components/layout/PageHeader'; +import { AdministrativeActivityStatusType, AdministrativeActivityType } from 'constants/misc'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; +import AlertContainer from './alert/AlertContainer'; +import AccessRequestContainer from './users/access-requests/AccessRequestContainer'; +import ActiveUsersList from './users/active/ActiveUsersList'; + +/** + * Page to display admin functionality for managing users, alerts, etc. + * + * @return {*} + */ +const AdminManagePage = () => { + const biohubApi = useBiohubApi(); + + // ACCESS REQUESTS + const accessRequestsDataLoader = useDataLoader(() => + biohubApi.admin.getAdministrativeActivities( + [AdministrativeActivityType.SYSTEM_ACCESS], + [ + AdministrativeActivityStatusType.PENDING, + AdministrativeActivityStatusType.REJECTED, + AdministrativeActivityStatusType.ACTIONED + ] + ) + ); + + useEffect(() => { + accessRequestsDataLoader.load(); + }, [accessRequestsDataLoader]); + + // ACTIVE USERS + const activeUsersDataLoader = useDataLoader(() => biohubApi.user.getUsersList()); + useEffect(() => { + activeUsersDataLoader.load(); + }, [activeUsersDataLoader]); + + const refreshAccessRequests = () => { + accessRequestsDataLoader.refresh(); + activeUsersDataLoader.refresh(); + }; + + const refreshActiveUsers = () => { + activeUsersDataLoader.refresh(); + }; + + return ( + <> + + + + + + + + + + + + ); +}; + +export default AdminManagePage; diff --git a/app/src/features/admin/AdminUsersRouter.tsx b/app/src/features/admin/AdminRouter.tsx similarity index 58% rename from app/src/features/admin/AdminUsersRouter.tsx rename to app/src/features/admin/AdminRouter.tsx index 29daa60b0d..d1330e87c1 100644 --- a/app/src/features/admin/AdminUsersRouter.tsx +++ b/app/src/features/admin/AdminRouter.tsx @@ -2,31 +2,31 @@ import React from 'react'; import { Redirect, Route, Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; -import ManageUsersPage from './users/ManageUsersPage'; +import AdminManagePage from './AdminManagePage'; import UsersDetailPage from './users/projects/UsersDetailPage'; /** - * Router for all `/admin/users/*` pages. + * Router for all `/admin/manage/*` pages. * * @return {*} */ -const AdminUsersRouter: React.FC = () => { +const AdminRouter: React.FC = () => { return ( - - + + - + {/* Catch any unknown routes, and re-direct to the not found page */} - + ); }; -export default AdminUsersRouter; +export default AdminRouter; diff --git a/app/src/features/admin/alert/AlertContainer.tsx b/app/src/features/admin/alert/AlertContainer.tsx new file mode 100644 index 0000000000..b1c9a22b71 --- /dev/null +++ b/app/src/features/admin/alert/AlertContainer.tsx @@ -0,0 +1,134 @@ +import { mdiCheck, mdiExclamationThick, mdiPlus } 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 Paper from '@mui/material/Paper'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlertFilterParams } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import CreateAlert from './create/CreateAlert'; +import DeleteAlert from './delete/DeleteAlert'; +import EditAlert from './edit/EditAlert'; +import AlertTable from './table/AlertTable'; + +enum AlertViewEnum { + ACTIVE = 'ACTIVE', + EXPIRED = 'EXPIRED' +} + +/** + * Container for displaying a list of alerts created by system administrators + */ +const AlertListContainer = () => { + const biohubApi = useBiohubApi(); + const [activeView, setActiveView] = useState(AlertViewEnum.ACTIVE); + const [modalState, setModalState] = useState({ + create: false, + edit: false, + delete: false + }); + const [alertId, setAlertId] = useState(null); + + const filters: IAlertFilterParams = + activeView === AlertViewEnum.ACTIVE ? { expiresAfter: dayjs().format() } : { expiresBefore: dayjs().format() }; + + // Load alerts based on filters + const alertDataLoader = useDataLoader((filters: IAlertFilterParams) => biohubApi.alert.getAlerts(filters)); + + // Define views + const views = [ + { value: AlertViewEnum.ACTIVE, label: 'Active', icon: mdiExclamationThick }, + { value: AlertViewEnum.EXPIRED, label: 'Expired', icon: mdiCheck } + ]; + + const closeModal = () => { + alertDataLoader.refresh(filters); + setModalState({ create: false, edit: false, delete: false }); + setAlertId(null); + }; + + useEffect(() => { + alertDataLoader.refresh(filters); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView]); + + return ( + + + + Alerts  + + + + + + view && setActiveView(view)} + exclusive + sx={{ + width: '100%', + gap: 1, + '& Button': { + py: 0.5, + px: 1.5, + border: 'none !important', + fontWeight: 700, + borderRadius: '4px !important', + fontSize: '0.875rem', + letterSpacing: '0.02rem' + } + }}> + {views.map(({ value, label, icon }) => ( + }> + {label} + + ))} + + + + + {/* Modals */} + + {alertId && modalState.edit && } + {alertId && modalState.delete && ( + + )} + + { + setAlertId(id); + setModalState((prev) => ({ ...prev, edit: true })); + }} + onDelete={(id) => { + setAlertId(id); + setModalState((prev) => ({ ...prev, delete: true })); + }} + /> + + + ); +}; + +export default AlertListContainer; diff --git a/app/src/features/admin/alert/create/CreateAlert.tsx b/app/src/features/admin/alert/create/CreateAlert.tsx new file mode 100644 index 0000000000..58253fa666 --- /dev/null +++ b/app/src/features/admin/alert/create/CreateAlert.tsx @@ -0,0 +1,118 @@ +import Typography from '@mui/material/Typography'; +import EditDialog from 'components/dialog/EditDialog'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { AlertI18N } from 'constants/i18n'; +import { ISnackbarProps } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import { AlertSeverity, IAlertCreateObject } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import yup from 'utils/YupSchema'; +import AlertForm from '../form/AlertForm'; + +const AlertYupSchema = yup.object().shape({ + name: yup.string().trim().max(50, 'Name cannot exceed 50 characters').required('Name is required'), + message: yup.string().max(250, 'Message cannot exceed 250 characters').required('Message is required'), + alert_type_id: yup.number().integer().required('Page is required'), + severity: yup.string().required('Style is required'), + record_end_date: yup.string().isValidDateString().nullable() +}); + +interface ICreateAlertProps { + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog containing the alert form for creating a new system alert + * + * @param {ICreateAlertProps} props + */ +const CreateAlert = (props: ICreateAlertProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const dialogContext = useDialogContext(); + const codesContext = useCodesContext(); + + const biohubApi = useBiohubApi(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext]); + + const alertTypeOptions = + codesContext.codesDataLoader.data?.alert_types.map((type) => ({ + value: type.id, + label: type.name + })) ?? []; + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: AlertI18N.createErrorTitle, + dialogText: AlertI18N.createErrorText, + onClose: () => dialogContext.setErrorDialog({ open: false }), + onOk: () => dialogContext.setErrorDialog({ open: false }), + ...textDialogProps, + open: true + }); + }; + + const handleSubmitAlert = async (values: IAlertCreateObject) => { + try { + setIsSubmitting(true); + + await biohubApi.alert.createAlert(values); + + // creation was a success, tell parent to refresh + props.onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert '{values.name}' created + + ), + open: true + }); + } catch (error: any) { + showCreateErrorDialog({ + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError).errors + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + , + initialValues: { + name: '', + message: '', + alert_type_id: '' as unknown as number, + severity: 'info' as AlertSeverity, + data: null, + record_end_date: null + }, + validationSchema: AlertYupSchema + }} + dialogSaveButtonLabel="Create" + onCancel={() => props.onClose()} + onSave={(formValues) => { + handleSubmitAlert(formValues); + }} + /> + ); +}; + +export default CreateAlert; diff --git a/app/src/features/admin/alert/delete/DeleteAlert.tsx b/app/src/features/admin/alert/delete/DeleteAlert.tsx new file mode 100644 index 0000000000..c836e1c484 --- /dev/null +++ b/app/src/features/admin/alert/delete/DeleteAlert.tsx @@ -0,0 +1,91 @@ +import Typography from '@mui/material/Typography'; +import YesNoDialog from 'components/dialog/YesNoDialog'; +import { AlertI18N } from 'constants/i18n'; +import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useContext, useEffect } from 'react'; + +interface IDeleteAlertProps { + alertId: number; + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog for deleting an alert + * + * @param {IDeleteAlertProps} props + * @returns + */ +const DeleteAlert = (props: IDeleteAlertProps) => { + const { alertId, open, onClose } = props; + const dialogContext = useContext(DialogContext); + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => biohubApi.alert.getAlertById(alertId)); + + useEffect(() => { + alertDataLoader.load(); + }, [alertDataLoader]); + + // API Error dialog + const showDeleteErrorDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: AlertI18N.deleteAlertErrorTitle, + dialogText: AlertI18N.deleteAlertErrorText, + open: true, + onYes: async () => dialogContext.setYesNoDialog({ open: false }), + onClose: () => dialogContext.setYesNoDialog({ open: false }) + }); + }; + + // Success snack bar + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + + const deleteAlert = async () => { + try { + await biohubApi.alert.deleteAlert(alertId); + // delete was a success, tell parent to refresh + onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert deleted + + ), + open: true + }); + } catch (error) { + // error deleting, show dialog that says you need to remove references + onClose(false); + showDeleteErrorDialog(); + } + }; + + if (!alertDataLoader.isReady || !alertDataLoader.data) { + return <>; + } + + return ( + { + deleteAlert(); + }} + onClose={() => {}} + onNo={() => onClose()} + /> + ); +}; + +export default DeleteAlert; diff --git a/app/src/features/admin/alert/edit/EditAlert.tsx b/app/src/features/admin/alert/edit/EditAlert.tsx new file mode 100644 index 0000000000..0a7cc993d2 --- /dev/null +++ b/app/src/features/admin/alert/edit/EditAlert.tsx @@ -0,0 +1,130 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; +import EditDialog from 'components/dialog/EditDialog'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { AlertI18N } from 'constants/i18n'; +import { ISnackbarProps } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlertUpdateObject } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import yup from 'utils/YupSchema'; +import AlertForm from '../form/AlertForm'; + +interface IEditAlertProps { + alertId: number; + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog containing the alert form for editing an existing system alert + * + * @param {IEditAlertProps} props + * + */ +const EditAlert = (props: IEditAlertProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const dialogContext = useDialogContext(); + const codesContext = useCodesContext(); + + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => biohubApi.alert.getAlertById(props.alertId)); + + useEffect(() => { + alertDataLoader.load(); + codesContext.codesDataLoader.load(); + }, [alertDataLoader, codesContext]); + + const alertTypeOptions = + codesContext.codesDataLoader.data?.alert_types.map((type) => ({ + value: type.id, + label: type.name + })) ?? []; + + // This is placed inside the `EditAlert` component to make use of an API call to check for used names + // The API call would violate the rules of react hooks if placed in an object outside of the component + // Reference: https://react.dev/warnings/invalid-hook-call-warning + const AlertYupSchema = yup.object().shape({ + name: yup.string().trim().max(50, 'Name cannot exceed 50 characters').required('Name is required'), + message: yup.string().max(250, 'Message cannot exceed 250 characters').required('Message is required'), + record_end_date: yup.string().isValidDateString().nullable() + }); + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: AlertI18N.updateErrorTitle, + dialogText: AlertI18N.updateErrorText, + onClose: () => dialogContext.setErrorDialog({ open: false }), + onOk: () => dialogContext.setErrorDialog({ open: false }), + ...textDialogProps, + open: true + }); + }; + + const handleSubmit = async (values: IAlertUpdateObject) => { + try { + setIsSubmitting(true); + + await biohubApi.alert.updateAlert(values); + + // creation was a success, tell parent to refresh + props.onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert '{values.name}' saved + + ), + open: true + }); + } catch (error: any) { + showCreateErrorDialog({ + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError).errors + }); + } finally { + setIsSubmitting(false); + } + }; + + if (!alertDataLoader.isReady || !alertDataLoader.data) { + return ; + } + + return ( + , + initialValues: { + alert_id: alertDataLoader.data.alert_id, + name: alertDataLoader.data.name, + message: alertDataLoader.data.message, + alert_type_id: alertDataLoader.data.alert_type_id, + severity: alertDataLoader.data.severity, + data: alertDataLoader.data.data, + record_end_date: alertDataLoader.data.record_end_date + }, + validationSchema: AlertYupSchema + }} + dialogSaveButtonLabel="Save" + onCancel={() => props.onClose()} + onSave={(formValues) => { + handleSubmit(formValues); + }} + /> + ); +}; + +export default EditAlert; diff --git a/app/src/features/admin/alert/form/AlertForm.tsx b/app/src/features/admin/alert/form/AlertForm.tsx new file mode 100644 index 0000000000..3190abf843 --- /dev/null +++ b/app/src/features/admin/alert/form/AlertForm.tsx @@ -0,0 +1,81 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; +import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { DateField } from 'components/fields/DateField'; +import { useFormikContext } from 'formik'; +import { IAlertCreateObject } from 'interfaces/useAlertApi.interface'; + +interface IAlertFormProps { + alertTypeOptions: IAutocompleteFieldOption[]; +} + +/** + * Form used to create and update system alerts, used by system administrators + * + */ +const AlertForm = (props: IAlertFormProps) => { + const { alertTypeOptions } = props; + + const { values } = useFormikContext(); + + return ( + <> +
+ + Display information + + + + + + + + + + + Expiry date (optional) + + + + +
+ + Preview + + + + ); +}; + +export default AlertForm; diff --git a/app/src/features/admin/alert/table/AlertTable.tsx b/app/src/features/admin/alert/table/AlertTable.tsx new file mode 100644 index 0000000000..47793fdb16 --- /dev/null +++ b/app/src/features/admin/alert/table/AlertTable.tsx @@ -0,0 +1,121 @@ +import Box from '@mui/material/Box'; +import { green, red } from '@mui/material/colors'; +import { GridColDef } from '@mui/x-data-grid'; +import AlertBar from 'components/alert/AlertBar'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { useCodesContext } from 'hooks/useContext'; +import { AlertSeverity, IAlert } from 'interfaces/useAlertApi.interface'; +import AlertTableActionsMenu from './components/AlertTableActionsMenu'; + +export interface IAlertTableTableProps { + alerts: IAlert[]; + onEdit: (alertId: number) => void; + onDelete: (alertId: number) => void; +} + +export interface IAlertTableRow { + id: number; + alert_type_id: number; + severity: AlertSeverity; + name: string; + message: string; + data: object | null; + record_end_date: string | null; + status: 'expired' | 'active'; +} + +/** + * Data grid table displaying alerts created by system administrators + * + * @param {IAlertTableTableProps} props + */ +const AlertTable = (props: IAlertTableTableProps) => { + const codesContext = useCodesContext(); + + const rows: IAlertTableRow[] = props.alerts.map((alert) => ({ ...alert, id: alert.alert_id })); + + const columns: GridColDef[] = [ + { + field: 'preview', + headerName: 'Alert', + flex: 1, + renderCell: (params) => ( + + + + ) + }, + { + field: 'alert_type_id', + headerName: 'Page', + description: 'Page that the alert displays on.', + headerAlign: 'left', + align: 'left', + width: 150, + renderCell: (params) => + codesContext.codesDataLoader.data?.alert_types.find((code) => code.id === params.row.alert_type_id)?.name + }, + { + field: 'record_end_date', + headerName: 'Expiry date', + description: 'Status of the alert.', + headerAlign: 'left', + align: 'left', + width: 150, + renderCell: (params) => + params.row.record_end_date ? dayjs(params.row.record_end_date).format(DATE_FORMAT.MediumDateFormat) : null + }, + { + field: 'status', + headerName: 'Status', + description: 'Status of the alert.', + headerAlign: 'center', + align: 'center', + width: 150, + renderCell: (params) => ( + + ) + }, + { + field: 'actions', + type: 'actions', + sortable: false, + align: 'right', + flex: 0, + renderCell: (params) => ( + + ) + } + ]; + + return ( + 'auto'} + rows={rows} + getRowId={(row) => `alert-${row.alert_id}`} + columns={columns} + pageSizeOptions={[5]} + rowSelection={false} + checkboxSelection={false} + hideFooter + disableRowSelectionOnClick + disableColumnSelector + disableColumnFilter + disableColumnMenu + sortingOrder={['asc', 'desc']} + data-testid="alert-table" + /> + ); +}; + +export default AlertTable; diff --git a/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx b/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx new file mode 100644 index 0000000000..17a7743fe7 --- /dev/null +++ b/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx @@ -0,0 +1,81 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import { useState } from 'react'; + +export interface IAlertTableActionsMenuProps { + alertId: number; + onEdit: (alertId: number) => void; + onDelete: (alertId: number) => void; +} + +/** + * Actions displayed in the context menu of an alert row in the alert table data grid + * + * @param {IAlertTableActionsMenuProps} props + */ +const AlertTableActionsMenu = (props: IAlertTableActionsMenuProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleClick = (event: any) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + + + { + handleClose(); + props.onEdit(props.alertId); + }} + data-testid="alert-table-row-edit"> + + + + Edit + + { + handleClose(); + props.onDelete(props.alertId); + }} + data-testid="alert-table-row-delete"> + + + + Delete + + + + ); +}; + +export default AlertTableActionsMenu; diff --git a/app/src/features/admin/users/ManageUsersPage.test.tsx b/app/src/features/admin/users/ManageUsersPage.test.tsx deleted file mode 100644 index ea17d4e119..0000000000 --- a/app/src/features/admin/users/ManageUsersPage.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { AuthStateContext } from 'contexts/authStateContext'; -import { CodesContext, ICodesContext } from 'contexts/codesContext'; -import { createMemoryHistory } from 'history'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { DataLoader } from 'hooks/useDataLoader'; -import { Router } from 'react-router'; -import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; -import { codes } from 'test-helpers/code-helpers'; -import { cleanup, render, waitFor } from 'test-helpers/test-utils'; -import ManageUsersPage from './ManageUsersPage'; - -const history = createMemoryHistory(); - -const renderContainer = () => { - const authState = getMockAuthState({ base: SystemAdminAuthState }); - - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes, - load: () => {} - } as DataLoader - }; - - return render( - - - - - - - - ); -}; - -jest.mock('../../../hooks/useBioHubApi'); -const mockBiohubApi = useBiohubApi as jest.Mock; - -const mockUseApi = { - admin: { - getAdministrativeActivities: jest.fn() - }, - user: { - getUsersList: jest.fn() - }, - codes: { - getAllCodeSets: jest.fn() - } -}; - -describe('ManageUsersPage', () => { - beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseApi); - mockUseApi.admin.getAdministrativeActivities.mockClear(); - mockUseApi.user.getUsersList.mockClear(); - mockUseApi.codes.getAllCodeSets.mockClear(); - - // mock code set response - mockUseApi.codes.getAllCodeSets.mockReturnValue({ - system_roles: [{ id: 1, name: 'Role 1' }], - administrative_activity_status_type: [ - { id: 1, name: 'Actioned' }, - { id: 1, name: 'Rejected' } - ] - }); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders the main page content correctly', async () => { - mockUseApi.admin.getAdministrativeActivities.mockReturnValue([]); - mockUseApi.user.getUsersList.mockReturnValue([]); - - const { getByText } = renderContainer(); - - await waitFor(() => { - expect(getByText('Manage Users')).toBeVisible(); - }); - }); - - it('renders the access requests and active users component', async () => { - mockUseApi.admin.getAdministrativeActivities.mockReturnValue([]); - mockUseApi.user.getUsersList.mockReturnValue([]); - - const { getByText } = renderContainer(); - - await waitFor(() => { - expect(getByText('No Pending Access Requests')).toBeVisible(); - expect(getByText('No Active Users')).toBeVisible(); - }); - }); -}); diff --git a/app/src/features/admin/users/ManageUsersPage.tsx b/app/src/features/admin/users/ManageUsersPage.tsx deleted file mode 100644 index 9c11f7e044..0000000000 --- a/app/src/features/admin/users/ManageUsersPage.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; -import Container from '@mui/material/Container'; -import PageHeader from 'components/layout/PageHeader'; -import { AdministrativeActivityStatusType, AdministrativeActivityType } from 'constants/misc'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; -import { ISystemUser } from 'interfaces/useUserApi.interface'; -import React, { useEffect, useState } from 'react'; -import AccessRequestContainer from './access-requests/AccessRequestContainer'; -import ActiveUsersList from './active/ActiveUsersList'; - -/** - * Page to display user management data/functionality. - * - * @return {*} - */ -const ManageUsersPage: React.FC = () => { - const biohubApi = useBiohubApi(); - - const [accessRequests, setAccessRequests] = useState([]); - const [isLoadingAccessRequests, setIsLoadingAccessRequests] = useState(false); - const [hasLoadedAccessRequests, setHasLoadedAccessRequests] = useState(false); - - const [activeUsers, setActiveUsers] = useState([]); - const [isLoadingActiveUsers, setIsLoadingActiveUsers] = useState(false); - const [hasLoadedActiveUsers, setHasLoadedActiveUsers] = useState(false); - - const [codes, setCodes] = useState(); - const [isLoadingCodes, setIsLoadingCodes] = useState(false); - - const refreshAccessRequests = async () => { - const accessResponse = await biohubApi.admin.getAdministrativeActivities( - [AdministrativeActivityType.SYSTEM_ACCESS], - [ - AdministrativeActivityStatusType.PENDING, - AdministrativeActivityStatusType.REJECTED, - AdministrativeActivityStatusType.ACTIONED - ] - ); - - setAccessRequests(accessResponse); - }; - - useEffect(() => { - const getAccessRequests = async () => { - const accessResponse = await biohubApi.admin.getAdministrativeActivities( - [AdministrativeActivityType.SYSTEM_ACCESS], - [ - AdministrativeActivityStatusType.PENDING, - AdministrativeActivityStatusType.REJECTED, - AdministrativeActivityStatusType.ACTIONED - ] - ); - - setAccessRequests(() => { - setHasLoadedAccessRequests(true); - setIsLoadingAccessRequests(false); - return accessResponse; - }); - }; - - if (isLoadingAccessRequests || hasLoadedAccessRequests) { - return; - } - - setIsLoadingAccessRequests(true); - - getAccessRequests(); - }, [biohubApi.admin, isLoadingAccessRequests, hasLoadedAccessRequests]); - - const refreshActiveUsers = async () => { - const activeUsersResponse = await biohubApi.user.getUsersList(); - - setActiveUsers(activeUsersResponse); - }; - - useEffect(() => { - const getActiveUsers = async () => { - const activeUsersResponse = await biohubApi.user.getUsersList(); - - setActiveUsers(() => { - setHasLoadedActiveUsers(true); - setIsLoadingActiveUsers(false); - return activeUsersResponse; - }); - }; - - if (hasLoadedActiveUsers || isLoadingActiveUsers) { - return; - } - - setIsLoadingActiveUsers(true); - - getActiveUsers(); - }, [biohubApi, isLoadingActiveUsers, hasLoadedActiveUsers]); - - useEffect(() => { - const getCodes = async () => { - const codesResponse = await biohubApi.codes.getAllCodeSets(); - - if (!codesResponse) { - // TODO error handling/messaging - return; - } - - setCodes(() => { - setIsLoadingCodes(false); - return codesResponse; - }); - }; - - if (isLoadingCodes || codes) { - return; - } - - setIsLoadingCodes(true); - - getCodes(); - }, [biohubApi.codes, isLoadingCodes, codes]); - - if (!hasLoadedAccessRequests || !hasLoadedActiveUsers || !codes) { - return ; - } - - return ( - <> - - - { - refreshAccessRequests(); - refreshActiveUsers(); - }} - /> - - - - - - ); -}; - -export default ManageUsersPage; diff --git a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx index 051a9616ce..154f211262 100644 --- a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx +++ b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx @@ -9,7 +9,6 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup/ToggleButtonGroup import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { useState } from 'react'; import AccessRequestActionedList from './list/actioned/AccessRequestActionedList'; import AccessRequestPendingList from './list/pending/AccessRequestPendingList'; @@ -17,7 +16,6 @@ import AccessRequestRejectedList from './list/rejected/AccessRequestRejectedList export interface IAccessRequestContainerProps { accessRequests: IGetAccessRequestsListResponse[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -32,7 +30,8 @@ enum AccessRequestViewEnum { * */ const AccessRequestContainer = (props: IAccessRequestContainerProps) => { - const { accessRequests, codes, refresh } = props; + const { accessRequests, refresh } = props; + const [activeView, setActiveView] = useState(AccessRequestViewEnum.PENDING); const views = [ @@ -107,7 +106,7 @@ const AccessRequestContainer = (props: IAccessRequestContainerProps) => { {activeView === AccessRequestViewEnum.PENDING && ( - + )} {activeView === AccessRequestViewEnum.ACTIONED && ( diff --git a/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx b/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx index 8144e3da8c..07a362026c 100644 --- a/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx +++ b/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx @@ -11,9 +11,9 @@ import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import dayjs from 'dayjs'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext } from 'hooks/useContext'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; -import { useContext, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import ReviewAccessRequestForm, { IReviewAccessRequestForm, ReviewAccessRequestFormInitialValues, @@ -22,7 +22,6 @@ import ReviewAccessRequestForm, { interface IAccessRequestPendingListProps { accessRequests: IGetAccessRequestsListResponse[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -33,7 +32,10 @@ interface IAccessRequestPendingListProps { * @returns */ const AccessRequestPendingList = (props: IAccessRequestPendingListProps) => { - const { accessRequests, codes, refresh } = props; + const { accessRequests, refresh } = props; + + const codesContext = useCodesContext(); + const codes = codesContext.codesDataLoader?.data; const biohubApi = useBiohubApi(); const dialogContext = useContext(DialogContext); @@ -41,6 +43,10 @@ const AccessRequestPendingList = (props: IAccessRequestPendingListProps) => { const [showReviewDialog, setShowReviewDialog] = useState(false); const [activeReview, setActiveReview] = useState(null); + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + const showSnackBar = (textDialogProps?: Partial) => { dialogContext.setSnackbar({ ...textDialogProps, open: true }); }; diff --git a/app/src/features/admin/users/active/ActiveUsersList.test.tsx b/app/src/features/admin/users/active/ActiveUsersList.test.tsx index 272db2df25..755bd0f8e8 100644 --- a/app/src/features/admin/users/active/ActiveUsersList.test.tsx +++ b/app/src/features/admin/users/active/ActiveUsersList.test.tsx @@ -60,7 +60,6 @@ describe('ActiveUsersList', () => { it('shows `No Active Users` when there are no active users', async () => { const { getByText } = renderContainer({ activeUsers: [], - codes: codes, refresh: () => {} }); @@ -72,7 +71,6 @@ describe('ActiveUsersList', () => { it('renders the add new users button correctly', async () => { const { getByTestId } = renderContainer({ activeUsers: [], - codes: codes, refresh: () => {} }); diff --git a/app/src/features/admin/users/active/ActiveUsersList.tsx b/app/src/features/admin/users/active/ActiveUsersList.tsx index f6380948e2..2e9073a02c 100644 --- a/app/src/features/admin/users/active/ActiveUsersList.tsx +++ b/app/src/features/admin/users/active/ActiveUsersList.tsx @@ -18,7 +18,6 @@ import { APIError } from 'hooks/api/useAxios'; import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useCodesContext } from 'hooks/useContext'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; import { useContext, useEffect, useState } from 'react'; import { useHistory } from 'react-router'; @@ -32,7 +31,6 @@ import AddSystemUsersForm, { export interface IActiveUsersListProps { activeUsers: ISystemUser[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -43,7 +41,7 @@ const pageSizeOptions = [10, 25, 50]; * */ const ActiveUsersList = (props: IActiveUsersListProps) => { - const { activeUsers, codes, refresh } = props; + const { activeUsers, refresh } = props; const authStateContext = useAuthStateContext(); const biohubApi = useBiohubApi(); @@ -59,6 +57,12 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); + const codes = codesContext.codesDataLoader.data; + + if (!codes) { + return <>; + } + const activeUsersColumnDefs: GridColDef[] = [ { field: 'system_user_id', @@ -86,7 +90,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { {params.row.display_name || 'No identifier'} @@ -159,7 +163,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { menuLabel: 'View Users Details', menuOnClick: () => history.push({ - pathname: `/admin/users/${params.row.system_user_id}`, + pathname: `/admin/manage/users/${params.row.system_user_id}`, state: params.row }) }, diff --git a/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx b/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx index cda6451ea1..ebf91a260c 100644 --- a/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx @@ -41,7 +41,7 @@ describe('UsersDetailHeader', () => { }); it('renders correctly when selectedUser are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByTestId } = render( @@ -56,7 +56,7 @@ describe('UsersDetailHeader', () => { describe('Are you sure? Dialog', () => { it('Remove User button opens dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -74,7 +74,7 @@ describe('UsersDetailHeader', () => { }); it('does nothing if the user clicks `Cancel` or away from the dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -93,16 +93,16 @@ describe('UsersDetailHeader', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); - it('deletes the user and routes user back to Manage Users page', async () => { + it('deletes the user and routes user back to Admin page', async () => { mockUseApi.user.deleteSystemUser.mockResolvedValue({ response: 200 } as any); - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -121,7 +121,7 @@ describe('UsersDetailHeader', () => { fireEvent.click(getByText('Remove')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users'); + expect(history.location.pathname).toEqual('/admin/manage/users'); }); }); }); diff --git a/app/src/features/admin/users/projects/UsersDetailHeader.tsx b/app/src/features/admin/users/projects/UsersDetailHeader.tsx index 48d73acebd..ac52ecd4f7 100644 --- a/app/src/features/admin/users/projects/UsersDetailHeader.tsx +++ b/app/src/features/admin/users/projects/UsersDetailHeader.tsx @@ -79,7 +79,7 @@ const UsersDetailHeader: React.FC = (props) => { open: true }); - history.push('/admin/users'); + history.push('/admin/manage/users'); } catch (error) { openErrorDialog({ dialogTitle: SystemUserI18N.removeUserErrorTitle, @@ -93,8 +93,8 @@ const UsersDetailHeader: React.FC = (props) => { '}> - - Manage Users + + Admin {userDetails.display_name} diff --git a/app/src/features/admin/users/projects/UsersDetailPage.test.tsx b/app/src/features/admin/users/projects/UsersDetailPage.test.tsx index e12cf2326b..dcf73b1557 100644 --- a/app/src/features/admin/users/projects/UsersDetailPage.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailPage.test.tsx @@ -51,7 +51,7 @@ describe('UsersDetailPage', () => { }); it('renders correctly when selectedUser are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.user.getUserById.mockResolvedValue({ system_user_id: 1, diff --git a/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx b/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx index ebac9cf029..4c26dbc221 100644 --- a/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx @@ -52,7 +52,7 @@ describe('UsersDetailProjects', () => { }); it('shows circular spinner when assignedProjects not yet loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByTestId } = render( @@ -66,7 +66,7 @@ describe('UsersDetailProjects', () => { }); it('renders empty list correctly when assignedProjects empty and loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }] @@ -87,7 +87,7 @@ describe('UsersDetailProjects', () => { }); it('renders list of a single project correctly when assignedProjects are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -119,7 +119,7 @@ describe('UsersDetailProjects', () => { }); it('renders list of a multiple projects correctly when assignedProjects are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -161,7 +161,7 @@ describe('UsersDetailProjects', () => { }); it('routes to project id details on click', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -199,7 +199,7 @@ describe('UsersDetailProjects', () => { describe('Are you sure? Dialog', () => { it('does nothing if the user clicks `Cancel` or away from the dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -239,12 +239,12 @@ describe('UsersDetailProjects', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); it('deletes User from project if the user clicks on `Remove` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -317,7 +317,7 @@ describe('UsersDetailProjects', () => { describe('Change users Project Role', () => { it('renders list of roles to change per project', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -361,7 +361,7 @@ describe('UsersDetailProjects', () => { }); it('renders dialog pop on role selection, does nothing if user clicks `Cancel` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -414,12 +414,12 @@ describe('UsersDetailProjects', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); it('renders dialog pop on role selection, Changes role on click of `Change Role` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], diff --git a/app/src/features/alert/banner/SystemAlertBanner.tsx b/app/src/features/alert/banner/SystemAlertBanner.tsx new file mode 100644 index 0000000000..8e5863969f --- /dev/null +++ b/app/src/features/alert/banner/SystemAlertBanner.tsx @@ -0,0 +1,116 @@ +import { mdiChevronDown, mdiChevronUp } 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 grey from '@mui/material/colors/grey'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlert, SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; + +interface ISystemAlertBannerProps { + alertTypes?: SystemAlertBannerEnum[]; +} + +// The number of alerts to show on initial page load +const NumberOfAlertsShownInitially = 2; + +/** + * Stack of system alerts created by system administrators + * + * @param {ISystemAlertBannerProps} props + * @returns + */ +export const SystemAlertBanner = (props: ISystemAlertBannerProps) => { + const { alertTypes } = props; + + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => + biohubApi.alert.getAlerts({ types: alertTypes, expiresAfter: dayjs().format() }) + ); + + useEffect(() => { + alertDataLoader.load(); + }, [alertDataLoader]); + + const [isExpanded, setIsExpanded] = useState(false); + + const alerts = alertDataLoader.data?.alerts ?? []; + + const numberOfAlerts = alerts.length; + + const renderAlerts = (alerts: IAlert[]) => { + const visibleAlerts = []; + const collapsedAlerts = []; + + for (let index = 0; index < numberOfAlerts; index++) { + const alert = alerts[index]; + + const alertComponent = ( + + ); + + if (index < NumberOfAlertsShownInitially) { + visibleAlerts.push(alertComponent); + } else { + collapsedAlerts.push(alertComponent); + } + } + + return ( + + {visibleAlerts} + {collapsedAlerts.length > 0 && {collapsedAlerts}} + + ); + }; + + if (!numberOfAlerts) { + return null; + } + + return ( + + {renderAlerts(alerts)} + {numberOfAlerts > NumberOfAlertsShownInitially && ( + + )} + + ); +}; diff --git a/app/src/features/funding-sources/list/FundingSourcesListPage.tsx b/app/src/features/funding-sources/list/FundingSourcesListPage.tsx index 7b2360a41a..e303eb8b3c 100644 --- a/app/src/features/funding-sources/list/FundingSourcesListPage.tsx +++ b/app/src/features/funding-sources/list/FundingSourcesListPage.tsx @@ -10,10 +10,12 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import PageHeader from 'components/layout/PageHeader'; import { FundingSourceI18N } from 'constants/i18n'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import useDataLoaderError from 'hooks/useDataLoaderError'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import React, { useState } from 'react'; import CreateFundingSource from '../components/CreateFundingSource'; import DeleteFundingSource from '../components/DeleteFundingSource'; @@ -111,6 +113,7 @@ const FundingSourcesListPage: React.FC = () => { /> + diff --git a/app/src/features/projects/view/ProjectPage.tsx b/app/src/features/projects/view/ProjectPage.tsx index e26ec4fdf7..6dd72d2423 100644 --- a/app/src/features/projects/view/ProjectPage.tsx +++ b/app/src/features/projects/view/ProjectPage.tsx @@ -5,8 +5,10 @@ import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import { CodesContext } from 'contexts/codesContext'; import { ProjectContext } from 'contexts/projectContext'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import ProjectAttachments from 'features/projects/view/ProjectAttachments'; import SurveysListPage from 'features/surveys/list/SurveysListPage'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useContext, useEffect } from 'react'; import ProjectDetails from './ProjectDetails'; import ProjectHeader from './ProjectHeader'; @@ -36,6 +38,7 @@ const ProjectPage = () => { <> + diff --git a/app/src/features/standards/StandardsPage.tsx b/app/src/features/standards/StandardsPage.tsx index 7c963b5d93..9a1af27b41 100644 --- a/app/src/features/standards/StandardsPage.tsx +++ b/app/src/features/standards/StandardsPage.tsx @@ -6,6 +6,8 @@ import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import PageHeader from 'components/layout/PageHeader'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useState } from 'react'; import { StandardsToolbar } from './components/StandardsToolbar'; import { EnvironmentStandards } from './view/environment/EnvironmentStandards'; @@ -37,6 +39,7 @@ const StandardsPage = () => { <> + {/* TOOLBAR FOR SWITCHING VIEWS */} diff --git a/app/src/features/standards/view/components/AccordionStandardCard.tsx b/app/src/features/standards/view/components/AccordionStandardCard.tsx index ca6d4e50de..4966a82f4b 100644 --- a/app/src/features/standards/view/components/AccordionStandardCard.tsx +++ b/app/src/features/standards/view/components/AccordionStandardCard.tsx @@ -16,8 +16,9 @@ interface IAccordionStandardCardProps extends PaperProps { /** * Returns a collapsible paper component for displaying lookup values - * @param props - * @returns + * + * @param {PropsWithChildren} props + * @return {*} */ export const AccordionStandardCard = (props: PropsWithChildren) => { const { label, subtitle, children, colour, ornament, disableCollapse, ...paperProps } = props; @@ -33,27 +34,26 @@ export const AccordionStandardCard = (props: PropsWithChildren + - - - {label} - + + {label} + + {ornament} + {expandable && } - {expandable && } diff --git a/app/src/features/summary/SummaryPage.tsx b/app/src/features/summary/SummaryPage.tsx index b70623d604..39d50016db 100644 --- a/app/src/features/summary/SummaryPage.tsx +++ b/app/src/features/summary/SummaryPage.tsx @@ -6,8 +6,10 @@ import Paper from '@mui/material/Paper'; import PageHeader from 'components/layout/PageHeader'; import { SystemRoleGuard } from 'components/security/Guards'; import { SYSTEM_ROLE } from 'constants/roles'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { ListDataTableContainer } from 'features/summary/list-data/ListDataTableContainer'; import { TabularDataTableContainer } from 'features/summary/tabular-data/TabularDataTableContainer'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { Link as RouterLink } from 'react-router-dom'; /** @@ -36,6 +38,8 @@ const SummaryPage = () => { /> + + diff --git a/app/src/features/surveys/animals/AnimalHeader.tsx b/app/src/features/surveys/animals/AnimalHeader.tsx index 5ea0cc4c52..fc93d4771a 100644 --- a/app/src/features/surveys/animals/AnimalHeader.tsx +++ b/app/src/features/surveys/animals/AnimalHeader.tsx @@ -1,6 +1,7 @@ +import { mdiEye, mdiWifiMarker } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; @@ -19,6 +20,20 @@ export interface IAnimalHeaderProps { */ export const AnimalHeader = (props: IAnimalHeaderProps) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Telemetry', + to: `/admin/projects/${project_id}/surveys/${survey_id}/telemetry/details`, + icon: mdiWifiMarker + }, + { + label: 'Observations', + to: `/admin/projects/${project_id}/surveys/${survey_id}/observations`, + icon: mdiEye + } + ]; + return ( { to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Animals - + Animals } /> diff --git a/app/src/features/surveys/animals/AnimalPage.tsx b/app/src/features/surveys/animals/AnimalPage.tsx index 07058fdb2d..b8418906ad 100644 --- a/app/src/features/surveys/animals/AnimalPage.tsx +++ b/app/src/features/surveys/animals/AnimalPage.tsx @@ -1,9 +1,11 @@ import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import Box from '@mui/system/Box'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useAnimalPageContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useEffect } from 'react'; import { AnimalHeader } from './AnimalHeader'; import { AnimalListContainer } from './list/AnimalListContainer'; @@ -56,6 +58,7 @@ export const SurveyAnimalPage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> + = (props) => { survey_block_id: null, name: '', description: '', + geojson: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0] + }, + properties: { + name: 'Sample', + description: 'This is a placeholder.' + } + }, sample_block_count: 0 }, validationSchema: BlockCreateYupSchema diff --git a/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx index 960e881d35..b91ae82a53 100644 --- a/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx @@ -1,10 +1,10 @@ import EditDialog from 'components/dialog/EditDialog'; import BlockForm from './BlockForm'; -import { BlockEditYupSchema, ISurveyBlock } from './SurveyBlockForm'; +import { BlockEditYupSchema, IPostSurveyBlock } from './SurveyBlockForm'; interface IEditBlockProps { open: boolean; - initialData?: ISurveyBlock; + initialData?: IPostSurveyBlock; onSave: (data: any, index?: number) => void; onClose: () => void; } @@ -23,6 +23,7 @@ const EditSurveyBlockDialog: React.FC = (props) => { survey_block_id: initialData?.block.survey_block_id || null, name: initialData?.block.name || '', description: initialData?.block.description || '', + geojson: initialData?.block.geojson || '', sample_block_count: initialData?.block.sample_block_count }, validationSchema: BlockEditYupSchema diff --git a/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx index 98779ebf7f..6c4c893d1e 100644 --- a/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx @@ -12,6 +12,7 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import YesNoDialog from 'components/dialog/YesNoDialog'; import { useFormikContext } from 'formik'; +import { Feature } from 'geojson'; import { IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; import React, { useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; @@ -28,6 +29,7 @@ export const SurveyBlockInitialValues = { export const BlockCreateYupSchema = yup.object({ name: yup.string().required('Name is required').max(50, 'Maximum 50 characters'), description: yup.string().required('Description is required').max(250, 'Maximum 250 characters') + // TODO: Include geojson in validation after adding map control for blocks }); // Form validation for Block Item @@ -35,12 +37,13 @@ export const BlockEditYupSchema = BlockCreateYupSchema.shape({ sample_block_count: yup.number().required('Sample block count is required.') }); -export interface ISurveyBlock { +export interface IPostSurveyBlock { index: number; block: { survey_block_id: number | null; name: string; description: string; + geojson: Feature; sample_block_count: number; }; } @@ -50,7 +53,7 @@ const SurveyBlockForm: React.FC = () => { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isYesNoDialogOpen, setIsYesNoDialogOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); - const [editData, setEditData] = useState(undefined); + const [editData, setEditData] = useState(undefined); const formikProps = useFormikContext(); const { values, handleSubmit, setFieldValue } = formikProps; diff --git a/app/src/features/surveys/observations/SurveyObservationHeader.tsx b/app/src/features/surveys/observations/SurveyObservationHeader.tsx index 74bd6deb39..61e5974c46 100644 --- a/app/src/features/surveys/observations/SurveyObservationHeader.tsx +++ b/app/src/features/surveys/observations/SurveyObservationHeader.tsx @@ -1,6 +1,7 @@ +import { mdiPaw, mdiWifiMarker } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; @@ -13,6 +14,20 @@ export interface SurveyObservationHeaderProps { const SurveyObservationHeader: React.FC = (props) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Animals', + to: `/admin/projects/${project_id}/surveys/${survey_id}/animals/details`, + icon: mdiPaw + }, + { + label: 'Telemetry', + to: `/admin/projects/${project_id}/surveys/${survey_id}/telemetry`, + icon: mdiWifiMarker + } + ]; + return ( = (props) to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Observations - + Observations } /> diff --git a/app/src/features/surveys/observations/SurveyObservationPage.tsx b/app/src/features/surveys/observations/SurveyObservationPage.tsx index 775702eccd..ffce32625f 100644 --- a/app/src/features/surveys/observations/SurveyObservationPage.tsx +++ b/app/src/features/surveys/observations/SurveyObservationPage.tsx @@ -36,7 +36,6 @@ export const SurveyObservationPage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> - { const codesContext = useCodesContext(); - const surveyContext = useSurveyContext(); + const observationsPageContext = useObservationsPageContext(); const observationsTableContext = useObservationsTableContext(); + const observationsContext = useObservationsContext(); useEffect(() => { codesContext.codesDataLoader.load(); - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [ - codesContext.codesDataLoader, - surveyContext.projectId, - surveyContext.sampleSiteDataLoader, - surveyContext.surveyId - ]); - - // Collect sample sites - const surveySampleSites: IGetSampleLocationDetails[] = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); - - const sampleSiteOptions: ISampleSiteOption[] = useMemo( - () => - surveySampleSites.map((site) => ({ - survey_sample_site_id: site.survey_sample_site_id, - sample_site_name: site.name - })) ?? [], - [surveySampleSites] - ); - - // Collect sample methods - const surveySampleMethods: IGetSampleMethodDetails[] = surveySampleSites - .filter((sampleSite) => Boolean(sampleSite.sample_methods)) - .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: method.technique.name, - response_metric: - getCodesName(codesContext.codesDataLoader.data, 'method_response_metrics', method.method_response_metric_id) ?? '' - })); - - // Collect sample periods - const samplePeriodOptions: ISamplePeriodOption[] = surveySampleMethods - .filter((sampleMethod) => Boolean(sampleMethod.sample_periods)) - .map((sampleMethod) => sampleMethod.sample_periods as IGetSamplePeriodRecord[]) - .flat(2) - .map((samplePeriod: IGetSamplePeriodRecord) => ({ - survey_sample_period_id: samplePeriod.survey_sample_period_id, - survey_sample_method_id: samplePeriod.survey_sample_method_id, - sample_period_name: `${samplePeriod.start_date} ${samplePeriod.start_time ?? ''} - ${samplePeriod.end_date} ${ - samplePeriod.end_time ?? '' - }` - })); + }, [codesContext.codesDataLoader]); const observationSubcountSignOptions = useMemo( () => @@ -121,58 +72,94 @@ const ObservationsTableContainer = () => { [codesContext.codesDataLoader.data?.observation_subcount_signs] ); + const sampleLocationsCache = useSampleLocationsCache(); + + useEffect(() => { + if (!observationsContext.observationsDataLoader.data?.supplementaryObservationData.sample_sites?.length) { + return; + } + + sampleLocationsCache.updateCachedSampleLocationsRef( + observationsContext.observationsDataLoader.data.supplementaryObservationData.sample_sites + ); + }, [ + observationsContext.observationsDataLoader.data?.supplementaryObservationData.sample_sites, + sampleLocationsCache + ]); + // The column definitions of the columns to render in the observations table const columns: GridColDef[] = useMemo( - () => [ - // Add standard observation columns to the table - TaxonomyColDef({ hasError: observationsTableContext.hasError }), - SampleSiteColDef({ sampleSiteOptions, hasError: observationsTableContext.hasError }), - SampleMethodColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - SamplePeriodColDef({ samplePeriodOptions, hasError: observationsTableContext.hasError }), - ObservationSubcountSignColDef({ observationSubcountSignOptions, hasError: observationsTableContext.hasError }), - ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - GenericDateColDef({ - field: 'observation_date', - headerName: 'Date', - hasError: observationsTableContext.hasError, - description: 'The date when the observation was made' - }), - GenericTimeColDef({ - field: 'observation_time', - headerName: 'Time', - hasError: observationsTableContext.hasError, - description: 'The time of day when the observation was made' - }), - GenericLatitudeColDef({ - field: 'latitude', - headerName: 'Latitude', - hasError: observationsTableContext.hasError, - description: 'The latitude where the observation was made' - }), - GenericLongitudeColDef({ - field: 'longitude', - headerName: 'Longitude', - hasError: observationsTableContext.hasError, - description: 'The longitude where the observation was made' - }), - // Add measurement columns to the table - ...getMeasurementColumnDefinitions( - observationsTableContext.measurementColumns, - observationsTableContext.hasError - ), - // Add environment columns to the table - ...getEnvironmentColumnDefinitions( - observationsTableContext.environmentColumns, - observationsTableContext.hasError - ), - GenericCommentColDef({ - field: 'comment', - headerName: '', - hasError: observationsTableContext.hasError, - handleOpen: (params: GridRenderEditCellParams) => observationsTableContext.setCommentDialogParams(params), - handleClose: () => observationsTableContext.setCommentDialogParams(null) - }) - ], + () => { + return [ + // Add standard observation columns to the table + TaxonomyColDef({ hasError: observationsTableContext.hasError }), + SampleSiteColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + onSelectOption: (selectedSampleSite) => { + if (!selectedSampleSite) { + return; + } + + sampleLocationsCache.updateCachedSampleLocationsRef([selectedSampleSite]); + }, + hasError: observationsTableContext.hasError + }), + SampleMethodColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + SamplePeriodColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + ObservationSubcountSignColDef({ observationSubcountSignOptions, hasError: observationsTableContext.hasError }), + ObservationCountColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + GenericDateColDef({ + field: 'observation_date', + headerName: 'Date', + hasError: observationsTableContext.hasError, + description: 'The date when the observation was made' + }), + GenericTimeColDef({ + field: 'observation_time', + headerName: 'Time', + hasError: observationsTableContext.hasError, + description: 'The time of day when the observation was made' + }), + GenericLatitudeColDef({ + field: 'latitude', + headerName: 'Latitude', + hasError: observationsTableContext.hasError, + description: 'The latitude where the observation was made' + }), + GenericLongitudeColDef({ + field: 'longitude', + headerName: 'Longitude', + hasError: observationsTableContext.hasError, + description: 'The longitude where the observation was made' + }), + // Add measurement columns to the table + ...getMeasurementColumnDefinitions( + observationsTableContext.measurementColumns, + observationsTableContext.hasError + ), + // Add environment columns to the table + ...getEnvironmentColumnDefinitions( + observationsTableContext.environmentColumns, + observationsTableContext.hasError + ), + GenericCommentColDef({ + field: 'comment', + headerName: '', + hasError: observationsTableContext.hasError, + handleOpen: (params: GridRenderEditCellParams) => observationsTableContext.setCommentDialogParams(params), + handleClose: () => observationsTableContext.setCommentDialogParams(null) + }) + ]; + }, // observationsTableContext is listed as a missing dependency // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -180,10 +167,7 @@ const ObservationsTableContainer = () => { observationsTableContext.environmentColumns, observationsTableContext.hasError, observationsTableContext.measurementColumns, - observationsTableContext.setCommentDialogParams, - sampleMethodOptions, - samplePeriodOptions, - sampleSiteOptions + observationsTableContext.setCommentDialogParams ] ); @@ -282,7 +266,7 @@ const ObservationsTableContainer = () => { observationsTableContext.isDisabled || codesContext.codesDataLoader.isLoading } - columns={columns} + columns={[...columns]} /> diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx index 7001069440..c809c0f4f7 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx @@ -2,17 +2,29 @@ import Typography from '@mui/material/Typography'; import { GridCellParams, GridColDef } from '@mui/x-data-grid'; import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; import AutocompleteDataGridViewCell from 'components/data-grid/autocomplete/AutocompleteDataGridViewCell'; -import ConditionalAutocompleteDataGridEditCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell'; -import ConditionalAutocompleteDataGridViewCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell'; import TaxonomyDataGridEditCell from 'components/data-grid/taxonomy/TaxonomyDataGridEditCell'; import TaxonomyDataGridViewCell from 'components/data-grid/taxonomy/TaxonomyDataGridViewCell'; import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; import { IObservationTableRow } from 'contexts/observationsTableContext'; +import { ObservationCountDataGridEditCell } from 'features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell'; +import SampleMethodDataGridEditCell from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell'; +import { SampleMethodDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell'; +import SamplePeriodDataGridEditCell from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell'; +import { SamplePeriodDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell'; +import { SampleSiteDataGridEditCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell'; +import { SampleSiteDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell'; +import { + getMethodsForRow, + getPeriodsForRow +} from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; import { CBMeasurementType, CBQualitativeOption } from 'interfaces/useCritterApi.interface'; import { EnvironmentQualitativeTypeDefinition, EnvironmentQuantitativeTypeDefinition } from 'interfaces/useReferenceApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { MutableRefObject } from 'react'; export type ISampleSiteOption = { survey_sample_site_id: number; @@ -66,10 +78,11 @@ export const TaxonomyColDef = (props: { }; export const SampleSiteColDef = (props: { - sampleSiteOptions: ISampleSiteOption[]; + cachedSampleLocationsRef: MutableRefObject; + onSelectOption: (selectedSampleSite: IGetSampleLocationNonSpatialDetails | null) => void; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { sampleSiteOptions, hasError } = props; + const { cachedSampleLocationsRef, onSelectOption, hasError } = props; return { field: 'survey_sample_site_id', @@ -84,24 +97,19 @@ export const SampleSiteColDef = (props: { align: 'left', renderCell: (params) => { return ( - + ({ - label: item.sample_site_name, - value: item.survey_sample_site_id - }))} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { return ( - + ({ - label: item.sample_site_name, - value: item.survey_sample_site_id - }))} + cachedSampleLocationsRef={cachedSampleLocationsRef} + onSelectOption={(selectedSampleSite) => onSelectOption(selectedSampleSite)} error={hasError(params)} /> ); @@ -110,10 +118,10 @@ export const SampleSiteColDef = (props: { }; export const SampleMethodColDef = (props: { - sampleMethodOptions: ISampleMethodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { sampleMethodOptions, hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'survey_sample_method_id', @@ -128,28 +136,21 @@ export const SampleMethodColDef = (props: { align: 'left', renderCell: (params) => { return ( - + { - return allOptions - .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) - .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); - }} - allOptions={sampleMethodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { + const methodOptions = getMethodsForRow(params, cachedSampleLocationsRef); + return ( - + { - return allOptions - .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) - .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); - }} - allOptions={sampleMethodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} + methodOptions={methodOptions} error={hasError(params)} /> ); @@ -158,10 +159,10 @@ export const SampleMethodColDef = (props: { }; export const SamplePeriodColDef = (props: { - samplePeriodOptions: ISamplePeriodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { samplePeriodOptions, hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'survey_sample_period_id', @@ -169,41 +170,28 @@ export const SamplePeriodColDef = (props: { description: 'The sampling period in which the observation was made', editable: true, hideable: true, - flex: 0, + flex: 1, minWidth: 180, disableColumnMenu: true, headerAlign: 'left', align: 'left', renderCell: (params) => { return ( - + { - return allOptions - .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) - .map((item) => ({ - label: item.sample_period_name, - value: item.survey_sample_period_id - })); - }} - allOptions={samplePeriodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { + const periodOptions = getPeriodsForRow(params, cachedSampleLocationsRef); + return ( - + { - return allOptions - .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) - .map((item) => ({ - label: item.sample_period_name, - value: item.survey_sample_period_id - })); - }} - allOptions={samplePeriodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} + periodOptions={periodOptions} error={hasError(params)} /> ); @@ -212,10 +200,10 @@ export const SamplePeriodColDef = (props: { }; export const ObservationCountColDef = (props: { - sampleMethodOptions: ISampleMethodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'count', @@ -234,39 +222,11 @@ export const ObservationCountColDef = (props: { ), renderEditCell: (params) => { - const error: boolean = hasError(params); - - const maxCount = - props.sampleMethodOptions.find( - (option) => option.survey_sample_method_id === params.row.survey_sample_method_id - )?.response_metric === 'Presence-absence' - ? 1 - : undefined; - return ( - { - if (!/^\d{0,7}$/.test(event.target.value)) { - // If the value is not a number, return - return; - } - - params.api.setEditCellValue({ - id: params.id, - field: params.field, - value: event.target.value - }); - }, - error - }} + cachedSampleLocationsRef={cachedSampleLocationsRef} + error={hasError(params)} /> ); } @@ -278,6 +238,7 @@ export const ObservationSubcountSignColDef = (props: { hasError: (params: GridCellParams) => boolean; }): GridColDef => { const { observationSubcountSignOptions, hasError } = props; + const signOptions = observationSubcountSignOptions.map((item) => ({ label: item.name, value: item.observation_subcount_sign_id diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx new file mode 100644 index 0000000000..bf762b996e --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx @@ -0,0 +1,71 @@ +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { useCodesContext } from 'hooks/useContext'; +import { MutableRefObject } from 'react'; +import { getCodesName } from 'utils/Utils'; + +export interface IPartialObservationCountDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {IPartialObservationCountDataGridEditCellProps} props + * @return {*} + */ +export const ObservationCountDataGridEditCell = ( + props: IPartialObservationCountDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const codesContext = useCodesContext(); + + const getResponseMetric = () => { + const currentMethod = getCurrentMethod(dataGridProps, cachedSampleLocationsRef); + + if (!currentMethod) { + return null; + } + + return getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + currentMethod.method_response_metric_id + ); + }; + + const maxCount = getResponseMetric() === 'Presence-absence' ? 1 : undefined; + + return ( + { + if (!/^\d{0,7}$/.test(event.target.value)) { + // If the value is not a number, return + return; + } + + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: event.target.value + }); + }, + error + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts new file mode 100644 index 0000000000..e3c5e864a1 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts @@ -0,0 +1,6 @@ +import { IGetSampleMethodDetails } from 'interfaces/useSamplingSiteApi.interface'; + +export interface IAutocompleteDataGridSampleMethodOption extends IGetSampleMethodDetails { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx new file mode 100644 index 0000000000..d08df773d6 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx @@ -0,0 +1,117 @@ +import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import TextField from '@mui/material/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSampleMethodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject, useRef } from 'react'; + +export interface ISampleMethodDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + methodOptions: IAutocompleteDataGridSampleMethodOption[]; + onSelectOption?: (selectedSampleSite: IAutocompleteDataGridSampleMethodOption | null) => void; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {ISampleMethodDataGridEditCellProps} props + * @return {*} + */ +const SampleMethodDataGridEditCell = ( + props: ISampleMethodDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, methodOptions, onSelectOption, error } = props; + + const ref = useRef(); + + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + + function getCurrentValue() { + const currentMethod = getCurrentMethod(dataGridProps, cachedSampleLocationsRef); + + return currentMethod; + } + + return ( + option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={createFilterOptions({ limit: 50 })} + onChange={(_, selectedOption) => { + // Set the sample method value with selected options value + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: selectedOption?.value + }); + + // If the sample method is changed, clear the sample period as it is dependent on the method + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_period_id', + value: null + }); + + onSelectOption?.(selectedOption); + }} + renderInput={(params) => ( + + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={dataGridProps.id} + /> + ); +}; + +export default SampleMethodDataGridEditCell; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx new file mode 100644 index 0000000000..ed491a67f2 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSampleMethodDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSampleMethodDataGridViewCellProps} props + * @return {*} + */ +export const SampleMethodDataGridViewCell = ( + props: IPartialSampleMethodDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentMethod(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts new file mode 100644 index 0000000000..0d9eb6d4cc --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts @@ -0,0 +1,6 @@ +import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; + +export interface IAutocompleteDataGridSamplePeriodOption extends IGetSamplePeriodRecord { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx new file mode 100644 index 0000000000..7c57b64439 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx @@ -0,0 +1,110 @@ +import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import TextField from '@mui/material/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSamplePeriodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface'; +import { getCurrentPeriod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject, useRef } from 'react'; + +export interface ISamplePeriodDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + periodOptions: IAutocompleteDataGridSamplePeriodOption[]; + onSelectOption?: (selectedSampleSite: IAutocompleteDataGridSamplePeriodOption | null) => void; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {ISamplePeriodDataGridEditCellProps} props + * @return {*} + */ +const SamplePeriodDataGridEditCell = ( + props: ISamplePeriodDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, periodOptions, onSelectOption, error } = props; + + const ref = useRef(); + + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + + function getCurrentValue() { + const currentPeriod = getCurrentPeriod(dataGridProps, cachedSampleLocationsRef); + + return currentPeriod; + } + + return ( + option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={createFilterOptions({ limit: 50 })} + onChange={(_, selectedOption) => { + // Set the sample period value with selected options value + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: selectedOption?.value + }); + + onSelectOption?.(selectedOption); + }} + renderInput={(params) => ( + + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={dataGridProps.id} + /> + ); +}; + +export default SamplePeriodDataGridEditCell; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx new file mode 100644 index 0000000000..45f25c670c --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentPeriod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSamplePeriodDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSamplePeriodDataGridViewCellProps} props + * @return {*} + */ +export const SamplePeriodDataGridViewCell = ( + props: IPartialSamplePeriodDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentPeriod(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts new file mode 100644 index 0000000000..6429f69518 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts @@ -0,0 +1,13 @@ +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; + +/** + * Defines a single option for a data grid taxonomy autocomplete control. + * + * @export + * @interface IAutocompleteDataGridSampleSiteOption + * @extends {IPartialSampleSite} + */ +export interface IAutocompleteDataGridSampleSiteOption extends IGetSampleLocationNonSpatialDetails { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx new file mode 100644 index 0000000000..8efb849320 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx @@ -0,0 +1,185 @@ +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import AsyncAutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell'; +import { IAutocompleteDataGridSampleSiteOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface'; +import { getCurrentSite } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useIsMounted from 'hooks/useIsMounted'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import debounce from 'lodash-es/debounce'; +import { MutableRefObject, useMemo } from 'react'; + +export interface ISampleSiteDataGridCellProps { + dataGridProps: GridRenderEditCellParams; + cachedSampleLocationsRef: MutableRefObject; + onSelectOption?: (selectedSampleSite: IGetSampleLocationNonSpatialDetails | null) => void; + error?: boolean; +} + +/** + * Data grid taxonomy component for edit. + * + * @template DataGridType + * @template ValueType + * @param {ISampleSiteDataGridCellProps} props + * @return {*} + */ +export const SampleSiteDataGridEditCell = ( + props: ISampleSiteDataGridCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, onSelectOption, error } = props; + + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + + const isMounted = useIsMounted(); + + /** + * Get the current option for the autocomplete, if the field has a value. + * + * @return {*} {(Promise)} + */ + const getCurrentOption = async (): Promise => { + const currentSite = getCurrentSite(dataGridProps, cachedSampleLocationsRef); + + if (!currentSite) { + return null; + } + + return { + ...currentSite, + label: currentSite.name, + value: currentSite.survey_sample_site_id + }; + }; + + /** + * Merge the cached sample locations with the new options returned by the async search, removing duplicates. + * + * @param {IGetSampleLocationNonSpatialDetails[]} cachedOptions + * @param {IGetSampleLocationNonSpatialDetails[]} options + * @return {*} + */ + const mergeOptions = ( + cachedOptions: IGetSampleLocationNonSpatialDetails[], + options: IGetSampleLocationNonSpatialDetails[] + ) => { + const mergedOptionsMap = new Map(); + + // Merge the cached options with the new options, ensuring no duplicates + [...options, ...cachedOptions].forEach((item) => { + mergedOptionsMap.set(item.survey_sample_site_id, { + ...item, + label: item.name, + value: item.survey_sample_site_id + }); + }); + + return Array.from(mergedOptionsMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + }; + + /** + * Debounced function to get the options for the autocomplete, based on the search term. + * Includes the cached sample locations in the resulting options array. + */ + const getOptions = useMemo( + () => + debounce( + async ( + searchTerm: string, + onSearchResults: (searchedValues: IAutocompleteDataGridSampleSiteOption[]) => void + ) => { + const keyword = searchTerm?.trim(); + + biohubApi.samplingSite + .getSampleSites(surveyContext.projectId, surveyContext.surveyId, { keyword }) + .then((response) => { + const options = response.sampleSites.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })); + + if (!isMounted()) { + return; + } + + const mergedOptions = mergeOptions(cachedSampleLocationsRef.current?.locations ?? [], options); + + onSearchResults(mergedOptions); + }); + + onSearchResults( + cachedSampleLocationsRef.current?.locations.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })) ?? [] + ); + }, + 500 + ), + [biohubApi.samplingSite, cachedSampleLocationsRef, isMounted, surveyContext.projectId, surveyContext.surveyId] + ); + + /** + * Get the initial options for the autocomplete. + * + * @return {*} + */ + const getInitialOptions = () => { + return ( + cachedSampleLocationsRef.current?.locations.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })) ?? [] + ); + }; + + return ( + { + // If the sample site is changed, clear the sample method and period as they are dependent on the site + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_method_id', + value: null + }); + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_period_id', + value: null + }); + + onSelectOption?.(selectedOption); + }} + placeholder="Search for a site" + error={error} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx new file mode 100644 index 0000000000..34f1674516 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentSite } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSampleSiteDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSampleSiteDataGridViewCellProps} props + * @return {*} + */ +export const SampleSiteDataGridViewCell = ( + props: IPartialSampleSiteDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentSite(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx new file mode 100644 index 0000000000..70fc75d86e --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx @@ -0,0 +1,49 @@ +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { useRef } from 'react'; + +export type SampleLocationCache = { + locations: IGetSampleLocationNonSpatialDetails[]; +}; + +export const useSampleLocationsCache = () => { + const cachedSampleLocationsRef = useRef(); + + const updateCachedSampleLocationsRef = (selectedSampleSites: IGetSampleLocationNonSpatialDetails[]) => { + if (!selectedSampleSites?.length) { + // If the selected sample site is null, nothing to add to the cache + return; + } + + if (!cachedSampleLocationsRef.current) { + // Initialize the cache + cachedSampleLocationsRef.current = { + locations: selectedSampleSites + }; + } + + const newSites = []; + + for (const site of selectedSampleSites) { + if ( + cachedSampleLocationsRef.current.locations.findIndex( + (item) => item.survey_sample_site_id === site.survey_sample_site_id + ) !== -1 + ) { + // The site is already in the cache + continue; + } + + newSites.push(site); + } + + // Update the cache + cachedSampleLocationsRef.current = { + locations: [...cachedSampleLocationsRef.current.locations, ...newSites] + }; + }; + + return { + cachedSampleLocationsRef, + updateCachedSampleLocationsRef + }; +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts new file mode 100644 index 0000000000..aa6a6fe630 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts @@ -0,0 +1,152 @@ +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSampleMethodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface'; +import { IAutocompleteDataGridSamplePeriodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface'; +import { IAutocompleteDataGridSampleSiteOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { IGetSampleLocationNonSpatialDetails, IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; +import { MutableRefObject } from 'react'; + +/** + * Given a site id and sample location cache, find the site object. + * + * @param {(number | undefined)} siteId + * @param {(SampleLocationCache | undefined)} cache + */ +const findSite = (siteId: number | undefined, cache: SampleLocationCache | undefined) => + cache?.locations.find((site) => site.survey_sample_site_id === siteId); + +/** + * Given a sample site object and method id, find the method object. + * + * @param {(IGetSampleLocationNonSpatialDetails | undefined)} site + * @param {(number | undefined)} methodId + */ +const findMethod = (site: IGetSampleLocationNonSpatialDetails | undefined, methodId: number | undefined) => + site?.sample_methods.find((method) => method.survey_sample_method_id === methodId); + +/** + * Transform a sampling option to be compatible with the autocomplete control. + * + * @template T + * @param {T} item + * @param {string} label + * @param {number} value + * @return {*} {(T & { label: string; value: number })} + */ +const formatOption = (item: T, label: string, value: number): T & { label: string; value: number } => ({ + ...item, + label, + value +}); + +/** + * Get the label for a period. + * + * @param {(IGetSamplePeriodRecord | null)} period + * @return {*} + */ +const getPeriodLabel = (period: IGetSamplePeriodRecord | null) => { + if (!period) { + return ''; + } + return `${period.start_date} ${period.start_time ?? ''} - ${period.end_date} ${period.end_time ?? ''}`; +}; + +/** + * Get the currently selected site for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSampleSiteOption | null)} + */ +export const getCurrentSite = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleSiteOption | null => { + const currentSite = findSite(dataGridProps.value as number, cachedSampleLocationsRef.current); + return currentSite ? formatOption(currentSite, currentSite.name, currentSite.survey_sample_site_id) : null; +}; + +/** + * Get the currently selected method for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSampleMethodOption | null)} + */ +export const getCurrentMethod = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleMethodOption | null => { + for (const site of cachedSampleLocationsRef.current?.locations ?? []) { + const currentMethod = findMethod(site, dataGridProps.value as number); + if (currentMethod) { + return formatOption(currentMethod, currentMethod.technique.name, currentMethod.survey_sample_method_id); + } + } + return null; +}; + +/** + * Get the currently selected period for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSamplePeriodOption | null)} + */ +export const getCurrentPeriod = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSamplePeriodOption | null => { + for (const site of cachedSampleLocationsRef.current?.locations ?? []) { + for (const method of site.sample_methods ?? []) { + const currentPeriod = method.sample_periods.find( + (period) => period.survey_sample_period_id === dataGridProps.value + ); + if (currentPeriod) { + return formatOption(currentPeriod, getPeriodLabel(currentPeriod), currentPeriod.survey_sample_period_id); + } + } + } + return null; +}; + +/** + * Get all valid methods for the currently selected site. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {IAutocompleteDataGridSampleMethodOption[]} + */ +export const getMethodsForRow = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleMethodOption[] => { + const site = findSite(dataGridProps.row.survey_sample_site_id, cachedSampleLocationsRef.current); + return (site?.sample_methods ?? []).map((method) => + formatOption(method, method.technique.name, method.survey_sample_method_id) + ); +}; + +/** + * Get all valid periods for the currently selected site and method. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {IAutocompleteDataGridSamplePeriodOption[]} + */ +export const getPeriodsForRow = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSamplePeriodOption[] => { + const site = findSite(dataGridProps.row.survey_sample_site_id, cachedSampleLocationsRef.current); + const method = findMethod(site, dataGridProps.row.survey_sample_method_id); + return (method?.sample_periods ?? []).map((period) => + formatOption(period, getPeriodLabel(period), period.survey_sample_period_id) + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx index edbc98e77e..2506ec6e94 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx @@ -14,14 +14,21 @@ 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 TablePagination from '@mui/material/TablePagination'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { SkeletonList } from 'components/loading/SkeletonLoaders'; -import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListSite'; +import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/site/SamplingSiteListSite'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useObservationsPageContext, useSurveyContext } from 'hooks/useContext'; -import { useEffect, useState } from 'react'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; + +const pageSizeOptions = [10, 25, 50, 1000]; /** * Renders a list of sampling sites. @@ -34,16 +41,43 @@ export const SamplingSiteListContainer = () => { const observationsPageContext = useObservationsPageContext(); const biohubApi = useBiohubApi(); - useEffect(() => { - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [surveyContext.projectId, surveyContext.sampleSiteDataLoader, surveyContext.surveyId]); - const [sampleSiteAnchorEl, setSampleSiteAnchorEl] = useState(null); const [headerAnchorEl, setHeaderAnchorEl] = useState(null); const [selectedSampleSiteId, setSelectedSampleSiteId] = useState(); const [checkboxSelectedIds, setCheckboxSelectedIds] = useState([]); - const sampleSites = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[1] + }); + const [sortModel] = useState([]); + + const sampleSiteDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.getSampleSites(surveyContext.projectId, surveyContext.surveyId, { pagination }) + ); + + const pagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sortModel); + + return { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + + // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + page: paginationModel.page + 1 + }; + }, [sortModel, paginationModel]); + + // Refresh survey list when pagination or sort changes + useEffect(() => { + sampleSiteDataLoader.refresh(pagination); + + // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination]); + + const sampleSites = sampleSiteDataLoader.data?.sampleSites ?? []; const handleSampleSiteMenuClick = ( event: React.MouseEvent, @@ -67,7 +101,7 @@ export const SamplingSiteListContainer = () => { .then(() => { dialogContext.setYesNoDialog({ open: false }); setSampleSiteAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + sampleSiteDataLoader.refresh(pagination); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -133,7 +167,7 @@ export const SamplingSiteListContainer = () => { dialogContext.setYesNoDialog({ open: false }); setCheckboxSelectedIds([]); setHeaderAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + sampleSiteDataLoader.refresh(pagination); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -155,6 +189,14 @@ export const SamplingSiteListContainer = () => { }); }; + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setPaginationModel({ page: 0, pageSize: parseInt(event.target.value, 10) }); + }; + + const handleChangePage = (_: React.MouseEvent | null, newPage: number) => { + setPaginationModel((model) => ({ ...model, page: newPage })); + }; + const handlePromptConfirmBulkDelete = () => { dialogContext.setYesNoDialog({ dialogTitle: 'Delete Sampling Sites?', @@ -179,7 +221,10 @@ export const SamplingSiteListContainer = () => { }); }; - const samplingSiteCount = sampleSites.length ?? 0; + const samplingSiteCount = useMemo( + () => sampleSiteDataLoader.data?.pagination.total ?? 0, + [sampleSiteDataLoader.data] + ); return ( <> @@ -287,102 +332,118 @@ export const SamplingSiteListContainer = () => { + + + + 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.isLoading ? ( + {sampleSiteDataLoader.isLoading ? ( + - ) : ( - - - - - Select All - - } - control={ - 0 && checkboxSelectedIds.length === samplingSiteCount} - indeterminate={ - checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < samplingSiteCount - } - onClick={() => { - if (checkboxSelectedIds.length === samplingSiteCount) { - setCheckboxSelectedIds([]); - return; - } + + ) : ( + + + {/* Display text if the sample site data loader has no items in it */} + {!sampleSiteDataLoader.data?.sampleSites.length && ( + + No Sampling Sites + + )} - const sampleSiteIds = sampleSites.map((sampleSite) => sampleSite.survey_sample_site_id); - setCheckboxSelectedIds(sampleSiteIds); - }} - inputProps={{ 'aria-label': 'controlled' }} - /> - } + {sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { + return ( + - - - - - {/* Display text if the sample site data loader has no items in it */} - {!surveyContext.sampleSiteDataLoader.data?.sampleSites.length && ( - - No Sampling Sites - - )} - - {surveyContext.sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { - return ( - - ); - })} - - {/* TODO how should we handle controlling pagination? */} - {/* - - {}} - rowsPerPageOptions={[10, 50]} - count={69} - /> - */} - - )} - + ); + })} + + + )}
+ {/* Pagination control */} + + + + ); diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx deleted file mode 100644 index fee52ec6e2..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { mdiArrowRightThin, mdiCalendarRange } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Timeline, TimelineConnector, TimelineContent, TimelineDot, TimelineItem, TimelineSeparator } from '@mui/lab'; -import Box from '@mui/material/Box'; -import grey from '@mui/material/colors/grey'; -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'; - -interface ISamplingSiteListPeriodProps { - samplePeriods: (IGetSamplePeriodRecord | ISurveySampleMethodPeriodData)[]; - observationsPageContext?: IObservationsPageContext; - observationsContext?: IObservationsContext; -} -/** - * Renders sampling periods for a sampling method - * @param props {ISamplingSiteListPeriodProps} - * @returns - */ -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; - - const dateSx = { - fontSize: '0.85rem', - color: 'textSecondary' - }; - - const timeSx = { - fontSize: '0.85rem', - color: 'text.secondary' - }; - - return ( - - {props.samplePeriods - .sort((a, b) => { - const startDateA = new Date(a.start_date); - const startDateB = new Date(b.start_date); - - if (startDateA === startDateB) { - if (a.start_time && b.start_time) { - if (a.start_time < b.start_time) return 1; - if (a.start_time > b.start_time) return -1; - return 0; - } - if (a.start_time && !b.start_time) { - return -1; - } - } - if (startDateA < startDateB) { - return -1; - } - if (startDateA > startDateB) { - return 1; - } - return 0; - }) - .map((samplePeriod, index) => ( - - - {props.samplePeriods.length > 1 ? ( - - - {index < props.samplePeriods.length - 1 && ( - - )} - - ) : ( - - - - )} - - - - - - {formatDate(samplePeriod.start_date as unknown as Date, false)} - - - {samplePeriod.start_time} - - - - - - - - {formatDate(samplePeriod.end_date as unknown as Date, false)} - - - {samplePeriod.end_time} - - - {observationsPageContext && observationsContext && samplePeriod?.survey_sample_period_id && ( - - { - observationsPageContext.setIsDisabled(true); - observationsPageContext.setIsLoading(true); - }} - onSuccess={() => { - observationsContext.observationsDataLoader.refresh(); - }} - onFinish={() => { - observationsPageContext.setIsDisabled(false); - observationsPageContext.setIsLoading(false); - }} - processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} - /> - - )} - - - - ))} - - ); -}; diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx b/app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx similarity index 64% rename from app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx rename to app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx index 8fc7756283..402dd1934d 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx +++ b/app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx @@ -5,20 +5,15 @@ import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionSummary from '@mui/material/AccordionSummary'; import Box from '@mui/material/Box'; import Checkbox from '@mui/material/Checkbox'; -import blue from '@mui/material/colors/blue'; import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; -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/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 { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { SamplingSiteListContent } from './accordion-details/SamplingSiteListContent'; export interface ISamplingSiteListSiteProps { - sampleSite: IGetSampleLocationDetails; + sampleSite: IGetSampleLocationNonSpatialDetails; isChecked: boolean; handleSampleSiteMenuClick: (event: React.MouseEvent, sample_site_id: number) => void; handleCheckboxChange: (sampleSiteId: number) => void; @@ -33,24 +28,10 @@ export interface ISamplingSiteListSiteProps { export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { const { sampleSite, isChecked, handleSampleSiteMenuClick, handleCheckboxChange } = props; - const staticLayers: IStaticLayer[] = [ - { - layerName: 'Sample Sites', - layerOptions: { color: blue[500], fillColor: blue[500] }, - features: [ - { - id: sampleSite.survey_sample_site_id, - key: `sampling-site-${sampleSite.survey_sample_site_id}`, - geoJSON: sampleSite.geojson - } - ] - } - ]; - let icon; - if (sampleSite.geojson.geometry.type === 'Point') { + if (sampleSite.geometry_type === 'Point') { icon = { path: mdiMapMarker, title: 'Point sampling site' }; - } else if (sampleSite.geojson.geometry.type === 'LineString') { + } else if (sampleSite.geometry_type === 'LineString') { icon = { path: mdiVectorLine, title: 'Transect sampling site' }; } else { icon = { path: mdiVectorSquare, title: 'Polygon sampling site' }; @@ -60,6 +41,12 @@ export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { { handleSampleSiteMenuClick(event, sampleSite.survey_sample_site_id) } aria-label="sample-site-settings"> - + { pb: 1, pl: 1, pr: 0 - }}> - {sampleSite.stratums && sampleSite.stratums.length > 0 && ( - - - - )} - - {sampleSite.sample_methods?.map((sampleMethod, index) => { - return ( - - ); - })} - - - - - + }} + /> + ); }; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx new file mode 100644 index 0000000000..41f48384de --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx @@ -0,0 +1,80 @@ +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import Skeleton from '@mui/material/Skeleton'; +import Stack from '@mui/material/Stack'; +import { SamplingStratumChips } from 'features/surveys/sampling-information/sites/edit/form/SamplingStratumChips'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; +import { SamplingSiteListMap } from './map/SamplingSiteMap'; +import { SamplingSiteListMethod } from './method/SamplingSiteListMethod'; + +export interface ISamplingSiteListContentProps { + surveySampleSiteId: number; +} + +/** + * Renders a list item for a single sampling method. + * + * @param {ISamplingSiteListContentProps} props + * @return {*} + */ +export const SamplingSiteListContent = (props: ISamplingSiteListContentProps) => { + const { surveySampleSiteId } = props; + + const biohubApi = useBiohubApi(); + const { surveyId, projectId } = useSurveyContext(); + + const sampleSiteDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId) + ); + + useEffect(() => { + sampleSiteDataLoader.load(); + }, [sampleSiteDataLoader]); + + const sampleSite = sampleSiteDataLoader.data; + + if (!sampleSite) { + return ( + + + + + + + ); + } + + return ( + <> + {sampleSite.stratums && sampleSite.stratums.length > 0 && ( + + + + )} + + {sampleSite.sample_methods?.map((sampleMethod, index) => { + return ( + + ); + })} + + + + + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx new file mode 100644 index 0000000000..1756c6e8c7 --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx @@ -0,0 +1,34 @@ +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'; + +export interface ISamplingSiteListMapProps { + sampleSite: IGetSampleLocationDetails; +} + +/** + * Renders a list item for a single sampling site. + * + * @param {ISamplingSiteListMapProps} props + * @return {*} + */ +export const SamplingSiteListMap = (props: ISamplingSiteListMapProps) => { + const { sampleSite } = props; + + const staticLayers: IStaticLayer[] = [ + { + layerName: 'Sample Sites', + layerOptions: { color: blue[500], fillColor: blue[500] }, + features: [ + { + id: sampleSite.survey_sample_site_id, + key: `sampling-site-${sampleSite.survey_sample_site_id}`, + geoJSON: sampleSite.geojson + } + ] + } + ]; + + return ; +}; diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx similarity index 95% rename from app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx rename to app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx index 39be47b8e5..448ea43903 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx @@ -2,7 +2,7 @@ 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 { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod'; import { useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; import { IGetSampleMethodDetails } from 'interfaces/useSamplingSiteApi.interface'; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx new file mode 100644 index 0000000000..8371f5418c --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx @@ -0,0 +1,143 @@ +import { mdiArrowRightThin, mdiCalendarRange } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Timeline, TimelineConnector, TimelineContent, TimelineDot, TimelineItem, TimelineSeparator } from '@mui/lab'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +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'; + +interface ISamplingSiteListPeriodProps { + samplePeriods: (IGetSamplePeriodRecord | ISurveySampleMethodPeriodData)[]; + observationsPageContext?: IObservationsPageContext; + observationsContext?: IObservationsContext; +} +/** + * Renders sampling periods for a sampling method + * @param props {ISamplingSiteListPeriodProps} + * @returns + */ +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; + + const dateSx = { + fontSize: '0.85rem', + color: 'textSecondary' + }; + + const timeSx = { + fontSize: '0.85rem', + color: 'text.secondary' + }; + + const sortedSamplePeriods = props.samplePeriods.sort((a, b) => { + const startDateA = new Date(a.start_date); + const startDateB = new Date(b.start_date); + + if (startDateA === startDateB) { + if (a.start_time && b.start_time) { + return a.start_time < b.start_time ? 1 : -1; + } + return a.start_time ? -1 : 1; + } + + return startDateA < startDateB ? -1 : 1; + }); + + return ( + + {sortedSamplePeriods.map((samplePeriod, index) => ( + + + {props.samplePeriods.length > 1 ? ( + + + {index < props.samplePeriods.length - 1 && ( + + )} + + ) : ( + + + + )} + + + + + + {formatDate(samplePeriod.start_date as unknown as Date, false)} + + + {samplePeriod.start_time} + + + + + + + + {formatDate(samplePeriod.end_date as unknown as Date, false)} + + + {samplePeriod.end_time} + + + {observationsPageContext && observationsContext && samplePeriod?.survey_sample_period_id && ( + + { + observationsPageContext.setIsDisabled(true); + observationsPageContext.setIsLoading(true); + }} + onSuccess={() => { + observationsContext.observationsDataLoader.refresh(); + }} + onFinish={() => { + observationsPageContext.setIsDisabled(false); + observationsPageContext.setIsLoading(false); + }} + processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} + /> + + )} + + + + ))} + + ); +}; diff --git a/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx b/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx index 7830445b02..1909bdbaf1 100644 --- a/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx +++ b/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx @@ -1,9 +1,11 @@ import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { SamplingSiteManageHeader } from 'features/surveys/sampling-information/manage/SamplingSiteManageHeader'; import { SamplingTechniqueContainer } from 'features/surveys/sampling-information/techniques/SamplingTechniqueContainer'; import { useProjectContext, useSurveyContext } from 'hooks/useContext'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import SamplingSiteContainer from '../sites/SamplingSiteContainer'; /** @@ -25,6 +27,7 @@ export const SamplingSiteManagePage = () => { /> + diff --git a/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx index 17d6d5e557..ce44e8e63b 100644 --- a/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx +++ b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx @@ -1,9 +1,10 @@ import Typography from '@mui/material/Typography'; -import { GridColDef } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import dayjs from 'dayjs'; import { useCodesContext } from 'hooks/useContext'; +import { formatTimeDifference } from 'utils/datetime'; import { getCodesName } from 'utils/Utils'; export interface ISamplingSitePeriodRowData { @@ -11,14 +12,19 @@ export interface ISamplingSitePeriodRowData { sample_site: string; sample_method: string; method_response_metric_id: number; - start_date: string; - end_date: string; + start_date: string | null; + end_date: string | null; start_time: string | null; end_time: string | null; } interface ISamplingPeriodTableProps { periods: ISamplingSitePeriodRowData[]; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + sortModel: GridSortModel; + setSortModel: React.Dispatch>; + rowCount: number; } /** @@ -28,11 +34,11 @@ interface ISamplingPeriodTableProps { * @returns {*} */ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { - const { periods } = props; + const { periods, paginationModel, setPaginationModel, sortModel, setSortModel, rowCount } = props; const codesContext = useCodesContext(); - const columns: GridColDef[] = [ + const columns: GridColDef[] = [ { field: 'sample_site', headerName: 'Site', @@ -47,15 +53,15 @@ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { field: 'method_response_metric_id', headerName: 'Response Metric', flex: 1, - renderCell: (params) => ( - <> - {getCodesName( - codesContext.codesDataLoader.data, - 'method_response_metrics', - params.row.method_response_metric_id - )} - - ) + valueGetter: (params) => { + const value = getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + params.row.method_response_metric_id + ); + + return value; + } }, { field: 'start_date', @@ -82,20 +88,39 @@ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { field: 'end_time', headerName: 'End time', flex: 1 + }, + { + field: 'duration', + headerName: 'Duration', + flex: 1, + renderCell: (params) => { + const { start_date, start_time, end_date, end_time } = params.row; + return formatTimeDifference(start_date, start_time, end_date, end_time); + } } ]; return ( 'auto'} rows={periods} getRowId={(row: ISamplingSitePeriodRowData) => row.id} columns={columns} checkboxSelection={false} disableRowSelectionOnClick + rowCount={rowCount} + paginationMode="server" + sortingMode="server" + sortModel={sortModel} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} pageSizeOptions={[10, 25, 50]} diff --git a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx index 6dd8e10403..2b173651f1 100644 --- a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx +++ b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx @@ -1,33 +1,17 @@ -import { mdiArrowTopRight, mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiPlus } 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 IconButton from '@mui/material/IconButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; 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 { SkeletonMap, SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; -import { - ISamplingSitePeriodRowData, - SamplingPeriodTable -} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; -import { SamplingSiteMapContainer } from 'features/surveys/sampling-information/sites/map/SamplingSiteMapContainer'; -import { SamplingSiteTable } from 'features/surveys/sampling-information/sites/table/SamplingSiteTable'; -import { - SamplingSiteManageTableView, - SamplingSiteTabs -} from 'features/surveys/sampling-information/sites/table/SamplingSiteTabs'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { useEffect, useMemo, useState } from 'react'; +import { useSamplingSiteStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer'; +import SurveyMap from 'features/surveys/view/SurveyMap'; +import { useSurveyContext } from 'hooks/useContext'; import { Link as RouterLink } from 'react-router-dom'; +import { SamplingSiteTableContainer } from './table/SamplingSiteTableContainer'; /** * Component for managing sampling sites, methods, and periods. @@ -37,134 +21,14 @@ import { Link as RouterLink } from 'react-router-dom'; */ const SamplingSiteContainer = () => { const surveyContext = useSurveyContext(); - const dialogContext = useDialogContext(); - const biohubApi = useBiohubApi(); - // State for bulk actions - const [headerAnchorEl, setHeaderAnchorEl] = useState(null); - const [siteSelection, setSiteSelection] = useState([]); - - // Controls whether sites, methods, or periods are shown - const [activeView, setActiveView] = useState(SamplingSiteManageTableView.SITES); - - const sampleSites = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); - const sampleSiteCount = surveyContext.sampleSiteDataLoader.data?.pagination.total ?? 0; - - const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { - const data: ISamplingSitePeriodRowData[] = []; - - for (const site of sampleSites) { - for (const method of site.sample_methods) { - for (const period of method.sample_periods) { - data.push({ - id: period.survey_sample_period_id, - sample_site: site.name, - sample_method: method.technique.name, - method_response_metric_id: method.method_response_metric_id, - start_date: period.start_date, - end_date: period.end_date, - start_time: period.start_time, - end_time: period.end_time - }); - } - } - } - - return data; - }, [sampleSites]); - - useEffect(() => { - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [surveyContext.sampleSiteDataLoader, surveyContext.projectId, surveyContext.surveyId]); - - // Handler for bulk delete operation - const handleBulkDelete = async () => { - try { - await biohubApi.samplingSite.deleteSampleSites( - surveyContext.projectId, - surveyContext.surveyId, - siteSelection.map((site) => Number(site)) // Convert GridRowId to number[] - ); - dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog - setSiteSelection([]); // Clear selection - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); // Refresh data - } catch (error) { - dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog on error - setSiteSelection([]); // Clear selection - // Show snackbar with error message - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error Deleting Items - - - {String(error)} - - - ), - open: true - }); - } - }; - - // Handler for clicking on header menu (bulk actions) - const handleHeaderMenuClick = (event: React.MouseEvent) => { - setHeaderAnchorEl(event.currentTarget); - }; - - // Handler for confirming bulk delete operation - const handlePromptConfirmBulkDelete = () => { - setHeaderAnchorEl(null); // Close header menu - 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: handleBulkDelete - }); - }; - - // Counts for the toggle button labels - const viewCounts = { - [SamplingSiteManageTableView.SITES]: sampleSiteCount, - [SamplingSiteManageTableView.PERIODS]: samplePeriods.length - }; + const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); return ( <> - {/* Bulk action menu */} - setHeaderAnchorEl(null)} - anchorEl={headerAnchorEl} - anchorOrigin={{ vertical: 'top', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }}> - - - - - Delete - - - - Sampling Sites ‌ - - ({sampleSiteCount}) - + Sampling Sites - - - @@ -198,59 +53,13 @@ const SamplingSiteContainer = () => { } isLoadingFallbackDelay={100}> - - - {/* Toggle buttons for changing between sites, methods, and periods */} - - - - - {/* Data tables */} - - {activeView === SamplingSiteManageTableView.SITES && ( - } - isLoadingFallbackDelay={100} - hasNoData={!viewCounts[SamplingSiteManageTableView.SITES]} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - )} - - {activeView === SamplingSiteManageTableView.PERIODS && ( - } - isLoadingFallbackDelay={100} - hasNoData={!viewCounts[SamplingSiteManageTableView.PERIODS]} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - )} + + + + ); }; diff --git a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx index 9085af7c82..04bf652355 100644 --- a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx @@ -22,6 +22,8 @@ import SampleSiteFileUploadItemProgressBar from 'features/surveys/sampling-infor 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'; +import useDataLoader from 'hooks/useDataLoader'; import { ICreateSamplingSiteRequest, ISurveySampleSite } from 'interfaces/useSamplingSiteApi.interface'; import { DrawEvents, LatLngBoundsExpression } from 'leaflet'; import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; @@ -65,6 +67,8 @@ export interface ISamplingSiteMapControlProps { const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { const classes = useStyles(); + const biohubApi = useBiohubApi(); + const surveyContext = useContext(SurveyContext); const [lastDrawn, setLastDrawn] = useState(null); @@ -74,7 +78,15 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { const { values, errors, setFieldValue, setFieldError } = formikProps; - let numSites = surveyContext.sampleSiteDataLoader.data?.sampleSites.length ?? 0; + const samplingSiteDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSitesGeometry(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + samplingSiteDataLoader.load(); + }, [samplingSiteDataLoader]); + + let numSites = samplingSiteDataLoader.data?.sampleSites.length ?? 0; const [updatedBounds, setUpdatedBounds] = useState(undefined); diff --git a/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx index 454dcf5be8..318deeac1c 100644 --- a/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx +++ b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx @@ -87,9 +87,6 @@ export const CreateSamplingSitePage = () => { 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}/sampling`, diff --git a/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx b/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx index f82a081ed7..43697c682e 100644 --- a/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx @@ -33,7 +33,7 @@ export interface IEditSampleSiteFormData { survey_sample_site_id: number | null; survey_id: number; name: string; - description: string; + description: string | null; geojson: Feature; sample_methods: (IGetSampleMethodDetails | ISurveySampleMethodFormData)[]; blocks: IGetSampleBlockDetails[]; @@ -105,7 +105,7 @@ export const EditSamplingSitePage = () => { const editSampleSite: IEditSampleSiteRequest = { sampleSite: { name: values.name, - description: values.description, + description: values.description ?? '', survey_id: values.survey_id, survey_sample_sites: [values.geojson as Feature], geojson: values.geojson, @@ -128,15 +128,11 @@ export const EditSamplingSitePage = () => { .then(() => { setIsSubmitting(false); - // 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}/sampling`, SKIP_CONFIRMATION_DIALOG ); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); 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 index fe02b40205..71267aa83e 100644 --- a/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx @@ -1,12 +1,12 @@ 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'; +import { IGetSampleStratumDetails } from 'interfaces/useSamplingSiteApi.interface'; const SAMPLING_SITE_CHIP_COLOURS = [purple, blue, pink, teal, cyan, orange]; interface ISamplingStratumChipsProps { - sampleSite: IGetSampleLocationDetails; + stratums: IGetSampleStratumDetails[]; } /** @@ -18,9 +18,9 @@ interface ISamplingStratumChipsProps { export const SamplingStratumChips = (props: ISamplingStratumChipsProps) => { return ( - {props.sampleSite.stratums.map((stratum, index) => ( + {props.stratums.map((stratum, index) => ( { - const staticLayers: IStaticLayer[] = props.samplingSites.map((sampleSite) => ({ - layerName: 'Sample Sites', - layerOptions: { color: blue[500], fillColor: blue[500] }, - features: [ - { - id: sampleSite.survey_sample_site_id, - key: `sample-site-${sampleSite.survey_sample_site_id}`, - geoJSON: sampleSite.geojson - } - ] - })); - - return ( - - - - ); -}; diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx index b5c078bab7..23ad45d3c3 100644 --- a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx +++ b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx @@ -8,13 +8,12 @@ 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 { GridColDef, GridRowSelectionModel } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridRowSelectionModel, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { Feature } from 'geojson'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; @@ -23,15 +22,21 @@ export interface ISamplingSiteRowData { id: number; name: string; description: string; - geojson: Feature; + geometry_type: string; blocks: string[]; stratums: string[]; } interface ISamplingSiteTableProps { - sites: IGetSampleLocationDetails[]; + sites: IGetSampleLocationNonSpatialDetails[]; bulkActionSites: GridRowSelectionModel; setBulkActionSites: (selection: GridRowSelectionModel) => void; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + sortModel: GridSortModel; + pageSizeOptions: number[]; + rowCount: number; } /** @@ -41,7 +46,17 @@ interface ISamplingSiteTableProps { * @returns {*} */ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { - const { sites, bulkActionSites, setBulkActionSites } = props; + const { + sites, + bulkActionSites, + setBulkActionSites, + paginationModel, + setPaginationModel, + sortModel, + setSortModel, + pageSizeOptions, + rowCount + } = props; const biohubApi = useBiohubApi(); const surveyContext = useSurveyContext(); @@ -60,7 +75,6 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { .then(() => { dialogContext.setYesNoDialog({ open: false }); setActionMenuAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -108,8 +122,8 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { const rows: ISamplingSiteRowData[] = sites.map((site) => ({ id: site.survey_sample_site_id, name: site.name, + geometry_type: site.geometry_type, description: site.description || '', - geojson: site.geojson, blocks: site.blocks.map((block) => block.name), stratums: site.stratums.map((stratum) => stratum.name) })); @@ -127,7 +141,7 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { renderCell: (params) => ( @@ -235,6 +249,7 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { {/* DATA TABLE */} 'auto'} disableColumnMenu rows={rows} @@ -243,12 +258,19 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { rowSelectionModel={bulkActionSites} onRowSelectionModelChange={setBulkActionSites} checkboxSelection + rowCount={rowCount} + paginationMode="server" + sortingMode="server" + sortModel={sortModel} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx new file mode 100644 index 0000000000..11975738f5 --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx @@ -0,0 +1,272 @@ +import { mdiArrowTopRight, mdiDotsVertical, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +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 from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { GridPaginationModel, GridRowSelectionModel, GridSortModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import { SamplingPeriodTable } from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import { SamplingSiteTable } from './SamplingSiteTable'; +import { SamplingSiteManageTableView, SamplingSiteTableView } from './view/SamplingSiteTableView'; + +const pageSizeOptions = [10, 25, 50]; + +export interface ISamplingSitePeriodRowData { + id: number; + sample_site: string; + sample_method: string; + method_response_metric_id: number; + start_date: string; + end_date: string; + start_time: string | null; + end_time: string | null; +} + +/** + * Returns a table of sampling sites with edit actions + * + * @returns {*} + */ +export const SamplingSiteTableContainer = () => { + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + const dialogContext = useDialogContext(); + + const [headerAnchorEl, setHeaderAnchorEl] = useState(null); + const [siteSelection, setSiteSelection] = useState([]); + + // Controls whether sites, methods, or periods are shown + const [activeView, setActiveView] = useState(SamplingSiteManageTableView.SITES); + + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + const [sortModel, setSortModel] = useState([]); + + const samplingSitesDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.getSampleSites(surveyContext.projectId, surveyContext.surveyId, { pagination }) + ); + + const pagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sortModel); + + return { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + + // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + page: paginationModel.page + 1 + }; + }, [sortModel, paginationModel]); + + // Refresh survey list when pagination or sort changes + useEffect(() => { + samplingSitesDataLoader.refresh(pagination); + + // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination]); + + const sampleSites = useMemo(() => samplingSitesDataLoader.data?.sampleSites ?? [], [samplingSitesDataLoader.data]); + + console.log(sampleSites); + + const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { + const data: ISamplingSitePeriodRowData[] = []; + + for (const site of sampleSites) { + for (const method of site.sample_methods) { + for (const period of method.sample_periods) { + data.push({ + id: period.survey_sample_period_id, + sample_site: site.name, + sample_method: method.technique.name, + method_response_metric_id: method.method_response_metric_id, + start_date: period.start_date, + end_date: period.end_date, + start_time: period.start_time, + end_time: period.end_time + }); + } + } + } + + return data; + }, [sampleSites]); + + // Handler for bulk delete operation + const handleBulkDelete = async () => { + try { + await biohubApi.samplingSite.deleteSampleSites( + surveyContext.projectId, + surveyContext.surveyId, + siteSelection.map((site) => Number(site)) // Convert GridRowId to number[] + ); + dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog + setSiteSelection([]); // Clear selection + samplingSitesDataLoader.refresh(pagination); // Refresh data + } catch (error) { + dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog on error + setSiteSelection([]); // Clear selection + // Show snackbar with error message + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Items + + + {String(error)} + + + ), + open: true + }); + } + }; + + // Handler for clicking on header menu (bulk actions) + const handleHeaderMenuClick = (event: React.MouseEvent) => { + setHeaderAnchorEl(event.currentTarget); + }; + + // Handler for confirming bulk delete operation + const handlePromptConfirmBulkDelete = () => { + setHeaderAnchorEl(null); // Close header menu + 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: handleBulkDelete + }); + }; + + return ( + <> + {/* Bulk action menu */} + setHeaderAnchorEl(null)} + anchorEl={headerAnchorEl} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }}> + + + + + Delete + + + + + {/* Toggle buttons for changing between sites, methods, and periods */} + + + + + + + + + + {/* Data tables */} + + {activeView === SamplingSiteManageTableView.SITES && ( + } + isLoadingFallbackDelay={100} + hasNoData={!sampleSites.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + )} + + {activeView === SamplingSiteManageTableView.PERIODS && ( + } + isLoadingFallbackDelay={100} + hasNoData={!samplePeriods.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + {}} + sortModel={[]} + setSortModel={() => {}} + rowCount={samplePeriods.length} + /> + + )} + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx b/app/src/features/surveys/sampling-information/sites/table/view/SamplingSiteTableView.tsx similarity index 54% rename from app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx rename to app/src/features/surveys/sampling-information/sites/table/view/SamplingSiteTableView.tsx index f7060fb206..7f008713b9 100644 --- a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx +++ b/app/src/features/surveys/sampling-information/sites/table/view/SamplingSiteTableView.tsx @@ -3,7 +3,6 @@ import { Icon } from '@mdi/react'; import Button from '@mui/material/Button'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import Toolbar from '@mui/material/Toolbar'; import { SetStateAction } from 'react'; export enum SamplingSiteManageTableView { @@ -18,28 +17,25 @@ interface ISamplingSiteManageTableView { export type ISamplingSiteCount = Record; -interface ISamplingSiteTabsProps { +interface ISamplingSiteTableViewProps { activeView: SamplingSiteManageTableView; setActiveView: React.Dispatch>; - viewCounts: Record; } /** * Renders tab controls for the sampling site table, which allow the user to switch between viewing sites and periods. * - * @param {ISamplingSiteTabsProps} props + * @param {ISamplingSiteTableViewProps} props * @return {*} */ -export const SamplingSiteTabs = (props: ISamplingSiteTabsProps) => { - const { activeView, setActiveView, viewCounts } = props; +export const SamplingSiteTableView = (props: ISamplingSiteTableViewProps) => { + const { activeView, setActiveView } = props; const views: ISamplingSiteManageTableView[] = [ { value: SamplingSiteManageTableView.SITES, icon: }, { value: SamplingSiteManageTableView.PERIODS, icon: } ]; - const activeViewCount = viewCounts[activeView]; - const updateDatasetView = (_: React.MouseEvent, view: SamplingSiteManageTableView) => { if (view) { setActiveView(view); @@ -47,30 +43,29 @@ export const SamplingSiteTabs = (props: ISamplingSiteTabsProps) => { }; return ( - - - {views.map((view) => ( - - {view.value} ({activeViewCount}) - - ))} - - + + {views.map((view) => ( + + {view.value} + + ))} + ); }; diff --git a/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx b/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx index fe860d44ad..522f4776e4 100644 --- a/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx +++ b/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx @@ -160,7 +160,7 @@ export const SamplingTechniqueContainer = () => { isLoading={surveyContext.techniqueDataLoader.isLoading || !surveyContext.techniqueDataLoader.isReady} isLoadingFallback={} isLoadingFallbackDelay={100}> - + (props: ISamp rows={rows} columns={columns} getRowHeight={() => 'auto'} + autoHeight={false} disableRowSelectionOnClick disableColumnMenu checkboxSelection diff --git a/app/src/features/surveys/telemetry/TelemetryHeader.tsx b/app/src/features/surveys/telemetry/TelemetryHeader.tsx index 85fb278961..9db58ca6a0 100644 --- a/app/src/features/surveys/telemetry/TelemetryHeader.tsx +++ b/app/src/features/surveys/telemetry/TelemetryHeader.tsx @@ -1,9 +1,9 @@ +import { mdiEye, mdiPaw } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; - export interface TelemetryHeaderProps { project_id: number; project_name: string; @@ -13,6 +13,20 @@ export interface TelemetryHeaderProps { export const TelemetryHeader = (props: TelemetryHeaderProps) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Animals', + to: `/admin/projects/${project_id}/surveys/${survey_id}/animals`, + icon: mdiPaw + }, + { + label: 'Observations', + to: `/admin/projects/${project_id}/surveys/${survey_id}/observations`, + icon: mdiEye + } + ]; + return ( { to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Telemetry - + Telemetry } /> diff --git a/app/src/features/surveys/telemetry/TelemetryPage.tsx b/app/src/features/surveys/telemetry/TelemetryPage.tsx index c91b025ff6..f969f3f3f6 100644 --- a/app/src/features/surveys/telemetry/TelemetryPage.tsx +++ b/app/src/features/surveys/telemetry/TelemetryPage.tsx @@ -2,12 +2,14 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import { TelemetryTableContextProvider } from 'contexts/telemetryTableContext'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { SurveyDeploymentList } from 'features/surveys/telemetry/list/SurveyDeploymentList'; import { TelemetryTableContainer } from 'features/surveys/telemetry/table/TelemetryTableContainer'; import { TelemetryHeader } from 'features/surveys/telemetry/TelemetryHeader'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useProjectContext, useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useEffect } from 'react'; export const TelemetryPage = () => { @@ -72,6 +74,7 @@ export const TelemetryPage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> + {/* Telematry List */} diff --git a/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx b/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx index df3b446537..667ec1691d 100644 --- a/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx +++ b/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx @@ -243,14 +243,12 @@ export const DeploymentTimelineForm = (props: IDeploymentTimelineFormProps) => { name="attachment_end_date" label="End date" required={values.attachment_end_time !== null} - formikProps={formikProps} /> )} diff --git a/app/src/features/surveys/view/SurveyDetails.test.tsx b/app/src/features/surveys/view/SurveyDetails.test.tsx index 7c6044d245..519f0b42df 100644 --- a/app/src/features/surveys/view/SurveyDetails.test.tsx +++ b/app/src/features/surveys/view/SurveyDetails.test.tsx @@ -51,7 +51,6 @@ describe('SurveyDetails', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -64,7 +63,7 @@ describe('SurveyDetails', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/SurveyHeader.test.tsx b/app/src/features/surveys/view/SurveyHeader.test.tsx index a1c685afab..76eecd025e 100644 --- a/app/src/features/surveys/view/SurveyHeader.test.tsx +++ b/app/src/features/surveys/view/SurveyHeader.test.tsx @@ -44,9 +44,6 @@ const mockSurveyContext: ISurveyContext = { artifactDataLoader: { data: null } as DataLoader, - sampleSiteDataLoader: { - data: null - } as DataLoader, critterDataLoader: { data: null } as DataLoader, diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index a1e7c05aac..8f42f5d485 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -5,9 +5,11 @@ import Stack from '@mui/material/Stack'; import { CodesContext } from 'contexts/codesContext'; import { SurveyContext } from 'contexts/surveyContext'; import { TaxonomyContextProvider } from 'contexts/taxonomyContext'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import SurveyDetails from 'features/surveys/view/SurveyDetails'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import React, { useContext, useEffect } from 'react'; -import { SurveySamplingContainer } from './components/sampling-data/SurveySamplingContainer'; +import { SurveySamplingTableContainer } from './components/sampling-data/SurveySamplingTableContainer'; import SurveyStudyArea from './components/SurveyStudyArea'; import { SurveySpatialContainer } from './survey-spatial/SurveySpatialContainer'; import SurveyAttachments from './SurveyAttachments'; @@ -34,9 +36,10 @@ const SurveyPage: React.FC = () => { <> + - + diff --git a/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx b/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx new file mode 100644 index 0000000000..827ac259fa --- /dev/null +++ b/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx @@ -0,0 +1,42 @@ +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; + +interface ISurveySampleSiteMapPopupProps { + surveySampleSiteId: number; +} + +export const SurveySampleSiteMapPopup = (props: ISurveySampleSiteMapPopupProps) => { + const { surveySampleSiteId } = props; + const { surveyId, projectId } = useSurveyContext(); + + const biohubApi = useBiohubApi(); + + const surveyDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId) + ); + + useEffect(() => { + surveyDataLoader.load(); + }, [surveyDataLoader]); + + const sampleSite = surveyDataLoader.data; + + const metadata = sampleSite + ? [ + { label: 'Name', value: sampleSite.name }, + { label: 'Description', value: sampleSite.description } + ] + : []; + + return ( + + ); +}; diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx index 1bb03a26b5..2c8b009931 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx @@ -22,7 +22,6 @@ describe('SurveyGeneralInformation', () => { it('renders correctly with end date', () => { 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; @@ -34,7 +33,6 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -60,7 +58,6 @@ 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; @@ -72,7 +69,6 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -87,7 +83,6 @@ describe('SurveyGeneralInformation', () => { it('renders an empty fragment if survey data has not loaded or is undefined', () => { 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; @@ -99,7 +94,6 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx index ef52862732..1257227555 100644 --- a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx @@ -13,7 +13,6 @@ describe('SurveyProprietaryData', () => { it('renders correctly with proprietor data', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -25,7 +24,6 @@ describe('SurveyProprietaryData', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -42,7 +40,6 @@ describe('SurveyProprietaryData', () => { data: { ...getSurveyForViewResponse, surveyData: { ...getSurveyForViewResponse.surveyData, proprietor: null } } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -54,7 +51,6 @@ describe('SurveyProprietaryData', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -69,7 +65,6 @@ describe('SurveyProprietaryData', () => { it('renders an empty fragment if survey data has not loaded or is undefined', () => { const mockSurveyDataLoader = { data: undefined } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -80,7 +75,6 @@ describe('SurveyProprietaryData', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, 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 3390775e55..fc26517788 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx @@ -21,7 +21,6 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -33,7 +32,6 @@ describe('SurveyPurposeAndMethodologyData', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -66,7 +64,6 @@ describe('SurveyPurposeAndMethodologyData', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -78,7 +75,6 @@ describe('SurveyPurposeAndMethodologyData', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader, 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 47e7ca53e1..ac5e1f0d18 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx @@ -42,7 +42,6 @@ describe.skip('SurveyStudyArea', () => { it('renders correctly with no data', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -54,7 +53,7 @@ describe.skip('SurveyStudyArea', () => { surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader }}> @@ -78,7 +77,6 @@ describe.skip('SurveyStudyArea', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -89,7 +87,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -106,7 +104,6 @@ describe.skip('SurveyStudyArea', () => { it('is rendered if there are geometries on the map', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -117,7 +114,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -138,7 +135,6 @@ describe.skip('SurveyStudyArea', () => { refresh: jest.fn() as unknown as any } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -159,7 +155,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> @@ -230,7 +226,6 @@ describe.skip('SurveyStudyArea', () => { it('shows error dialog with API error message when updating survey data fails', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const mockTechniqueDataLoader = { data: [] } as DataLoader; @@ -269,7 +264,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader, techniqueDataLoader: mockTechniqueDataLoader }}> diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx index 0dce0843b2..e879240333 100644 --- a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx +++ b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx @@ -10,6 +10,7 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext, useTaxonomyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; import { IObservationCountByGroup } from 'interfaces/useAnalyticsApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { useEffect, useMemo } from 'react'; import { getBasicGroupByColDefs, @@ -101,10 +102,9 @@ export const ObservationAnalyticsDataTableContainer = (props: IObservationAnalyt [analyticsDataLoader?.data] ); - const sampleSites = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); + // TODO: Include sampling information in the analytics response / otherwise get sampling information, + // which is now more complicated because sample sites are paginated. + const sampleSites: IGetSampleLocationNonSpatialDetails[] = []; const allGroupByColumns = useMemo( () => [...groupByColumns, ...groupByQualitativeMeasurements, ...groupByQuantitativeMeasurements], @@ -152,7 +152,7 @@ export const ObservationAnalyticsDataTableContainer = (props: IObservationAnalyt } isLoadingFallbackDelay={100} hasNoData={!analyticsDataLoader.data?.length} diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx index b94f62b1ce..38acf9eb17 100644 --- a/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx +++ b/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx @@ -6,7 +6,7 @@ import dayjs from 'dayjs'; import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; import { IObservationAnalyticsRow } from 'features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer'; import { IGroupByOption } from 'features/surveys/view/components/analytics/SurveyObservationAnalytics'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import isEqual from 'lodash-es/isEqual'; @@ -92,11 +92,11 @@ export const getSpeciesColDef = ( /** * Get the column definition for the sampling site. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingSiteColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', @@ -122,11 +122,11 @@ export const getSamplingSiteColDef = ( /** * Get the column definition for the sampling method. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingMethodColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', @@ -154,11 +154,11 @@ export const getSamplingMethodColDef = ( /** * Get the column definition for the sampling period. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingPeriodColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', diff --git a/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx b/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx index 34ed569e19..1eb04189db 100644 --- a/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx +++ b/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx @@ -14,13 +14,7 @@ export enum SurveyObservationTabularDataContainerViewEnum { ANALYTICS = 'ANALYTICS' } -interface ISurveyObservationTabularDataContainerProps { - isLoading: boolean; -} - -const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularDataContainerProps) => { - const { isLoading } = props; - +const SurveyObservationTabularDataContainer = () => { const [activeDataView, setActiveDataView] = useState( SurveyObservationTabularDataContainerViewEnum.COUNTS ); @@ -32,7 +26,7 @@ const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularD return ( <> - + { @@ -70,10 +64,8 @@ const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularD - - {activeDataView === SurveyObservationTabularDataContainerViewEnum.COUNTS && ( - - )} + + {activeDataView === SurveyObservationTabularDataContainerViewEnum.COUNTS && } {activeDataView === SurveyObservationTabularDataContainerViewEnum.ANALYTICS && } diff --git a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx b/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx deleted file mode 100644 index ad4ea684ea..0000000000 --- a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Box from '@mui/material/Box'; -import Divider from '@mui/material/Divider'; -import { SurveySamplingHeader } from './components/SurveySamplingHeader'; -import { SurveySamplingTabs } from './table/SurveySamplingTabs'; - -export const SurveySamplingContainer = () => { - return ( - - - - - - - - ); -}; diff --git a/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx b/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx new file mode 100644 index 0000000000..e3f6b232e1 --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx @@ -0,0 +1,264 @@ +import { mdiAutoFix, mdiCalendarRange, mdiMapMarker } from '@mdi/js'; +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; +import { SamplingPeriodTable } from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; +import { SurveyTechniquesCardContainer } from 'features/surveys/view/components/sampling-data/table/technique/SurveyTechniqueCardContainer'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import { SurveySitesTable } from './components/site/SurveySitesTable'; +import { SurveySamplingHeader } from './components/SurveySamplingHeader'; + +const pageSizeOptions = [10, 25, 50]; + +export enum SurveySamplingView { + TECHNIQUES = 'TECHNIQUES', + SITES = 'SITES', + PERIODS = 'PERIODS' +} + +export const SurveySamplingTableContainer = () => { + const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + const [activeView, setActiveView] = useState(SurveySamplingView.TECHNIQUES); + + const views = [ + { value: SurveySamplingView.TECHNIQUES, label: 'Techniques', icon: mdiAutoFix }, + { value: SurveySamplingView.SITES, label: 'Sampling Sites', icon: mdiMapMarker }, + { value: SurveySamplingView.PERIODS, label: 'Sampling Periods', icon: mdiCalendarRange } + ]; + + // Pagination and sorting for techniques + const [techniquesPaginationModel, setTechniquesPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + const [techniquesSortModel, setTechniquesSortModel] = useState([]); + + // Pagination and sorting for sites + const [sitesPaginationModel, setSitesPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + const [sitesSortModel, setSitesSortModel] = useState([]); + + // Pagination and sorting for periods + const [periodsPaginationModel, setPeriodsPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + const [periodsSortModel, setPeriodsSortModel] = useState([]); + + // Sampling sites data loader and pagination + const samplingSitesDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.findSampleSites( + { + survey_id: surveyContext.surveyId + }, + pagination + ) + ); + const sitesPagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sitesSortModel); + return { + limit: sitesPaginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: sitesPaginationModel.page + 1 + }; + }, [sitesSortModel, sitesPaginationModel]); + + // Sampling periods data loader and pagination + const samplingPeriodsDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.findSamplePeriods( + { + survey_id: surveyContext.surveyId + }, + pagination + ) + ); + const periodsPagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(periodsSortModel); + return { + limit: periodsPaginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: periodsPaginationModel.page + 1 + }; + }, [periodsSortModel, periodsPaginationModel]); + + // Refresh data if there is data + useEffect(() => { + if ( + activeView === SurveySamplingView.TECHNIQUES && + Number(surveyContext.techniqueDataLoader.data?.pagination.total) !== 0 + ) { + surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + } + if ( + [SurveySamplingView.SITES, SurveySamplingView.PERIODS].includes(activeView) && + Number(samplingSitesDataLoader.data?.pagination.total) !== 0 + ) { + samplingSitesDataLoader.refresh(sitesPagination); + } + if ( + activeView === SurveySamplingView.PERIODS && + Number(surveyContext.techniqueDataLoader.data?.pagination.total) !== 0 + ) { + samplingPeriodsDataLoader.refresh(periodsPagination); + } + // Including data loaders in the dependency cause infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView, sitesPagination]); + + const techniques = surveyContext.techniqueDataLoader.data?.techniques ?? []; + const sampleSites = useMemo(() => samplingSitesDataLoader.data?.sites ?? [], [samplingSitesDataLoader.data?.sites]); + const samplePeriods = useMemo( + () => + samplingPeriodsDataLoader.data?.periods.map((item) => { + return { + id: item.survey_sample_period_id, + sample_site: item.sample_site.name, + sample_method: item.method_technique.name, + method_response_metric_id: item.sample_method.method_response_metric_id, + start_date: item.start_date, + end_date: item.end_date, + start_time: item.start_time, + end_time: item.end_time + }; + }) ?? [], + [samplingPeriodsDataLoader.data?.periods] + ); + + return ( + + + + + + + + + + + + + + {activeView === SurveySamplingView.TECHNIQUES && ( + + + + } + isLoadingFallbackDelay={100} + hasNoData={!techniques.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + + )} + + {activeView === SurveySamplingView.SITES && ( + + + + } + isLoadingFallbackDelay={100} + hasNoData={!sampleSites.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + + )} + + {/* TODO: Add pagination to the survey periods request */} + {activeView === SurveySamplingView.PERIODS && ( + + + + } + isLoadingFallbackDelay={100} + hasNoData={!samplePeriods.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + + )} + + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx similarity index 61% rename from app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx rename to app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx index bf505534e1..a0d8041f68 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx @@ -1,27 +1,34 @@ import Box from '@mui/material/Box'; import blueGrey from '@mui/material/colors/blueGrey'; -import { GridColDef } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { ISamplingSiteRowData } from 'features/surveys/sampling-information/sites/table/SamplingSiteTable'; -import { Feature } from 'geojson'; +import { IFindSampleSiteRecord } from 'interfaces/useSamplingSiteApi.interface'; import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; -export interface ISurveySitesRowData { - id: number; - name: string; - description: string; - geojson: Feature; - blocks: string[]; - stratums: string[]; -} +const pageSizeOptions = [10, 25, 50]; export interface ISurveySitesTableProps { - sites: ISurveySitesRowData[]; + sites: IFindSampleSiteRecord[]; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + sortModel: GridSortModel; + rowCount: number; } export const SurveySitesTable = (props: ISurveySitesTableProps) => { - const { sites } = props; + const { sites, paginationModel, setPaginationModel, sortModel, setSortModel, rowCount } = props; + + const rows: ISamplingSiteRowData[] = sites.map((site) => ({ + id: site.survey_sample_site_id, + name: site.name, + geometry_type: site.geometry_type, + description: site.description || '', + blocks: site.blocks.map((block) => block.name), + stratums: site.stratums.map((stratum) => stratum.name) + })); const columns: GridColDef[] = [ { @@ -36,7 +43,7 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { renderCell: (params) => ( @@ -81,17 +88,25 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { 'auto'} - rows={sites} + rows={rows} getRowId={(row) => row.id} columns={columns} disableRowSelectionOnClick + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} + sortModel={sortModel} + paginationModel={paginationModel} + paginationMode="server" + sortingMode="server" + rowCount={rowCount} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); }; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniquesTable.tsx similarity index 68% rename from app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx rename to app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniquesTable.tsx index 117db526bc..1ba7e473f5 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniquesTable.tsx @@ -1,14 +1,16 @@ import Box from '@mui/material/Box'; import blueGrey from '@mui/material/colors/blueGrey'; import Typography from '@mui/material/Typography'; -import { GridColDef } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { ITechniqueRowData } from 'features/surveys/sampling-information/techniques/table/SamplingTechniqueTable'; import { useCodesContext } from 'hooks/useContext'; -import { TechniqueAttractant } from 'interfaces/useTechniqueApi.interface'; +import { IGetTechniqueResponse, TechniqueAttractant } from 'interfaces/useTechniqueApi.interface'; import { getCodesName } from 'utils/Utils'; +const pageSizeOptions = [10, 25, 50]; + export interface ISurveyTechniqueRowData { id: number; method_lookup_id: number; @@ -19,14 +21,29 @@ export interface ISurveyTechniqueRowData { } export interface ISurveyTechniquesTableProps { - techniques: ISurveyTechniqueRowData[]; + techniques: IGetTechniqueResponse[]; + paginationModel: GridPaginationModel; + sortModel: GridSortModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + rowCount: number; } export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { - const { techniques } = props; + const { techniques, paginationModel, setPaginationModel, sortModel, setSortModel, rowCount } = props; const codesContext = useCodesContext(); + const rows: ISurveyTechniqueRowData[] = + techniques.map((technique) => ({ + id: technique.method_technique_id, + name: technique.name, + method_lookup_id: technique.method_lookup_id, + description: technique.description, + attractants: technique.attractants, + distance_threshold: technique.distance_threshold + })) ?? []; + const columns: GridColDef[] = [ { field: 'name', @@ -101,16 +118,25 @@ export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { noRowsMessage={'No Techniques'} rowSelection={false} getRowHeight={() => 'auto'} - rows={techniques} + rows={rows} getRowId={(row) => row.id} columns={columns} disableRowSelectionOnClick + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} + sortModel={sortModel} + paginationModel={paginationModel} + // TODO: Enable pagination; saving for a separate PR because it should probably include + // removing techniquesDataLoader from the surveyContext + paginationMode="server" + sortingMode="server" + rowCount={rowCount} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); }; diff --git a/app/src/features/surveys/view/components/sampling-data/components/view/SurveySamplingViewTabs.tsx b/app/src/features/surveys/view/components/sampling-data/components/view/SurveySamplingViewTabs.tsx new file mode 100644 index 0000000000..6d1607687e --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/view/SurveySamplingViewTabs.tsx @@ -0,0 +1,65 @@ +import { mdiAutoFix, mdiCalendarRange, mdiMapMarker } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Box, Button, ToggleButton, ToggleButtonGroup } from '@mui/material'; +import { SurveySamplingView } from '../../SurveySamplingTableContainer'; + +interface SurveySamplingViewTabsProps { + activeView: SurveySamplingView; + setActiveView: (view: SurveySamplingView) => void; +} + +export const SurveySamplingViewTabs = ({ activeView, setActiveView }: SurveySamplingViewTabsProps) => { + 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 + + } + value={SurveySamplingView.SITES}> + Sites + + } + value={SurveySamplingView.PERIODS}> + Periods + + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/table/SurveySamplingTabs.tsx b/app/src/features/surveys/view/components/sampling-data/table/SurveySamplingTabs.tsx deleted file mode 100644 index d715256c7e..0000000000 --- a/app/src/features/surveys/view/components/sampling-data/table/SurveySamplingTabs.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { mdiArrowTopRight, mdiAutoFix, mdiCalendarRange, mdiMapMarker } from '@mdi/js'; -import Box from '@mui/material/Box'; -import Divider from '@mui/material/Divider'; -import Stack from '@mui/material/Stack'; -import { LoadingGuard } from 'components/loading/LoadingGuard'; -import { SkeletonList, SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; -import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; -import { - ISamplingSitePeriodRowData, - SamplingPeriodTable -} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; -import { - ISurveySitesRowData, - SurveySitesTable -} from 'features/surveys/view/components/sampling-data/components/SurveySitesTable'; -import { useSurveyContext } from 'hooks/useContext'; -import { useEffect, useMemo, useState } from 'react'; -import { SurveyTechniquesCardContainer } from './technique/SurveyTechniqueCardContainer'; - -export enum SurveySamplingView { - TECHNIQUES = 'TECHNIQUES', - SITES = 'SITES', - PERIODS = 'PERIODS' -} - -export const SurveySamplingTabs = () => { - const surveyContext = useSurveyContext(); - - const [activeView, setActiveView] = useState(SurveySamplingView.TECHNIQUES); - - const views = [ - { value: SurveySamplingView.TECHNIQUES, label: 'Techniques', icon: mdiAutoFix }, - { value: SurveySamplingView.SITES, label: 'Sampling Sites', icon: mdiMapMarker }, - { value: SurveySamplingView.PERIODS, label: 'Sampling Periods', icon: mdiCalendarRange } - ]; - - 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 - ]); - - const sampleSites: ISurveySitesRowData[] = useMemo( - () => - surveyContext.sampleSiteDataLoader.data?.sampleSites.map((site) => ({ - id: site.survey_sample_site_id, - name: site.name, - description: site.description, - geojson: site.geojson, - blocks: site.blocks.map((block) => block.name), - stratums: site.stratums.map((stratum) => stratum.name) - })) ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); - - const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { - const data: ISamplingSitePeriodRowData[] = []; - - for (const site of surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []) { - for (const method of site.sample_methods) { - for (const period of method.sample_periods) { - data.push({ - id: period.survey_sample_period_id, - sample_site: site.name, - sample_method: method.technique.name, - method_response_metric_id: method.method_response_metric_id, - start_date: period.start_date, - end_date: period.end_date, - start_time: period.start_time, - end_time: period.end_time - }); - } - } - } - - return data; - }, [surveyContext.sampleSiteDataLoader.data?.sampleSites]); - - const techniquesCount = surveyContext.techniqueDataLoader.data?.count; - const sampleSitesCount = surveyContext.sampleSiteDataLoader.data?.sampleSites.length; - const samplePeriodsCount = samplePeriods.length; - - return ( - - - - - - - - - {activeView === SurveySamplingView.TECHNIQUES && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!techniquesCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - {activeView === SurveySamplingView.SITES && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!sampleSitesCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - {activeView === SurveySamplingView.PERIODS && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!samplePeriodsCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - - ); -}; diff --git a/app/src/features/surveys/view/components/sampling-data/table/technique/SurveyTechniqueCardContainer.tsx b/app/src/features/surveys/view/components/sampling-data/table/technique/SurveyTechniqueCardContainer.tsx index 9646902f2b..059b7716f2 100644 --- a/app/src/features/surveys/view/components/sampling-data/table/technique/SurveyTechniqueCardContainer.tsx +++ b/app/src/features/surveys/view/components/sampling-data/table/technique/SurveyTechniqueCardContainer.tsx @@ -1,4 +1,5 @@ import Stack from '@mui/material/Stack'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; @@ -7,6 +8,11 @@ import { SurveyTechniqueCard } from './components/SurveyTechniqueCard'; export interface ISurveyTechniquesCardContainerProps { techniques: IGetTechniqueResponse[]; + paginationModel: GridPaginationModel; + sortModel: GridSortModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + rowCount: number; } export const SurveyTechniquesCardContainer = (props: ISurveyTechniquesCardContainerProps) => { @@ -23,7 +29,7 @@ export const SurveyTechniquesCardContainer = (props: ISurveyTechniquesCardContai }, [methodAttributeDataLoader]); return ( - + {techniques.map((technique) => { const attributes = methodAttributeDataLoader.data?.find( (method) => method.method_lookup_id === technique.method_lookup_id diff --git a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx index db559114d6..35e3c3fb4c 100644 --- a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx +++ b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx @@ -9,7 +9,9 @@ import { import { SurveySpatialTelemetry } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry'; import { useObservationsContext, useTaxonomyContext } from 'hooks/useContext'; import { isEqual } from 'lodash-es'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useSamplingSiteStaticLayer } from './components/map/useSamplingSiteStaticLayer'; +import { useStudyAreaStaticLayer } from './components/map/useStudyAreaStaticLayer'; /** * Container component for displaying survey spatial data. @@ -24,6 +26,14 @@ export const SurveySpatialContainer = (): JSX.Element => { const [activeView, setActiveView] = useState(SurveySpatialDatasetViewEnum.OBSERVATIONS); + const studyAreaStaticLayer = useStudyAreaStaticLayer(); + const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); + + const staticLayers = useMemo( + () => [studyAreaStaticLayer, samplingSiteStaticLayer], + [samplingSiteStaticLayer, studyAreaStaticLayer] + ); + // Fetch and cache all taxonomic data required for the observations. useEffect(() => { const cacheTaxonomicData = async () => { @@ -71,13 +81,15 @@ export const SurveySpatialContainer = (): JSX.Element => { /> {/* Display the corresponding dataset view based on the selected active view */} - {isEqual(SurveySpatialDatasetViewEnum.OBSERVATIONS, activeView) && } + {isEqual(SurveySpatialDatasetViewEnum.OBSERVATIONS, activeView) && ( + + )} {isEqual(SurveySpatialDatasetViewEnum.TELEMETRY, activeView) && ( - + )} - {isEqual(SurveySpatialDatasetViewEnum.ANIMALS, activeView) && } + {isEqual(SurveySpatialDatasetViewEnum.ANIMALS, activeView) && } ); }; diff --git a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx index d1688bd46c..12f12757ea 100644 --- a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx @@ -4,7 +4,7 @@ import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; import { SurveySpatialAnimalCapturePopup } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalCapturePopup'; import { SurveySpatialAnimalMortalityPopup } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalMortalityPopup'; import { SurveySpatialAnimalTable } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalTable'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; @@ -12,11 +12,18 @@ import useDataLoader from 'hooks/useDataLoader'; import { useEffect, useMemo } from 'react'; import { coloredCustomMortalityMarker } from 'utils/mapUtils'; +interface ISurveySpatialAnimalProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component for displaying animal capture points on a map and in a table. * Retrieves and displays data related to animal captures for a specific survey. */ -export const SurveySpatialAnimal = () => { +export const SurveySpatialAnimal = (props: ISurveySpatialAnimalProps) => { const surveyContext = useSurveyContext(); const crittersApi = useCritterbaseApi(); @@ -91,7 +98,10 @@ export const SurveySpatialAnimal = () => { <> {/* Display map with animal capture points */} - + {/* Display data table with animal capture details */} diff --git a/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx b/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx deleted file mode 100644 index ce916ecbc1..0000000000 --- a/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { IStaticLayer } from 'components/map/components/StaticLayers'; -import { useSamplingSiteStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer'; -import { useStudyAreaStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useStudyAreaStaticLayer'; -import SurveyMap from 'features/surveys/view/SurveyMap'; -import { useMemo } from 'react'; - -/** - * Props interface for SurveySpatialMap component. - */ -interface ISurveyDataMapProps { - /** - * Array of additional static layers to be added to the map. - */ - staticLayers: IStaticLayer[]; - /** - * Loading indicator to control map skeleton loader. - */ - isLoading: boolean; -} - -/** - * Component for displaying survey-related spatial data on a map. - * - * Automatically includes the study area and sampling site static layers. - * - * @param {ISurveyDataMapProps} props - * @return {*} - */ -export const SurveySpatialMap = (props: ISurveyDataMapProps) => { - const { staticLayers, isLoading } = props; - - const studyAreaStaticLayer = useStudyAreaStaticLayer(); - - const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); - - const allStaticLayers = useMemo( - () => [studyAreaStaticLayer, samplingSiteStaticLayer, ...staticLayers], - [samplingSiteStaticLayer, staticLayers, studyAreaStaticLayer] - ); - - return ; -}; diff --git a/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx b/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx index 9fdf33aa54..325903a7d6 100644 --- a/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx @@ -1,8 +1,11 @@ import { IStaticLayer } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; -import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; +import { SurveySampleSiteMapPopup } from 'features/surveys/view/SurveySampleSiteMapPopup'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; import { Popup } from 'react-leaflet'; /** @@ -12,6 +15,17 @@ import { Popup } from 'react-leaflet'; */ export const useSamplingSiteStaticLayer = (): IStaticLayer => { const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + const geometryDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSitesGeometry(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + geometryDataLoader.load(); + }, [geometryDataLoader]); + + const samplingSites = geometryDataLoader.data?.sampleSites ?? []; const samplingSiteStaticLayer: IStaticLayer = { layerName: 'Sampling Sites', @@ -20,7 +34,7 @@ export const useSamplingSiteStaticLayer = (): IStaticLayer => { fillColor: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR }, features: - surveyContext.sampleSiteDataLoader.data?.sampleSites.flatMap((site) => { + samplingSites.flatMap((site) => { return { id: site.survey_sample_site_id, key: `sampling-site-${site.survey_sample_site_id}`, @@ -28,32 +42,9 @@ export const useSamplingSiteStaticLayer = (): IStaticLayer => { }; }) ?? [], popup: (feature) => { - const sampleSite = surveyContext.sampleSiteDataLoader.data?.sampleSites.find( - (item) => item.survey_sample_site_id === feature.id - ); - - const metadata = []; - - if (sampleSite) { - metadata.push({ - label: 'Name', - value: sampleSite.name - }); - - metadata.push({ - label: 'Description', - value: sampleSite.description - }); - } - return ( - + ); }, diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx index 426c266a1f..a12f2b6630 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx @@ -2,8 +2,8 @@ import Box from '@mui/material/Box'; import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; import SurveyObservationTabularDataContainer from 'features/surveys/view/components/data-container/SurveyObservationTabularDataContainer'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; import { SurveySpatialObservationPointPopup } from 'features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationPointPopup'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; @@ -12,10 +12,17 @@ import { IGetSurveyObservationsGeometryResponse } from 'interfaces/useObservatio import { useEffect, useMemo } from 'react'; import { coloredCustomObservationMarker } from 'utils/mapUtils'; +interface ISurveySpatialObservationProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component to display survey observation data on a map and in a table. */ -export const SurveySpatialObservation = () => { +export const SurveySpatialObservation = (props: ISurveySpatialObservationProps) => { const surveyContext = useSurveyContext(); const { surveyId, projectId } = surveyContext; const biohubApi = useBiohubApi(); @@ -62,12 +69,15 @@ export const SurveySpatialObservation = () => { <> {/* Display map with observation points */} - + {/* Display data table with observation details */} - - + + ); diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx index a7afdece4e..74fa78a213 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx @@ -28,17 +28,12 @@ interface IObservationTableRow { longitude: number | null; } -interface ISurveyDataObservationTableProps { - isLoading: boolean; -} - /** * Component to display observation data in a table with server-side pagination and sorting. * - * @param {ISurveyDataObservationTableProps} props - Component properties. - * @returns {JSX.Element} The rendered component. + * @returns {*} */ -export const SurveySpatialObservationTable = (props: ISurveyDataObservationTableProps) => { +export const SurveySpatialObservationTable = () => { const biohubApi = useBiohubApi(); const surveyContext = useContext(SurveyContext); const taxonomyContext = useTaxonomyContext(); @@ -160,7 +155,7 @@ export const SurveySpatialObservationTable = (props: ISurveyDataObservationTable return ( } isLoadingFallbackDelay={100} hasNoData={!rows.length} diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx index 25011e2585..2156146e37 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx @@ -1,9 +1,9 @@ import Box from '@mui/material/Box'; import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; import { SurveySpatialTelemetryPopup } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup'; import { SurveySpatialTelemetryTable } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { Position } from 'geojson'; import { useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; @@ -11,12 +11,19 @@ import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; import { IAnimalDeployment, ITelemetry } from 'interfaces/useTelemetryApi.interface'; import { useCallback, useEffect, useMemo } from 'react'; +interface ISurveySpatialTelemetryProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component to display telemetry data on a map and in a table. * - * @returns {JSX.Element} The rendered component. + * @returns {*} The rendered component. */ -export const SurveySpatialTelemetry = () => { +export const SurveySpatialTelemetry = (props: ISurveySpatialTelemetryProps) => { const surveyContext = useSurveyContext(); const telemetryDataContext = useTelemetryDataContext(); @@ -131,12 +138,12 @@ export const SurveySpatialTelemetry = () => { <> {/* Display map with telemetry points */} - + {/* Display data table with telemetry details */} - + ); diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx index c36cad35b5..42e09d47a3 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx @@ -29,18 +29,12 @@ interface ITelemetryData { itis_scientific_name: string; } -interface ISurveyDataTelemetryTableProps { - isLoading: boolean; -} - /** * Component to display telemetry data in a table format. * - * @param {ISurveyDataTelemetryTableProps} props - The component props. - * @param {boolean} props.isLoading - Indicates if the data is currently loading. - * @returns {JSX.Element} The rendered component. + * @returns {*} The rendered component. */ -export const SurveySpatialTelemetryTable = (props: ISurveyDataTelemetryTableProps) => { +export const SurveySpatialTelemetryTable = () => { const surveyContext = useContext(SurveyContext); const telemetryDataContext = useTelemetryDataContext(); @@ -170,7 +164,7 @@ export const SurveySpatialTelemetryTable = (props: ISurveyDataTelemetryTableProp return ( } isLoadingFallbackDelay={100} hasNoData={!rows.length} diff --git a/app/src/hooks/api/useAlertApi.ts b/app/src/hooks/api/useAlertApi.ts new file mode 100644 index 0000000000..3dd402e7bc --- /dev/null +++ b/app/src/hooks/api/useAlertApi.ts @@ -0,0 +1,88 @@ +import { AxiosInstance } from 'axios'; +import { + IAlert, + IAlertCreateObject, + IAlertFilterParams, + IAlertUpdateObject, + IGetAlertsResponse +} from 'interfaces/useAlertApi.interface'; +import qs from 'qs'; + +/** + * Returns a set of supported api methods for managing alerts + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +export const useAlertApi = (axios: AxiosInstance) => { + /** + * Get system alert details based on its ID for viewing purposes. + * + * @param {IAlertFilterParams} filterObject + * @return {*} {Promise} + */ + const getAlerts = async (filterObject?: IAlertFilterParams): Promise => { + const params = { + ...filterObject + }; + + const { data } = await axios.get(`/api/alert`, { + params: params, + paramsSerializer: (params: any) => { + return qs.stringify(params); + } + }); + + return data; + }; + + /** + * Get a specific alert for editing + * + * @param {number} alertId + * @return {*} {Promise} + */ + const getAlertById = async (alertId: number): Promise => { + const { data } = await axios.get(`/api/alert/${alertId}`); + + return data; + }; + + /** + * Create a new system alert + * + * @param {IAlertCreateObject} alert + * @return {*} {Promise} + */ + const createAlert = async (alert: IAlertCreateObject): Promise => { + const { data } = await axios.post(`/api/alert`, alert); + + return data; + }; + + /** + * Create a new system alert + * + * @param {IAlert} alert + * @return {*} {Promise<{ alert_id: number }>} + */ + const updateAlert = async (alert: IAlertUpdateObject): Promise<{ alert_id: number }> => { + const { data } = await axios.put(`/api/alert/${alert.alert_id}`, alert); + + return data; + }; + + /** + * Get system alert details based on its ID for viewing purposes. + * + * @param {number} alertId + * @return {*} {Promise<{ alert_id: number }>} + */ + const deleteAlert = async (alertId: number): Promise<{ alert_id: number }> => { + const { data } = await axios.delete(`/api/alert/${alertId}`); + + return data; + }; + + return { getAlerts, updateAlert, createAlert, deleteAlert, getAlertById }; +}; diff --git a/app/src/hooks/api/useAnimalApi.ts b/app/src/hooks/api/useAnimalApi.ts index caf5bb9882..451c8e770c 100644 --- a/app/src/hooks/api/useAnimalApi.ts +++ b/app/src/hooks/api/useAnimalApi.ts @@ -20,7 +20,7 @@ const useAnimalApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @return {*} {Promise} + * @return {*} {Promise} */ const getCaptureMortalityGeometry = async ( projectId: number, diff --git a/app/src/hooks/api/useObservationApi.test.ts b/app/src/hooks/api/useObservationApi.test.ts index 0b2e4173ff..eeec7a8e78 100644 --- a/app/src/hooks/api/useObservationApi.test.ts +++ b/app/src/hooks/api/useObservationApi.test.ts @@ -40,7 +40,8 @@ describe('useObservationApi', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 100, diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 653c061ec3..702a76afca 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -105,23 +105,15 @@ const useObservationApi = (axios: AxiosInstance) => { surveyId: number, pagination?: ApiPaginationRequestOptions ): Promise => { - let urlParamsString = ''; - - if (pagination) { - const params = new URLSearchParams(); - params.append('page', pagination.page.toString()); - params.append('limit', pagination.limit.toString()); - if (pagination.sort) { - params.append('sort', pagination.sort); - } - if (pagination.order) { - params.append('order', pagination.order); - } - urlParamsString = `?${params.toString()}`; - } + const params = { + ...pagination + }; const { data } = await axios.get( - `/api/project/${projectId}/survey/${surveyId}/observations${urlParamsString}` + `/api/project/${projectId}/survey/${surveyId}/observations`, + { + params + } ); return data; diff --git a/app/src/hooks/api/useSamplingSiteApi.ts b/app/src/hooks/api/useSamplingSiteApi.ts index a8f7783595..a53a84c475 100644 --- a/app/src/hooks/api/useSamplingSiteApi.ts +++ b/app/src/hooks/api/useSamplingSiteApi.ts @@ -2,9 +2,13 @@ import { AxiosInstance } from 'axios'; import { ICreateSamplingSiteRequest, IEditSampleSiteRequest, + IFindSamplePeriodResponse, + IFindSampleSiteResponse, IGetSampleLocationDetails, - IGetSampleSiteResponse + IGetSampleLocationNonSpatialResponse, + IGetSampleSiteGeometryResponse } from 'interfaces/useSamplingSiteApi.interface'; +import { ApiPaginationRequestOptions } from 'types/misc'; /** * Returns a set of supported api methods for working with search functionality @@ -30,14 +34,45 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { }; /** - * Get Sample Sites + * Get Sample Sites, paginated or filtered by keyword. * * @param {number} projectId * @param {number} surveyId - * @return {*} {Promise} + * @param {ApiPaginationRequestOptions} pagination + * @return {*} {Promise} + */ + const getSampleSites = async ( + projectId: number, + surveyId: number, + options?: { + keyword?: string; + pagination?: ApiPaginationRequestOptions; + } + ): Promise => { + const params = { + keyword: options?.keyword, + ...options?.pagination + }; + + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site`, { + params + }); + + return data; + }; + + /** + * Get Sample Sites geometry data + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} */ - const getSampleSites = async (projectId: number, surveyId: number): Promise => { - const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site`); + const getSampleSitesGeometry = async ( + projectId: number, + surveyId: number + ): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site/spatial`); return data; }; @@ -48,7 +83,7 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {number} sampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} */ const getSampleSiteById = async ( projectId: number, @@ -59,6 +94,103 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { return data; }; + /** + * Find sample sites. + * + * @param {{ + * survey_id?: number; + * keyword?: string; + * system_user_id?: number; + * }} [filterFieldData] + * @param {ApiPaginationRequestOptions} [pagination] + * @return {*} {Promise} + */ + const findSampleSites = async ( + filterFieldData?: { + survey_id?: number; + keyword?: string; + system_user_id?: number; + }, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { + ...filterFieldData, + ...pagination + }; + + const { data } = await axios.get(`/api/sampling-locations/sites`, { + params + }); + + return data; + }; + + /** + * Find sample methods. + * + * @param {{ + * survey_id?: number; + * sample_site_id: number; + * keyword?: string; + * system_user_id?: number; + * }} [filterFieldData] + * @param {ApiPaginationRequestOptions} [pagination] + * @return {*} {Promise} + */ + const findSampleMethods = async ( + filterFieldData?: { + survey_id?: number; + sample_site_id: number; + keyword?: string; + system_user_id?: number; + }, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { + ...filterFieldData, + ...pagination + }; + + const { data } = await axios.get(`/api/sampling-locations/methods`, { + params + }); + + return data; + }; + + /** + * Find sample periods. + * + * @param {{ + * survey_id?: number; + * sample_site_id: number; + * sample_method_id: number; + * system_user_id?: number; + * }} [filterFieldData] + * @param {ApiPaginationRequestOptions} [pagination] + * @return {*} {Promise} + */ + const findSamplePeriods = async ( + filterFieldData?: { + survey_id?: number; + sample_site_id?: number; + sample_method_id?: number; + system_user_id?: number; + }, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { + ...filterFieldData, + ...pagination + }; + + const { data } = await axios.get(`/api/sampling-locations/periods`, { + params + }); + + return data; + }; + /** * Edit Sample Site * @@ -109,6 +241,10 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { createSamplingSites, getSampleSites, getSampleSiteById, + getSampleSitesGeometry, + findSampleSites, + findSampleMethods, + findSamplePeriods, editSampleSite, deleteSampleSite, deleteSampleSites diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index 0115bd2df5..794ca897e1 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -3,6 +3,7 @@ import useReferenceApi from 'hooks/api/useReferenceApi'; import { useConfigContext } from 'hooks/useContext'; import { useMemo } from 'react'; import useAdminApi from './api/useAdminApi'; +import { useAlertApi } from './api/useAlertApi'; import useAnalyticsApi from './api/useAnalyticsApi'; import useAnimalApi from './api/useAnimalApi'; import useAxios from './api/useAxios'; @@ -72,6 +73,8 @@ export const useBiohubApi = () => { const telemetry = useTelemetryApi(apiAxios); + const alert = useAlertApi(apiAxios); + return useMemo( () => ({ analytics, @@ -93,7 +96,8 @@ export const useBiohubApi = () => { samplingSite, standards, reference, - telemetry + telemetry, + alert }), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/src/interfaces/useAlertApi.interface.ts b/app/src/interfaces/useAlertApi.interface.ts new file mode 100644 index 0000000000..deb5e5d6bd --- /dev/null +++ b/app/src/interfaces/useAlertApi.interface.ts @@ -0,0 +1,38 @@ +export interface IGetAlertsResponse { + alerts: IAlert[]; +} + +export type AlertSeverity = 'info' | 'success' | 'error' | 'warning'; +export interface IAlert { + alert_id: number; + alert_type_id: number; + severity: AlertSeverity; + name: string; + message: string; + data: object | null; + record_end_date: string | null; + status: 'expired' | 'active'; +} + +export type IAlertCreateObject = Omit; + +export type IAlertUpdateObject = Omit; + +export interface IAlertFilterParams { + expiresBefore?: string; + expiresAfter?: string; + types?: SystemAlertBannerEnum[]; +} + +export enum SystemAlertBannerEnum { + SUMMARY = 'Summary', + TELEMETRY = 'Manage Telemetry', + OBSERVATIONS = 'Manage Observations', + ANIMALS = 'Manage Animals', + SAMPLING = 'Manage Sampling', + PROJECTS = 'Project', + SURVEYS = 'Survey', + STANDARDS = 'Standards', + ADMINISTRATOR = 'Administrator', + FUNDING = 'Funding' +} diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index 61d67cc9ea..a053431566 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -41,4 +41,5 @@ export interface IGetAllCodeSetsResponse { method_response_metrics: CodeSet<{ id: number; name: string; description: string }>; attractants: CodeSet<{ id: number; name: string; description: string }>; observation_subcount_signs: CodeSet<{ id: number; name: string; description: string }>; + alert_types: CodeSet<{ id: number; name: string; description: string }>; } diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index bc92967884..dc521ed39d 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -7,6 +7,7 @@ import { EnvironmentQuantitativeTypeDefinition } from 'interfaces/useReferenceApi.interface'; import { ApiPaginationResponseParams } from 'types/misc'; +import { IGetSampleLocationNonSpatialDetails } from './useSamplingSiteApi.interface'; export interface IGetSurveyObservationsResponse { surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; supplementaryObservationData: SupplementaryObservationData; @@ -20,7 +21,7 @@ export interface IGetSurveyObservationsGeometryObject { export interface IGetSurveyObservationsGeometryResponse { surveyObservationsGeometry: IGetSurveyObservationsGeometryObject[]; - supplementaryObservationData: SupplementaryObservationData; + supplementaryObservationData: SupplementaryObservationCountData; } type ObservationSamplingData = { @@ -37,8 +38,8 @@ export type StandardObservationColumns = { survey_sample_method_id: number | null; survey_sample_period_id: number | null; count: number | null; - observation_date: string; - observation_time: string; + observation_date: string | null; + observation_time: string | null; latitude: number | null; longitude: number | null; }; @@ -66,6 +67,11 @@ export type SupplementaryObservationCountData = { observationCount: number; }; +export type ObservationSamplingSupplementaryData = { + // sample_sites: IGetBasicSampleLocation[]; + sample_sites: IGetSampleLocationNonSpatialDetails[]; +}; + export type SupplementaryObservationMeasurementData = { qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; @@ -73,7 +79,9 @@ export type SupplementaryObservationMeasurementData = { quantitative_environments: EnvironmentQuantitativeTypeDefinition[]; }; -export type SupplementaryObservationData = SupplementaryObservationCountData & SupplementaryObservationMeasurementData; +export type SupplementaryObservationData = SupplementaryObservationCountData & + SupplementaryObservationMeasurementData & + ObservationSamplingSupplementaryData; type ObservationSubCountQualitativeMeasurementRecord = { observation_subcount_id: number; diff --git a/app/src/interfaces/useSamplingSiteApi.interface.ts b/app/src/interfaces/useSamplingSiteApi.interface.ts index 33799d4129..5338a7951a 100644 --- a/app/src/interfaces/useSamplingSiteApi.interface.ts +++ b/app/src/interfaces/useSamplingSiteApi.interface.ts @@ -40,11 +40,22 @@ export interface IEditSampleSiteRequest { }; } -export interface IGetSampleSiteResponse { - sampleSites: IGetSampleLocationDetails[]; +export interface IGetSampleLocationNonSpatialResponse { + sampleSites: IGetSampleLocationNonSpatialDetails[]; pagination: ApiPaginationResponseParams; } +export interface IGetSampleLocationNonSpatialDetails { + survey_sample_site_id: number; + survey_id: number; + name: string; + description: string; + geometry_type: string; + sample_methods: IGetSampleMethodDetails[]; + blocks: IGetSampleBlockDetails[]; + stratums: IGetSampleStratumDetails[]; +} + export interface IGetSampleLocationRecord { survey_sample_site_id: number; survey_id: number; @@ -58,6 +69,42 @@ export interface IGetSampleLocationRecord { revision_count: number; } +export interface IGetSampleSiteGeometryResponse { + sampleSites: IGetSampleSiteGeometry[]; +} + +export interface IFindSampleSiteResponse { + sites: { + survey_sample_site_id: number; + survey_id: number; + name: string; + description: string | null; + geometry_type: string; + blocks: IGetSampleBlockDetails[]; + stratums: IGetSampleStratumDetails[]; + }[]; + pagination: ApiPaginationResponseParams; +} + +export interface IFindSampleSiteRecord { + survey_sample_site_id: number; + survey_id: number; + name: string; + description: string | null; + geometry_type: string; + blocks: IGetSampleBlockDetails[]; + stratums: IGetSampleStratumDetails[]; +} +export interface IFindSampleSiteResponse { + sites: IFindSampleSiteRecord[]; + pagination: ApiPaginationResponseParams; +} + +export interface IGetSampleSiteGeometry { + survey_sample_site_id: number; + geojson: Feature; +} + export interface IGetSampleLocationDetails { survey_sample_site_id: number; survey_id: number; @@ -69,6 +116,29 @@ export interface IGetSampleLocationDetails { stratums: IGetSampleStratumDetails[]; } +export interface IGetBasicSamplePeriod { + survey_sample_period_id: number; + survey_sample_method_id: number; + start_date: string; + end_date: string; + start_time: string; + end_time: string; +} + +export interface IGetBasicSampleMethod { + survey_sample_method_id: number; + survey_sample_site_id: number; + method_response_metric_id: number; + technique: { survey_technique_id: number; name: string }; + sample_periods: IGetBasicSamplePeriod[]; +} + +export interface IGetBasicSampleLocation { + survey_sample_site_id: number; + name: string; + sample_methods: IGetBasicSampleMethod; +} + export interface IGetSampleLocationDetailsForUpdate { survey_sample_site_id: number | null; survey_id: number; @@ -84,11 +154,11 @@ export interface IGetSampleBlockDetails { survey_sample_block_id: number; survey_sample_site_id: number | null; survey_block_id: number; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; + // create_date: string; + // create_user: number; + // update_date: string | null; + // update_user: number | null; + // revision_count: number; name: string; description: string; } @@ -97,11 +167,11 @@ export interface IGetSampleStratumDetails { survey_sample_stratum_id: number; survey_sample_site_id: number; survey_stratum_id: number; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; + // create_date: string; + // create_user: number; + // update_date: string | null; + // update_user: number | null; + // revision_count: number; name: string; description: string; } @@ -137,3 +207,28 @@ export interface IGetSamplePeriodRecord { update_user: number | null; revision_count: number; } + +export interface IFindSamplePeriodRecord { + survey_sample_period_id: number; + survey_sample_method_id: number; + survey_id: number; + start_date: string | null; + end_date: string | null; + start_time: string | null; + end_time: string | null; + sample_method: { + method_response_metric_id: number; + }; + method_technique: { + method_technique_id: number; + name: string; + }; + sample_site: { + survey_sample_site_id: number; + name: string; + }; +} +export interface IFindSamplePeriodResponse { + periods: IFindSamplePeriodRecord[]; + pagination: ApiPaginationResponseParams; +} diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index fd99d9a7fe..0f6dfaa043 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -70,6 +70,7 @@ export interface ISurveyBlockForm { survey_block_id: number | null; name: string; description: string; + geojson: Feature; sample_block_count: number; }[]; } @@ -138,6 +139,7 @@ export interface IGetSurveyBlock { name: string; description: string; revision_count: number; + geojson: Feature; sample_block_count: number; } @@ -219,6 +221,7 @@ export type IUpdateSurveyRequest = ISurveyLocationForm & { survey_block_id?: number | null; name: string; description: string; + geojson: Feature | null; }[]; site_selection: { strategies: string[]; @@ -441,6 +444,7 @@ export interface IGetSurveyForUpdateResponse { survey_id: number; name: string; description: string; + geojson: Feature; sample_block_count: number; revision_count: number; }[]; diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 240f3e60ce..368f48a5da 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -66,5 +66,9 @@ export const codes: IGetAllCodeSetsResponse = { observation_subcount_signs: [ { id: 1, name: 'Scat', description: 'Scat left by the species.' }, { id: 2, name: 'Direct sighting', description: 'A direct sighting of the species.' } + ], + alert_types: [ + { id: 1, name: 'Survey', description: 'Alert about surveys.' }, + { id: 2, name: 'General', description: 'General alert.' } ] }; diff --git a/app/src/themes/appTheme.ts b/app/src/themes/appTheme.ts index cd82c81de1..6dc932c415 100644 --- a/app/src/themes/appTheme.ts +++ b/app/src/themes/appTheme.ts @@ -134,7 +134,6 @@ const appTheme = createTheme({ textOverflow: 'ellipsis' }, '& span': { - display: 'block', fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis' diff --git a/app/src/utils/datetime.test.ts b/app/src/utils/datetime.test.ts new file mode 100644 index 0000000000..582fcd1e72 --- /dev/null +++ b/app/src/utils/datetime.test.ts @@ -0,0 +1,53 @@ +import { combineDateTime, formatTimeDifference } from './datetime'; + +describe('combineDateTime', () => { + it('combines date and time into an ISO string', () => { + const result = combineDateTime('2024-01-01', '12:30:00'); + expect(result).toEqual('2024-01-01T12:30:00.000Z'); + }); + + it('combines date without time into an ISO string', () => { + const result = combineDateTime('2024-01-01'); + expect(result).toEqual('2024-01-01T00:00:00.000Z'); + }); + + it('returns ISO string for a different date and time', () => { + const result = combineDateTime('2023-12-31', '23:59:59'); + expect(result).toEqual('2023-12-31T23:59:59.000Z'); + }); + + it('handles invalid date formats gracefully', () => { + const date = combineDateTime('badDate', '12:00'); + expect(date).toEqual('Invalid Date'); + + const time = combineDateTime('2024-01-01', 'badtime'); + expect(time).toEqual('Invalid Date'); + }); +}); + +describe('formatTimeDifference', () => { + it('formats the time difference correctly between two dates and times', () => { + const result = formatTimeDifference('2024-01-01', '12:00', '2024-01-02', '13:30'); + expect(result).toEqual('1 day and 1 hour'); + }); + + it('handles time difference with only dates', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-03', null); + expect(result).toEqual('2 days'); + }); + + it('formats the time difference correctly with no time component', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-01', '01:00'); + expect(result).toEqual('1 hour'); + }); + + it('returns null when there is no time difference', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-01', null); + expect(result).toBeNull(); + }); + + it('handles cases with invalid inputs', () => { + const result = formatTimeDifference('invalid-date', null, '2024-01-01', null); + expect(result).toBeNull(); + }); +}); diff --git a/app/src/utils/datetime.ts b/app/src/utils/datetime.ts index cec5233cf9..3c7dad2da2 100644 --- a/app/src/utils/datetime.ts +++ b/app/src/utils/datetime.ts @@ -1,3 +1,11 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { pluralize } from './Utils'; + +const TIMESTAMP_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSS[Z]'; + +dayjs.extend(duration); + /** * Combine date and time and return ISO string. * @@ -7,7 +15,50 @@ */ export const combineDateTime = (date: string, time?: string | null) => { if (date && time) { - return new Date(`${date}T${time}`).toISOString(); + return dayjs(`${date} ${time}`).format(TIMESTAMP_FORMAT); } - return new Date(`${date}T00:00:00`).toISOString(); + + return dayjs(`${date}`).format(TIMESTAMP_FORMAT); +}; + +/** + * Formats the time difference between two timestamps into a human-readable string. + * + * @param {string} startDate + * @param {string | null} startTime + * @param {string} endDate + * @param {string | null} endTime + * @returns {string | null} A formatted string indicating an amount of time + */ +export const formatTimeDifference = ( + startDate?: string | null, + startTime?: string | null, + endDate?: string | null, + endTime?: string | null +): string | null => { + const startDateTime = startTime ? dayjs(`${startDate} ${startTime}`) : dayjs(startDate); + const endDateTime = endTime ? dayjs(`${endDate} ${endTime}`) : dayjs(endDate); + + if (!startDateTime.isValid() || !endDateTime.isValid()) { + return null; + } + + // Calculate the total difference + const diff = dayjs.duration(endDateTime.diff(startDateTime)); + + const parts = []; + + for (const unit of ['year', 'month', 'day', 'hour', 'minute', 'second']) { + const value = diff[`${unit}s`](); + + if (value > 0) { + parts.push(`${value} ${pluralize(value, unit)}`); + } + } + + if (!parts.length) { + return null; + } + + return parts.slice(0, 2).join(' and '); }; diff --git a/app/src/utils/spatial-utils.test.ts b/app/src/utils/spatial-utils.test.ts index 7f4456b85b..161c1e7cb7 100644 --- a/app/src/utils/spatial-utils.test.ts +++ b/app/src/utils/spatial-utils.test.ts @@ -165,7 +165,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.TRANSECT); }); @@ -183,7 +183,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.TRANSECT); }); @@ -198,7 +198,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.POINT); }); @@ -216,7 +216,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.POINT); }); @@ -238,7 +238,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.AREA); }); @@ -262,7 +262,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.AREA); }); diff --git a/app/src/utils/spatial-utils.ts b/app/src/utils/spatial-utils.ts index 0b088e5cf5..13432db010 100644 --- a/app/src/utils/spatial-utils.ts +++ b/app/src/utils/spatial-utils.ts @@ -52,18 +52,16 @@ export const isGeoJsonPointFeature = (feature?: unknown): feature is Feature { - const geometryType = feature.geometry.type; - - if (['MultiLineString', 'LineString'].includes(geometryType)) { +export const getSamplingSiteSpatialType = (type: string): SAMPLING_SITE_SPATIAL_TYPE | null => { + if (['MultiLineString', 'LineString'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.TRANSECT; } - if (['Point', 'MultiPoint'].includes(geometryType)) { + if (['Point', 'MultiPoint'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.POINT; } - if (['Polygon', 'MultiPolygon'].includes(geometryType)) { + if (['Polygon', 'MultiPolygon'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.AREA; } diff --git a/compose.yml b/compose.yml index 8d1955a247..df5d29b2a9 100644 --- a/compose.yml +++ b/compose.yml @@ -50,12 +50,6 @@ services: - DB_PORT=5432 - DB_DATABASE=${DB_DATABASE} - DB_SCHEMA=${DB_SCHEMA} - # Seed - - PROJECT_SEEDER_USER_IDENTIFIER=${PROJECT_SEEDER_USER_IDENTIFIER} - - NUM_SEED_PROJECTS=${NUM_SEED_PROJECTS} - - NUM_SEED_SURVEYS_PER_PROJECT=${NUM_SEED_SURVEYS_PER_PROJECT} - - NUM_SEED_OBSERVATIONS_PER_SURVEY=${NUM_SEED_OBSERVATIONS_PER_SURVEY} - - NUM_SEED_SUBCOUNTS_PER_OBSERVATION=${NUM_SEED_SUBCOUNTS_PER_OBSERVATION} # Keycloak - KEYCLOAK_HOST=${KEYCLOAK_HOST} - KEYCLOAK_REALM=${KEYCLOAK_REALM} diff --git a/database/src/migrations/20240927000000_alert_table.ts b/database/src/migrations/20240927000000_alert_table.ts new file mode 100644 index 0000000000..a1b8e0ad8a --- /dev/null +++ b/database/src/migrations/20240927000000_alert_table.ts @@ -0,0 +1,130 @@ +import { Knex } from 'knex'; + +/** + * New table: + * - alert + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Create lookup table for alert type options + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub; + + CREATE TABLE alert_type ( + alert_type_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(100) NOT NULL, + description varchar(100) NOT NULL, + 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 alert_type_pk PRIMARY KEY (alert_type_id) + ); + + COMMENT ON TABLE alert_type IS 'Alert type options that alerts can belong to.'; + COMMENT ON COLUMN alert_type.alert_type_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN alert_type.name IS 'The name of the alert_type.'; + COMMENT ON COLUMN alert_type.description IS 'The description of the alert_type and its intended use case.'; + COMMENT ON COLUMN alert_type.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN alert_type.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN alert_type.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN alert_type.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN alert_type.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN alert_type.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + -- Create new table + ---------------------------------------------------------------------------------------- + + CREATE TYPE alert_severity AS ENUM ('info', 'warning', 'error', 'success'); + + CREATE TABLE alert ( + alert_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + alert_type_id integer NOT NULL, + name varchar(50) NOT NULL, + message varchar(250) NOT NULL, + data json, + severity alert_severity NOT NULL, + 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 alert_pk PRIMARY KEY (alert_id) + ); + + COMMENT ON TABLE alert IS 'Alert records about various topics (i.e. bad data, system news, etc).'; + COMMENT ON COLUMN alert.alert_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN alert.alert_type_id IS 'The alert_type_id of the alert. Used to categorize/group alerts by type.'; + COMMENT ON COLUMN alert.name IS 'The name of the alert.'; + COMMENT ON COLUMN alert.message IS 'The message of the alert.'; + COMMENT ON COLUMN alert.severity IS 'The severity of the alert, used for MUI styling.'; + COMMENT ON COLUMN alert.data IS 'The data of the alert. Should ideally align with the type of the alert.'; + COMMENT ON COLUMN alert.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN alert.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN alert.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN alert.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN alert.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN alert.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + -- Create audit/journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_alert BEFORE INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_alert AFTER INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_alert_type BEFORE INSERT OR UPDATE OR DELETE ON biohub.alert_type FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_alert_type AFTER INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create constraints/indexes on foreign keys + ---------------------------------------------------------------------------------------- + + ALTER TABLE alert ADD CONSTRAINT alert_fk1 + FOREIGN KEY (alert_type_id) + REFERENCES alert_type(alert_type_id); + + CREATE INDEX alert_idx1 ON alert(alert_type_id); + + ---------------------------------------------------------------------------------------- + -- Insert initial alert type options + ---------------------------------------------------------------------------------------- + + INSERT INTO + alert_type (name, description) + VALUES + ('Summary', 'General alerts for the summary page.'), + ('Manage Telemetry', 'Alerts about telemetry.'), + ('Manage Observations', 'Alerts about observations.'), + ('Manage Animals', 'Alerts about animals.'), + ('Manage Sampling', 'Alerts about sampling information.'), + ('Project', 'Alerts about Projects.'), + ('Survey', 'Alerts about Surveys.'), + ('Standards', 'Alerts about standards.'), + ('Funding', 'Alerts about funding sources.'), + ('Administrator', 'Alerts about administrator functions.'); + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW alert AS SELECT * FROM biohub.alert; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241023115300_observation_contraints.ts b/database/src/migrations/20241023115300_observation_contraints.ts new file mode 100644 index 0000000000..394ac6dd23 --- /dev/null +++ b/database/src/migrations/20241023115300_observation_contraints.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex'; + +/** + * Drops NOT NULL constraints on observation latitude, longitude, date and time. + * Observations can be valid without locations and timestamps. eg. A surveyor only measured the start and end of their + * sampling period, not the time of each observation made during that period. + * + * When interpreting the data, observations without locations/timestamps are assumed to inherit the location/time range of + * associated sampling records. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH=biohub; + + ALTER TABLE survey_observation + ALTER COLUMN latitude DROP NOT NULL, + ALTER COLUMN longitude DROP NOT NULL, + ALTER COLUMN observation_date DROP NOT NULL, + ALTER COLUMN observation_time DROP NOT NULL, + ADD CONSTRAINT survey_observation_date_check + CHECK (observation_date IS NOT NULL OR survey_sample_period_id IS NOT NULL), + ADD CONSTRAINT survey_observation_location_check + CHECK ((latitude IS NOT NULL AND longitude IS NOT NULL) OR survey_sample_period_id IS NOT NULL); + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241101160200_survey_block_geojson.ts b/database/src/migrations/20241101160200_survey_block_geojson.ts new file mode 100644 index 0000000000..c5d2053f8a --- /dev/null +++ b/database/src/migrations/20241101160200_survey_block_geojson.ts @@ -0,0 +1,30 @@ +import { Knex } from 'knex'; + +/** + * Add geometry-related columns to the survey block table, allowing blocks to be spatial. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH=biohub, public; + + ALTER TABLE biohub.survey_block ADD COLUMN geojson JSONB NOT NULL; + ALTER TABLE biohub.survey_block ADD COLUMN geometry geometry(geometry, 3005); + ALTER TABLE biohub.survey_block ADD COLUMN geography geography(geometry, 4326); + + COMMENT ON COLUMN survey_block.geojson IS 'A JSON representation of the project boundary geometry that provides necessary details for shape manipulation in client side tools.'; + COMMENT ON COLUMN survey_block.geometry IS 'The containing geometry of the record.'; + COMMENT ON COLUMN survey_block.geography IS 'The containing geography of the record.'; + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW biohub_dapi_v1.survey_block AS SELECT * FROM biohub.survey_block; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index 00b02d99e1..89eef14992 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -51,6 +51,11 @@ export async function seed(knex: Knex): Promise { await knex.raw(`${insertAccessRequest()}`); } + // Insert system alerts + for (let i = 0; i < 8; i++) { + await knex.raw(`${insertSystemAlert()}`); + } + // Check if at least 1 project already exists const checkProjectsResponse = await knex.raw(checkAnyProjectExists()); @@ -770,3 +775,31 @@ const insertAccessRequest = () => ` $$${faker.lorem.sentences(2)}$$ ); `; + +/** + * SQL to insert a fake system alert + * + */ +const insertSystemAlert = () => ` + INSERT INTO alert + ( + alert_type_id, + name, + message, + data, + severity, + record_end_date, + create_user, + update_user + ) + VALUES ( + (SELECT alert_type_id FROM alert_type ORDER BY random() LIMIT 1), + $$${faker.lorem.words(3)}$$, + $$${faker.lorem.sentences(2)}$$, + NULL, + '${faker.helpers.arrayElement(['info', 'success', 'warning', 'error'])}', + (CASE WHEN random() < 0.5 THEN NULL ELSE (CURRENT_DATE - INTERVAL '30 days') END), + (SELECT system_user_id FROM system_user ORDER BY random() LIMIT 1), + (SELECT system_user_id FROM system_user ORDER BY random() LIMIT 1) + ); +`;