diff --git a/src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx b/src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx new file mode 100644 index 0000000000..3068061e10 --- /dev/null +++ b/src/components/AdvanceAnalyticsV2/ProgressOverlay.jsx @@ -0,0 +1,20 @@ +import { + Spinner, +} from '@openedx/paragon'; +import PropTypes from 'prop-types'; +import EmptyChart from './charts/EmptyChart'; + +const ProgressOverlay = ({ isError, message }) => ( +
+
+ {isError ? : } +
+
+); + +ProgressOverlay.propTypes = { + isError: PropTypes.bool.isRequired, + message: PropTypes.string.isRequired, +}; + +export default ProgressOverlay; diff --git a/src/components/AdvanceAnalyticsV2/charts/EmptyChart.jsx b/src/components/AdvanceAnalyticsV2/charts/EmptyChart.jsx index 2e230b38b2..f5260f9a38 100644 --- a/src/components/AdvanceAnalyticsV2/charts/EmptyChart.jsx +++ b/src/components/AdvanceAnalyticsV2/charts/EmptyChart.jsx @@ -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 = { diff --git a/src/components/AdvanceAnalyticsV2/charts/EmptyChart.test.jsx b/src/components/AdvanceAnalyticsV2/charts/EmptyChart.test.jsx index 0fa8d76923..a25e151654 100644 --- a/src/components/AdvanceAnalyticsV2/charts/EmptyChart.test.jsx +++ b/src/components/AdvanceAnalyticsV2/charts/EmptyChart.test.jsx @@ -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', () => { diff --git a/src/components/AdvanceAnalyticsV2/charts/ScatterChart.jsx b/src/components/AdvanceAnalyticsV2/charts/ScatterChart.jsx index e2d0b72ba0..3cd27aba19 100644 --- a/src/components/AdvanceAnalyticsV2/charts/ScatterChart.jsx +++ b/src/components/AdvanceAnalyticsV2/charts/ScatterChart.jsx @@ -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, @@ -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, }; diff --git a/src/components/AdvanceAnalyticsV2/charts/ScatterChart.test.jsx b/src/components/AdvanceAnalyticsV2/charts/ScatterChart.test.jsx index cc2bf26aac..f728425027 100644 --- a/src/components/AdvanceAnalyticsV2/charts/ScatterChart.test.jsx +++ b/src/components/AdvanceAnalyticsV2/charts/ScatterChart.test.jsx @@ -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 => { diff --git a/src/components/AdvanceAnalyticsV2/data/constants.js b/src/components/AdvanceAnalyticsV2/data/constants.js index 1e591db955..c813144ed7 100644 --- a/src/components/AdvanceAnalyticsV2/data/constants.js +++ b/src/components/AdvanceAnalyticsV2/data/constants.js @@ -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', +}; diff --git a/src/components/AdvanceAnalyticsV2/data/hooks.js b/src/components/AdvanceAnalyticsV2/data/hooks.js new file mode 100644 index 0000000000..e38982819e --- /dev/null +++ b/src/components/AdvanceAnalyticsV2/data/hooks.js @@ -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 = {}) => { + 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, + }); +}; diff --git a/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx b/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx new file mode 100644 index 0000000000..c64bb2ea81 --- /dev/null +++ b/src/components/AdvanceAnalyticsV2/data/hooks.test.jsx @@ -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 }) => ( + + {children} + + ); + + 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), + })); + }); +}); diff --git a/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx b/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx index 19694ca07d..65733fd4e8 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Skills.jsx @@ -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 (
@@ -29,7 +42,37 @@ const Skills = ({ startDate, endDate, enterpriseId }) => { enterpriseId={enterpriseId} isDownloadCSV /> - + {(isLoading || isError) ? ( + + ) : ( + + )}
@@ -41,7 +84,30 @@ const Skills = ({ startDate, endDate, enterpriseId }) => { description: 'Title for the top skills by enrollment chart.', })} /> - + {(isLoading || isError) ? ( + + ) : ( + + )}
@@ -53,7 +119,31 @@ const Skills = ({ startDate, endDate, enterpriseId }) => { description: 'Title for the top skills by completion chart.', })} /> - + {(isLoading || isError) ? ( + + ) : ( + + )}
diff --git a/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx b/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx index 3929f7cc80..97372f5be5 100644 --- a/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tabs/Skills.test.jsx @@ -1,38 +1,133 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import Skills from './Skills'; import '@testing-library/jest-dom'; +import { queryClient } from '../../test/testUtils'; +import * as hooks from '../data/hooks'; -describe('Enrollments Component', () => { - test('renders all sections with correct classes and content', () => { - const { container } = render( - - - , - ); - - const sections = [ - { - className: '.top-skill-chart-container', - title: 'Top Skills', - subtitle: 'See the top skills that are the most in demand in your organization, based on enrollments and completions.', - }, - { - className: '.top-skills-by-enrollment-chart-container', - title: 'Top Skills by Enrollment', - }, - { - className: '.top-skills-by-completion-chart-container', - title: 'Top Skills by Completion', - }, - ]; - - sections.forEach(({ className, title, subtitle }) => { - const section = container.querySelector(className); - expect(section).toHaveTextContent(title); - if (subtitle) { - expect(section).toHaveTextContent(subtitle); - } +jest.mock('../data/hooks'); + +const mockAnalyticsSkillsData = { + topSkills: [], + topSkillsByEnrollments: [], + topSkillsByCompletions: [], +}; + +jest.mock('../charts/ScatterChart', () => { + const MockedScatterChart = () =>
Mocked ScatterChart
; + return MockedScatterChart; +}); +jest.mock('../charts/BarChart', () => { + const MockedBarChart = () =>
Mocked BarChart
; + return MockedBarChart; +}); + +jest.mock('../../../data/services/EnterpriseDataApiService', () => ({ + fetchAdminAnalyticsSkills: jest.fn(), +})); + +describe('Skills Tab', () => { + describe('renders static text', () => { + test('renders all sections with correct classes and content', () => { + hooks.useEnterpriseSkillsAnalytics.mockReturnValue({ + isLoading: true, + data: null, + isError: false, + isFetching: false, + error: null, + }); + + const { container } = render( + + + + , + , + ); + + const sections = [ + { + className: '.top-skill-chart-container', + title: 'Top Skills', + subtitle: 'See the top skills that are the most in demand in your organization, based on enrollments and completions.', + }, + { + className: '.top-skills-by-enrollment-chart-container', + title: 'Top Skills by Enrollment', + }, + { + className: '.top-skills-by-completion-chart-container', + title: 'Top Skills by Completion', + }, + ]; + + sections.forEach(({ className, title, subtitle }) => { + const section = container.querySelector(className); + expect(section).toHaveTextContent(title); + if (subtitle) { + expect(section).toHaveTextContent(subtitle); + } + }); + }); + }); + + describe('when loading data from API', () => { + test('renders correct messages', () => { + hooks.useEnterpriseSkillsAnalytics.mockReturnValue({ + isLoading: true, + data: null, + isError: false, + isFetching: false, + error: null, + }); + + render( + + + + , + , + ); + + expect(screen.getByText('Loading top skills chart data')).toBeInTheDocument(); + expect(screen.getByText('Loading top skills by enrollment chart data')).toBeInTheDocument(); + expect(screen.getByText('Loading top skills by completions chart data')).toBeInTheDocument(); + }); + }); + + describe('when data successfully loaded from API', () => { + test('renders charts', () => { + hooks.useEnterpriseSkillsAnalytics.mockReturnValue({ + isLoading: false, + data: mockAnalyticsSkillsData, + isError: false, + isFetching: false, + error: null, + }); + render( + + + + , + , + ); + + expect(screen.getByText('Mocked ScatterChart')).toBeInTheDocument(); + const elements = screen.getAllByText('Mocked BarChart'); + expect(elements).toHaveLength(2); }); }); }); diff --git a/src/components/AdvanceAnalyticsV2/tests/AnalyticsV2Page.test.jsx b/src/components/AdvanceAnalyticsV2/tests/AnalyticsV2Page.test.jsx index 0cba1d8df1..aada545617 100644 --- a/src/components/AdvanceAnalyticsV2/tests/AnalyticsV2Page.test.jsx +++ b/src/components/AdvanceAnalyticsV2/tests/AnalyticsV2Page.test.jsx @@ -1,11 +1,13 @@ import React from 'react'; import { render, fireEvent, within } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; import '@testing-library/jest-dom/extend-expect'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import AnalyticsV2Page from '../AnalyticsV2Page'; +import { queryClient } from '../../test/testUtils'; import '@testing-library/jest-dom'; const mockStore = configureMockStore([thunk]); @@ -34,11 +36,13 @@ const store = mockStore({ }); const renderWithProviders = (component) => render( - - - {component} - - , + + + + {component} + + + , ); describe('AnalyticsV2Page', () => { diff --git a/src/data/services/EnterpriseDataApiService.js b/src/data/services/EnterpriseDataApiService.js index b95fb84111..c5a6e29868 100644 --- a/src/data/services/EnterpriseDataApiService.js +++ b/src/data/services/EnterpriseDataApiService.js @@ -1,5 +1,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { snakeCaseObject } from '@edx/frontend-platform/utils'; +import { snakeCaseObject, camelCaseObject } from '@edx/frontend-platform/utils'; +import omitBy from 'lodash/omitBy'; +import isEmpty from 'lodash/isEmpty'; import store from '../store'; import { configuration } from '../../config'; @@ -139,6 +141,14 @@ class EnterpriseDataApiService { return EnterpriseDataApiService.apiClient().get(url); } + static fetchAdminAnalyticsSkills(enterpriseCustomerUUID, options) { + const enterpriseUUID = EnterpriseDataApiService.getEnterpriseUUID(enterpriseCustomerUUID); + const transformOptions = omitBy(snakeCaseObject(options), isEmpty); + const queryParams = new URLSearchParams(transformOptions); + const url = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${enterpriseUUID}/skills/stats?${queryParams.toString()}`; + return EnterpriseDataApiService.apiClient().get(url).then((response) => camelCaseObject(response.data)); + } + static fetchDashboardInsights(enterpriseId) { const enterpriseUUID = EnterpriseDataApiService.getEnterpriseUUID(enterpriseId); const url = `${EnterpriseDataApiService.enterpriseAdminBaseUrl}insights/${enterpriseUUID}`; diff --git a/src/data/services/tests/EnterpriseDataApiService.test.js b/src/data/services/tests/EnterpriseDataApiService.test.js new file mode 100644 index 0000000000..02e2d0f4e6 --- /dev/null +++ b/src/data/services/tests/EnterpriseDataApiService.test.js @@ -0,0 +1,44 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { snakeCaseObject, camelCaseObject } from '@edx/frontend-platform/utils'; + +import EnterpriseDataApiService from '../EnterpriseDataApiService'; + +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 mockEnterpriseUUID = '33ce6562-95e0-4ecf-a2a7-7d407eb96f69'; + +describe('EnterpriseDataApiService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('fetchAdminAnalyticsSkills calls correct API endpoint', async () => { + const requestOptions = { startDate: '2021-01-01', endDate: '2021-12-31' }; + const queryParams = new URLSearchParams(snakeCaseObject(requestOptions)); + const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${mockEnterpriseUUID}`; + const analyticsSkillsURL = `${baseURL}/skills/stats?${queryParams.toString()}`; + const response = await EnterpriseDataApiService.fetchAdminAnalyticsSkills(mockEnterpriseUUID, requestOptions); + expect(axios.get).toBeCalledWith(analyticsSkillsURL); + expect(response).toEqual(camelCaseObject(mockAnalyticsSkillsData)); + }); + test('fetchAdminAnalyticsSkills remove falsy query params', () => { + const requestOptions = { startDate: '', endDate: null, otherDate: undefined }; + const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${mockEnterpriseUUID}`; + const analyticsSkillsURL = `${baseURL}/skills/stats?`; + EnterpriseDataApiService.fetchAdminAnalyticsSkills(mockEnterpriseUUID, requestOptions); + expect(axios.get).toBeCalledWith(analyticsSkillsURL); + }); +});