Skip to content

Commit

Permalink
Merge pull request #1287 from openedx/ammar/integrate-advance-analyti…
Browse files Browse the repository at this point in the history
…cs-charts

feat: integrate advance analytics charts
  • Loading branch information
muhammad-ammar authored Sep 6, 2024
2 parents 11e8e2e + 3f786ef commit 1957eff
Show file tree
Hide file tree
Showing 21 changed files with 1,238 additions and 287 deletions.
2 changes: 2 additions & 0 deletions src/components/AdvanceAnalyticsV2/AnalyticsV2Page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ const AnalyticsV2Page = ({ enterpriseId }) => {
<Engagements
startDate={startDate}
endDate={endDate}
granularity={granularity}
calculation={calculation}
enterpriseId={enterpriseId}
/>
</Tab>
Expand Down
2 changes: 1 addition & 1 deletion src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const ProgressOverlay = ({ isError, message }) => (

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

export default ProgressOverlay;
49 changes: 49 additions & 0 deletions src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
Spinner,
} from '@openedx/paragon';
import ScatterChart from './ScatterChart';
import LineChart from './LineChart';
import BarChart from './BarChart';
import EmptyChart from './EmptyChart';

const ChartWrapper = ({
isFetching,
isError,
chartType,
chartProps,
loadingMessage,
}) => {
if (isError) {
return <EmptyChart />;
}

const chartMap = {
ScatterChart: <ScatterChart {...chartProps} />,
LineChart: <LineChart {...chartProps} />,
BarChart: <BarChart {...chartProps} />,
};

return (
<div className={classNames('analytics-chart-container', { chartType }, { 'is-fetching': isFetching })}>
{isFetching && (
<div className="spinner-centered">
<Spinner animation="border" screenReaderText={loadingMessage} />
</div>
)}
{chartProps.data && chartMap[chartType]}
</div>
);
};

ChartWrapper.propTypes = {
isFetching: PropTypes.bool.isRequired,
isError: PropTypes.bool.isRequired,
chartType: PropTypes.oneOf(['ScatterChart', 'LineChart', 'BarChart']).isRequired,
chartProps: PropTypes.object.isRequired,

Check failure on line 45 in src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx

View workflow job for this annotation

GitHub Actions / tests

Prop type "object" is forbidden
loadingMessage: PropTypes.string.isRequired,
};

export default ChartWrapper;
24 changes: 23 additions & 1 deletion src/components/AdvanceAnalyticsV2/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,27 @@ const generateKey = (key, enterpriseUUID, requestOptions) => [
// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories.
export const advanceAnalyticsQueryKeys = {
all: analyticsDefaultKeys,
skills: (enterpriseUUID, requestOptions) => generateKey('skills', enterpriseUUID, requestOptions),
skills: (enterpriseUUID, requestOptions) => (
generateKey('skills', enterpriseUUID, requestOptions)
),
completions: (enterpriseUUID, requestOptions) => (
generateKey('completions', enterpriseUUID, requestOptions)
),
engagements: (enterpriseUUID, requestOptions) => (
generateKey('engagements', enterpriseUUID, requestOptions)
),
enrollments: (enterpriseUUID, requestOptions) => (
generateKey('enrollments', enterpriseUUID, requestOptions)
),
enrollmentsTable: (enterpriseUUID, requestOptions) => (
generateKey(analyticsDataTableKeys.enrollments, enterpriseUUID, requestOptions)
),
engagementsTable: (enterpriseUUID, requestOptions) => (
generateKey(analyticsDataTableKeys.engagements, enterpriseUUID, requestOptions)
),
completionsTable: (enterpriseUUID, requestOptions) => (
generateKey(analyticsDataTableKeys.completions, enterpriseUUID, requestOptions)
),
leaderboardTable: (enterpriseUUID, requestOptions) => (
generateKey(analyticsDataTableKeys.leaderboard, enterpriseUUID, requestOptions)
),
Expand All @@ -75,3 +95,5 @@ export const skillsTypeColorMap = {
'Soft Skill': '#638FFF',
Certification: '#FE6100',
};

export const chartColorMap = { certificate: '#3669C9', audit: '#06262B' };
40 changes: 16 additions & 24 deletions src/components/AdvanceAnalyticsV2/data/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,28 @@ import { useQuery } from '@tanstack/react-query';
import { advanceAnalyticsQueryKeys } from './constants';
import EnterpriseDataApiService from '../../../data/services/EnterpriseDataApiService';

export const useEnterpriseSkillsAnalytics = (enterpriseCustomerUUID, startDate, endDate, queryOptions = {}) => {
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,
});
};

export const useEnterpriseAnalyticsTableData = (
export const useEnterpriseAnalyticsData = ({
enterpriseCustomerUUID,
tableKey,
key,
startDate,
endDate,
currentPage,
granularity = undefined,
calculation = undefined,
currentPage = undefined,
queryOptions = {},
) => {
const requestOptions = { startDate, endDate, page: currentPage };
}) => {
const requestOptions = {
startDate, endDate, granularity, calculation, page: currentPage,
};
return useQuery({
queryKey: advanceAnalyticsQueryKeys[tableKey](enterpriseCustomerUUID, requestOptions),
queryFn: () => EnterpriseDataApiService.fetchAdminAnalyticsTableData(
queryKey: advanceAnalyticsQueryKeys[key](enterpriseCustomerUUID, requestOptions),
queryFn: () => EnterpriseDataApiService.fetchAdminAnalyticsData(
enterpriseCustomerUUID,
tableKey,
key,
requestOptions,
),
select: (respnose) => respnose.data,
staleTime: 0.5 * (1000 * 60 * 60), // 30 minutes. Length of time before your data becomes stale
cacheTime: 0.75 * (1000 * 60 * 60), // 45 minutes. Length of time before inactive data gets removed from the cache
staleTime: 0.5 * (1000 * 60 * 60), // 30 minutes. The time in milliseconds after data is considered stale.
cacheTime: 0.75 * (1000 * 60 * 60), // 45 minutes. Cache data will be garbage collected after this duration.
keepPreviousData: true,
...queryOptions,
});
Expand All @@ -43,9 +35,9 @@ export const usePaginatedData = (data) => useMemo(() => {
if (data) {
return {
data: data.results,
pageCount: data.num_pages,
pageCount: data.numPages,
itemCount: data.count,
currentPage: data.current_page,
currentPage: data.currentPage,
};
}

Expand Down
113 changes: 96 additions & 17 deletions src/components/AdvanceAnalyticsV2/data/hooks.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,63 +4,142 @@ 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 { snakeCaseObject, camelCaseObject } from '@edx/frontend-platform/utils';
import { useEnterpriseAnalyticsData } 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');
jest.spyOn(EnterpriseDataApiService, 'fetchAdminAnalyticsData');

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

const mockAnalyticsSkillsData = {
top_skills: [],
top_skills_by_enrollments: [],
top_skills_by_completions: [],
const mockAnalyticsCompletionsChartsData = {
completions_over_time: [],
top_courses_by_completions: [],
top_subjects_by_completions: [],
};

axiosMock.onAny().reply(200);
axios.get = jest.fn(() => Promise.resolve({ data: mockAnalyticsSkillsData }));
const mockAnalyticsLeaderboardTableData = [
{
email: 'user@example.com',
dailySessions: 243,
learningTimeSeconds: 1111,
learningTimeHours: 3.4,
averageSessionLength: 1.6,
courseCompletions: 4,
},
];

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

describe('useEnterpriseSkillsAnalytics', () => {
describe('useEnterpriseAnalyticsData', () => {
afterEach(() => {
axiosMock.reset();
});

const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient()}>
{children}
</QueryClientProvider>
);

it('fetch skills analytics data', async () => {
it('fetch analytics chart data', async () => {
const startDate = '2021-01-01';
const endDate = '2021-12-31';
const requestOptions = { startDate, endDate };
const queryParams = new URLSearchParams(snakeCaseObject(requestOptions));
const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${TEST_ENTERPRISE_ID}`;
const analyticsCompletionsURL = `${baseURL}/completions/stats?${queryParams.toString()}`;
axiosMock.onGet(`${analyticsCompletionsURL}`).reply(200, mockAnalyticsCompletionsChartsData);
const { result, waitForNextUpdate } = renderHook(
() => useEnterpriseAnalyticsData({
enterpriseCustomerUUID: TEST_ENTERPRISE_ID,
key: 'completions',
startDate,
endDate,
}),
{ wrapper },
);

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

await waitForNextUpdate();

expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalled();
expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalledWith(
TEST_ENTERPRISE_ID,
'completions',
{
calculation: undefined,
endDate: '2021-12-31',
granularity: undefined,
page: undefined,
startDate: '2021-01-01',
},
);
expect(result.current).toEqual(expect.objectContaining({
isFetching: false,
error: null,
data: camelCaseObject(mockAnalyticsCompletionsChartsData),
}));
expect(axiosMock.history.get[0].url).toBe(analyticsCompletionsURL);
});
it('fetch analytics table data', async () => {
const startDate = '2021-01-01';
const endDate = '2021-12-31';
const requestOptions = { startDate, endDate };
const queryParams = new URLSearchParams(snakeCaseObject(requestOptions));
const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${TEST_ENTERPRISE_ID}`;
const analyticsLeaderboardURL = `${baseURL}/leaderboard?${queryParams.toString()}`;
axiosMock.onGet(`${analyticsLeaderboardURL}`).reply(200, mockAnalyticsLeaderboardTableData);
const { result, waitForNextUpdate } = renderHook(
() => useEnterpriseSkillsAnalytics(TEST_ENTERPRISE_ID, startDate, endDate),
() => useEnterpriseAnalyticsData({
enterpriseCustomerUUID: TEST_ENTERPRISE_ID,
key: 'leaderboardTable',
startDate,
endDate,
}),
{ wrapper },
);

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

await waitForNextUpdate();

expect(EnterpriseDataApiService.fetchAdminAnalyticsSkills).toHaveBeenCalled();
expect(EnterpriseDataApiService.fetchAdminAnalyticsSkills).toHaveBeenCalledWith(TEST_ENTERPRISE_ID, requestOptions);
expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalled();
expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalledWith(
TEST_ENTERPRISE_ID,
'completions',
{
calculation: undefined,
endDate: '2021-12-31',
granularity: undefined,
page: undefined,
startDate: '2021-01-01',
},
);
expect(result.current).toEqual(expect.objectContaining({
isLoading: false,
isFetching: false,
error: null,
data: camelCaseObject(mockAnalyticsSkillsData),
data: camelCaseObject(mockAnalyticsLeaderboardTableData),
}));
expect(axiosMock.history.get[0].url).toBe(analyticsLeaderboardURL);
});
});
24 changes: 24 additions & 0 deletions src/components/AdvanceAnalyticsV2/styles/index.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
.analytics-stat-number {
font-size: 2.5rem;
}

.analytics-chart-container {
position: relative;
min-height: 40vh;

&.is-fetching::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba($white, .7);
z-index: 1;
}

.spinner-centered {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
}
}
Loading

0 comments on commit 1957eff

Please sign in to comment.