Skip to content

Commit

Permalink
Merge pull request #1051 from openedx/maham/ENT-7363
Browse files Browse the repository at this point in the history
feat: display AI analytics section on LPR page
  • Loading branch information
mahamakifdar19 committed Oct 11, 2023
2 parents 6a47c98 + 814066d commit 74c47c8
Show file tree
Hide file tree
Showing 19 changed files with 2,988 additions and 198 deletions.
37 changes: 37 additions & 0 deletions src/components/AIAnalyticsSummary/data/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { logError } from '@edx/frontend-platform/logging';
import LmsApiService from '../../../data/services/LmsApiService';

const useAIAnalyticsSummary = (enterpriseId, insights) => {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [analyticsSummaryData, setAnalyticsSummaryData] = useState(null);

useEffect(() => {
const fetchData = async () => {
try {
const response = await LmsApiService.generateAIAnalyticsSummary(enterpriseId, insights);
setAnalyticsSummaryData(response.data);
} catch (err) {
setError(err);
logError(err);
} finally {
setIsLoading(false);
}
};

if (enterpriseId && insights) {
fetchData();
} else {
setIsLoading(false);
}
}, [enterpriseId, insights]);

return {
isLoading,
error,
data: analyticsSummaryData,
};
};

export default useAIAnalyticsSummary;
102 changes: 102 additions & 0 deletions src/components/AIAnalyticsSummary/tests/hooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { renderHook } from '@testing-library/react-hooks';
import useAIAnalyticsSummary from '../data/hooks';
import LmsApiService from '../../../data/services/LmsApiService';

jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));

jest.mock('../../../data/services/LmsApiService', () => ({
generateAIAnalyticsSummary: jest.fn(),
}));

const TEST_ENTERPRISE_ID = 'test-enterprise-uuid';

const mockInsightsData = {
learner_progress: {
enterprise_customer_uuid: 'aac56d39-f38d-4510-8ef9-085cab048ea9',
enterprise_customer_name: 'Microsoft Corporation',
active_subscription_plan: true,
assigned_licenses: 0,
activated_licenses: 0,
assigned_licenses_percentage: 0.0,
activated_licenses_percentage: 0.0,
active_enrollments: 1026,
at_risk_enrollment_less_than_one_hour: 26,
at_risk_enrollment_end_date_soon: 15,
at_risk_enrollment_dormant: 918,
created_at: '2023-10-02T03:24:17Z',
},
learner_engagement: {
enterprise_customer_uuid: 'aac56d39-f38d-4510-8ef9-085cab048ea9',
enterprise_customer_name: 'Microsoft Corporation',
enrolls: 49,
enrolls_prior: 45,
passed: 2,
passed_prior: 0,
engage: 67,
engage_prior: 50,
hours: 62,
hours_prior: 49,
contract_end_date: '2022-06-13T00:00:00Z',
active_contract: false,
created_at: '2023-10-02T03:24:40Z',
},
};
const mockAnalyticsData = {
learner_progress: 'As an administrator running an online learning program on edX For Business, currently, none of the licenses are active',
learner_engagement: 'In the last 30 days, your online learning program on edX For Business has seen positive growth.',
};

describe('useAIAnalyticsSummary', () => {
it('should fetch AI analytics summary data', async () => {
LmsApiService.generateAIAnalyticsSummary.mockResolvedValueOnce({ data: mockAnalyticsData });
const { result, waitForNextUpdate } = renderHook(() => useAIAnalyticsSummary(TEST_ENTERPRISE_ID, mockInsightsData));

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

await waitForNextUpdate();

expect(LmsApiService.generateAIAnalyticsSummary).toHaveBeenCalled();
expect(result.current).toEqual({
isLoading: false,
error: null,
data: mockAnalyticsData,
});
});

it('should handle error when fetching AI analytics summary data', async () => {
const error = new Error('An error occurred');
LmsApiService.generateAIAnalyticsSummary.mockRejectedValueOnce(error);
const { result, waitForNextUpdate } = renderHook(() => useAIAnalyticsSummary(TEST_ENTERPRISE_ID, mockInsightsData));

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

await waitForNextUpdate();

expect(LmsApiService.generateAIAnalyticsSummary).toHaveBeenCalled();
expect(result.current).toEqual({
isLoading: false,
error,
data: null,
});
});

it('should not fetch data if enterpriseId or insights is missing', async () => {
const { result } = renderHook(() => useAIAnalyticsSummary(TEST_ENTERPRISE_ID));

expect(result.current).toEqual({
isLoading: false,
data: null,
error: null,
});
});
});
118 changes: 118 additions & 0 deletions src/components/Admin/AIAnalyticsSummary.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import {
Button, Card, Stack, Badge, useToggle,
} from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { AutoFixHigh, Groups } from '@edx/paragon/icons';
import useAIAnalyticsSummary from '../AIAnalyticsSummary/data/hooks';

const AnalyticsDetailCard = ({
onClose,
isLoading,
error,
data,
}) => (
<Card className="mt-3 mb-4" isLoading={isLoading}>
<Card.Section>
<Badge variant="light" className="mb-3 font-weight-semibold">
<FormattedMessage id="adminPortal.analyticsCardBetaButton" defaultMessage="Beta" />
</Badge>
<Stack gap={1} direction="horizontal">
<p className="card-text text-justify small">
<FormattedMessage
id="adminPortal.analyticsCardText"
defaultMessage={
error
? `An error occurred: ${error.message}`
: data || 'Analytics not found.'
}
/>
</p>
<Button variant="link" className="mb-4 ml-3" onClick={onClose}>
<span className="small font-weight-bold text-gray-800">Dismiss</span>
</Button>
</Stack>
<label className="x-small" htmlFor="poweredBylabel">
<FormattedMessage id="adminPortal.analyticsCardPoweredBylabel" defaultMessage="Powered by OpenAI" />
</label>
</Card.Section>
</Card>
);

AnalyticsDetailCard.propTypes = {
onClose: PropTypes.func.isRequired,
isLoading: PropTypes.bool,
error: PropTypes.instanceOf(Error),
data: PropTypes.string,
};

const AIAnalyticsSummary = ({ enterpriseId, insights }) => {
const [summarizeCardIsOpen, showSummarizeCard, hideSummarizeCard] = useToggle(false);
const [trackProgressCardIsOpen, showTrackProgressCard, hideTrackProgressCard] = useToggle(false);

const { data: analyticsSummary, isLoading, error } = useAIAnalyticsSummary(enterpriseId, insights);

return (
<>
<Stack gap={3} direction="horizontal">
<Button
variant="outline-primary"
className="d-sm-inline"
onClick={() => {
showSummarizeCard(true);
hideTrackProgressCard(true);
}}
data-testid="summarize-analytics"
>
<>
<AutoFixHigh className="mr-2" />
<FormattedMessage id="adminPortal.summarizeAnalytics" defaultMessage="Summarize Analytics" />
</>
</Button>
<Button
variant="outline-primary"
className="d-sm-inline"
onClick={() => {
showTrackProgressCard(true);
hideSummarizeCard(true);
}}
data-testid="track-progress"
>
<>
<Groups className="mr-2" />
<FormattedMessage id="adminPortal.trackProgress" defaultMessage="Track Progress" />
</>
</Button>
</Stack>
{summarizeCardIsOpen && (
<AnalyticsDetailCard
data={analyticsSummary?.learner_engagement}
onClose={() => hideSummarizeCard(true)}
isLoading={isLoading}
error={error}
/>
)}
{trackProgressCardIsOpen && (
<AnalyticsDetailCard
data={analyticsSummary?.learner_progress}
onClose={() => hideTrackProgressCard(true)}
isLoading={isLoading}
error={error}
/>
)}
</>
);
};

const mapStateToProps = state => ({
insights: state.dashboardInsights.insights,
});

AIAnalyticsSummary.propTypes = {
enterpriseId: PropTypes.string.isRequired,
insights: PropTypes.objectOf(PropTypes.shape),
};

export default connect(mapStateToProps)(AIAnalyticsSummary);
110 changes: 110 additions & 0 deletions src/components/Admin/AIAnalyticsSummary.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import configureMockStore from 'redux-mock-store';
import { MemoryRouter } from 'react-router-dom';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import thunk from 'redux-thunk';
import AIAnalyticsSummary from './AIAnalyticsSummary';

const mockedInsights = {
learner_progress: {
enterprise_customer_uuid: 'aac56d39-f38d-4510-8ef9-085cab048ea9',
enterprise_customer_name: 'Microsoft Corporation',
active_subscription_plan: true,
assigned_licenses: 0,
activated_licenses: 0,
assigned_licenses_percentage: 0.0,
activated_licenses_percentage: 0.0,
active_enrollments: 1026,
at_risk_enrollment_less_than_one_hour: 26,
at_risk_enrollment_end_date_soon: 15,
at_risk_enrollment_dormant: 918,
created_at: '2023-10-02T03:24:17Z',
},
learner_engagement: {
enterprise_customer_uuid: 'aac56d39-f38d-4510-8ef9-085cab048ea9',
enterprise_customer_name: 'Microsoft Corporation',
enrolls: 49,
enrolls_prior: 45,
passed: 2,
passed_prior: 0,
engage: 67,
engage_prior: 50,
hours: 62,
hours_prior: 49,
contract_end_date: '2022-06-13T00:00:00Z',
active_contract: false,
created_at: '2023-10-02T03:24:40Z',
},
};
const mockStore = configureMockStore([thunk]);
const store = mockStore({
portalConfiguration: {
enterpriseId: 'test-enterprise-id',
},
dashboardInsights: mockedInsights,
});

const AIAnalyticsSummaryWrapper = props => (
<MemoryRouter>
<Provider store={store}>
<IntlProvider locale="en">
<AIAnalyticsSummary
enterpriseId="test-enterprise-id"
insights={mockedInsights}
{...props}
/>,
</IntlProvider>
</Provider>
</MemoryRouter>
);

describe('<AIAnalyticsSummary />', () => {
it('should render action buttons correctly', () => {
const tree = renderer
.create((
<AIAnalyticsSummaryWrapper
insights={mockedInsights}
/>
))
.toJSON();

expect(tree).toMatchSnapshot();
});

it('should display AnalyticsDetailCard with learner_engagement data when Summarize Analytics button is clicked', () => {
const wrapper = mount(<AIAnalyticsSummaryWrapper insights={mockedInsights} />);
wrapper.find('[data-testid="summarize-analytics"]').first().simulate('click');

const tree = renderer
.create(<AIAnalyticsSummaryWrapper insights={mockedInsights} />)
.toJSON();

expect(tree).toMatchSnapshot();
});

it('should display AnalyticsDetailCard with learner_progress data when Track Progress button is clicked', () => {
const wrapper = mount(<AIAnalyticsSummaryWrapper insights={mockedInsights} />);
wrapper.find('[data-testid="track-progress"]').first().simulate('click');

const tree = renderer
.create(<AIAnalyticsSummaryWrapper insights={mockedInsights} />)
.toJSON();

expect(tree).toMatchSnapshot();
});

it('should handle null analytics data', () => {
const insightsData = { ...mockedInsights, learner_engagement: null };
const wrapper = mount(<AIAnalyticsSummaryWrapper insights={insightsData} />);
wrapper.find('[data-testid="summarize-analytics"]').first().simulate('click');

const tree = renderer
.create(<AIAnalyticsSummaryWrapper insights={insightsData} />)
.toJSON();

expect(tree).toMatchSnapshot();
});
});
11 changes: 11 additions & 0 deletions src/components/Admin/AIAnalyticsSummarySkeleton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';
import { Skeleton, Stack } from '@edx/paragon';

const AIAnalyticsSummarySkeleton = () => (
<Stack direction="horizontal" gap={2}>
<Skeleton height={40} width={250} />
<Skeleton height={40} width={250} />
</Stack>
);

export default AIAnalyticsSummarySkeleton;
Loading

0 comments on commit 74c47c8

Please sign in to comment.