-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #149 from edx/dsheraz/PROD-2477
feat: add feature based enrollment components
- Loading branch information
Showing
13 changed files
with
639 additions
and
5 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,67 @@ | ||
import React, { | ||
useEffect, | ||
useContext, | ||
useState, | ||
} from 'react'; | ||
import { camelCaseObject } from '@edx/frontend-platform'; | ||
import { Row, Col } from '@edx/paragon'; | ||
import PropTypes from 'prop-types'; | ||
|
||
import UserMessagesContext from '../userMessages/UserMessagesContext'; | ||
import AlertList from '../userMessages/AlertList'; | ||
import getFeatureBasedEnrollmentDetails from './data/api'; | ||
import PageLoading from '../components/common/PageLoading'; | ||
import FeatureBasedEnrollmentCard from './FeatureBasedEnrollmentCard'; | ||
|
||
export default function FeatureBasedEnrollment({ courseId }) { | ||
const [fbeData, setFBEData] = useState(undefined); | ||
const { add, clear } = useContext(UserMessagesContext); | ||
|
||
useEffect(() => { | ||
clear('featureBasedEnrollment'); | ||
setFBEData(undefined); | ||
getFeatureBasedEnrollmentDetails(courseId).then((response) => { | ||
const camelCaseResponse = camelCaseObject(response); | ||
if (camelCaseResponse.errors) { | ||
camelCaseResponse.errors.forEach(error => add(error)); | ||
} else { | ||
setFBEData(camelCaseResponse); | ||
} | ||
}); | ||
}, [courseId]); | ||
|
||
return ( | ||
<section className="mb-3"> | ||
<AlertList topic="featureBasedEnrollment" className="mb-3" /> | ||
<h3 id="fbe-title-header" className="my-4">Feature Based Enrollment Configuration</h3> | ||
|
||
{/* eslint-disable-next-line no-nested-ternary */} | ||
{fbeData | ||
? ( | ||
(Object.keys(fbeData).length > 0 | ||
? ( | ||
<> | ||
<h4 className="my-4">Course Title: {fbeData.courseName}</h4> | ||
<Row> | ||
<Col> | ||
<FeatureBasedEnrollmentCard title="Content Type Gating" fbeData={fbeData.gatingConfig} /> | ||
</Col> | ||
<Col> | ||
<FeatureBasedEnrollmentCard title="Duration Config" fbeData={fbeData.durationConfig} /> | ||
</Col> | ||
</Row> | ||
</> | ||
) | ||
: (<p className="my-4">No Feature Based Enrollment Configurations were found.</p>) | ||
) | ||
) | ||
: ( | ||
<PageLoading srMessage="Loading" /> | ||
)} | ||
</section> | ||
); | ||
} | ||
|
||
FeatureBasedEnrollment.propTypes = { | ||
courseId: PropTypes.string.isRequired, | ||
}; |
85 changes: 85 additions & 0 deletions
85
src/FeatureBasedEnrollments/FeatureBasedEnrollment.test.jsx
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,85 @@ | ||
import { mount } from 'enzyme'; | ||
import React from 'react'; | ||
import { waitForComponentToPaint } from '../setupTest'; | ||
import FeatureBasedEnrollment from './FeatureBasedEnrollment'; | ||
import UserMessagesProvider from '../userMessages/UserMessagesProvider'; | ||
import { fbeEnabledResponse } from './data/test/featureBasedEnrollment'; | ||
|
||
import * as api from './data/api'; | ||
|
||
const FeatureBasedEnrollmentWrapper = (props) => ( | ||
<UserMessagesProvider> | ||
<FeatureBasedEnrollment {...props} /> | ||
</UserMessagesProvider> | ||
); | ||
|
||
describe('Feature Based Enrollment', () => { | ||
const props = { | ||
courseId: 'course-v1:testX+test123+2030', | ||
}; | ||
|
||
let wrapper; | ||
|
||
beforeEach(async () => { | ||
// api file has only one default export, so that will be spied-on | ||
jest.spyOn(api, 'default').mockImplementationOnce(() => Promise.resolve(fbeEnabledResponse)); | ||
wrapper = mount(<FeatureBasedEnrollmentWrapper {...props} />); | ||
await waitForComponentToPaint(wrapper); | ||
}); | ||
|
||
afterEach(() => { | ||
wrapper.unmount(); | ||
}); | ||
|
||
it('default props', () => { | ||
const courseId = wrapper.prop('courseId'); | ||
expect(courseId).toEqual(props.courseId); | ||
}); | ||
|
||
it('Successful fetch for FBE data', async () => { | ||
const cardList = wrapper.find('Card'); | ||
const courseTitle = wrapper.find('h4'); | ||
|
||
expect(cardList).toHaveLength(2); | ||
expect(wrapper.find('h3#fbe-title-header').text()).toEqual('Feature Based Enrollment Configuration'); | ||
expect(courseTitle.text()).toEqual('Course Title: test course'); | ||
}); | ||
|
||
it('No FBE Data', async () => { | ||
jest.spyOn(api, 'default').mockImplementationOnce(() => Promise.resolve({})); | ||
wrapper = mount(<FeatureBasedEnrollmentWrapper {...props} />); | ||
await waitForComponentToPaint(wrapper); | ||
|
||
const cardList = wrapper.find('Card'); | ||
const noRecordMessage = wrapper.find('p'); | ||
|
||
expect(cardList).toHaveLength(0); | ||
expect(wrapper.find('h3#fbe-title-header').text()).toEqual('Feature Based Enrollment Configuration'); | ||
expect(noRecordMessage.text()).toEqual('No Feature Based Enrollment Configurations were found.'); | ||
}); | ||
|
||
it('Page Loading component render', async () => { | ||
wrapper = mount(<FeatureBasedEnrollmentWrapper {...props} />); | ||
expect(wrapper.find('PageLoading').html()).toEqual(expect.stringContaining('Loading')); | ||
}); | ||
|
||
it('Error fetching FBE data', async () => { | ||
const fbeErrors = { | ||
errors: [ | ||
{ | ||
code: null, | ||
dismissible: true, | ||
text: 'Error fetching FBE Data', | ||
type: 'error', | ||
topic: 'featureBasedEnrollment', | ||
}, | ||
], | ||
}; | ||
jest.spyOn(api, 'default').mockImplementationOnce(() => Promise.resolve(fbeErrors)); | ||
wrapper = mount(<FeatureBasedEnrollmentWrapper {...props} />); | ||
await waitForComponentToPaint(wrapper); | ||
|
||
const alert = wrapper.find('.alert'); | ||
expect(alert.text()).toEqual('Error fetching FBE Data'); | ||
}); | ||
}); |
44 changes: 44 additions & 0 deletions
44
src/FeatureBasedEnrollments/FeatureBasedEnrollmentCard.jsx
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,44 @@ | ||
import React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { | ||
Card, Badge, | ||
} from '@edx/paragon'; | ||
import { formatDate } from '../utils'; | ||
|
||
export default function FeatureBasedEnrollmentCard({ title, fbeData }) { | ||
return ( | ||
<Card className="px-3 mb-1"> | ||
<Card.Body className="p-0"> | ||
<Card.Title as="h3" className="btn-header mt-4"> | ||
{title} { fbeData.enabled ? <Badge variant="success">Enabled</Badge> : <Badge variant="danger">Disabled</Badge> } | ||
</Card.Title> | ||
|
||
<table className="fbe-table"> | ||
<tbody> | ||
|
||
<tr> | ||
<th>Enabled As Of</th> | ||
<td>{fbeData.enabledAsOf !== 'N/A' ? formatDate(fbeData.enabledAsOf) : fbeData.enabledAsOf}</td> | ||
</tr> | ||
|
||
<tr> | ||
<th>Reason</th> | ||
<td>{fbeData.reason}</td> | ||
</tr> | ||
|
||
</tbody> | ||
|
||
</table> | ||
</Card.Body> | ||
</Card> | ||
); | ||
} | ||
|
||
FeatureBasedEnrollmentCard.propTypes = { | ||
title: PropTypes.string.isRequired, | ||
fbeData: PropTypes.shape({ | ||
enabled: PropTypes.bool.isRequired, | ||
reason: PropTypes.string.isRequired, | ||
enabledAsOf: PropTypes.string.isRequired, | ||
}).isRequired, | ||
}; |
85 changes: 85 additions & 0 deletions
85
src/FeatureBasedEnrollments/FeatureBasedEnrollmentCard.test.jsx
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,85 @@ | ||
import { mount } from 'enzyme'; | ||
import React from 'react'; | ||
import FeatureBasedEnrollmentCard from './FeatureBasedEnrollmentCard'; | ||
import { | ||
fbeDurationConfigEnabled, | ||
fbeDurationConfigDisabled, | ||
fbeGatingConfigEnabled, | ||
fbeGatingConfigDisabled, | ||
} from './data/test/featureBasedEnrollment'; | ||
|
||
describe('Feature Based Enrollment Card Component', () => { | ||
let wrapper; | ||
|
||
afterEach(() => { | ||
wrapper.unmount(); | ||
}); | ||
|
||
describe('Gating config', () => { | ||
const title = 'Gating Config'; | ||
|
||
it('Gating config enabled', () => { | ||
wrapper = mount(<FeatureBasedEnrollmentCard title={title} fbeData={fbeGatingConfigEnabled} />); | ||
const header = wrapper.find('h3.card-title'); | ||
const dataTable = wrapper.find('table.fbe-table tr'); | ||
const dateRow = dataTable.at(0); | ||
const reasonRow = dataTable.at(1); | ||
|
||
expect(header.text()).toEqual('Gating Config Enabled'); | ||
expect(dateRow.find('th').at(0).text()).toEqual('Enabled As Of'); | ||
expect(dateRow.find('td').at(0).text()).toEqual('Jan 1, 2020 12:00 AM'); | ||
|
||
expect(reasonRow.find('th').at(0).text()).toEqual('Reason'); | ||
expect(reasonRow.find('td').at(0).text()).toEqual('Site'); | ||
}); | ||
|
||
it('Gating config disabled', () => { | ||
wrapper = mount(<FeatureBasedEnrollmentCard title={title} fbeData={fbeGatingConfigDisabled} />); | ||
const header = wrapper.find('h3.card-title'); | ||
const dataTable = wrapper.find('table.fbe-table tr'); | ||
const dateRow = dataTable.at(0); | ||
const reasonRow = dataTable.at(1); | ||
|
||
expect(header.text()).toEqual('Gating Config Disabled'); | ||
expect(dateRow.find('th').at(0).text()).toEqual('Enabled As Of'); | ||
expect(dateRow.find('td').at(0).text()).toEqual('N/A'); | ||
|
||
expect(reasonRow.find('th').at(0).text()).toEqual('Reason'); | ||
expect(reasonRow.find('td').at(0).text()).toEqual(''); | ||
}); | ||
}); | ||
|
||
describe('Duration config', () => { | ||
const title = 'Duration Config'; | ||
|
||
it('Duration config enabled', () => { | ||
wrapper = mount(<FeatureBasedEnrollmentCard title={title} fbeData={fbeDurationConfigEnabled} />); | ||
const header = wrapper.find('h3.card-title'); | ||
const dataTable = wrapper.find('table.fbe-table tr'); | ||
const dateRow = dataTable.at(0); | ||
const reasonRow = dataTable.at(1); | ||
|
||
expect(header.text()).toEqual('Duration Config Enabled'); | ||
expect(dateRow.find('th').at(0).text()).toEqual('Enabled As Of'); | ||
expect(dateRow.find('td').at(0).text()).toEqual('Feb 1, 2020 12:00 AM'); | ||
|
||
expect(reasonRow.find('th').at(0).text()).toEqual('Reason'); | ||
expect(reasonRow.find('td').at(0).text()).toEqual('Site Config'); | ||
}); | ||
|
||
it('Duration config disabled', () => { | ||
wrapper = mount(<FeatureBasedEnrollmentCard title={title} fbeData={fbeDurationConfigDisabled} />); | ||
const header = wrapper.find('h3.card-title'); | ||
const dataTable = wrapper.find('table.fbe-table tr'); | ||
const dateRow = dataTable.at(0); | ||
const reasonRow = dataTable.at(1); | ||
|
||
expect(header.text()).toEqual('Duration Config Disabled'); | ||
expect(dateRow.find('th').at(0).text()).toEqual('Enabled As Of'); | ||
expect(dateRow.find('td').at(0).text()).toEqual('N/A'); | ||
|
||
expect(reasonRow.find('th').at(0).text()).toEqual('Reason'); | ||
expect(reasonRow.find('td').at(0).text()).toEqual(''); | ||
}); | ||
}); | ||
}); |
111 changes: 111 additions & 0 deletions
111
src/FeatureBasedEnrollments/FeatureBasedEnrollmentIndexPage.jsx
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,111 @@ | ||
import { history } from '@edx/frontend-platform'; | ||
import React, { | ||
useRef, | ||
useEffect, | ||
useCallback, | ||
useContext, | ||
useState, | ||
} from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { Link } from 'react-router-dom'; | ||
import { Input, Button } from '@edx/paragon'; | ||
|
||
import UserMessagesContext from '../userMessages/UserMessagesContext'; | ||
import AlertList from '../userMessages/AlertList'; | ||
import { isValidCourseID } from '../utils'; | ||
import FeatureBasedEnrollment from './FeatureBasedEnrollment'; | ||
|
||
export default function FeatureBasedEnrollmentIndexPage({ location }) { | ||
const params = new Map( | ||
location.search | ||
.slice(1) // removes '?' mark from start | ||
.split('&') | ||
.map(queryParams => queryParams.split('=')), | ||
); | ||
|
||
const searchRef = useRef(); | ||
const { add, clear } = useContext(UserMessagesContext); | ||
const [searchValue, setSearchValue] = useState(params.get('course_id') || undefined); | ||
|
||
if (params.has('course_id')) { | ||
const courseId = params.get('course_id'); | ||
params.set('course_id', decodeURIComponent(courseId)); | ||
} | ||
|
||
function pushHistoryIfChanged(nextUrl) { | ||
if (nextUrl !== location.pathname + location.search) { | ||
history.push(nextUrl); | ||
} | ||
} | ||
|
||
function validateInput(inputValue) { | ||
if (!isValidCourseID(inputValue)) { | ||
add({ | ||
code: null, | ||
dismissible: false, | ||
text: `Supplied course ID "${inputValue}" is either invalid or incorrect.`, | ||
type: 'error', | ||
topic: 'featureBasedEnrollmentGeneral', | ||
}); | ||
history.replace('/feature_based_enrollments'); | ||
return false; | ||
} | ||
return true; | ||
} | ||
|
||
const handleSearchInput = useCallback((inputValue) => { | ||
clear('featureBasedEnrollmentGeneral'); | ||
if (inputValue !== undefined && inputValue !== '') { | ||
if (!validateInput(inputValue)) { | ||
return; | ||
} | ||
setSearchValue(inputValue); | ||
pushHistoryIfChanged(`/feature_based_enrollments/?course_id=${inputValue}`); | ||
} else if (inputValue === '') { | ||
history.replace('/feature_based_enrollments'); | ||
} | ||
}); | ||
|
||
const submit = useCallback((event) => { | ||
const inputValue = searchRef.current.value; | ||
setSearchValue(undefined); | ||
event.preventDefault(); | ||
handleSearchInput(inputValue); | ||
return false; | ||
}); | ||
|
||
// Run this once when the user re-loads the page with course_id query param present in url | ||
useEffect(() => { | ||
if (params.get('course_id')) { | ||
handleSearchInput(params.get('course_id')); | ||
} | ||
}, []); | ||
|
||
return ( | ||
<main className="ml-5 mr-5 mt-3 mb-5"> | ||
|
||
<section className="mb-3"> | ||
<Link to="/">< Back to Tools</Link> | ||
</section> | ||
|
||
<AlertList topic="featureBasedEnrollmentGeneral" className="mb-3" /> | ||
|
||
<section className="mb-3"> | ||
<form className="form-inline"> | ||
<label htmlFor="courseId">Course ID</label> | ||
<Input ref={searchRef} className="flex-grow-1 mr-1" name="courseId" type="text" defaultValue={searchValue} /> | ||
<Button type="submit" onClick={submit} variant="primary">Search</Button> | ||
</form> | ||
</section> | ||
|
||
{searchValue && <FeatureBasedEnrollment courseId={searchValue} />} | ||
</main> | ||
); | ||
} | ||
|
||
FeatureBasedEnrollmentIndexPage.propTypes = { | ||
location: PropTypes.shape({ | ||
pathname: PropTypes.string, | ||
search: PropTypes.string, | ||
}).isRequired, | ||
}; |
Oops, something went wrong.