Compare commits

...

12 Commits

Author SHA1 Message Date
Max Sokolski
d43579321b chore(deps): update dependency @edx/frontend-lib-content-components to v2.5.3 (#1410) 2024-10-21 15:31:14 +03:00
Stanislav
4aed4dee15 fix: Calendar icon over datepicker modal (#1366) 2024-10-16 10:21:22 -04:00
Raymond Zhou
0189e919a6 Revert "fix: layout responsive for edit page (#1058)" (#1325)
This reverts commit 65e431cebe.
2024-09-25 07:51:04 -04:00
Ihor Romaniuk
65e431cebe fix: layout responsive for edit page (#1058) 2024-09-25 07:49:31 -04:00
Stanislav
af4e25b39f fix: Fix content overflow in the Pages & Resources modal windows (#1302) 2024-09-19 15:16:48 -04:00
Kaustav Banerjee
0589912714 feat: backport: remove new library button if user does not have create access for v1 libraries (#1282) 2024-09-19 09:23:29 -07:00
Stanislav
4ff3684772 fix: Add missed translation for Lock File tooltip (#1297) 2024-09-18 10:18:19 -04:00
Stanislav
0ae7aa0265 fix: Fix content overflow in the Overwrite Files modal window (#1292) 2024-09-17 10:28:33 -04:00
Dmytro
dc7bb9fe04 fix: create course button inactive after using org drop-down (#1277)
Co-authored-by: Dima Alipov <dimaalipov@192.168.1.101>
2024-09-12 12:46:00 -04:00
Dmytro
8abcbe0385 fix: hide due dates config and add discussion enable setting (#1267)
Hide release and due dates config in self paced courses and
discussion enable setting for unit in outline.

Co-authored-by: Dima Alipov <dimaalipov@192.168.1.101>
2024-09-10 11:32:18 -04:00
Dmytro
455656265e fix: no validation for combined length of org, number, run (#1261)
Co-authored-by: Dima Alipov <dimaalipov@192.168.1.101>
2024-09-10 10:24:39 -04:00
Muhammad Anas
8518933970 feat: customize the certificate link in header (#1223) (#1225)
* feat: customize the certificate link in header

* fix: lint issues

* fix: tests
2024-08-21 13:38:38 -04:00
31 changed files with 344 additions and 171 deletions

1
.env
View File

@@ -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

View File

@@ -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=''

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -79,3 +79,7 @@
color: $black;
}
}
.react-datepicker-popper {
z-index: 3;
}

View File

@@ -459,6 +459,7 @@ const CourseOutline = ({ courseId }) => {
onConfigureSubmit={handleConfigureItemSubmit}
currentItemData={currentItemData}
enableProctoredExams={enableProctoredExams}
isSelfPaced={statusBarData.isSelfPaced}
/>
<DeleteModal
category={deleteCategory}

View File

@@ -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');

View File

@@ -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,
},
});

View File

@@ -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),
));
};
}

View File

@@ -38,6 +38,7 @@ const HighlightsModal = ({
onClose={onClose}
hasCloseButton
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header className="highlights-modal__header">
<ModalDialog.Title>

View File

@@ -30,6 +30,7 @@ const PublishModal = ({
onClose={onClose}
hasCloseButton
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header className="publish-modal__header">
<ModalDialog.Title>

View File

@@ -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;

View File

@@ -32,6 +32,7 @@ const FileValidationModal = ({
title={intl.formatMessage(messages.overwriteModalTitle)}
isOpen={isOpen}
onClose={close}
isOverflowVisible={false}
>
<ModalDialog.Header>
<ModalDialog.Title>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();

View File

@@ -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,

View File

@@ -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',

View File

@@ -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,

View File

@@ -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();

View File

@@ -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]);

View File

@@ -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',

View File

@@ -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 }) => ([
{

View File

@@ -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({

View File

@@ -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',

View File

@@ -25,6 +25,7 @@ const AppSettingsModalBase = ({
variant={variant}
hasCloseButton={isMobile}
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">{title}</ModalDialog.Title>

View File

@@ -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;
}

View File

@@ -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,