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 advance analytics charts #1287

Merged
merged 2 commits into from
Sep 6, 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
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 />;

Check warning on line 20 in src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/AdvanceAnalyticsV2/charts/ChartWrapper.jsx#L20

Added line #L20 was not covered by tests
}

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: '[email protected]',
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
Loading