Compare commits

...

8 Commits

Author SHA1 Message Date
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
24 changed files with 504 additions and 39 deletions

8
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.1.11",
"@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.1.11",
"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.1.11.tgz",
"integrity": "sha512-RjE263H/GabHmEe5EFaku7LSngkJitVbnWSxvRhsmO2o5LWwEctUcpkQaK7YCN6fpAlqXmcXVMrtM/lzP4j2Bw==", "integrity": "sha512-vzDpneZIXmjFo5sZxxZiVjt1zgczfEkJhT2h/sg2mcJ0m7Zuo9dPJeilATqB0pSTjZnNsIbX+NfT/Dx/mSJciQ==",
"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",

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

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

@@ -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 = () => {
@@ -457,6 +478,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
@@ -2237,4 +2241,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
@@ -458,3 +459,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

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

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

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

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

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

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

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