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: display AI analytics section on LPR page #1051

Merged
merged 1 commit into from
Oct 11, 2023
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
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);
mahamakifdar19 marked this conversation as resolved.
Show resolved Hide resolved

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}`

Check warning on line 28 in src/components/Admin/AIAnalyticsSummary.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Admin/AIAnalyticsSummary.jsx#L28

Added line #L28 was not covered by tests
: 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)}

Check warning on line 92 in src/components/Admin/AIAnalyticsSummary.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Admin/AIAnalyticsSummary.jsx#L92

Added line #L92 was not covered by tests
isLoading={isLoading}
error={error}
/>
)}
{trackProgressCardIsOpen && (
<AnalyticsDetailCard
data={analyticsSummary?.learner_progress}
onClose={() => hideTrackProgressCard(true)}

Check warning on line 100 in src/components/Admin/AIAnalyticsSummary.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Admin/AIAnalyticsSummary.jsx#L100

Added line #L100 was not covered by tests
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}>

Check warning on line 5 in src/components/Admin/AIAnalyticsSummarySkeleton.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/Admin/AIAnalyticsSummarySkeleton.jsx#L5

Added line #L5 was not covered by tests
<Skeleton height={40} width={250} />
<Skeleton height={40} width={250} />
</Stack>
jajjibhai008 marked this conversation as resolved.
Show resolved Hide resolved
);

export default AIAnalyticsSummarySkeleton;
Loading