Compare commits

...

21 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
Kristin Aoki
d80a68132a feat: bump frontend-lib-content-components to 2.5.1 (#1174)
The primary purpose of this version bump backport is to remove the 2U feedback form
link from the editors (https://github.com/openedx/frontend-lib-content-components/issues/476),
but several other improvements will also be pulled in:

* Fix the Text editor so that when an image is added, it is added at the cursor,
  instead of the beginning of the component.
* Improve editor load time by reducing API calls and switching some calls to be lazy.
* Align controls better in the group feedback component.
* Add validation for start & stop date fields.
* Fix image handling bugs in both the raw & visual text editors.

Full changelog: https://github.com/openedx/frontend-lib-content-components/compare/v2.1.11...v2.5.1

Co-authored-by: Kyle McCormick <kyle@axim.org>
2024-07-23 10:44:07 -04:00
Maria Grimaldi
b66238c7c0 fix: bump frontend-lib-content-components package (#1071) (#1075)
Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-06-06 13:39:44 -04:00
Chris Chávez
e4c5238f70 fix: Bug - Unusable "Languages" taxonomy appears in tagging drawer (#1057)
* Hide language taxonomy when empty
* New message on search result when taxonomy is empty
* Empty taxonomies message added in drawer
2024-06-05 17:30:17 +05:30
Yusuf Musleh
1bc759a1e7 fix: Search result redirect to unit lib component (#1027) (#1069)
This change fixes redirection to the library component in the unit when selecting the search result. It also fixes an issue with navigating to the library MFE when selecting a library component.
2024-06-05 17:19:15 +05:30
Ihor Romaniuk
5cc04f8a80 fix: info icon shrinking on advanced settings page (#1068) 2024-06-03 11:20:35 -04:00
Chris Chávez
de4189b4a5 feat: Show toast when exporting course tags (#1051) 2024-06-03 20:02:53 +05:30
Maria Grimaldi
785b91d3c7 fix: allow grace period minutes only (#1064) (#1067)
* fix: allow grace period minutes only

* fix: zero minutes error

Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-06-03 09:31:26 -04:00
Glib Glugovskiy
a63409eaa6 fix: wrong lock status update message (#1053) (#1054)
Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-05-29 09:46:51 -04:00
Glib Glugovskiy
3c8e5b2501 fix: update date using utc timezone instead of local (#1043) (#1055)
* fix: update date using utc timezone instead of local

* fix: lint error

Co-authored-by: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
2024-05-29 09:46:28 -04:00
51 changed files with 875 additions and 219 deletions

1
.env
View File

@@ -34,6 +34,7 @@ ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
ENABLE_CERTIFICATE_PAGE=true
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''
HOTJAR_APP_ID='' HOTJAR_APP_ID=''
HOTJAR_VERSION=6 HOTJAR_VERSION=6

View File

@@ -35,6 +35,7 @@ ENABLE_TEAM_TYPE_SETTING=false
ENABLE_UNIT_PAGE=false ENABLE_UNIT_PAGE=false
ENABLE_ASSETS_PAGE=false ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_NEW_VIDEO_UPLOAD_PAGE=true ENABLE_NEW_VIDEO_UPLOAD_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''

View File

@@ -31,6 +31,7 @@ ENABLE_TEAM_TYPE_SETTING=false
ENABLE_UNIT_PAGE=true ENABLE_UNIT_PAGE=true
ENABLE_ASSETS_PAGE=false ENABLE_ASSETS_PAGE=false
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_CERTIFICATE_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com" INVITE_STUDENTS_EMAIL_TO="someone@domain.com"

10
package-lock.json generated
View File

@@ -18,7 +18,7 @@
"@edx/frontend-component-footer": "^13.0.2", "@edx/frontend-component-footer": "^13.0.2",
"@edx/frontend-component-header": "^5.1.0", "@edx/frontend-component-header": "^5.1.0",
"@edx/frontend-enterprise-hotjar": "^2.0.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/frontend-platform": "7.0.1",
"@edx/openedx-atlas": "^0.6.0", "@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/fontawesome-svg-core": "1.2.36",
@@ -2580,9 +2580,9 @@
} }
}, },
"node_modules/@edx/frontend-lib-content-components": { "node_modules/@edx/frontend-lib-content-components": {
"version": "2.1.7", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.1.7.tgz", "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.5.3.tgz",
"integrity": "sha512-RjE263H/GabHmEe5EFaku7LSngkJitVbnWSxvRhsmO2o5LWwEctUcpkQaK7YCN6fpAlqXmcXVMrtM/lzP4j2Bw==", "integrity": "sha512-B9/UlnDBhMUjvosvsZ0a8Kga/DdWA6PpNOHi2r0w+xc2U6jWKzpO/ZFdNQMWv6ZVIRbSAIpKy2swSDiCWXskxw==",
"dependencies": { "dependencies": {
"@codemirror/lang-html": "^6.0.0", "@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-xml": "^6.0.0",
@@ -2621,7 +2621,7 @@
"xmlchecker": "^0.1.0" "xmlchecker": "^0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@edx/frontend-platform": "^7.0.1", "@edx/frontend-platform": "^7.0.1 || ^8.0.0",
"@openedx/paragon": "^21.5.7 || ^22.0.0", "@openedx/paragon": "^21.5.7 || ^22.0.0",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"react": "^16.14.0 || ^17.0.0", "react": "^16.14.0 || ^17.0.0",

View File

@@ -45,7 +45,7 @@
"@edx/frontend-component-footer": "^13.0.2", "@edx/frontend-component-footer": "^13.0.2",
"@edx/frontend-component-header": "^5.1.0", "@edx/frontend-component-header": "^5.1.0",
"@edx/frontend-enterprise-hotjar": "^2.0.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/frontend-platform": "7.0.1",
"@edx/openedx-atlas": "^0.6.0", "@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36", "@fortawesome/fontawesome-svg-core": "1.2.36",

View File

@@ -124,7 +124,7 @@ const CourseAuthoringRoutes = () => {
/> />
<Route <Route
path="certificates" path="certificates"
element={<PageWrap><Certificates courseId={courseId} /></PageWrap>} element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true' ? <PageWrap><Certificates courseId={courseId} /></PageWrap> : null}
/> />
<Route <Route
path="textbooks" path="textbooks"

View File

@@ -71,7 +71,7 @@ const SettingCard = ({
iconAs={Icon} iconAs={Icon}
alt={intl.formatMessage(messages.helpButtonText)} alt={intl.formatMessage(messages.helpButtonText)}
variant="primary" variant="primary"
className=" ml-1 mr-2" className="flex-shrink-0 ml-1 mr-2"
/> />
<ModalPopup <ModalPopup
hasArrow hasArrow

View File

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

View File

@@ -8,14 +8,72 @@ import {
Button, Button,
Toast, Toast,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import messages from './messages'; import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible'; import ContentTagsCollapsible from './ContentTagsCollapsible';
import Loading from '../generic/Loading'; import Loading from '../generic/Loading';
import useContentTagsDrawerContext from './ContentTagsDrawerHelper'; import useContentTagsDrawerContext from './ContentTagsDrawerHelper';
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context'; 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. * 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. * 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, contentName,
isTaxonomyListLoaded, isTaxonomyListLoaded,
isContentTaxonomyTagsLoaded, isContentTaxonomyTagsLoaded,
tagsByTaxonomy,
stagedContentTags, stagedContentTags,
collapsibleStates, collapsibleStates,
isEditMode, isEditMode,
@@ -110,19 +167,7 @@ const ContentTagsDrawer = ({ id, onClose }) => {
<p className="h4 text-gray-500 font-weight-bold"> <p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.headerSubtitle)} {intl.formatMessage(messages.headerSubtitle)}
</p> </p>
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded <TaxonomyList contentId={contentId} />
? 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 />}
{otherTaxonomies.length !== 0 && ( {otherTaxonomies.length !== 0 && (
<div> <div>
<p className="h4 text-gray-500 font-weight-bold"> <p className="h4 text-gray-500 font-weight-bold">

View File

@@ -22,6 +22,11 @@
.other-description { .other-description {
font-size: .9rem; 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 // Apply styles to sheet only if it has a child with a .tags-drawer class

View File

@@ -20,17 +20,20 @@ import {
import { getTaxonomyListData } from '../taxonomy/data/api'; import { getTaxonomyListData } from '../taxonomy/data/api';
import messages from './messages'; import messages from './messages';
import { ContentTagsDrawerSheetContext } from './common/context'; import { ContentTagsDrawerSheetContext } from './common/context';
import { languageExportId } from './utils';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab'; const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
const mockOnClose = jest.fn(); const mockOnClose = jest.fn();
const mockMutate = jest.fn(); const mockMutate = jest.fn();
const mockSetBlockingSheet = jest.fn(); const mockSetBlockingSheet = jest.fn();
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useParams: () => ({ useParams: () => ({
contentId, contentId,
}), }),
useNavigate: () => mockNavigate,
})); }));
// FIXME: replace these mocks with API mocks // 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 = () => { const setupLargeMockDataForStagedTagsTesting = () => {
useContentTaxonomyTagsData.mockReturnValue({ useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true, isSuccess: true,
@@ -1057,4 +1137,47 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.getByText(/tag 3/i)).toBeInTheDocument(); 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');
});
}); });

View File

@@ -4,7 +4,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { useContentData, useContentTaxonomyTagsData, useContentTaxonomyTagsUpdater } from './data/apiHooks'; import { useContentData, useContentTaxonomyTagsData, useContentTaxonomyTagsUpdater } from './data/apiHooks';
import { useTaxonomyList } from '../taxonomy/data/apiHooks'; import { useTaxonomyList } from '../taxonomy/data/apiHooks';
import { extractOrgFromContentId } from './utils'; import { extractOrgFromContentId, languageExportId } from './utils';
import messages from './messages'; import messages from './messages';
import { ContentTagsDrawerSheetContext } from './common/context'; 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 { return {
fechedTaxonomies: sortTaxonomies(taxonomiesList), fechedTaxonomies: sortTaxonomies(filteredTaxonomies),
fechedOtherTaxonomies: otherTaxonomiesList, fechedOtherTaxonomies: otherTaxonomiesList,
}; };
} }

View File

@@ -323,7 +323,9 @@ const ContentTagsDropDownSelector = ({
{ tagPages.data.length === 0 && !tagPages.isLoading && ( { tagPages.data.length === 0 && !tagPages.isLoading && (
<div className="d-flex justify-content-center muted-text"> <div className="d-flex justify-content-center muted-text">
<FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} /> { searchTerm
? <FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
: <FormattedMessage {...messages.noTagsInTaxonomyMessage} />}
</div> </div>
)} )}

View File

@@ -282,4 +282,28 @@ describe('<ContentTagsDropDownSelector />', () => {
expect(getByText(message)).toBeInTheDocument(); 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();
});
});
}); });

View File

@@ -25,6 +25,11 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found', id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
defaultMessage: 'No tags found with the search term "{searchTerm}"', 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: { taxonomyTagChecked: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-checked', id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.tag-checked',
defaultMessage: '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.', 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', 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; export default messages;

View File

@@ -1,2 +1,3 @@
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1]; export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
export const languageExportId = 'languages-v1';

View File

@@ -8,6 +8,7 @@ import {
Layout, Layout,
Row, Row,
TransitionReplace, TransitionReplace,
Toast,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { import {
@@ -20,6 +21,7 @@ import {
SortableContext, SortableContext,
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { useLocation } from 'react-router-dom';
import { LoadingSpinner } from '../generic/Loading'; import { LoadingSpinner } from '../generic/Loading';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
@@ -52,9 +54,11 @@ import {
} from '../generic/drag-helper/utils'; } from '../generic/drag-helper/utils';
import { useCourseOutline } from './hooks'; import { useCourseOutline } from './hooks';
import messages from './messages'; import messages from './messages';
import { getTagsExportFile } from './data/api';
const CourseOutline = ({ courseId }) => { const CourseOutline = ({ courseId }) => {
const intl = useIntl(); const intl = useIntl();
const location = useLocation();
const { const {
courseName, courseName,
@@ -117,6 +121,23 @@ const CourseOutline = ({ courseId }) => {
errors, errors,
} = useCourseOutline({ courseId }); } = 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 [sections, setSections] = useState(sectionsList);
const restoreSectionList = () => { const restoreSectionList = () => {
@@ -438,6 +459,7 @@ const CourseOutline = ({ courseId }) => {
onConfigureSubmit={handleConfigureItemSubmit} onConfigureSubmit={handleConfigureItemSubmit}
currentItemData={currentItemData} currentItemData={currentItemData}
enableProctoredExams={enableProctoredExams} enableProctoredExams={enableProctoredExams}
isSelfPaced={statusBarData.isSelfPaced}
/> />
<DeleteModal <DeleteModal
category={deleteCategory} category={deleteCategory}
@@ -457,6 +479,15 @@ const CourseOutline = ({ courseId }) => {
onInternetConnectionFailed={handleInternetConnectionFailed} onInternetConnectionFailed={handleInternetConnectionFailed}
/> />
</div> </div>
{toastMessage && (
<Toast
show
onClose={/* istanbul ignore next */ () => setToastMessage(null)}
data-testid="taxonomy-toast"
>
{toastMessage}
</Toast>
)}
</> </>
); );
}; };

View File

@@ -1,5 +1,5 @@
import { import {
act, render, waitFor, fireEvent, within, act, render, waitFor, fireEvent, within, screen,
} from '@testing-library/react'; } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react'; import { AppProvider } from '@edx/frontend-platform/react';
@@ -10,6 +10,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { closestCorners } from '@dnd-kit/core'; import { closestCorners } from '@dnd-kit/core';
import { useLocation } from 'react-router-dom';
import { import {
getCourseBestPracticesApiUrl, getCourseBestPracticesApiUrl,
getCourseLaunchApiUrl, getCourseLaunchApiUrl,
@@ -19,6 +20,7 @@ import {
getCourseBlockApiUrl, getCourseBlockApiUrl,
getCourseItemApiUrl, getCourseItemApiUrl,
getXBlockBaseApiUrl, getXBlockBaseApiUrl,
exportTags,
} from './data/api'; } from './data/api';
import { RequestStatus } from '../data/constants'; import { RequestStatus } from '../data/constants';
import { import {
@@ -74,9 +76,7 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useLocation: () => ({ useLocation: jest.fn(),
pathname: mockPathname,
}),
})); }));
jest.mock('../help-urls/hooks', () => ({ jest.mock('../help-urls/hooks', () => ({
@@ -135,6 +135,10 @@ describe('<CourseOutline />', () => {
}, },
}); });
useLocation.mockReturnValue({
pathname: mockPathname,
});
store = initializeStore(); store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock axiosMock
@@ -1405,6 +1409,7 @@ describe('<CourseOutline />', () => {
publish: 'republish', publish: 'republish',
metadata: { metadata: {
visible_to_staff_only: isVisibleToStaffOnly, visible_to_staff_only: isVisibleToStaffOnly,
discussion_enabled: false,
group_access: newGroupAccess, group_access: newGroupAccess,
}, },
}) })
@@ -1423,6 +1428,7 @@ describe('<CourseOutline />', () => {
// after configuraiton response // after configuraiton response
unit.visibilityState = 'staff_only'; unit.visibilityState = 'staff_only';
unit.discussion_enabled = false;
unit.userPartitionInfo = { unit.userPartitionInfo = {
selectablePartitions: [ selectablePartitions: [
{ {
@@ -1465,6 +1471,11 @@ describe('<CourseOutline />', () => {
)).toBeInTheDocument(); )).toBeInTheDocument();
let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox'); let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
await act(async () => fireEvent.click(visibilityCheckbox)); 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'); let groupeType = await within(configureModal).findByTestId('group-type-select');
fireEvent.change(groupeType, { target: { value: '0' } }); fireEvent.change(groupeType, { target: { value: '0' } });
@@ -1481,6 +1492,10 @@ describe('<CourseOutline />', () => {
configureModal = await findByTestId('configure-modal'); configureModal = await findByTestId('configure-modal');
visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox'); visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
expect(visibilityCheckbox).toBeChecked(); expect(visibilityCheckbox).toBeChecked();
discussionCheckbox = await within(configureModal).findByLabelText(
configureModalMessages.discussionEnabledCheckbox.defaultMessage,
);
expect(discussionCheckbox).not.toBeChecked();
groupeType = await within(configureModal).findByTestId('group-type-select'); groupeType = await within(configureModal).findByTestId('group-type-select');
expect(groupeType).toHaveValue('0'); expect(groupeType).toHaveValue('0');
@@ -2237,4 +2252,38 @@ describe('<CourseOutline />', () => {
// check pasteFileNotices in store // check pasteFileNotices in store
expect(store.getState().courseOutline.pasteFileNotices).toEqual({}); 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();
});
}); });

View File

@@ -29,6 +29,7 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`; export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`; export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`; 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 * @typedef {Object} courseOutline
@@ -310,7 +311,7 @@ export async function configureCourseSubsection(
* @param {object} groupAccess * @param {object} groupAccess
* @returns {Promise<Object>} * @returns {Promise<Object>}
*/ */
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess) { export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.post(getCourseItemApiUrl(unitId), { .post(getCourseItemApiUrl(unitId), {
publish: 'republish', 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 // The backend expects metadata.visible_to_staff_only to either true or null
visible_to_staff_only: isVisibleToStaffOnly ? true : null, visible_to_staff_only: isVisibleToStaffOnly ? true : null,
group_access: groupAccess, group_access: groupAccess,
discussion_enabled: discussionEnabled,
}, },
}); });
@@ -458,3 +460,33 @@ export async function dismissNotification(url) {
await getAuthenticatedHttpClient() await getAuthenticatedHttpClient()
.delete(url); .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);
}

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) => { return async (dispatch) => {
dispatch(configureCourseItemQuery( dispatch(configureCourseItemQuery(
sectionId, sectionId,
async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess), async () => configureCourseUnit(itemId, isVisibleToStaffOnly, groupAccess, discussionEnabled),
)); ));
}; };
} }

View File

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

View File

@@ -29,6 +29,21 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.section-list.button.new-section', id: 'course-authoring.course-outline.section-list.button.new-section',
defaultMessage: '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; export default messages;

View File

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

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useToggle } from '@openedx/paragon'; import { useToggle } from '@openedx/paragon';
import { COMMA_SEPARATED_DATE_FORMAT } from '../constants'; import { COMMA_SEPARATED_DATE_FORMAT } from '../constants';
import { convertToDateFromString } from '../utils';
import { getCourseHandouts, getCourseUpdates } from './data/selectors'; import { getCourseHandouts, getCourseUpdates } from './data/selectors';
import { REQUEST_TYPES } from './constants'; import { REQUEST_TYPES } from './constants';
import { import {
@@ -55,9 +56,10 @@ const useCourseUpdates = ({ courseId }) => {
}; };
const handleUpdatesSubmit = (data) => { const handleUpdatesSubmit = (data) => {
const dateWithoutTimezone = convertToDateFromString(data.date);
const dataToSend = { const dataToSend = {
...data, ...data,
date: moment(data.date).format(COMMA_SEPARATED_DATE_FORMAT), date: moment(dateWithoutTimezone).format(COMMA_SEPARATED_DATE_FORMAT),
}; };
const { id, date, content } = dataToSend; const { id, date, content } = dataToSend;

View File

@@ -5,14 +5,18 @@ import {
waitFor, waitFor,
act, act,
} from '@testing-library/react'; } from '@testing-library/react';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import moment from 'moment/moment'; import moment from 'moment/moment';
import initializeStore from '../../store';
import { REQUEST_TYPES } from '../constants'; import { REQUEST_TYPES } from '../constants';
import { courseHandoutsMock, courseUpdatesMock } from '../__mocks__'; import { courseHandoutsMock, courseUpdatesMock } from '../__mocks__';
import UpdateForm from './UpdateForm'; import UpdateForm from './UpdateForm';
import messages from './messages'; import messages from './messages';
let store;
const closeMock = jest.fn(); const closeMock = jest.fn();
const onSubmitMock = jest.fn(); const onSubmitMock = jest.fn();
const addNewUpdateMock = { id: 0, date: moment().utc().toDate(), content: 'Some content' }; const addNewUpdateMock = { id: 0, date: moment().utc().toDate(), content: 'Some content' };
@@ -48,18 +52,32 @@ const courseUpdatesInitialValues = (requestType) => {
}; };
const renderComponent = ({ requestType }) => render( const renderComponent = ({ requestType }) => render(
<IntlProvider locale="en"> <AppProvider store={store}>
<UpdateForm <IntlProvider locale="en">
isOpen <UpdateForm
close={closeMock} isOpen
requestType={requestType} close={closeMock}
onSubmit={onSubmitMock} requestType={requestType}
courseUpdatesInitialValues={courseUpdatesInitialValues(requestType)} onSubmit={onSubmitMock}
/> courseUpdatesInitialValues={courseUpdatesInitialValues(requestType)}
</IntlProvider>, />
</IntlProvider>
</AppProvider>,
); );
describe('<UpdateForm />', () => { describe('<UpdateForm />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('render Add new update form correctly', async () => { it('render Add new update form correctly', async () => {
const { getByText, getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.add_new_update }); const { getByText, getByDisplayValue, getByRole } = renderComponent({ requestType: REQUEST_TYPES.add_new_update });
const { date } = courseUpdatesInitialValues(REQUEST_TYPES.add_new_update); const { date } = courseUpdatesInitialValues(REQUEST_TYPES.add_new_update);

View File

@@ -53,3 +53,7 @@ export const VisibilityTypes = /** @type {const} */ ({
UNSCHEDULED: 'unscheduled', UNSCHEDULED: 'unscheduled',
NEEDS_ATTENTION: 'needs_attention', 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)} title={intl.formatMessage(messages.overwriteModalTitle)}
isOpen={isOpen} isOpen={isOpen}
onClose={close} onClose={close}
isOverflowVisible={false}
> >
<ModalDialog.Header> <ModalDialog.Header>
<ModalDialog.Title> <ModalDialog.Title>

View File

@@ -165,12 +165,13 @@ export function updateAssetLock({ assetId, courseId, locked }) {
try { try {
await updateLockStatus({ assetId, courseId, locked }); await updateLockStatus({ assetId, courseId, locked });
const lockStatus = locked ? 'locked' : 'public';
dispatch(updateModel({ dispatch(updateModel({
modelType: 'assets', modelType: 'assets',
model: { model: {
id: assetId, id: assetId,
locked, locked,
lockStatus: locked, lockStatus,
}, },
})); }));
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL })); dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL }));

View File

@@ -47,12 +47,12 @@ const messages = defineMessages({
description: 'Label for lock file checkbox in info modal', description: 'Label for lock file checkbox in info modal',
}, },
activeCheckboxLabel: { 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', defaultMessage: 'Active',
description: 'Label for active checkbox in filter section of sort and filter modal', description: 'Label for active checkbox in filter section of sort and filter modal',
}, },
inactiveCheckboxLabel: { 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', defaultMessage: 'Inactive',
description: 'Label for inactive checkbox in filter section of sort and filter modal', description: 'Label for inactive checkbox in filter section of sort and filter modal',
}, },
@@ -111,6 +111,15 @@ const messages = defineMessages({
defaultMessage: 'Cancel', defaultMessage: 'Cancel',
description: 'The message displayed in the button to confirm cancelling the upload', 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; export default messages;

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect, Provider } from 'react-redux'; import { connect, Provider, useSelector } from 'react-redux';
import { createStore } from 'redux'; import { createStore } from 'redux';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { import {
@@ -18,20 +18,19 @@ export const SUPPORTED_TEXT_EDITORS = {
}; };
const mapStateToProps = () => ({ const mapStateToProps = () => ({
assets: {}, images: {},
lmsEndpointUrl: getConfig().LMS_BASE_URL, lmsEndpointUrl: getConfig().LMS_BASE_URL,
studioEndpointUrl: getConfig().STUDIO_BASE_URL, studioEndpointUrl: getConfig().STUDIO_BASE_URL,
isLibrary: true, isLibrary: true,
onEditorChange: () => ({}), onEditorChange: () => ({}),
}); });
const Editor = connect(mapStateToProps)(TinyMceWidget); const Editor = connect(mapStateToProps)(TinyMceWidget);
export const WysiwygEditor = ({ export const WysiwygEditor = ({
initialValue, editorType, onChange, minHeight, initialValue, editorType, onChange, minHeight,
}) => { }) => {
const { editorRef, refReady, setEditorRef } = prepareEditorRef(); const { editorRef, refReady, setEditorRef } = prepareEditorRef();
const { courseId } = useSelector((state) => state.courseDetail);
const isEquivalentCodeExtraSpaces = (first, second) => { const isEquivalentCodeExtraSpaces = (first, second) => {
// Utils allows to compare code extra spaces // Utils allows to compare code extra spaces
const removeWhitespace = (str) => str.replace(/\s/g, ''); const removeWhitespace = (str) => str.replace(/\s/g, '');
@@ -75,6 +74,7 @@ export const WysiwygEditor = ({
setEditorRef={setEditorRef} setEditorRef={setEditorRef}
onChange={handleUpdate} onChange={handleUpdate}
initializeEditor={() => ({})} initializeEditor={() => ({})}
learningContextId={courseId}
/> />
</Provider> </Provider>
); );

View File

@@ -10,6 +10,7 @@ const BasicTab = ({
setFieldValue, setFieldValue,
courseGraders, courseGraders,
isSubsection, isSubsection,
isSelfPaced,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -27,26 +28,30 @@ const BasicTab = ({
return ( return (
<> <>
<h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.releaseDateAndTime} /></h5> {!isSelfPaced && (
<hr /> <>
<div data-testid="release-date-stack"> <h5 className="mt-4 text-gray-700"><FormattedMessage {...messages.releaseDateAndTime} /></h5>
<Stack className="mt-3" direction="horizontal" gap={5}> <hr />
<DatepickerControl <div data-testid="release-date-stack">
type={DATEPICKER_TYPES.date} <Stack className="mt-3" direction="horizontal" gap={5}>
value={releaseDate} <DatepickerControl
label={intl.formatMessage(messages.releaseDate)} type={DATEPICKER_TYPES.date}
controlName="state-date" value={releaseDate}
onChange={(val) => setFieldValue('releaseDate', val)} label={intl.formatMessage(messages.releaseDate)}
/> controlName="state-date"
<DatepickerControl onChange={(val) => setFieldValue('releaseDate', val)}
type={DATEPICKER_TYPES.time} />
value={releaseDate} <DatepickerControl
label={intl.formatMessage(messages.releaseTimeUTC)} type={DATEPICKER_TYPES.time}
controlName="start-time" value={releaseDate}
onChange={(val) => setFieldValue('releaseDate', val)} label={intl.formatMessage(messages.releaseTimeUTC)}
/> controlName="start-time"
</Stack> onChange={(val) => setFieldValue('releaseDate', val)}
</div> />
</Stack>
</div>
</>
)}
{ {
isSubsection && ( isSubsection && (
<div> <div>
@@ -66,25 +71,27 @@ const BasicTab = ({
{createOptions()} {createOptions()}
</Form.Control> </Form.Control>
</Form.Group> </Form.Group>
<div data-testid="due-date-stack"> {!isSelfPaced && (
<Stack className="mt-3" direction="horizontal" gap={5}> <div data-testid="due-date-stack">
<DatepickerControl <Stack className="mt-3" direction="horizontal" gap={5}>
type={DATEPICKER_TYPES.date} <DatepickerControl
value={dueDate} type={DATEPICKER_TYPES.date}
label={intl.formatMessage(messages.dueDate)} value={dueDate}
controlName="state-date" label={intl.formatMessage(messages.dueDate)}
onChange={(val) => setFieldValue('dueDate', val)} controlName="state-date"
data-testid="due-date-picker" onChange={(val) => setFieldValue('dueDate', val)}
/> data-testid="due-date-picker"
<DatepickerControl />
type={DATEPICKER_TYPES.time} <DatepickerControl
value={dueDate} type={DATEPICKER_TYPES.time}
label={intl.formatMessage(messages.dueTimeUTC)} value={dueDate}
controlName="start-time" label={intl.formatMessage(messages.dueTimeUTC)}
onChange={(val) => setFieldValue('dueDate', val)} controlName="start-time"
/> onChange={(val) => setFieldValue('dueDate', val)}
</Stack> />
</div> </Stack>
</div>
)}
</div> </div>
) )
} }
@@ -101,6 +108,7 @@ BasicTab.propTypes = {
}).isRequired, }).isRequired,
courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired, courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired,
setFieldValue: PropTypes.func.isRequired, setFieldValue: PropTypes.func.isRequired,
isSelfPaced: PropTypes.bool.isRequired,
}; };
export default injectIntl(BasicTab); export default injectIntl(BasicTab);

View File

@@ -28,6 +28,7 @@ const ConfigureModal = ({
currentItemData, currentItemData,
enableProctoredExams, enableProctoredExams,
isXBlockComponent, isXBlockComponent,
isSelfPaced,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { const {
@@ -58,6 +59,7 @@ const ConfigureModal = ({
supportsOnboarding, supportsOnboarding,
showReviewRules, showReviewRules,
onlineProctoringRules, onlineProctoringRules,
discussionEnabled,
} = currentItemData; } = currentItemData;
const getSelectedGroups = () => { const getSelectedGroups = () => {
@@ -98,6 +100,7 @@ const ConfigureModal = ({
// by default it is -1 i.e. accessible to all learners & staff // by default it is -1 i.e. accessible to all learners & staff
selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex,
selectedGroups: getSelectedGroups(), selectedGroups: getSelectedGroups(),
discussionEnabled,
}; };
const validationSchema = Yup.object().shape({ const validationSchema = Yup.object().shape({
@@ -127,6 +130,7 @@ const ConfigureModal = ({
).nullable(true), ).nullable(true),
selectedPartitionIndex: Yup.number().integer(), selectedPartitionIndex: Yup.number().integer(),
selectedGroups: Yup.array().of(Yup.string()), selectedGroups: Yup.array().of(Yup.string()),
discussionEnabled: Yup.boolean(),
}); });
const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id; const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id;
@@ -168,7 +172,7 @@ const ConfigureModal = ({
const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id;
groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10));
} }
onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess); onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess, data.discussionEnabled);
break; break;
default: default:
break; break;
@@ -186,6 +190,7 @@ const ConfigureModal = ({
setFieldValue={setFieldValue} setFieldValue={setFieldValue}
isSubsection={isSubsection} isSubsection={isSubsection}
courseGraders={courseGraders === 'undefined' ? [] : courseGraders} courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
isSelfPaced={isSelfPaced}
/> />
</Tab> </Tab>
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}> <Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
@@ -208,6 +213,7 @@ const ConfigureModal = ({
setFieldValue={setFieldValue} setFieldValue={setFieldValue}
isSubsection={isSubsection} isSubsection={isSubsection}
courseGraders={courseGraders === 'undefined' ? [] : courseGraders} courseGraders={courseGraders === 'undefined' ? [] : courseGraders}
isSelfPaced={isSelfPaced}
/> />
</Tab> </Tab>
<Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}> <Tab eventKey="visibility" title={intl.formatMessage(messages.visibilityTabTitle)}>
@@ -259,6 +265,7 @@ const ConfigureModal = ({
onClose={onClose} onClose={onClose}
hasCloseButton hasCloseButton
isFullscreenOnMobile isFullscreenOnMobile
isOverflowVisible={false}
> >
<div data-testid="configure-modal"> <div data-testid="configure-modal">
<ModalDialog.Header className="configure-modal__header"> <ModalDialog.Header className="configure-modal__header">
@@ -358,8 +365,10 @@ ConfigureModal.propTypes = {
supportsOnboarding: PropTypes.bool, supportsOnboarding: PropTypes.bool,
showReviewRules: PropTypes.bool, showReviewRules: PropTypes.bool,
onlineProctoringRules: PropTypes.string, onlineProctoringRules: PropTypes.string,
discussionEnabled: PropTypes.bool.isRequired,
}).isRequired, }).isRequired,
isXBlockComponent: PropTypes.bool, isXBlockComponent: PropTypes.bool,
isSelfPaced: PropTypes.bool.isRequired,
}; };
export default ConfigureModal; export default ConfigureModal;

View File

@@ -44,6 +44,7 @@ const renderComponent = () => render(
onClose={onCloseMock} onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock} onConfigureSubmit={onConfigureSubmitMock}
currentItemData={currentSectionMock} currentItemData={currentSectionMock}
isSelfPaced={false}
/> />
</IntlProvider>, </IntlProvider>,
</AppProvider>, </AppProvider>,
@@ -85,7 +86,7 @@ describe('<ConfigureModal /> for Section', () => {
}); });
}); });
const renderSubsectionComponent = () => render( const renderSubsectionComponent = (props) => render(
<AppProvider store={store}> <AppProvider store={store}>
<IntlProvider locale="en"> <IntlProvider locale="en">
<ConfigureModal <ConfigureModal
@@ -93,6 +94,8 @@ const renderSubsectionComponent = () => render(
onClose={onCloseMock} onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock} onConfigureSubmit={onConfigureSubmitMock}
currentItemData={currentSubsectionMock} currentItemData={currentSubsectionMock}
isSelfPaced={false}
{...props}
/> />
</IntlProvider>, </IntlProvider>,
</AppProvider>, </AppProvider>,
@@ -129,6 +132,14 @@ describe('<ConfigureModal /> for Subsection', () => {
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); 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', () => { it('switches to the subsection Visibility tab and renders correctly', () => {
const { getByRole, getByText } = renderSubsectionComponent(); const { getByRole, getByText } = renderSubsectionComponent();
@@ -198,6 +209,7 @@ describe('<ConfigureModal /> for Unit', () => {
expect(getByText(`${currentUnitMock.displayName} settings`)).toBeInTheDocument(); expect(getByText(`${currentUnitMock.displayName} settings`)).toBeInTheDocument();
expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.discussionEnabledCheckbox.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument();

View File

@@ -21,12 +21,17 @@ const UnitTab = ({
isVisibleToStaffOnly, isVisibleToStaffOnly,
selectedPartitionIndex, selectedPartitionIndex,
selectedGroups, selectedGroups,
discussionEnabled,
} = values; } = values;
const handleChange = (e) => { const handleVisibilityChange = (e) => {
setFieldValue('isVisibleToStaffOnly', e.target.checked); setFieldValue('isVisibleToStaffOnly', e.target.checked);
}; };
const handleDiscussionChange = (e) => {
setFieldValue('discussionEnabled', e.target.checked);
};
const handleSelect = (e) => { const handleSelect = (e) => {
setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10)); setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10));
setFieldValue('selectedGroups', []); setFieldValue('selectedGroups', []);
@@ -42,9 +47,9 @@ const UnitTab = ({
<> <>
{!isXBlockComponent && ( {!isXBlockComponent && (
<> <>
<h3 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h3> <h4 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h4>
<hr /> <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} /> <FormattedMessage {...messages.hideFromLearners} />
</Form.Checkbox> </Form.Checkbox>
{showWarning && ( {showWarning && (
@@ -52,77 +57,84 @@ const UnitTab = ({
<FormattedMessage {...messages.unitVisibilityWarning} /> <FormattedMessage {...messages.unitVisibilityWarning} />
</Alert> </Alert>
)} )}
<hr />
</> </>
)} )}
<Form.Group controlId="groupSelect"> {userPartitionInfo.selectablePartitions.length > 0 && (
<Form.Label as="legend" className="font-weight-bold"> <Form.Group controlId="groupSelect">
<FormattedMessage {...messages.restrictAccessTo} /> <h4 className="mt-3"><FormattedMessage {...messages.unitAccess} /></h4>
</Form.Label> <hr />
<Form.Control <Form.Label as="legend" className="font-weight-bold">
as="select" <FormattedMessage {...messages.restrictAccessTo} />
name="groupSelect" </Form.Label>
value={selectedPartitionIndex} <Form.Control
onChange={handleSelect} as="select"
data-testid="group-type-select" name="groupSelect"
> value={selectedPartitionIndex}
<option value="-1" key="-1"> onChange={handleSelect}
{userPartitionInfo.selectedPartitionIndex === -1 data-testid="group-type-select"
? intl.formatMessage(messages.unitSelectGroupType) >
: intl.formatMessage(messages.unitAllLearnersAndStaff)} <option value="-1" key="-1">
</option> {userPartitionInfo.selectedPartitionIndex === -1
{userPartitionInfo.selectablePartitions.map((partition, index) => ( ? intl.formatMessage(messages.unitSelectGroupType)
<option : intl.formatMessage(messages.unitAllLearnersAndStaff)}
key={partition.id}
value={index}
>
{partition.name}
</option> </option>
))} {userPartitionInfo.selectablePartitions.map((partition, index) => (
</Form.Control> <option
key={partition.id}
{selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && ( value={index}
<Form.Group controlId="select-groups-checkboxes"> >
<Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label> {partition.name}
<div </option>
role="group" ))}
className="d-flex flex-column" </Form.Control>
data-testid="group-checkboxes" {selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && (
aria-labelledby="select-groups-checkboxes" <Form.Group controlId="select-groups-checkboxes">
> <Form.Label><FormattedMessage {...messages.unitSelectGroup} /></Form.Label>
{userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => ( <div
<Form.Group role="group"
key={group.id} className="d-flex flex-column"
className="pgn__form-checkbox" data-testid="group-checkboxes"
> aria-labelledby="select-groups-checkboxes"
<Field >
as={Form.Control} {userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => (
className="flex-grow-0 mr-1" <Form.Group
controlClassName="pgn__form-checkbox-input mr-1" key={group.id}
type="checkbox" className="pgn__form-checkbox"
value={`${group.id}`} >
name="selectedGroups" <Field
/> as={Form.Control}
<div> className="flex-grow-0 mr-1"
<Form.Label controlClassName="pgn__form-checkbox-input mr-1"
className={classNames({ 'text-danger': checkIsDeletedGroup(group) })} type="checkbox"
isInline value={`${group.id}`}
> name="selectedGroups"
{group.name} />
</Form.Label> <div>
{group.deleted && ( <Form.Label
<Form.Control.Feedback type="invalid" hasIcon={false}> className={classNames({ 'text-danger': checkIsDeletedGroup(group) })}
{intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)} isInline
</Form.Control.Feedback> >
)} {group.name}
</div> </Form.Label>
</Form.Group> {group.deleted && (
))} <Form.Control.Feedback type="invalid" hasIcon={false}>
</div> {intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)}
</Form.Group> </Form.Control.Feedback>
)} )}
</Form.Group> </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, isXBlockComponent: PropTypes.bool,
values: PropTypes.shape({ values: PropTypes.shape({
isVisibleToStaffOnly: PropTypes.bool.isRequired, isVisibleToStaffOnly: PropTypes.bool.isRequired,
discussionEnabled: PropTypes.bool.isRequired,
selectedPartitionIndex: PropTypes.oneOfType([ selectedPartitionIndex: PropTypes.oneOfType([
PropTypes.string, PropTypes.string,
PropTypes.number, PropTypes.number,

View File

@@ -42,6 +42,22 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility', id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility',
defaultMessage: '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: { hideFromLearners: {
id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners', id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners',
defaultMessage: '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 AlertMessage from '../alert-message';
import { STATEFUL_BUTTON_STATES } from '../../constants'; 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 { getSavingStatus } from '../data/selectors';
import { getStudioHomeData } from '../../studio-home/data/selectors'; import { getStudioHomeData } from '../../studio-home/data/selectors';
import { updatePostErrors } from '../data/slice'; import { updatePostErrors } from '../data/slice';
@@ -132,6 +132,8 @@ const CreateOrRerunCourseForm = ({
}, },
]; ];
const errorMessage = errors[TOTAL_LENGTH_KEY] || postErrors?.errMsg;
const createButtonState = { const createButtonState = {
labels: { labels: {
default: intl.formatMessage(isCreateNewCourse ? messages.createButton : messages.rerunCreateButton), default: intl.formatMessage(isCreateNewCourse ? messages.createButton : messages.rerunCreateButton),
@@ -202,11 +204,11 @@ const CreateOrRerunCourseForm = ({
return ( return (
<div className="create-or-rerun-course-form"> <div className="create-or-rerun-course-form">
<TransitionReplace> <TransitionReplace>
{showErrorBanner ? ( {(errors[TOTAL_LENGTH_KEY] || showErrorBanner) ? (
<AlertMessage <AlertMessage
variant="danger" variant="danger"
icon={InfoIcon} icon={InfoIcon}
title={postErrors.errMsg} title={errorMessage}
aria-hidden="true" aria-hidden="true"
aria-labelledby={intl.formatMessage( aria-labelledby={intl.formatMessage(
messages.alertErrorExistsAriaLabelledBy, messages.alertErrorExistsAriaLabelledBy,

View File

@@ -198,6 +198,30 @@ describe('<CreateOrRerunCourseForm />', () => {
expect(rerunBtn).toBeDisabled(); 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 () => { it('should be disabled create button if form has error', async () => {
render(<RootWrapper {...props} />); render(<RootWrapper {...props} />);
await mockStore(); await mockStore();

View File

@@ -5,7 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; 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 { getStudioHomeData } from '../../studio-home/data/selectors';
import { import {
getRedirectUrlObj, getRedirectUrlObj,
@@ -58,6 +58,12 @@ const useCreateOrRerunCourse = (initialValues) => {
intl.formatMessage(messages.disallowedCharsError), intl.formatMessage(messages.disallowedCharsError),
) )
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)), .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 { const {
@@ -76,7 +82,11 @@ const useCreateOrRerunCourse = (initialValues) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
setFormFilled(Object.values(values).every((i) => i)); setFormFilled(
Object.entries(values)
?.filter(([key]) => key !== 'undefined')
.every(([, value]) => value),
);
dispatch(updatePostErrors({})); dispatch(updatePostErrors({}));
}, [values]); }, [values]);

View File

@@ -1,4 +1,5 @@
import { defineMessages } from '@edx/frontend-platform/i18n'; import { defineMessages } from '@edx/frontend-platform/i18n';
import { MAX_TOTAL_LENGTH } from '../../data/constants';
const messages = defineMessages({ const messages = defineMessages({
courseDisplayNameLabel: { courseDisplayNameLabel: {
@@ -117,6 +118,10 @@ const messages = defineMessages({
id: 'course-authoring.create-or-rerun-course.no-space.error', id: 'course-authoring.create-or-rerun-course.no-space.error',
defaultMessage: 'Please do not use any spaces in this field.', 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: { alertErrorExistsAriaLabelledBy: {
id: 'course-authoring.create-or-rerun-course.error.already-exists.labelledBy', id: 'course-authoring.create-or-rerun-course.error.already-exists.labelledBy',
defaultMessage: 'alert-already-exists-title', defaultMessage: 'alert-already-exists-title',

View File

@@ -48,6 +48,26 @@ describe('<DeadlineSection />', () => {
expect(testObj.gracePeriod.minutes).toBe(13); 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 () => { it('checking deadline input value if grace Period equal null', async () => {
const { getByTestId } = render(<RootWrapper gracePeriod={null} setGradingData={setGradingData} />); const { getByTestId } = render(<RootWrapper gracePeriod={null} setGradingData={setGradingData} />);
await waitFor(() => { await waitFor(() => {

View File

@@ -12,7 +12,7 @@ const DeadlineSection = ({
intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert, intl, setShowSavePrompt, gracePeriod, setGradingData, setShowSuccessAlert,
}) => { }) => {
const timeStampValue = gracePeriod const timeStampValue = gracePeriod
? gracePeriod.hours && `${formatTime(gracePeriod.hours)}:${formatTime(gracePeriod.minutes)}` ? `${formatTime(gracePeriod.hours)}:${formatTime(gracePeriod.minutes)}`
: DEFAULT_TIME_STAMP; : DEFAULT_TIME_STAMP;
const [newDeadlineValue, setNewDeadlineValue] = useState(timeStampValue); const [newDeadlineValue, setNewDeadlineValue] = useState(timeStampValue);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);

View File

@@ -31,32 +31,37 @@ export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => {
return items; return items;
}; };
export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => ([ export const getSettingMenuItems = ({ studioBaseUrl, courseId, intl }) => {
{ const items = [
href: `${studioBaseUrl}/settings/details/${courseId}`, {
title: intl.formatMessage(messages['header.links.scheduleAndDetails']), 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}/settings/grading/${courseId}`,
}, title: intl.formatMessage(messages['header.links.grading']),
{ },
href: `${studioBaseUrl}/course_team/${courseId}`, {
title: intl.formatMessage(messages['header.links.courseTeam']), 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}/group_configurations/${courseId}`,
}, title: intl.formatMessage(messages['header.links.groupConfigurations']),
{ },
href: `${studioBaseUrl}/settings/advanced/${courseId}`, {
title: intl.formatMessage(messages['header.links.advancedSettings']), href: `${studioBaseUrl}/settings/advanced/${courseId}`,
}, title: intl.formatMessage(messages['header.links.advancedSettings']),
{ },
href: `${studioBaseUrl}/certificates/${courseId}`, ];
title: intl.formatMessage(messages['header.links.certificates']), 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 }) => ([ export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
{ {
@@ -69,7 +74,7 @@ export const getToolsMenuItems = ({ studioBaseUrl, courseId, intl }) => ([
}, },
...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' ...(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']), title: intl.formatMessage(messages['header.links.exportTags']),
}] : [] }] : []
), ),

View File

@@ -1,5 +1,5 @@
import { getConfig, setConfig } from '@edx/frontend-platform'; import { getConfig, setConfig } from '@edx/frontend-platform';
import { getContentMenuItems, getToolsMenuItems } from './utils'; import { getContentMenuItems, getToolsMenuItems, getSettingMenuItems } from './utils';
const props = { const props = {
studioBaseUrl: 'UrLSTuiO', 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', () => { describe('getToolsMenuItems', () => {
it('should include export tags option', () => { it('should include export tags option', () => {
setConfig({ setConfig({

View File

@@ -121,6 +121,7 @@ initialize({
ENABLE_UNIT_PAGE: process.env.ENABLE_UNIT_PAGE || 'false', ENABLE_UNIT_PAGE: process.env.ENABLE_UNIT_PAGE || 'false',
ENABLE_ASSETS_PAGE: process.env.ENABLE_ASSETS_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_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_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_HOME_PAGE_COURSE_API_V2: process.env.ENABLE_HOME_PAGE_COURSE_API_V2 === 'true',
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true', ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',

View File

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

View File

@@ -41,7 +41,7 @@ function getItemIcon(blockType) {
*/ */
function getLibraryHitUrl(hit, libraryAuthoringMfeUrl) { function getLibraryHitUrl(hit, libraryAuthoringMfeUrl) {
const { contextKey } = hit; const { contextKey } = hit;
return `${libraryAuthoringMfeUrl}/library/${contextKey}`; return `${libraryAuthoringMfeUrl}library/${contextKey}`;
} }
/** /**
@@ -62,10 +62,20 @@ function getUnitUrlSuffix(hit) {
function getUnitComponentUrlSuffix(hit) { function getUnitComponentUrlSuffix(hit) {
const { breadcrumbs, contextKey, usageKey } = hit; const { breadcrumbs, contextKey, usageKey } = hit;
if (breadcrumbs.length > 1) { if (breadcrumbs.length > 1) {
const parent = breadcrumbs[breadcrumbs.length - 1]; let parent = breadcrumbs[breadcrumbs.length - 1];
if ('usageKey' in parent) { 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); 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) { if (breadcrumbs.length > 1) {
const parent = breadcrumbs[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); return getUnitComponentUrlSuffix(hit);
} }
} }

View File

@@ -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 () => { test('click lib component result navigates to the context', async () => {
const data = generateGetStudioHomeDataApiResponse(); const data = generateGetStudioHomeDataApiResponse();
data.redirectToLibraryAuthoringMfe = true; data.redirectToLibraryAuthoringMfe = true;

View File

@@ -262,6 +262,76 @@
"org": "SampleTaxonomyOrg1", "org": "SampleTaxonomyOrg1",
"access_id": "6" "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", "query": "learn",

View File

@@ -49,6 +49,7 @@ const StudioHome = ({ intl }) => {
studioRequestEmail, studioRequestEmail,
libraryAuthoringMfeUrl, libraryAuthoringMfeUrl,
redirectToLibraryAuthoringMfe, redirectToLibraryAuthoringMfe,
showNewLibraryButton,
} = studioHomeData; } = studioHomeData;
function getHeaderButtons() { function getHeaderButtons() {
@@ -78,23 +79,25 @@ const StudioHome = ({ intl }) => {
); );
} }
let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`; if (showNewLibraryButton) {
if (redirectToLibraryAuthoringMfe) { let libraryHref = `${getConfig().STUDIO_BASE_URL}/home_library`;
libraryHref = `${libraryAuthoringMfeUrl}/create`; if (redirectToLibraryAuthoringMfe) {
} libraryHref = `${libraryAuthoringMfeUrl}/create`;
}
headerButtons.push( headerButtons.push(
<Button <Button
variant="outline-primary" variant="outline-primary"
iconBefore={AddIcon} iconBefore={AddIcon}
size="sm" size="sm"
disabled={showNewCourseContainer} disabled={showNewCourseContainer}
href={libraryHref} href={libraryHref}
data-testid="new-library-button" data-testid="new-library-button"
> >
{intl.formatMessage(messages.addNewLibraryBtnText)} {intl.formatMessage(messages.addNewLibraryBtnText)}
</Button>, </Button>,
); );
}
return headerButtons; 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 () => { it('should render create new course container', async () => {
useSelector.mockReturnValue({ useSelector.mockReturnValue({
...studioHomeMock, ...studioHomeMock,

View File

@@ -32,7 +32,7 @@ export const generateGetStudioHomeDataApiResponse = () => ({
inProcessCourseActions: [], inProcessCourseActions: [],
libraries: [], libraries: [],
librariesEnabled: true, librariesEnabled: true,
libraryAuthoringMfeUrl: 'http://localhost:3001', libraryAuthoringMfeUrl: 'http://localhost:3001/',
optimizationEnabled: false, optimizationEnabled: false,
redirectToLibraryAuthoringMfe: false, redirectToLibraryAuthoringMfe: false,
requestCourseCreatorUrl: '/request_course_creator', requestCourseCreatorUrl: '/request_course_creator',