-
Notifications
You must be signed in to change notification settings - Fork 32
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: display AI analytics section on LPR page
- Loading branch information
1 parent
6a47c98
commit 814066d
Showing
19 changed files
with
2,988 additions
and
198 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.