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: integrate skills charts #1274

Merged
merged 1 commit into from
Aug 28, 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
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();

muhammad-ammar marked this conversation as resolved.
Show resolved Hide resolved
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
Loading