Skip to content

Commit 880525f

Browse files
Merge pull request #1665 from openedx/maham/ENT-10833
[ENT-10833] Add budget filtering for Analytics V2
2 parents 05043a2 + d197f95 commit 880525f

15 files changed

+419
-18
lines changed

src/components/AdvanceAnalyticsV2.0/AnalyticsFilters.jsx

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ const AnalyticsFilters = ({
3232
groups,
3333
isFetching,
3434
isGroupsLoading,
35+
budgets,
36+
isBudgetsFetching,
3537
activeTab,
3638
isEnterpriseCoursesFetching,
3739
enterpriseCourses,
40+
budgetUUID,
41+
setBudgetUUID,
3842

3943
}) => {
4044
const intl = useIntl();
@@ -297,7 +301,7 @@ const AnalyticsFilters = ({
297301
as="select"
298302
value={groupUUID}
299303
onChange={(e) => setGroupUUID(e.target.value)}
300-
disabled={isGroupsLoading || groups === undefined || groups.length === 0}
304+
disabled={isGroupsLoading}
301305
>
302306
<option value={DEFAULT_GROUP}>
303307
{intl.formatMessage({
@@ -307,8 +311,8 @@ const AnalyticsFilters = ({
307311
})}
308312
</option>
309313
{groups?.map(grp => (
310-
<option value={grp.uuid} key={grp.uuid}>
311-
{grp.name}
314+
<option value={grp?.uuid} key={grp?.uuid}>
315+
{grp?.name}
312316
</option>
313317
))}
314318
</Form.Control>
@@ -322,16 +326,31 @@ const AnalyticsFilters = ({
322326
<FormattedMessage
323327
id="advance.analytics.filter.by.budget"
324328
defaultMessage="Filter by budget"
325-
description="Advance analytics filter by budget label"
329+
description="Advance analytics budget filter label"
326330
/>
327331
</Form.Label>
328332
<Form.Control
329333
controlClassName="font-weight-normal analytics-filter-form-controls rounded-0"
330334
as="select"
331-
disabled
332-
value=""
335+
value={budgetUUID}
336+
onChange={(e) => setBudgetUUID(e.target.value)}
337+
disabled={isBudgetsFetching}
333338
>
334-
<option value="">All budgets</option>
339+
<option value="">
340+
{intl.formatMessage({
341+
id: 'adminPortal.analytics.budget.filter.all',
342+
defaultMessage: 'All budgets',
343+
description: 'Label for the all budgets option',
344+
})}
345+
</option>
346+
{budgets?.map(budget => (
347+
<option
348+
key={budget?.subsidyAccessPolicyUuid}
349+
value={budget?.subsidyAccessPolicyUuid}
350+
>
351+
{budget?.subsidyAccessPolicyDisplayName}
352+
</option>
353+
))}
335354
</Form.Control>
336355
</Form.Group>
337356
</div>
@@ -458,6 +477,10 @@ AnalyticsFilters.propTypes = {
458477
activeTab: PropTypes.string.isRequired,
459478
isEnterpriseCoursesFetching: PropTypes.bool,
460479
enterpriseCourses: PropTypes.arrayOf(PropTypes.shape({ })),
480+
budgets: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
481+
isBudgetsFetching: PropTypes.bool.isRequired,
482+
budgetUUID: PropTypes.string,
483+
setBudgetUUID: PropTypes.func.isRequired,
461484
};
462485

463486
export default AnalyticsFilters;

src/components/AdvanceAnalyticsV2.0/data/hooks.test.jsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,52 @@ describe('useEnterpriseAnalyticsData', () => {
203203
},
204204
);
205205
});
206+
it('includes budgetUUID in API request when provided', async () => {
207+
const startDate = '2021-01-01';
208+
const endDate = '2021-12-31';
209+
const budgetUUID = 'budget-1234';
210+
const courseType = COURSE_TYPES.ALL_COURSE_TYPES;
211+
const course = ALL_COURSES;
212+
213+
const requestOptions = {
214+
startDate,
215+
endDate,
216+
budgetUUID,
217+
};
218+
219+
const queryParams = new URLSearchParams(snakeCaseObject(requestOptions));
220+
const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${TEST_ENTERPRISE_ID}`;
221+
const analyticsURL = `${baseURL}/completions/stats?${queryParams.toString()}`;
222+
223+
axiosMock.onGet(analyticsURL).reply(200, mockAnalyticsCompletionsChartsData);
224+
225+
const { result } = renderHook(
226+
() => useEnterpriseAnalyticsData({
227+
enterpriseCustomerUUID: TEST_ENTERPRISE_ID,
228+
key: 'completions',
229+
startDate,
230+
endDate,
231+
courseType,
232+
course,
233+
budgetUUID,
234+
}),
235+
{ wrapper },
236+
);
237+
238+
await waitFor(() => {
239+
expect(result.current.isFetching).toBe(false);
240+
});
241+
242+
expect(EnterpriseDataApiService.fetchAdminAnalyticsData).toHaveBeenCalledWith(
243+
TEST_ENTERPRISE_ID,
244+
'completions',
245+
{
246+
startDate,
247+
endDate,
248+
budgetUUID,
249+
},
250+
);
251+
});
206252
});
207253

208254
describe('useEnterpriseAnalyticsAggregatesData', () => {
@@ -303,6 +349,42 @@ describe('useEnterpriseAnalyticsAggregatesData', () => {
303349
expect(result.current.isFetching).toBe(false);
304350
});
305351

352+
expect(EnterpriseDataApiService.fetchAdminAggregatesData).toHaveBeenCalledWith(
353+
TEST_ENTERPRISE_ID,
354+
requestOptions,
355+
);
356+
});
357+
it('includes budgetUUID in aggregates API request when provided', async () => {
358+
const startDate = '2021-01-01';
359+
const endDate = '2021-12-31';
360+
const budgetUUID = 'budget-9876';
361+
362+
const requestOptions = {
363+
startDate,
364+
endDate,
365+
budgetUUID,
366+
};
367+
368+
const queryParams = new URLSearchParams(snakeCaseObject(requestOptions));
369+
const baseURL = `${EnterpriseDataApiService.enterpriseAdminAnalyticsV2BaseUrl}${TEST_ENTERPRISE_ID}`;
370+
const analyticsURL = `${baseURL}/aggregates/stats?${queryParams.toString()}`;
371+
372+
axiosMock.onGet(analyticsURL).reply(200, {});
373+
374+
const { result } = renderHook(
375+
() => useEnterpriseAnalyticsAggregatesData({
376+
enterpriseCustomerUUID: TEST_ENTERPRISE_ID,
377+
startDate,
378+
endDate,
379+
budgetUUID,
380+
}),
381+
{ wrapper },
382+
);
383+
384+
await waitFor(() => {
385+
expect(result.current.isFetching).toBe(false);
386+
});
387+
306388
expect(EnterpriseDataApiService.fetchAdminAggregatesData).toHaveBeenCalledWith(
307389
TEST_ENTERPRISE_ID,
308390
requestOptions,

src/components/AdvanceAnalyticsV2.0/data/hooks/index.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { default as useEnterpriseEnrollmentsData } from './useEnterpriseEnrollme
99
export { default as useEnterpriseEngagementData } from './useEnterpriseEngagementData';
1010
export { default as useEnterpriseCompletionsData } from './useEnterpriseCompletionsData';
1111
export { default as useEnterpriseCourses } from './useEnterpriseCourses';
12+
export { default as useEnterpriseBudgets } from './useEnterpriseBudgets';
1213

1314
export const useEnterpriseAnalyticsData = ({
1415
enterpriseCustomerUUID,
@@ -22,6 +23,7 @@ export const useEnterpriseAnalyticsData = ({
2223
pageSize = undefined,
2324
courseType = undefined,
2425
course = undefined,
26+
budgetUUID = undefined,
2527
queryOptions = {},
2628
}) => {
2729
const requestOptions = {
@@ -42,6 +44,10 @@ export const useEnterpriseAnalyticsData = ({
4244
requestOptions.courseKey = course.value;
4345
}
4446

47+
if (budgetUUID) {
48+
requestOptions.budgetUUID = budgetUUID;
49+
}
50+
4551
return useQuery({
4652
queryKey: advanceAnalyticsQueryKeys[key](enterpriseCustomerUUID, requestOptions),
4753
queryFn: () => EnterpriseDataApiService.fetchAdminAnalyticsData(
@@ -79,6 +85,7 @@ export const useEnterpriseAnalyticsAggregatesData = ({
7985
endDate,
8086
courseType = undefined,
8187
course = undefined,
88+
budgetUUID = undefined,
8289
queryOptions = {},
8390
}) => {
8491
const requestOptions = {
@@ -94,6 +101,10 @@ export const useEnterpriseAnalyticsAggregatesData = ({
94101
requestOptions.courseKey = course.value;
95102
}
96103

104+
if (budgetUUID) {
105+
requestOptions.budgetUUID = budgetUUID;
106+
}
107+
97108
return useQuery({
98109
queryKey: advanceAnalyticsQueryKeys.aggregates(enterpriseCustomerUUID, requestOptions),
99110
queryFn: () => EnterpriseDataApiService.fetchAdminAggregatesData(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { useMemo } from 'react';
3+
import { camelCaseObject } from '@edx/frontend-platform/utils';
4+
import { generateKey } from '../constants';
5+
import EnterpriseDataApiService from '../../../../data/services/EnterpriseDataApiService';
6+
7+
/**
8+
* Fetches enterprise budgets.
9+
*
10+
* @param {String} enterpriseCustomerUUID - UUID of the enterprise customer.
11+
* @param {object} queryOptions - Additional options for the query.
12+
*/
13+
const useEnterpriseBudgets = ({
14+
enterpriseCustomerUUID,
15+
queryOptions = {},
16+
}) => {
17+
const requestOptions = { };
18+
const response = useQuery({
19+
queryKey: generateKey('budgets', enterpriseCustomerUUID, requestOptions),
20+
queryFn: () => EnterpriseDataApiService.fetchEnterpriseBudgets(enterpriseCustomerUUID),
21+
staleTime: 0.5 * (1000 * 60 * 60), // 30 minutes. Data considered stale after this duration
22+
cacheTime: 0.75 * (1000 * 60 * 60), // 45 minutes. Cache GC after this duration
23+
keepPreviousData: true,
24+
...queryOptions,
25+
});
26+
27+
return useMemo(() => camelCaseObject({
28+
data: response?.data?.data,
29+
isFetching: false,
30+
}), [response]);
31+
};
32+
33+
export default useEnterpriseBudgets;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { useQuery } from '@tanstack/react-query';
3+
import useEnterpriseBudgets from './useEnterpriseBudgets';
4+
import { generateKey } from '../constants';
5+
6+
jest.mock('../../../../data/services/EnterpriseDataApiService', () => ({
7+
fetchEnterpriseBudgets: jest.fn(),
8+
}));
9+
10+
jest.mock('@tanstack/react-query', () => ({
11+
useQuery: jest.fn(),
12+
}));
13+
14+
jest.mock('../constants', () => ({
15+
generateKey: jest.fn(),
16+
}));
17+
18+
describe('useEnterpriseBudgets', () => {
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
const enterpriseCustomerUUID = 'enterprise-456';
24+
25+
it('calls useQuery with correct parameters', () => {
26+
const mockData = {
27+
data: [
28+
{ subsidyAccessPolicyUuid: 'budget-uuid-1', subsidyAccessPolicyDisplayName: 'Budget 1' },
29+
{ subsidyAccessPolicyUuid: 'budget-uuid-2', subsidyAccessPolicyDisplayName: 'Budget 2' },
30+
],
31+
};
32+
useQuery.mockReturnValue({ data: mockData });
33+
generateKey.mockReturnValue(['budgets', enterpriseCustomerUUID]);
34+
35+
const { result } = renderHook(() => useEnterpriseBudgets({
36+
enterpriseCustomerUUID,
37+
}));
38+
39+
expect(useQuery).toHaveBeenCalledWith(expect.objectContaining({
40+
queryKey: ['budgets', enterpriseCustomerUUID],
41+
queryFn: expect.any(Function),
42+
staleTime: expect.any(Number),
43+
cacheTime: expect.any(Number),
44+
keepPreviousData: true,
45+
}));
46+
47+
expect(result.current.data).toEqual(mockData.data);
48+
expect(result.current.isFetching).toBe(false);
49+
});
50+
51+
it('returns empty data when API response is undefined', () => {
52+
useQuery.mockReturnValue({ data: undefined });
53+
generateKey.mockReturnValue(['budgets', enterpriseCustomerUUID]);
54+
55+
const { result } = renderHook(() => useEnterpriseBudgets({
56+
enterpriseCustomerUUID,
57+
}));
58+
59+
expect(result.current.data).toBeUndefined();
60+
expect(result.current.isFetching).toBe(false);
61+
});
62+
63+
it('uses queryOptions passed into the hook', () => {
64+
const mockOptions = { enabled: false };
65+
generateKey.mockReturnValue(['budgets', enterpriseCustomerUUID]);
66+
67+
renderHook(() => useEnterpriseBudgets({
68+
enterpriseCustomerUUID,
69+
queryOptions: mockOptions,
70+
}));
71+
72+
expect(useQuery).toHaveBeenCalledWith(expect.objectContaining({
73+
...mockOptions,
74+
}));
75+
});
76+
});
File renamed without changes.

src/components/AdvanceAnalyticsV2.0/data/hooks/useEnterpriseCourses.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const useEnterpriseCourses = ({
3333
endDate,
3434
groupUUID = undefined,
3535
courseType = undefined,
36+
budgetUUID = undefined,
3637
queryOptions = {},
3738
}) => {
3839
const requestOptions = {
@@ -45,6 +46,10 @@ const useEnterpriseCourses = ({
4546
requestOptions.courseType = courseType;
4647
}
4748

49+
if (budgetUUID) {
50+
requestOptions.budgetUUID = budgetUUID;
51+
}
52+
4853
const response = useQuery({
4954
queryKey: generateKey('enterprise-course', enterpriseCustomerUUID, requestOptions),
5055
queryFn: () => EnterpriseDataApiService.fetchEnterpriseCourses(

0 commit comments

Comments
 (0)