Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: GEO-1120 - admin dashboard num employers widget #780

Merged
merged 7 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions admin-frontend/src/components/DashboardPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
></NumSubmissionsInYear>
</v-col>
<v-col>
<NumEmployerLogins></NumEmployerLogins>
<NumEmployerLogons ref="numEmployerLogons"></NumEmployerLogons>
</v-col>
</v-row>
</v-col>
Expand Down Expand Up @@ -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';
Expand All @@ -92,6 +92,7 @@ const recentlySubmittedReports = ref<typeof RecentlySubmittedReports>();
const recentlyViewedReports = ref<typeof RecentlyViewedReports>();
const numSubmissionsInYear = ref<typeof NumSubmissionsInYear>();
const publicAnnouncements = ref<typeof PublicAnnouncements>();
const numEmployerLogons = ref<typeof NumEmployerLogons>();

onMounted(() => {
//Periodically refresh the widgets
Expand All @@ -105,5 +106,6 @@ async function refresh() {
await recentlyViewedReports.value?.refresh();
await numSubmissionsInYear.value?.refresh();
await publicAnnouncements.value?.refresh();
await numEmployerLogons.value?.refresh();
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down
24 changes: 0 additions & 24 deletions admin-frontend/src/components/dashboard/NumEmployerLogins.vue

This file was deleted.

75 changes: 75 additions & 0 deletions admin-frontend/src/components/dashboard/NumEmployerLogons.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<template>
<v-card class="ptap-widget">
<v-card-text class="h-100 d-flex flex-column">
<div class="widget-header flex-grow-0 flex-shrink-0">
Total number of employers who have logged on to date
</div>
<div
class="d-flex flex-column justify-center align-center text-primary flex-grow-1 flex-shrink-0"
>
<v-skeleton-loader v-if="isLoading" type="avatar"></v-skeleton-loader>
<div v-if="!isLoading">
<span v-if="hasError">
<v-tooltip text="Unable to load the data">
<template #activator="{ props }">
<v-icon
icon="mdi-alert"
size="x-large"
color="grey"
v-bind="props"
@click="refresh"
></v-icon>
</template>
</v-tooltip>
</span>
<span v-if="!hasError" class="widget-value">{{
numEmployersWhoHaveLoggedOn
}}</span>
</div>
</div>
</v-card-text>
</v-card>
</template>

<script setup lang="ts">
import ApiService from '../../services/apiService';
import { ref, onMounted } from 'vue';
import { EmployerMetrics } from '../../types/employers';

onMounted(() => {
refresh();
});

const numEmployersWhoHaveLoggedOn = ref<number | null>();
const hasError = ref<boolean>(false);
const isLoading = ref<boolean>(false);

async function refresh() {
hasError.value = false;
isLoading.value = true;
try {
const employerMetrics: EmployerMetrics =
await ApiService.getEmployerMetrics();
numEmployersWhoHaveLoggedOn.value =
employerMetrics?.num_employers_logged_on_to_date;
} catch (e) {
hasError.value = true;
} finally {
isLoading.value = false;
}
}

defineExpose({
refresh,
});
</script>

<style lang="scss">
.widget-header {
font-size: 1.2em;
}
.widget-value {
font-size: 6em;
font-weight: bold;
}
</style>
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
25 changes: 24 additions & 1 deletion admin-frontend/src/services/__tests__/apiService.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
};
Expand Down
14 changes: 14 additions & 0 deletions admin-frontend/src/services/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -276,6 +277,19 @@ export default {
}
},

async getEmployerMetrics(): Promise<EmployerMetrics> {
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,
Expand Down
3 changes: 3 additions & 0 deletions admin-frontend/src/types/employers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type EmployerMetrics = {
num_employers_logged_on_to_date: number;
};
1 change: 1 addition & 0 deletions admin-frontend/src/utils/constant.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
51 changes: 51 additions & 0 deletions backend/src/v1/routes/dashboard/employer-metrics-routes.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
});
});
22 changes: 22 additions & 0 deletions backend/src/v1/routes/dashboard/employer-metrics-routes.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions backend/src/v1/routes/dashboard/index.ts
Original file line number Diff line number Diff line change
@@ -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;
30 changes: 30 additions & 0 deletions backend/src/v1/services/employer-service.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
});
Loading