diff --git a/admin-frontend/src/components/DashboardPage.vue b/admin-frontend/src/components/DashboardPage.vue index a76ebfb2..5d660aee 100644 --- a/admin-frontend/src/components/DashboardPage.vue +++ b/admin-frontend/src/components/DashboardPage.vue @@ -32,7 +32,7 @@ > - + @@ -79,7 +79,7 @@ import RecentlySubmittedReports from './dashboard/RecentlySubmittedReports.vue'; import RecentlyViewedReports from './dashboard/RecentlyViewedReports.vue'; import NumSubmissionsInYear from './dashboard/NumSubmissionsInYear.vue'; -import NumEmployerLogins from './dashboard/NumEmployerLogins.vue'; +import NumEmployerLogons from './dashboard/NumEmployerLogons.vue'; import PublicAnnouncements from './dashboard/PublicAnnouncements.vue'; import ToolTip from './ToolTip.vue'; import { ref, onMounted } from 'vue'; @@ -92,6 +92,7 @@ const recentlySubmittedReports = ref(); const recentlyViewedReports = ref(); const numSubmissionsInYear = ref(); const publicAnnouncements = ref(); +const numEmployerLogons = ref(); onMounted(() => { //Periodically refresh the widgets @@ -105,5 +106,6 @@ async function refresh() { await recentlyViewedReports.value?.refresh(); await numSubmissionsInYear.value?.refresh(); await publicAnnouncements.value?.refresh(); + await numEmployerLogons.value?.refresh(); } diff --git a/admin-frontend/src/components/__tests__/DashboardPage.spec.ts b/admin-frontend/src/components/__tests__/DashboardPage.spec.ts index 6b80cc75..1c2ef8f6 100644 --- a/admin-frontend/src/components/__tests__/DashboardPage.spec.ts +++ b/admin-frontend/src/components/__tests__/DashboardPage.spec.ts @@ -5,7 +5,7 @@ import { createVuetify } from 'vuetify'; import * as components from 'vuetify/components'; import * as directives from 'vuetify/directives'; import DashboardPage from '../DashboardPage.vue'; -import NumEmployerLogins from '../dashboard/NumEmployerLogins.vue'; +import NumEmployerLogins from '../dashboard/NumEmployerLogons.vue'; // Mock the ResizeObserver const ResizeObserverMock = vi.fn(() => ({ diff --git a/admin-frontend/src/components/dashboard/NumEmployerLogins.vue b/admin-frontend/src/components/dashboard/NumEmployerLogins.vue deleted file mode 100644 index fbaf3795..00000000 --- a/admin-frontend/src/components/dashboard/NumEmployerLogins.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - - - Total number of employers who have logged on to date - - - 0 - - - - - - diff --git a/admin-frontend/src/components/dashboard/NumEmployerLogons.vue b/admin-frontend/src/components/dashboard/NumEmployerLogons.vue new file mode 100644 index 00000000..3adfeea0 --- /dev/null +++ b/admin-frontend/src/components/dashboard/NumEmployerLogons.vue @@ -0,0 +1,75 @@ + + + + + Total number of employers who have logged on to date + + + + + + + + + + + + {{ + numEmployersWhoHaveLoggedOn + }} + + + + + + + + + diff --git a/admin-frontend/src/components/dashboard/__tests__/NumEmployerLogons.spec.ts b/admin-frontend/src/components/dashboard/__tests__/NumEmployerLogons.spec.ts new file mode 100644 index 00000000..ab32ced6 --- /dev/null +++ b/admin-frontend/src/components/dashboard/__tests__/NumEmployerLogons.spec.ts @@ -0,0 +1,55 @@ +import { createTestingPinia } from '@pinia/testing'; +import { render, screen, waitFor } from '@testing-library/vue'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createVuetify } from 'vuetify'; +import * as components from 'vuetify/components'; +import * as directives from 'vuetify/directives'; +import { EmployerMetrics } from '../../../types/employers'; +import NumEmployerLogons from '../NumEmployerLogons.vue'; + +global.ResizeObserver = require('resize-observer-polyfill'); +const pinia = createTestingPinia(); +const vuetify = createVuetify({ components, directives }); + +const mockEmployerMetrics: EmployerMetrics = { + num_employers_logged_on_to_date: 6, +}; +const mockGetEmployerMetrics = vi.fn().mockResolvedValue(mockEmployerMetrics); + +vi.mock('../../../services/apiService', () => ({ + default: { + getEmployerMetrics: (...args) => { + return mockGetEmployerMetrics(...args); + }, + }, +})); + +const wrappedRender = () => { + return render(NumEmployerLogons, { + global: { + plugins: [pinia, vuetify], + }, + }); +}; + +describe('NumEmployerLogons', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('displays the number of employers who have logged on to date', async () => { + await wrappedRender(); + expect(mockGetEmployerMetrics).toHaveBeenCalled(); + + await waitFor(() => { + expect( + screen.getByText( + `${mockEmployerMetrics.num_employers_logged_on_to_date}`, + { + exact: true, + }, + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/admin-frontend/src/services/__tests__/apiService.spec.ts b/admin-frontend/src/services/__tests__/apiService.spec.ts index 5f4a0aca..0329effa 100644 --- a/admin-frontend/src/services/__tests__/apiService.spec.ts +++ b/admin-frontend/src/services/__tests__/apiService.spec.ts @@ -1,6 +1,7 @@ import { AxiosError } from 'axios'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AnnouncementStatus } from '../../types/announcements'; +import { EmployerMetrics } from '../../types/employers'; import { ReportMetrics } from '../../types/reports'; import ApiService from '../apiService'; @@ -652,9 +653,31 @@ describe('ApiService', () => { }); }); + describe('getEmployerMetrics', () => { + describe('when the data are successfully retrieved from the backend', () => { + it('returns an object with the expected metrics', async () => { + const mockBackendResponse = { + num_employers_logged_on_to_date: 8, + }; + const mockAxiosResponse = { + data: mockBackendResponse, + }; + vi.spyOn(ApiService.apiAxios, 'get').mockResolvedValueOnce( + mockAxiosResponse, + ); + + const resp: EmployerMetrics = await ApiService.getEmployerMetrics(); + expect(resp).toEqual(mockBackendResponse); + }); + }); + }); + describe('getAnnouncementsMetrics', () => { it('returns an announcement metrics', async () => { - const mockBackendResponse = { draft: { count: 1 }, published: { count: 2 } }; + const mockBackendResponse = { + draft: { count: 1 }, + published: { count: 2 }, + }; const mockAxiosResponse = { data: mockBackendResponse, }; diff --git a/admin-frontend/src/services/apiService.ts b/admin-frontend/src/services/apiService.ts index 69273874..005313b8 100644 --- a/admin-frontend/src/services/apiService.ts +++ b/admin-frontend/src/services/apiService.ts @@ -13,6 +13,7 @@ import { AnnouncementSortType, IAnnouncementSearchResult, } from '../types/announcements'; +import { EmployerMetrics } from '../types/employers'; import { IReportSearchResult, ReportMetrics } from '../types/reports'; import { ApiRoutes, POWERBI_RESOURCE } from '../utils/constant'; import AuthService from './authService'; @@ -276,6 +277,19 @@ export default { } }, + async getEmployerMetrics(): Promise { + try { + const resp = await apiAxios.get(ApiRoutes.EMPLOYER_METRICS); + if (resp?.data) { + return resp.data; + } + throw new Error('Unable to fetch employer metrics'); + } catch (e) { + console.log(`Failed to get employer metrics from API - ${e}`); + throw e; + } + }, + async getAnnouncements( offset: number = 0, limit: number = 20, diff --git a/admin-frontend/src/types/employers.ts b/admin-frontend/src/types/employers.ts new file mode 100644 index 00000000..bdb54b2b --- /dev/null +++ b/admin-frontend/src/types/employers.ts @@ -0,0 +1,3 @@ +export type EmployerMetrics = { + num_employers_logged_on_to_date: number; +}; diff --git a/admin-frontend/src/utils/constant.js b/admin-frontend/src/utils/constant.js index bd7f1db3..3cf53b60 100644 --- a/admin-frontend/src/utils/constant.js +++ b/admin-frontend/src/utils/constant.js @@ -32,6 +32,7 @@ export const ApiRoutes = Object.freeze({ CLAMAV_SCAN: `${clamavBaseRoot}/`, RESOURCES: `${baseRoot}/v1/resources`, REPORT_METRICS: `${baseRoot}/v1/dashboard/reports-metrics`, + EMPLOYER_METRICS: `${baseRoot}/v1/dashboard/employer-metrics`, }); export const PAGE_TITLES = Object.freeze({ diff --git a/backend/src/v1/routes/dashboard/employer-metrics-routes.spec.ts b/backend/src/v1/routes/dashboard/employer-metrics-routes.spec.ts new file mode 100644 index 00000000..4580af48 --- /dev/null +++ b/backend/src/v1/routes/dashboard/employer-metrics-routes.spec.ts @@ -0,0 +1,51 @@ +import express, { Application } from 'express'; +import request from 'supertest'; +import router from './employer-metrics-routes'; + +let app: Application; +const getEmployerMetricsMock = jest.fn(); +jest.mock('../../services/employer-service', () => ({ + employerService: { + getEmployerMetrics: (...args) => getEmployerMetricsMock(...args), + }, +})); +describe('employer-metrics-routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use('/dashboard', router); + }); + + describe('GET /employer-metrics', () => { + describe('200', () => { + it('should return the announcement metrics', async () => { + // Arrange + getEmployerMetricsMock.mockResolvedValueOnce({}); + + // Act + const response = await request(app).get('/dashboard/employer-metrics'); + + // Assert + expect(getEmployerMetricsMock).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + }); + }); + describe('500', () => { + it('should return 500 if an error occurs', async () => { + // Arrange + getEmployerMetricsMock.mockRejectedValueOnce( + new Error('An error occurred'), + ); + + // Act + const response = await request(app).get('/dashboard/employer-metrics'); + + // Assert + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: 'An error occurred while fetching the employer metrics', + }); + }); + }); + }); +}); diff --git a/backend/src/v1/routes/dashboard/employer-metrics-routes.ts b/backend/src/v1/routes/dashboard/employer-metrics-routes.ts new file mode 100644 index 00000000..3aa40902 --- /dev/null +++ b/backend/src/v1/routes/dashboard/employer-metrics-routes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { logger } from '../../../logger'; +import { employerService } from '../../services/employer-service'; + +const router = Router(); + +/** + * Get employer metrics + */ +router.get('/employer-metrics', async (req, res) => { + try { + const metrics = await employerService.getEmployerMetrics(); + res.json(metrics); + } catch (error) { + logger.error(error); + res.status(500).send({ + error: 'An error occurred while fetching the employer metrics', + }); + } +}); + +export default router; diff --git a/backend/src/v1/routes/dashboard/index.ts b/backend/src/v1/routes/dashboard/index.ts index 12bced86..82caec89 100644 --- a/backend/src/v1/routes/dashboard/index.ts +++ b/backend/src/v1/routes/dashboard/index.ts @@ -1,10 +1,12 @@ import { Router } from 'express'; import announcementMetricsRouter from './announcement-metrics-routes'; +import employerMetricsRouter from './employer-metrics-routes'; import reportsMetricsRouter from './report-metrics-routes'; const router = Router(); router.use(announcementMetricsRouter); router.use(reportsMetricsRouter); +router.use(employerMetricsRouter); export default router; diff --git a/backend/src/v1/services/employer-service.spec.ts b/backend/src/v1/services/employer-service.spec.ts new file mode 100644 index 00000000..6b5e4269 --- /dev/null +++ b/backend/src/v1/services/employer-service.spec.ts @@ -0,0 +1,30 @@ +import { employerService } from '../services/employer-service'; +import { EmployerMetrics } from '../types/employers'; + +const mockCountPayTransparencyCompanies = jest.fn(); + +jest.mock('../prisma/prisma-client-readonly-replica', () => ({ + __esModule: true, + ...jest.requireActual('../prisma/prisma-client-readonly-replica'), + default: { + pay_transparency_company: { + count: () => mockCountPayTransparencyCompanies(), + }, + }, +})); + +describe('employer-service', () => { + describe('getEmployerMetrics', () => { + it('delegates request to the database', async () => { + const numCompaniesLoggedOnToDate = 16; + mockCountPayTransparencyCompanies.mockResolvedValue( + numCompaniesLoggedOnToDate, + ); + const employerMetrics: EmployerMetrics = + await employerService.getEmployerMetrics(); + expect(employerMetrics.num_employers_logged_on_to_date).toBe( + numCompaniesLoggedOnToDate, + ); + }); + }); +}); diff --git a/backend/src/v1/services/employer-service.ts b/backend/src/v1/services/employer-service.ts new file mode 100644 index 00000000..e3c116d9 --- /dev/null +++ b/backend/src/v1/services/employer-service.ts @@ -0,0 +1,16 @@ +import prismaReadOnlyReplica from '../prisma/prisma-client-readonly-replica'; +import { EmployerMetrics } from '../types/employers'; + +export const employerService = { + /** + * Get employer metrics + * @returns EmployerMetrics + */ + async getEmployerMetrics(): Promise { + const numEmployers = + await prismaReadOnlyReplica.pay_transparency_company.count(); + return { + num_employers_logged_on_to_date: numEmployers, + }; + }, +}; diff --git a/backend/src/v1/types/employers.ts b/backend/src/v1/types/employers.ts new file mode 100644 index 00000000..bdb54b2b --- /dev/null +++ b/backend/src/v1/types/employers.ts @@ -0,0 +1,3 @@ +export type EmployerMetrics = { + num_employers_logged_on_to_date: number; +};