Compare commits
21 Commits
open-relea
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43579321b | ||
|
|
4aed4dee15 | ||
|
|
0189e919a6 | ||
|
|
65e431cebe | ||
|
|
af4e25b39f | ||
|
|
0589912714 | ||
|
|
4ff3684772 | ||
|
|
0ae7aa0265 | ||
|
|
dc7bb9fe04 | ||
|
|
8abcbe0385 | ||
|
|
455656265e | ||
|
|
8518933970 | ||
|
|
d80a68132a | ||
|
|
b66238c7c0 | ||
|
|
e4c5238f70 | ||
|
|
1bc759a1e7 | ||
|
|
5cc04f8a80 | ||
|
|
de4189b4a5 | ||
|
|
785b91d3c7 | ||
|
|
a63409eaa6 | ||
|
|
3c8e5b2501 |
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"
|
||||
|
||||
10
package-lock.json
generated
10
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.1.7",
|
||||
"@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.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.1.7.tgz",
|
||||
"integrity": "sha512-RjE263H/GabHmEe5EFaku7LSngkJitVbnWSxvRhsmO2o5LWwEctUcpkQaK7YCN6fpAlqXmcXVMrtM/lzP4j2Bw==",
|
||||
"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",
|
||||
@@ -2621,7 +2621,7 @@
|
||||
"xmlchecker": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-platform": "^7.0.1",
|
||||
"@edx/frontend-platform": "^7.0.1 || ^8.0.0",
|
||||
"@openedx/paragon": "^21.5.7 || ^22.0.0",
|
||||
"prop-types": "^15.5.10",
|
||||
"react": "^16.14.0 || ^17.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.1.7",
|
||||
"@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"
|
||||
|
||||
@@ -71,7 +71,7 @@ const SettingCard = ({
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.helpButtonText)}
|
||||
variant="primary"
|
||||
className=" ml-1 mr-2"
|
||||
className="flex-shrink-0 ml-1 mr-2"
|
||||
/>
|
||||
<ModalPopup
|
||||
hasArrow
|
||||
|
||||
@@ -79,3 +79,7 @@
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.react-datepicker-popper {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@@ -8,14 +8,72 @@ import {
|
||||
Button,
|
||||
Toast,
|
||||
} from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import messages from './messages';
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
import Loading from '../generic/Loading';
|
||||
import useContentTagsDrawerContext from './ContentTagsDrawerHelper';
|
||||
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
|
||||
|
||||
const TaxonomyList = ({ contentId }) => {
|
||||
const navigate = useNavigate();
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
isTaxonomyListLoaded,
|
||||
isContentTaxonomyTagsLoaded,
|
||||
tagsByTaxonomy,
|
||||
stagedContentTags,
|
||||
collapsibleStates,
|
||||
} = React.useContext(ContentTagsDrawerContext);
|
||||
|
||||
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
|
||||
if (tagsByTaxonomy.length !== 0) {
|
||||
return (
|
||||
<div>
|
||||
{ tagsByTaxonomy.map((data) => (
|
||||
<div key={`taxonomy-tags-collapsible-${data.id}`}>
|
||||
<ContentTagsCollapsible
|
||||
contentId={contentId}
|
||||
taxonomyAndTagsData={data}
|
||||
stagedContentTags={stagedContentTags[data.id] || []}
|
||||
collapsibleState={collapsibleStates[data.id] || false}
|
||||
/>
|
||||
<hr />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
{...messages.emptyDrawerContent}
|
||||
values={{
|
||||
link: (
|
||||
<Button
|
||||
tabIndex="0"
|
||||
size="inline"
|
||||
variant="link"
|
||||
className="text-info-500 p-0 enable-taxonomies-button"
|
||||
onClick={() => navigate('/taxonomies')}
|
||||
>
|
||||
{ intl.formatMessage(messages.emptyDrawerContentLink) }
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Loading />;
|
||||
};
|
||||
|
||||
TaxonomyList.propTypes = {
|
||||
contentId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawer with the functionality to show and manage tags in a certain content.
|
||||
* It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe.
|
||||
@@ -42,7 +100,6 @@ const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
contentName,
|
||||
isTaxonomyListLoaded,
|
||||
isContentTaxonomyTagsLoaded,
|
||||
tagsByTaxonomy,
|
||||
stagedContentTags,
|
||||
collapsibleStates,
|
||||
isEditMode,
|
||||
@@ -110,19 +167,7 @@ const ContentTagsDrawer = ({ id, onClose }) => {
|
||||
<p className="h4 text-gray-500 font-weight-bold">
|
||||
{intl.formatMessage(messages.headerSubtitle)}
|
||||
</p>
|
||||
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
|
||||
? tagsByTaxonomy.map((data) => (
|
||||
<div key={`taxonomy-tags-collapsible-${data.id}`}>
|
||||
<ContentTagsCollapsible
|
||||
contentId={contentId}
|
||||
taxonomyAndTagsData={data}
|
||||
stagedContentTags={stagedContentTags[data.id] || []}
|
||||
collapsibleState={collapsibleStates[data.id] || false}
|
||||
/>
|
||||
<hr />
|
||||
</div>
|
||||
))
|
||||
: <Loading />}
|
||||
<TaxonomyList contentId={contentId} />
|
||||
{otherTaxonomies.length !== 0 && (
|
||||
<div>
|
||||
<p className="h4 text-gray-500 font-weight-bold">
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
.other-description {
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.enable-taxonomies-button:not([disabled]):hover {
|
||||
background-color: transparent;
|
||||
color: $info-900 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply styles to sheet only if it has a child with a .tags-drawer class
|
||||
|
||||
@@ -20,17 +20,20 @@ import {
|
||||
import { getTaxonomyListData } from '../taxonomy/data/api';
|
||||
import messages from './messages';
|
||||
import { ContentTagsDrawerSheetContext } from './common/context';
|
||||
import { languageExportId } from './utils';
|
||||
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
|
||||
const mockOnClose = jest.fn();
|
||||
const mockMutate = jest.fn();
|
||||
const mockSetBlockingSheet = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
contentId,
|
||||
}),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
// FIXME: replace these mocks with API mocks
|
||||
@@ -256,6 +259,83 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
};
|
||||
|
||||
const setupMockDataLanguageTaxonomyTestings = (hasTags) => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
taxonomies: [
|
||||
{
|
||||
name: 'Languages',
|
||||
taxonomyId: 123,
|
||||
exportId: languageExportId,
|
||||
canTagObject: true,
|
||||
tags: hasTags ? [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
] : [],
|
||||
},
|
||||
{
|
||||
name: 'Taxonomy 1',
|
||||
taxonomyId: 1234,
|
||||
canTagObject: true,
|
||||
tags: [
|
||||
{
|
||||
value: 'Tag 1',
|
||||
lineage: ['Tag 1'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
{
|
||||
value: 'Tag 2',
|
||||
lineage: ['Tag 2'],
|
||||
canDeleteObjecttag: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
id: 123,
|
||||
name: 'Languages',
|
||||
description: 'This is a description 1',
|
||||
exportId: languageExportId,
|
||||
canTagObject: true,
|
||||
},
|
||||
{
|
||||
id: 1234,
|
||||
name: 'Taxonomy 1',
|
||||
description: 'This is a description 2',
|
||||
canTagObject: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
canChangeTag: false,
|
||||
canDeleteTag: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setupLargeMockDataForStagedTagsTesting = () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
@@ -1057,4 +1137,47 @@ describe('<ContentTagsDrawer />', () => {
|
||||
|
||||
expect(screen.getByText(/tag 3/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Language Taxonomy', async () => {
|
||||
setupMockDataLanguageTaxonomyTestings(true);
|
||||
render(<RootWrapper />);
|
||||
expect(await screen.findByText('Languages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide Language Taxonomy', async () => {
|
||||
setupMockDataLanguageTaxonomyTestings(false);
|
||||
render(<RootWrapper />);
|
||||
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText('Languages')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty drawer message', async () => {
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
taxonomies: [],
|
||||
},
|
||||
});
|
||||
getTaxonomyListData.mockResolvedValue({
|
||||
results: [],
|
||||
});
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
canAddTag: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
expect(await screen.findByText(/to use tags, please or contact your administrator\./i)).toBeInTheDocument();
|
||||
const enableButton = screen.getByRole('button', {
|
||||
name: /enable a taxonomy/i,
|
||||
});
|
||||
fireEvent.click(enableButton);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/taxonomies');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useContentData, useContentTaxonomyTagsData, useContentTaxonomyTagsUpdater } from './data/apiHooks';
|
||||
import { useTaxonomyList } from '../taxonomy/data/apiHooks';
|
||||
import { extractOrgFromContentId } from './utils';
|
||||
import { extractOrgFromContentId, languageExportId } from './utils';
|
||||
import messages from './messages';
|
||||
import { ContentTagsDrawerSheetContext } from './common/context';
|
||||
|
||||
@@ -142,8 +142,14 @@ const useContentTagsDrawerContext = (contentId) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete Language taxonomy if is empty
|
||||
const filteredTaxonomies = taxonomiesList.filter(
|
||||
(taxonomy) => taxonomy.exportId !== languageExportId
|
||||
|| taxonomy.contentTags.length !== 0,
|
||||
);
|
||||
|
||||
return {
|
||||
fechedTaxonomies: sortTaxonomies(taxonomiesList),
|
||||
fechedTaxonomies: sortTaxonomies(filteredTaxonomies),
|
||||
fechedOtherTaxonomies: otherTaxonomiesList,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,7 +323,9 @@ const ContentTagsDropDownSelector = ({
|
||||
|
||||
{ tagPages.data.length === 0 && !tagPages.isLoading && (
|
||||
<div className="d-flex justify-content-center muted-text">
|
||||
<FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
|
||||
{ searchTerm
|
||||
? <FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
|
||||
: <FormattedMessage {...messages.noTagsInTaxonomyMessage} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -282,4 +282,28 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render "noTagsInTaxonomy" message if taxonomy is empty', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data: [],
|
||||
},
|
||||
});
|
||||
|
||||
const searchTerm = '';
|
||||
await act(async () => {
|
||||
const { getByText } = await getComponent({ ...data, searchTerm });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
|
||||
});
|
||||
|
||||
const message = 'No tags in this taxonomy yet';
|
||||
expect(getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,11 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
|
||||
defaultMessage: 'No tags found with the search term "{searchTerm}"',
|
||||
},
|
||||
noTagsInTaxonomyMessage: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-in-taxonomy',
|
||||
defaultMessage: 'No tags in this taxonomy yet',
|
||||
description: 'Message when the user uses the tags dropdown selector of an empty taxonomy',
|
||||
},
|
||||
taxonomyTagChecked: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-checked',
|
||||
defaultMessage: 'Checked',
|
||||
@@ -124,6 +129,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'These tags are already applied, but you can\'t add new ones as you don\'t have access to their taxonomies.',
|
||||
description: 'Description of "Other tags" subsection in tags drawer',
|
||||
},
|
||||
emptyDrawerContent: {
|
||||
id: 'course-authoring.content-tags-drawer.empty',
|
||||
defaultMessage: 'To use tags, please {link} or contact your administrator.',
|
||||
description: 'Message when there are no taxonomies.',
|
||||
},
|
||||
emptyDrawerContentLink: {
|
||||
id: 'course-authoring.content-tags-drawer.empty-link',
|
||||
defaultMessage: 'enable a taxonomy',
|
||||
description: 'Message of the link used in empty drawer message.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
|
||||
export const languageExportId = 'languages-v1';
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Layout,
|
||||
Row,
|
||||
TransitionReplace,
|
||||
Toast,
|
||||
} from '@openedx/paragon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
@@ -52,9 +54,11 @@ import {
|
||||
} from '../generic/drag-helper/utils';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { getTagsExportFile } from './data/api';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
courseName,
|
||||
@@ -117,6 +121,23 @@ const CourseOutline = ({ courseId }) => {
|
||||
errors,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
// Use `setToastMessage` to show the toast.
|
||||
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash === '#export-tags') {
|
||||
setToastMessage(intl.formatMessage(messages.exportTagsCreatingToastMessage));
|
||||
getTagsExportFile(courseId, courseName).then(() => {
|
||||
setToastMessage(intl.formatMessage(messages.exportTagsSuccessToastMessage));
|
||||
}).catch(() => {
|
||||
setToastMessage(intl.formatMessage(messages.exportTagsErrorToastMessage));
|
||||
});
|
||||
|
||||
// Delete `#export-tags` from location
|
||||
window.location.href = '#';
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const [sections, setSections] = useState(sectionsList);
|
||||
|
||||
const restoreSectionList = () => {
|
||||
@@ -438,6 +459,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
onConfigureSubmit={handleConfigureItemSubmit}
|
||||
currentItemData={currentItemData}
|
||||
enableProctoredExams={enableProctoredExams}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
/>
|
||||
<DeleteModal
|
||||
category={deleteCategory}
|
||||
@@ -457,6 +479,15 @@ const CourseOutline = ({ courseId }) => {
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
/>
|
||||
</div>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
show
|
||||
onClose={/* istanbul ignore next */ () => setToastMessage(null)}
|
||||
data-testid="taxonomy-toast"
|
||||
>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
act, render, waitFor, fireEvent, within,
|
||||
act, render, waitFor, fireEvent, within, screen,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
@@ -10,6 +10,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { closestCorners } from '@dnd-kit/core';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
getCourseBestPracticesApiUrl,
|
||||
getCourseLaunchApiUrl,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
getCourseBlockApiUrl,
|
||||
getCourseItemApiUrl,
|
||||
getXBlockBaseApiUrl,
|
||||
exportTags,
|
||||
} from './data/api';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import {
|
||||
@@ -74,9 +76,7 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
pathname: mockPathname,
|
||||
}),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../help-urls/hooks', () => ({
|
||||
@@ -135,6 +135,10 @@ describe('<CourseOutline />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
useLocation.mockReturnValue({
|
||||
pathname: mockPathname,
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
@@ -1405,6 +1409,7 @@ describe('<CourseOutline />', () => {
|
||||
publish: 'republish',
|
||||
metadata: {
|
||||
visible_to_staff_only: isVisibleToStaffOnly,
|
||||
discussion_enabled: false,
|
||||
group_access: newGroupAccess,
|
||||
},
|
||||
})
|
||||
@@ -1423,6 +1428,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// after configuraiton response
|
||||
unit.visibilityState = 'staff_only';
|
||||
unit.discussion_enabled = false;
|
||||
unit.userPartitionInfo = {
|
||||
selectablePartitions: [
|
||||
{
|
||||
@@ -1465,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' } });
|
||||
@@ -1481,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');
|
||||
@@ -2237,4 +2252,38 @@ describe('<CourseOutline />', () => {
|
||||
// check pasteFileNotices in store
|
||||
expect(store.getState().courseOutline.pasteFileNotices).toEqual({});
|
||||
});
|
||||
|
||||
it('should show toats on export tags', async () => {
|
||||
const expectedResponse = 'this is a test';
|
||||
axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
.reply(200, expectedResponse);
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
});
|
||||
window.URL.createObjectURL = jest.fn().mockReturnValue('http://example.com/archivo');
|
||||
window.URL.revokeObjectURL = jest.fn();
|
||||
render(<RootWrapper />);
|
||||
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();
|
||||
|
||||
const expectedRequest = axiosMock.history.get.filter(request => request.url === exportTags(courseId));
|
||||
expect(expectedRequest.length).toBe(1);
|
||||
|
||||
expect(await screen.findByText('Course tags exported successfully')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show toast on export tags error', async () => {
|
||||
axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
.reply(404);
|
||||
useLocation.mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
expect(await screen.findByText('Please wait. Creating export file for course tags...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('An error has occurred creating the file')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
|
||||
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||
|
||||
/**
|
||||
* @typedef {Object} courseOutline
|
||||
@@ -310,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',
|
||||
@@ -318,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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -458,3 +460,33 @@ export async function dismissNotification(url) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the file of the exported tags
|
||||
* @param {string} courseId The ID of the content
|
||||
* @returns void
|
||||
*/
|
||||
export async function getTagsExportFile(courseId, courseName) {
|
||||
// Gets exported tags and builds the blob to download CSV file.
|
||||
// This can be done with this code:
|
||||
// `window.location.href = exportTags(contentId);`
|
||||
// but it is done in this way so we know when the operation ends to close the toast.
|
||||
const response = await getAuthenticatedHttpClient().get(exportTags(courseId), {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (response.status !== 200) {
|
||||
throw response.statusText;
|
||||
}
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${courseName}.csv`;
|
||||
a.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,6 +29,21 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.section-list.button.new-section',
|
||||
defaultMessage: 'New section',
|
||||
},
|
||||
exportTagsCreatingToastMessage: {
|
||||
id: 'course-authoring.course-outline.export-tags.toast.creating.message',
|
||||
defaultMessage: 'Please wait. Creating export file for course tags...',
|
||||
description: 'In progress message in toast when exporting tags of a course',
|
||||
},
|
||||
exportTagsSuccessToastMessage: {
|
||||
id: 'course-authoring.course-outline.export-tags.toast.success.message',
|
||||
defaultMessage: 'Course tags exported successfully',
|
||||
description: 'Success message in toast when exporting tags of a course',
|
||||
},
|
||||
exportTagsErrorToastMessage: {
|
||||
id: 'course-authoring.course-outline.export-tags.toast.error.message',
|
||||
defaultMessage: 'An error has occurred creating the file',
|
||||
description: 'Error message in toast when exporting tags of a course',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -30,6 +30,7 @@ const PublishModal = ({
|
||||
onClose={onClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header className="publish-modal__header">
|
||||
<ModalDialog.Title>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { COMMA_SEPARATED_DATE_FORMAT } from '../constants';
|
||||
import { convertToDateFromString } from '../utils';
|
||||
import { getCourseHandouts, getCourseUpdates } from './data/selectors';
|
||||
import { REQUEST_TYPES } from './constants';
|
||||
import {
|
||||
@@ -55,9 +56,10 @@ const useCourseUpdates = ({ courseId }) => {
|
||||
};
|
||||
|
||||
const handleUpdatesSubmit = (data) => {
|
||||
const dateWithoutTimezone = convertToDateFromString(data.date);
|
||||
const dataToSend = {
|
||||
...data,
|
||||
date: moment(data.date).format(COMMA_SEPARATED_DATE_FORMAT),
|
||||
date: moment(dateWithoutTimezone).format(COMMA_SEPARATED_DATE_FORMAT),
|
||||
};
|
||||
const { id, date, content } = dataToSend;
|
||||
|
||||
|
||||
@@ -5,14 +5,18 @@ import {
|
||||
waitFor,
|
||||
act,
|
||||
} from '@testing-library/react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import moment from 'moment/moment';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { REQUEST_TYPES } from '../constants';
|
||||
import { courseHandoutsMock, courseUpdatesMock } from '../__mocks__';
|
||||
import UpdateForm from './UpdateForm';
|
||||
import messages from './messages';
|
||||
|
||||
let store;
|
||||
const closeMock = jest.fn();
|
||||
const onSubmitMock = jest.fn();
|
||||
const addNewUpdateMock = { id: 0, date: moment().utc().toDate(), content: 'Some content' };
|
||||
@@ -48,18 +52,32 @@ const courseUpdatesInitialValues = (requestType) => {
|
||||
};
|
||||
|
||||
const renderComponent = ({ requestType }) => render(
|
||||
<IntlProvider locale="en">
|
||||
<UpdateForm
|
||||
isOpen
|
||||
close={closeMock}
|
||||
requestType={requestType}
|
||||
onSubmit={onSubmitMock}
|
||||
courseUpdatesInitialValues={courseUpdatesInitialValues(requestType)}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<UpdateForm
|
||||
isOpen
|
||||
close={closeMock}
|
||||
requestType={requestType}
|
||||
onSubmit={onSubmitMock}
|
||||
courseUpdatesInitialValues={courseUpdatesInitialValues(requestType)}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<UpdateForm />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
});
|
||||
it('render Add new update form correctly', async () => {
|
||||
const { getByText, getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.add_new_update });
|
||||
const { date } = courseUpdatesInitialValues(REQUEST_TYPES.add_new_update);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -165,12 +165,13 @@ export function updateAssetLock({ assetId, courseId, locked }) {
|
||||
|
||||
try {
|
||||
await updateLockStatus({ assetId, courseId, locked });
|
||||
const lockStatus = locked ? 'locked' : 'public';
|
||||
dispatch(updateModel({
|
||||
modelType: 'assets',
|
||||
model: {
|
||||
id: assetId,
|
||||
locked,
|
||||
lockStatus: locked,
|
||||
lockStatus,
|
||||
},
|
||||
}));
|
||||
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect, Provider } from 'react-redux';
|
||||
import { connect, Provider, useSelector } from 'react-redux';
|
||||
import { createStore } from 'redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
@@ -18,20 +18,19 @@ export const SUPPORTED_TEXT_EDITORS = {
|
||||
};
|
||||
|
||||
const mapStateToProps = () => ({
|
||||
assets: {},
|
||||
images: {},
|
||||
lmsEndpointUrl: getConfig().LMS_BASE_URL,
|
||||
studioEndpointUrl: getConfig().STUDIO_BASE_URL,
|
||||
isLibrary: true,
|
||||
onEditorChange: () => ({}),
|
||||
});
|
||||
|
||||
const Editor = connect(mapStateToProps)(TinyMceWidget);
|
||||
|
||||
export const WysiwygEditor = ({
|
||||
initialValue, editorType, onChange, minHeight,
|
||||
}) => {
|
||||
const { editorRef, refReady, setEditorRef } = prepareEditorRef();
|
||||
|
||||
const { courseId } = useSelector((state) => state.courseDetail);
|
||||
const isEquivalentCodeExtraSpaces = (first, second) => {
|
||||
// Utils allows to compare code extra spaces
|
||||
const removeWhitespace = (str) => str.replace(/\s/g, '');
|
||||
@@ -75,6 +74,7 @@ export const WysiwygEditor = ({
|
||||
setEditorRef={setEditorRef}
|
||||
onChange={handleUpdate}
|
||||
initializeEditor={() => ({})}
|
||||
learningContextId={courseId}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -48,6 +48,26 @@ describe('<DeadlineSection />', () => {
|
||||
expect(testObj.gracePeriod.minutes).toBe(13);
|
||||
});
|
||||
});
|
||||
it('checking deadline input value if grace Period has no hours', async () => {
|
||||
const { getByTestId } = render(<RootWrapper
|
||||
gracePeriod={{ hours: 0, minutes: 13 }}
|
||||
setGradingData={setGradingData}
|
||||
/>);
|
||||
await waitFor(() => {
|
||||
const inputElement = getByTestId('deadline-period-input');
|
||||
expect(inputElement.value).toBe('00:13');
|
||||
});
|
||||
});
|
||||
it('checking deadline input value if grace Period has no minutes', async () => {
|
||||
const { getByTestId } = render(<RootWrapper
|
||||
gracePeriod={{ hours: 13, minutes: 0 }}
|
||||
setGradingData={setGradingData}
|
||||
/>);
|
||||
await waitFor(() => {
|
||||
const inputElement = getByTestId('deadline-period-input');
|
||||
expect(inputElement.value).toBe('13:00');
|
||||
});
|
||||
});
|
||||
it('checking deadline input value if grace Period equal null', async () => {
|
||||
const { getByTestId } = render(<RootWrapper gracePeriod={null} setGradingData={setGradingData} />);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ const DeadlineSection = ({
|
||||
intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert,
|
||||
}) => {
|
||||
const timeStampValue = gracePeriod
|
||||
? gracePeriod.hours && `${formatTime(gracePeriod.hours)}:${formatTime(gracePeriod.minutes)}`
|
||||
? `${formatTime(gracePeriod.hours)}:${formatTime(gracePeriod.minutes)}`
|
||||
: DEFAULT_TIME_STAMP;
|
||||
const [newDeadlineValue, setNewDeadlineValue] = useState(timeStampValue);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
@@ -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 }) => ([
|
||||
{
|
||||
@@ -69,7 +74,7 @@ export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
|
||||
},
|
||||
...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'
|
||||
? [{
|
||||
href: `${studioBaseUrl}/api/content_tagging/v1/object_tags/${courseId}/export/`,
|
||||
href: '#export-tags',
|
||||
title: intl.formatMessage(messages['header.links.exportTags']),
|
||||
}] : []
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -41,7 +41,7 @@ function getItemIcon(blockType) {
|
||||
*/
|
||||
function getLibraryHitUrl(hit, libraryAuthoringMfeUrl) {
|
||||
const { contextKey } = hit;
|
||||
return `${libraryAuthoringMfeUrl}/library/${contextKey}`;
|
||||
return `${libraryAuthoringMfeUrl}library/${contextKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,10 +62,20 @@ function getUnitUrlSuffix(hit) {
|
||||
function getUnitComponentUrlSuffix(hit) {
|
||||
const { breadcrumbs, contextKey, usageKey } = hit;
|
||||
if (breadcrumbs.length > 1) {
|
||||
const parent = breadcrumbs[breadcrumbs.length - 1];
|
||||
let parent = breadcrumbs[breadcrumbs.length - 1];
|
||||
|
||||
if ('usageKey' in parent) {
|
||||
return `course/${contextKey}/container/${parent.usageKey}?show=${encodeURIComponent(usageKey)}`;
|
||||
// Handle case for library component in unit
|
||||
let libComponentUsageKey;
|
||||
if (parent.usageKey.includes('type@library_content') && breadcrumbs.length > 2) {
|
||||
libComponentUsageKey = parent.usageKey;
|
||||
parent = breadcrumbs[breadcrumbs.length - 2];
|
||||
}
|
||||
|
||||
if ('usageKey' in parent) {
|
||||
const encodedUsageKey = encodeURIComponent(libComponentUsageKey || usageKey);
|
||||
return `course/${contextKey}/container/${parent.usageKey}?show=${encodedUsageKey}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +106,13 @@ function getUrlSuffix(hit) {
|
||||
return getUnitUrlSuffix(hit);
|
||||
}
|
||||
|
||||
// Check if the parent is a unit
|
||||
// Check if the parent is a unit or a library component in a unit
|
||||
if (breadcrumbs.length > 1) {
|
||||
const parent = breadcrumbs[breadcrumbs.length - 1];
|
||||
|
||||
if ('usageKey' in parent && parent.usageKey.includes('type@vertical')) {
|
||||
if ('usageKey' in parent && (
|
||||
parent.usageKey.includes('type@vertical') || parent.usageKey.includes('type@library_content'))
|
||||
) {
|
||||
return getUnitComponentUrlSuffix(hit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,31 @@ describe('<SearchUI />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('click lib component in unit result navigates to the context of encompassing lib component', async () => {
|
||||
const { findAllByRole } = rendered;
|
||||
|
||||
const [resultItem] = await findAllByRole('button', { name: /Text block in Lib Component/ });
|
||||
|
||||
// Clicking the "Open in new window" button should open the result in a new window:
|
||||
const { open } = window;
|
||||
window.open = jest.fn();
|
||||
fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'
|
||||
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40library_content%2Bblock%40427e5cd03fbe431d9d551c67d4e280ae',
|
||||
'_blank',
|
||||
);
|
||||
window.open = open;
|
||||
|
||||
// Clicking in the result should navigate to the result's URL:
|
||||
fireEvent.click(resultItem);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'/course/course-v1:SampleTaxonomyOrg1+STC1+2023_1/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'
|
||||
+ '?show=block-v1%3ASampleTaxonomyOrg1%2BSTC1%2B2023_1%2Btype%40library_content%2Bblock%40427e5cd03fbe431d9d551c67d4e280ae',
|
||||
);
|
||||
});
|
||||
|
||||
test('click lib component result navigates to the context', async () => {
|
||||
const data = generateGetStudioHomeDataApiResponse();
|
||||
data.redirectToLibraryAuthoringMfe = true;
|
||||
|
||||
@@ -262,6 +262,76 @@
|
||||
"org": "SampleTaxonomyOrg1",
|
||||
"access_id": "6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Text block in Lib Component",
|
||||
"block_id": "b654d61248bcc1f84c08",
|
||||
"content": {
|
||||
"html_content": " This is a text block lib component. "
|
||||
},
|
||||
"id": "block-v1sampletaxonomyorg1stc12023_1typehtmlblockb654d61248bcc1f84c08-77f1f658",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Sample Taxonomy Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Section 1",
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection 1.1",
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@sequential+block@92e3e9ca156c44fa8a735f0e9e7c854f"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit 1.1.1",
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b"
|
||||
},
|
||||
{
|
||||
"display_name": "Randomized Content Block",
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@library_content+block@427e5cd03fbe431d9d551c67d4e280ae"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@html+block@b654d61248bcc1f84c08",
|
||||
"block_type": "html",
|
||||
"context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1",
|
||||
"org": "SampleTaxonomyOrg1",
|
||||
"access_id": 6,
|
||||
"_formatted": {
|
||||
"display_name": "Text block in Lib Component",
|
||||
"block_id": "b654d61248bcc1f84c08",
|
||||
"content": {
|
||||
"html_content": " This is a text block lib component. "
|
||||
},
|
||||
"id": "block-v1sampletaxonomyorg1stc12023_1typehtmlblockb654d61248bcc1f84c08-77f1f658",
|
||||
"type": "course_block",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Sample Taxonomy Course"
|
||||
},
|
||||
{
|
||||
"display_name": "Section 1",
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@chapter+block@c7077c8cafcf420dbc0b440bf27bad04"
|
||||
},
|
||||
{
|
||||
"display_name": "Subsection 1.1",
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@sequential+block@92e3e9ca156c44fa8a735f0e9e7c854f"
|
||||
},
|
||||
{
|
||||
"display_name": "Unit 1.1.1",
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b"
|
||||
},
|
||||
{
|
||||
"display_name": "Randomized Content Block",
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@library_content+block@427e5cd03fbe431d9d551c67d4e280ae"
|
||||
}
|
||||
],
|
||||
"usage_key": "block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@html+block@b654d61248bcc1f84c08",
|
||||
"block_type": "html",
|
||||
"context_key": "course-v1:SampleTaxonomyOrg1+STC1+2023_1",
|
||||
"org": "SampleTaxonomyOrg1",
|
||||
"access_id": "6"
|
||||
}
|
||||
}
|
||||
],
|
||||
"query": "learn",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,7 +32,7 @@ export const generateGetStudioHomeDataApiResponse = () => ({
|
||||
inProcessCourseActions: [],
|
||||
libraries: [],
|
||||
librariesEnabled: true,
|
||||
libraryAuthoringMfeUrl: 'http://localhost:3001',
|
||||
libraryAuthoringMfeUrl: 'http://localhost:3001/',
|
||||
optimizationEnabled: false,
|
||||
redirectToLibraryAuthoringMfe: false,
|
||||
requestCourseCreatorUrl: '/request_course_creator',
|
||||
|
||||
Reference in New Issue
Block a user