Skip to content

Commit

Permalink
Merge pull request #1274 from openedx/ammar/integrate-skills-charts
Browse files Browse the repository at this point in the history
feat: integrate skills charts
  • Loading branch information
muhammad-ammar authored Aug 28, 2024
2 parents 7388dfc + 95b149b commit dc806a9
Show file tree
Hide file tree
Showing 13 changed files with 434 additions and 57 deletions.
20 changes: 20 additions & 0 deletions src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
Spinner,
} from '@openedx/paragon';
import PropTypes from 'prop-types';
import EmptyChart from './charts/EmptyChart';

const ProgressOverlay = ({ isError, message }) => (
<div className="position-relative" style={{ minHeight: '50vh' }}>
<div className="position-absolute w-100 h-100 d-flex align-items-center justify-content-center bg-transparent">
{isError ? <EmptyChart /> : <Spinner animation="border" variant="primary" screenReaderText={message} />}
</div>
</div>
);

ProgressOverlay.propTypes = {
isError: PropTypes.bool.isRequired,
message: PropTypes.string.isRequired,
};

export default ProgressOverlay;
9 changes: 5 additions & 4 deletions src/components/AdvanceAnalyticsV2/charts/EmptyChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ const EmptyChart = ({ message }) => {
yanchor: 'middle',
},
],
xaxis: { visible: false },
yaxis: { visible: false },
xaxis: { visible: true },
yaxis: { visible: true },
margin: {
t: 0, b: 0, l: 0, r: 0,
},
paper_bgcolor: 'lightgray',
plot_bgcolor: 'lightgray',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
autosize: true,
dragmode: false,
};

const config = {
Expand Down
9 changes: 5 additions & 4 deletions src/components/AdvanceAnalyticsV2/charts/EmptyChart.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ describe('EmptyChart', () => {
yanchor: 'middle',
},
],
xaxis: { visible: false },
yaxis: { visible: false },
xaxis: { visible: true },
yaxis: { visible: true },
margin: {
t: 0, b: 0, l: 0, r: 0,
},
paper_bgcolor: 'lightgray',
plot_bgcolor: 'lightgray',
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
autosize: true,
dragmode: false,
};

it('renders correctly', () => {
Expand Down
15 changes: 10 additions & 5 deletions src/components/AdvanceAnalyticsV2/charts/ScatterChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const ScatterChart = ({
name: category,
marker: {
color: colorMap[category],
size: filteredData.map(item => (item[markerSizeKey] + 0.7) * 10),
size: filteredData.map(item => item[markerSizeKey] * 0.015).map(size => (size < 5 ? size + 6 : size)),
},
customdata: customDataKeys.length ? filteredData.map(item => customDataKeys.map(key => item[key])) : [],
hovertemplate,
Expand All @@ -43,11 +43,16 @@ const ScatterChart = ({
const layout = {
margin: { t: 0 },
legend: {
title: '', yanchor: 'top', y: 0.99, xanchor: 'right', x: 0.99, bgcolor: 'white', itemsizing: 'constant',
title: '', yanchor: 'top', y: 0.99, xanchor: 'left', x: 0.99, bgcolor: 'white', itemsizing: 'constant',
},
yaxis: {
title: yAxisTitle,
zeroline: false,
},
xaxis: {
title: xAxisTitle,
zeroline: false,
},
xaxis: { title: xAxisTitle },
yaxis: { title: yAxisTitle },
dragmode: false,
autosize: true,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ describe('ScatterChart', () => {
expect(traces[1].y).toEqual([4]);
expect(traces[0].marker.color).toBe('red');
expect(traces[1].marker.color).toBe('blue');
expect(traces[0].marker.size).toEqual([37]);
expect(traces[1].marker.size).toEqual([57]);
expect(traces[0].marker.size).toEqual([6.045]);
expect(traces[1].marker.size).toEqual([6.075]);
expect(traces[0].customdata[0]).toEqual(['A']);
expect(traces[1].customdata[0]).toEqual(['B']);
traces.forEach(trace => {
Expand Down
26 changes: 26 additions & 0 deletions src/components/AdvanceAnalyticsV2/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,29 @@ export const ANALYTICS_TABS = {
LEADERBOARD: 'leaderboard',
ENGAGEMENTS: 'engagements',
};

// Query Key factory for the admin analytics module, intended to be used with `@tanstack/react-query`.
// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories.
export const advanceAnalyticsQueryKeys = {
all: ['admin-analytics'],
skills: (enterpriseUUID, requestOptions) => [...advanceAnalyticsQueryKeys.all, 'skills', enterpriseUUID].concat(
Object.values(requestOptions),
),
};

export const skillsColorMap = {
'business-management': '#4A1D90',
communication: '#DCD6F7',
'computer-science': '#BE219A',
'data-analysis-statistics': '#F27A68',
engineering: '#E7D39A',
other: 'grey',
};

export const skillsTypeColorMap = {
'Common Skill': '#6574A6',
'Specialized Skill': '#FEAF00',
'Hard Skill': '#DC267F',
'Soft Skill': '#638FFF',
Certification: '#FE6100',
};
15 changes: 15 additions & 0 deletions src/components/AdvanceAnalyticsV2/data/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';

import { advanceAnalyticsQueryKeys } from './constants';
import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService';

export const useEnterpriseSkillsAnalytics = (enterpriseCustomerUUID, startDate, endDate, queryOptions = {}) => {

Check failure on line 6 in src/components/AdvanceAnalyticsV2/data/hooks.js

View workflow job for this annotation

GitHub Actions / tests (18)

Prefer default export on a file with single export

Check failure on line 6 in src/components/AdvanceAnalyticsV2/data/hooks.js

View workflow job for this annotation

GitHub Actions / tests (20)

Prefer default export on a file with single export
const requestOptions = { startDate, endDate };
return useQuery({
queryKey: advanceAnalyticsQueryKeys.skills(enterpriseCustomerUUID, requestOptions),
queryFn: () => EnterpriseDataApiService.fetchAdminAnalyticsSkills(enterpriseCustomerUUID, requestOptions),
staleTime: 1 * (1000 * 60 * 60), // 1 hour. Length of time before your data becomes stale
cacheTime: 2 * (1000 * 60 * 60), // 2 hours. Length of time before inactive data gets removed from the cache
...queryOptions,
});
};
66 changes: 66 additions & 0 deletions src/components/AdvanceAnalyticsV2/data/hooks.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* eslint-disable import/no-extraneous-dependencies */
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { useEnterpriseSkillsAnalytics } from './hooks';
import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService';
import { queryClient } from '../../test/testUtils';

jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));

jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsSkills');

const axiosMock = new MockAdapter(axios);
getAuthenticatedHttpClient.mockReturnValue(axios);

const mockAnalyticsSkillsData = {
top_skills: [],
top_skills_by_enrollments: [],
top_skills_by_completions: [],
};

axiosMock.onAny().reply(200);
axios.get = jest.fn(() => Promise.resolve({ data: mockAnalyticsSkillsData }));

const TEST_ENTERPRISE_ID = '33ce6562-95e0-4ecf-a2a7-7d407eb96f69';

describe('useEnterpriseSkillsAnalytics', () => {
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient()}>
{children}
</QueryClientProvider>
);

it('fetch skills analytics data', async () => {
const startDate = '2021-01-01';
const endDate = '2021-12-31';
const requestOptions = { startDate, endDate };
const { result, waitForNextUpdate } = renderHook(
() => useEnterpriseSkillsAnalytics(TEST_ENTERPRISE_ID, startDate, endDate),
{ wrapper },
);

expect(result.current).toEqual(
expect.objectContaining({
isLoading: true,
error: null,
data: undefined,
}),
);

await waitForNextUpdate();

expect(EnterpriseDataApiService.fetchAdminAnalyticsSkills).toHaveBeenCalled();
expect(EnterpriseDataApiService.fetchAdminAnalyticsSkills).toHaveBeenCalledWith(TEST_ENTERPRISE_ID, requestOptions);
expect(result.current).toEqual(expect.objectContaining({
isLoading: false,
error: null,
data: camelCaseObject(mockAnalyticsSkillsData),
}));
});
});
100 changes: 95 additions & 5 deletions src/components/AdvanceAnalyticsV2/tabs/Skills.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,25 @@ import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import Header from '../Header';
import EmptyChart from '../charts/EmptyChart';
import { ANALYTICS_TABS, CHART_TYPES } from '../data/constants';
import BarChart from '../charts/BarChart';
import {
ANALYTICS_TABS, CHART_TYPES, skillsColorMap, skillsTypeColorMap,
} from '../data/constants';
import ScatterChart from '../charts/ScatterChart';
import ProgressOverlay from '../ProgressOverlay';
import { useEnterpriseSkillsAnalytics } from '../data/hooks';

const Skills = ({ startDate, endDate, enterpriseId }) => {
const intl = useIntl();

const {
isLoading, isError, data,
} = useEnterpriseSkillsAnalytics(
enterpriseId,
startDate,
endDate,
);

return (
<div className="tab-skills mt-4">
<div className="top-skill-chart-container mb-4">
Expand All @@ -29,7 +42,37 @@ const Skills = ({ startDate, endDate, enterpriseId }) => {
enterpriseId={enterpriseId}
isDownloadCSV
/>
<EmptyChart />
{(isLoading || isError) ? (
<ProgressOverlay
isError={isError}
message={intl.formatMessage({
id: 'advance.analytics.skills.tab.chart.top.skills.loading.message',
defaultMessage: 'Loading top skills chart data',
description: 'Loading message for the top skills chart.',
})}
/>
) : (
<ScatterChart
data={data.topSkills}
xKey="enrolls"
yKey="completions"
colorKey="skillType"
colorMap={skillsTypeColorMap}
xAxisTitle={intl.formatMessage({
id: 'advance.analytics.skills.tab.chart.top.skills.x.axis.title',
defaultMessage: 'Enrollments',
description: 'X-axis title for the top skills chart.',
})}
yAxisTitle={intl.formatMessage({
id: 'advance.analytics.skills.tab.chart.top.skills.y.axis.title',
defaultMessage: 'Completions',
description: 'Y-axis title for the top skills chart.',
})}
markerSizeKey="completions"
customDataKeys={['skillName', 'skillType']}
hovertemplate="Skill: %{customdata[0]}<br>Enrolls: %{x}<br>Completions: %{y}"
/>
)}
</div>
<div className="row">
<div className="col-md-6">
Expand All @@ -41,7 +84,30 @@ const Skills = ({ startDate, endDate, enterpriseId }) => {
description: 'Title for the top skills by enrollment chart.',
})}
/>
<EmptyChart />
{(isLoading || isError) ? (
<ProgressOverlay
isError={isError}
message={intl.formatMessage({
id: 'advance.analytics.skills.tab.chart.top.skills.by.enrollment.loading.message',
defaultMessage: 'Loading top skills by enrollment chart data',
description: 'Loading message for the top skills by enrollment chart.',
})}
/>
) : (
<BarChart
data={data.topSkillsByEnrollments}
xKey="skillName"
yKey="count"
colorKey="primarySubjectName"
colorMap={skillsColorMap}
yAxisTitle={intl.formatMessage({
id: 'advance.analytics.skills.tab.chart.top.skills.by.enrollment.y.axis.title',
defaultMessage: 'Number of Enrollments',
description: 'Y-axis title for the top skills by enrollment chart.',
})}
hovertemplate="Skill: %{x}<br>Enrolls: %{y}"
/>
)}
</div>
</div>
<div className="col-md-6">
Expand All @@ -53,7 +119,31 @@ const Skills = ({ startDate, endDate, enterpriseId }) => {
description: 'Title for the top skills by completion chart.',
})}
/>
<EmptyChart />
{(isLoading || isError) ? (
<ProgressOverlay
isError={isError}
message={intl.formatMessage({
id: 'advance.analytics.skills.tab.chart.top.skills.by.completion.loading.message',
defaultMessage: 'Loading top skills by completions chart data',
description: 'Loading message for the top skills by completions chart.',
})}
/>
) : (
<BarChart
isLoading={isLoading}
data={data.topSkillsByCompletions}
xKey="skillName"
yKey="count"
colorKey="primarySubjectName"
colorMap={skillsColorMap}
yAxisTitle={intl.formatMessage({
id: 'advance.analytics.skills.tab.chart.top.skills.by.completion.y.axis.title',
defaultMessage: 'Number of Completions',
description: 'Y-axis title for the top skills by completion chart.',
})}
hovertemplate="Skill: %{x}<br>Completions: %{y}"
/>
)}
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit dc806a9

Please sign in to comment.