@@ -197,7 +215,13 @@ ContentTagsDropDownSelector.propTypes = {
taxonomyId: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
lineage: PropTypes.arrayOf(PropTypes.string),
- tagsTree: PropTypes.objectOf(
+ appliedContentTagsTree: PropTypes.objectOf(
+ PropTypes.shape({
+ explicit: PropTypes.bool.isRequired,
+ children: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ ).isRequired,
+ stagedContentTagsTree: PropTypes.objectOf(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.scss b/src/content-tags-drawer/ContentTagsDropDownSelector.scss
index 4a3541e10d..4c32ddb4dd 100644
--- a/src/content-tags-drawer/ContentTagsDropDownSelector.scss
+++ b/src/content-tags-drawer/ContentTagsDropDownSelector.scss
@@ -4,11 +4,24 @@
.taxonomy-tags-load-more-button {
flex: 1;
+
+ &:hover {
+ background-color: transparent;
+ color: $info-900 !important;
+ }
}
.pgn__selectable_box.taxonomy-tags-selectable-box {
box-shadow: none;
padding: 0;
+
+ // Override indeterminate [-] (implicit) checkbox styles to match checked checkbox styles
+ // In the future, this customizability should be implemented in paragon instead
+ input.pgn__form-checkbox-input {
+ &:indeterminate {
+ @extend :checked; /* stylelint-disable-line scss/at-extend-no-missing-placeholder */
+ }
+ }
}
.pgn__selectable_box.taxonomy-tags-selectable-box:disabled,
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
index 7800e99e77..ee067aa69d 100644
--- a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
+++ b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
@@ -25,10 +25,12 @@ const data = {
taxonomyId: 123,
level: 0,
tagsTree: {},
+ appliedContentTagsTree: {},
+ stagedContentTagsTree: {},
};
const ContentTagsDropDownSelectorComponent = ({
- taxonomyId, level, lineage, tagsTree, searchTerm,
+ taxonomyId, level, lineage, tagsTree, searchTerm, appliedContentTagsTree, stagedContentTagsTree,
}) => (
);
@@ -53,15 +57,25 @@ describe('
', () => {
jest.clearAllMocks();
});
+ async function getComponent(updatedData) {
+ const componentData = (!updatedData ? data : updatedData);
+
+ return render(
+
,
+ );
+ }
+
it('should render taxonomy tags drop down selector loading with spinner', async () => {
await act(async () => {
- const { getByRole } = render(
-
,
- );
+ const { getByRole } = await getComponent();
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses
});
@@ -86,14 +100,8 @@ describe('
', () => {
});
await act(async () => {
- const { container, getByText } = render(
-
,
- );
+ const { container, getByText } = await getComponent();
+
await waitFor(() => {
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
@@ -120,13 +128,8 @@ describe('
', () => {
});
await act(async () => {
- const { container, getByText } = render(
-
,
- );
+ const { container, getByText } = await getComponent();
+
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
@@ -162,13 +165,7 @@ describe('
', () => {
},
},
};
- const { container, getByText } = render(
-
,
- );
+ const { container, getByText } = await getComponent(dataWithTagsTree);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
@@ -230,13 +227,7 @@ describe('
', () => {
},
},
};
- const { container, getByText } = render(
-
,
- );
+ const { container, getByText } = await getComponent(dataWithTagsTree);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
@@ -291,15 +282,7 @@ describe('
', () => {
const initalSearchTerm = 'test 1';
await act(async () => {
- const { rerender } = render(
-
,
- );
+ const { rerender } = await getComponent({ ...data, searchTerm: initalSearchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
@@ -312,6 +295,8 @@ describe('
', () => {
level={data.level}
tagsTree={data.tagsTree}
searchTerm={updatedSearchTerm}
+ appliedContentTagsTree={{}}
+ stagedContentTagsTree={{}}
/>);
await waitFor(() => {
@@ -326,6 +311,8 @@ describe('
', () => {
level={data.level}
tagsTree={data.tagsTree}
searchTerm={cleanSearchTerm}
+ appliedContentTagsTree={{}}
+ stagedContentTagsTree={{}}
/>);
await waitFor(() => {
@@ -347,15 +334,7 @@ describe('
', () => {
const searchTerm = 'uncommon search term';
await act(async () => {
- const { getByText } = render(
-
,
- );
+ const { getByText } = await getComponent({ ...data, searchTerm });
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
diff --git a/src/content-tags-drawer/TagBubble.jsx b/src/content-tags-drawer/TagBubble.jsx
index 50c2b3560e..b1b0c9b0ba 100644
--- a/src/content-tags-drawer/TagBubble.jsx
+++ b/src/content-tags-drawer/TagBubble.jsx
@@ -14,7 +14,7 @@ const TagBubble = ({
const handleClick = React.useCallback(() => {
if (!implicit && canRemove) {
- removeTagHandler(lineage.join(','), false);
+ removeTagHandler(lineage.join(','));
}
}, [implicit, lineage, canRemove, removeTagHandler]);
diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js
new file mode 100644
index 0000000000..3ce4d2050a
--- /dev/null
+++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js
@@ -0,0 +1,3 @@
+module.exports = {
+ 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20,
+};
diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js
new file mode 100644
index 0000000000..687e3d357b
--- /dev/null
+++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js
@@ -0,0 +1,35 @@
+module.exports = {
+ 'hierarchical taxonomy tag 1': {
+ children: {
+ 'hierarchical taxonomy tag 1.7': {
+ children: {
+ 'hierarchical taxonomy tag 1.7.59': {
+ children: {},
+ },
+ },
+ },
+ },
+ },
+ 'hierarchical taxonomy tag 2': {
+ children: {
+ 'hierarchical taxonomy tag 2.13': {
+ children: {
+ 'hierarchical taxonomy tag 2.13.46': {
+ children: {},
+ },
+ },
+ },
+ },
+ },
+ 'hierarchical taxonomy tag 3': {
+ children: {
+ 'hierarchical taxonomy tag 3.4': {
+ children: {
+ 'hierarchical taxonomy tag 3.4.50': {
+ children: {},
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/src/content-tags-drawer/__mocks__/index.js b/src/content-tags-drawer/__mocks__/index.js
index 5ec3027386..8c4274d643 100644
--- a/src/content-tags-drawer/__mocks__/index.js
+++ b/src/content-tags-drawer/__mocks__/index.js
@@ -2,3 +2,5 @@ export { default as taxonomyTagsMock } from './taxonomyTagsMock';
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
export { default as contentDataMock } from './contentDataMock';
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';
+export { default as contentTaxonomyTagsCountMock } from './contentTaxonomyTagsCountMock';
+export { default as contentTaxonomyTagsTreeMock } from './contentTaxonomyTagsTreeMock';
diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js
index 2d26b4fd01..54319cc103 100644
--- a/src/content-tags-drawer/data/api.js
+++ b/src/content-tags-drawer/data/api.js
@@ -32,6 +32,7 @@ export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
export const getCourseContentDataApiURL = (contentId) => new URL(`/api/contentstore/v1/course_settings/${contentId}`, getApiBaseUrl()).href;
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
+export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href;
/**
* Get all tags that belong to taxonomy.
@@ -55,6 +56,19 @@ export async function getContentTaxonomyTagsData(contentId) {
return camelCaseObject(data[contentId]);
}
+/**
+ * Get the count of tags that are applied to the content object
+ * @param {string} contentId The id of the content object to fetch the count of the applied tags for
+ * @returns {Promise
}
+ */
+export async function getContentTaxonomyTagsCount(contentId) {
+ const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsCountApiUrl(contentId));
+ if (contentId in data) {
+ return camelCaseObject(data[contentId]);
+ }
+ return 0;
+}
+
/**
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
* @param {string} contentId The id of the content object (unit/component)
diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js
index 9fa88dcb79..7ccb353548 100644
--- a/src/content-tags-drawer/data/api.test.js
+++ b/src/content-tags-drawer/data/api.test.js
@@ -6,6 +6,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
taxonomyTagsMock,
contentTaxonomyTagsMock,
+ contentTaxonomyTagsCountMock,
contentDataMock,
updateContentTaxonomyTagsMock,
} from '../__mocks__';
@@ -19,6 +20,8 @@ import {
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
+ getContentTaxonomyTagsCountApiUrl,
+ getContentTaxonomyTagsCount,
} from './api';
let axiosMock;
@@ -88,6 +91,24 @@ describe('content tags drawer api calls', () => {
expect(result).toEqual(contentTaxonomyTagsMock[contentId]);
});
+ it('should get content taxonomy tags count', async () => {
+ const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
+ axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, contentTaxonomyTagsCountMock);
+ const result = await getContentTaxonomyTagsCount(contentId);
+
+ expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId));
+ expect(result).toEqual(contentTaxonomyTagsCountMock[contentId]);
+ });
+
+ it('should get content taxonomy tags count as zero', async () => {
+ const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
+ axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, {});
+ const result = await getContentTaxonomyTagsCount(contentId);
+
+ expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId));
+ expect(result).toEqual(0);
+ });
+
it('should get content data for course component', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock);
diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx
index 0b311d795f..f393a9edd9 100644
--- a/src/content-tags-drawer/data/apiHooks.jsx
+++ b/src/content-tags-drawer/data/apiHooks.jsx
@@ -11,6 +11,7 @@ import {
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
+ getContentTaxonomyTagsCount,
} from './api';
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
@@ -105,6 +106,18 @@ export const useContentTaxonomyTagsData = (contentId) => (
})
);
+/**
+ * Build the query to get the count og taxonomy tags applied to the content object
+ * @param {string} contentId The ID of the content object to fetch the count of the applied tags for
+ */
+// FixMe: remove
+// export const useContentTaxonomyTagsCount = (contentId) => (
+// useQuery({
+// queryKey: ['contentTaxonomyTagsCount', contentId],
+// queryFn: () => getContentTaxonomyTagsCount(contentId),
+// })
+// );
+
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
@@ -145,6 +158,9 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
contentPattern = contentId.replace(/\+type@.*$/, '*');
}
queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] });
+ // FixMe: remove code below
+ // queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] });
+ // queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTagsCount', contentId] });
},
});
};
diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx
index 4e12ef5ea5..127d71cc5b 100644
--- a/src/content-tags-drawer/data/apiHooks.test.jsx
+++ b/src/content-tags-drawer/data/apiHooks.test.jsx
@@ -6,6 +6,7 @@ import {
useContentTaxonomyTagsData,
useContentData,
useContentTaxonomyTagsUpdater,
+ useContentTaxonomyTagsCount,
} from './apiHooks';
import { updateContentTaxonomyTags } from './api';
@@ -134,6 +135,24 @@ describe('useContentTaxonomyTagsData', () => {
});
});
+describe('useContentTaxonomyTagsCount', () => {
+ it('should return success response', () => {
+ useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
+ const contentId = '123';
+ const result = useContentTaxonomyTagsCount(contentId);
+
+ expect(result).toEqual({ isSuccess: true, data: 'data' });
+ });
+
+ it('should return failure response', () => {
+ useQuery.mockReturnValueOnce({ isSuccess: false });
+ const contentId = '123';
+ const result = useContentTaxonomyTagsCount(contentId);
+
+ expect(result).toEqual({ isSuccess: false });
+ });
+});
+
describe('useContentData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
diff --git a/src/content-tags-drawer/index.scss b/src/content-tags-drawer/index.scss
new file mode 100644
index 0000000000..d179bf86ab
--- /dev/null
+++ b/src/content-tags-drawer/index.scss
@@ -0,0 +1,2 @@
+@import "content-tags-drawer/TagBubble";
+@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls";
diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js
index c54e6b7bcc..47a8c1bc86 100644
--- a/src/content-tags-drawer/messages.js
+++ b/src/content-tags-drawer/messages.js
@@ -33,6 +33,32 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
defaultMessage: 'taxonomy tags selection',
},
+ manageTagsButton: {
+ id: 'course-authoring.content-tags-drawer.button.manage',
+ defaultMessage: 'Manage Tags',
+ description: 'Label in the button that opens the drawer to edit content tags',
+ },
+ tagsSidebarTitle: {
+ id: 'course-authoring.course-unit.sidebar.tags.title',
+ defaultMessage: 'Unit Tags',
+ description: 'Title of the tags sidebar',
+ },
+ collapsibleAddTagsPlaceholderText: {
+ id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.placeholder-text',
+ defaultMessage: 'Add a tag',
+ },
+ collapsibleAddStagedTagsButtonText: {
+ id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.save-staged-tags',
+ defaultMessage: 'Add tags',
+ },
+ collapsibleCancelStagedTagsButtonText: {
+ id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.cancel-staged-tags',
+ defaultMessage: 'Cancel',
+ },
+ collapsibleInlineAddStagedTagsButtonText: {
+ id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.inline-save-staged-tags',
+ defaultMessage: 'Add',
+ },
});
export default messages;
diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx
new file mode 100644
index 0000000000..29b2e244d6
--- /dev/null
+++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx
@@ -0,0 +1,112 @@
+// @ts-check
+import React, { useState, useMemo } from 'react';
+import {
+ Card, Stack, Button, Sheet, Collapsible, Icon,
+} from '@openedx/paragon';
+import { ArrowDropDown, ArrowDropUp } from '@openedx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { useParams } from 'react-router-dom';
+import { ContentTagsDrawer } from '..';
+
+import messages from '../messages';
+import { useContentTaxonomyTagsData } from '../data/apiHooks';
+import { LoadingSpinner } from '../../generic/Loading';
+import TagsTree from './TagsTree';
+
+const TagsSidebarBody = () => {
+ const intl = useIntl();
+ const [showManageTags, setShowManageTags] = useState(false);
+ const contentId = useParams().blockId;
+ const onClose = () => setShowManageTags(false);
+
+ const {
+ data: contentTaxonomyTagsData,
+ isSuccess: isContentTaxonomyTagsLoaded,
+ } = useContentTaxonomyTagsData(contentId || '');
+
+ const buildTagsTree = (contentTags) => {
+ const resultTree = {};
+ contentTags.forEach(item => {
+ let currentLevel = resultTree;
+
+ item.lineage.forEach((key) => {
+ if (!currentLevel[key]) {
+ currentLevel[key] = {
+ children: {},
+ canChangeObjecttag: item.canChangeObjecttag,
+ canDeleteObjecttag: item.canDeleteObjecttag,
+ };
+ }
+
+ currentLevel = currentLevel[key].children;
+ });
+ });
+
+ return resultTree;
+ };
+
+ const tree = useMemo(() => {
+ const result = [];
+ if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) {
+ contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => {
+ result.push({
+ ...taxonomy,
+ tags: buildTagsTree(taxonomy.tags),
+ });
+ });
+ }
+ return result;
+ }, [isContentTaxonomyTagsLoaded, contentTaxonomyTagsData]);
+
+ return (
+ <>
+
+
+ { isContentTaxonomyTagsLoaded
+ ? (
+
+ {tree.map((taxonomy) => (
+
+ }
+ iconWhenOpen={}
+ >
+
+
+
+ ))}
+
+ )
+ : (
+
+
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+TagsSidebarBody.propTypes = {};
+
+export default TagsSidebarBody;
diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx
new file mode 100644
index 0000000000..32be90bf44
--- /dev/null
+++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import TagsSidebarBody from './TagsSidebarBody';
+import { useContentTaxonomyTagsData } from '../data/apiHooks';
+import { contentTaxonomyTagsMock } from '../__mocks__';
+
+const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
+
+jest.mock('../data/apiHooks', () => ({
+ useContentTaxonomyTagsData: jest.fn(() => ({
+ isSuccess: false,
+ data: {},
+ })),
+}));
+jest.mock('../ContentTagsDrawer', () => jest.fn(() => Mocked ContentTagsDrawer
));
+
+const RootWrapper = () => (
+
+
+
+);
+
+describe('', () => {
+ it('shows spinner before the content data query is complete', () => {
+ render();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('should render data after wuery is complete', () => {
+ useContentTaxonomyTagsData.mockReturnValue({
+ isSuccess: true,
+ data: contentTaxonomyTagsMock[contentId],
+ });
+ render();
+ const taxonomyButton = screen.getByRole('button', { name: /hierarchicaltaxonomy/i });
+ expect(taxonomyButton).toBeInTheDocument();
+
+ /// ContentTagsDrawer must be closed
+ expect(screen.queryByText('Mocked ContentTagsDrawer')).not.toBeInTheDocument();
+ });
+
+ it('should open ContentTagsDrawer', () => {
+ useContentTaxonomyTagsData.mockReturnValue({
+ isSuccess: true,
+ data: contentTaxonomyTagsMock[contentId],
+ });
+ render();
+
+ const manageButton = screen.getByRole('button', { name: /manage tags/i });
+ fireEvent.click(manageButton);
+
+ expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
+ });
+});
diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss
new file mode 100644
index 0000000000..a3c0978f8c
--- /dev/null
+++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss
@@ -0,0 +1,23 @@
+.tags-sidebar {
+ .tags-sidebar-body {
+ .tags-sidebar-taxonomy {
+ .collapsible-trigger {
+ font-weight: bold;
+ border: none;
+ justify-content: start;
+ padding-left: 0;
+ padding-bottom: 0;
+
+ .collapsible-icon {
+ order: -1;
+ margin-left: 0;
+ }
+ }
+
+ .collapsible-body {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+ }
+ }
+}
diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx
new file mode 100644
index 0000000000..e3927deb89
--- /dev/null
+++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx
@@ -0,0 +1,36 @@
+// @ts-check
+import React from 'react';
+import { Stack } from '@openedx/paragon';
+import { useParams } from 'react-router-dom';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import messages from '../messages';
+import { useContentTaxonomyTagsCount } from '../data/apiHooks';
+import TagCount from '../../generic/tag-count';
+
+const TagsSidebarHeader = () => {
+ const intl = useIntl();
+ const contentId = useParams().blockId;
+
+ const {
+ data: contentTaxonomyTagsCount,
+ isSuccess: isContentTaxonomyTagsCountLoaded,
+ } = useContentTaxonomyTagsCount(contentId || '');
+
+ return (
+
+
+ {intl.formatMessage(messages.tagsSidebarTitle)}
+
+ { isContentTaxonomyTagsCountLoaded
+ && }
+
+ );
+};
+
+TagsSidebarHeader.propTypes = {};
+
+export default TagsSidebarHeader;
diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx
new file mode 100644
index 0000000000..ab0f9339e8
--- /dev/null
+++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import TagsSidebarHeader from './TagsSidebarHeader';
+import { useContentTaxonomyTagsCount } from '../data/apiHooks';
+
+jest.mock('../data/apiHooks', () => ({
+ useContentTaxonomyTagsCount: jest.fn(() => ({
+ isSuccess: false,
+ data: 17,
+ })),
+}));
+
+const RootWrapper = () => (
+
+
+
+);
+
+describe('', () => {
+ it('should not render count on loading', () => {
+ render();
+ expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
+ expect(screen.queryByText('17')).not.toBeInTheDocument();
+ });
+
+ it('should render count after query is complete', () => {
+ useContentTaxonomyTagsCount.mockReturnValue({
+ isSuccess: true,
+ data: 17,
+ });
+ render();
+ expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
+ expect(screen.getByText('17')).toBeInTheDocument();
+ });
+});
diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx
new file mode 100644
index 0000000000..df9923271a
--- /dev/null
+++ b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx
@@ -0,0 +1,50 @@
+// @ts-check
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Icon } from '@openedx/paragon';
+import { Tag } from '@openedx/paragon/icons';
+
+const TagsTree = ({ tags, rootDepth, parentKey }) => {
+ if (Object.keys(tags).length === 0) {
+ return null;
+ }
+
+ // Used to Generate tabs for the parents of this tree
+ const tabsNumberArray = Array.from({ length: rootDepth }, (_, index) => index + 1);
+
+ return (
+
+ {Object.keys(tags).map((key) => (
+
+
+ {
+ tabsNumberArray.map((index) => )
+ }
+ {key}
+
+ { tags[key].children
+ && (
+
+ )}
+
+ ))}
+
+ );
+};
+
+TagsTree.propTypes = {
+ tags: PropTypes.shape({}).isRequired,
+ parentKey: PropTypes.string,
+ rootDepth: PropTypes.number,
+};
+
+TagsTree.defaultProps = {
+ rootDepth: 0,
+ parentKey: undefined,
+};
+
+export default TagsTree;
diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx
new file mode 100644
index 0000000000..0ca8c5333a
--- /dev/null
+++ b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import TagsTree from './TagsTree';
+import { contentTaxonomyTagsTreeMock } from '../__mocks__';
+
+describe('', () => {
+ it('should render component and tags correctly', () => {
+ render();
+ expect(screen.getByText('hierarchical taxonomy tag 1')).toBeInTheDocument();
+ expect(screen.getByText('hierarchical taxonomy tag 2.13')).toBeInTheDocument();
+ expect(screen.getByText('hierarchical taxonomy tag 3.4.50')).toBeInTheDocument();
+ });
+});
diff --git a/src/content-tags-drawer/tags-sidebar-controls/index.jsx b/src/content-tags-drawer/tags-sidebar-controls/index.jsx
new file mode 100644
index 0000000000..98ffc5e7c4
--- /dev/null
+++ b/src/content-tags-drawer/tags-sidebar-controls/index.jsx
@@ -0,0 +1,13 @@
+import TagsSidebarHeader from './TagsSidebarHeader';
+import TagsSidebarBody from './TagsSidebarBody';
+
+const TagsSidebarControls = () => (
+ <>
+
+
+ >
+);
+
+TagsSidebarControls.propTypes = {};
+
+export default TagsSidebarControls;
diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx
index 8a117abab3..25691135c4 100644
--- a/src/course-unit/CourseUnit.jsx
+++ b/src/course-unit/CourseUnit.jsx
@@ -13,6 +13,7 @@ import getPageHeadTitle from '../generic/utils';
import AlertMessage from '../generic/alert-message';
import ProcessingNotification from '../generic/processing-notification';
import InternetConnectionAlert from '../generic/internet-connection-alert';
+import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
import Loading from '../generic/Loading';
import AddComponent from './add-component/AddComponent';
import CourseXBlock from './course-xblock/CourseXBlock';
@@ -23,6 +24,9 @@ import Sequence from './course-sequence';
import Sidebar from './sidebar';
import { useCourseUnit } from './hooks';
import messages from './messages';
+import PublishControls from './sidebar/PublishControls';
+import LocationInfo from './sidebar/LocationInfo';
+import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
@@ -32,6 +36,7 @@ const CourseUnit = ({ courseId }) => {
sequenceId,
unitTitle,
isQueryPending,
+ sequenceStatus,
savingStatus,
isTitleEditFormOpen,
isErrorAlert,
@@ -57,6 +62,14 @@ const CourseUnit = ({ courseId }) => {
return ;
}
+ if (sequenceStatus === RequestStatus.FAILED) {
+ return (
+
+
+
+ );
+ }
+
return (
<>
@@ -123,8 +136,15 @@ const CourseUnit = ({ courseId }) => {
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx
index 233bb63cc1..5a9685aea3 100644
--- a/src/course-unit/CourseUnit.test.jsx
+++ b/src/course-unit/CourseUnit.test.jsx
@@ -44,6 +44,7 @@ import deleteModalMessages from '../generic/delete-modal/messages';
import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
+import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
let axiosMock;
let store;
@@ -59,6 +60,31 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedUsedNavigate,
}));
+jest.mock('@tanstack/react-query', () => ({
+ useQuery: jest.fn(({ queryKey }) => {
+ if (queryKey[0] === 'contentTaxonomyTags') {
+ return {
+ data: {
+ taxonomies: [],
+ },
+ isSuccess: true,
+ };
+ } if (queryKey[0] === 'contentTaxonomyTagsCount') {
+ return {
+ data: 17,
+ isSuccess: true,
+ };
+ }
+ return {
+ data: {},
+ isSuccess: true,
+ };
+ }),
+ useQueryClient: jest.fn(() => ({
+ setQueryData: jest.fn(),
+ })),
+}));
+
const RootWrapper = () => (
@@ -92,17 +118,24 @@ describe('', () => {
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, courseVerticalChildrenMock);
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
+ axiosMock
+ .onGet(getContentTaxonomyTagsApiUrl(blockId))
+ .reply(200, {});
+ axiosMock
+ .onGet(getContentTaxonomyTagsCountApiUrl(blockId))
+ .reply(200, 17);
});
it('render CourseUnit component correctly', async () => {
- const { getByText, getByRole } = render();
+ const { getByText, getByRole, getByTestId } = render();
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
await waitFor(() => {
+ const unitHeaderTitle = getByTestId('unit-header-title');
expect(getByText(unitDisplayName)).toBeInTheDocument();
- expect(getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
- expect(getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
+ expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
+ expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument();
@@ -136,7 +169,10 @@ describe('', () => {
it('checks courseUnit title changing when edit query is successfully', async () => {
const {
- findByText, queryByRole, getByRole,
+ findByText,
+ queryByRole,
+ getByRole,
+ getByTestId,
} = render();
let editTitleButton = null;
let titleEditField = null;
@@ -160,8 +196,11 @@ describe('', () => {
});
await waitFor(() => {
- editTitleButton = getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
- titleEditField = queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
+ const unitHeaderTitle = getByTestId('unit-header-title');
+ editTitleButton = within(unitHeaderTitle)
+ .getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
+ titleEditField = within(unitHeaderTitle)
+ .queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
});
expect(titleEditField).not.toBeInTheDocument();
fireEvent.click(editTitleButton);
@@ -299,7 +338,7 @@ describe('', () => {
});
it('the sequence unit is updated after changing the unit header', async () => {
- const { getAllByTestId, getByRole } = render();
+ const { getAllByTestId, getByTestId } = render();
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
@@ -331,10 +370,12 @@ describe('', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
- const editTitleButton = getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
+ const unitHeaderTitle = getByTestId('unit-header-title');
+
+ const editTitleButton = within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
fireEvent.click(editTitleButton);
- const titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
+ const titleEditField = within(unitHeaderTitle).getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
fireEvent.change(titleEditField, { target: { value: newDisplayName } });
await act(async () => fireEvent.blur(titleEditField));
diff --git a/src/course-unit/__mocks__/courseSectionVertical.js b/src/course-unit/__mocks__/courseSectionVertical.js
index ceea70fd55..67d57c2e62 100644
--- a/src/course-unit/__mocks__/courseSectionVertical.js
+++ b/src/course-unit/__mocks__/courseSectionVertical.js
@@ -289,6 +289,10 @@ module.exports = {
},
},
],
+ course_sequence_ids: [
+ 'block-v1:edx+876+2030+type@sequential+block@297321078a0f4c26a50d671ed87642a6',
+ 'block-v1:edx+876+2030+type@sequential+block@4e91bdfefd8e4173a03d19c4d91e1936',
+ ],
xblock_info: {
id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
display_name: 'Getting Started',
diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js
index 043a693fff..28035e1afd 100644
--- a/src/course-unit/course-sequence/hooks.js
+++ b/src/course-unit/course-sequence/hooks.js
@@ -3,20 +3,14 @@ import { useLayoutEffect, useRef, useState } from 'react';
import { useWindowSize } from '@openedx/paragon';
import { useModel } from '../../generic/model-store';
-import {
- getCourseSectionVertical,
- getCourseUnit,
- sequenceIdsSelector,
-} from '../data/selectors';
+import { getCourseSectionVertical, getSequenceIds } from '../data/selectors';
-export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
- const sequenceIds = useSelector(sequenceIdsSelector);
+export function useSequenceNavigationMetadata(courseId, currentSequenceId, currentUnitId) {
const { nextUrl, prevUrl } = useSelector(getCourseSectionVertical);
const sequence = useModel('sequences', currentSequenceId);
- const { courseId } = useSelector(getCourseUnit);
const isFirstUnit = !prevUrl;
const isLastUnit = !nextUrl;
-
+ const sequenceIds = useSelector(getSequenceIds);
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx
index e7a47ec748..a2fbe55d4e 100644
--- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx
+++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx
@@ -20,6 +20,7 @@ import SequenceNavigationTabs from './SequenceNavigationTabs';
const SequenceNavigation = ({
intl,
+ courseId,
unitId,
sequenceId,
className,
@@ -28,7 +29,7 @@ const SequenceNavigation = ({
const sequenceStatus = useSelector(getSequenceStatus);
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
- } = useSequenceNavigationMetadata(sequenceId, unitId);
+ } = useSequenceNavigationMetadata(courseId, sequenceId, unitId);
const sequence = useModel('sequences', sequenceId);
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
@@ -104,6 +105,7 @@ const SequenceNavigation = ({
SequenceNavigation.propTypes = {
intl: intlShape.isRequired,
+ courseId: PropTypes.string.isRequired,
unitId: PropTypes.string,
className: PropTypes.string,
sequenceId: PropTypes.string,
diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx
index 370488ce06..7565a8c0d1 100644
--- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx
+++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx
@@ -1,9 +1,9 @@
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
+import { useNavigate } from 'react-router-dom';
import { Button } from '@openedx/paragon';
import { Plus as PlusIcon } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { useNavigate } from 'react-router-dom';
import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice';
import { getCourseId, getSequenceId } from '../../data/selectors';
diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js
index 80e25dac13..e4b6365424 100644
--- a/src/course-unit/course-xblock/messages.js
+++ b/src/course-unit/course-xblock/messages.js
@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
blockAltButtonEdit: {
id: 'course-authoring.course-unit.xblock.button.edit.alt',
- defaultMessage: 'Edit Item',
+ defaultMessage: 'Edit',
},
blockActionsDropdownAlt: {
id: 'course-authoring.course-unit.xblock.button.actions.alt',
diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js
index f7adc33b47..6520d1e1de 100644
--- a/src/course-unit/data/api.js
+++ b/src/course-unit/data/api.js
@@ -3,23 +3,13 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants';
-import {
- normalizeLearningSequencesData,
- normalizeMetadata,
- normalizeCourseHomeCourseMetadata,
- appendBrowserTimezoneToUrl,
- normalizeCourseSectionVerticalData,
-} from './utils';
+import { normalizeCourseSectionVerticalData } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
-const getLmsBaseUrl = () => getConfig().LMS_BASE_URL;
export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/container/${itemId}`;
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
-export const getLearningSequencesOutlineApiUrl = (courseId) => `${getLmsBaseUrl()}/api/learning_sequences/v1/course_outline/${courseId}`;
-export const getCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/courseware/course/${courseId}`;
-export const getCourseHomeCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/course_home/course_metadata/${courseId}`;
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
@@ -65,45 +55,6 @@ export async function getCourseSectionVerticalData(unitId) {
return normalizeCourseSectionVerticalData(data);
}
-/**
- * Retrieves the outline of learning sequences for a specific course.
- * @param {string} courseId - The ID of the course.
- * @returns {Promise
diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx
index ec15f993f8..beda688193 100644
--- a/src/files-and-videos/files-page/FilesPage.test.jsx
+++ b/src/files-and-videos/files-page/FilesPage.test.jsx
@@ -10,7 +10,7 @@ import userEvent from '@testing-library/user-event';
import ReactDOM from 'react-dom';
import { saveAs } from 'file-saver';
-import { initializeMockApp } from '@edx/frontend-platform';
+import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -36,9 +36,12 @@ import {
deleteAssetFile,
updateAssetLock,
getUsagePaths,
+ validateAssetFiles,
} from './data/thunks';
import { getAssetsUrl } from './data/api';
import messages from '../generic/messages';
+import filesPageMessages from './messages';
+import { updateFileValues } from './data/utils';
let axiosMock;
let store;
@@ -124,12 +127,13 @@ describe('FilesAndUploads', () => {
await emptyMockStore(RequestStatus.SUCCESSFUL);
const dropzone = screen.getByTestId('files-dropzone');
await act(async () => {
+ axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] });
axiosMock.onPost(getAssetsUrl(courseId)).reply(204, generateNewAssetApiResponse());
Object.defineProperty(dropzone, 'files', {
value: [file],
});
fireEvent.drop(dropzone);
- await executeThunk(addAssetFile(courseId, file, 0), store.dispatch);
+ await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch);
});
const addStatus = store.getState().assets.addingStatus;
expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
@@ -191,19 +195,106 @@ describe('FilesAndUploads', () => {
});
describe('table actions', () => {
- it('should upload a single file', async () => {
- await mockStore(RequestStatus.SUCCESSFUL);
- axiosMock.onPost(getAssetsUrl(courseId)).reply(200, generateNewAssetApiResponse());
- let addFilesButton;
- await waitFor(() => {
- addFilesButton = screen.getByLabelText('file-input');
+ describe('upload a single file', () => {
+ it('should upload without duplication modal', async () => {
+ await mockStore(RequestStatus.SUCCESSFUL);
+ axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] });
+ axiosMock.onPost(getAssetsUrl(courseId)).reply(200, generateNewAssetApiResponse());
+ let addFilesButton;
+ await waitFor(() => {
+ addFilesButton = screen.getByLabelText('file-input');
+ });
+ await act(async () => {
+ userEvent.upload(addFilesButton, file);
+ await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch);
+ });
+ const addStatus = store.getState().assets.addingStatus;
+ expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
});
- await act(async () => {
- userEvent.upload(addFilesButton, file);
- await executeThunk(addAssetFile(courseId, file, 1), store.dispatch);
+
+ it('should show duplicate file modal', async () => {
+ file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' });
+
+ await mockStore(RequestStatus.SUCCESSFUL);
+ axiosMock.onGet(
+ `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`,
+ ).reply(200, { assets: [{ display_name: 'mOckID6' }] });
+ let addFilesButton;
+ await waitFor(() => {
+ addFilesButton = screen.getByLabelText('file-input');
+ });
+ await act(async () => {
+ userEvent.upload(addFilesButton, file);
+ await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch);
+ });
+ expect(screen.getByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeVisible();
+ });
+
+ it('should overwrite duplicate file', async () => {
+ file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' });
+
+ await mockStore(RequestStatus.SUCCESSFUL);
+ axiosMock.onGet(
+ `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`,
+ ).reply(200, { assets: [{ display_name: 'mOckID6' }] });
+ const { asset: newDefaultAssetResponse } = generateNewAssetApiResponse();
+ const responseData = {
+ asset: {
+ ...newDefaultAssetResponse, id: 'mOckID6',
+ },
+ };
+
+ axiosMock.onPost(getAssetsUrl(courseId)).reply(200, responseData);
+ let addFilesButton;
+ await waitFor(() => {
+ addFilesButton = screen.getByLabelText('file-input');
+ });
+ await act(async () => {
+ userEvent.upload(addFilesButton, file);
+ await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch);
+ });
+
+ const overwriteButton = screen.getByText(filesPageMessages.confirmOverwriteButtonLabel.defaultMessage);
+ await act(async () => {
+ fireEvent.click(overwriteButton);
+ });
+
+ const assetData = store.getState().models.assets.mOckID6;
+ const { asset: responseAssetData } = responseData;
+ const [defaultData] = updateFileValues([camelCaseObject(responseAssetData)]);
+
+ expect(screen.queryByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeNull();
+ expect(assetData).toEqual(defaultData);
+ });
+
+ it('should keep original file', async () => {
+ file = new File(['(⌐□_□)'], 'mOckID6', { type: 'image/png' });
+
+ await mockStore(RequestStatus.SUCCESSFUL);
+ axiosMock.onGet(
+ `${getAssetsUrl(courseId)}?display_name=mOckID6&page_size=1`,
+ ).reply(200, { assets: [{ display_name: 'mOckID6' }] });
+ let addFilesButton;
+ await waitFor(() => {
+ addFilesButton = screen.getByLabelText('file-input');
+ });
+ await act(async () => {
+ userEvent.upload(addFilesButton, file);
+ await executeThunk(validateAssetFiles(courseId, [file]), store.dispatch);
+ });
+
+ const cancelButton = screen.getByText(filesPageMessages.cancelOverwriteButtonLabel.defaultMessage);
+ await act(async () => {
+ fireEvent.click(cancelButton);
+ });
+
+ const assetData = store.getState().models.assets.mOckID6;
+ const defaultAssets = generateFetchAssetApiResponse().assets;
+ const [defaultData] = updateFileValues([defaultAssets[4]]);
+
+ expect(screen.queryByText(filesPageMessages.overwriteConfirmMessage.defaultMessage)).toBeNull();
+ expect(assetData).toEqual(defaultData);
});
- const addStatus = store.getState().assets.addingStatus;
- expect(addStatus).toEqual(RequestStatus.SUCCESSFUL);
});
it('should have disabled action buttons', async () => {
@@ -503,7 +594,7 @@ describe('FilesAndUploads', () => {
it('invalid file size should show error', async () => {
const errorMessage = 'File download.png exceeds maximum size of 20 MB.';
await mockStore(RequestStatus.SUCCESSFUL);
-
+ axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] });
axiosMock.onPost(getAssetsUrl(courseId)).reply(413, { error: errorMessage });
const addFilesButton = screen.getByLabelText('file-input');
await act(async () => {
@@ -515,8 +606,23 @@ describe('FilesAndUploads', () => {
expect(screen.getByText('Error')).toBeVisible();
});
+ it('404 validation should show error', async () => {
+ await mockStore(RequestStatus.SUCCESSFUL);
+ axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(404);
+ const addFilesButton = screen.getByLabelText('file-input');
+ await act(async () => {
+ userEvent.upload(addFilesButton, file);
+ await executeThunk(addAssetFile(courseId, file, 1), store.dispatch);
+ });
+ const addStatus = store.getState().assets.addingStatus;
+ expect(addStatus).toEqual(RequestStatus.FAILED);
+
+ expect(screen.getByText('Error')).toBeVisible();
+ });
+
it('404 upload should show error', async () => {
await mockStore(RequestStatus.SUCCESSFUL);
+ axiosMock.onGet(`${getAssetsUrl(courseId)}?display_name=download.png&page_size=1`).reply(200, { assets: [] });
axiosMock.onPost(getAssetsUrl(courseId)).reply(404);
const addFilesButton = screen.getByLabelText('file-input');
await act(async () => {
diff --git a/src/files-and-videos/files-page/data/api.js b/src/files-and-videos/files-page/data/api.js
index ded712b836..73140c5f03 100644
--- a/src/files-and-videos/files-page/data/api.js
+++ b/src/files-and-videos/files-page/data/api.js
@@ -24,6 +24,19 @@ export async function getAssets(courseId, page) {
return camelCaseObject(data);
}
+/**
+ * Fetches the course custom pages for provided course
+ * @param {string} courseId
+ * @returns {Promise<[{}]>}
+ */
+export async function getAssetDetails({ courseId, filenames, fileCount }) {
+ const params = new URLSearchParams(filenames.map(filename => ['display_name', filename]));
+ params.append('page_size', fileCount);
+ const { data } = await getAuthenticatedHttpClient()
+ .get(`${getAssetsUrl(courseId)}?${params}`);
+ return camelCaseObject(data);
+}
+
/**
* Fetch asset file.
* @param {blockId} courseId Course ID for the course to operate on
diff --git a/src/files-and-videos/files-page/data/slice.js b/src/files-and-videos/files-page/data/slice.js
index e9bad95817..cb7ad33b7a 100644
--- a/src/files-and-videos/files-page/data/slice.js
+++ b/src/files-and-videos/files-page/data/slice.js
@@ -9,6 +9,7 @@ const slice = createSlice({
initialState: {
assetIds: [],
loadingStatus: RequestStatus.IN_PROGRESS,
+ duplicateFiles: [],
updatingStatus: '',
addingStatus: '',
deletingStatus: '',
@@ -64,6 +65,9 @@ const slice = createSlice({
addAssetSuccess: (state, { payload }) => {
state.assetIds = [payload.assetId, ...state.assetIds];
},
+ updateDuplicateFiles: (state, { payload }) => {
+ state.duplicateFiles = payload.files;
+ },
updateErrors: (state, { payload }) => {
const { error, message } = payload;
if (error === 'loading') {
@@ -89,6 +93,7 @@ export const {
updateErrors,
clearErrors,
updateEditStatus,
+ updateDuplicateFiles,
} = slice.actions;
export const {
diff --git a/src/files-and-videos/files-page/data/thunks.js b/src/files-and-videos/files-page/data/thunks.js
index ded413170c..9b0a623978 100644
--- a/src/files-and-videos/files-page/data/thunks.js
+++ b/src/files-and-videos/files-page/data/thunks.js
@@ -15,6 +15,7 @@ import {
deleteAsset,
updateLockStatus,
getDownload,
+ getAssetDetails,
} from './api';
import {
setAssetIds,
@@ -25,11 +26,12 @@ import {
updateErrors,
clearErrors,
updateEditStatus,
+ updateDuplicateFiles,
} from './slice';
-import { updateFileValues } from './utils';
+import { getUploadConflicts, updateFileValues } from './utils';
-export function fetchAddtionalAsstets(courseId, totalCount) {
+export function fetchAdditionalAssets(courseId, totalCount) {
return async (dispatch) => {
let remainingAssetCount = totalCount;
let page = 1;
@@ -66,7 +68,7 @@ export function fetchAssets(courseId) {
assetIds: assets.map(asset => asset.id),
}));
if (totalCount > 50) {
- dispatch(fetchAddtionalAsstets(courseId, totalCount - 50));
+ dispatch(fetchAdditionalAssets(courseId, totalCount - 50));
}
dispatch(updateLoadingStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
} catch (error) {
@@ -104,7 +106,7 @@ export function deleteAssetFile(courseId, id) {
};
}
-export function addAssetFile(courseId, file) {
+export function addAssetFile(courseId, file, isOverwrite) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }));
@@ -115,9 +117,11 @@ export function addAssetFile(courseId, file) {
modelType: 'assets',
model: { ...parsedAssets },
}));
- dispatch(addAssetSuccess({
- assetId: asset.id,
- }));
+ if (!isOverwrite) {
+ dispatch(addAssetSuccess({
+ assetId: asset.id,
+ }));
+ }
dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.SUCCESSFUL }));
} catch (error) {
if (error.response && error.response.status === 413) {
@@ -131,6 +135,30 @@ export function addAssetFile(courseId, file) {
};
}
+export function validateAssetFiles(courseId, files) {
+ return async (dispatch) => {
+ dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.IN_PROGRESS }));
+ dispatch(updateDuplicateFiles({ files: {} }));
+
+ try {
+ const filenames = [];
+ files.forEach(file => filenames.push(file.name));
+ await getAssetDetails({ courseId, filenames, fileCount: filenames.length }).then(({ assets }) => {
+ const [conflicts, newFiles] = getUploadConflicts(files, assets);
+ if (!isEmpty(newFiles)) {
+ newFiles.forEach(file => dispatch(addAssetFile(courseId, file)));
+ }
+ if (!isEmpty(conflicts)) {
+ dispatch(updateDuplicateFiles({ files: conflicts }));
+ }
+ });
+ } catch (error) {
+ files.forEach(file => dispatch(updateErrors({ error: 'add', message: `Failed to validate ${file.name}.` })));
+ dispatch(updateEditStatus({ editType: 'add', status: RequestStatus.FAILED }));
+ }
+ };
+}
+
export function updateAssetLock({ assetId, courseId, locked }) {
return async (dispatch) => {
dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.IN_PROGRESS }));
diff --git a/src/files-and-videos/files-page/data/utils.js b/src/files-and-videos/files-page/data/utils.js
index 993875353d..57d4d7aab2 100644
--- a/src/files-and-videos/files-page/data/utils.js
+++ b/src/files-and-videos/files-page/data/utils.js
@@ -57,3 +57,17 @@ export const getSrc = ({ thumbnail, wrapperType, externalUrl }) => {
return InsertDriveFile;
}
};
+
+export const getUploadConflicts = (filesToUpload, assets) => {
+ const filesFound = assets.map(item => item.displayName);
+ const conflicts = {};
+ const newFiles = [];
+ filesToUpload.forEach(file => {
+ if (filesFound.includes(file.name)) {
+ conflicts[file.name] = file;
+ } else {
+ newFiles.push(file);
+ }
+ });
+ return [conflicts, newFiles];
+};
diff --git a/src/files-and-videos/files-page/factories/mockApiResponses.jsx b/src/files-and-videos/files-page/factories/mockApiResponses.jsx
index 0ad2f06409..7c6d1bc5cd 100644
--- a/src/files-and-videos/files-page/factories/mockApiResponses.jsx
+++ b/src/files-and-videos/files-page/factories/mockApiResponses.jsx
@@ -22,6 +22,7 @@ export const initialState = {
usageMetrics: [],
loading: '',
},
+ duplicateFiles: {},
},
models: {
assets: {
@@ -101,9 +102,9 @@ export const generateFetchAssetApiResponse = () => ({
displayName: 'mOckID6',
locked: false,
externalUrl: 'static_tab_1',
- portableUrl: 'May 17, 2023 at 22:08 UTC',
+ portableUrl: '',
contentType: 'application/octet-stream',
- dateAdded: '',
+ dateAdded: 'May 17, 2023 at 22:08 UTC',
thumbnail: null,
},
],
diff --git a/src/files-and-videos/files-page/messages.js b/src/files-and-videos/files-page/messages.js
index b06b48d45d..646c4d2c58 100644
--- a/src/files-and-videos/files-page/messages.js
+++ b/src/files-and-videos/files-page/messages.js
@@ -81,6 +81,26 @@ const messages = defineMessages({
id: 'course-authoring.files-and-videos.sort-and-filter.modal.filter.otherCheckbox.label',
defaultMessage: 'Other',
},
+ overwriteConfirmMessage: {
+ id: 'course-authoring.files-and-videos.overwrite.modal.confirmation-message',
+ defaultMessage: 'Some of the uploaded files already exist in this course. Do you want to overwrite the following files?',
+ description: 'The message displayed in the modal shown when uploading files with pre-existing names',
+ },
+ overwriteModalTitle: {
+ id: 'course-authoring.files-and-videos.overwrite.modal.title',
+ defaultMessage: 'Overwrite files',
+ description: 'The title of the modal to confirm overwriting the files',
+ },
+ confirmOverwriteButtonLabel: {
+ id: 'course-authoring.files-and-videos.overwrite.modal.overwrite-button.label',
+ defaultMessage: 'Overwrite',
+ description: 'The message displayed in the button to confirm overwriting the files',
+ },
+ cancelOverwriteButtonLabel: {
+ id: 'course-authoring.files-and-videos.overwrite.modal.cancel-button.label',
+ defaultMessage: 'Cancel',
+ description: 'The message displayed in the button to confirm cancelling the upload',
+ },
});
export default messages;
diff --git a/src/files-and-videos/generic/FileInput.jsx b/src/files-and-videos/generic/FileInput.jsx
index 1de18b052a..455853722f 100644
--- a/src/files-and-videos/generic/FileInput.jsx
+++ b/src/files-and-videos/generic/FileInput.jsx
@@ -12,10 +12,9 @@ export const useFileInput = ({
const addFile = (e) => {
const { files } = e.target;
setSelectedRows(files);
- Object.values(files).forEach(file => {
- onAddFile(file);
- setAddOpen();
- });
+ onAddFile(Object.values(files));
+ setAddOpen();
+ e.target.value = '';
};
return {
click,
diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx
index 9907164ef8..60d364c5e9 100644
--- a/src/files-and-videos/generic/FileTable.jsx
+++ b/src/files-and-videos/generic/FileTable.jsx
@@ -95,14 +95,14 @@ const FileTable = ({
}, [files]);
const fileInputControl = useFileInput({
- onAddFile: (file) => handleAddFile(file),
+ onAddFile: (uploads) => handleAddFile(uploads),
setSelectedRows,
setAddOpen,
});
const handleDropzoneAsset = ({ fileData, handleError }) => {
try {
const file = fileData.get('file');
- handleAddFile(file);
+ handleAddFile([file]);
} catch (error) {
handleError(error);
}
diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx
index 662577c65c..74cf61d320 100644
--- a/src/files-and-videos/videos-page/VideosPage.jsx
+++ b/src/files-and-videos/videos-page/VideosPage.jsx
@@ -80,11 +80,14 @@ const VideosPage = ({
const supportedFileFormats = { 'video/*': videoSupportedFileFormats || FILES_AND_UPLOAD_TYPE_FILTERS.video };
- const handleAddFile = (file) => dispatch(addVideoFile(courseId, file, videoIds));
+ const handleErrorReset = (error) => dispatch(resetErrors(error));
+ const handleAddFile = (files) => {
+ handleErrorReset({ errorType: 'add' });
+ files.forEach(file => dispatch(addVideoFile(courseId, file, videoIds)));
+ };
const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id));
const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId }));
const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId }));
- const handleErrorReset = (error) => dispatch(resetErrors(error));
const handleFileOrder = ({ newFileIdOrder, sortType }) => {
dispatch(updateVideoOrder(courseId, newFileIdOrder, sortType));
};
diff --git a/src/generic/styles.scss b/src/generic/styles.scss
index becfb9a77a..0a8dde0e9a 100644
--- a/src/generic/styles.scss
+++ b/src/generic/styles.scss
@@ -6,3 +6,4 @@
@import "./create-or-rerun-course/CreateOrRerunCourseForm";
@import "./WysiwygEditor";
@import "./course-stepper/CouseStepper";
+@import "./tag-count/TagCount";
diff --git a/src/index.jsx b/src/index.jsx
index 57db4eb380..725cdea0b5 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -112,6 +112,7 @@ initialize({
CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null,
ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false',
ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true',
+ ENABLE_OPEN_MANAGED_TEAM_TYPE: process.env.ENABLE_OPEN_MANAGED_TEAM_TYPE === 'true',
BBB_LEARN_MORE_URL: process.env.BBB_LEARN_MORE_URL || '',
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
STUDIO_SHORT_NAME: process.env.STUDIO_SHORT_NAME || null,
diff --git a/src/index.scss b/src/index.scss
index feedc94249..fc03c9f773 100755
--- a/src/index.scss
+++ b/src/index.scss
@@ -19,7 +19,9 @@
@import "import-page/CourseImportPage";
@import "taxonomy";
@import "files-and-videos";
-@import "content-tags-drawer/TagBubble";
+@import "content-tags-drawer";
@import "course-outline/CourseOutline";
@import "course-unit/CourseUnit";
@import "course-checklist/CourseChecklist";
+@import "content-tags-drawer/ContentTagsDropDownSelector";
+@import "content-tags-drawer/ContentTagsCollapsible";