From ef532a352747584d3f930fcb21d5eb9a6a89d8d1 Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Wed, 15 Jan 2025 10:03:19 -0800 Subject: [PATCH 1/4] SIMSBIOHUB-659: Add Pagination to System Alerts on Admin Page (#1459) Add Pagination to System Alerts on Admin Page --------- Co-authored-by: Nick Phura --- api/src/database-models/alert.ts | 38 ++++++++ api/src/models/alert-view.ts | 20 ++--- api/src/openapi/schemas/alert.ts | 16 +++- api/src/paths/alert/index.test.ts | 32 ++++++- api/src/paths/alert/index.ts | 23 ++++- api/src/paths/alert/{alertId}/index.test.ts | 3 +- api/src/repositories/alert-repository.ts | 76 ++++++++++++++-- api/src/services/alert-service.test.ts | 83 ++++++++++++------ api/src/services/alert-service.ts | 33 +++++-- .../features/admin/alert/AlertContainer.tsx | 74 ++++++++++++---- .../features/admin/alert/table/AlertTable.tsx | 86 +++++++++++-------- app/src/hooks/api/useAlertApi.ts | 8 +- app/src/interfaces/useAlertApi.interface.ts | 17 +++- 13 files changed, 386 insertions(+), 123 deletions(-) create mode 100644 api/src/database-models/alert.ts diff --git a/api/src/database-models/alert.ts b/api/src/database-models/alert.ts new file mode 100644 index 0000000000..f5b37bacef --- /dev/null +++ b/api/src/database-models/alert.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +/** + * Alert Model. + * + * @description Data model for `Alert`. + */ +export const AlertModel = z.object({ + alert_id: z.number(), + alert_type_id: z.number(), + name: z.string(), + message: z.string(), + severity: z.enum(['info', 'success', 'error', 'warning']), + data: z.object({}).nullable(), + record_end_date: 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 AlertModel = z.infer; + +/** + * Alert Record. Intentionally do not omit create_date. + * + * @description Data record for `Alert`. + */ +export const AlertRecord = AlertModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type AlertRecord = z.infer; diff --git a/api/src/models/alert-view.ts b/api/src/models/alert-view.ts index f57e2fffa5..436fcfd98d 100644 --- a/api/src/models/alert-view.ts +++ b/api/src/models/alert-view.ts @@ -1,21 +1,15 @@ import { z } from 'zod'; +import { AlertRecord } from '../database-models/alert'; -// 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(), +export const AlertRecordWithStatus = AlertRecord.extend({ + create_date: z.string(), status: z.enum(['active', 'expired']) }); +export type AlertRecordWithStatus = z.infer; -// Infer types from the schema -export type IAlert = z.infer; -export type IAlertCreateObject = Omit; -export type IAlertUpdateObject = Omit; +export type IAlertUpdateObject = AlertRecord; + +export type IAlertCreateObject = Omit; // Filter object for viewing alerts export interface IAlertFilterObject { diff --git a/api/src/openapi/schemas/alert.ts b/api/src/openapi/schemas/alert.ts index 312681feb9..169e697f6f 100644 --- a/api/src/openapi/schemas/alert.ts +++ b/api/src/openapi/schemas/alert.ts @@ -34,6 +34,10 @@ const baseSystemAlertSchema: OpenAPIV3.SchemaObject = { description: 'End date of the alert', type: 'string', nullable: true + }, + create_date: { + description: 'Timestamp for when the record was created', + type: 'string' } } }; @@ -60,7 +64,17 @@ export const systemAlertPutSchema: OpenAPIV3.SchemaObject = { */ export const systemAlertGetSchema: OpenAPIV3.SchemaObject = { ...baseSystemAlertSchema, - required: ['alert_id', 'name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity', 'status'], + required: [ + 'alert_id', + 'name', + 'message', + 'data', + 'alert_type_id', + 'record_end_date', + 'severity', + 'status', + 'create_date' + ], additionalProperties: false, properties: { ...systemAlertPutSchema.properties, diff --git a/api/src/paths/alert/index.test.ts b/api/src/paths/alert/index.test.ts index 82243c1846..0b707bc399 100644 --- a/api/src/paths/alert/index.test.ts +++ b/api/src/paths/alert/index.test.ts @@ -20,6 +20,7 @@ describe('getAlerts', () => { describe('as a system user', () => { it('returns a list of system alerts', async () => { + const mockTotal = 10; const mockAlerts = [ { alert_id: 1, @@ -29,7 +30,8 @@ describe('getAlerts', () => { severity: 'error' as IAlertSeverity, status: 'active' as IAlertStatus, data: null, - record_end_date: null + record_end_date: null, + create_date: '2020-01-01T10:10:10' }, { alert_id: 2, @@ -39,13 +41,18 @@ describe('getAlerts', () => { severity: 'error' as IAlertSeverity, status: 'active' as IAlertStatus, data: null, - record_end_date: null + record_end_date: null, + create_date: '2020-01-01T10:10:10' } ]; + const mockFilters = { types: 'Surveys', expiresBefore: '2020-01-01', expiresAfter: undefined }; + const mockPaginationParams = { page: '1', limit: '10', sort: undefined, order: undefined }; const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub() }); sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - sinon.stub(AlertService.prototype, 'getAlerts').resolves(mockAlerts); + + const getAlertsStub = sinon.stub(AlertService.prototype, 'getAlerts').resolves(mockAlerts); + const getAlertsCountStub = sinon.stub(AlertService.prototype, 'getAlertsCount').resolves(mockTotal); const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); mockReq.system_user = { @@ -63,13 +70,30 @@ describe('getAlerts', () => { agency: null }; + mockReq.query = { + ...mockFilters, + ...mockPaginationParams + }; + 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; + + expect(getAlertsStub).to.have.been.calledOnceWith(mockFilters, { + ...mockPaginationParams, + page: Number(mockPaginationParams.page), + limit: Number(mockPaginationParams.limit) + }); + expect(getAlertsCountStub).to.have.been.calledOnceWith(mockFilters); + + expect(mockRes.jsonValue.pagination).not.to.be.null; + expect(mockRes.jsonValue).to.eql({ + alerts: mockAlerts, + pagination: { total: mockTotal, per_page: 10, current_page: 1, last_page: 1, sort: undefined, order: undefined } + }); }); it('handles errors gracefully', async () => { diff --git a/api/src/paths/alert/index.ts b/api/src/paths/alert/index.ts index df67c3fb3d..9a052eca4b 100644 --- a/api/src/paths/alert/index.ts +++ b/api/src/paths/alert/index.ts @@ -4,9 +4,15 @@ 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 { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../openapi/schemas/pagination'; import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; import { AlertService } from '../../services/alert-service'; import { getLogger } from '../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../utils/pagination'; const defaultLog = getLogger('paths/alert/index'); @@ -59,7 +65,8 @@ GET.apiDoc = { schema: { type: 'string' } - } + }, + ...paginationRequestQueryParamSchema ], responses: { 200: { @@ -72,7 +79,8 @@ GET.apiDoc = { additionalProperties: false, required: ['alerts'], properties: { - alerts: { type: 'array', description: 'Array of system alerts', items: systemAlertGetSchema } + alerts: { type: 'array', description: 'Array of system alerts', items: systemAlertGetSchema }, + pagination: { ...paginationResponseSchema } } } } @@ -112,13 +120,20 @@ export function getAlerts(): RequestHandler { const filterObject = parseQueryParams(req); + const paginationOptions = makePaginationOptionsFromRequest(req); + const alertService = new AlertService(connection); - const alerts = await alertService.getAlerts(filterObject); + const [alerts, alertsTotalCount] = await Promise.all([ + alertService.getAlerts(filterObject, ensureCompletePaginationOptions(paginationOptions)), + alertService.getAlertsCount(filterObject) + ]); await connection.commit(); - return res.status(200).json({ alerts: alerts }); + return res + .status(200) + .json({ alerts: alerts, pagination: makePaginationResponse(alertsTotalCount, paginationOptions) }); } catch (error) { defaultLog.error({ label: 'getAlerts', message: 'error', error }); await connection.rollback(); diff --git a/api/src/paths/alert/{alertId}/index.test.ts b/api/src/paths/alert/{alertId}/index.test.ts index e10b9c1d78..c132fe7944 100644 --- a/api/src/paths/alert/{alertId}/index.test.ts +++ b/api/src/paths/alert/{alertId}/index.test.ts @@ -28,7 +28,8 @@ describe('getAlerts', () => { severity: 'error' as IAlertSeverity, status: 'active' as IAlertStatus, data: null, - record_end_date: null + record_end_date: null, + create_date: '2020-01-01T10:10:10' }; const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); diff --git a/api/src/repositories/alert-repository.ts b/api/src/repositories/alert-repository.ts index 46ca743e86..1019816a92 100644 --- a/api/src/repositories/alert-repository.ts +++ b/api/src/repositories/alert-repository.ts @@ -1,8 +1,15 @@ import { Knex } from 'knex'; import SQL from 'sql-template-strings'; +import { z } from 'zod'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; -import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view'; +import { + AlertRecordWithStatus, + IAlertCreateObject, + IAlertFilterObject, + IAlertUpdateObject +} from '../models/alert-view'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; import { BaseRepository } from './base-repository'; /** @@ -31,6 +38,7 @@ export class AlertRepository extends BaseRepository { 'alert.data', 'alert.severity', 'alert.record_end_date', + 'alert.create_date', knex.raw(` CASE WHEN alert.record_end_date < NOW() THEN 'expired' @@ -38,18 +46,21 @@ export class AlertRepository extends BaseRepository { END AS status `) ) - .from('alert') - .orderBy('alert.create_date', 'DESC'); + .from('alert'); } /** * Get alert records with optional filters applied * * @param {IAlertFilterObject} filterObject - * @return {*} {Promise} + * @param {ApiPaginationOptions} pagination + * @return {*} {Promise} * @memberof AlertRepository */ - async getAlerts(filterObject: IAlertFilterObject): Promise { + async getAlerts( + filterObject: IAlertFilterObject, + pagination?: ApiPaginationOptions + ): Promise { const queryBuilder = this._getAlertBaseQuery(); if (filterObject.expiresAfter) { @@ -70,24 +81,71 @@ export class AlertRepository extends BaseRepository { .whereRaw('lower(at.name) = ANY(?)', [filterObject.types.map((type) => type.toLowerCase())]); } - const response = await this.connection.knex(queryBuilder, IAlert); + if (pagination) { + queryBuilder.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + queryBuilder.orderBy(pagination.sort, pagination.order); + } else { + queryBuilder.orderBy('alert_id', 'desc'); + } + } + + const response = await this.connection.knex(queryBuilder, AlertRecordWithStatus); return response.rows; } + /** + * Gets count of alert records with optional filters applied + * + * @param {IAlertFilterObject} filterObject + * @return {*} {Promise} + * @memberof AlertRepository + */ + async getAlertsCount(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 knex = getKnex(); + + const query = knex.from(queryBuilder.as('qb')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(query, z.object({ count: z.number() })); + + return response.rows[0].count; + } + /** * Get a specific alert by its Id * * @param {number} alertId - * @return {*} {Promise} + * @return {*} {Promise< AlertRecordWithStatus>} * @memberof AlertRepository */ - async getAlertById(alertId: number): Promise { + async getAlertById(alertId: number): Promise { const queryBuilder = this._getAlertBaseQuery(); queryBuilder.where('alert_id', alertId); - const response = await this.connection.knex(queryBuilder, IAlert); + const response = await this.connection.knex(queryBuilder, AlertRecordWithStatus); return response.rows[0]; } diff --git a/api/src/services/alert-service.test.ts b/api/src/services/alert-service.test.ts index 3c99559c01..f99461b357 100644 --- a/api/src/services/alert-service.test.ts +++ b/api/src/services/alert-service.test.ts @@ -2,7 +2,7 @@ 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 { AlertRecordWithStatus, IAlertCreateObject, IAlertFilterObject, IAlertSeverity } from '../models/alert-view'; import { AlertRepository } from '../repositories/alert-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { AlertService } from './alert-service'; @@ -10,23 +10,16 @@ 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[] = [ + const mockDBConnection = getMockDBConnection(); + const alertService = new AlertService(mockDBConnection); + + const mockAlerts: AlertRecordWithStatus[] = [ { alert_id: 1, name: 'Alert 1', @@ -35,24 +28,46 @@ describe('AlertService', () => { data: {}, severity: 'error' as IAlertSeverity, status: 'active', - record_end_date: null + record_end_date: null, + create_date: '2020-01-01T10:10:10' } ]; - mockAlertRepository.getAlerts.resolves(mockAlerts); + const alertStub = sinon.stub(AlertRepository.prototype, 'getAlerts').resolves(mockAlerts); - const filterObject: IAlertFilterObject = {}; // Define your filter object as needed + const filterObject: IAlertFilterObject = {}; const response = await alertService.getAlerts(filterObject); expect(response).to.eql(mockAlerts); - expect(mockAlertRepository.getAlerts).to.have.been.calledOnceWith(filterObject); + expect(alertStub).to.have.been.calledOnceWith(filterObject); + }); + }); + + describe('getAlertsCount', () => { + it('returns total count of alerts', async () => { + const mockDBConnection = getMockDBConnection(); + const alertService = new AlertService(mockDBConnection); + + const mockAlertsCount = 10; + + const getAlertsCountStub = sinon.stub(AlertRepository.prototype, 'getAlertsCount').resolves(mockAlertsCount); + + const filterObject: IAlertFilterObject = {}; + + const response = await alertService.getAlertsCount(filterObject); + + expect(response).to.eql(mockAlertsCount); + expect(getAlertsCountStub).to.have.been.calledOnceWith(filterObject); }); }); describe('getAlertById', () => { it('returns a specific alert by its Id', async () => { - const mockAlert: IAlert = { + const mockDBConnection = getMockDBConnection(); + const alertService = new AlertService(mockDBConnection); + + const mockAlert: AlertRecordWithStatus = { alert_id: 1, name: 'Alert 1', message: 'Message 1', @@ -60,20 +75,24 @@ describe('AlertService', () => { data: {}, severity: 'error' as IAlertSeverity, status: 'active', - record_end_date: null + record_end_date: null, + create_date: '2020-01-01T10:10:10' }; - mockAlertRepository.getAlertById.resolves(mockAlert); + const getAlertByIdStub = sinon.stub(AlertRepository.prototype, 'getAlertById').resolves(mockAlert); const response = await alertService.getAlertById(1); expect(response).to.eql(mockAlert); - expect(mockAlertRepository.getAlertById).to.have.been.calledOnceWith(1); + expect(getAlertByIdStub).to.have.been.calledOnceWith(1); }); }); describe('createAlert', () => { it('creates an alert and returns its Id', async () => { + const mockDBConnection = getMockDBConnection(); + const alertService = new AlertService(mockDBConnection); + const mockAlertId = 1; const mockAlert: IAlertCreateObject = { name: 'New Alert', @@ -84,19 +103,22 @@ describe('AlertService', () => { record_end_date: null }; - mockAlertRepository.createAlert.resolves(mockAlertId); + const createAlertStub = sinon.stub(AlertRepository.prototype, 'createAlert').resolves(mockAlertId); const response = await alertService.createAlert(mockAlert); expect(response).to.equal(mockAlertId); - expect(mockAlertRepository.createAlert).to.have.been.calledOnceWith(mockAlert); + expect(createAlertStub).to.have.been.calledOnceWith(mockAlert); }); }); describe('updateAlert', () => { it('updates an alert and returns its Id', async () => { + const mockDBConnection = getMockDBConnection(); + const alertService = new AlertService(mockDBConnection); + const mockAlertId = 1; - const mockAlert: IAlert = { + const mockAlert: AlertRecordWithStatus = { alert_id: mockAlertId, name: 'Updated Alert', message: 'Updated message', @@ -104,27 +126,32 @@ describe('AlertService', () => { data: {}, severity: 'error' as IAlertSeverity, status: 'active', - record_end_date: null + record_end_date: null, + create_date: '2020-01-01T10:10:10' }; - mockAlertRepository.updateAlert.resolves(mockAlertId); + const updateAlertStub = sinon.stub(AlertRepository.prototype, 'updateAlert').resolves(mockAlertId); const response = await alertService.updateAlert(mockAlert); expect(response).to.equal(mockAlertId); - expect(mockAlertRepository.updateAlert).to.have.been.calledOnceWith(mockAlert); + expect(updateAlertStub).to.have.been.calledOnceWith(mockAlert); }); }); describe('deleteAlert', () => { it('deletes an alert and returns its Id', async () => { + const mockDBConnection = getMockDBConnection(); + const alertService = new AlertService(mockDBConnection); + const mockAlertId = 1; - mockAlertRepository.deleteAlert.resolves(mockAlertId); + + const alertStub = sinon.stub(AlertRepository.prototype, 'deleteAlert').resolves(mockAlertId); const response = await alertService.deleteAlert(mockAlertId); expect(response).to.equal(mockAlertId); - expect(mockAlertRepository.deleteAlert).to.have.been.calledOnceWith(mockAlertId); + expect(alertStub).to.have.been.calledOnceWith(mockAlertId); }); }); }); diff --git a/api/src/services/alert-service.ts b/api/src/services/alert-service.ts index 7c8174356a..7c8e2f9ce2 100644 --- a/api/src/services/alert-service.ts +++ b/api/src/services/alert-service.ts @@ -1,6 +1,12 @@ import { IDBConnection } from '../database/db'; -import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view'; +import { + AlertRecordWithStatus, + IAlertCreateObject, + IAlertFilterObject, + IAlertUpdateObject +} from '../models/alert-view'; import { AlertRepository } from '../repositories/alert-repository'; +import { ApiPaginationOptions } from '../zod-schema/pagination'; import { DBService } from './db-service'; export class AlertService extends DBService { @@ -16,21 +22,36 @@ export class AlertService extends DBService { * Get all alert records, including deactivated alerts * * @param {IAlertFilterObject} filterObject - * @return {*} Promise + * @param {ApiPaginationOptions} pagination + * @return {*} Promise * @memberof AlertService */ - async getAlerts(filterObject: IAlertFilterObject): Promise { - return this.alertRepository.getAlerts(filterObject); + async getAlerts( + filterObject: IAlertFilterObject, + pagination?: ApiPaginationOptions + ): Promise { + return this.alertRepository.getAlerts(filterObject, pagination); + } + + /** + * Get count of alert records, including deactivated alerts + * + * @param {IAlertFilterObject} filterObject + * @return {*} Promise + * @memberof AlertService + */ + async getAlertsCount(filterObject: IAlertFilterObject): Promise { + return this.alertRepository.getAlertsCount(filterObject); } /** * Get a specific alert by its ID * * @param {number} alertId - * @return {*} Promise + * @return {*} Promise * @memberof AlertService */ - async getAlertById(alertId: number): Promise { + async getAlertById(alertId: number): Promise { return this.alertRepository.getAlertById(alertId); } diff --git a/app/src/features/admin/alert/AlertContainer.tsx b/app/src/features/admin/alert/AlertContainer.tsx index 0da1f0bbe5..3817a0a746 100644 --- a/app/src/features/admin/alert/AlertContainer.tsx +++ b/app/src/features/admin/alert/AlertContainer.tsx @@ -6,12 +6,15 @@ import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; 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 { useEffect, useMemo, useState } from 'react'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; import CreateAlert from './create/CreateAlert'; import DeleteAlert from './delete/DeleteAlert'; import EditAlert from './edit/EditAlert'; @@ -22,8 +25,18 @@ enum AlertViewEnum { EXPIRED = 'EXPIRED' } +// Default pagination parameters +const initialPaginationParams: Required = { + page: 0, + limit: 5, + sort: 'alert_id', + order: 'desc' +}; + /** * Container for displaying a list of alerts created by system administrators + * + * @returns {*} */ const AlertListContainer = () => { const biohubApi = useBiohubApi(); @@ -34,41 +47,54 @@ const AlertListContainer = () => { delete: false }); const [alertId, setAlertId] = useState(null); + const [paginationModel, setPaginationModel] = useState({ + pageSize: initialPaginationParams.limit, + page: initialPaginationParams.page + }); + const [sortModel, setSortModel] = useState([ + { + field: initialPaginationParams.sort, + sort: initialPaginationParams.order + } + ]); + + const paginationSort: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sortModel); + return { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: paginationModel.page + 1 // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + }; + }, [paginationModel, sortModel]); 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 alertDataLoader = useDataLoader((filters: IAlertFilterParams, pagination: ApiPaginationRequestOptions) => { + return biohubApi.alert.getAlerts(filters, pagination); + }); const closeModal = () => { - alertDataLoader.refresh(filters); + alertDataLoader.refresh(filters, paginationSort); setModalState({ create: false, edit: false, delete: false }); setAlertId(null); }; useEffect(() => { - alertDataLoader.refresh(filters); + alertDataLoader.refresh(filters, paginationSort); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeView]); + }, [paginationSort]); return ( - Alerts  + Alerts - - {props.children} - {props.errors.length > 0 ? : null} + + + {props.title} + - + + {props.summary} + + {props.children} + {props.errors.length > 0 ? : null} + ); }; diff --git a/app/src/components/csv/CSVErrorsCardStack.tsx b/app/src/components/csv/CSVErrorsCardStack.tsx new file mode 100644 index 0000000000..ead1e77893 --- /dev/null +++ b/app/src/components/csv/CSVErrorsCardStack.tsx @@ -0,0 +1,109 @@ +import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; +import Icon from '@mdi/react'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; +import { useMemo, useState } from 'react'; +import { CSVError } from 'utils/csv-utils'; +import { v4 } from 'uuid'; + +const MAX_ERRORS_SHOWN = 10; + +interface CSVErrorsCardStackProps { + errors: CSVError[]; +} + +/** + * Returns a stack of CSV errors with information about solutions and pagination + * + * @param {CSVErrorsCardStackProps} props + * @returns {*} + */ +export const CSVErrorsCardStack = (props: CSVErrorsCardStackProps) => { + const [currentPage, setCurrentPage] = useState(0); + + const pageCount = Math.ceil(props.errors.length / MAX_ERRORS_SHOWN); + + const rows: (CSVError & { id: string })[] = useMemo(() => { + return props.errors.slice(currentPage * MAX_ERRORS_SHOWN, (currentPage + 1) * MAX_ERRORS_SHOWN).map((error) => { + return { + id: v4(), + ...error + }; + }); + }, [props.errors, currentPage]); + + const handleNextPage = () => { + if ((currentPage + 1) * MAX_ERRORS_SHOWN < props.errors.length) { + setCurrentPage(currentPage + 1); + } + }; + + const handlePreviousPage = () => { + if (currentPage > 0) { + setCurrentPage(currentPage - 1); + } + }; + + return ( + + {rows.map((error) => { + return ( + + {error.solution} + + + + Row + + {error.row ?? 'N/A'} + + + + Column + + {error.header ?? 'N/A'} + + + + Value + + {error.cell ?? 'N/A'} + + {(error.cell || error.header) && error.values && ( + + + Allowed Values + + {error.values.join(', ')} + + )} + + + } + /> + ); + })} + {props.errors.length > MAX_ERRORS_SHOWN && ( + + + + + + Page {currentPage + 1} of {pageCount} + + + + + + )} + + ); +}; diff --git a/app/src/components/csv/CSVErrorsTableContainer.tsx b/app/src/components/csv/CSVErrorsCardStackContainer.tsx similarity index 52% rename from app/src/components/csv/CSVErrorsTableContainer.tsx rename to app/src/components/csv/CSVErrorsCardStackContainer.tsx index 954b3b8d74..d9c9b4d3ac 100644 --- a/app/src/components/csv/CSVErrorsTableContainer.tsx +++ b/app/src/components/csv/CSVErrorsCardStackContainer.tsx @@ -1,10 +1,10 @@ -import { Divider, Paper, Toolbar, Typography } from '@mui/material'; +import { Toolbar, Typography } from '@mui/material'; import { Box, Stack } from '@mui/system'; import { ReactElement } from 'react'; import { CSVError } from 'utils/csv-utils'; -import { CSVErrorsTable } from './CSVErrorsTable'; +import { CSVErrorsCardStack } from './CSVErrorsCardStack'; -interface CSVErrorsTableContainerProps { +interface CSVErrorsCardStackContainerProps { errors: CSVError[]; title?: ReactElement; } @@ -12,36 +12,29 @@ interface CSVErrorsTableContainerProps { /** * Renders a CSV errors table with toolbar. * - * @param {CSVErrorsTableContainerProps} props + * @param {CSVErrorsCardStackContainerProps} props * @returns {*} {JSX.Element} */ -export const CSVErrorsTableContainer = (props: CSVErrorsTableContainerProps) => { +export const CSVErrorsCardStackContainer = (props: CSVErrorsCardStackContainerProps) => { return ( - - + + {props.title ?? ( - CSV Errors Detected ‌ + Errors ‌ ({props.errors.length}) )} - - + - + ); }; diff --git a/app/src/components/csv/CSVErrorsTable.tsx b/app/src/components/csv/CSVErrorsTable.tsx deleted file mode 100644 index 9ff5cff794..0000000000 --- a/app/src/components/csv/CSVErrorsTable.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { GridColDef } from '@mui/x-data-grid'; -import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { useMemo } from 'react'; -import { CSVError } from 'utils/csv-utils'; -import { v4 } from 'uuid'; -import { CSVErrorsTableOptionsMenu } from './CSVErrorsTableOptionsMenu'; - -interface CSVErrorsTableProps { - errors: CSVError[]; -} - -/** - * Renders a CSV errors table. - * - * @param {CSVErrorsTableProps} props - * @returns {*} {JSX.Element} - */ -export const CSVErrorsTable = (props: CSVErrorsTableProps) => { - const columns: GridColDef[] = [ - { - field: 'row', - headerName: 'Row', - description: 'Row number in the CSV file', - minWidth: 85 - }, - { - field: 'header', - headerName: 'Header', - description: 'Column header in the CSV file', - minWidth: 150, - maxWidth: 250, - renderCell: (params) => { - return params.value?.toUpperCase(); - } - }, - { - field: 'cell', - headerName: 'Cell', - description: 'The cell value in the CSV file', - minWidth: 85 - }, - { - field: 'error', - headerName: 'Error', - description: 'The error message', - flex: 1, - minWidth: 250, - resizable: true - }, - { - field: 'solution', - headerName: 'Solution', - description: 'The solution to the error', - flex: 1, - minWidth: 250, - resizable: true - }, - { - field: 'values', - headerName: 'Options', - description: 'The applicable cell values', - minWidth: 85, - renderCell: (params) => { - return params.value?.length ? : 'N/A'; - } - } - ]; - - const rows = useMemo(() => { - return props.errors.map((error) => { - return { - id: v4(), - ...error - }; - }); - }, [props.errors]); - - return ( - 'auto'} - rows={rows} - getRowId={(row) => row.id} - columns={columns} - pageSizeOptions={[5, 10, 25, 50]} - rowSelection={false} - checkboxSelection={false} - sortingOrder={['asc', 'desc']} - initialState={{ - pagination: { - paginationModel: { - pageSize: 5 - } - } - }} - /> - ); -}; diff --git a/app/src/components/csv/CSVErrorsTableOptionsMenu.tsx b/app/src/components/csv/CSVErrorsTableOptionsMenu.tsx deleted file mode 100644 index 78f18a03a4..0000000000 --- a/app/src/components/csv/CSVErrorsTableOptionsMenu.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { mdiChevronDown } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Button, Menu, MenuItem } from '@mui/material'; -import { useState } from 'react'; - -interface CSVErrorsTableOptionsMenuProps { - options: string[]; -} - -/** - * Renders a CSV errors table options menu. - * - * @param {CSVErrorsTableOptionsMenuProps} props - * @returns {*} {JSX.Element} - */ -export const CSVErrorsTableOptionsMenu = (props: CSVErrorsTableOptionsMenuProps) => { - const [anchorEl, setAnchorEl] = useState(null); - - return ( - <> - - setAnchorEl(null)}> - {props.options.map((value) => ( - {value} - ))} - - - ); -}; diff --git a/app/src/components/csv/CSVSingleImportDialog.tsx b/app/src/components/csv/CSVSingleImportDialog.tsx index 9c149db552..0891be1aa5 100644 --- a/app/src/components/csv/CSVSingleImportDialog.tsx +++ b/app/src/components/csv/CSVSingleImportDialog.tsx @@ -1,5 +1,5 @@ import LoadingButton from '@mui/lab/LoadingButton/LoadingButton'; -import { Box, Dialog, DialogActions, DialogContent, Divider, Typography, useMediaQuery, useTheme } from '@mui/material'; +import { Box, Dialog, DialogActions, DialogContent, Divider, useMediaQuery, useTheme } from '@mui/material'; import { AxiosProgressEvent } from 'axios'; import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; import { FileUploadSingleItem } from 'components/file-upload/FileUploadSingleItem'; @@ -90,24 +90,27 @@ export const CSVSingleImportDialog = (props: CSVSingleImportDialogProps) => { // Wait for the complete status to be rendered + 500ms before closing the dialog await waitForRenderCycle(500); + // Show a success snackbar message + dialogContext.setSnackbar({ + open: true, + snackbarAutoCloseMs: 2000, + snackbarMessage: 'Successfully imported telemetry' + }); + handleClose(); } catch (err) { if (err instanceof Error) { setError(err); } - setUploadStatus(UploadFileStatus.FAILED); - } finally { - // Show a success snackbar message + // Show a failure snackbar message dialogContext.setSnackbar({ open: true, snackbarAutoCloseMs: 2000, - snackbarMessage: ( - - {uploadStatus === UploadFileStatus.FAILED ? 'CSV failed to import' : 'CSV imported'} - - ) + snackbarMessage: 'Failed to import telemetry' }); + + setUploadStatus(UploadFileStatus.FAILED); } }; @@ -116,7 +119,7 @@ export const CSVSingleImportDialog = (props: CSVSingleImportDialogProps) => { } return ( - + { <> setShowImportDialog(false)} onImport={handleImportTelemetryCSV} onDownloadTemplate={() => From 00711d62c5c541815c1e5faa40c5ca8c2c170bfc Mon Sep 17 00:00:00 2001 From: AMEIJER1 <141779443+AMEIJER1@users.noreply.github.com> Date: Wed, 15 Jan 2025 15:41:14 -0800 Subject: [PATCH 3/4] BugFix: Fix Type for Capture Attachments (#1460) - modifying critter index file --------- Co-authored-by: Macgregor Aubertin-Young Co-authored-by: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> --- .../survey/{surveyId}/critters/{critterId}/index.test.ts | 2 +- .../survey/{surveyId}/critters/{critterId}/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.test.ts index ebb9cd08c2..587176e344 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.test.ts @@ -162,7 +162,7 @@ describe('getSurveyCritter', () => { expect(mockRes.status).to.have.been.calledWith(200); expect(mockRes.json).to.have.been.calledWith({ - attachments: mockAttachments, + attachments: { capture_attachments: mockAttachments.captureAttachments }, ...mockCritterbaseCritter, ...mockSimsCritter }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts index 21037369a6..955bf47e53 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts @@ -231,7 +231,7 @@ GET.apiDoc = { type: 'object', description: 'Attachments associated with the critter. Only included if requested via the expand query parameter.', - required: ['captureAttachments'], + required: ['capture_attachments'], properties: { capture_attachments: { type: 'array', @@ -310,7 +310,7 @@ export function getSurveyCritter(): RequestHandler { ? critterAttachmentService.findAllCritterAttachments(surveyCritter.critter_id).then((response) => { return { attachments: { - captureAttachments: response.captureAttachments + capture_attachments: response.captureAttachments // TODO: add mortality attachments } }; From 7344aa4d4e68caf3cc7b13648da903e53b47ac0a Mon Sep 17 00:00:00 2001 From: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:29:06 -0800 Subject: [PATCH 4/4] BugFix: Method Attributes SQL (#1462) - fix sql --- api/src/repositories/standards-repository.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/src/repositories/standards-repository.ts b/api/src/repositories/standards-repository.ts index e289484ab8..bf49be21f8 100644 --- a/api/src/repositories/standards-repository.ts +++ b/api/src/repositories/standards-repository.ts @@ -88,9 +88,9 @@ export class StandardsRepository extends BaseRepository { taq.description AS qual_description, COALESCE(json_agg( json_build_object( - 'name', mlaqo.name, - 'description', mlaqo.description - ) ORDER BY mlaqo.name + 'name', taqo.name, + 'description', taqo.description + ) ORDER BY taqo.name ), '[]'::json) AS options FROM method_lookup_attribute_qualitative_option mlaqo @@ -98,6 +98,8 @@ export class StandardsRepository extends BaseRepository { method_lookup_attribute_qualitative mlaq ON mlaqo.method_lookup_attribute_qualitative_id = mlaq.method_lookup_attribute_qualitative_id LEFT JOIN technique_attribute_qualitative taq ON mlaq.technique_attribute_qualitative_id = taq.technique_attribute_qualitative_id + LEFT JOIN + technique_attribute_qualitative_option taqo ON taqo.technique_attribute_qualitative_option_id = mlaqo.technique_attribute_qualitative_option_id GROUP BY mlaq.method_lookup_id, taq.name,