diff --git a/.env b/.env index 5183e318c7..9c900c37fb 100644 --- a/.env +++ b/.env @@ -41,3 +41,4 @@ HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=false INVITE_STUDENTS_EMAIL_TO='' +AI_TRANSLATIONS_BASE_URL='' diff --git a/.env.development b/.env.development index 4b98461d45..55b8ce70cd 100644 --- a/.env.development +++ b/.env.development @@ -33,7 +33,6 @@ ENABLE_ACCESSIBILITY_PAGE=false ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true -ENABLE_NEW_COURSE_OUTLINE_PAGE = false ENABLE_NEW_VIDEO_UPLOAD_PAGE = false ENABLE_UNIT_PAGE = false ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false @@ -43,3 +42,4 @@ HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=true INVITE_STUDENTS_EMAIL_TO="someone@domain.com" +AI_TRANSLATIONS_BASE_URL='http://localhost:18760' diff --git a/package-lock.json b/package-lock.json index 8e2e8758b5..fa7dd56936 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,11 @@ "license": "AGPL-3.0", "dependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", + "@edx/frontend-component-ai-translations-edx": "^1.4.1", "@edx/frontend-component-footer": "^12.3.0", "@edx/frontend-component-header": "^4.7.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "1.176.0", + "@edx/frontend-lib-content-components": "1.177.1", "@edx/frontend-platform": "5.6.1", "@edx/paragon": "^21.5.6", "@fortawesome/fontawesome-svg-core": "1.2.36", @@ -2540,6 +2541,42 @@ "node": ">=8" } }, + "node_modules/@edx/frontend-component-ai-translations-edx": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@edx/frontend-component-ai-translations-edx/-/frontend-component-ai-translations-edx-1.4.1.tgz", + "integrity": "sha512-uHRUv7LwIA+1gBWyTgETMMn6izpIUVxGl5ZicnCLRXnS/sbAMpA+0QWpN/S+7J9szz42KZADhxQEI1XuMnFL4g==", + "dependencies": { + "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", + "@edx/frontend-platform": "5.6.1", + "@edx/paragon": "21.5.6", + "babel-polyfill": "6.26.0", + "prop-types": "^15.5.10", + "react-responsive": "8.2.0" + }, + "peerDependencies": { + "@edx/frontend-platform": "^4.0.0 || ^5.0.0 || ^6.0.0", + "prop-types": "^15.5.10", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0" + } + }, + "node_modules/@edx/frontend-component-ai-translations-edx/node_modules/react-responsive": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-8.2.0.tgz", + "integrity": "sha512-iagCqVrw4QSjhxKp3I/YK6+ODkWY6G+YPElvdYKiUUbywwh9Ds0M7r26Fj2/7dWFFbOpcGnJE6uE7aMck8j5Qg==", + "dependencies": { + "hyphenate-style-name": "^1.0.0", + "matchmediaquery": "^0.3.0", + "prop-types": "^15.6.1", + "shallow-equal": "^1.1.0" + }, + "engines": { + "node": ">= 0.10" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@edx/frontend-component-footer": { "version": "12.5.1", "resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.5.1.tgz", @@ -2725,9 +2762,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "1.176.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.176.0.tgz", - "integrity": "sha512-WGEl4+LWnpUJQ1Li2VxHa4VpudNgR4ZEB2i/qZLfaW8zw8hBviWVuig8ONKgYBtaE5uPggInSBMWj0ixVAzYRw==", + "version": "1.177.1", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.177.1.tgz", + "integrity": "sha512-sEmQa9RV37FdUfYvp1i1026uRKM4/iMHJTe6o0gklAJN8B8YZCMdjLj32POpAPcZdD4yKFNRteRU6EeKUHW68w==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", diff --git a/package.json b/package.json index 9427be6b59..5fd9d403f9 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,11 @@ }, "dependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", + "@edx/frontend-component-ai-translations-edx": "^1.4.1", "@edx/frontend-component-footer": "^12.3.0", "@edx/frontend-component-header": "^4.7.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "1.176.0", + "@edx/frontend-lib-content-components": "1.177.1", "@edx/frontend-platform": "5.6.1", "@edx/paragon": "^21.5.6", "@fortawesome/fontawesome-svg-core": "1.2.36", diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 0aa1043a6d..6ff7f475bd 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -11,6 +11,7 @@ import VideoSelectorContainer from './selectors/VideoSelectorContainer'; import CustomPages from './custom-pages'; import { FilesPage, VideosPage } from './files-and-videos'; import { AdvancedSettings } from './advanced-settings'; +import { CourseOutline } from './course-outline'; import ScheduleAndDetails from './schedule-and-details'; import { GradingSettings } from './grading-settings'; import CourseTeam from './course-team/CourseTeam'; @@ -41,8 +42,8 @@ const CourseAuthoringRoutes = () => { : null} + path="/" + element={} /> tag) + * @param {boolean} editable - Whether the tags can be edited */ -const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => { +const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) => { const intl = useIntl(); + const { id, name } = taxonomyAndTagsData; + const { - id, name, contentTags, - } = taxonomyAndTagsData; + tagChangeHandler, tagsTree, contentTagsCount, checkedTags, + } = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData); const [isOpen, open, close] = useToggle(false); - const [target, setTarget] = React.useState(null); + const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null); + + const handleSelectableBoxChange = React.useCallback((e) => { + tagChangeHandler(e.target.value, e.target.checked); + }); return (
- +
- + + {editable && ( + + )}
@@ -76,11 +155,14 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => { columns={1} ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)} className="taxonomy-tags-selectable-box-set" + onChange={handleSelectableBoxChange} + value={checkedTags} >
@@ -92,11 +174,10 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => { variant="light" pill className={classNames('align-self-start', 'mt-3', { - // eslint-disable-next-line quote-props - 'invisible': contentTags.length === 0, + invisible: contentTagsCount === 0, })} > - {contentTags.length} + {contentTagsCount} @@ -104,6 +185,7 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => { }; ContentTagsCollapsible.propTypes = { + contentId: PropTypes.string.isRequired, taxonomyAndTagsData: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, @@ -112,6 +194,7 @@ ContentTagsCollapsible.propTypes = { lineage: PropTypes.arrayOf(PropTypes.string), })), }).isRequired, + editable: PropTypes.bool.isRequired, }; export default ContentTagsCollapsible; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.scss b/src/content-tags-drawer/ContentTagsCollapsible.scss index a207fd9474..e55d597af3 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.scss +++ b/src/content-tags-drawer/ContentTagsCollapsible.scss @@ -22,3 +22,7 @@ overflow-y: scroll; max-height: 20rem; } + +.pgn__modal-popup__arrow { + visibility: hidden; +} diff --git a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx index eca8c94bfc..096eb27457 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx @@ -1,37 +1,59 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { act, render } from '@testing-library/react'; +import { + act, + render, + fireEvent, + waitFor, +} from '@testing-library/react'; import PropTypes from 'prop-types'; import ContentTagsCollapsible from './ContentTagsCollapsible'; +import messages from './messages'; +import { useTaxonomyTagsData } from './data/apiHooks'; jest.mock('./data/apiHooks', () => ({ - useTaxonomyTagsDataResponse: jest.fn(), - useIsTaxonomyTagsDataLoaded: jest.fn(), + useContentTaxonomyTagsUpdater: jest.fn(() => ({ + isError: false, + mutate: jest.fn(), + })), + useTaxonomyTagsData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), })); const data = { - id: 123, - name: 'Taxonomy 1', - contentTags: [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - }, - { - value: 'Tag 2', - lineage: ['Tag 2'], - }, - ], + contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab', + taxonomyAndTagsData: { + id: 123, + name: 'Taxonomy 1', + contentTags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + }, + { + value: 'Tag 1.1', + lineage: ['Tag 1', 'Tag 1.1'], + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + }, + ], + }, + editable: true, }; -const ContentTagsCollapsibleComponent = ({ taxonomyAndTagsData }) => ( +const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData, editable }) => ( - + ); ContentTagsCollapsibleComponent.propTypes = { + contentId: PropTypes.string.isRequired, taxonomyAndTagsData: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, @@ -40,22 +62,155 @@ ContentTagsCollapsibleComponent.propTypes = { lineage: PropTypes.arrayOf(PropTypes.string), })), }).isRequired, + editable: PropTypes.bool.isRequired, }; describe('', () => { it('should render taxonomy tags data along content tags number badge', async () => { await act(async () => { - const { container, getByText } = render(); + const { container, getByText } = render( + , + ); expect(getByText('Taxonomy 1')).toBeInTheDocument(); expect(container.getElementsByClassName('badge').length).toBe(1); - expect(getByText('2')).toBeInTheDocument(); + expect(getByText('3')).toBeInTheDocument(); + }); + }); + + it('should render new tags as they are checked in the dropdown', async () => { + useTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + results: [{ + value: 'Tag 1', + subTagsUrl: null, + }, { + value: 'Tag 2', + subTagsUrl: null, + }, { + value: 'Tag 3', + subTagsUrl: null, + }], + }, + }); + + await act(async () => { + const { container, getByText, getAllByText } = render( + , + ); + + // Expand the Taxonomy to view applied tags and "Add tags" button + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + await act(async () => { + fireEvent.click(expandToggle); + }); + + // Click on "Add tags" button to open dropdown to select new tags + const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); + await act(async () => { + fireEvent.click(addTagsButton); + }); + + // Wait for the dropdown selector for tags to open, + // Tag 3 should only appear there + await waitFor(() => { + expect(getByText('Tag 3')).toBeInTheDocument(); + expect(getAllByText('Tag 3').length === 1); + }); + + const tag3 = getByText('Tag 3'); + await act(async () => { + fireEvent.click(tag3); + }); + + // After clicking on Tag 3, it should also appear in amongst + // the tag bubbles in the tree + await waitFor(() => { + expect(getAllByText('Tag 3').length === 2); + }); + }); + }); + + it('should remove tag when they are unchecked in the dropdown', async () => { + useTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + results: [{ + value: 'Tag 1', + subTagsUrl: null, + }, { + value: 'Tag 2', + subTagsUrl: null, + }, { + value: 'Tag 3', + subTagsUrl: null, + }], + }, + }); + + await act(async () => { + const { container, getByText, getAllByText } = render( + , + ); + + // Expand the Taxonomy to view applied tags and "Add tags" button + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + await act(async () => { + fireEvent.click(expandToggle); + }); + + // Check that Tag 2 appears in tag bubbles + await waitFor(() => { + expect(getByText('Tag 2')).toBeInTheDocument(); + }); + + // Click on "Add tags" button to open dropdown to select new tags + const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); + await act(async () => { + fireEvent.click(addTagsButton); + }); + + // Wait for the dropdown selector for tags to open, + // Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied) + await waitFor(() => { + expect(getByText('Tag 3')).toBeInTheDocument(); + }); + + // Get the Tag 2 checkbox and click on it + const tag2 = getAllByText('Tag 2')[1]; + await act(async () => { + fireEvent.click(tag2); + }); + + // After clicking on Tag 2, it should be removed from + // the tag bubbles in so only the one in the dropdown appears + expect(getAllByText('Tag 2').length === 1); }); }); it('should render taxonomy tags data without tags number badge', async () => { - data.contentTags = []; + const updatedData = { ...data }; + updatedData.taxonomyAndTagsData.contentTags = []; await act(async () => { - const { container, getByText } = render(); + const { container, getByText } = render( + , + ); expect(getByText('Taxonomy 1')).toBeInTheDocument(); expect(container.getElementsByClassName('invisible').length).toBe(1); }); diff --git a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx new file mode 100644 index 0000000000..3692e15492 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { useCheckboxSetValues } from '@edx/paragon'; +import { cloneDeep } from 'lodash'; + +import { useContentTaxonomyTagsUpdater } from './data/apiHooks'; + +/** + * Util function that consolidates two tag trees into one, sorting the keys in + * alphabetical order. + * + * @param {object} tree1 - first tag tree + * @param {object} tree2 - second tag tree + * @returns {object} merged tree containing both tree1 and tree2 + */ +const mergeTrees = (tree1, tree2) => { + const mergedTree = cloneDeep(tree1); + + const sortKeysAlphabetically = (obj) => { + const sortedObj = {}; + Object.keys(obj) + .sort() + .forEach((key) => { + sortedObj[key] = obj[key]; + if (obj[key] && typeof obj[key] === 'object') { + sortedObj[key].children = sortKeysAlphabetically(obj[key].children); + } + }); + return sortedObj; + }; + + const mergeRecursively = (destination, source) => { + Object.entries(source).forEach(([key, sourceValue]) => { + const destinationValue = destination[key]; + + if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') { + mergeRecursively(destinationValue, sourceValue); + } else { + // eslint-disable-next-line no-param-reassign + destination[key] = cloneDeep(sourceValue); + } + }); + }; + + mergeRecursively(mergedTree, tree2); + return sortKeysAlphabetically(mergedTree); +}; + +/** + * Util function that removes the tag along with its ancestors if it was + * the only explicit child tag. + * + * @param {object} tree - tag tree to remove the tag from + * @param {string[]} tagsToRemove - full lineage of tag to remove. + * eg: ['grand parent', 'parent', 'tag'] + */ +const removeTags = (tree, tagsToRemove) => { + if (!tree || !tagsToRemove.length) { + return; + } + const key = tagsToRemove[0]; + if (tree[key]) { + removeTags(tree[key].children, tagsToRemove.slice(1)); + + if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) { + // eslint-disable-next-line no-param-reassign + delete tree[key]; + } + } +}; + +/* + * Handles all the underlying logic for the ContentTagsCollapsible component + */ +const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { + const { + id, contentTags, + } = taxonomyAndTagsData; + // State to determine whether the tags are being updating so we can make a call + // to the update endpoint to the reflect those changes + const [updatingTags, setUpdatingTags] = React.useState(false); + const updateTags = useContentTaxonomyTagsUpdater(contentId, id); + + // Keeps track of the content objects tags count (both implicit and explicit) + const [contentTagsCount, setContentTagsCount] = React.useState(0); + + // Keeps track of the tree structure for tags that are add by selecting/unselecting + // tags in the dropdowns. + const [addedContentTags, setAddedContentTags] = React.useState({}); + + // To handle checking/unchecking tags in the SelectableBox + const [checkedTags, { add, remove, clear }] = useCheckboxSetValues(); + + // Handles making requests to the update endpoint whenever the checked tags change + React.useEffect(() => { + // We have this check because this hook is fired when the component first loads + // and reloads (on refocus). We only want to make a request to the update endpoint when + // the user is updating the tags. + if (updatingTags) { + setUpdatingTags(false); + const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1))); + updateTags.mutate({ tags }); + } + }, [contentId, id, checkedTags]); + + // This converts the contentTags prop to the tree structure mentioned above + const appliedContentTags = React.useMemo(() => { + let contentTagsCounter = 0; + + // Clear all the tags that have not been commited and the checked boxes when + // fresh contentTags passed in so the latest state from the backend is rendered + setAddedContentTags({}); + clear(); + + // When an error occurs while updating, the contentTags query is invalidated, + // hence they will be recalculated, and the updateTags mutation should be reset. + if (updateTags.isError) { + updateTags.reset(); + } + + const resultTree = {}; + contentTags.forEach(item => { + let currentLevel = resultTree; + + item.lineage.forEach((key, index) => { + if (!currentLevel[key]) { + const isExplicit = index === item.lineage.length - 1; + currentLevel[key] = { + explicit: isExplicit, + children: {}, + }; + + // Populating the SelectableBox with "selected" (explicit) tags + const value = item.lineage.map(l => encodeURIComponent(l)).join(','); + // eslint-disable-next-line no-unused-expressions + isExplicit ? add(value) : remove(value); + contentTagsCounter += 1; + } + + currentLevel = currentLevel[key].children; + }); + }); + + setContentTagsCount(contentTagsCounter); + return resultTree; + }, [contentTags, updateTags.isError]); + + // This is the source of truth that represents the current state of tags in + // this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in + // the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by + // selecting/unselecting them in the dropdown) change, the tree is recomputed. + const tagsTree = React.useMemo(() => ( + mergeTrees(appliedContentTags, addedContentTags) + ), [appliedContentTags, addedContentTags]); + + // Add tag to the tree, and while traversing remove any selected ancestor tags + // as they should become implicit + const addTags = (tree, tagLineage, selectedTag) => { + const value = []; + let traversal = tree; + tagLineage.forEach(tag => { + const isExplicit = selectedTag === tag; + + if (!traversal[tag]) { + traversal[tag] = { explicit: isExplicit, children: {} }; + } else { + traversal[tag].explicit = isExplicit; + } + + // Clear out the ancestor tags leading to newly selected tag + // as they automatically become implicit + value.push(encodeURIComponent(tag)); + // eslint-disable-next-line no-unused-expressions + isExplicit ? add(value.join(',')) : remove(value.join(',')); + + traversal = traversal[tag].children; + }); + }; + + const tagChangeHandler = React.useCallback((tagSelectableBoxValue, checked) => { + const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t)); + const selectedTag = tagLineage.slice(-1)[0]; + + const addedTree = { ...addedContentTags }; + if (checked) { + // We "add" the tag to the SelectableBox.Set inside the addTags method + addTags(addedTree, tagLineage, selectedTag); + } else { + // Remove tag from the SelectableBox.Set + remove(tagSelectableBoxValue); + + // We remove them from both incase we are unselecting from an + // existing applied Tag or a newly added one + removeTags(addedTree, tagLineage); + removeTags(appliedContentTags, tagLineage); + } + + setAddedContentTags(addedTree); + setUpdatingTags(true); + }); + + return { + tagChangeHandler, tagsTree, contentTagsCount, checkedTags, + }; +}; + +export default useContentTagsCollapsibleHelper; diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx index eeed39be1d..071c8d4075 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.jsx @@ -10,10 +10,8 @@ import messages from './messages'; import ContentTagsCollapsible from './ContentTagsCollapsible'; import { extractOrgFromContentId } from './utils'; import { - useContentTaxonomyTagsDataResponse, - useIsContentTaxonomyTagsDataLoaded, - useContentDataResponse, - useIsContentDataLoaded, + useContentTaxonomyTagsData, + useContentData, } from './data/apiHooks'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks'; import Loading from '../generic/Loading'; @@ -24,26 +22,17 @@ const ContentTagsDrawer = () => { const org = extractOrgFromContentId(contentId); - const useContentData = () => { - const contentData = useContentDataResponse(contentId); - const isContentDataLoaded = useIsContentDataLoaded(contentId); - return { contentData, isContentDataLoaded }; - }; - - const useContentTaxonomyTagsData = () => { - const contentTaxonomyTagsData = useContentTaxonomyTagsDataResponse(contentId); - const isContentTaxonomyTagsLoaded = useIsContentTaxonomyTagsDataLoaded(contentId); - return { contentTaxonomyTagsData, isContentTaxonomyTagsLoaded }; - }; - const useTaxonomyListData = () => { const taxonomyListData = useTaxonomyListDataResponse(org); const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org); return { taxonomyListData, isTaxonomyListLoaded }; }; - const { contentData, isContentDataLoaded } = useContentData(); - const { contentTaxonomyTagsData, isContentTaxonomyTagsLoaded } = useContentTaxonomyTagsData(); + const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId); + const { + data: contentTaxonomyTagsData, + isSuccess: isContentTaxonomyTagsLoaded, + } = useContentTaxonomyTagsData(contentId); const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData(); const closeContentTagsDrawer = () => { @@ -113,7 +102,8 @@ const ContentTagsDrawer = () => { { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded ? taxonomies.map((data) => (
- + {/* TODO: Properly set whether tags should be editable or not based on permissions */} +
)) diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index ad479b1569..c722fb48be 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -4,10 +4,8 @@ import { act, render, fireEvent } from '@testing-library/react'; import ContentTagsDrawer from './ContentTagsDrawer'; import { - useContentTaxonomyTagsDataResponse, - useIsContentTaxonomyTagsDataLoaded, - useContentDataResponse, - useIsContentDataLoaded, + useContentTaxonomyTagsData, + useContentData, } from './data/apiHooks'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks'; @@ -19,12 +17,17 @@ jest.mock('react-router-dom', () => ({ })); jest.mock('./data/apiHooks', () => ({ - useContentTaxonomyTagsDataResponse: jest.fn(), - useIsContentTaxonomyTagsDataLoaded: jest.fn(), - useContentDataResponse: jest.fn(), - useIsContentDataLoaded: jest.fn(), - useTaxonomyTagsDataResponse: jest.fn(), - useIsTaxonomyTagsDataLoaded: jest.fn(), + useContentTaxonomyTagsData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), + useContentData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), + useContentTaxonomyTagsUpdater: jest.fn(() => ({ + isError: false, + })), })); jest.mock('../taxonomy/data/apiHooks', () => ({ @@ -45,7 +48,6 @@ describe('', () => { }); it('shows spinner before the content data query is complete', async () => { - useIsContentDataLoaded.mockReturnValue(false); await act(async () => { const { getAllByRole } = render(); const spinner = getAllByRole('status')[0]; @@ -55,7 +57,6 @@ describe('', () => { it('shows spinner before the taxonomy tags query is complete', async () => { useIsTaxonomyListDataLoaded.mockReturnValue(false); - useIsContentTaxonomyTagsDataLoaded.mockReturnValue(false); await act(async () => { const { getAllByRole } = render(); const spinner = getAllByRole('status')[1]; @@ -64,9 +65,11 @@ describe('', () => { }); it('shows the content display name after the query is complete', async () => { - useIsContentDataLoaded.mockReturnValue(true); - useContentDataResponse.mockReturnValue({ - displayName: 'Unit 1', + useContentData.mockReturnValue({ + isSuccess: true, + data: { + displayName: 'Unit 1', + }, }); await act(async () => { const { getByText } = render(); @@ -76,36 +79,38 @@ describe('', () => { it('shows the taxonomies data including tag numbers after the query is complete', async () => { useIsTaxonomyListDataLoaded.mockReturnValue(true); - useIsContentTaxonomyTagsDataLoaded.mockReturnValue(true); - useContentTaxonomyTagsDataResponse.mockReturnValue({ - taxonomies: [ - { - name: 'Taxonomy 1', - taxonomyId: 123, - editable: true, - tags: [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - }, - { - value: 'Tag 2', - lineage: ['Tag 2'], - }, - ], - }, - { - name: 'Taxonomy 2', - taxonomyId: 124, - editable: true, - tags: [ - { - value: 'Tag 3', - lineage: ['Tag 3'], - }, - ], - }, - ], + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + editable: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + }, + ], + }, + { + name: 'Taxonomy 2', + taxonomyId: 124, + editable: true, + tags: [ + { + value: 'Tag 3', + lineage: ['Tag 3'], + }, + ], + }, + ], + }, }); useTaxonomyListDataResponse.mockReturnValue({ results: [{ diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx index e71ac8a80f..bde6d5e968 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx @@ -1,19 +1,20 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { SelectableBox, Icon, Spinner, + Button, } from '@edx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; import messages from './messages'; import './ContentTagsDropDownSelector.scss'; -import { useTaxonomyTagsDataResponse, useIsTaxonomyTagsDataLoaded } from './data/apiHooks'; +import { useTaxonomyTagsData } from './data/apiHooks'; const ContentTagsDropDownSelector = ({ - taxonomyId, level, subTagsUrl, + taxonomyId, level, subTagsUrl, lineage, tagsTree, }) => { const intl = useIntl(); // This object represents the states of the dropdowns on this level @@ -21,6 +22,19 @@ const ContentTagsDropDownSelector = ({ // the value true (open) false (closed) const [dropdownStates, setDropdownStates] = useState({}); + const [tags, setTags] = useState([]); + const [nextPage, setNextPage] = useState(null); + + // `fetchUrl` is initially `subTagsUrl` to fetch the initial data, + // however if it is null that means it is the root, and the apiHooks + // would automatically handle it. Later this url is set to the next + // page of results (if any) + // + // TODO: In the future we may need to refactor this to keep track + // of the count for how many times the user clicked on "load more" then + // use useQueries to load all the pages based on that. + const [fetchUrl, setFetchUrl] = useState(subTagsUrl); + const isOpen = (i) => dropdownStates[i]; const clickAndEnterHandler = (i) => { @@ -29,12 +43,33 @@ const ContentTagsDropDownSelector = ({ setDropdownStates({ ...dropdownStates, [i]: !dropdownStates[i] }); }; - const taxonomyTagsData = useTaxonomyTagsDataResponse(taxonomyId, subTagsUrl); - const isTaxonomyTagsLoaded = useIsTaxonomyTagsDataLoaded(taxonomyId, subTagsUrl); + const { data: taxonomyTagsData, isSuccess: isTaxonomyTagsLoaded } = useTaxonomyTagsData(taxonomyId, fetchUrl); + + const isImplicit = (tag) => { + // Traverse the tags tree using the lineage + let traversal = tagsTree; + lineage.forEach(t => { + // We need to decode the tag to traverse the tree since the lineage value is encoded + traversal = traversal[decodeURIComponent(t)]?.children || {}; + }); + + return (traversal[tag.value] && !traversal[tag.value].explicit) || false; + }; + + useEffect(() => { + if (isTaxonomyTagsLoaded && taxonomyTagsData) { + setTags([...tags, ...taxonomyTagsData.results]); + setNextPage(taxonomyTagsData.next); + } + }, [isTaxonomyTagsLoaded, taxonomyTagsData]); + + const loadMoreTags = useCallback(() => { + setFetchUrl(nextPage); + }, [nextPage]); return ( - isTaxonomyTagsLoaded && taxonomyTagsData - ? taxonomyTagsData.results.map((taxonomyTag, i) => ( + <> + {tags.map((taxonomyTag, i) => (
{taxonomyTag.value} @@ -65,12 +103,27 @@ const ContentTagsDropDownSelector = ({ taxonomyId={taxonomyId} subTagsUrl={taxonomyTag.subTagsUrl} level={level + 1} + lineage={[...lineage, encodeURIComponent(taxonomyTag.value)]} + tagsTree={tagsTree} /> )}
- )) - : ( + ))} + + { nextPage && isTaxonomyTagsLoaded + ? ( + + ) + : null} + + { !isTaxonomyTagsLoaded ? (
- ) + ) : null} + ); }; ContentTagsDropDownSelector.defaultProps = { subTagsUrl: undefined, + lineage: [], }; ContentTagsDropDownSelector.propTypes = { taxonomyId: PropTypes.number.isRequired, level: PropTypes.number.isRequired, subTagsUrl: PropTypes.string, + lineage: PropTypes.arrayOf(PropTypes.string), + tagsTree: PropTypes.objectOf( + PropTypes.shape({ + explicit: PropTypes.bool.isRequired, + children: PropTypes.shape({}).isRequired, + }).isRequired, + ).isRequired, }; export default ContentTagsDropDownSelector; diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.scss b/src/content-tags-drawer/ContentTagsDropDownSelector.scss index 33c29517e8..a6b72affc9 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.scss +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.scss @@ -6,3 +6,12 @@ box-shadow: none; padding: 0; } + +.pgn__selectable_box.taxonomy-tags-selectable-box:disabled, +.pgn__selectable_box.taxonomy-tags-selectable-box[disabled] { + opacity: 1 !important; +} + +.pgn__selectable_box-active.taxonomy-tags-selectable-box { + outline: none !important; +} diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx index 80ca659632..5823feaac4 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx @@ -1,51 +1,64 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { act, render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import PropTypes from 'prop-types'; import ContentTagsDropDownSelector from './ContentTagsDropDownSelector'; -import { useTaxonomyTagsDataResponse, useIsTaxonomyTagsDataLoaded } from './data/apiHooks'; +import { useTaxonomyTagsData } from './data/apiHooks'; jest.mock('./data/apiHooks', () => ({ - useTaxonomyTagsDataResponse: jest.fn(), - useIsTaxonomyTagsDataLoaded: jest.fn(), + useTaxonomyTagsData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), })); const data = { taxonomyId: 123, level: 0, + tagsTree: {}, }; -const TaxonomyTagsDropDownSelectorComponent = ({ - taxonomyId, level, subTagsUrl, +const ContentTagsDropDownSelectorComponent = ({ + taxonomyId, level, subTagsUrl, lineage, tagsTree, }) => ( ); -TaxonomyTagsDropDownSelectorComponent.defaultProps = { +ContentTagsDropDownSelectorComponent.defaultProps = { subTagsUrl: undefined, + lineage: [], }; -TaxonomyTagsDropDownSelectorComponent.propTypes = { +ContentTagsDropDownSelectorComponent.propTypes = { taxonomyId: PropTypes.number.isRequired, level: PropTypes.number.isRequired, subTagsUrl: PropTypes.string, + lineage: PropTypes.arrayOf(PropTypes.string), + tagsTree: PropTypes.objectOf( + PropTypes.shape({ + explicit: PropTypes.bool.isRequired, + children: PropTypes.shape({}).isRequired, + }).isRequired, + ).isRequired, }; describe('', () => { it('should render taxonomy tags drop down selector loading with spinner', async () => { - useIsTaxonomyTagsDataLoaded.mockReturnValue(false); await act(async () => { const { getByRole } = render( - , ); const spinner = getByRole('status'); @@ -54,43 +67,53 @@ describe('', () => { }); it('should render taxonomy tags drop down selector with no sub tags', async () => { - useIsTaxonomyTagsDataLoaded.mockReturnValue(true); - useTaxonomyTagsDataResponse.mockReturnValue({ - results: [{ - value: 'Tag 1', - subTagsUrl: null, - }], + useTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + results: [{ + value: 'Tag 1', + subTagsUrl: null, + }], + }, }); await act(async () => { const { container, getByText } = render( - , ); - expect(getByText('Tag 1')).toBeInTheDocument(); - expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0); + await waitFor(() => { + expect(getByText('Tag 1')).toBeInTheDocument(); + expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0); + }); }); }); it('should render taxonomy tags drop down selector with sub tags', async () => { - useIsTaxonomyTagsDataLoaded.mockReturnValue(true); - useTaxonomyTagsDataResponse.mockReturnValue({ - results: [{ - value: 'Tag 2', - subTagsUrl: 'https://example.com', - }], + useTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + results: [{ + value: 'Tag 2', + subTagsUrl: 'https://example.com', + }], + }, }); await act(async () => { const { container, getByText } = render( - , ); - expect(getByText('Tag 2')).toBeInTheDocument(); - expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); + await waitFor(() => { + expect(getByText('Tag 2')).toBeInTheDocument(); + expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); + }); }); }); }); diff --git a/src/content-tags-drawer/ContentTagsTree.jsx b/src/content-tags-drawer/ContentTagsTree.jsx index e75ead4766..08ae7e145f 100644 --- a/src/content-tags-drawer/ContentTagsTree.jsx +++ b/src/content-tags-drawer/ContentTagsTree.jsx @@ -1,88 +1,77 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import TagBubble from './TagBubble'; /** - * Component that renders Tags under a Taxonomy in the nested tree format - * It constructs a tree structure consolidating the tag data. Example: + * Component that renders Tags under a Taxonomy in the nested tree format. * - * FROM: - * - * [ - * { - * "value": "DNA Sequencing", - * "lineage": [ - * "Science and Research", - * "Genetics Subcategory", - * "DNA Sequencing" - * ] - * }, - * { - * "value": "Virology", - * "lineage": [ - * "Science and Research", - * "Molecular, Cellular, and Microbiology", - * "Virology" - * ] - * } - * ] - * - * TO: + * Example: * * { * "Science and Research": { - * "Genetics Subcategory": { - * "DNA Sequencing": {} - * }, - * "Molecular, Cellular, and Microbiology": { - * "Virology": {} + * explicit: false, + * children: { + * "Genetics Subcategory": { + * explicit: false, + * children: { + * "DNA Sequencing": { + * explicit: true, + * children: {} + * } + * } + * }, + * "Molecular, Cellular, and Microbiology": { + * explicit: false, + * children: { + * "Virology": { + * explicit: true, + * children: {} + * } + * } + * } * } * } - * } + * }; * - * @param {Object[]} appliedContentTags - Array of taxonomy tags that are applied to the content - * @param {string} appliedContentTags.value - Value of applied Tag - * @param {string} appliedContentTags.lineage - Array of Tag's ancestors sorted (ancestor -> tag) + * @param {Object} tagsTree - Array of taxonomy tags that are applied to the content + * @param {Func} removeTagHandler - Function that is called when removing tags from tree + * @param {boolean} editable - Whether the tags appear with an 'x' allowing the user to remove them */ -const ContentTagsTree = ({ appliedContentTags }) => { - const tagsTree = useMemo(() => { - const tree = {}; - appliedContentTags.forEach(tag => { - tag.lineage.reduce((currentLevel, ancestor) => { - // eslint-disable-next-line no-param-reassign - currentLevel[ancestor] = currentLevel[ancestor] || {}; - return currentLevel[ancestor]; - }, tree); - }); - return tree; - }, [appliedContentTags]); - - const renderTagsTree = (tag, level) => Object.keys(tag).map((key) => { +const ContentTagsTree = ({ tagsTree, removeTagHandler, editable }) => { + const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => { + const updatedLineage = [...lineage, encodeURIComponent(key)]; if (tag[key] !== undefined) { return (
- { renderTagsTree(tag[key], level + 1) } + { renderTagsTree(tag[key].children, level + 1, updatedLineage) }
); } return null; }); - return renderTagsTree(tagsTree, 0); + return renderTagsTree(tagsTree, 0, []); }; ContentTagsTree.propTypes = { - appliedContentTags: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.string, - lineage: PropTypes.arrayOf(PropTypes.string), - })).isRequired, + tagsTree: PropTypes.objectOf( + PropTypes.shape({ + explicit: PropTypes.bool.isRequired, + children: PropTypes.shape({}).isRequired, + }).isRequired, + ).isRequired, + removeTagHandler: PropTypes.func.isRequired, + editable: PropTypes.bool.isRequired, }; export default ContentTagsTree; diff --git a/src/content-tags-drawer/ContentTagsTree.test.jsx b/src/content-tags-drawer/ContentTagsTree.test.jsx index dd28cc9a98..ac41f3c1f1 100644 --- a/src/content-tags-drawer/ContentTagsTree.test.jsx +++ b/src/content-tags-drawer/ContentTagsTree.test.jsx @@ -5,42 +5,53 @@ import PropTypes from 'prop-types'; import ContentTagsTree from './ContentTagsTree'; -const data = [ - { - value: 'DNA Sequencing', - lineage: [ - 'Science and Research', - 'Genetics Subcategory', - 'DNA Sequencing', - ], +const data = { + 'Science and Research': { + explicit: false, + children: { + 'Genetics Subcategory': { + explicit: false, + children: { + 'DNA Sequencing': { + explicit: true, + children: {}, + }, + }, + }, + 'Molecular, Cellular, and Microbiology': { + explicit: false, + children: { + Virology: { + explicit: true, + children: {}, + }, + }, + }, + }, }, - { - value: 'Virology', - lineage: [ - 'Science and Research', - 'Molecular, Cellular, and Microbiology', - 'Virology', - ], - }, -]; +}; -const ContentTagsTreeComponent = ({ appliedContentTags }) => ( +const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler, editable }) => ( - + ); ContentTagsTreeComponent.propTypes = { - appliedContentTags: PropTypes.arrayOf(PropTypes.shape({ - value: PropTypes.string, - lineage: PropTypes.arrayOf(PropTypes.string), - })).isRequired, + tagsTree: PropTypes.objectOf( + PropTypes.shape({ + explicit: PropTypes.bool.isRequired, + children: PropTypes.shape({}).isRequired, + }).isRequired, + ).isRequired, + removeTagHandler: PropTypes.func.isRequired, + editable: PropTypes.bool.isRequired, }; describe('', () => { it('should render taxonomy tags data along content tags number badge', async () => { await act(async () => { - const { getByText } = render(); + const { getByText } = render( {}} editable />); expect(getByText('Science and Research')).toBeInTheDocument(); expect(getByText('Genetics Subcategory')).toBeInTheDocument(); expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument(); diff --git a/src/content-tags-drawer/TagBubble.jsx b/src/content-tags-drawer/TagBubble.jsx index 8c7137ffa0..2287e48ab3 100644 --- a/src/content-tags-drawer/TagBubble.jsx +++ b/src/content-tags-drawer/TagBubble.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { - Button, + Chip, } from '@edx/paragon'; import { Tag, Close } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; @@ -8,35 +8,43 @@ import PropTypes from 'prop-types'; import TagOutlineIcon from './TagOutlineIcon'; const TagBubble = ({ - value, subTagsCount, implicit, level, + value, implicit, level, lineage, removeTagHandler, editable, }) => { - const className = `tag-bubble mb-2 ${implicit ? 'implicit' : ''}`; - const tagIcon = () => (implicit ? : ); + const className = `tag-bubble mb-2 border-light-300 ${implicit ? 'implicit' : ''}`; + + const handleClick = React.useCallback(() => { + if (!implicit && editable) { + removeTagHandler(lineage.join(','), false); + } + }, [implicit, lineage, editable, removeTagHandler]); + return (
- + {value} +
); }; TagBubble.defaultProps = { - subTagsCount: 0, implicit: true, level: 0, }; TagBubble.propTypes = { value: PropTypes.string.isRequired, - subTagsCount: PropTypes.number, implicit: PropTypes.bool, level: PropTypes.number, + lineage: PropTypes.arrayOf(PropTypes.string).isRequired, + removeTagHandler: PropTypes.func.isRequired, + editable: PropTypes.bool.isRequired, }; export default TagBubble; diff --git a/src/content-tags-drawer/TagBubble.scss b/src/content-tags-drawer/TagBubble.scss index 281d0fe209..c64d80d342 100644 --- a/src/content-tags-drawer/TagBubble.scss +++ b/src/content-tags-drawer/TagBubble.scss @@ -1,13 +1,5 @@ -.tag-bubble.btn-outline-dark { - border-color: $light-300; - - &:hover { - color: $white; - background-color: $dark; - border-color: $dark; - } -} - -.implicit > .implicit-tag-icon { - color: $dark; +.tag-bubble.pgn__chip { + border-style: solid; + border-width: 2px; + background-color: transparent; } diff --git a/src/content-tags-drawer/TagBubble.test.jsx b/src/content-tags-drawer/TagBubble.test.jsx index 90ba32f288..48fe71ecfd 100644 --- a/src/content-tags-drawer/TagBubble.test.jsx +++ b/src/content-tags-drawer/TagBubble.test.jsx @@ -1,66 +1,98 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { render } from '@testing-library/react'; +import { act, render, fireEvent } from '@testing-library/react'; import PropTypes from 'prop-types'; import TagBubble from './TagBubble'; const data = { value: 'Tag 1', + lineage: [], + removeTagHandler: jest.fn(), }; -const TagBubbleComponent = ({ value, subTagsCount, implicit }) => ( +const TagBubbleComponent = ({ + value, implicit, level, lineage, removeTagHandler, editable, +}) => ( - + ); TagBubbleComponent.defaultProps = { - subTagsCount: 0, implicit: true, + level: 0, }; TagBubbleComponent.propTypes = { value: PropTypes.string.isRequired, - subTagsCount: PropTypes.number, implicit: PropTypes.bool, + level: PropTypes.number, + lineage: PropTypes.arrayOf(PropTypes.string).isRequired, + removeTagHandler: PropTypes.func.isRequired, + editable: PropTypes.bool.isRequired, }; describe('', () => { - it('should render only value of the implicit tag with no sub tags', () => { - const { container, getByText } = render(); + it('should render implicit tag', () => { + const { container, getByText } = render( + , + ); expect(getByText(data.value)).toBeInTheDocument(); expect(container.getElementsByClassName('implicit').length).toBe(1); + expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(0); }); - it('should render value of the implicit tag with sub tags', () => { + it('should render explicit tag', () => { const tagBubbleData = { - subTagsCount: 5, + implicit: false, ...data, }; const { container, getByText } = render( , ); - expect(getByText(`${tagBubbleData.value} (${tagBubbleData.subTagsCount})`)).toBeInTheDocument(); - expect(container.getElementsByClassName('implicit').length).toBe(1); + expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument(); + expect(container.getElementsByClassName('implicit').length).toBe(0); + expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(1); }); - it('should render value of the explicit tag with no sub tags', () => { + it('should call removeTagHandler when "x" clicked on explicit tag', async () => { const tagBubbleData = { implicit: false, ...data, }; - const { container, getByText } = render( + const { container } = render( , ); - expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument(); - expect(container.getElementsByClassName('implicit').length).toBe(0); - expect(container.getElementsByClassName('btn-icon-after').length).toBe(1); + + const xButton = container.getElementsByClassName('pgn__chip__icon-after')[0]; + await act(async () => { + fireEvent.click(xButton); + }); + expect(data.removeTagHandler).toHaveBeenCalled(); }); }); diff --git a/src/content-tags-drawer/TagOutlineIcon.jsx b/src/content-tags-drawer/TagOutlineIcon.jsx index f817b1f077..7f9d439254 100644 --- a/src/content-tags-drawer/TagOutlineIcon.jsx +++ b/src/content-tags-drawer/TagOutlineIcon.jsx @@ -10,7 +10,6 @@ const TagOutlineIcon = (props) => ( aria-hidden="true" {...props} > - diff --git a/src/content-tags-drawer/__mocks__/index.js b/src/content-tags-drawer/__mocks__/index.js index b09fc5d3ab..5ec3027386 100644 --- a/src/content-tags-drawer/__mocks__/index.js +++ b/src/content-tags-drawer/__mocks__/index.js @@ -1,3 +1,4 @@ export { default as taxonomyTagsMock } from './taxonomyTagsMock'; export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock'; export { default as contentDataMock } from './contentDataMock'; +export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock'; diff --git a/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js b/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js new file mode 100644 index 0000000000..cc319f1197 --- /dev/null +++ b/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js @@ -0,0 +1,25 @@ +module.exports = { + 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': { + taxonomies: [ + { + name: 'FlatTaxonomy', + taxonomyId: 3, + editable: true, + tags: [ + { + value: 'flat taxonomy tag 100', + lineage: [ + 'flat taxonomy tag 100', + ], + }, + { + value: 'flat taxonomy tag 3856', + lineage: [ + 'flat taxonomy tag 3856', + ], + }, + ], + }, + ], + }, +}; diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js index f6f4f1761e..a6082b33da 100644 --- a/src/content-tags-drawer/data/api.js +++ b/src/content-tags-drawer/data/api.js @@ -9,7 +9,7 @@ export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${co /** * Get all tags that belong to taxonomy. - * @param {string} taxonomyId The id of the taxonomy to fetch tags for + * @param {number} taxonomyId The id of the taxonomy to fetch tags for * @param {string} fullPathProvided Optional param that contains the full URL to fetch data * If provided, we use it instead of generating the URL. This is usually for fetching subTags * @returns {Promise} @@ -40,3 +40,17 @@ export async function getContentData(contentId) { const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId)); return camelCaseObject(data); } + +/** + * Update content object's applied tags + * @param {string} contentId The id of the content object (unit/component) + * @param {number} taxonomyId The id of the taxonomy the tags belong to + * @param {string[]} tags The list of tags (values) to set on content object + * @returns {Promise} + */ +export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) { + let url = getContentTaxonomyTagsApiUrl(contentId); + url = `${url}?taxonomy=${taxonomyId}`; + const { data } = await getAuthenticatedHttpClient().put(url, { tags }); + return camelCaseObject(data[contentId]); +} diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js index ffe19ab960..0d474ee0c2 100644 --- a/src/content-tags-drawer/data/api.test.js +++ b/src/content-tags-drawer/data/api.test.js @@ -2,7 +2,12 @@ import MockAdapter from 'axios-mock-adapter'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { taxonomyTagsMock, contentTaxonomyTagsMock, contentDataMock } from '../__mocks__'; +import { + taxonomyTagsMock, + contentTaxonomyTagsMock, + contentDataMock, + updateContentTaxonomyTagsMock, +} from '../__mocks__'; import { getTaxonomyTagsApiUrl, @@ -11,6 +16,7 @@ import { getTaxonomyTagsData, getContentTaxonomyTagsData, getContentData, + updateContentTaxonomyTags, } from './api'; let axiosMock; @@ -33,7 +39,7 @@ describe('content tags drawer api calls', () => { }); it('should get taxonomy tags data', async () => { - const taxonomyId = '123'; + const taxonomyId = 123; axiosMock.onGet().reply(200, taxonomyTagsMock); const result = await getTaxonomyTagsData(taxonomyId); @@ -42,7 +48,7 @@ describe('content tags drawer api calls', () => { }); it('should get taxonomy tags data with fullPathProvided', async () => { - const taxonomyId = '123'; + const taxonomyId = 123; const fullPathProvided = 'http://example.com/'; axiosMock.onGet().reply(200, taxonomyTagsMock); const result = await getTaxonomyTagsData(taxonomyId, fullPathProvided); @@ -68,4 +74,15 @@ describe('content tags drawer api calls', () => { expect(axiosMock.history.get[0].url).toEqual(getContentDataApiUrl(contentId)); expect(result).toEqual(contentDataMock); }); + + it('should update content taxonomy tags', async () => { + const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; + const taxonomyId = 3; + const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856']; + axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}?taxonomy=${taxonomyId}`).reply(200, updateContentTaxonomyTagsMock); + const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags); + + expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}?taxonomy=${taxonomyId}`); + expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]); + }); }); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index 90ed6b4af6..97ffde2fe2 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -1,108 +1,68 @@ // @ts-check -import { useQuery } from '@tanstack/react-query'; -import { getTaxonomyTagsData, getContentTaxonomyTagsData, getContentData } from './api'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + getTaxonomyTagsData, + getContentTaxonomyTagsData, + getContentData, + updateContentTaxonomyTags, +} from './api'; /** * Builds the query to get the taxonomy tags - * @param {string} taxonomyId The id of the taxonomy to fetch tags for + * @param {number} taxonomyId The id of the taxonomy to fetch tags for * @param {string} fullPathProvided Optional param that contains the full URL to fetch data * If provided, we use it instead of generating the URL. This is usually for fetching subTags */ -const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => ( +export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => ( useQuery({ queryKey: [`taxonomyTags${ fullPathProvided || taxonomyId }`], queryFn: () => getTaxonomyTagsData(taxonomyId, fullPathProvided), }) ); -/** - * Gets the taxonomy tags data - * @param {string} taxonomyId The id of the taxonomy to fetch tags for - * @param {string} fullPathProvided Optional param that contains the full URL to fetch data - * If provided, we use it instead of generating the URL. This is usually for fetching subTags - * @returns {import("./types.mjs").TaxonomyTagsData | undefined} - */ -export const useTaxonomyTagsDataResponse = (taxonomyId, fullPathProvided) => { - const response = useTaxonomyTagsData(taxonomyId, fullPathProvided); - if (response.status === 'success') { - return response.data; - } - return undefined; -}; - -/** - * Returns the status of the taxonomy tags query - * @param {string} taxonomyId The id of the taxonomy to fetch tags for - * @param {string} fullPathProvided Optional param that contains the full URL to fetch data - * If provided, we use it instead of generating the URL. This is usually for fetching subTags - * @returns {boolean} - */ -export const useIsTaxonomyTagsDataLoaded = (taxonomyId, fullPathProvided) => ( - useTaxonomyTagsData(taxonomyId, fullPathProvided).status === 'success' -); - /** * Builds the query to get the taxonomy tags applied to the content object * @param {string} contentId The id of the content object to fetch the applied tags for */ -const useContentTaxonomyTagsData = (contentId) => ( +export const useContentTaxonomyTagsData = (contentId) => ( useQuery({ - queryKey: ['contentTaxonomyTags'], + queryKey: ['contentTaxonomyTags', contentId], queryFn: () => getContentTaxonomyTagsData(contentId), }) ); -/** - * Gets the taxonomy tags applied to the content object - * @param {string} contentId The id of the content object to fetch the applied tags for - * @returns {import("./types.mjs").ContentTaxonomyTagsData | undefined} - */ -export const useContentTaxonomyTagsDataResponse = (contentId) => { - const response = useContentTaxonomyTagsData(contentId); - if (response.status === 'success') { - return response.data; - } - return undefined; -}; - -/** - * Gets the status of the content taxonomy tags query - * @param {string} contentId The id of the content object to fetch the applied tags for - * @returns {boolean} - */ -export const useIsContentTaxonomyTagsDataLoaded = (contentId) => ( - useContentTaxonomyTagsData(contentId).status === 'success' -); - /** * Builds the query to get meta data about the content object * @param {string} contentId The id of the content object (unit/component) */ -const useContentData = (contentId) => ( +export const useContentData = (contentId) => ( useQuery({ - queryKey: ['contentData'], + queryKey: ['contentData', contentId], queryFn: () => getContentData(contentId), }) ); /** - * Gets the information about the content object - * @param {string} contentId The id of the content object (unit/component) - * @returns {import("./types.mjs").ContentData | undefined} + * Builds the mutation to update the tags applied to the content object + * @param {string} contentId The id of the content object to update tags for + * @param {number} taxonomyId The id of the taxonomy the tags belong to */ -export const useContentDataResponse = (contentId) => { - const response = useContentData(contentId); - if (response.status === 'success') { - return response.data; - } - return undefined; -}; +export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => { + const queryClient = useQueryClient(); -/** - * Gets the status of the content data query - * @param {string} contentId The id of the content object (unit/component) - * @returns {boolean} - */ -export const useIsContentDataLoaded = (contentId) => ( - useContentData(contentId).status === 'success' -); + return useMutation({ + /** + * @type {import("@tanstack/react-query").MutateFunction< + * any, + * any, + * { + * tags: string[] + * } + * >} + */ + mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] }); + }, + }); +}; diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx index f969782adc..3abf33a223 100644 --- a/src/content-tags-drawer/data/apiHooks.test.jsx +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -1,121 +1,96 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { act } from '@testing-library/react'; import { - useTaxonomyTagsDataResponse, - useIsTaxonomyTagsDataLoaded, - useContentTaxonomyTagsDataResponse, - useIsContentTaxonomyTagsDataLoaded, - useContentDataResponse, - useIsContentDataLoaded, + useTaxonomyTagsData, + useContentTaxonomyTagsData, + useContentData, + useContentTaxonomyTagsUpdater, } from './apiHooks'; +import { updateContentTaxonomyTags } from './api'; + jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), + useMutation: jest.fn(), + useQueryClient: jest.fn(), })); -describe('useTaxonomyTagsDataResponse', () => { - it('should return data when status is success', () => { - useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } }); - const taxonomyId = '123'; - const result = useTaxonomyTagsDataResponse(taxonomyId); - - expect(result).toEqual({ data: 'data' }); - }); - - it('should return undefined when status is not success', () => { - useQuery.mockReturnValueOnce({ status: 'error' }); - const taxonomyId = '123'; - const result = useTaxonomyTagsDataResponse(taxonomyId); - - expect(result).toBeUndefined(); - }); -}); +jest.mock('./api', () => ({ + updateContentTaxonomyTags: jest.fn(), +})); -describe('useIsTaxonomyTagsDataLoaded', () => { - it('should return true when status is success', () => { - useQuery.mockReturnValueOnce({ status: 'success' }); - const taxonomyId = '123'; - const result = useIsTaxonomyTagsDataLoaded(taxonomyId); +describe('useTaxonomyTagsData', () => { + it('should return success response', () => { + useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); + const taxonomyId = 123; + const result = useTaxonomyTagsData(taxonomyId); - expect(result).toBe(true); + expect(result).toEqual({ isSuccess: true, data: 'data' }); }); - it('should return false when status is not success', () => { - useQuery.mockReturnValueOnce({ status: 'error' }); - const taxonomyId = '123'; - const result = useIsTaxonomyTagsDataLoaded(taxonomyId); + it('should return failure response', () => { + useQuery.mockReturnValueOnce({ isSuccess: false }); + const taxonomyId = 123; + const result = useTaxonomyTagsData(taxonomyId); - expect(result).toBe(false); + expect(result).toEqual({ isSuccess: false }); }); }); -describe('useContentTaxonomyTagsDataResponse', () => { - it('should return data when status is success', () => { - useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } }); +describe('useContentTaxonomyTagsData', () => { + it('should return success response', () => { + useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); const contentId = '123'; - const result = useContentTaxonomyTagsDataResponse(contentId); + const result = useContentTaxonomyTagsData(contentId); - expect(result).toEqual({ data: 'data' }); + expect(result).toEqual({ isSuccess: true, data: 'data' }); }); - it('should return undefined when status is not success', () => { - useQuery.mockReturnValueOnce({ status: 'error' }); + it('should return failure response', () => { + useQuery.mockReturnValueOnce({ isSuccess: false }); const contentId = '123'; - const result = useContentTaxonomyTagsDataResponse(contentId); + const result = useContentTaxonomyTagsData(contentId); - expect(result).toBeUndefined(); + expect(result).toEqual({ isSuccess: false }); }); }); -describe('useIsContentTaxonomyTagsDataLoaded', () => { - it('should return true when status is success', () => { - useQuery.mockReturnValueOnce({ status: 'success' }); +describe('useContentData', () => { + it('should return success response', () => { + useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); const contentId = '123'; - const result = useIsContentTaxonomyTagsDataLoaded(contentId); + const result = useContentData(contentId); - expect(result).toBe(true); + expect(result).toEqual({ isSuccess: true, data: 'data' }); }); - it('should return false when status is not success', () => { - useQuery.mockReturnValueOnce({ status: 'error' }); + it('should return failure response', () => { + useQuery.mockReturnValueOnce({ isSuccess: false }); const contentId = '123'; - const result = useIsContentTaxonomyTagsDataLoaded(contentId); + const result = useContentData(contentId); - expect(result).toBe(false); + expect(result).toEqual({ isSuccess: false }); }); }); -describe('useContentDataResponse', () => { - it('should return data when status is success', () => { - useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } }); - const contentId = '123'; - const result = useContentDataResponse(contentId); +describe('useContentTaxonomyTagsUpdater', () => { + it('should call the update content taxonomy tags function', async () => { + useMutation.mockReturnValueOnce({ mutate: jest.fn() }); - expect(result).toEqual({ data: 'data' }); - }); + const contentId = 'testerContent'; + const taxonomyId = 123; + const mutation = useContentTaxonomyTagsUpdater(contentId, taxonomyId); + mutation.mutate({ tags: ['tag1', 'tag2'] }); - it('should return undefined when status is not success', () => { - useQuery.mockReturnValueOnce({ status: 'error' }); - const contentId = '123'; - const result = useContentDataResponse(contentId); + expect(useMutation).toBeCalled(); - expect(result).toBeUndefined(); - }); -}); - -describe('useIsContentDataLoaded', () => { - it('should return true when status is success', () => { - useQuery.mockReturnValueOnce({ status: 'success' }); - const contentId = '123'; - const result = useIsContentDataLoaded(contentId); - - expect(result).toBe(true); - }); - - it('should return false when status is not success', () => { - useQuery.mockReturnValueOnce({ status: 'error' }); - const contentId = '123'; - const result = useIsContentDataLoaded(contentId); + const [config] = useMutation.mock.calls[0]; + const { mutationFn } = config; - expect(result).toBe(false); + await act(async () => { + const tags = ['tag1', 'tag2']; + await mutationFn({ tags }); + expect(updateContentTaxonomyTags).toBeCalledWith(contentId, taxonomyId, tags); + }); }); }); diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js index 6203bb2e83..2fe9deb994 100644 --- a/src/content-tags-drawer/messages.js +++ b/src/content-tags-drawer/messages.js @@ -17,6 +17,10 @@ const messages = defineMessages({ id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.spinner.loading', defaultMessage: 'Loading tags', }, + loadMoreTagsButtonText: { + id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button', + defaultMessage: 'Load more', + }, taxonomyTagsAriaLabel: { id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label', defaultMessage: 'taxonomy tags selection', diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx new file mode 100644 index 0000000000..51bf39f269 --- /dev/null +++ b/src/course-outline/CourseOutline.jsx @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, + Layout, + TransitionReplace, +} from '@edx/paragon'; +import { + CheckCircle as CheckCircleIcon, + Warning as WarningIcon, +} from '@edx/paragon/icons'; + +import SubHeader from '../generic/sub-header/SubHeader'; +import { RequestStatus } from '../data/constants'; +import InternetConnectionAlert from '../generic/internet-connection-alert'; +import AlertMessage from '../generic/alert-message'; +import HeaderNavigations from './header-navigations/HeaderNavigations'; +import OutlineSideBar from './outline-sidebar/OutlineSidebar'; +import messages from './messages'; +import { useCourseOutline } from './hooks'; +import StatusBar from './status-bar/StatusBar'; +import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal'; + +const CourseOutline = ({ courseId }) => { + const intl = useIntl(); + + const { + savingStatus, + statusBarData, + isLoading, + isReIndexShow, + showErrorAlert, + showSuccessAlert, + isSectionsExpanded, + isEnableHighlightsModalOpen, + isInternetConnectionAlertFailed, + isDisabledReindexButton, + headerNavigationsActions, + openEnableHighlightsModal, + closeEnableHighlightsModal, + handleEnableHighlightsSubmit, + handleInternetConnectionFailed, + } = useCourseOutline({ courseId }); + + if (isLoading) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; + } + + return ( + <> + +
+ + {showSuccessAlert ? ( + + + )} + /> + + +
+
+
+ +
+
+
+
+ + + +
+ +
+
+
+ + {showErrorAlert && ( +
+ + ); +}; + +CourseOutline.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default CourseOutline; diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss new file mode 100644 index 0000000000..732420365c --- /dev/null +++ b/src/course-outline/CourseOutline.scss @@ -0,0 +1,2 @@ +@import "./header-navigations/HeaderNavigations"; +@import "./status-bar/StatusBar"; diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx new file mode 100644 index 0000000000..b6d98bc470 --- /dev/null +++ b/src/course-outline/CourseOutline.test.jsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { + getCourseBestPracticesApiUrl, + getCourseLaunchApiUrl, + getCourseOutlineIndexApiUrl, + getCourseReindexApiUrl, + getEnableHighlightsEmailsApiUrl, +} from './data/api'; +import { + enableCourseHighlightsEmailsQuery, + fetchCourseBestPracticesQuery, + fetchCourseLaunchQuery, + fetchCourseOutlineIndexQuery, + fetchCourseReindexQuery, +} from './data/thunk'; +import initializeStore from '../store'; +import { + courseOutlineIndexMock, + courseBestPracticesMock, + courseLaunchMock, +} from './__mocks__'; +import { executeThunk } from '../utils'; +import CourseOutline from './CourseOutline'; +import messages from './messages'; + +let axiosMock; +let store; +const mockPathname = '/foo-bar'; +const courseId = '123'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: mockPathname, + }), +})); + +const RootWrapper = () => ( + + + + + +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseOutlineIndexApiUrl(courseId)) + .reply(200, courseOutlineIndexMock); + await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch); + }); + + it('render CourseOutline component correctly', async () => { + const { getByText } = render(); + + await waitFor(() => { + expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument(); + }); + }); + + it('check reindex and render success alert is correctly', async () => { + const { getByText } = render(); + + axiosMock + .onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink)) + .reply(200); + await executeThunk(fetchCourseReindexQuery(courseId, courseOutlineIndexMock.reindexLink), store.dispatch); + + expect(getByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument(); + }); + + it('render error alert after failed reindex correctly', async () => { + const { getByText } = render(); + + axiosMock + .onGet(getCourseReindexApiUrl('some link')) + .reply(500); + await executeThunk(fetchCourseReindexQuery(courseId, 'some link'), store.dispatch); + + expect(getByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument(); + }); + + it('render checklist value correctly', async () => { + const { getByText } = render(); + + axiosMock + .onGet(getCourseBestPracticesApiUrl({ + courseId, excludeGraded: true, all: true, + })) + .reply(200, courseBestPracticesMock); + + axiosMock + .onGet(getCourseLaunchApiUrl({ + courseId, gradedOnly: true, validateOras: true, all: true, + })) + .reply(200, courseLaunchMock); + + await executeThunk(fetchCourseLaunchQuery({ + courseId, gradedOnly: true, validateOras: true, all: true, + }), store.dispatch); + await executeThunk(fetchCourseBestPracticesQuery({ + courseId, excludeGraded: true, all: true, + }), store.dispatch); + + expect(getByText('4/9 completed')).toBeInTheDocument(); + }); + + it('check highlights are enabled after enable highlights query is successful', async () => { + const { findByTestId } = render(); + + axiosMock + .onGet(getCourseOutlineIndexApiUrl(courseId)) + .reply(200, { + ...courseOutlineIndexMock, + highlightsEnabledForMessaging: false, + }); + + axiosMock + .onPost(getEnableHighlightsEmailsApiUrl(courseId), { + publish: 'republish', + metadata: { + highlights_enabled_for_messaging: true, + }, + }) + .reply(200); + + await executeThunk(enableCourseHighlightsEmailsQuery(courseId), store.dispatch); + expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument(); + }); +}); diff --git a/src/course-outline/__mocks__/courseBestPractices.js b/src/course-outline/__mocks__/courseBestPractices.js new file mode 100644 index 0000000000..494c54d856 --- /dev/null +++ b/src/course-outline/__mocks__/courseBestPractices.js @@ -0,0 +1,43 @@ +module.exports = { + isSelfPaced: false, + sections: { + totalNumber: 6, + totalVisible: 4, + numberWithHighlights: 2, + highlightsActiveForCourse: true, + highlightsEnabled: true, + }, + subsections: { + totalVisible: 5, + numWithOneBlockType: 2, + numBlockTypes: { + min: 0, + max: 3, + mean: 1, + median: 1, + mode: 1, + }, + }, + units: { + totalVisible: 9, + numBlocks: { + min: 1, + max: 2, + mean: 2, + median: 2, + mode: 2, + }, + }, + videos: { + totalNumber: 7, + numMobileEncoded: 0, + numWithValId: 3, + durations: { + min: null, + max: null, + mean: null, + median: null, + mode: null, + }, + }, +}; diff --git a/src/course-outline/__mocks__/courseLaunch.js b/src/course-outline/__mocks__/courseLaunch.js new file mode 100644 index 0000000000..40b629f465 --- /dev/null +++ b/src/course-outline/__mocks__/courseLaunch.js @@ -0,0 +1,31 @@ +module.exports = { + isSelfPaced: false, + dates: { + hasStartDate: true, + hasEndDate: false, + }, + assignments: { + totalNumber: 11, + totalVisible: 7, + assignmentsWithDatesBeforeStart: [], + assignmentsWithDatesAfterEnd: [], + assignmentsWithOraDatesBeforeStart: [], + assignmentsWithOraDatesAfterEnd: [], + }, + grades: { + hasGradingPolicy: true, + sumOfWeights: 1, + }, + certificates: { + isActivated: false, + hasCertificate: false, + isEnabled: true, + }, + updates: { + hasUpdate: true, + }, + proctoring: { + needsProctoringEscalationEmail: false, + hasProctoringEscalationEmail: false, + }, +}; diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js new file mode 100644 index 0000000000..b65784c9be --- /dev/null +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -0,0 +1,2952 @@ +module.exports = { + courseReleaseDate: 'Set Date', + courseStructure: { + id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course', + displayName: 'Demonstration Course', + category: 'course', + hasChildren: true, + unitLevelDiscussions: false, + editedOn: 'Aug 23, 2023 at 12:35 UTC', + published: true, + publishedOn: 'Aug 23, 2023 at 11:32 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course', + releasedToStudents: false, + releaseDate: 'Nov 09, 2023 at 22:00 UTC', + visibilityState: null, + hasExplicitStaffLock: false, + start: '2023-11-09T22:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + highlightsEnabledForMessaging: true, + highlightsEnabled: true, + highlightsPreviewOnly: false, + highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', + enableProctoredExams: false, + createZendeskTickets: true, + enableTimedExams: true, + childInfo: { + category: 'chapter', + displayName: 'Section', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b', + displayName: 'Introduction 12', + category: 'chapter', + hasChildren: true, + editedOn: 'Aug 23, 2023 at 12:35 UTC', + published: true, + publishedOn: 'Aug 23, 2023 at 12:35 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b', + releasedToStudents: true, + releaseDate: 'Aug 10, 2023 at 22:00 UTC', + visibilityState: 'staff_only', + hasExplicitStaffLock: true, + start: '2023-08-10T22:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + highlights: [ + 'New Highlight 1', + 'New Highlight 4', + ], + highlightsEnabled: true, + highlightsPreviewOnly: false, + highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', + childInfo: { + category: 'sequential', + displayName: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction', + displayName: 'Demo Course Overview', + category: 'sequential', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40edx_introduction', + releasedToStudents: true, + releaseDate: 'Jan 01, 1970 at 05:00 UTC', + visibilityState: 'staff_only', + hasExplicitStaffLock: false, + start: '1970-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + hideAfterDue: false, + isProctoredExam: false, + wasExamEverLinkedWithExternal: false, + onlineProctoringRules: '', + isPracticeExam: false, + isOnboardingExam: false, + isTimeLimited: false, + examReviewRules: '', + defaultTimeLimitMinutes: null, + proctoringExamConfigurationLink: null, + supportsOnboarding: false, + showReviewRules: true, + childInfo: { + category: 'vertical', + displayName: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', + displayName: 'Introduction: Video and Sequences', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', + releasedToStudents: true, + releaseDate: 'Jan 01, 1970 at 05:00 UTC', + visibilityState: 'staff_only', + hasExplicitStaffLock: false, + start: '1970-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: true, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: true, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: true, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@graded_interactions', + displayName: 'Example Week 2: Get Interactive', + category: 'chapter', + hasChildren: true, + editedOn: 'Aug 16, 2023 at 11:52 UTC', + published: true, + publishedOn: 'Aug 16, 2023 at 11:52 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40graded_interactions', + releasedToStudents: false, + releaseDate: 'Nov 09, 2023 at 22:00 UTC', + visibilityState: 'ready', + hasExplicitStaffLock: false, + start: '2023-11-09T22:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + highlights: [ + 'New', + ], + highlightsEnabled: true, + highlightsPreviewOnly: false, + highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', + childInfo: { + category: 'sequential', + displayName: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations', + displayName: "Lesson 2 - Let's Get Interactive!", + category: 'sequential', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40simulations', + releasedToStudents: true, + releaseDate: 'Jan 01, 1970 at 05:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '1970-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + hideAfterDue: false, + isProctoredExam: false, + wasExamEverLinkedWithExternal: false, + onlineProctoringRules: '', + isPracticeExam: false, + isOnboardingExam: false, + isTimeLimited: false, + examReviewRules: '', + defaultTimeLimitMinutes: null, + proctoringExamConfigurationLink: null, + supportsOnboarding: false, + showReviewRules: true, + childInfo: { + category: 'vertical', + displayName: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0', + displayName: "Lesson 2 - Let's Get Interactive! ", + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d0d804e8863c4a95a659c04d8a2b2bc0', + releasedToStudents: true, + releaseDate: 'Jan 01, 1970 at 05:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '1970-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e', + displayName: 'An Interactive Reference Table', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e', + releasedToStudents: true, + releaseDate: 'Jan 01, 1970 at 05:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '1970-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471', + displayName: 'Zooming Diagrams', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471', + releasedToStudents: true, + releaseDate: 'Jan 01, 1970 at 05:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '1970-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c', + displayName: 'Electronic Sound Experiment', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c', + releasedToStudents: true, + releaseDate: 'Jan 01, 1970 at 05:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '1970-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d', + displayName: 'New Unit', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d', + releasedToStudents: true, + releaseDate: 'Jan 01, 1970 at 05:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '1970-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations', + displayName: 'Homework - Labs and Demos', + category: 'sequential', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40graded_simulations', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: 'Homework', + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + hideAfterDue: false, + isProctoredExam: false, + wasExamEverLinkedWithExternal: false, + onlineProctoringRules: '', + isPracticeExam: false, + isOnboardingExam: false, + isTimeLimited: false, + examReviewRules: '', + defaultTimeLimitMinutes: null, + proctoringExamConfigurationLink: null, + supportsOnboarding: false, + showReviewRules: true, + childInfo: { + category: 'vertical', + displayName: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf', + displayName: 'Labs and Demos', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6cee45205a449369d7ef8f159b22bdf', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55', + displayName: 'Code Grader', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1', + displayName: 'Electric Circuit Simulator', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae', + displayName: 'Protein Creator', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7', + displayName: 'Molecule Structures', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e', + displayName: 'Homework - Essays', + category: 'sequential', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40175e76c4951144a29d46211361266e0e', + releasedToStudents: false, + releaseDate: 'Nov 09, 2023 at 22:00 UTC', + visibilityState: 'ready', + hasExplicitStaffLock: false, + start: '2023-11-09T22:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + hideAfterDue: false, + isProctoredExam: false, + wasExamEverLinkedWithExternal: false, + onlineProctoringRules: '', + isPracticeExam: false, + isOnboardingExam: false, + isTimeLimited: false, + examReviewRules: '', + defaultTimeLimitMinutes: null, + proctoringExamConfigurationLink: null, + supportsOnboarding: false, + showReviewRules: true, + childInfo: { + category: 'vertical', + displayName: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050', + displayName: 'Peer Assessed Essays', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050', + releasedToStudents: false, + releaseDate: 'Nov 09, 2023 at 22:00 UTC', + visibilityState: 'ready', + hasExplicitStaffLock: false, + start: '2023-11-09T22:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@1414ffd5143b4b508f739b563ab468b7', + displayName: 'About Exams and Certificates', + category: 'chapter', + hasChildren: true, + editedOn: 'Aug 10, 2023 at 10:40 UTC', + published: true, + publishedOn: 'Aug 10, 2023 at 10:40 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%401414ffd5143b4b508f739b563ab468b7', + releasedToStudents: false, + releaseDate: 'Jan 01, 2030 at 05:00 UTC', + visibilityState: 'needs_attention', + hasExplicitStaffLock: false, + start: '2030-01-01T05:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + highlights: [], + highlightsEnabled: true, + highlightsPreviewOnly: false, + highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', + childInfo: { + category: 'sequential', + displayName: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow', + displayName: 'edX Exams', + category: 'sequential', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40workflow', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: 'Exam', + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + hideAfterDue: false, + isProctoredExam: false, + wasExamEverLinkedWithExternal: false, + onlineProctoringRules: '', + isPracticeExam: false, + isOnboardingExam: false, + isTimeLimited: false, + examReviewRules: '', + defaultTimeLimitMinutes: null, + proctoringExamConfigurationLink: null, + supportsOnboarding: false, + showReviewRules: true, + childInfo: { + category: 'vertical', + displayName: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3', + displayName: 'EdX Exams', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@934cc32c177d41b580c8413e561346b3', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131', + displayName: 'Immediate Feedback', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93', + displayName: 'Getting Answers', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa', + displayName: 'Answering More Than Once', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91', + displayName: 'Limited Checks', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a', + displayName: 'Randomized Questions', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c', + displayName: 'Overall Grade Performance', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff', + displayName: 'Passing a Course', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45', + displayName: 'Getting Your edX Certificate', + category: 'vertical', + hasChildren: true, + editedOn: 'Jul 07, 2023 at 11:14 UTC', + published: true, + publishedOn: 'Jul 07, 2023 at 11:14 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 00:00 UTC', + visibilityState: 'live', + hasExplicitStaffLock: false, + start: '2013-02-05T00:00:00Z', + graded: true, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@46e11a7b395f45b9837df6c6ac609004', + displayName: 'Publish section', + category: 'chapter', + hasChildren: true, + editedOn: 'Aug 23, 2023 at 12:22 UTC', + published: true, + publishedOn: 'Aug 23, 2023 at 12:22 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%4046e11a7b395f45b9837df6c6ac609004', + releasedToStudents: false, + releaseDate: 'Nov 09, 2023 at 22:00 UTC', + visibilityState: 'ready', + hasExplicitStaffLock: false, + start: '2023-11-09T22:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + highlights: [], + highlightsEnabled: true, + highlightsPreviewOnly: false, + highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', + childInfo: { + category: 'sequential', + displayName: 'Subsection', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@1945e9656cbe4abe8f2020c67e9e1f61', + displayName: 'Subsection sub', + category: 'sequential', + hasChildren: true, + editedOn: 'Aug 23, 2023 at 11:32 UTC', + published: true, + publishedOn: 'Aug 23, 2023 at 11:33 UTC', + studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%401945e9656cbe4abe8f2020c67e9e1f61', + releasedToStudents: false, + releaseDate: 'Nov 09, 2023 at 22:00 UTC', + visibilityState: 'ready', + hasExplicitStaffLock: false, + start: '2023-11-09T22:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + hideAfterDue: false, + isProctoredExam: false, + wasExamEverLinkedWithExternal: false, + onlineProctoringRules: '', + isPracticeExam: false, + isOnboardingExam: false, + isTimeLimited: false, + examReviewRules: '', + defaultTimeLimitMinutes: null, + proctoringExamConfigurationLink: null, + supportsOnboarding: false, + showReviewRules: true, + childInfo: { + category: 'vertical', + displayName: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b8149aa5af944aed8eebf9c7dc9f3d0b', + displayName: 'Unit', + category: 'vertical', + hasChildren: true, + editedOn: 'Aug 23, 2023 at 11:32 UTC', + published: true, + publishedOn: 'Aug 23, 2023 at 11:33 UTC', + studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b8149aa5af944aed8eebf9c7dc9f3d0b', + releasedToStudents: false, + releaseDate: 'Nov 09, 2023 at 22:00 UTC', + visibilityState: 'ready', + hasExplicitStaffLock: false, + start: '2023-11-09T22:00:00Z', + graded: false, + dueDate: '', + due: null, + relativeWeeksDue: null, + format: null, + courseGraders: [ + 'Homework', + 'Exam', + ], + hasChanges: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatoryMessage: null, + groupAccess: {}, + userPartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + showCorrectness: 'always', + discussionEnabled: true, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + ], + }, + ancestorHasStaffLock: false, + staffOnlyMessage: false, + hasPartitionGroupComponents: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + deprecatedBlocksInfo: { + deprecatedEnabledBlockTypes: [], + blocks: [], + advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76', + }, + discussionsIncontextFeedbackUrl: '', + discussionsIncontextLearnmoreUrl: '', + initialState: { + expandedLocators: [ + 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6', + 'block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d', + ], + locatorToShow: 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6', + }, + languageCode: 'en', + lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course', + mfeProctoredExamSettingsUrl: '', + notificationDismissUrl: '/course_notifications/course-v1:edx+101+y76/2', + proctoringErrors: [], + reindexLink: '/course/course-v1:edx+101+y76/search_reindex', + rerunNotificationId: 2, +}; diff --git a/src/course-outline/__mocks__/courseOutlineIndexWithoutSections.js b/src/course-outline/__mocks__/courseOutlineIndexWithoutSections.js new file mode 100644 index 0000000000..89a48492d9 --- /dev/null +++ b/src/course-outline/__mocks__/courseOutlineIndexWithoutSections.js @@ -0,0 +1,25 @@ +module.exports = { + courseReleaseDate: 'Set Date', + courseStructure: {}, + deprecatedBlocksInfo: { + deprecatedEnabledBlockTypes: [], + blocks: [], + advanceSettingsUrl: '/settings/advanced/course-v1:edx+101+y76', + }, + discussionsIncontextFeedbackUrl: '', + discussionsIncontextLearnmoreUrl: '', + initialState: { + expandedLocators: [ + 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6', + 'block-v1:edx+101+y76+type@sequential+block@8a85e287e30a47e98d8c1f37f74a6a9d', + ], + locatorToShow: 'block-v1:edx+101+y76+type@chapter+block@03de0adc9d1c4cc097062d80eb04abf6', + }, + languageCode: 'en', + lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course', + mfeProctoredExamSettingsUrl: '', + notificationDismissUrl: '/course_notifications/course-v1:edx+101+y76/2', + proctoringErrors: [], + reindexLink: '/course/course-v1:edx+101+y76/search_reindex', + rerunNotificationId: 2, +}; diff --git a/src/course-outline/__mocks__/index.js b/src/course-outline/__mocks__/index.js new file mode 100644 index 0000000000..e699605ef6 --- /dev/null +++ b/src/course-outline/__mocks__/index.js @@ -0,0 +1,4 @@ +export { default as courseOutlineIndexMock } from './courseOutlineIndex'; +export { default as courseOutlineIndexWithoutSections } from './courseOutlineIndexWithoutSections'; +export { default as courseBestPracticesMock } from './courseBestPractices'; +export { default as courseLaunchMock } from './courseLaunch'; diff --git a/src/course-outline/constants.js b/src/course-outline/constants.js new file mode 100644 index 0000000000..cd4c58eeb4 --- /dev/null +++ b/src/course-outline/constants.js @@ -0,0 +1,59 @@ +export const CHECKLIST_FILTERS = { + ALL: 'ALL', + SELF_PACED: 'SELF_PACED', + INSTRUCTOR_PACED: 'INSTRUCTOR_PACED', +}; + +export const LAUNCH_CHECKLIST = { + data: [ + { + id: 'welcomeMessage', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'gradingPolicy', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'certificate', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'courseDates', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'assignmentDeadlines', + pacingTypeFilter: CHECKLIST_FILTERS.INSTRUCTOR_PACED, + }, + { + id: 'proctoringEmail', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + ], +}; + +export const BEST_PRACTICES_CHECKLIST = { + data: [ + { + id: 'videoDuration', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'mobileFriendlyVideo', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'diverseSequences', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'weeklyHighlights', + pacingTypeFilter: CHECKLIST_FILTERS.SELF_PACED, + }, + { + id: 'unitDepth', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + ], +}; diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js new file mode 100644 index 0000000000..9b613bf81c --- /dev/null +++ b/src/course-outline/data/api.js @@ -0,0 +1,107 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; + +export const getCourseOutlineIndexApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`; + +export const getCourseBestPracticesApiUrl = ({ + courseId, + excludeGraded, + all, +}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`; + +export const getCourseLaunchApiUrl = ({ + courseId, + gradedOnly, + validateOras, + all, +}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`; + +export const getEnableHighlightsEmailsApiUrl = (courseId) => { + const formattedCourseId = courseId.split('course-v1:')[1]; + return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`; +}; + +export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`; + +/** + * Get course outline index. + * @param {string} courseId + * @returns {Promise} + */ +export async function getCourseOutlineIndex(courseId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseOutlineIndexApiUrl(courseId)); + + return camelCaseObject(data); +} + +/** + * Get course best practices. + * @param {string} courseId + * @param {boolean} excludeGraded + * @param {boolean} all + * @returns {Promise} + */ +export async function getCourseBestPractices({ + courseId, + excludeGraded, + all, +}) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all })); + + return camelCaseObject(data); +} + +/** + * Get course launch. + * @param {string} courseId + * @param {boolean} gradedOnly + * @param {boolean} validateOras + * @param {boolean} all + * @returns {Promise} + */ +export async function getCourseLaunch({ + courseId, + gradedOnly, + validateOras, + all, +}) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseLaunchApiUrl({ + courseId, gradedOnly, validateOras, all, + })); + + return camelCaseObject(data); +} + +/** + * Enable course highlights emails + * @param {string} courseId + * @returns {Promise} + */ +export async function enableCourseHighlightsEmails(courseId) { + const { data } = await getAuthenticatedHttpClient() + .post(getEnableHighlightsEmailsApiUrl(courseId), { + publish: 'republish', + metadata: { + highlights_enabled_for_messaging: true, + }, + }); + + return data; +} + +/** + * Restart reindex course + * @param {string} reindexLink + * @returns {Promise} + */ +export async function restartIndexingOnCourse(reindexLink) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseReindexApiUrl(reindexLink)); + + return camelCaseObject(data); +} diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js new file mode 100644 index 0000000000..096723ce5d --- /dev/null +++ b/src/course-outline/data/selectors.js @@ -0,0 +1,4 @@ +export const getOutlineIndexData = (state) => state.courseOutline.outlineIndexData; +export const getLoadingStatus = (state) => state.courseOutline.loadingStatus; +export const getStatusBarData = (state) => state.courseOutline.statusBarData; +export const getSavingStatus = (state) => state.courseOutline.savingStatus; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js new file mode 100644 index 0000000000..ea08b47026 --- /dev/null +++ b/src/course-outline/data/slice.js @@ -0,0 +1,77 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +import { RequestStatus } from '../../data/constants'; + +const slice = createSlice({ + name: 'courseOutline', + initialState: { + loadingStatus: { + outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS, + reIndexLoadingStatus: RequestStatus.IN_PROGRESS, + }, + outlineIndexData: {}, + savingStatus: '', + statusBarData: { + courseReleaseDate: '', + highlightsEnabledForMessaging: false, + highlightsDocUrl: '', + isSelfPaced: false, + checklist: { + totalCourseLaunchChecks: 0, + completedCourseLaunchChecks: 0, + totalCourseBestPracticesChecks: 0, + completedCourseBestPracticesChecks: 0, + }, + }, + }, + reducers: { + fetchOutlineIndexSuccess: (state, { payload }) => { + state.outlineIndexData = payload; + }, + updateOutlineIndexLoadingStatus: (state, { payload }) => { + state.loadingStatus = { + ...state.loadingStatus, + outlineIndexLoadingStatus: payload.status, + }; + }, + updateReindexLoadingStatus: (state, { payload }) => { + state.loadingStatus = { + ...state.loadingStatus, + reIndexLoadingStatus: payload.status, + }; + }, + updateStatusBar: (state, { payload }) => { + state.statusBarData = { + ...state.statusBarData, + ...payload, + }; + }, + fetchStatusBarChecklistSuccess: (state, { payload }) => { + state.statusBarData.checklist = { + ...state.statusBarData.checklist, + ...payload, + }; + }, + fetchStatusBarSelPacedSuccess: (state, { payload }) => { + state.statusBarData.isSelfPaced = payload.isSelfPaced; + }, + updateSavingStatus: (state, { payload }) => { + state.savingStatus = payload.status; + }, + }, +}); + +export const { + fetchOutlineIndexSuccess, + updateOutlineIndexLoadingStatus, + updateReindexLoadingStatus, + updateStatusBar, + fetchStatusBarChecklistSuccess, + fetchStatusBarSelPacedSuccess, + updateSavingStatus, +} = slice.actions; + +export const { + reducer, +} = slice; diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js new file mode 100644 index 0000000000..194ae03d79 --- /dev/null +++ b/src/course-outline/data/thunk.js @@ -0,0 +1,104 @@ +import { RequestStatus } from '../../data/constants'; +import { + getCourseBestPracticesChecklist, + getCourseLaunchChecklist, +} from '../utils/getChecklistForStatusBar'; +import { + enableCourseHighlightsEmails, + getCourseBestPractices, + getCourseLaunch, + getCourseOutlineIndex, + restartIndexingOnCourse, +} from './api'; +import { + fetchOutlineIndexSuccess, + updateOutlineIndexLoadingStatus, + updateReindexLoadingStatus, + updateStatusBar, + fetchStatusBarChecklistSuccess, + fetchStatusBarSelPacedSuccess, + updateSavingStatus, +} from './slice'; + +export function fetchCourseOutlineIndexQuery(courseId) { + return async (dispatch) => { + dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const outlineIndex = await getCourseOutlineIndex(courseId); + const { courseReleaseDate, courseStructure: { highlightsEnabledForMessaging, highlightsDocUrl } } = outlineIndex; + dispatch(fetchOutlineIndexSuccess(outlineIndex)); + dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging, highlightsDocUrl })); + + dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function fetchCourseLaunchQuery({ + courseId, + gradedOnly = true, + validateOras = true, + all = true, +}) { + return async (dispatch) => { + try { + const data = await getCourseLaunch({ + courseId, gradedOnly, validateOras, all, + }); + dispatch(fetchStatusBarSelPacedSuccess({ isSelfPaced: data.isSelfPaced })); + dispatch(fetchStatusBarChecklistSuccess(getCourseLaunchChecklist(data))); + + return true; + } catch (error) { + return false; + } + }; +} + +export function fetchCourseBestPracticesQuery({ + courseId, + excludeGraded = true, + all = true, +}) { + return async (dispatch) => { + try { + const data = await getCourseBestPractices({ courseId, excludeGraded, all }); + dispatch(fetchStatusBarChecklistSuccess(getCourseBestPracticesChecklist(data))); + + return true; + } catch (error) { + return false; + } + }; +} + +export function enableCourseHighlightsEmailsQuery(courseId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + + try { + await enableCourseHighlightsEmails(courseId); + dispatch(fetchCourseOutlineIndexQuery(courseId)); + + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function fetchCourseReindexQuery(courseId, reindexLink) { + return async (dispatch) => { + dispatch(updateReindexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + await restartIndexingOnCourse(reindexLink); + dispatch(updateReindexLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateReindexLoadingStatus({ status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-outline/enable-highlights-modal/EnableHighlightsModal.jsx b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.jsx new file mode 100644 index 0000000000..c7e4258aaa --- /dev/null +++ b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, AlertModal, Button, Hyperlink, +} from '@edx/paragon'; + +import messages from './messages'; + +const EnableHighlightsModal = ({ + onEnableHighlightsSubmit, + isOpen, + close, + highlightsDocUrl, +}) => { + const intl = useIntl(); + + return ( + + + + + )} + > +

{intl.formatMessage(messages.description_1)}

+

+ {intl.formatMessage(messages.description_2)} + + {intl.formatMessage(messages.link)} + +

+
+ ); +}; + +EnableHighlightsModal.propTypes = { + onEnableHighlightsSubmit: PropTypes.func.isRequired, + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + highlightsDocUrl: PropTypes.string.isRequired, +}; + +export default EnableHighlightsModal; diff --git a/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx new file mode 100644 index 0000000000..833631d034 --- /dev/null +++ b/src/course-outline/enable-highlights-modal/EnableHighlightsModal.test.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import EnableHighlightsModal from './EnableHighlightsModal'; +import messages from './messages'; + +const onEnableHighlightsSubmitMock = jest.fn(); +const closeMock = jest.fn(); + +const highlightsDocUrl = 'https://example.com/'; + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('renders EnableHighlightsModal component correctly', () => { + const { getByText, getByRole } = renderComponent(); + + expect(getByText(messages.title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.description_1.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.description_2.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.submitButton.defaultMessage })).toBeInTheDocument(); + + const hyperlink = getByText(messages.link.defaultMessage); + expect(hyperlink).toBeInTheDocument(); + expect(hyperlink.href).toBe(highlightsDocUrl); + }); + + it('calls onEnableHighlightsSubmit function when the "Submit" button is clicked', () => { + const { getByRole } = renderComponent(); + + const submitButton = getByRole('button', { name: messages.submitButton.defaultMessage }); + fireEvent.click(submitButton); + expect(onEnableHighlightsSubmitMock).toHaveBeenCalled(); + }); + + it('calls the close function when the "Cancel" button is clicked', () => { + const { getByRole } = renderComponent(); + + const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage }); + fireEvent.click(cancelButton); + expect(closeMock).toHaveBeenCalled(); + }); +}); diff --git a/src/course-outline/enable-highlights-modal/messages.js b/src/course-outline/enable-highlights-modal/messages.js new file mode 100644 index 0000000000..dbad07e941 --- /dev/null +++ b/src/course-outline/enable-highlights-modal/messages.js @@ -0,0 +1,30 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + title: { + id: 'course-authoring.course-outline.status-bar.modal.title', + defaultMessage: 'Enable course highlight emails', + }, + description_1: { + id: 'course-authoring.course-outline.status-bar.modal.description-1', + defaultMessage: 'When you enable course highlight emails, learners automatically receive email messages for each section that has highlights. You cannot disable highlights after you start sending them.', + }, + description_2: { + id: 'course-authoring.course-outline.status-bar.modal.description-2', + defaultMessage: 'Are you sure you want to enable course highlight emails?', + }, + link: { + id: 'course-authoring.course-outline.status-bar.modal.link', + defaultMessage: 'Learn more', + }, + cancelButton: { + id: 'course-authoring.course-outline.status-bar.modal.cancelButton', + defaultMessage: 'Cancel', + }, + submitButton: { + id: 'course-authoring.course-outline.status-bar.modal.submitButton', + defaultMessage: 'Enable', + }, +}); + +export default messages; diff --git a/src/course-outline/header-navigations/HeaderNavigations.jsx b/src/course-outline/header-navigations/HeaderNavigations.jsx new file mode 100644 index 0000000000..57cdd693e2 --- /dev/null +++ b/src/course-outline/header-navigations/HeaderNavigations.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, OverlayTrigger, Tooltip } from '@edx/paragon'; +import { + Add as IconAdd, + ArrowDropDown as ArrowDownIcon, + ArrowDropUp as ArrowUpIcon, +} from '@edx/paragon/icons'; + +import messages from './messages'; + +const HeaderNavigations = ({ + headerNavigationsActions, + isReIndexShow, + isSectionsExpanded, + isDisabledReindexButton, +}) => { + const intl = useIntl(); + const { + handleNewSection, handleReIndex, handleExpandAll, lmsLink, + } = headerNavigationsActions; + + return ( + + ); +}; + +HeaderNavigations.propTypes = { + isReIndexShow: PropTypes.bool.isRequired, + isSectionsExpanded: PropTypes.bool.isRequired, + isDisabledReindexButton: PropTypes.bool.isRequired, + headerNavigationsActions: PropTypes.shape({ + handleNewSection: PropTypes.func.isRequired, + handleReIndex: PropTypes.func.isRequired, + handleExpandAll: PropTypes.func.isRequired, + lmsLink: PropTypes.string.isRequired, + }).isRequired, +}; + +export default HeaderNavigations; diff --git a/src/course-outline/header-navigations/HeaderNavigations.scss b/src/course-outline/header-navigations/HeaderNavigations.scss new file mode 100644 index 0000000000..e5867e8068 --- /dev/null +++ b/src/course-outline/header-navigations/HeaderNavigations.scss @@ -0,0 +1,4 @@ +.header-navigations { + display: flex; + gap: .75rem; +} diff --git a/src/course-outline/header-navigations/HeaderNavigations.test.jsx b/src/course-outline/header-navigations/HeaderNavigations.test.jsx new file mode 100644 index 0000000000..6b0fde9ca8 --- /dev/null +++ b/src/course-outline/header-navigations/HeaderNavigations.test.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import HeaderNavigations from './HeaderNavigations'; +import messages from './messages'; + +const handleNewSectionMock = jest.fn(); +const handleReIndexMock = jest.fn(); +const handleExpandAllMock = jest.fn(); + +const headerNavigationsActions = { + handleNewSection: handleNewSectionMock, + handleReIndex: handleReIndexMock, + handleExpandAll: handleExpandAllMock, + lmsLink: '', +}; + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('render HeaderNavigations component correctly', () => { + const { getByRole } = renderComponent(); + + expect(getByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.reindexButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render HeaderNavigations component with isReIndexShow is false correctly', () => { + const { getByRole, queryByRole } = renderComponent({ isReIndexShow: false }); + + expect(getByRole('button', { name: messages.newSectionButton.defaultMessage })).toBeInTheDocument(); + expect(queryByRole('button', { name: messages.reindexButton.defaultMessage })).not.toBeInTheDocument(); + expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('calls the correct handlers when clicking buttons', () => { + const { getByRole } = renderComponent(); + + const newSectionButton = getByRole('button', { name: messages.newSectionButton.defaultMessage }); + fireEvent.click(newSectionButton); + expect(handleNewSectionMock).toHaveBeenCalledTimes(1); + + const reIndexButton = getByRole('button', { name: messages.reindexButton.defaultMessage }); + fireEvent.click(reIndexButton); + expect(handleReIndexMock).toHaveBeenCalledTimes(1); + + const expandAllButton = getByRole('button', { name: messages.expandAllButton.defaultMessage }); + fireEvent.click(expandAllButton); + expect(handleExpandAllMock).toHaveBeenCalledTimes(1); + }); + + it('render collapse button correctly', () => { + const { getByRole } = renderComponent({ + isSectionsExpanded: true, + }); + + expect(getByRole('button', { name: messages.collapseAllButton.defaultMessage })).toBeInTheDocument(); + }); + + it('render expand button correctly', () => { + const { getByRole } = renderComponent({ + isSectionsExpanded: false, + }); + + expect(getByRole('button', { name: messages.expandAllButton.defaultMessage })).toBeInTheDocument(); + }); +}); diff --git a/src/course-outline/header-navigations/messages.js b/src/course-outline/header-navigations/messages.js new file mode 100644 index 0000000000..588a2fa5d3 --- /dev/null +++ b/src/course-outline/header-navigations/messages.js @@ -0,0 +1,38 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + newSectionButton: { + id: 'course-authoring.course-outline.header-navigations.button.new-section', + defaultMessage: 'New section', + }, + newSectionButtonTooltip: { + id: 'course-authoring.course-outline.header-navigations.button.new-section.tooltip', + defaultMessage: 'Click to add a new section', + }, + reindexButton: { + id: 'course-authoring.course-outline.header-navigations.button.reindex', + defaultMessage: 'Reindex', + }, + reindexButtonTooltip: { + id: 'course-authoring.course-outline.header-navigations.button.reindex.tooltip', + defaultMessage: 'Reindex current course', + }, + expandAllButton: { + id: 'course-authoring.course-outline.header-navigations.button.expand-all', + defaultMessage: 'Expand all', + }, + collapseAllButton: { + id: 'course-authoring.course-outline.header-navigations.button.collapse-all', + defaultMessage: 'Collapse all', + }, + viewLiveButton: { + id: 'course-authoring.course-outline.header-navigations.button.view-live', + defaultMessage: 'View live', + }, + viewLiveButtonTooltip: { + id: 'course-authoring.course-outline.header-navigations.button.view-live.tooltip', + defaultMessage: 'Click to open the courseware in the LMS in a new tab', + }, +}); + +export default messages; diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx new file mode 100644 index 0000000000..f96b84641c --- /dev/null +++ b/src/course-outline/hooks.jsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useToggle } from '@edx/paragon'; + +import { RequestStatus } from '../data/constants'; +import { updateSavingStatus } from './data/slice'; +import { + getLoadingStatus, + getOutlineIndexData, + getSavingStatus, + getStatusBarData, +} from './data/selectors'; +import { + enableCourseHighlightsEmailsQuery, + fetchCourseBestPracticesQuery, + fetchCourseLaunchQuery, + fetchCourseOutlineIndexQuery, + fetchCourseReindexQuery, +} from './data/thunk'; + +const useCourseOutline = ({ courseId }) => { + const dispatch = useDispatch(); + + const { reindexLink, lmsLink } = useSelector(getOutlineIndexData); + const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus); + const statusBarData = useSelector(getStatusBarData); + const savingStatus = useSelector(getSavingStatus); + + const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false); + const [isSectionsExpanded, setSectionsExpanded] = useState(false); + const [isDisabledReindexButton, setDisableReindexButton] = useState(false); + const [showSuccessAlert, setShowSuccessAlert] = useState(false); + const [showErrorAlert, setShowErrorAlert] = useState(false); + + const headerNavigationsActions = { + handleNewSection: () => { + // TODO add handler + }, + handleReIndex: () => { + setDisableReindexButton(true); + setShowSuccessAlert(false); + setShowErrorAlert(false); + + dispatch(fetchCourseReindexQuery(courseId, reindexLink)).then(() => { + setDisableReindexButton(false); + }); + }, + handleExpandAll: () => { + setSectionsExpanded((prevState) => !prevState); + }, + lmsLink, + }; + + const handleEnableHighlightsSubmit = () => { + dispatch(enableCourseHighlightsEmailsQuery(courseId)); + closeEnableHighlightsModal(); + }; + + const handleInternetConnectionFailed = () => { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + }; + + useEffect(() => { + dispatch(fetchCourseOutlineIndexQuery(courseId)); + dispatch(fetchCourseBestPracticesQuery({ courseId })); + dispatch(fetchCourseLaunchQuery({ courseId })); + }, [courseId]); + + useEffect(() => { + if (reIndexLoadingStatus === RequestStatus.FAILED) { + setShowErrorAlert(true); + } + + if (reIndexLoadingStatus === RequestStatus.SUCCESSFUL) { + setShowSuccessAlert(true); + } + }, [reIndexLoadingStatus]); + + return { + savingStatus, + isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS, + isReIndexShow: Boolean(reindexLink), + showSuccessAlert, + showErrorAlert, + isDisabledReindexButton, + isSectionsExpanded, + headerNavigationsActions, + handleEnableHighlightsSubmit, + statusBarData, + isEnableHighlightsModalOpen, + openEnableHighlightsModal, + closeEnableHighlightsModal, + isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED, + handleInternetConnectionFailed, + }; +}; + +// eslint-disable-next-line import/prefer-default-export +export { useCourseOutline }; diff --git a/src/course-outline/index.js b/src/course-outline/index.js new file mode 100644 index 0000000000..fbb90f3e66 --- /dev/null +++ b/src/course-outline/index.js @@ -0,0 +1,2 @@ +/* eslint-disable import/prefer-default-export */ +export { default as CourseOutline } from './CourseOutline'; diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js new file mode 100644 index 0000000000..387b7f8ded --- /dev/null +++ b/src/course-outline/messages.js @@ -0,0 +1,34 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headingTitle: { + id: 'course-authoring.course-outline.headingTitle', + defaultMessage: 'Course outline', + }, + headingSubtitle: { + id: 'course-authoring.course-outline.subTitle', + defaultMessage: 'Content', + }, + alertSuccessTitle: { + id: 'course-authoring.course-outline.reindex.alert.success.title', + defaultMessage: 'Course index', + }, + alertSuccessDescription: { + id: 'course-authoring.course-outline.reindex.alert.success.description', + defaultMessage: 'Course has been successfully reindexed.', + }, + alertSuccessAriaLabelledby: { + id: 'course-authoring.course-outline.reindex.alert.success.aria.labelledby', + defaultMessage: 'alert-confirmation-title', + }, + alertSuccessAriaDescribedby: { + id: 'course-authoring.course-outline.reindex.alert.success.aria.describedby', + defaultMessage: 'alert-confirmation-description', + }, + alertErrorTitle: { + id: 'course-authoring.course-outline.reindex.alert.error.title', + defaultMessage: 'There were errors reindexing course.', + }, +}); + +export default messages; diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.jsx b/src/course-outline/outline-sidebar/OutlineSidebar.jsx new file mode 100644 index 0000000000..0440ad6e52 --- /dev/null +++ b/src/course-outline/outline-sidebar/OutlineSidebar.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Hyperlink } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { HelpSidebar } from '../../generic/help-sidebar'; +import { useHelpUrls } from '../../help-urls/hooks'; +import { getFormattedSidebarMessages } from './utils'; + +const OutlineSideBar = ({ courseId }) => { + const intl = useIntl(); + const { + visibility: learnMoreVisibilityUrl, + grading: learnMoreGradingUrl, + outline: learnMoreOutlineUrl, + } = useHelpUrls(['visibility', 'grading', 'outline']); + + const sidebarMessages = getFormattedSidebarMessages( + { + learnMoreGradingUrl, + learnMoreOutlineUrl, + learnMoreVisibilityUrl, + }, + intl, + ); + + return ( + + {sidebarMessages.map(({ title, descriptions, link }, index) => { + const isLastSection = index === sidebarMessages.length - 1; + + return ( +
+

{title}

+ {descriptions.map((description) => ( +

{description}

+ ))} + {Boolean(link) && Boolean(link.href) && ( + + {link.text} + + )} + {!isLastSection &&
} +
+ ); + })} +
+ ); +}; + +OutlineSideBar.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default OutlineSideBar; diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.test.jsx b/src/course-outline/outline-sidebar/OutlineSidebar.test.jsx new file mode 100644 index 0000000000..0df0095bbb --- /dev/null +++ b/src/course-outline/outline-sidebar/OutlineSidebar.test.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { AppProvider } from '@edx/frontend-platform/react'; + +import { helpUrls } from '../../help-urls/__mocks__'; +import { getHelpUrlsApiUrl } from '../../help-urls/data/api'; +import initializeStore from '../../store'; +import OutlineSidebar from './OutlineSidebar'; +import messages from './messages'; + +let axiosMock; +let store; +const mockPathname = '/foo-bar'; +const courseId = '123'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: mockPathname, + }), +})); + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + +const renderComponent = (props) => render( + + + + + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getHelpUrlsApiUrl()) + .reply(200, helpUrls); + }); + + it('render OutlineSidebar component correctly', async () => { + const { getByText } = renderComponent(); + + await waitFor(() => { + expect(getByText(messages.section_1_title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_1_descriptions_1.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_1_descriptions_2.defaultMessage)).toBeInTheDocument(); + + expect(getByText(messages.section_2_title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_2_descriptions_1.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_2_link.defaultMessage)).toBeInTheDocument(); + + expect(getByText(messages.section_3_title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_3_descriptions_1.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_3_link.defaultMessage)).toBeInTheDocument(); + + expect(getByText(messages.section_4_title.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_4_descriptions_1.defaultMessage)).toBeInTheDocument(); + + expect(getByText(messages.section_4_descriptions_2.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.section_4_descriptions_3.defaultMessage)).toBeInTheDocument(); + + expect(getByText(messages.section_4_link.defaultMessage)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/course-outline/outline-sidebar/messages.js b/src/course-outline/outline-sidebar/messages.js new file mode 100644 index 0000000000..7ef0cf0cfb --- /dev/null +++ b/src/course-outline/outline-sidebar/messages.js @@ -0,0 +1,70 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + section_1_title: { + id: 'course-authoring.course-outline.sidebar.section-1.title', + defaultMessage: 'Creating your course organization', + }, + section_1_descriptions_1: { + id: 'course-authoring.course-outline.sidebar.section-1.descriptions-1', + defaultMessage: 'You add sections, subsections, and units directly in the outline.', + }, + section_1_descriptions_2: { + id: 'course-authoring.course-outline.sidebar.section-1.descriptions-2', + defaultMessage: 'Create a section, then add subsections and units. Open a unit to add course components.', + }, + section_2_title: { + id: 'course-authoring.course-outline.sidebar.section-2.title', + defaultMessage: 'Reorganizing your course', + }, + section_2_descriptions_1: { + id: 'course-authoring.course-outline.sidebar.section-2.descriptions-1', + defaultMessage: 'Drag sections, subsections, and units to new locations in the outline.', + }, + section_2_link: { + id: 'course-authoring.course-outline.sidebar.section-2.link', + defaultMessage: 'Learn more about the course outline', + }, + section_3_title: { + id: 'course-authoring.course-outline.sidebar.section-3.title', + defaultMessage: 'Setting release dates and grading policies', + }, + section_3_descriptions_1: { + id: 'course-authoring.course-outline.sidebar.section-3.descriptions-1', + defaultMessage: 'Select the Configure icon for a section or subsection to set its release date. When you configure a subsection, you can also set the grading policy and due date.', + }, + section_3_link: { + id: 'course-authoring.course-outline.sidebar.section-3.link', + defaultMessage: 'Learn more about grading policy settings', + }, + section_4_title: { + id: 'course-authoring.course-outline.sidebar.section-4.title', + defaultMessage: 'Changing the content learners see', + }, + section_4_descriptions_1: { + id: 'course-authoring.course-outline.sidebar.section-4.descriptions-1', + defaultMessage: 'To publish draft content, select the Publish icon for a section, subsection, or unit.', + }, + section_4_descriptions_2: { + id: 'course-authoring.course-outline.sidebar.section-4.descriptions-2', + defaultMessage: 'To make a section, subsection, or unit unavailable to learners, select the Configure icon for that level, then select the appropriate {hide} option. Grades for hidden sections, subsections, and units are not included in grade calculations.', + }, + section_4_descriptions_2_hide: { + id: 'course-authoring.course-outline.sidebar.section-4.descriptions-2.hide', + defaultMessage: 'Hide', + }, + section_4_descriptions_3: { + id: 'course-authoring.course-outline.sidebar.section-4.descriptions-3', + defaultMessage: 'To hide the content of a subsection from learners after the subsection due date has passed, select the Configure icon for a subsection, then select {hide}. Grades for the subsection remain included in grade calculations.', + }, + section_4_descriptions_3_hide: { + id: 'course-authoring.course-outline.sidebar.section-4.descriptions-3.hide', + defaultMessage: 'Hide content after due date', + }, + section_4_link: { + id: 'course-authoring.course-outline.sidebar.section-4.link', + defaultMessage: 'Learn more about content visibility settings', + }, +}); + +export default messages; diff --git a/src/course-outline/outline-sidebar/utils.jsx b/src/course-outline/outline-sidebar/utils.jsx new file mode 100644 index 0000000000..29782a22c6 --- /dev/null +++ b/src/course-outline/outline-sidebar/utils.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import messages from './messages'; + +/** + * Get formatted sidebar messages for render + * @param {object} docsLinks - Docs links object from store + * @returns {Array<{ + * title: string, + * descriptions: Array, + * link?: { + * text: string, + * href: string + * } + * }>} + */ +const getFormattedSidebarMessages = (docsLinks, intl) => { + const { learnMoreOutlineUrl, learnMoreGradingUrl, learnMoreVisibilityUrl } = docsLinks; + + return [ + { + title: intl.formatMessage(messages.section_1_title), + descriptions: [ + intl.formatMessage(messages.section_1_descriptions_1), + intl.formatMessage(messages.section_1_descriptions_2), + ], + }, + { + title: intl.formatMessage(messages.section_2_title), + descriptions: [ + intl.formatMessage(messages.section_2_descriptions_1), + ], + link: { + text: intl.formatMessage(messages.section_2_link), + href: learnMoreOutlineUrl, + }, + }, + { + title: intl.formatMessage(messages.section_3_title), + descriptions: [ + intl.formatMessage(messages.section_3_descriptions_1), + ], + link: { + text: intl.formatMessage(messages.section_3_link), + href: learnMoreGradingUrl, + }, + }, + { + title: intl.formatMessage(messages.section_4_title), + descriptions: [ + intl.formatMessage(messages.section_4_descriptions_1), + intl.formatMessage( + messages.section_4_descriptions_2, + { hide: {intl.formatMessage(messages.section_4_descriptions_2_hide)} }, + ), + intl.formatMessage( + messages.section_4_descriptions_3, + { hide: {intl.formatMessage(messages.section_4_descriptions_3_hide)} }, + ), + ], + link: { + text: intl.formatMessage(messages.section_4_link), + href: learnMoreVisibilityUrl, + }, + }, + ]; +}; + +// eslint-disable-next-line import/prefer-default-export +export { getFormattedSidebarMessages }; diff --git a/src/course-outline/status-bar/StatusBar.jsx b/src/course-outline/status-bar/StatusBar.jsx new file mode 100644 index 0000000000..62d28f0430 --- /dev/null +++ b/src/course-outline/status-bar/StatusBar.jsx @@ -0,0 +1,116 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Hyperlink, Stack } from '@edx/paragon'; +import { AppContext } from '@edx/frontend-platform/react'; + +import messages from './messages'; + +const StatusBar = ({ + statusBarData, + isLoading, + courseId, + openEnableHighlightsModal, +}) => { + const intl = useIntl(); + const { config } = useContext(AppContext); + + const { + courseReleaseDate, + highlightsEnabledForMessaging, + highlightsDocUrl, + checklist, + isSelfPaced, + } = statusBarData; + + const { + completedCourseLaunchChecks, + completedCourseBestPracticesChecks, + totalCourseLaunchChecks, + totalCourseBestPracticesChecks, + } = checklist; + + const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; + const checklistDestination = new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href; + const scheduleDestination = new URL(`course/${courseId}/settings/details#schedule`, config.BASE_URL).href; + + if (isLoading) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>; + } + + return ( + +
+
{intl.formatMessage(messages.startDateTitle)}
+ + {courseReleaseDate} + +
+
+
{intl.formatMessage(messages.pacingTypeTitle)}
+ + {isSelfPaced + ? intl.formatMessage(messages.pacingTypeSelfPaced) + : intl.formatMessage(messages.pacingTypeInstructorPaced)} + +
+
+
{intl.formatMessage(messages.checklistTitle)}
+ + {checkListTitle} {intl.formatMessage(messages.checklistCompleted)} + +
+
+
{intl.formatMessage(messages.highlightEmailsTitle)}
+
+ {highlightsEnabledForMessaging ? ( + + {intl.formatMessage(messages.highlightEmailsEnabled)} + + ) : ( + + )} + + {intl.formatMessage(messages.highlightEmailsLink)} + +
+
+
+ ); +}; + +StatusBar.propTypes = { + courseId: PropTypes.string.isRequired, + isLoading: PropTypes.bool.isRequired, + openEnableHighlightsModal: PropTypes.func.isRequired, + statusBarData: PropTypes.shape({ + courseReleaseDate: PropTypes.string.isRequired, + isSelfPaced: PropTypes.bool.isRequired, + checklist: PropTypes.shape({ + totalCourseLaunchChecks: PropTypes.number.isRequired, + completedCourseLaunchChecks: PropTypes.number.isRequired, + totalCourseBestPracticesChecks: PropTypes.number.isRequired, + completedCourseBestPracticesChecks: PropTypes.number.isRequired, + }), + highlightsEnabledForMessaging: PropTypes.bool.isRequired, + highlightsDocUrl: PropTypes.string.isRequired, + }).isRequired, +}; + +export default StatusBar; diff --git a/src/course-outline/status-bar/StatusBar.scss b/src/course-outline/status-bar/StatusBar.scss new file mode 100644 index 0000000000..873abef83b --- /dev/null +++ b/src/course-outline/status-bar/StatusBar.scss @@ -0,0 +1,12 @@ +.outline-status-bar { + .outline-status-bar__item { + display: flex; + flex-direction: column; + justify-content: space-evenly; + min-height: 3.75rem; + + & h5 { + margin-bottom: 0; + } + } +} diff --git a/src/course-outline/status-bar/StatusBar.test.jsx b/src/course-outline/status-bar/StatusBar.test.jsx new file mode 100644 index 0000000000..64cd617a5e --- /dev/null +++ b/src/course-outline/status-bar/StatusBar.test.jsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; + +import StatusBar from './StatusBar'; +import messages from './messages'; +import initializeStore from '../../store'; + +let store; +const mockPathname = '/foo-bar'; +const courseId = '123'; +const isLoading = false; +const openEnableHighlightsModalMock = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: mockPathname, + }), +})); + +const statusBarData = { + courseReleaseDate: 'Feb 05, 2013 at 05:00 UTC', + isSelfPaced: true, + checklist: { + totalCourseLaunchChecks: 5, + completedCourseLaunchChecks: 1, + totalCourseBestPracticesChecks: 4, + completedCourseBestPracticesChecks: 1, + }, + highlightsEnabledForMessaging: true, + highlightsDocUrl: 'https://example.com/highlights-doc', +}; + +const renderComponent = (props) => render( + + + + + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('renders StatusBar component correctly', () => { + const { getByText } = renderComponent(); + + expect(getByText(messages.startDateTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(statusBarData.courseReleaseDate)).toBeInTheDocument(); + + expect(getByText(messages.pacingTypeTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.pacingTypeSelfPaced.defaultMessage)).toBeInTheDocument(); + + expect(getByText(messages.checklistTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(`2/9 ${messages.checklistCompleted.defaultMessage}`)).toBeInTheDocument(); + + expect(getByText(messages.highlightEmailsTitle.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.highlightEmailsLink.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.highlightEmailsEnabled.defaultMessage)).toBeInTheDocument(); + }); + + it('renders StatusBar when isSelfPaced is false', () => { + const { getByText } = renderComponent({ + statusBarData: { + ...statusBarData, + isSelfPaced: false, + }, + }); + + expect(getByText(messages.pacingTypeInstructorPaced.defaultMessage)).toBeInTheDocument(); + }); + + it('calls openEnableHighlightsModal function when the "Enable Highlight Emails" button is clicked', () => { + const { getByRole } = renderComponent({ + statusBarData: { + ...statusBarData, + highlightsEnabledForMessaging: false, + }, + }); + + const enableHighlightsButton = getByRole('button', { name: messages.highlightEmailsButton.defaultMessage }); + fireEvent.click(enableHighlightsButton); + expect(openEnableHighlightsModalMock).toHaveBeenCalledTimes(1); + }); + + it('not render component when isLoading is true', () => { + const { queryByTestId } = renderComponent({ + isLoading: true, + }); + + expect(queryByTestId('outline-status-bar')).not.toBeInTheDocument(); + }); +}); diff --git a/src/course-outline/status-bar/messages.js b/src/course-outline/status-bar/messages.js new file mode 100644 index 0000000000..58ddb2bef3 --- /dev/null +++ b/src/course-outline/status-bar/messages.js @@ -0,0 +1,46 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + startDateTitle: { + id: 'course-authoring.course-outline.status-bar.start-date', + defaultMessage: 'Start date', + }, + pacingTypeTitle: { + id: 'course-authoring.course-outline.status-bar.pacing-type', + defaultMessage: 'Pacing type', + }, + pacingTypeSelfPaced: { + id: 'course-authoring.course-outline.status-bar.pacing-type.self-paced', + defaultMessage: 'Self-paced', + }, + pacingTypeInstructorPaced: { + id: 'course-authoring.course-outline.status-bar.pacing-type.instructor-Paced', + defaultMessage: 'Instructor-paced', + }, + checklistTitle: { + id: 'course-authoring.course-outline.status-bar.checklists', + defaultMessage: 'Checklists', + }, + checklistCompleted: { + id: 'course-authoring.course-outline.status-bar.checklists.completed', + defaultMessage: 'completed', + }, + highlightEmailsTitle: { + id: 'course-authoring.course-outline.status-bar.highlight-emails', + defaultMessage: 'Course highlight emails', + }, + highlightEmailsButton: { + id: 'course-authoring.course-outline.status-bar.highlight-emails.button', + defaultMessage: 'Enable now', + }, + highlightEmailsEnabled: { + id: 'course-authoring.course-outline.status-bar.highlight-emails.enabled', + defaultMessage: 'Enabled', + }, + highlightEmailsLink: { + id: 'course-authoring.course-outline.status-bar.highlight-emails.link', + defaultMessage: 'Learn more', + }, +}); + +export default messages; diff --git a/src/course-outline/utils/courseChecklistValidators.js b/src/course-outline/utils/courseChecklistValidators.js new file mode 100644 index 0000000000..20fae67a23 --- /dev/null +++ b/src/course-outline/utils/courseChecklistValidators.js @@ -0,0 +1,106 @@ +/** + * The utilities are taken from the https://github.com/openedx/studio-frontend repository. + * Perform a minor refactoring of the functions while preserving their original functionality. + */ + +export const hasWelcomeMessage = (updates) => updates.hasUpdate; + +export const hasGradingPolicy = (grades) => { + // eslint-disable-next-line no-shadow + const { hasGradingPolicy, sumOfWeights } = grades; + + return hasGradingPolicy && parseFloat(sumOfWeights.toPrecision(2), 10) === 1.0; +}; + +export const hasCertificate = (certificates) => { + // eslint-disable-next-line no-shadow + const { isActivated, hasCertificate } = certificates; + + return isActivated && hasCertificate; +}; + +export const hasDates = (dates) => { + const { hasStartDate, hasEndDate } = dates; + + return hasStartDate && hasEndDate; +}; + +export const hasAssignmentDeadlines = (assignments, dates) => { + const { + totalNumber, + assignmentsWithDatesBeforeStart, + assignmentsWithDatesAfterEnd, + assignmentsWithOraDatesBeforeStart, + assignmentsWithOraDatesAfterEnd, + } = assignments; + + if (!hasDates(dates)) { + return false; + } + if (totalNumber === 0) { + return false; + } + if (assignmentsWithDatesBeforeStart.length > 0) { + return false; + } + if (assignmentsWithDatesAfterEnd.length > 0) { + return false; + } + if (assignmentsWithOraDatesBeforeStart.length > 0) { + return false; + } + if (assignmentsWithOraDatesAfterEnd.length > 0) { + return false; + } + + return true; +}; + +export const hasShortVideoDuration = (videos) => { + const { totalNumber, durations } = videos; + + if (totalNumber === 0) { + return true; + } + if (totalNumber > 0 && durations.median <= 600) { + return true; + } + + return false; +}; + +export const hasMobileFriendlyVideos = (videos) => { + const { totalNumber, numMobileEncoded } = videos; + + if (totalNumber === 0) { + return true; + } + if (totalNumber > 0 && (numMobileEncoded / totalNumber) >= 0.9) { + return true; + } + + return false; +}; + +export const hasDiverseSequences = (subsections) => { + const { totalVisible, numWithOneBlockType } = subsections; + + if (totalVisible === 0) { + return false; + } + if (totalVisible > 0) { + return ((numWithOneBlockType / totalVisible) < 0.2); + } + + return false; +}; + +export const hasWeeklyHighlights = (sections) => { + const { highlightsActiveForCourse, highlightsEnabled } = sections; + + return highlightsActiveForCourse && highlightsEnabled; +}; + +export const hasShortUnitDepth = (units) => units.numBlocks.median <= 3; + +export const hasProctoringEscalationEmail = (proctoring) => proctoring.hasProctoringEscalationEmail; diff --git a/src/course-outline/utils/courseChecklistValidators.test.js b/src/course-outline/utils/courseChecklistValidators.test.js new file mode 100644 index 0000000000..401475bc2f --- /dev/null +++ b/src/course-outline/utils/courseChecklistValidators.test.js @@ -0,0 +1,297 @@ +import * as validators from './courseChecklistValidators'; + +describe('courseCheckValidators utility functions', () => { + describe('hasWelcomeMessage', () => { + it('returns true when course run has an update', () => { + expect(validators.hasWelcomeMessage({ hasUpdate: true })).toEqual(true); + }); + + it('returns false when course run does not have an update', () => { + expect(validators.hasWelcomeMessage({ hasUpdate: false })).toEqual(false); + }); + }); + + describe('hasGradingPolicy', () => { + it('returns true when sum of weights is 1', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: true, sumOfWeights: 1 }, + )).toEqual(true); + }); + + it('returns true when sum of weights is not 1 due to floating point approximation (1.00004)', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: true, sumOfWeights: 1.00004 }, + )).toEqual(true); + }); + + it('returns false when sum of weights is not 1', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: true, sumOfWeights: 2 }, + )).toEqual(false); + }); + + it('returns true when hasGradingPolicy is true', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: true, sumOfWeights: 1 }, + )).toEqual(true); + }); + + it('returns false when hasGradingPolicy is false', () => { + expect(validators.hasGradingPolicy( + { hasGradingPolicy: false, sumOfWeights: 1 }, + )).toEqual(false); + }); + }); + + describe('hasCertificate', () => { + it('returns true when certificates are activated and course run has a certificate', () => { + expect(validators.hasCertificate({ isActivated: true, hasCertificate: true })) + .toEqual(true); + }); + + it('returns false when certificates are not activated and course run has a certificate', () => { + expect(validators.hasCertificate({ isActivated: false, hasCertificate: true })) + .toEqual(false); + }); + + it('returns false when certificates are activated and course run does not have a certificate', () => { + expect(validators.hasCertificate({ isActivated: true, hasCertificate: false })) + .toEqual(false); + }); + + it('returns false when certificates are not activated and course run does not have a certificate', () => { + expect(validators.hasCertificate({ isActivated: false, hasCertificate: false })) + .toEqual(false); + }); + }); + + describe('hasDates', () => { + it('returns true when course run has start date and end date', () => { + expect(validators.hasDates({ hasStartDate: true, hasEndDate: true })).toEqual(true); + }); + + it('returns false when course run has no start date and end date', () => { + expect(validators.hasDates({ hasStartDate: false, hasEndDate: true })).toEqual(false); + }); + + it('returns true when course run has start date and no end date', () => { + expect(validators.hasDates({ hasStartDate: true, hasEndDate: false })).toEqual(false); + }); + + it('returns true when course run has no start date and no end date', () => { + expect(validators.hasDates({ hasStartDate: false, hasEndDate: false })).toEqual(false); + }); + }); + + describe('hasAssignmentDeadlines', () => { + it('returns true when a course run has start and end date and all assignments are within range', () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: 0, + assignmentsWithDatesAfterEnd: 0, + assignmentsWithOraDatesAfterEnd: 0, + assignmentsWithOraDatesBeforeStart: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(true); + }); + + it('returns false when a course run has no start and no end date', () => { + expect(validators.hasAssignmentDeadlines( + {}, + { + hasStartDate: false, + hasEndDate: false, + }, + )).toEqual(false); + }); + + it('returns false when a course has start and end date and no assignments', () => { + expect(validators.hasAssignmentDeadlines( + { + totalNumber: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }); + + it('returns false when a course run has start and end date and assignments before start', () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: ['test'], + assignmentsWithDatesAfterEnd: 0, + assignmentsWithOraDatesAfterEnd: 0, + assignmentsWithOraDatesBeforeStart: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }); + + it('returns false when a course run has start and end date and assignments after end', () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: 0, + assignmentsWithDatesAfterEnd: ['test'], + assignmentsWithOraDatesAfterEnd: 0, + assignmentsWithOraDatesBeforeStart: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }); + }); + + it( + 'returns false when a course run has start and end date and an ora with a date before start', + () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: 0, + assignmentsWithDatesAfterEnd: 0, + assignmentsWithOraDatesAfterEnd: 0, + assignmentsWithOraDatesBeforeStart: ['test'], + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }, + ); + + it( + 'returns false when a course run has start and end date and an ora with a date after end', + () => { + expect(validators.hasAssignmentDeadlines( + { + assignmentsWithDatesBeforeStart: 0, + assignmentsWithDatesAfterEnd: 0, + assignmentsWithOraDatesAfterEnd: ['test'], + assignmentsWithOraDatesBeforeStart: 0, + }, + { + hasStartDate: true, + hasEndDate: true, + }, + )).toEqual(false); + }, + ); + + describe('hasShortVideoDuration', () => { + it('returns true if course run has no videos', () => { + expect(validators.hasShortVideoDuration({ totalNumber: 0 })).toEqual(true); + }); + + it('returns true if course run videos have a median duration <= to 600', () => { + expect(validators.hasShortVideoDuration({ totalNumber: 1, durations: { median: 100 } })) + .toEqual(true); + }); + + it('returns true if course run videos have a median duration > to 600', () => { + expect(validators.hasShortVideoDuration({ totalNumber: 10, durations: { median: 700 } })) + .toEqual(false); + }); + }); + + describe('hasMobileFriendlyVideos', () => { + it('returns true if course run has no videos', () => { + expect(validators.hasMobileFriendlyVideos({ totalNumber: 0 })).toEqual(true); + }); + + it('returns true if course run videos are >= 90% mobile friendly', () => { + expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 9 })) + .toEqual(true); + }); + + it('returns true if course run videos are < 90% mobile friendly', () => { + expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 8 })) + .toEqual(false); + }); + }); + + describe('hasDiverseSequences', () => { + it('returns true if < 20% of visible subsections have more than one block type', () => { + expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 1 })) + .toEqual(true); + }); + + it('returns false if no visible subsections', () => { + expect(validators.hasDiverseSequences({ totalVisible: 0 })).toEqual(false); + }); + + it('returns false if >= 20% of visible subsections have more than one block type', () => { + expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 3 })) + .toEqual(false); + }); + + it('return false if < 0 visible subsections', () => { + expect(validators.hasDiverseSequences({ totalVisible: -1, numWithOneBlockType: 1 })) + .toEqual(false); + }); + }); + + describe('hasWeeklyHighlights', () => { + it('returns true when course run has highlights enabled', () => { + const data = { highlightsActiveForCourse: true, highlightsEnabled: true }; + expect(validators.hasWeeklyHighlights(data)).toEqual(true); + }); + + it('returns false when course run has highlights enabled', () => { + const data = { highlightsActiveForCourse: false, highlightsEnabled: false }; + expect(validators.hasWeeklyHighlights(data)).toEqual(false); + + data.highlightsEnabled = true; + data.highlightsActiveForCourse = false; + expect(validators.hasWeeklyHighlights(data)).toEqual(false); + + data.highlightsEnabled = false; + data.highlightsActiveForCourse = true; + expect(validators.hasWeeklyHighlights(data)).toEqual(false); + }); + }); + + describe('hasShortUnitDepth', () => { + it('returns true when course run has median number of blocks <= 3', () => { + const units = { + numBlocks: { + median: 3, + }, + }; + + expect(validators.hasShortUnitDepth(units)).toEqual(true); + }); + + it('returns false when course run has median number of blocks > 3', () => { + const units = { + numBlocks: { + median: 4, + }, + }; + + expect(validators.hasShortUnitDepth(units)).toEqual(false); + }); + }); + + describe('hasProctoringEscalationEmail', () => { + it('returns true when the course has a proctoring escalation email', () => { + const proctoring = { hasProctoringEscalationEmail: true }; + expect(validators.hasProctoringEscalationEmail(proctoring)).toEqual(true); + }); + + it('returns false when the course does not have a proctoring escalation email', () => { + const proctoring = { hasProctoringEscalationEmail: false }; + expect(validators.hasProctoringEscalationEmail(proctoring)).toEqual(false); + }); + }); +}); diff --git a/src/course-outline/utils/getChecklistForStatusBar.js b/src/course-outline/utils/getChecklistForStatusBar.js new file mode 100644 index 0000000000..acab5d7eb3 --- /dev/null +++ b/src/course-outline/utils/getChecklistForStatusBar.js @@ -0,0 +1,79 @@ +import { LAUNCH_CHECKLIST, BEST_PRACTICES_CHECKLIST } from '../constants'; +import { getChecklistValues, getChecklistValidatedValue } from './getChecklistValues'; + +/** + * Get status bar course launch checklist values + * @param {object} data - course launch data + * @returns { + * totalCourseLaunchChecks: {number}, + * completedCourseLaunchChecks: {number} + * } - total and completed launch checklist items + */ +const getCourseLaunchChecklist = (data) => { + if (Object.keys(data).length > 0) { + const { isSelfPaced, certificates } = data; + + const filteredCourseLaunchChecks = getChecklistValues({ + checklist: LAUNCH_CHECKLIST.data, + isSelfPaced, + hasCertificatesEnabled: certificates.isEnabled, + hasHighlightsEnabled: false, + }); + + const completedCourseLaunchChecks = filteredCourseLaunchChecks.reduce((result, currentValue) => { + const value = getChecklistValidatedValue(data, currentValue.id); + return value ? result + 1 : result; + }, 0); + + return { + totalCourseLaunchChecks: filteredCourseLaunchChecks.length, + completedCourseLaunchChecks, + }; + } + + return { + totalCourseLaunchChecks: 0, + completedCourseLaunchChecks: 0, + }; +}; + +/** + * Get status bar course best practices checklist values + * @param {object} data - course best practices data + * @returns { + * totalCourseBestPracticesChecks: {number}, + * completedCourseBestPracticesChecks: {number} + * } - total and completed launch checklist items + */ +const getCourseBestPracticesChecklist = (data) => { + if (Object.keys(data).length > 0) { + const { isSelfPaced, sections } = data; + + const filteredBestPracticesChecks = getChecklistValues({ + checklist: BEST_PRACTICES_CHECKLIST.data, + isSelfPaced, + hasCertificatesEnabled: false, + hasHighlightsEnabled: sections.highlightsEnadled, + }); + + const completedCourseBestPracticesChecks = filteredBestPracticesChecks.reduce((result, currentValue) => { + const value = getChecklistValidatedValue(data, currentValue.id); + return value ? result + 1 : result; + }, 0); + + return { + totalCourseBestPracticesChecks: filteredBestPracticesChecks.length, + completedCourseBestPracticesChecks, + }; + } + + return { + totalCourseBestPracticesChecks: 0, + completedCourseBestPracticesChecks: 0, + }; +}; + +export { + getCourseLaunchChecklist, + getCourseBestPracticesChecklist, +}; diff --git a/src/course-outline/utils/getChecklistValues.js b/src/course-outline/utils/getChecklistValues.js new file mode 100644 index 0000000000..2ddd965ce8 --- /dev/null +++ b/src/course-outline/utils/getChecklistValues.js @@ -0,0 +1,79 @@ +import { CHECKLIST_FILTERS } from '../constants'; +import * as healthValidators from './courseChecklistValidators'; + +/** + * The utilities are taken from the https://github.com/openedx/studio-frontend repository. + * Perform a minor refactoring of the functions while preserving their original functionality. + */ +const getChecklistValidatedValue = (data, id) => { + const { + updates, + grades, + certificates, + dates, + assignments, + videos, + subsections, + sections, + units, + proctoring, + } = data; + + switch (id) { + case 'welcomeMessage': + return healthValidators.hasWelcomeMessage(updates); + case 'gradingPolicy': + return healthValidators.hasGradingPolicy(grades); + case 'certificate': + return healthValidators.hasCertificate(certificates); + case 'courseDates': + return healthValidators.hasDates(dates); + case 'assignmentDeadlines': + return healthValidators.hasAssignmentDeadlines(assignments, dates); + case 'videoDuration': + return healthValidators.hasShortVideoDuration(videos); + case 'mobileFriendlyVideo': + return healthValidators.hasMobileFriendlyVideos(videos); + case 'diverseSequences': + return healthValidators.hasDiverseSequences(subsections); + case 'weeklyHighlights': + return healthValidators.hasWeeklyHighlights(sections); + case 'unitDepth': + return healthValidators.hasShortUnitDepth(units); + case 'proctoringEmail': + return healthValidators.hasProctoringEscalationEmail(proctoring); + default: + throw new Error(`Unknown validator ${id}.`); + } +}; + +const getChecklistValues = ({ + checklist, + isSelfPaced, + hasCertificatesEnabled, + hasHighlightsEnabled, + needsProctoringEscalationEmail, +}) => { + let filteredCheckList; + + if (isSelfPaced) { + filteredCheckList = checklist.filter(({ pacingTypeFilter }) => pacingTypeFilter === CHECKLIST_FILTERS.ALL + || pacingTypeFilter === CHECKLIST_FILTERS.SELF_PACED); + } else { + filteredCheckList = checklist.filter(({ pacingTypeFilter }) => pacingTypeFilter === CHECKLIST_FILTERS.ALL + || pacingTypeFilter === CHECKLIST_FILTERS.INSTRUCTOR_PACED); + } + + filteredCheckList = filteredCheckList.filter(({ id }) => id !== 'certificate' + || hasCertificatesEnabled); + + filteredCheckList = filteredCheckList.filter(({ id }) => id !== 'weeklyHighlights' + || hasHighlightsEnabled); + + filteredCheckList = filteredCheckList.filter(({ id }) => id !== 'proctoringEmail' + || needsProctoringEscalationEmail); + + return filteredCheckList; +}; + +export { getChecklistValues, getChecklistValidatedValue }; diff --git a/src/course-outline/utils/getChecklistValues.test.js b/src/course-outline/utils/getChecklistValues.test.js new file mode 100644 index 0000000000..24ac32e973 --- /dev/null +++ b/src/course-outline/utils/getChecklistValues.test.js @@ -0,0 +1,86 @@ +import { getChecklistValues } from './getChecklistValues'; +import { CHECKLIST_FILTERS } from '../constants'; + +const checklist = [ + { + id: 'welcomeMessage', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'gradingPolicy', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'certificate', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'courseDates', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, + { + id: 'assignmentDeadlines', + pacingTypeFilter: CHECKLIST_FILTERS.INSTRUCTOR_PACED, + }, + { + id: 'weeklyHighlights', + pacingTypeFilter: CHECKLIST_FILTERS.SELF_PACED, + }, + { + id: 'proctoringEmail', + pacingTypeFilter: CHECKLIST_FILTERS.ALL, + }, +]; + +let courseData; +describe('getChecklistValues utility function', () => { + beforeEach(() => { + courseData = { + isSelfPaced: true, + hasCertificatesEnabled: true, + hasHighlightsEnabled: true, + needsProctoringEscalationEmail: true, + }; + }); + it('returns only checklist items with filters ALL and SELF_PACED when isSelfPaced is true', () => { + const filteredChecklist = getChecklistValues({ checklist, ...courseData }); + + filteredChecklist.forEach((( + item => expect(item.pacingTypeFilter === CHECKLIST_FILTERS.ALL + || item.pacingTypeFilter === CHECKLIST_FILTERS.SELF_PACED) + ))); + + expect(filteredChecklist.filter(item => item.pacingTypeFilter === CHECKLIST_FILTERS.ALL).length) + .toEqual(checklist.filter(item => item.pacingTypeFilter === CHECKLIST_FILTERS.ALL).length); + expect(filteredChecklist.filter(item => item.pacingTypeFilter === CHECKLIST_FILTERS.SELF_PACED).length) + .toEqual(checklist.filter(item => item.pacingTypeFilter === CHECKLIST_FILTERS.SELF_PACED).length); + }); + + it('returns only checklist items with filters ALL and INSTRUCTOR_PACED when isSelfPaced is false', () => { + courseData.isSelfPaced = false; + const filteredChecklist = getChecklistValues({ checklist, ...courseData }); + + filteredChecklist.forEach((( + item => expect(item.pacingTypeFilter === CHECKLIST_FILTERS.ALL + || item.pacingTypeFilter === CHECKLIST_FILTERS.INSTRUCTOR_PACED) + ))); + + expect(filteredChecklist.filter(item => item.pacingTypeFilter === CHECKLIST_FILTERS.ALL).length) + .toEqual(checklist.filter(item => item.pacingTypeFilter === CHECKLIST_FILTERS.ALL).length); + expect(filteredChecklist + .filter(item => item.pacingTypeFilter === CHECKLIST_FILTERS.INSTRUCTOR_PACED).length) + .toEqual(checklist.filter(item => item.pacingTypeFilter === CHECKLIST_FILTERS.INSTRUCTOR_PACED).length); + }); + + it('excludes weekly highlights when they are disabled', () => { + courseData.hasHighlightsEnabled = false; + const filteredChecklist = getChecklistValues({ checklist, ...courseData }); + expect(filteredChecklist.filter(item => item.id === 'weeklyHighlights').length).toEqual(0); + }); + + it('excludes proctoring escalation email when not needed', () => { + courseData.needsProctoringEscalationEmail = false; + const filteredChecklist = getChecklistValues({ checklist, ...courseData }); + expect(filteredChecklist.filter(item => item.id === 'proctoringEmail').length).toEqual(0); + }); +}); diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 1111dbc93a..8bbf0802f3 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n'; import { CheckboxFilter, Container } from '@edx/paragon'; @@ -85,25 +84,26 @@ const FilesPage = ({ const maxFileSize = 20 * 1048576; const activeColumn = { - id: 'usageLocations', + id: 'activeStatus', Header: 'Active', - accessor: (({ usageLocations }) => !isEmpty(usageLocations)), + accessor: 'activeStatus', Cell: ({ row }) => ActiveColumn({ row }), Filter: CheckboxFilter, + filter: 'exactTextCase', filterChoices: [ - { name: intl.formatMessage(messages.activeCheckboxLabel), value: true }, - { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: false }, + { name: intl.formatMessage(messages.activeCheckboxLabel), value: 'active' }, + { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: 'inactive' }, ], }; const accessColumn = { - id: 'locked', + id: 'lockStatus', Header: 'Access', - accessor: 'locked', + accessor: 'lockStatus', Cell: ({ row }) => AccessColumn({ row }), Filter: CheckboxFilter, filterChoices: [ - { name: intl.formatMessage(messages.lockedCheckboxLabel), value: true }, - { name: intl.formatMessage(messages.publicCheckboxLabel), value: false }, + { name: intl.formatMessage(messages.lockedCheckboxLabel), value: 'locked' }, + { name: intl.formatMessage(messages.publicCheckboxLabel), value: 'public' }, ], }; const thumbnailColumn = { diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index 9c5f7a46c2..da15d89690 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -339,7 +339,15 @@ describe('FilesAndUploads', () => { const assetMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); expect(assetMenuButton).toBeVisible(); - axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`).reply(201, { usage_locations: { mOckID1: ['subsection - unit / block'] } }); + axiosMock.onGet(`${getAssetsUrl(courseId)}mOckID1/usage`) + .reply(201, { + usage_locations: { + mOckID1: [{ + display_location: 'subsection - unit / block', + url: 'base/unit_id#block_id', + }], + }, + }); await waitFor(() => { fireEvent.click(within(assetMenuButton).getByLabelText('file-menu-toggle')); fireEvent.click(screen.getByText('Info')); diff --git a/src/files-and-videos/files-page/data/thunks.js b/src/files-and-videos/files-page/data/thunks.js index 27922f095e..90bdb5ab4f 100644 --- a/src/files-and-videos/files-page/data/thunks.js +++ b/src/files-and-videos/files-page/data/thunks.js @@ -1,4 +1,6 @@ import { isEmpty } from 'lodash'; +import { camelCaseObject } from '@edx/frontend-platform'; + import { RequestStatus } from '../../../data/constants'; import { addModel, @@ -111,6 +113,7 @@ export function updateAssetLock({ assetId, courseId, locked }) { model: { id: assetId, locked, + lockStatus: locked, }, })); dispatch(updateEditStatus({ editType: 'lock', status: RequestStatus.SUCCESSFUL })); @@ -133,11 +136,13 @@ export function getUsagePaths({ asset, courseId }) { try { const { usageLocations } = await getAssetUsagePaths({ assetId: asset.id, courseId }); const assetLocations = usageLocations[asset.id]; + const activeStatus = assetLocations?.length > 0 ? 'active' : 'inactive'; dispatch(updateModel({ modelType: 'assets', model: { id: asset.id, - usageLocations: assetLocations, + usageLocations: camelCaseObject(assetLocations), + activeStatus, }, })); dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); diff --git a/src/files-and-videos/files-page/data/utils.js b/src/files-and-videos/files-page/data/utils.js index 2526b4ad42..676c140ecd 100644 --- a/src/files-and-videos/files-page/data/utils.js +++ b/src/files-and-videos/files-page/data/utils.js @@ -24,13 +24,17 @@ export const updateFileValues = (files) => { wrapperType = 'audio'; } - const { dateAdded } = file; + const { dateAdded, locked, usageLocations } = file; const utcDateString = dateAdded.replace(/\bat\b/g, ''); const utcDateTime = new Date(utcDateString).toString(); + const lockStatus = locked ? 'locked' : 'public'; + const activeStatus = usageLocations?.length > 0 ? 'active' : 'inactive'; updatedFiles.push({ ...file, wrapperType, + lockStatus, + activeStatus, dateAdded: utcDateTime, }); }); diff --git a/src/files-and-videos/generic/InfoModal.jsx b/src/files-and-videos/generic/InfoModal.jsx index 8f81149c4c..6b9c5ee5c8 100644 --- a/src/files-and-videos/generic/InfoModal.jsx +++ b/src/files-and-videos/generic/InfoModal.jsx @@ -1,19 +1,24 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, + intlShape, FormattedMessage, } from '@edx/frontend-platform/i18n'; import { + Icon, ModalDialog, Stack, Truncate, } from '@edx/paragon'; +import { Error } from '@edx/paragon/icons'; import messages from './messages'; import UsageMetricsMessages from './UsageMetricsMessage'; import FileThumbnail from './ThumbnailPreview'; +import { TRANSCRIPT_FAILURE_STATUSES } from '../videos-page/data/constants'; +import AlertMessage from '../../generic/alert-message'; const InfoModal = ({ file, @@ -23,55 +28,74 @@ const InfoModal = ({ usagePathStatus, error, sidebar, -}) => ( - - - -
- - {file?.displayName} - -
-
-
- -
-
-
- - -
-
- + // injected + intl, +}) => { + const [activeTab, setActiveTab] = useState('fileInfo'); + const showTranscriptionError = TRANSCRIPT_FAILURE_STATUSES.includes(file?.transcriptionStatus) + && activeTab !== 'fileInfo'; + + return ( + + + +
+ + {file?.displayName} + +
+
+
+ +
+ {showTranscriptionError && ( + + + {intl.formatMessage(messages.transcriptionErrorMessage, { error: file.errorDescription })}
- -
-
-
-
- {sidebar(file)} + )} + variant="danger" + /> + )} +
+
+ + +
+
+ +
+ +
+
+
+
+ {sidebar(file, activeTab, setActiveTab)} +
-
- - -); + + + ); +}; InfoModal.propTypes = { file: PropTypes.shape({ @@ -86,6 +110,8 @@ InfoModal.propTypes = { fileSize: PropTypes.number.isRequired, usageLocations: PropTypes.arrayOf(PropTypes.string), status: PropTypes.string, + transcriptionStatus: PropTypes.string, + errorDescription: PropTypes.string, }), onClose: PropTypes.func.isRequired, isOpen: PropTypes.bool.isRequired, @@ -93,6 +119,8 @@ InfoModal.propTypes = { error: PropTypes.arrayOf(PropTypes.string).isRequired, thumbnailPreview: PropTypes.func.isRequired, sidebar: PropTypes.func.isRequired, + // injected + intl: intlShape.isRequired, }; InfoModal.defaultProps = { diff --git a/src/files-and-videos/generic/UsageMetricsMessage.jsx b/src/files-and-videos/generic/UsageMetricsMessage.jsx index 84354dcd70..fee391f094 100644 --- a/src/files-and-videos/generic/UsageMetricsMessage.jsx +++ b/src/files-and-videos/generic/UsageMetricsMessage.jsx @@ -1,6 +1,12 @@ import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; import PropTypes from 'prop-types'; -import { Icon, Row, Spinner } from '@edx/paragon'; +import { + Hyperlink, + Icon, + Row, + Spinner, +} from '@edx/paragon'; import { ErrorOutline } from '@edx/paragon/icons'; import isEmpty from 'lodash/isEmpty'; import { RequestStatus } from '../../data/constants'; @@ -20,8 +26,10 @@ const UsageMetricsMessage = ({ ) : (
    {usageLocations.map(location => ( -
  • - {location} +
  • + + {location.displayLocation} +
  • ))}
diff --git a/src/files-and-videos/generic/index.js b/src/files-and-videos/generic/index.js index 0449cc8705..a9dc9d93ec 100644 --- a/src/files-and-videos/generic/index.js +++ b/src/files-and-videos/generic/index.js @@ -6,6 +6,7 @@ import { MoreInfoColumn, StatusColumn, ThumbnailColumn, + TranscriptColumn, } from './table-components'; import FileInput, { useFileInput } from './FileInput'; @@ -19,6 +20,7 @@ export { ThumbnailColumn, FileInput, useFileInput, + TranscriptColumn, }; export { default as FileTable } from './FileTable'; export { default as EditFileErrors } from './EditFileErrors'; diff --git a/src/files-and-videos/generic/messages.js b/src/files-and-videos/generic/messages.js index 757e871f21..ba62f2b2d7 100644 --- a/src/files-and-videos/generic/messages.js +++ b/src/files-and-videos/generic/messages.js @@ -37,6 +37,10 @@ const messages = defineMessages({ id: 'course-authoring.files-and-upload.errorAlert.message', defaultMessage: '{message}', }, + transcriptionErrorMessage: { + id: 'course-authoring.files-and-uploads.file-info.transcripts.error.alert', + defaultMessage: 'Transcript failed: "{error}"', + }, usageTitle: { id: 'course-authoring.files-and-uploads.file-info.usage.title', defaultMessage: 'Usage', diff --git a/src/files-and-videos/generic/table-components/index.js b/src/files-and-videos/generic/table-components/index.js index 4d8a1eacee..d82173b263 100644 --- a/src/files-and-videos/generic/table-components/index.js +++ b/src/files-and-videos/generic/table-components/index.js @@ -9,6 +9,7 @@ import { MoreInfoColumn, StatusColumn, ThumbnailColumn, + TranscriptColumn, } from './table-custom-columns'; export { @@ -22,4 +23,5 @@ export { MoreInfoColumn, StatusColumn, ThumbnailColumn, + TranscriptColumn, }; diff --git a/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js index 149aed54ce..7c07deeeea 100644 --- a/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.js @@ -5,30 +5,8 @@ export const getFilterOptions = (columns) => { const filterableColumns = columns.filter(column => column?.filterChoices); filterableColumns.forEach(column => { - const { id, filterChoices } = column; - let updatedChoices = filterChoices; - - switch (id) { - case 'locked': - updatedChoices = filterChoices.map(choice => ( - { ...choice, value: choice.value ? 'locked' : 'public' } - )); - break; - case 'usageLocations': - updatedChoices = filterChoices.map(choice => ( - { ...choice, value: choice.value ? 'active' : 'inactive' } - )); - break; - case 'transcripts': - updatedChoices = filterChoices.map(choice => ( - { ...choice, value: choice.value ? 'transcribed' : 'notTranscribed' } - )); - break; - default: - break; - } - - allOptions.push(...updatedChoices); + const { filterChoices } = column; + allOptions.push(...filterChoices); }); return allOptions; @@ -39,26 +17,11 @@ export const getCheckedFilters = (state) => { const allFilters = []; filters.forEach(filter => { const { id, value } = filter; - let updatedValues = value; - - switch (id) { - case 'locked': - updatedValues = value.map(val => (val ? 'locked' : 'public')); - break; - case 'usageLocations': - updatedValues = value.map(val => (val ? 'active' : 'inactive')); - break; - case 'transcripts': - updatedValues = value.map(val => (val ? 'transcribed' : 'notTranscribed')); - break; - default: - break; - } - if (isArray(updatedValues)) { - allFilters.push(...updatedValues); + if (isArray(value)) { + allFilters.push(...value); } else { - allFilters.push([id, updatedValues]); + allFilters.push([id, value]); } }); @@ -77,47 +40,7 @@ export const processFilters = (filters, columns, setAllFilters) => { filterableColumns.forEach(({ id, filterChoices }) => { const filterValues = filterChoices.map(choice => choice.value); - let processedFilters = filters; - - switch (id) { - case 'locked': - processedFilters = filters.map(match => { - if (match === 'locked') { - return true; - } - if (match === 'public') { - return false; - } - return match; - }); - break; - case 'usageLocations': - processedFilters = filters.map(match => { - if (match === 'active') { - return true; - } - if (match === 'inactive') { - return false; - } - return match; - }); - break; - case 'transcripts': - processedFilters = filters.map(match => { - if (match === 'transcribed') { - return true; - } - if (match === 'notTranscribed') { - return false; - } - return match; - }); - break; - default: - break; - } - - const matchingFilters = filterValues.filter(value => processedFilters.includes(value)); + const matchingFilters = filterValues.filter(value => filters.includes(value)); if (!isEmpty(matchingFilters)) { allFilters.push({ id, value: matchingFilters }); diff --git a/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js index 55fdb3a245..824f2b66d6 100644 --- a/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js +++ b/src/files-and-videos/generic/table-components/sort-and-filter-modal/utils.test.js @@ -1,84 +1,6 @@ import { getCheckedFilters, getFilterOptions, processFilters } from './utils'; describe('getCheckboxFilters', () => { - describe('switch case locked', () => { - it('should equal array with string locked', () => { - const state = { - filters: [ - { id: 'locked', value: [true] }, - ], - }; - const expected = ['locked']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal public', () => { - const state = { - filters: [ - { id: 'locked', value: [false] }, - ], - }; - const expected = ['public']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - }); - - describe('switch case usageLocations', () => { - it('value attribute should equal active', () => { - const state = { - filters: [ - { id: 'usageLocations', value: [true] }, - ], - }; - const expected = ['active']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal inactive', () => { - const state = { - filters: [ - { id: 'usageLocations', value: [false] }, - ], - }; - const expected = ['inactive']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - }); - - describe('switch case transcripts', () => { - it('should equal array with string transcribed', () => { - const state = { - filters: [ - { id: 'transcripts', value: [true] }, - ], - }; - const expected = ['transcribed']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - - it('should equal array with string notTranscribed', () => { - const state = { - filters: [ - { id: 'transcripts', value: [false] }, - ], - }; - const expected = ['notTranscribed']; - const actual = getCheckedFilters(state); - - expect(actual).toEqual(expected); - }); - }); - describe('switch case default', () => { it('should equal array with string test', () => { const state = { @@ -107,84 +29,6 @@ describe('getCheckboxFilters', () => { }); describe('getFilterOptions', () => { - describe('switch case locked', () => { - it('value attribute should equal locked', () => { - const columns = [ - { id: 'locked', filterChoices: [{ name: 'Locked', value: true }] }, - ]; - const expected = [ - { name: 'Locked', value: 'locked' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal public', () => { - const columns = [ - { id: 'locked', filterChoices: [{ name: 'Public', value: false }] }, - ]; - const expected = [ - { name: 'Public', value: 'public' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - }); - - describe('switch case usageLocation', () => { - it('value attribute should equal active', () => { - const columns = [ - { id: 'usageLocations', filterChoices: [{ name: 'Active', value: true }] }, - ]; - const expected = [ - { name: 'Active', value: 'active' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal inactive', () => { - const columns = [ - { id: 'usageLocations', filterChoices: [{ name: 'Inactive', value: false }] }, - ]; - const expected = [ - { name: 'Inactive', value: 'inactive' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - }); - - describe('switch case transcripts', () => { - it('value attribute should equal transcribed', () => { - const columns = [ - { id: 'transcripts', filterChoices: [{ name: 'Transcribed', value: true }] }, - ]; - const expected = [ - { name: 'Transcribed', value: 'transcribed' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - - it('value attribute should equal notTranscribed', () => { - const columns = [ - { id: 'transcripts', filterChoices: [{ name: 'Not transcribed', value: false }] }, - ]; - const expected = [ - { name: 'Not transcribed', value: 'notTranscribed' }, - ]; - const actual = getFilterOptions(columns); - - expect(actual).toEqual(expected); - }); - }); - describe('switch case default', () => { it('value attribute should equal test', () => { const columns = [ @@ -217,90 +61,6 @@ describe('processFilters', () => { expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); }); - describe('switch case locked', () => { - it('should call setAllFilters with locked filter', () => { - const filters = ['locked']; - const columns = [ - { id: 'locked', filterChoices: [{ name: 'Locked', value: true }, { name: 'Public', value: false }] }, - ]; - const expectedParameter = [{ id: 'locked', value: [true] }]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - - it('should call setAllFilters with public filter', () => { - const filters = ['public', 'filter']; - const columns = [ - { id: 'locked', filterChoices: [{ name: 'Public', value: false }] }, - { id: 'test', filterChoices: [{ name: 'Filter', value: 'filter' }] }, - ]; - const expectedParameter = [ - { id: 'locked', value: [false] }, - { id: 'test', value: ['filter'] }, - ]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - }); - - describe('switch case usageLocations', () => { - it('should call setAllFilters with active filter', () => { - const filters = ['active']; - const columns = [ - { id: 'usageLocations', filterChoices: [{ name: 'Active', value: true }] }, - ]; - const expectedParameter = [{ id: 'usageLocations', value: [true] }]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - - it('should call setAllFilters with inactive filter', () => { - const filters = ['inactive', 'filter']; - const columns = [ - { id: 'usageLocations', filterChoices: [{ name: 'Inactive', value: false }] }, - { id: 'test', filterChoices: [{ name: 'Filter', value: 'filter' }] }, - ]; - const expectedParameter = [ - { id: 'usageLocations', value: [false] }, - { id: 'test', value: ['filter'] }, - ]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - }); - - describe('switch case transcripts', () => { - it('should call setAllFilters with transcribed filter', () => { - const filters = ['transcribed', 'filter']; - const columns = [ - { id: 'transcripts', filterChoices: [{ name: 'Transcribed', value: true }] }, - { id: 'test', filterChoices: [{ name: 'Filter', value: 'filter' }] }, - ]; - const expectedParameter = [ - { id: 'transcripts', value: [true] }, - { id: 'test', value: ['filter'] }, - ]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - - it('should call setAllFilters with notTranscribed filter', () => { - const filters = ['notTranscribed']; - const columns = [ - { id: 'transcripts', filterChoices: [{ name: 'Not transcribed', value: false }] }, - ]; - const expectedParameter = [{ id: 'transcripts', value: [false] }]; - processFilters(filters, columns, setAllFilters); - - expect(setAllFilters).toHaveBeenCalledWith(expectedParameter); - }); - }); - describe('switch case default', () => { it('should call setAllFilters with test filter', () => { const filters = ['filter']; diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.jsx b/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.jsx new file mode 100644 index 0000000000..0f445c5c3e --- /dev/null +++ b/src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Icon } from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; +import { TRANSCRIPT_FAILURE_STATUSES } from '../../../videos-page/data/constants'; + +const TranscriptColumn = ({ row }) => { + const { transcripts, transcriptionStatus } = row.original; + const numOfTranscripts = transcripts?.length; + const transcriptMessage = numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; + + return ( +
+ {TRANSCRIPT_FAILURE_STATUSES.includes(transcriptionStatus) && ( + + )} + +
+ ); +}; + +TranscriptColumn.propTypes = { + row: { + original: { + transcript: PropTypes.arrayOf([PropTypes.string]).isRequired, + transcriptionStatus: PropTypes.string.isRequired, + }.isRequired, + }.isRequired, +}; + +export default injectIntl(TranscriptColumn); diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/index.js b/src/files-and-videos/generic/table-components/table-custom-columns/index.js index 78284945fc..5c33cab8e6 100644 --- a/src/files-and-videos/generic/table-components/table-custom-columns/index.js +++ b/src/files-and-videos/generic/table-components/table-custom-columns/index.js @@ -3,6 +3,7 @@ import ActiveColumn from './ActiveColumn'; import MoreInfoColumn from './MoreInfoColumn'; import StatusColumn from './StatusColumn'; import ThumbnailColumn from './ThumbnailColumn'; +import TranscriptColumn from './TranscriptColumn'; export { AccessColumn, @@ -10,4 +11,5 @@ export { MoreInfoColumn, StatusColumn, ThumbnailColumn, + TranscriptColumn, }; diff --git a/src/files-and-videos/videos-page/VideosPage.jsx b/src/files-and-videos/videos-page/VideosPage.jsx index 984bf9818a..d2c3b6241c 100644 --- a/src/files-and-videos/videos-page/VideosPage.jsx +++ b/src/files-and-videos/videos-page/VideosPage.jsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, @@ -37,6 +36,7 @@ import { FileTable, StatusColumn, ThumbnailColumn, + TranscriptColumn, } from '../generic'; import TranscriptSettings from './transcript-settings'; import VideoThumbnail from './VideoThumbnail'; @@ -108,32 +108,32 @@ const VideosPage = ({ fileType: 'video', }; const thumbnailPreview = (props) => VideoThumbnail({ ...props, handleAddThumbnail, videoImageSettings }); - const infoModalSidebar = (video) => VideoInfoModalSidebar({ video }); + const infoModalSidebar = (video, activeTab, setActiveTab) => ( + VideoInfoModalSidebar({ video, activeTab, setActiveTab }) + ); const maxFileSize = videoUploadMaxFileSize * 1073741824; const transcriptColumn = { - id: 'transcripts', + id: 'transcriptStatus', Header: 'Transcript', - accessor: (({ transcripts }) => !isEmpty(transcripts)), - Cell: ({ row }) => { - const { transcripts } = row.original; - const numOfTranscripts = transcripts?.length; - return numOfTranscripts > 0 ? `(${numOfTranscripts}) available` : null; - }, + accessor: 'transcriptStatus', + Cell: ({ row }) => TranscriptColumn({ row }), Filter: CheckboxFilter, + filter: 'exactTextCase', filterChoices: [ - { name: intl.formatMessage(messages.transcribedCheckboxLabel), value: true }, - { name: intl.formatMessage(messages.notTranscribedCheckboxLabel), value: false }, + { name: intl.formatMessage(messages.transcribedCheckboxLabel), value: 'transcribed' }, + { name: intl.formatMessage(messages.notTranscribedCheckboxLabel), value: 'notTranscribed' }, ], }; const activeColumn = { - id: 'usageLocations', + id: 'activeStatus', Header: 'Active', - accessor: (({ usageLocations }) => !isEmpty(usageLocations)), + accessor: 'activeStatus', Cell: ({ row }) => ActiveColumn({ row }), Filter: CheckboxFilter, + filter: 'exactTextCase', filterChoices: [ - { name: intl.formatMessage(messages.activeCheckboxLabel), value: true }, - { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: false }, + { name: intl.formatMessage(messages.activeCheckboxLabel), value: 'active' }, + { name: intl.formatMessage(messages.inactiveCheckboxLabel), value: 'inactive' }, ], }; const durationColumn = { diff --git a/src/files-and-videos/videos-page/VideosPage.test.jsx b/src/files-and-videos/videos-page/VideosPage.test.jsx index 1a4e889ce6..d431aa7fc7 100644 --- a/src/files-and-videos/videos-page/VideosPage.test.jsx +++ b/src/files-and-videos/videos-page/VideosPage.test.jsx @@ -430,73 +430,99 @@ describe('FilesAndUploads', () => { }); describe('card menu actions', () => { - it('should open video info', async () => { - renderComponent(); - await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + describe('Info', () => { + it('should open video info', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`) - .reply(201, { usageLocations: ['subsection - unit / block'] }); - await waitFor(() => { - fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); - }); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`) + .reply(201, { + usageLocations: [{ + display_location: 'subsection - unit / block', + url: 'base/unit_id#block_id', + }], + }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); - expect(screen.getByText(messages.infoTitle.defaultMessage)).toBeVisible(); + expect(screen.getByText(messages.infoTitle.defaultMessage)).toBeVisible(); - const { usageStatus } = store.getState().videos; + const { usageStatus } = store.getState().videos; - expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); + expect(usageStatus).toEqual(RequestStatus.SUCCESSFUL); - expect(screen.getByText('subsection - unit / block')).toBeVisible(); - }); + expect(screen.getByText('subsection - unit / block')).toBeVisible(); + }); - it('should open video info modal and show info tab', async () => { - renderComponent(); - await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); + it('should open video info modal and show info tab', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); - await waitFor(() => { - fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); + + expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + + const infoTab = screen.getAllByRole('tab')[0]; + expect(infoTab).toBeVisible(); + + expect(infoTab).toHaveClass('active'); }); - expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + it('should open video info modal and show transcript tab', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); + expect(videoMenuButton).toBeVisible(); - const infoTab = screen.getAllByRole('tab')[0]; - expect(infoTab).toBeVisible(); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); - expect(infoTab).toHaveClass('active'); - }); + expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); - it('should open video info modal and show transcript tab', async () => { - renderComponent(); - await mockStore(RequestStatus.SUCCESSFUL); - expect(screen.getByTestId('grid-card-mOckID1')).toBeVisible(); - const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1'); - expect(videoMenuButton).toBeVisible(); + const transcriptTab = screen.getAllByRole('tab')[1]; + await act(async () => { + fireEvent.click(transcriptTab); + }); + expect(transcriptTab).toBeVisible(); - axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1/usage`).reply(201, { usageLocations: [] }); - await waitFor(() => { - fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); - fireEvent.click(screen.getByText('Info')); + expect(transcriptTab).toHaveClass('active'); }); - expect(screen.getByText(messages.usageNotInUseMessage.defaultMessage)).toBeVisible(); + it('should show transcript error', async () => { + renderComponent(); + await mockStore(RequestStatus.SUCCESSFUL); + const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID3'); - const transcriptTab = screen.getAllByRole('tab')[1]; - await act(async () => { - fireEvent.click(transcriptTab); - }); - expect(transcriptTab).toBeVisible(); + axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID3/usage`).reply(201, { usageLocations: [] }); + await waitFor(() => { + fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle')); + fireEvent.click(screen.getByText('Info')); + }); - expect(transcriptTab).toHaveClass('active'); + const transcriptTab = screen.getAllByRole('tab')[1]; + await act(async () => { + fireEvent.click(transcriptTab); + }); + + expect(screen.getByText('Transcript (1)')).toBeVisible(); + }); }); it('download button should download file', async () => { diff --git a/src/files-and-videos/videos-page/data/constants.js b/src/files-and-videos/videos-page/data/constants.js index b534e7eb73..ab666b37aa 100644 --- a/src/files-and-videos/videos-page/data/constants.js +++ b/src/files-and-videos/videos-page/data/constants.js @@ -6,3 +6,6 @@ export const MIN_WIDTH = 640; export const MIN_HEIGHT = 360; export const ASPECT_RATIO = 16 / 9; export const ASPECT_RATIO_ERROR_MARGIN = 0.1; +export const TRANSCRIPT_FAILURE_STATUSES = ['Transcript Failed', 'Partial Failure']; +export const VIDEO_PROCESSING_STATUSES = ['Uploading', 'In Progress', 'Uploaded']; +export const VIDEO_SUCCESS_STATUSES = ['Ready', 'Imported']; diff --git a/src/files-and-videos/videos-page/data/thunks.js b/src/files-and-videos/videos-page/data/thunks.js index 42547a4719..72719bc1ec 100644 --- a/src/files-and-videos/videos-page/data/thunks.js +++ b/src/files-and-videos/videos-page/data/thunks.js @@ -176,11 +176,14 @@ export function deleteVideoTranscript({ apiUrl, }); const updatedTranscripts = transcripts.filter(transcript => transcript !== language); + const transcriptStatus = updatedTranscripts?.length > 0 ? 'transcribed' : 'notTranscribed'; + dispatch(updateModel({ modelType: 'videos', model: { id: videoId, transcripts: updatedTranscripts, + transcriptStatus, }, })); @@ -244,11 +247,14 @@ export function uploadVideoTranscript({ updatedTranscripts = [...transcripts, newLanguage]; } + const transcriptStatus = updatedTranscripts?.length > 0 ? 'transcribed' : 'notTranscribed'; + dispatch(updateModel({ modelType: 'videos', model: { id: videoId, transcripts: updatedTranscripts, + transcriptStatus, }, })); @@ -272,11 +278,14 @@ export function getUsagePaths({ video, courseId }) { try { const { usageLocations } = await getVideoUsagePaths({ videoId: video.id, courseId }); + const activeStatus = usageLocations?.length > 0 ? 'active' : 'inactive'; + dispatch(updateModel({ modelType: 'videos', model: { id: video.id, usageLocations, + activeStatus, }, })); dispatch(updateEditStatus({ editType: 'usageMetrics', status: RequestStatus.SUCCESSFUL })); diff --git a/src/files-and-videos/videos-page/data/utils.js b/src/files-and-videos/videos-page/data/utils.js index a5c4963e40..4d9408607b 100644 --- a/src/files-and-videos/videos-page/data/utils.js +++ b/src/files-and-videos/videos-page/data/utils.js @@ -7,6 +7,8 @@ import { MAX_WIDTH, MIN_HEIGHT, MIN_WIDTH, + VIDEO_PROCESSING_STATUSES, + VIDEO_SUCCESS_STATUSES, } from './constants'; ensureConfig([ @@ -22,6 +24,8 @@ export const updateFileValues = (files) => { created, courseVideoImageUrl, status, + transcripts, + usageLocations, } = file; const wrapperType = 'video'; @@ -29,11 +33,13 @@ export const updateFileValues = (files) => { if (thumbnail && thumbnail.startsWith('/')) { thumbnail = `${getConfig().STUDIO_BASE_URL}${thumbnail}`; } + const transcriptStatus = transcripts?.length > 0 ? 'transcribed' : 'notTranscribed'; + const activeStatus = usageLocations?.length > 0 ? 'active' : 'inactive'; let uploadStatus = status; - if (status === 'Ready' || status === 'Imported') { + if (VIDEO_SUCCESS_STATUSES.includes(status)) { uploadStatus = 'Success'; - } else if (status === 'In Progress' || status === 'Uploaded') { + } else if (VIDEO_PROCESSING_STATUSES.includes(status)) { uploadStatus = 'Processing'; } @@ -45,6 +51,8 @@ export const updateFileValues = (files) => { dateAdded: created.toString(), status: uploadStatus, thumbnail, + transcriptStatus, + activeStatus, }); }); diff --git a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx index fc0610319d..3aa621e9d9 100644 --- a/src/files-and-videos/videos-page/factories/mockApiResponses.jsx +++ b/src/files-and-videos/videos-page/factories/mockApiResponses.jsx @@ -120,6 +120,7 @@ export const generateFetchVideosApiResponse = () => ({ status: 'Imported', duration: 12333, downloadLink: 'http://mOckID1.mp4', + fileSize: 213456354, }, { edx_video_id: 'mOckID5', @@ -140,6 +141,8 @@ export const generateFetchVideosApiResponse = () => ({ status: 'Ready', duration: null, downloadLink: '', + transcription_status: 'Transcript Failed', + error_description: 'Unable to process transcript request', }, ], concurrent_upload_limit: 4, diff --git a/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx b/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx index 2c240fb006..dc3f9fa551 100644 --- a/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx @@ -8,13 +8,20 @@ import { import InfoTab from './InfoTab'; import TranscriptTab from './TranscriptTab'; import messages from './messages'; +import { TRANSCRIPT_FAILURE_STATUSES } from '../data/constants'; const VideoInfoModalSidebar = ({ video, + activeTab, + setActiveTab, // injected intl, }) => ( - + setActiveTab(tab)} + > @@ -24,6 +31,11 @@ const VideoInfoModalSidebar = ({ messages.transcriptTabTitle, { transcriptCount: video.transcripts.length }, )} + notification={TRANSCRIPT_FAILURE_STATUSES.includes(video.transcriptionStatus) && ( + + {intl.formatMessage(messages.notificationScreenReaderText)} + + )} > @@ -38,7 +50,10 @@ VideoInfoModalSidebar.propTypes = { dateAdded: PropTypes.string.isRequired, fileSize: PropTypes.number.isRequired, transcripts: PropTypes.arrayOf(PropTypes.string), + transcriptionStatus: PropTypes.string.isRequired, }), + activeTab: PropTypes.string.isRequired, + setActiveTab: PropTypes.func.isRequired, // injected intl: intlShape.isRequired, }; diff --git a/src/files-and-videos/videos-page/info-sidebar/messages.js b/src/files-and-videos/videos-page/info-sidebar/messages.js index 3beb245c61..a1823e4667 100644 --- a/src/files-and-videos/videos-page/info-sidebar/messages.js +++ b/src/files-and-videos/videos-page/info-sidebar/messages.js @@ -11,6 +11,11 @@ const messages = defineMessages({ defaultMessage: 'Transcript ({transcriptCount})', description: 'Title for info tab', }, + notificationScreenReaderText: { + id: 'course-authoring.video-uploads.file-info.transcriptTab.notification.screenReader.text', + defaultMessage: 'Transcription error', + description: 'Scrren reader text for transcript tab notification', + }, dateAddedTitle: { id: 'course-authoring.video-uploads.file-info.infoTab.dateAdded.title', defaultMessage: 'Date added', diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx index 024c14eff1..f513927a33 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx @@ -24,7 +24,7 @@ const LanguageSelect = ({ > {currentSelection} - +
{Object.entries(options).map(([valueKey, text]) => { if (valueKey === value) { diff --git a/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx index 993aea8c25..af8351a008 100644 --- a/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx @@ -54,18 +54,19 @@ const Transcript = ({ <> {isConfirmationOpen ? ( - )} /> + )} /> -