Compare commits
12 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43579321b | ||
|
|
4aed4dee15 | ||
|
|
0189e919a6 | ||
|
|
65e431cebe | ||
|
|
af4e25b39f | ||
|
|
0589912714 | ||
|
|
4ff3684772 | ||
|
|
0ae7aa0265 | ||
|
|
dc7bb9fe04 | ||
|
|
8abcbe0385 | ||
|
|
455656265e | ||
|
|
8518933970 |
1
.env
1
.env
@@ -34,6 +34,7 @@ ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
HOTJAR_APP_ID=''
|
||||
HOTJAR_VERSION=6
|
||||
|
||||
@@ -35,6 +35,7 @@ ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_UNIT_PAGE=false
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
|
||||
@@ -31,6 +31,7 @@ ENABLE_TEAM_TYPE_SETTING=false
|
||||
ENABLE_UNIT_PAGE=true
|
||||
ENABLE_ASSETS_PAGE=false
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
|
||||
ENABLE_CERTIFICATE_PAGE=true
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES=true
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -18,7 +18,7 @@
|
||||
"@edx/frontend-component-footer": "^13.0.2",
|
||||
"@edx/frontend-component-header": "^5.1.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^2.5.1",
|
||||
"@edx/frontend-lib-content-components": "2.5.3",
|
||||
"@edx/frontend-platform": "7.0.1",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
@@ -2580,9 +2580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-lib-content-components": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.5.1.tgz",
|
||||
"integrity": "sha512-qxSGCF6GxDaeszW/f3ikf8V0j9yR6r1sohV15fHURrNvV0JPF860EIVgbPHR1FKjL1Iwo16Q/KWc0uY+HVwBYA==",
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.5.3.tgz",
|
||||
"integrity": "sha512-B9/UlnDBhMUjvosvsZ0a8Kga/DdWA6PpNOHi2r0w+xc2U6jWKzpO/ZFdNQMWv6ZVIRbSAIpKy2swSDiCWXskxw==",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@edx/frontend-component-footer": "^13.0.2",
|
||||
"@edx/frontend-component-header": "^5.1.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "^2.5.1",
|
||||
"@edx/frontend-lib-content-components": "2.5.3",
|
||||
"@edx/frontend-platform": "7.0.1",
|
||||
"@edx/openedx-atlas": "^0.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
|
||||
@@ -124,7 +124,7 @@ const CourseAuthoringRoutes = () => {
|
||||
/>
|
||||
<Route
|
||||
path="certificates"
|
||||
element={<PageWrap><Certificates courseId={courseId} /></PageWrap>}
|
||||
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
|
||||
/>
|
||||
<Route
|
||||
path="textbooks"
|
||||
|
||||
@@ -79,3 +79,7 @@
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@@ -459,6 +459,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
onConfigureSubmit={handleConfigureItemSubmit}
|
||||
currentItemData={currentItemData}
|
||||
enableProctoredExams={enableProctoredExams}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
/>
|
||||
<DeleteModal
|
||||
category={deleteCategory}
|
||||
|
||||
@@ -1409,6 +1409,7 @@ describe('<CourseOutline />', () => {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
visible_to_staff_only: isVisibleToStaffOnly,
|
||||
discussion_enabled: false,
|
||||
group_access: newGroupAccess,
|
||||
},
|
||||
})
|
||||
@@ -1427,6 +1428,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// after configuraiton response
|
||||
unit.visibilityState = 'staff_only';
|
||||
unit.discussion_enabled = false;
|
||||
unit.userPartitionInfo = {
|
||||
selectablePartitions: [
|
||||
{
|
||||
@@ -1469,6 +1471,11 @@ describe('<CourseOutline />', () => {
|
||||
)).toBeInTheDocument();
|
||||
let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||
await act(async () => fireEvent.click(visibilityCheckbox));
|
||||
let discussionCheckbox = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.discussionEnabledCheckbox.defaultMessage,
|
||||
);
|
||||
expect(discussionCheckbox).toBeChecked();
|
||||
await act(async () => fireEvent.click(discussionCheckbox));
|
||||
|
||||
let groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||
fireEvent.change(groupeType, { target: { value: '0' } });
|
||||
@@ -1485,6 +1492,10 @@ describe('<CourseOutline />', () => {
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||
expect(visibilityCheckbox).toBeChecked();
|
||||
discussionCheckbox = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.discussionEnabledCheckbox.defaultMessage,
|
||||
);
|
||||
expect(discussionCheckbox).not.toBeChecked();
|
||||
|
||||
groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||
expect(groupeType).toHaveValue('0');
|
||||
|
||||
@@ -311,7 +311,7 @@ export async function configureCourseSubsection(
|
||||
* @param {object} groupAccess
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess) {
|
||||
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(unitId), {
|
||||
publish: 'republish',
|
||||
@@ -319,6 +319,7 @@ export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAcc
|
||||
// The backend expects metadata.visible_to_staff_only to either true or null
|
||||
visible_to_staff_only: isVisibleToStaffOnly ? true : null,
|
||||
group_access: groupAccess,
|
||||
discussion_enabled: discussionEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -334,11 +334,11 @@ export function configureCourseSubsectionQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess) {
|
||||
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess),
|
||||
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess, discussionEnabled),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const HighlightsModal = ({
|
||||
onClose={onClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header className="highlights-modal__header">
|
||||
<ModalDialog.Title>
|
||||
|
||||
@@ -30,6 +30,7 @@ const PublishModal = ({
|
||||
onClose={onClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header className="publish-modal__header">
|
||||
<ModalDialog.Title>
|
||||
|
||||
@@ -53,3 +53,7 @@ export const VisibilityTypes = /** @type {const} */ ({
|
||||
UNSCHEDULED: 'unscheduled',
|
||||
NEEDS_ATTENTION: 'needs_attention',
|
||||
});
|
||||
|
||||
export const TOTAL_LENGTH_KEY = 'total-length';
|
||||
|
||||
export const MAX_TOTAL_LENGTH = 65;
|
||||
|
||||
@@ -32,6 +32,7 @@ const FileValidationModal = ({
|
||||
title={intl.formatMessage(messages.overwriteModalTitle)}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
|
||||
@@ -47,12 +47,12 @@ const messages = defineMessages({
|
||||
description: 'Label for lock file checkbox in info modal',
|
||||
},
|
||||
activeCheckboxLabel: {
|
||||
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.activeCheckbox.label',
|
||||
id: 'course-authoring.files-and-videos.file-info.activeCheckbox.label',
|
||||
defaultMessage: 'Active',
|
||||
description: 'Label for active checkbox in filter section of sort and filter modal',
|
||||
},
|
||||
inactiveCheckboxLabel: {
|
||||
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.inactiveCheckbox.label',
|
||||
id: 'course-authoring.files-and-videos.file-info.inactiveCheckbox.label',
|
||||
defaultMessage: 'Inactive',
|
||||
description: 'Label for inactive checkbox in filter section of sort and filter modal',
|
||||
},
|
||||
@@ -111,6 +111,15 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'The message displayed in the button to confirm cancelling the upload',
|
||||
},
|
||||
lockFileTooltipContent: {
|
||||
id: 'course-authoring.files-and-uploads.file-info.lockFile.tooltip.content',
|
||||
defaultMessage: `By default, anyone can access a file you upload if
|
||||
they know the web URL, even if they are not enrolled in your course.
|
||||
You can prevent outside access to a file by locking the file. When
|
||||
you lock a file, the web URL only allows learners who are enrolled
|
||||
in your course and signed in to access the file.`,
|
||||
description: 'Tooltip message for the lock icon in the table view of files',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -10,6 +10,7 @@ const BasicTab = ({
|
||||
setFieldValue,
|
||||
courseGraders,
|
||||
isSubsection,
|
||||
isSelfPaced,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -27,26 +28,30 @@ const BasicTab = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.releaseDateAndTime} /></h5>
|
||||
<hr />
|
||||
<div data-testid="release-date-stack">
|
||||
<Stack className="mt-3" direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseDate)}
|
||||
controlName="state-date"
|
||||
onChange={(val) => setFieldValue('releaseDate', val)}
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={(val) => setFieldValue('releaseDate', val)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
{!isSelfPaced && (
|
||||
<>
|
||||
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.releaseDateAndTime} /></h5>
|
||||
<hr />
|
||||
<div data-testid="release-date-stack">
|
||||
<Stack className="mt-3" direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseDate)}
|
||||
controlName="state-date"
|
||||
onChange={(val) => setFieldValue('releaseDate', val)}
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={releaseDate}
|
||||
label={intl.formatMessage(messages.releaseTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={(val) => setFieldValue('releaseDate', val)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{
|
||||
isSubsection && (
|
||||
<div>
|
||||
@@ -66,25 +71,27 @@ const BasicTab = ({
|
||||
{createOptions()}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
<div data-testid="due-date-stack">
|
||||
<Stack className="mt-3" direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={dueDate}
|
||||
label={intl.formatMessage(messages.dueDate)}
|
||||
controlName="state-date"
|
||||
onChange={(val) => setFieldValue('dueDate', val)}
|
||||
data-testid="due-date-picker"
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={dueDate}
|
||||
label={intl.formatMessage(messages.dueTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={(val) => setFieldValue('dueDate', val)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
{!isSelfPaced && (
|
||||
<div data-testid="due-date-stack">
|
||||
<Stack className="mt-3" direction="horizontal" gap={5}>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.date}
|
||||
value={dueDate}
|
||||
label={intl.formatMessage(messages.dueDate)}
|
||||
controlName="state-date"
|
||||
onChange={(val) => setFieldValue('dueDate', val)}
|
||||
data-testid="due-date-picker"
|
||||
/>
|
||||
<DatepickerControl
|
||||
type={DATEPICKER_TYPES.time}
|
||||
value={dueDate}
|
||||
label={intl.formatMessage(messages.dueTimeUTC)}
|
||||
controlName="start-time"
|
||||
onChange={(val) => setFieldValue('dueDate', val)}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -101,6 +108,7 @@ BasicTab.propTypes = {
|
||||
}).isRequired,
|
||||
courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
setFieldValue: PropTypes.func.isRequired,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BasicTab);
|
||||
|
||||
@@ -28,6 +28,7 @@ const ConfigureModal = ({
|
||||
currentItemData,
|
||||
enableProctoredExams,
|
||||
isXBlockComponent,
|
||||
isSelfPaced,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
@@ -58,6 +59,7 @@ const ConfigureModal = ({
|
||||
supportsOnboarding,
|
||||
showReviewRules,
|
||||
onlineProctoringRules,
|
||||
discussionEnabled,
|
||||
} = currentItemData;
|
||||
|
||||
const getSelectedGroups = () => {
|
||||
@@ -98,6 +100,7 @@ const ConfigureModal = ({
|
||||
// by default it is -1 i.e. accessible to all learners & staff
|
||||
selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex,
|
||||
selectedGroups: getSelectedGroups(),
|
||||
discussionEnabled,
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
@@ -127,6 +130,7 @@ const ConfigureModal = ({
|
||||
).nullable(true),
|
||||
selectedPartitionIndex: Yup.number().integer(),
|
||||
selectedGroups: Yup.array().of(Yup.string()),
|
||||
discussionEnabled: Yup.boolean(),
|
||||
});
|
||||
|
||||
const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id;
|
||||
@@ -168,7 +172,7 @@ const ConfigureModal = ({
|
||||
const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id;
|
||||
groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10));
|
||||
}
|
||||
onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess);
|
||||
onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess, data.discussionEnabled);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -186,6 +190,7 @@ const ConfigureModal = ({
|
||||
setFieldValue={setFieldValue}
|
||||
isSubsection={isSubsection}
|
||||
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
|
||||
isSelfPaced={isSelfPaced}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
@@ -208,6 +213,7 @@ const ConfigureModal = ({
|
||||
setFieldValue={setFieldValue}
|
||||
isSubsection={isSubsection}
|
||||
courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
|
||||
isSelfPaced={isSelfPaced}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
|
||||
@@ -259,6 +265,7 @@ const ConfigureModal = ({
|
||||
onClose={onClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<div data-testid="configure-modal">
|
||||
<ModalDialog.Header className="configure-modal__header">
|
||||
@@ -358,8 +365,10 @@ ConfigureModal.propTypes = {
|
||||
supportsOnboarding: PropTypes.bool,
|
||||
showReviewRules: PropTypes.bool,
|
||||
onlineProctoringRules: PropTypes.string,
|
||||
discussionEnabled: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
isXBlockComponent: PropTypes.bool,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default ConfigureModal;
|
||||
|
||||
@@ -44,6 +44,7 @@ const renderComponent = () => render(
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
currentItemData={currentSectionMock}
|
||||
isSelfPaced={false}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
@@ -85,7 +86,7 @@ describe('<ConfigureModal /> for Section', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const renderSubsectionComponent = () => render(
|
||||
const renderSubsectionComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<ConfigureModal
|
||||
@@ -93,6 +94,8 @@ const renderSubsectionComponent = () => render(
|
||||
onClose={onCloseMock}
|
||||
onConfigureSubmit={onConfigureSubmitMock}
|
||||
currentItemData={currentSubsectionMock}
|
||||
isSelfPaced={false}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
@@ -129,6 +132,14 @@ describe('<ConfigureModal /> for Subsection', () => {
|
||||
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides release and due dates for self paced courses', () => {
|
||||
const { queryByText } = renderSubsectionComponent({ isSelfPaced: true });
|
||||
expect(queryByText(messages.releaseDate.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(queryByText(messages.releaseTimeUTC.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(queryByText(messages.dueDate.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(queryByText(messages.dueTimeUTC.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to the subsection Visibility tab and renders correctly', () => {
|
||||
const { getByRole, getByText } = renderSubsectionComponent();
|
||||
|
||||
@@ -198,6 +209,7 @@ describe('<ConfigureModal /> for Unit', () => {
|
||||
expect(getByText(`${currentUnitMock.displayName} settings`)).toBeInTheDocument();
|
||||
expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.discussionEnabledCheckbox.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -21,12 +21,17 @@ const UnitTab = ({
|
||||
isVisibleToStaffOnly,
|
||||
selectedPartitionIndex,
|
||||
selectedGroups,
|
||||
discussionEnabled,
|
||||
} = values;
|
||||
|
||||
const handleChange = (e) => {
|
||||
const handleVisibilityChange = (e) => {
|
||||
setFieldValue('isVisibleToStaffOnly', e.target.checked);
|
||||
};
|
||||
|
||||
const handleDiscussionChange = (e) => {
|
||||
setFieldValue('discussionEnabled', e.target.checked);
|
||||
};
|
||||
|
||||
const handleSelect = (e) => {
|
||||
setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10));
|
||||
setFieldValue('selectedGroups', []);
|
||||
@@ -42,9 +47,9 @@ const UnitTab = ({
|
||||
<>
|
||||
{!isXBlockComponent && (
|
||||
<>
|
||||
<h3 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h3>
|
||||
<h4 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h4>
|
||||
<hr />
|
||||
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="unit-visibility-checkbox">
|
||||
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleVisibilityChange} data-testid="unit-visibility-checkbox">
|
||||
<FormattedMessage {...messages.hideFromLearners} />
|
||||
</Form.Checkbox>
|
||||
{showWarning && (
|
||||
@@ -52,77 +57,84 @@ const UnitTab = ({
|
||||
<FormattedMessage {...messages.unitVisibilityWarning} />
|
||||
</Alert>
|
||||
)}
|
||||
<hr />
|
||||
</>
|
||||
)}
|
||||
<Form.Group controlId="groupSelect">
|
||||
<Form.Label as="legend" className="font-weight-bold">
|
||||
<FormattedMessage {...messages.restrictAccessTo} />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
name="groupSelect"
|
||||
value={selectedPartitionIndex}
|
||||
onChange={handleSelect}
|
||||
data-testid="group-type-select"
|
||||
>
|
||||
<option value="-1" key="-1">
|
||||
{userPartitionInfo.selectedPartitionIndex === -1
|
||||
? intl.formatMessage(messages.unitSelectGroupType)
|
||||
: intl.formatMessage(messages.unitAllLearnersAndStaff)}
|
||||
</option>
|
||||
{userPartitionInfo.selectablePartitions.map((partition, index) => (
|
||||
<option
|
||||
key={partition.id}
|
||||
value={index}
|
||||
>
|
||||
{partition.name}
|
||||
{userPartitionInfo.selectablePartitions.length > 0 && (
|
||||
<Form.Group controlId="groupSelect">
|
||||
<h4 className="mt-3"><FormattedMessage {...messages.unitAccess} /></h4>
|
||||
<hr />
|
||||
<Form.Label as="legend" className="font-weight-bold">
|
||||
<FormattedMessage {...messages.restrictAccessTo} />
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
name="groupSelect"
|
||||
value={selectedPartitionIndex}
|
||||
onChange={handleSelect}
|
||||
data-testid="group-type-select"
|
||||
>
|
||||
<option value="-1" key="-1">
|
||||
{userPartitionInfo.selectedPartitionIndex === -1
|
||||
? intl.formatMessage(messages.unitSelectGroupType)
|
||||
: intl.formatMessage(messages.unitAllLearnersAndStaff)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
|
||||
{selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && (
|
||||
<Form.Group controlId="select-groups-checkboxes">
|
||||
<Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label>
|
||||
<div
|
||||
role="group"
|
||||
className="d-flex flex-column"
|
||||
data-testid="group-checkboxes"
|
||||
aria-labelledby="select-groups-checkboxes"
|
||||
>
|
||||
{userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => (
|
||||
<Form.Group
|
||||
key={group.id}
|
||||
className="pgn__form-checkbox"
|
||||
>
|
||||
<Field
|
||||
as={Form.Control}
|
||||
className="flex-grow-0 mr-1"
|
||||
controlClassName="pgn__form-checkbox-input mr-1"
|
||||
type="checkbox"
|
||||
value={`${group.id}`}
|
||||
name="selectedGroups"
|
||||
/>
|
||||
<div>
|
||||
<Form.Label
|
||||
className={classNames({ 'text-danger': checkIsDeletedGroup(group) })}
|
||||
isInline
|
||||
>
|
||||
{group.name}
|
||||
</Form.Label>
|
||||
{group.deleted && (
|
||||
<Form.Control.Feedback type="invalid" hasIcon={false}>
|
||||
{intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
))}
|
||||
</div>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Form.Group>
|
||||
|
||||
{userPartitionInfo.selectablePartitions.map((partition, index) => (
|
||||
<option
|
||||
key={partition.id}
|
||||
value={index}
|
||||
>
|
||||
{partition.name}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
{selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && (
|
||||
<Form.Group controlId="select-groups-checkboxes">
|
||||
<Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label>
|
||||
<div
|
||||
role="group"
|
||||
className="d-flex flex-column"
|
||||
data-testid="group-checkboxes"
|
||||
aria-labelledby="select-groups-checkboxes"
|
||||
>
|
||||
{userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => (
|
||||
<Form.Group
|
||||
key={group.id}
|
||||
className="pgn__form-checkbox"
|
||||
>
|
||||
<Field
|
||||
as={Form.Control}
|
||||
className="flex-grow-0 mr-1"
|
||||
controlClassName="pgn__form-checkbox-input mr-1"
|
||||
type="checkbox"
|
||||
value={`${group.id}`}
|
||||
name="selectedGroups"
|
||||
/>
|
||||
<div>
|
||||
<Form.Label
|
||||
className={classNames({ 'text-danger': checkIsDeletedGroup(group) })}
|
||||
isInline
|
||||
>
|
||||
{group.name}
|
||||
</Form.Label>
|
||||
{group.deleted && (
|
||||
<Form.Control.Feedback type="invalid" hasIcon={false}>
|
||||
{intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
))}
|
||||
</div>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Form.Group>
|
||||
)}
|
||||
<h4 className="mt-4"><FormattedMessage {...messages.discussionEnabledSectionTitle} /></h4>
|
||||
<hr />
|
||||
<Form.Checkbox checked={discussionEnabled} onChange={handleDiscussionChange}>
|
||||
<FormattedMessage {...messages.discussionEnabledCheckbox} />
|
||||
</Form.Checkbox>
|
||||
<p className="x-small font-weight-bold"><FormattedMessage {...messages.discussionEnabledDescription} /></p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -135,6 +147,7 @@ UnitTab.propTypes = {
|
||||
isXBlockComponent: PropTypes.bool,
|
||||
values: PropTypes.shape({
|
||||
isVisibleToStaffOnly: PropTypes.bool.isRequired,
|
||||
discussionEnabled: PropTypes.bool.isRequired,
|
||||
selectedPartitionIndex: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
|
||||
@@ -42,6 +42,22 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility',
|
||||
defaultMessage: 'Unit visibility',
|
||||
},
|
||||
unitAccess: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-access',
|
||||
defaultMessage: 'Unit access',
|
||||
},
|
||||
discussionEnabledSectionTitle: {
|
||||
id: 'course-authoring.course-outline.configure-modal.discussion-enabled.section-title',
|
||||
defaultMessage: 'Discussion',
|
||||
},
|
||||
discussionEnabledCheckbox: {
|
||||
id: 'course-authoring.course-outline.configure-modal.discussion-enabled.checkbox',
|
||||
defaultMessage: 'Enable discussion',
|
||||
},
|
||||
discussionEnabledDescription: {
|
||||
id: 'course-authoring.course-outline.configure-modal.discussion-enabled.description',
|
||||
defaultMessage: 'Topics for unpublished units will not be created',
|
||||
},
|
||||
hideFromLearners: {
|
||||
id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners',
|
||||
defaultMessage: 'Hide from learners',
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TypeaheadDropdown } from '@edx/frontend-lib-content-components';
|
||||
|
||||
import AlertMessage from '../alert-message';
|
||||
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { RequestStatus, TOTAL_LENGTH_KEY } from '../../data/constants';
|
||||
import { getSavingStatus } from '../data/selectors';
|
||||
import { getStudioHomeData } from '../../studio-home/data/selectors';
|
||||
import { updatePostErrors } from '../data/slice';
|
||||
@@ -132,6 +132,8 @@ const CreateOrRerunCourseForm = ({
|
||||
},
|
||||
];
|
||||
|
||||
const errorMessage = errors[TOTAL_LENGTH_KEY] || postErrors?.errMsg;
|
||||
|
||||
const createButtonState = {
|
||||
labels: {
|
||||
default: intl.formatMessage(isCreateNewCourse ? messages.createButton : messages.rerunCreateButton),
|
||||
@@ -202,11 +204,11 @@ const CreateOrRerunCourseForm = ({
|
||||
return (
|
||||
<div className="create-or-rerun-course-form">
|
||||
<TransitionReplace>
|
||||
{showErrorBanner ? (
|
||||
{(errors[TOTAL_LENGTH_KEY] || showErrorBanner) ? (
|
||||
<AlertMessage
|
||||
variant="danger"
|
||||
icon={InfoIcon}
|
||||
title={postErrors.errMsg}
|
||||
title={errorMessage}
|
||||
aria-hidden="true"
|
||||
aria-labelledby={intl.formatMessage(
|
||||
messages.alertErrorExistsAriaLabelledBy,
|
||||
|
||||
@@ -198,6 +198,30 @@ describe('<CreateOrRerunCourseForm />', () => {
|
||||
expect(rerunBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error message when total length exceeds 65 characters', async () => {
|
||||
const updatedProps = {
|
||||
...props,
|
||||
initialValues: {
|
||||
displayName: 'Long Title Course',
|
||||
org: 'long-org',
|
||||
number: 'number',
|
||||
run: '2024',
|
||||
},
|
||||
};
|
||||
|
||||
render(<RootWrapper {...updatedProps} />);
|
||||
await mockStore();
|
||||
const numberInput = screen.getByPlaceholderText(messages.courseNumberPlaceholder.defaultMessage);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(numberInput, { target: { value: 'long-name-which-is-longer-than-65-characters-to-check-for-errors' } });
|
||||
});
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.getByText(messages.totalLengthError)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should be disabled create button if form has error', async () => {
|
||||
render(<RootWrapper {...props} />);
|
||||
await mockStore();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { RequestStatus, MAX_TOTAL_LENGTH, TOTAL_LENGTH_KEY } from '../../data/constants';
|
||||
import { getStudioHomeData } from '../../studio-home/data/selectors';
|
||||
import {
|
||||
getRedirectUrlObj,
|
||||
@@ -58,6 +58,12 @@ const useCreateOrRerunCourse = (initialValues) => {
|
||||
intl.formatMessage(messages.disallowedCharsError),
|
||||
)
|
||||
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)),
|
||||
}).test(TOTAL_LENGTH_KEY, intl.formatMessage(messages.totalLengthError), function validateTotalLength() {
|
||||
const { org, number, run } = this?.options.originalValue || {};
|
||||
if ((org?.length || 0) + (number?.length || 0) + (run?.length || 0) > MAX_TOTAL_LENGTH) {
|
||||
return this.createError({ path: TOTAL_LENGTH_KEY, message: intl.formatMessage(messages.totalLengthError) });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -76,7 +82,11 @@ const useCreateOrRerunCourse = (initialValues) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFormFilled(Object.values(values).every((i) => i));
|
||||
setFormFilled(
|
||||
Object.entries(values)
|
||||
?.filter(([key]) => key !== 'undefined')
|
||||
.every(([, value]) => value),
|
||||
);
|
||||
dispatch(updatePostErrors({}));
|
||||
}, [values]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
import { MAX_TOTAL_LENGTH } from '../../data/constants';
|
||||
|
||||
const messages = defineMessages({
|
||||
courseDisplayNameLabel: {
|
||||
@@ -117,6 +118,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.create-or-rerun-course.no-space.error',
|
||||
defaultMessage: 'Please do not use any spaces in this field.',
|
||||
},
|
||||
totalLengthError: {
|
||||
id: 'course-authoring.create-or-rerun-course.total-length-error.error',
|
||||
defaultMessage: `The combined length of the organization, course number and course run fields cannot be more than ${MAX_TOTAL_LENGTH} characters.`,
|
||||
},
|
||||
alertErrorExistsAriaLabelledBy: {
|
||||
id: 'course-authoring.create-or-rerun-course.error.already-exists.labelledBy',
|
||||
defaultMessage: 'alert-already-exists-title',
|
||||
|
||||
@@ -31,32 +31,37 @@ export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => {
|
||||
return items;
|
||||
};
|
||||
|
||||
export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/details/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/grading/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.grading']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/course_team/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.courseTeam']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/group_configurations/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.groupConfigurations']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.advancedSettings']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/certificates/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.certificates']),
|
||||
},
|
||||
]);
|
||||
export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => {
|
||||
const items = [
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/details/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/grading/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.grading']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/course_team/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.courseTeam']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/group_configurations/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.groupConfigurations']),
|
||||
},
|
||||
{
|
||||
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.advancedSettings']),
|
||||
},
|
||||
];
|
||||
if (getConfig().ENABLE_CERTIFICATE_PAGE === 'true') {
|
||||
items.push({
|
||||
href: `${studioBaseUrl}/certificates/${courseId}`,
|
||||
title: intl.formatMessage(messages['header.links.certificates']),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { getContentMenuItems, getToolsMenuItems } from './utils';
|
||||
import { getContentMenuItems, getToolsMenuItems, getSettingMenuItems } from './utils';
|
||||
|
||||
const props = {
|
||||
studioBaseUrl: 'UrLSTuiO',
|
||||
@@ -29,6 +29,25 @@ describe('header utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSettingsMenuitems', () => {
|
||||
it('should include certificates option', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_CERTIFICATE_PAGE: 'true',
|
||||
});
|
||||
const actualItems = getSettingMenuItems(props);
|
||||
expect(actualItems).toHaveLength(6);
|
||||
});
|
||||
it('should not include certificates option', () => {
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_CERTIFICATE_PAGE: 'false',
|
||||
});
|
||||
const actualItems = getSettingMenuItems(props);
|
||||
expect(actualItems).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolsMenuItems', () => {
|
||||
it('should include export tags option', () => {
|
||||
setConfig({
|
||||
|
||||
@@ -121,6 +121,7 @@ initialize({
|
||||
ENABLE_UNIT_PAGE: process.env.ENABLE_UNIT_PAGE || 'false',
|
||||
ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_PAGE || 'false',
|
||||
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
|
||||
ENABLE_CERTIFICATE_PAGE: process.env.ENABLE_CERTIFICATE_PAGE || 'false',
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2: process.env.ENABLE_HOME_PAGE_COURSE_API_V2 === 'true',
|
||||
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
|
||||
|
||||
@@ -25,6 +25,7 @@ const AppSettingsModalBase = ({
|
||||
variant={variant}
|
||||
hasCloseButton={isMobile}
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title data-testid="modal-title">{title}</ModalDialog.Title>
|
||||
|
||||
@@ -49,6 +49,7 @@ const StudioHome = ({ intl }) => {
|
||||
studioRequestEmail,
|
||||
libraryAuthoringMfeUrl,
|
||||
redirectToLibraryAuthoringMfe,
|
||||
showNewLibraryButton,
|
||||
} = studioHomeData;
|
||||
|
||||
function getHeaderButtons() {
|
||||
@@ -78,23 +79,25 @@ const StudioHome = ({ intl }) => {
|
||||
);
|
||||
}
|
||||
|
||||
let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`;
|
||||
if (redirectToLibraryAuthoringMfe) {
|
||||
libraryHref = `${libraryAuthoringMfeUrl}/create`;
|
||||
}
|
||||
if (showNewLibraryButton) {
|
||||
let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`;
|
||||
if (redirectToLibraryAuthoringMfe) {
|
||||
libraryHref = `${libraryAuthoringMfeUrl}/create`;
|
||||
}
|
||||
|
||||
headerButtons.push(
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={AddIcon}
|
||||
size="sm"
|
||||
disabled={showNewCourseContainer}
|
||||
href={libraryHref}
|
||||
data-testid="new-library-button"
|
||||
>
|
||||
{intl.formatMessage(messages.addNewLibraryBtnText)}
|
||||
</Button>,
|
||||
);
|
||||
headerButtons.push(
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={AddIcon}
|
||||
size="sm"
|
||||
disabled={showNewCourseContainer}
|
||||
href={libraryHref}
|
||||
data-testid="new-library-button"
|
||||
>
|
||||
{intl.formatMessage(messages.addNewLibraryBtnText)}
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
return headerButtons;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,15 @@ describe('<StudioHome />', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('do not render new library button if showNewLibraryButton is False', () => {
|
||||
useSelector.mockReturnValue({
|
||||
...studioHomeMock,
|
||||
showNewLibraryButton: false,
|
||||
});
|
||||
const { queryByTestId } = render(<RootWrapper />);
|
||||
expect(queryByTestId('new-library-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render create new course container', async () => {
|
||||
useSelector.mockReturnValue({
|
||||
...studioHomeMock,
|
||||
|
||||
Reference in New Issue
Block a user