diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.tsx similarity index 90% rename from src/course-outline/CourseOutline.test.jsx rename to src/course-outline/CourseOutline.test.tsx index 3ff6690d2..4fcc13963 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -1,16 +1,20 @@ -import { - act, render, waitFor, fireEvent, within, screen, -} from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { AppProvider } from '@edx/frontend-platform/react'; -import { getConfig, initializeMockApp } from '@edx/frontend-platform'; -import MockAdapter from 'axios-mock-adapter'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { getConfig } from '@edx/frontend-platform'; import { cloneDeep } from 'lodash'; import { closestCorners } from '@dnd-kit/core'; import { logError } from '@edx/frontend-platform/logging'; import { useLocation } from 'react-router-dom'; +import { RequestStatus } from '@src/data/constants'; +import { clipboardUnit } from '@src/__mocks__'; +import { executeThunk } from '@src/utils'; +import configureModalMessages from '@src/generic/configure-modal/messages'; +import pasteButtonMessages from '@src/generic/clipboard/paste-component/messages'; +import { getApiBaseUrl, getClipboardUrl } from '@src/generic/data/api'; +import { postXBlockBaseApiUrl } from '@src/course-unit/data/api'; +import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { + act, fireEvent, initializeMocks, render, screen, waitFor, within, +} from '@src/testUtils'; +import { XBlock } from '@src/data/types'; import { getCourseBestPracticesApiUrl, getCourseLaunchApiUrl, @@ -21,16 +25,14 @@ import { getCourseItemApiUrl, getXBlockBaseApiUrl, exportTags, - createDiscussionsTopics, + createDiscussionsTopicsUrl, } from './data/api'; -import { RequestStatus } from '../data/constants'; import { fetchCourseBestPracticesQuery, fetchCourseLaunchQuery, fetchCourseOutlineIndexQuery, syncDiscussionsTopics, updateCourseSectionHighlightsQuery, } from './data/thunk'; -import initializeStore from '../store'; import { courseOutlineIndexMock, courseOutlineIndexWithoutSections, @@ -39,15 +41,10 @@ import { courseSectionMock, courseSubsectionMock, } from './__mocks__'; -import { clipboardUnit } from '../__mocks__'; -import { executeThunk } from '../utils'; import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants'; import CourseOutline from './CourseOutline'; -import configureModalMessages from '../generic/configure-modal/messages'; -import pasteButtonMessages from '../generic/clipboard/paste-component/messages'; import messages from './messages'; -import { getApiBaseUrl, getClipboardUrl } from '../generic/data/api'; import headerMessages from './header-navigations/messages'; import cardHeaderMessages from './card-header/messages'; import enableHighlightsModalMessages from './enable-highlights-modal/messages'; @@ -60,14 +57,13 @@ import { moveSubsection, moveUnit, } from './drag-helper/utils'; -import { postXBlockBaseApiUrl } from '../course-unit/data/api'; -import { COMPONENT_TYPES } from '../generic/block-type-utils/constants'; -let axiosMock; +let axiosMock: import('axios-mock-adapter/types'); let store; const mockPathname = '/foo-bar'; const courseId = '123'; -const containerKey = 'lct:org:lib:unit:1'; +const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1'); +const getContainerType = jest.fn().mockReturnValue('unit'); window.HTMLElement.prototype.scrollIntoView = jest.fn(); @@ -76,7 +72,7 @@ jest.mock('react-router-dom', () => ({ useLocation: jest.fn(), })); -jest.mock('../help-urls/hooks', () => ({ +jest.mock('@src/help-urls/hooks', () => ({ useHelpUrls: () => ({ contentHighlights: 'some', visibility: 'some', @@ -98,13 +94,13 @@ jest.mock('./data/api', () => ({ })); // Mock ComponentPicker to call onComponentSelected on click -jest.mock('../library-authoring/component-picker', () => ({ +jest.mock('@src/library-authoring/component-picker', () => ({ ComponentPicker: (props) => { const onClick = () => { // eslint-disable-next-line react/prop-types props.onComponentSelected({ - usageKey: containerKey, - blockType: 'unti', + usageKey: getContainerKey(), + blockType: getContainerType(), }); }; return ( @@ -119,8 +115,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), })); -const queryClient = new QueryClient(); - jest.mock('@dnd-kit/core', () => ({ ...jest.requireActual('@dnd-kit/core'), // Since jsdom (used by jest) does not support getBoundingClientRect function @@ -130,38 +124,34 @@ jest.mock('@dnd-kit/core', () => ({ closestCorners: jest.fn(), })); +jest.mock('@src/studio-home/data/selectors', () => ({ + ...jest.requireActual('@src/studio-home/data/selectors'), + getStudioHomeData: jest.fn().mockReturnValue({ + librariesV2Enabled: true, + }), +})); + // eslint-disable-next-line no-promise-executor-return const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const RootWrapper = () => ( - - - - - - - +const renderComponent = () => render( + , ); describe('', () => { beforeEach(async () => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); + const mocks = initializeMocks(); - useLocation.mockReturnValue({ + jest.mocked(useLocation).mockReturnValue({ pathname: mockPathname, + state: undefined, + key: '', + search: '', + hash: '', }); - store = initializeStore({ - studioHome: { studioHomeData: { librariesV2Enabled: true } }, - }); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + store = mocks.reduxStore; + axiosMock = mocks.axiosMock; axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, courseOutlineIndexMock); @@ -188,7 +178,7 @@ describe('', () => { }); it('render CourseOutline component correctly', async () => { - const { getByText } = render(); + const { getByText } = renderComponent(); await waitFor(() => { expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument(); @@ -198,10 +188,10 @@ describe('', () => { it('logs an error when syncDiscussionsTopics encounters an API failure', async () => { axiosMock - .onGet(createDiscussionsTopics(courseId)) + .onPost(createDiscussionsTopicsUrl(courseId)) .reply(500, 'some internal error'); - await executeThunk(syncDiscussionsTopics(), store.dispatch); + await executeThunk(syncDiscussionsTopics(courseId), store.dispatch); expect(logError).toHaveBeenCalledTimes(1); }); @@ -211,7 +201,7 @@ describe('', () => { .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(500, 'some internal error'); - const { findByText, queryByRole } = render(); + const { findByText, queryByRole } = renderComponent(); expect(await findByText('"some internal error"')).toBeInTheDocument(); // check errors in store expect(store.getState().courseOutline.errors).toEqual({ @@ -230,7 +220,7 @@ describe('', () => { }); it('check reindex and render success alert is correctly', async () => { - const { findByText, findByTestId } = render(); + const { findByText, findByTestId } = renderComponent(); axiosMock .onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink)) @@ -242,7 +232,7 @@ describe('', () => { }); it('check video sharing option udpates correctly', async () => { - const { findByLabelText } = render(); + const { findByLabelText } = renderComponent(); axiosMock .onPost(getCourseBlockApiUrl(courseId), { @@ -265,7 +255,7 @@ describe('', () => { }); it('check video sharing option shows error on failure', async () => { - render(); + renderComponent(); axiosMock .onPost(getCourseBlockApiUrl(courseId), { @@ -295,7 +285,7 @@ describe('', () => { }); it('render error alert after failed reindex correctly', async () => { - const { findByText, findByTestId } = render(); + const { findByText, findByTestId } = renderComponent(); axiosMock .onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink)) @@ -307,7 +297,7 @@ describe('', () => { }); it('check that new section list is saved when dragged', async () => { - const { findAllByRole, findByTestId } = render(); + const { findAllByRole, findByTestId } = renderComponent(); const expandAllButton = await findByTestId('expand-collapse-all-button'); fireEvent.click(expandAllButton); const [section] = store.getState().courseOutline.sectionsList; @@ -319,7 +309,7 @@ describe('', () => { .reply(200, { dummy: 'value' }); const section1 = store.getState().courseOutline.sectionsList[0].id; - closestCorners.mockReturnValue([{ id: section1 }]); + jest.mocked(closestCorners).mockReturnValue([{ id: section1 }]); fireEvent.keyDown(draggableButton, { code: 'Space' }); await sleep(1); @@ -334,7 +324,7 @@ describe('', () => { }); it('check section list is restored to original order when API call fails', async () => { - const { findAllByRole, findByTestId } = render(); + const { findAllByRole, findByTestId } = renderComponent(); const expandAllButton = await findByTestId('expand-collapse-all-button'); fireEvent.click(expandAllButton); const [section] = store.getState().courseOutline.sectionsList; @@ -346,7 +336,7 @@ describe('', () => { .reply(500); const section1 = store.getState().courseOutline.sectionsList[0].id; - closestCorners.mockReturnValue([{ id: section1 }]); + jest.mocked(closestCorners).mockReturnValue([{ id: section1 }]); fireEvent.keyDown(draggableButton, { code: 'Space' }); await sleep(1); @@ -361,11 +351,18 @@ describe('', () => { }); it('adds new section correctly', async () => { - const { findAllByTestId, findByTestId } = render(); + const { findAllByTestId } = renderComponent(); let elements = await findAllByTestId('section-card'); window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ top: 0, bottom: 4000, + height: 0, + width: 0, + x: 0, + y: 0, + left: 0, + right: 0, + toJSON: () => {}, })); expect(elements.length).toBe(4); @@ -377,22 +374,29 @@ describe('', () => { axiosMock .onGet(getXBlockApiUrl(courseSectionMock.id)) .reply(200, courseSectionMock); - const newSectionButton = await findByTestId('new-section-button'); + const newSectionButton = (await screen.findAllByRole('button', { name: 'New section' }))[0]; await act(async () => fireEvent.click(newSectionButton)); elements = await findAllByTestId('section-card'); expect(elements.length).toBe(5); - expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled(); + expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); }); it('adds new subsection correctly', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); const [section] = await findAllByTestId('section-card'); let subsections = await within(section).findAllByTestId('subsection-card'); expect(subsections.length).toBe(2); window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({ top: 0, bottom: 4000, + height: 0, + width: 0, + x: 0, + y: 0, + left: 0, + right: 0, + toJSON: () => {}, })); axiosMock @@ -403,18 +407,18 @@ describe('', () => { axiosMock .onGet(getXBlockApiUrl(courseSubsectionMock.id)) .reply(200, courseSubsectionMock); - const newSubsectionButton = await within(section).findByTestId('new-subsection-button'); + const newSubsectionButton = await within(section).findByRole('button', { name: 'New subsection' }); await act(async () => { fireEvent.click(newSubsectionButton); }); subsections = await within(section).findAllByTestId('subsection-card'); expect(subsections.length).toBe(3); - expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled(); + expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); }); it('adds new unit correctly', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); const [sectionElement] = await findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const units = await within(subsectionElement).findAllByTestId('unit-card'); @@ -425,7 +429,7 @@ describe('', () => { .reply(200, { locator: 'some', }); - const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button'); + const newUnitButton = await within(subsectionElement).findByRole('button', { name: 'New unit' }); await act(async () => fireEvent.click(newUnitButton)); expect(axiosMock.history.post.length).toBe(3); const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; @@ -438,7 +442,9 @@ describe('', () => { }); it('adds a unit from library correctly', async () => { - render(); + getContainerKey.mockReturnValue('lct:org:lib:unit:1'); + getContainerKey.mockReturnValue('unit'); + renderComponent(); const [sectionElement] = await screen.findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const units = await within(subsectionElement).findAllByTestId('unit-card'); @@ -448,6 +454,7 @@ describe('', () => { .onPost(postXBlockBaseApiUrl()) .reply(200, { locator: 'some', + parent_locator: 'parent', }); const addUnitFromLibraryButton = within(subsectionElement).getByRole('button', { @@ -459,20 +466,95 @@ describe('', () => { const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' }); fireEvent.click(dummyBtn); - waitFor(() => expect(axiosMock.history.post.length).toBe(2)); + waitFor(() => expect(axiosMock.history.post.length).toBe(3)); const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [subsection] = section.childInfo.children; - expect(axiosMock.history.post[2].data).toBe(JSON.stringify({ - type: COMPONENT_TYPES.libraryV2, - category: 'vertical', - parent_locator: subsection.id, - library_content_key: containerKey, - })); + waitFor(() => { + expect(axiosMock.history.post[2].data).toBe(JSON.stringify({ + type: COMPONENT_TYPES.libraryV2, + category: 'vertical', + parent_locator: subsection.id, + library_content_key: getContainerKey(), + })); + }); + }); + + it('adds a subsection from library correctly', async () => { + getContainerKey.mockReturnValue('lct:org:lib:subsection:1'); + getContainerKey.mockReturnValue('subsection'); + renderComponent(); + const [sectionElement] = await screen.findAllByTestId('section-card'); + const subsections = await within(sectionElement).findAllByTestId('subsection-card'); + expect(subsections.length).toBe(2); + + axiosMock + .onPost(postXBlockBaseApiUrl()) + .reply(200, { + locator: 'some', + parent_locator: 'parent', + }); + + const addSubsectionFromLibraryButton = within(sectionElement).getByRole('button', { + name: /use subsection from library/i, + }); + fireEvent.click(addSubsectionFromLibraryButton); + + // click dummy button to execute onComponentSelected prop. + const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' }); + fireEvent.click(dummyBtn); + + waitFor(() => expect(axiosMock.history.post.length).toBe(3)); + + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + waitFor(() => { + expect(axiosMock.history.post[2].data).toBe(JSON.stringify({ + type: COMPONENT_TYPES.libraryV2, + category: 'sequential', + parent_locator: section.id, + library_content_key: getContainerKey(), + })); + }); + }); + + it('adds a section from library correctly', async () => { + getContainerKey.mockReturnValue('lct:org:lib:section:1'); + getContainerKey.mockReturnValue('section'); + renderComponent(); + const sections = await screen.findAllByTestId('section-card'); + expect(sections.length).toBe(4); + + axiosMock + .onPost(postXBlockBaseApiUrl()) + .reply(200, { + locator: 'some', + parent_locator: 'parent', + }); + + const addSectionFromLibraryButton = await screen.findByRole('button', { + name: /use section from library/i, + }); + fireEvent.click(addSectionFromLibraryButton); + + // click dummy button to execute onComponentSelected prop. + const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' }); + fireEvent.click(dummyBtn); + + waitFor(() => expect(axiosMock.history.post.length).toBe(3)); + + const courseUsageKey = courseOutlineIndexMock.courseStructure.id; + waitFor(() => { + expect(axiosMock.history.post[2].data).toBe(JSON.stringify({ + type: COMPONENT_TYPES.libraryV2, + category: 'chapter', + parent_locator: courseUsageKey, + library_content_key: getContainerKey(), + })); + }); }); it('render checklist value correctly', async () => { - const { getByText } = render(); + const { getByText } = renderComponent(); await executeThunk(fetchCourseLaunchQuery({ courseId, gradedOnly: true, validateOras: true, all: true, @@ -490,7 +572,7 @@ describe('', () => { courseId, gradedOnly: true, validateOras: true, all: true, })) .reply(500); - const { findByText, findByRole } = render(); + const { findByText, findByRole } = renderComponent(); await executeThunk(fetchCourseLaunchQuery({ courseId, gradedOnly: true, validateOras: true, all: true, @@ -520,7 +602,7 @@ describe('', () => { }); it('check highlights are enabled after enable highlights query is successful', async () => { - const { findByTestId, findByText } = render(); + const { findByTestId, findByText } = renderComponent(); axiosMock.reset(); axiosMock @@ -549,7 +631,7 @@ describe('', () => { }); it('should expand and collapse subsections, after click on subheader buttons', async () => { - const { queryAllByTestId, findByText } = render(); + const { queryAllByTestId, findByText } = renderComponent(); const collapseBtn = await findByText(headerMessages.collapseAllButton.defaultMessage); expect(collapseBtn).toBeInTheDocument(); @@ -573,7 +655,7 @@ describe('', () => { .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, courseOutlineIndexWithoutSections); - const { getByTestId } = render(); + const { getByTestId } = renderComponent(); await waitFor(() => { expect(getByTestId('empty-placeholder')).toBeInTheDocument(); @@ -588,7 +670,7 @@ describe('', () => { notificationDismissUrl: '/some/url', }); - render(); + renderComponent(); const alert = await screen.findByText(pageAlertMessages.configurationErrorTitle.defaultMessage); expect(alert).toBeInTheDocument(); const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' }); @@ -601,15 +683,11 @@ describe('', () => { }); it('check edit title works for section, subsection and unit', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); const checkEditTitle = async (section, element, item, newName, elementName) => { axiosMock.reset(); axiosMock - .onPost(getCourseItemApiUrl(item.id, { - metadata: { - display_name: newName, - }, - })) + .onPost(getCourseItemApiUrl(item.id)) .reply(200, { dummy: 'value' }); // mock section, subsection and unit name and check within the elements. // this is done to avoid adding conditions to this mock. @@ -643,14 +721,13 @@ describe('', () => { await act(async () => fireEvent.blur(editField)); expect( axiosMock.history.post[axiosMock.history.post.length - 1].data, - `Failed for ${elementName}!`, ).toBe(JSON.stringify({ metadata: { display_name: newName, }, })); const results = await within(element).findAllByText(newName); - expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0); + expect(results.length).toBeGreaterThan(0); }; // check section @@ -670,7 +747,7 @@ describe('', () => { }); it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => { - render(); + renderComponent(); // get section, subsection and unit const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await screen.findAllByTestId('section-card'); @@ -681,7 +758,7 @@ describe('', () => { const checkDeleteBtn = async (item, element, elementName) => { await waitFor(() => { - expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument(); + expect(screen.queryByText(item.displayName)).toBeInTheDocument(); }); axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200); @@ -694,7 +771,7 @@ describe('', () => { fireEvent.click(confirmButton); await waitFor(() => { - expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument(); + expect(screen.queryByText(item.displayName)).not.toBeInTheDocument(); }); }; @@ -708,9 +785,9 @@ describe('', () => { }); it('check whether section, subsection and unit is duplicated successfully', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get section, subsection and unit - const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children as unknown as XBlock[]; const [sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); @@ -722,12 +799,10 @@ describe('', () => { if (parentElement) { expect( await within(parentElement).findAllByTestId(`${elementName}-card`), - `Failed for ${elementName}!`, ).toHaveLength(expectedLength - 1); } else { expect( await findAllByTestId(`${elementName}-card`), - `Failed for ${elementName}!`, ).toHaveLength(expectedLength - 1); } @@ -758,12 +833,10 @@ describe('', () => { if (parentElement) { expect( await within(parentElement).findAllByTestId(`${elementName}-card`), - `Failed for ${elementName}!`, ).toHaveLength(expectedLength); } else { expect( await findAllByTestId(`${elementName}-card`), - `Failed for ${elementName}!`, ).toHaveLength(expectedLength); } }; @@ -778,8 +851,8 @@ describe('', () => { }); it('check section, subsection & unit is published when publish button is clicked', async () => { - const { findAllByTestId, findByTestId } = render(); - const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const { findAllByTestId, findByTestId } = renderComponent(); + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children as unknown as XBlock[]; const [sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); @@ -788,8 +861,7 @@ describe('', () => { const checkPublishBtn = async (item, element, elementName) => { expect( - (await within(element).getAllByRole('status'))[0], - `Failed for ${elementName}!`, + (await within(element).findAllByRole('status'))[0], ).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage); axiosMock @@ -819,6 +891,7 @@ describe('', () => { { ...section.childInfo.children[0], childInfo: { + displayName: 'Unit Tests', children: [ { ...section.childInfo.children[0].childInfo.children[0], @@ -846,8 +919,7 @@ describe('', () => { await act(async () => fireEvent.click(confirmButton)); expect( - (await within(element).getAllByRole('status'))[0], - `Failed for ${elementName}!`, + (await within(element).findAllByRole('status'))[0], ).toHaveTextContent(cardHeaderMessages.statusBadgeLive.defaultMessage); }; @@ -860,7 +932,7 @@ describe('', () => { }); it('check configure modal for section', async () => { - const { findByTestId, findAllByTestId } = render(); + const { findByTestId, findAllByTestId } = renderComponent(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; const newReleaseDateIso = '2025-09-10T22:00:00Z'; const newReleaseDate = '09/10/2025'; @@ -916,8 +988,8 @@ describe('', () => { const { findAllByTestId, findByTestId, - } = render(); - const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + } = renderComponent(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock; const [subsection] = section.childInfo.children; const expectedRequestData = { publish: 'republish', @@ -953,7 +1025,7 @@ describe('', () => { subsection.format = expectedRequestData.graderType; subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; - subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue; + subsection.hideAfterDue = expectedRequestData.metadata.hide_after_due; section.childInfo.children[0] = subsection; axiosMock .onGet(getXBlockApiUrl(section.id)) @@ -1009,7 +1081,7 @@ describe('', () => { expect(releaseDatePicker).toHaveValue('08/10/2025'); releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM'); expect(releaseDateTimePicker).toHaveValue('00:00'); - dueDateStack = await await within(configureModal).findByTestId('due-date-stack'); + dueDateStack = await within(configureModal).findByTestId('due-date-stack'); dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY'); expect(dueDatePicker).toHaveValue('09/10/2025'); dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM'); @@ -1031,8 +1103,8 @@ describe('', () => { const { findAllByTestId, findByTestId, - } = render(); - const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + } = renderComponent(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock; const [subsection, secondSubsection] = section.childInfo.children; const expectedRequestData = { publish: 'republish', @@ -1176,8 +1248,8 @@ describe('', () => { const { findAllByTestId, findByTestId, - } = render(); - const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + } = renderComponent(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock; const [subsection] = section.childInfo.children; const expectedRequestData = { publish: 'republish', @@ -1276,8 +1348,8 @@ describe('', () => { const { findAllByTestId, findByTestId, - } = render(); - const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + } = renderComponent(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock; const [, subsection] = section.childInfo.children; const expectedRequestData = { publish: 'republish', @@ -1376,8 +1448,8 @@ describe('', () => { const { findAllByTestId, findByTestId, - } = render(); - const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]); + } = renderComponent(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]) as unknown as XBlock; const [subsection] = section.childInfo.children; const expectedRequestData = { publish: 'republish', @@ -1466,7 +1538,7 @@ describe('', () => { }); it('check configure modal for unit', async () => { - const { findAllByTestId, findByTestId } = render(); + const { findAllByTestId, findByTestId } = renderComponent(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; const [subsection] = section.childInfo.children; const [unit] = subsection.childInfo.children; @@ -1530,7 +1602,7 @@ describe('', () => { .reply(200, section); fireEvent.click(unitDropdownButton); - const configureBtn = await within(firstUnit).getByTestId('unit-card-header__menu-configure-button'); + const configureBtn = await within(firstUnit).findByTestId('unit-card-header__menu-configure-button'); fireEvent.click(configureBtn); let configureModal = await findByTestId('configure-modal'); @@ -1575,7 +1647,7 @@ describe('', () => { }); it('check update highlights when update highlights query is successfully', async () => { - const { getByRole } = render(); + const { getByRole } = renderComponent(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; const highlights = [ @@ -1610,7 +1682,7 @@ describe('', () => { }); it('check whether section move up and down options work correctly', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get second section element const courseBlockId = courseOutlineIndexMock.courseStructure.id; const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children; @@ -1639,7 +1711,7 @@ describe('', () => { }); it('check whether section move up & down option is rendered correctly based on index', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get first, second and last section element const { 0: firstSection, 1: secondSection, length, [length - 1]: lastSection, @@ -1682,7 +1754,7 @@ describe('', () => { }); it('check whether subsection move up and down options work correctly', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get second section element const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); @@ -1721,7 +1793,7 @@ describe('', () => { }); it('check whether subsection move up to prev section if it is on top of its parent section', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); const [firstSection, section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [, sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; @@ -1757,7 +1829,7 @@ describe('', () => { }); it('check whether subsection move down to next section if it is in bottom position of its parent section', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); const [section, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); const lastSubsectionIdx = section.childInfo.children.length - 1; @@ -1794,7 +1866,7 @@ describe('', () => { }); it('check whether subsection move up & down option is rendered correctly based on index', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // using first section const sectionElements = await findAllByTestId('section-card'); const firstSectionElement = sectionElements[0]; @@ -1844,7 +1916,7 @@ describe('', () => { }); it('check whether unit move up and down options work correctly', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get second section -> second subsection -> second unit element const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [, sectionElement] = await findAllByTestId('section-card'); @@ -1883,7 +1955,7 @@ describe('', () => { }); it('check whether unit moves up to previous subsection if it is in top position in parent subsection', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get second section -> second subsection -> first unit element const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [, sectionElement] = await findAllByTestId('section-card'); @@ -1917,7 +1989,7 @@ describe('', () => { }); it('check whether unit moves up to previous subsection of prev section if it is in top position in parent subsection & section', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get second section -> second subsection -> first unit element const [firstSection, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children; const [, sectionElement] = await findAllByTestId('section-card'); @@ -1962,7 +2034,7 @@ describe('', () => { }); it('check whether unit moves down to next subsection if it is in last position in parent subsection', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get second section -> second subsection -> first unit element const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [, sectionElement] = await findAllByTestId('section-card'); @@ -1997,7 +2069,7 @@ describe('', () => { }); it('check whether unit moves down to next subsection of next section if it is in last position in parent subsection & section', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get second section -> second subsection -> first unit element const [, secondSection, thirdSection] = courseOutlineIndexMock.courseStructure.childInfo.children; const [, sectionElement] = await findAllByTestId('section-card'); @@ -2044,7 +2116,7 @@ describe('', () => { }); it('check whether unit move up & down option is rendered correctly based on index', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // using first section -> first subsection -> first unit const sections = await findAllByTestId('section-card'); const [sectionElement] = sections; @@ -2085,7 +2157,7 @@ describe('', () => { }); it('check that new subsection list is saved when dragged', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); const [sectionElement] = await findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); @@ -2095,7 +2167,7 @@ describe('', () => { const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = subsectionsDraggers[1]; const subsection1 = section.childInfo.children[0].id; - closestCorners.mockReturnValue([{ id: subsection1 }]); + jest.mocked(closestCorners).mockReturnValue([{ id: subsection1 }]); axiosMock .onPut(getCourseItemApiUrl(section.id)) .reply(200, { dummy: 'value' }); @@ -2119,7 +2191,7 @@ describe('', () => { }); it('check that new subsection list is restored to original order when API call fails', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); const [sectionElement] = await findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); @@ -2129,7 +2201,7 @@ describe('', () => { const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); const draggableButton = subsectionsDraggers[1]; const subsection1 = section.childInfo.children[0].id; - closestCorners.mockReturnValue([{ id: subsection1 }]); + jest.mocked(closestCorners).mockReturnValue([{ id: subsection1 }]); axiosMock .onPut(getCourseItemApiUrl(section.id)) @@ -2148,7 +2220,7 @@ describe('', () => { }); it('check that new unit list is saved when dragged', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get third section const [, , sectionElement] = await findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); @@ -2159,7 +2231,7 @@ describe('', () => { const sections = courseOutlineIndexMock.courseStructure.childInfo.children; const unit1 = subsection.childInfo.children[0].id; - closestCorners.mockReturnValue([{ id: unit1 }]); + jest.mocked(closestCorners).mockReturnValue([{ id: unit1 }]); axiosMock .onPut(getCourseItemApiUrl(subsection.id)) @@ -2182,7 +2254,7 @@ describe('', () => { }); it('check that new unit list is restored to original order when API call fails', async () => { - const { findAllByTestId } = render(); + const { findAllByTestId } = renderComponent(); // get third section const [, , sectionElement] = await findAllByTestId('section-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); @@ -2193,7 +2265,7 @@ describe('', () => { const sections = courseOutlineIndexMock.courseStructure.childInfo.children; const unit1 = subsection.childInfo.children[0].id; - closestCorners.mockReturnValue([{ id: unit1 }]); + jest.mocked(closestCorners).mockReturnValue([{ id: unit1 }]); axiosMock .onPut(getCourseItemApiUrl(subsection.id)) @@ -2216,7 +2288,7 @@ describe('', () => { }); it('check whether unit copy & paste option works correctly', async () => { - render(); + renderComponent(); // get first section -> first subsection -> first unit element const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await screen.findAllByTestId('section-card'); @@ -2251,7 +2323,7 @@ describe('', () => { // find clipboard content popover link const popoverContent = screen.queryByTestId('popover-content'); - expect(popoverContent.tagName).toBe('A'); + expect(popoverContent?.tagName).toBe('A'); expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`); // check paste button functionality @@ -2310,16 +2382,23 @@ describe('', () => { // Without the delay the success message renders too quickly const delayedResponse = axiosMock .onGet(exportTags(courseId)) + // Issue with types in upstream lib, should be fixed by this PR + // https://github.com/ctimmerm/axios-mock-adapter/pull/391/files + // @ts-ignore-next-line .withDelayInMs(500); delayedResponse(200, expectedResponse); - useLocation.mockReturnValue({ + jest.mocked(useLocation).mockReturnValue({ pathname: '/foo-bar', hash: '#export-tags', + state: undefined, + key: '', + search: '', }); + window.URL.createObjectURL = jest.fn().mockReturnValue('http://example.com/archivo'); window.URL.revokeObjectURL = jest.fn(); - render(); + renderComponent(); await screen.findByText('Please wait. Creating export file for course tags...'); const expectedRequest = axiosMock.history.get.filter(request => request.url === exportTags(courseId)); @@ -2333,15 +2412,21 @@ describe('', () => { // Without the delay the error renders too quickly const delayedResponse = axiosMock .onGet(exportTags(courseId)) + // Issue with types in upstream lib, should be fixed by this PR + // https://github.com/ctimmerm/axios-mock-adapter/pull/391/files + // @ts-ignore-next-line .withDelayInMs(500); delayedResponse(404); - useLocation.mockReturnValue({ + jest.mocked(useLocation).mockReturnValue({ pathname: '/foo-bar', hash: '#export-tags', + state: undefined, + key: '', + search: '', }); - render(); + renderComponent(); await screen.findByText('Please wait. Creating export file for course tags...'); await screen.findByText('An error has occurred creating the file'); }); @@ -2351,7 +2436,7 @@ describe('', () => { .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(403); - const { getByTestId } = render(); + const { getByTestId } = renderComponent(); await waitFor(() => { expect(getByTestId('redux-provider')).toBeInTheDocument(); diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.tsx similarity index 79% rename from src/course-outline/CourseOutline.jsx rename to src/course-outline/CourseOutline.tsx index 2a5e33b09..af1dd80de 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.tsx @@ -1,20 +1,15 @@ -// @ts-check -import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; +import { useState, useEffect, useCallback } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - Button, Container, Layout, Row, TransitionReplace, Toast, + StandardModal, } from '@openedx/paragon'; import { Helmet } from 'react-helmet'; -import { - Add as IconAdd, - CheckCircle as CheckCircleIcon, -} from '@openedx/paragon/icons'; +import { CheckCircle as CheckCircleIcon } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; import { arrayMove, @@ -22,18 +17,25 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { useLocation } from 'react-router-dom'; -import { CourseAuthoringOutlineSidebarSlot } from '../plugin-slots/CourseAuthoringOutlineSidebarSlot'; +import { CourseAuthoringOutlineSidebarSlot } from '@src/plugin-slots/CourseAuthoringOutlineSidebarSlot'; -import { LoadingSpinner } from '../generic/Loading'; -import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; -import { RequestStatus } from '../data/constants'; -import SubHeader from '../generic/sub-header/SubHeader'; -import ProcessingNotification from '../generic/processing-notification'; -import InternetConnectionAlert from '../generic/internet-connection-alert'; -import DeleteModal from '../generic/delete-modal/DeleteModal'; -import ConfigureModal from '../generic/configure-modal/ConfigureModal'; -import AlertMessage from '../generic/alert-message'; -import getPageHeadTitle from '../generic/utils'; +import { LoadingSpinner } from '@src/generic/Loading'; +import { getProcessingNotification } from '@src/generic/processing-notification/data/selectors'; +import { RequestStatus } from '@src/data/constants'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import ProcessingNotification from '@src/generic/processing-notification'; +import InternetConnectionAlert from '@src/generic/internet-connection-alert'; +import DeleteModal from '@src/generic/delete-modal/DeleteModal'; +import ConfigureModal from '@src/generic/configure-modal/ConfigureModal'; +import AlertMessage from '@src/generic/alert-message'; +import getPageHeadTitle from '@src/generic/utils'; +import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot'; +import { ContainerType } from '@src/generic/key-utils'; +import { ComponentPicker, SelectedComponent } from '@src/library-authoring'; +import { ContentType } from '@src/library-authoring/routes'; +import { NOTIFICATION_MESSAGES } from '@src/constants'; +import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { XBlock } from '@src/data/types'; import { getCurrentItem, getProctoredExamsFlag } from './data/selectors'; import { COURSE_BLOCK_NAMES } from './constants'; import StatusBar from './status-bar/StatusBar'; @@ -54,13 +56,18 @@ import { import { useCourseOutline } from './hooks'; import messages from './messages'; import { getTagsExportFile } from './data/api'; -import CourseOutlineHeaderActionsSlot from '../plugin-slots/CourseOutlineHeaderActionsSlot'; +import OutlineAddChildButtons from './OutlineAddChildButtons'; -const CourseOutline = ({ courseId }) => { +interface CourseOutlineProps { + courseId: string, +} + +const CourseOutline = ({ courseId }: CourseOutlineProps) => { const intl = useIntl(); const location = useLocation(); const { + courseUsageKey, courseName, savingStatus, statusBarData, @@ -89,6 +96,9 @@ const CourseOutline = ({ courseId }) => { headerNavigationsActions, openEnableHighlightsModal, closeEnableHighlightsModal, + isAddLibrarySectionModalOpen, + openAddLibrarySectionModal, + closeAddLibrarySectionModal, handleEnableHighlightsSubmit, handleInternetConnectionFailed, handleOpenHighlightsModal, @@ -104,6 +114,8 @@ const CourseOutline = ({ courseId }) => { handleNewSubsectionSubmit, handleNewUnitSubmit, handleAddUnitFromLibrary, + handleAddSubsectionFromLibrary, + handleAddSectionFromLibrary, getUnitUrl, handleVideoSharingOptionChange, handlePasteClipboardClick, @@ -119,10 +131,11 @@ const CourseOutline = ({ courseId }) => { handleSubsectionDragAndDrop, handleUnitDragAndDrop, errors, + resetScrollState, } = useCourseOutline({ courseId }); // Use `setToastMessage` to show the toast. - const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null)); + const [toastMessage, setToastMessage] = useState(null); useEffect(() => { // Wait for the course data to load before exporting tags. @@ -139,7 +152,7 @@ const CourseOutline = ({ courseId }) => { } }, [location, courseId, courseName]); - const [sections, setSections] = useState(sectionsList); + const [sections, setSections] = useState(sectionsList); const restoreSectionList = () => { setSections(() => [...sectionsList]); @@ -157,10 +170,8 @@ const CourseOutline = ({ courseId }) => { /** * Move section to new index - * @param {any} currentIndex - * @param {any} newIndex */ - const updateSectionOrderByIndex = (currentIndex, newIndex) => { + const updateSectionOrderByIndex = (currentIndex: number, newIndex: number) => { if (currentIndex === newIndex) { return; } @@ -173,11 +184,8 @@ const CourseOutline = ({ courseId }) => { /** * Uses details from move information and moves subsection - * @param {any} section - * @param {any} moveDetails - * @returns {void} */ - const updateSubsectionOrderByIndex = (section, moveDetails) => { + const updateSubsectionOrderByIndex = (section: XBlock, moveDetails) => { const { fn, args, sectionId } = moveDetails; if (!args) { return; @@ -196,11 +204,8 @@ const CourseOutline = ({ courseId }) => { /** * Uses details from move information and moves unit - * @param {any} section - * @param {any} moveDetails - * @returns {void} */ - const updateUnitOrderByIndex = (section, moveDetails) => { + const updateUnitOrderByIndex = (section: XBlock, moveDetails) => { const { fn, args, sectionId, subsectionId, } = moveDetails; @@ -220,6 +225,16 @@ const CourseOutline = ({ courseId }) => { } }; + const handleSelectLibrarySection = useCallback((selectedSection: SelectedComponent) => { + handleAddSectionFromLibrary.mutateAsync({ + type: COMPONENT_TYPES.libraryV2, + category: ContainerType.Chapter, + parentLocator: courseUsageKey, + libraryContentKey: selectedSection.usageKey, + }); + closeAddLibrarySectionModal(); + }, [closeAddLibrarySectionModal, handleAddSectionFromLibrary.mutateAsync, courseId, courseUsageKey]); + useEffect(() => { setSections(sectionsList); }, [sectionsList]); @@ -357,6 +372,8 @@ const CourseOutline = ({ courseId }) => { isSectionsExpanded={isSectionsExpanded} onNewSubsectionSubmit={handleNewSubsectionSubmit} onOrderChange={updateSectionOrderByIndex} + onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync} + resetScrollState={resetScrollState} > { onDuplicateSubmit={handleDuplicateSubsectionSubmit} onOpenConfigureModal={openConfigureModal} onNewUnitSubmit={handleNewUnitSubmit} - onAddUnitFromLibrary={handleAddUnitFromLibrary} + onAddUnitFromLibrary={handleAddUnitFromLibrary.mutateAsync} onOrderChange={updateSubsectionOrderByIndex} onPasteClick={handlePasteClipboardClick} + resetScrollState={resetScrollState} > { {courseActions.childAddable && ( - + )} ) : ( - + + {courseActions.childAddable && ( + + )} + )} )} @@ -493,11 +513,33 @@ const CourseOutline = ({ courseId }) => { close={closeDeleteModal} onDeleteSubmit={handleDeleteItemSubmit} /> + + +
{ ); }; -CourseOutline.propTypes = { - courseId: PropTypes.string.isRequired, -}; - export default CourseOutline; diff --git a/src/course-outline/OutlineAddChildButtons.test.tsx b/src/course-outline/OutlineAddChildButtons.test.tsx new file mode 100644 index 000000000..2d2ce120f --- /dev/null +++ b/src/course-outline/OutlineAddChildButtons.test.tsx @@ -0,0 +1,42 @@ +import userEvent from '@testing-library/user-event'; +import { ContainerType } from '@src/generic/key-utils'; +import { + initializeMocks, render, screen, waitFor, +} from '@src/testUtils'; +import OutlineAddChildButtons from './OutlineAddChildButtons'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn().mockReturnValue({ librariesV2Enabled: true }), +})); + +[ + { containerType: ContainerType.Section }, + { containerType: ContainerType.Subsection }, + { containerType: ContainerType.Unit }, +].forEach(({ containerType }) => { + describe(` for ${containerType}`, () => { + beforeEach(() => { + initializeMocks(); + }); + + it('renders and behaves correctly', async () => { + const newClickHandler = jest.fn(); + const useFromLibClickHandler = jest.fn(); + render(); + + const newBtn = await screen.findByRole('button', { name: `New ${containerType}` }); + expect(newBtn).toBeInTheDocument(); + const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` }); + expect(useBtn).toBeInTheDocument(); + userEvent.click(newBtn); + waitFor(() => expect(newClickHandler).toHaveBeenCalled()); + userEvent.click(useBtn); + waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled()); + }); + }); +}); diff --git a/src/course-outline/OutlineAddChildButtons.tsx b/src/course-outline/OutlineAddChildButtons.tsx new file mode 100644 index 000000000..7e31a5f57 --- /dev/null +++ b/src/course-outline/OutlineAddChildButtons.tsx @@ -0,0 +1,89 @@ +import { Button, Stack } from '@openedx/paragon'; +import { Add as IconAdd, Newsstand } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useSelector } from 'react-redux'; +import { getStudioHomeData } from '@src/studio-home/data/selectors'; +import { ContainerType } from '@src/generic/key-utils'; +import messages from './messages'; + +interface NewChildButtonsProps { + handleNewButtonClick: () => void; + handleUseFromLibraryClick: () => void; + childType: ContainerType; + btnVariant?: string; + btnClasses?: string; + btnSize?: 'sm' | 'md' | 'lg' | 'inline'; +} + +const OutlineAddChildButtons = ({ + handleNewButtonClick, + handleUseFromLibraryClick, + childType, + btnVariant = 'outline-primary', + btnClasses = 'mt-4 border-gray-500 rounded-0', + btnSize, +}: NewChildButtonsProps) => { + // WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below, + // as it has a useEffect that fetches course waffle flags whenever + // location.search is updated. Course search updates location.search when + // user types, which will then trigger the useEffect and reload the page. + // See https://github.com/openedx/frontend-app-authoring/pull/1938. + const { librariesV2Enabled } = useSelector(getStudioHomeData); + const intl = useIntl(); + let messageMap = { + newButton: messages.newUnitButton, + importButton: messages.useUnitFromLibraryButton, + }; + + switch (childType) { + case ContainerType.Section: + messageMap = { + newButton: messages.newSectionButton, + importButton: messages.useSectionFromLibraryButton, + }; + break; + case ContainerType.Subsection: + messageMap = { + newButton: messages.newSubsectionButton, + importButton: messages.useSubsectionFromLibraryButton, + }; + break; + case ContainerType.Unit: + messageMap = { + newButton: messages.newUnitButton, + importButton: messages.useUnitFromLibraryButton, + }; + break; + default: + break; + } + + return ( + + + {librariesV2Enabled && ( + + )} + + ); +}; + +export default OutlineAddChildButtons; diff --git a/src/course-outline/card-header/CardHeader.test.jsx b/src/course-outline/card-header/CardHeader.test.tsx similarity index 62% rename from src/course-outline/card-header/CardHeader.test.jsx rename to src/course-outline/card-header/CardHeader.test.tsx index 97a512004..d7b6c860b 100644 --- a/src/course-outline/card-header/CardHeader.test.jsx +++ b/src/course-outline/card-header/CardHeader.test.tsx @@ -1,12 +1,9 @@ -import { MemoryRouter } from 'react-router-dom'; -import { - act, render, fireEvent, waitFor, screen, -} from '@testing-library/react'; import { setConfig, getConfig } from '@edx/frontend-platform'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; -import { ITEM_BADGE_STATUS } from '../constants'; +import { ITEM_BADGE_STATUS } from '@src/course-outline/constants'; +import { + act, fireEvent, initializeMocks, render, screen, waitFor, +} from '@src/testUtils'; import CardHeader from './CardHeader'; import TitleButton from './TitleButton'; import messages from './messages'; @@ -56,9 +53,7 @@ const cardHeaderProps = { }, }; -const queryClient = new QueryClient(); - -const renderComponent = (props, entry = '/') => { +const renderComponent = (props?: object, entry = '/') => { const titleComponent = ( { ); return render( - - - - - - - , + , + { + path: '/', + routerProps: { + initialEntries: [entry], + }, + }, ); }; describe('', () => { - it('render CardHeader component correctly', async () => { - const { findByText, findByTestId, queryByTestId } = renderComponent(); + beforeEach(() => { + initializeMocks(); + }); - expect(await findByText(cardHeaderProps.title)).toBeInTheDocument(); - expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument(); - expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument(); + it('render CardHeader component correctly', async () => { + renderComponent(); + + expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument(); + expect(await screen.findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument(); + expect(await screen.findByTestId('subsection-card-header__menu')).toBeInTheDocument(); await waitFor(() => { - expect(queryByTestId('edit field')).not.toBeInTheDocument(); + expect(screen.queryByTestId('edit field')).not.toBeInTheDocument(); }); }); it('render status badge as live', async () => { - const { findByText } = renderComponent(); - expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument(); + renderComponent(); + expect(await screen.findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument(); }); it('render status badge as published_not_live', async () => { - const { findByText } = renderComponent({ + renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, }); - expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument(); }); it('render status badge as staff_only', async () => { - const { findByText } = renderComponent({ + renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.staffOnly, }); - expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument(); }); it('render status badge as draft', async () => { - const { findByText } = renderComponent({ + renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.draft, }); - expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); }); it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => { - const { findByText, findByTestId } = renderComponent({ + renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, }); - const menuButton = await findByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); - expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true'); + expect(await screen.findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true'); }); it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => { - const { findByText, findByTestId } = renderComponent({ + renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, hasChanges: true, }); - const menuButton = await findByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); - expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled'); + expect(await screen.findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled'); }); it('calls handleExpanded when button is clicked', async () => { - const { findByTestId } = renderComponent(); + renderComponent(); - const expandButton = await findByTestId('subsection-card-header__expanded-btn'); + const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn'); fireEvent.click(expandButton); expect(onExpandMock).toHaveBeenCalled(); }); it('calls onClickMenuButton when menu is clicked', async () => { - const { findByTestId } = renderComponent(); + renderComponent(); - const menuButton = await findByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); await act(async () => fireEvent.click(menuButton)); expect(onClickMenuButtonMock).toHaveBeenCalled(); }); it('calls onClickPublish when item is clicked', async () => { - const { findByText, findByTestId } = renderComponent({ + renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.draft, }); - const menuButton = await findByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); - const publishMenuItem = await findByText(messages.menuPublish.defaultMessage); + const publishMenuItem = await screen.findByText(messages.menuPublish.defaultMessage); await act(async () => fireEvent.click(publishMenuItem)); expect(onClickPublishMock).toHaveBeenCalled(); }); @@ -210,119 +209,114 @@ describe('', () => { }); it('calls onClickEdit when the button is clicked', async () => { - const { findByTestId } = renderComponent(); + renderComponent(); - const editButton = await findByTestId('subsection-edit-button'); + const editButton = await screen.findByTestId('subsection-edit-button'); await act(async () => fireEvent.click(editButton)); expect(onClickEditMock).toHaveBeenCalled(); }); it('check is field visible when isFormOpen is true', async () => { - const { findByTestId, queryByTestId } = renderComponent({ + renderComponent({ ...cardHeaderProps, isFormOpen: true, }); - expect(await findByTestId('subsection-edit-field')).toBeInTheDocument(); + expect(await screen.findByTestId('subsection-edit-field')).toBeInTheDocument(); waitFor(() => { - expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument(); - expect(queryByTestId('edit-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('edit-button')).not.toBeInTheDocument(); }); }); it('check is field disabled when isDisabledEditField is true', async () => { - const { findByTestId } = renderComponent({ + renderComponent({ ...cardHeaderProps, isFormOpen: true, isDisabledEditField: true, }); - expect(await findByTestId('subsection-edit-field')).toBeDisabled(); + expect(await screen.findByTestId('subsection-edit-field')).toBeDisabled(); }); it('check editing is enabled when isDisabledEditField is false', async () => { - const { getByTestId } = renderComponent({ - ...cardHeaderProps, - }); + renderComponent({ ...cardHeaderProps }); - expect(getByTestId('subsection-edit-button')).toBeEnabled(); + expect(screen.getByTestId('subsection-edit-button')).toBeEnabled(); // Ensure menu items related to editing are enabled - const menuButton = getByTestId('subsection-card-header__menu-button'); + const menuButton = screen.getByTestId('subsection-card-header__menu-button'); await act(async () => fireEvent.click(menuButton)); - expect(await getByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled'); - expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled'); + expect(await screen.findByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled'); + expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled'); }); it('check editing is disabled when isDisabledEditField is true', async () => { - const { getByTestId } = renderComponent({ - ...cardHeaderProps, - isDisabledEditField: true, - }); + renderComponent({ ...cardHeaderProps, isDisabledEditField: true }); - expect(await getByTestId('subsection-edit-button')).toBeDisabled(); + expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled(); // Ensure menu items related to editing are disabled - const menuButton = getByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); await act(async () => fireEvent.click(menuButton)); - expect(await getByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true'); - expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true'); + expect(await screen.findByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true'); + expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true'); }); it('calls onClickDelete when item is clicked', async () => { - const { findByText, findByTestId } = renderComponent(); + renderComponent(); - const menuButton = await findByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); await act(async () => fireEvent.click(menuButton)); - const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage); + const deleteMenuItem = await screen.findByText(messages.menuDelete.defaultMessage); await act(async () => fireEvent.click(deleteMenuItem)); expect(onClickDeleteMock).toHaveBeenCalledTimes(1); }); it('calls onClickDuplicate when item is clicked', async () => { - const { findByText, findByTestId } = renderComponent(); + renderComponent(); - const menuButton = await findByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); - const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage); + const duplicateMenuItem = await screen.findByText(messages.menuDuplicate.defaultMessage); fireEvent.click(duplicateMenuItem); await act(async () => fireEvent.click(duplicateMenuItem)); expect(onClickDuplicateMock).toHaveBeenCalled(); }); it('check if proctoringExamConfigurationLink is visible', async () => { - const { findByText, findByTestId } = renderComponent({ + renderComponent({ ...cardHeaderProps, proctoringExamConfigurationLink: 'proctoringlink', isSequential: true, }); - const menuButton = await findByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); await act(async () => fireEvent.click(menuButton)); - const element = await findByText(messages.menuProctoringLinkText.defaultMessage); + const element = await screen.findByText(messages.menuProctoringLinkText.defaultMessage); expect(element).toBeInTheDocument(); expect(element.getAttribute('href')).toBe(`${getConfig().STUDIO_BASE_URL}/proctoringlink`); }); it('check if proctoringExamConfigurationLink is absolute', async () => { - const { findByText, findByTestId } = renderComponent({ + renderComponent({ ...cardHeaderProps, proctoringExamConfigurationLink: 'http://localhost:9000/proctoringlink', isSequential: true, }); - const menuButton = await findByTestId('subsection-card-header__menu-button'); + const menuButton = await screen.findByTestId('subsection-card-header__menu-button'); await act(async () => fireEvent.click(menuButton)); - const element = await findByText(messages.menuProctoringLinkText.defaultMessage); + const element = await screen.findByText(messages.menuProctoringLinkText.defaultMessage); expect(element).toBeInTheDocument(); expect(element.getAttribute('href')).toBe('http://localhost:9000/proctoringlink'); }); it('check if discussion enabled badge is visible', async () => { - const { queryByText } = renderComponent({ + renderComponent({ ...cardHeaderProps, isVertical: true, discussionEnabled: true, @@ -336,7 +330,7 @@ describe('', () => { }, }); - expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument(); + expect(screen.queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument(); }); it('should render tag count if is not zero and the waffle flag is enabled', async () => { diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.tsx similarity index 78% rename from src/course-outline/card-header/CardHeader.jsx rename to src/course-outline/card-header/CardHeader.tsx index 46ac9015d..11929f62a 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -1,6 +1,6 @@ -// @ts-check -import React, { useEffect, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; +import { + ReactNode, useEffect, useRef, useState, +} from 'react'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useSearchParams } from 'react-router-dom'; @@ -18,15 +18,57 @@ import { Sync as SyncIcon, } from '@openedx/paragon/icons'; -import { useContentTagsCount } from '../../generic/data/apiHooks'; -import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; -import TagCount from '../../generic/tag-count'; -import { useEscapeClick } from '../../hooks'; +import { useContentTagsCount } from '@src/generic/data/apiHooks'; +import { ContentTagsDrawerSheet } from '@src/content-tags-drawer'; +import TagCount from '@src/generic/tag-count'; +import { useEscapeClick } from '@src/hooks'; +import { XBlockActions } from '@src/data/types'; import { ITEM_BADGE_STATUS } from '../constants'; import { scrollToElement } from '../utils'; import CardStatus from './CardStatus'; import messages from './messages'; +interface CardHeaderProps { + title: string; + status: string; + cardId?: string, + hasChanges: boolean; + onClickPublish: () => void; + onClickConfigure: () => void; + onClickMenuButton: () => void; + onClickEdit: () => void; + isFormOpen: boolean; + onEditSubmit: (titleValue: string) => void; + closeForm: () => void; + isDisabledEditField: boolean; + onClickDelete: () => void; + onClickDuplicate: () => void; + onClickMoveUp: () => void; + onClickMoveDown: () => void; + onClickCopy?: () => void; + titleComponent: ReactNode; + namePrefix: string; + proctoringExamConfigurationLink?: string, + actions: XBlockActions, + enableCopyPasteUnits?: boolean; + isVertical?: boolean; + isSequential?: boolean; + discussionEnabled?: boolean; + discussionsSettings?: { + providerType: string; + enableGradedUnits: boolean; + }; + parentInfo?: { + graded: boolean; + isTimeLimited?: boolean; + }, + // An optional component that is rendered before the dropdown. This is used by the Subsection + // and Unit card components to render their plugin slots. + extraActionsComponent?: ReactNode, + onClickSync?: () => void; + readyToSync?: boolean; +} + const CardHeader = ({ title, status, @@ -58,7 +100,7 @@ const CardHeader = ({ extraActionsComponent, onClickSync, readyToSync, -}) => { +}: CardHeaderProps) => { const intl = useIntl(); const [searchParams] = useSearchParams(); const [titleValue, setTitleValue] = useState(title); @@ -93,7 +135,7 @@ const CardHeader = ({ && discussionsSettings?.providerType === 'openedx' && ( discussionsSettings?.enableGradedUnits - || (!discussionsSettings?.enableGradedUnits && !parentInfo.graded) + || (!discussionsSettings?.enableGradedUnits && !parentInfo?.graded) ) ); @@ -155,7 +197,7 @@ const CardHeader = ({ )}
{(isVertical || isSequential) && ( - + )} { getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && ( @@ -260,67 +302,4 @@ const CardHeader = ({ ); }; -CardHeader.defaultProps = { - enableCopyPasteUnits: false, - isVertical: false, - isSequential: false, - onClickCopy: null, - proctoringExamConfigurationLink: null, - discussionEnabled: false, - discussionsSettings: {}, - parentInfo: {}, - cardId: '', - extraActionsComponent: null, - readyToSync: false, - onClickSync: null, -}; - -CardHeader.propTypes = { - title: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - cardId: PropTypes.string, - hasChanges: PropTypes.bool.isRequired, - onClickPublish: PropTypes.func.isRequired, - onClickConfigure: PropTypes.func.isRequired, - onClickMenuButton: PropTypes.func.isRequired, - onClickEdit: PropTypes.func.isRequired, - isFormOpen: PropTypes.bool.isRequired, - onEditSubmit: PropTypes.func.isRequired, - closeForm: PropTypes.func.isRequired, - isDisabledEditField: PropTypes.bool.isRequired, - onClickDelete: PropTypes.func.isRequired, - onClickDuplicate: PropTypes.func.isRequired, - onClickMoveUp: PropTypes.func.isRequired, - onClickMoveDown: PropTypes.func.isRequired, - onClickCopy: PropTypes.func, - titleComponent: PropTypes.node.isRequired, - namePrefix: PropTypes.string.isRequired, - proctoringExamConfigurationLink: PropTypes.string, - actions: PropTypes.shape({ - deletable: PropTypes.bool.isRequired, - draggable: PropTypes.bool.isRequired, - childAddable: PropTypes.bool.isRequired, - duplicable: PropTypes.bool.isRequired, - allowMoveUp: PropTypes.bool, - allowMoveDown: PropTypes.bool, - }).isRequired, - enableCopyPasteUnits: PropTypes.bool, - isVertical: PropTypes.bool, - isSequential: PropTypes.bool, - discussionEnabled: PropTypes.bool, - discussionsSettings: PropTypes.shape({ - providerType: PropTypes.string, - enableGradedUnits: PropTypes.bool, - }), - parentInfo: PropTypes.shape({ - isTimeLimited: PropTypes.bool, - graded: PropTypes.bool, - }), - // An optional component that is rendered before the dropdown. This is used by the Subsection - // and Unit card components to render their plugin slots. - extraActionsComponent: PropTypes.node, - onClickSync: PropTypes.func, - readyToSync: PropTypes.bool, -}; - export default CardHeader; diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.ts similarity index 67% rename from src/course-outline/data/api.js rename to src/course-outline/data/api.ts index 69bf8f018..d0e6dc17a 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.ts @@ -1,15 +1,22 @@ -// @ts-check import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { XBlock } from '@src/data/types'; +import { CourseOutline } from './types'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getCourseOutlineIndexApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`; +export const getCourseOutlineIndexApiUrl = ( + courseId: string, +) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`; export const getCourseBestPracticesApiUrl = ({ courseId, excludeGraded, all, +}: { + courseId: string, + excludeGraded: boolean, + all: boolean, }) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`; export const getCourseLaunchApiUrl = ({ @@ -17,42 +24,31 @@ export const getCourseLaunchApiUrl = ({ gradedOnly, validateOras, all, +}:{ + courseId: string, + gradedOnly: boolean, + validateOras: boolean, + all: boolean, }) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`; -export const getCourseBlockApiUrl = (courseId) => { +export const getCourseBlockApiUrl = (courseId: string) => { const formattedCourseId = courseId.split('course-v1:')[1]; return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`; }; -export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`; +export const getCourseReindexApiUrl = (reindexLink: string) => `${getApiBaseUrl()}${reindexLink}`; export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; -export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`; -export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`; -export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`; - -/** - * @typedef {Object} courseOutline - * @property {string} courseReleaseDate - * @property {Object} courseStructure - * @property {Object} deprecatedBlocksInfo - * @property {string} discussionsIncontextLearnmoreUrl - * @property {Object} initialState - * @property {Object} initialUserClipboard - * @property {string} languageCode - * @property {string} lmsLink - * @property {string} mfeProctoredExamSettingsUrl - * @property {string} notificationDismissUrl - * @property {string[]} proctoringErrors - * @property {string} reindexLink - * @property {null} rerunNotificationId - */ +export const getCourseItemApiUrl = (itemId: string) => `${getXBlockBaseApiUrl()}${itemId}`; +export const getXBlockApiUrl = (blockId: string) => `${getXBlockBaseApiUrl()}outline/${blockId}`; +export const exportTags = (courseId: string) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`; +export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl()}/api/discussions/v0/course/${courseId}/sync_discussion_topics`; /** * Get course outline index. * @param {string} courseId * @returns {Promise} */ -export async function getCourseOutlineIndex(courseId) { +export async function getCourseOutlineIndex(courseId: string): Promise { const { data } = await getAuthenticatedHttpClient() .get(getCourseOutlineIndexApiUrl(courseId)); @@ -64,9 +60,9 @@ export async function getCourseOutlineIndex(courseId) { * @param courseId * @returns {Promise} */ -export async function createDiscussionsTopics(courseId) { +export async function createDiscussionsTopics(courseId: string): Promise | object> { const { data } = await getAuthenticatedHttpClient() - .post(`${getApiBaseUrl()}/api/discussions/v0/course/${courseId}/sync_discussion_topics`); + .post(createDiscussionsTopicsUrl(courseId)); return camelCaseObject(data); } @@ -79,35 +75,46 @@ export async function getCourseBestPractices({ courseId, excludeGraded, all, -}) { +}: { + courseId: string; + excludeGraded: boolean; + all: boolean; +}): Promise<{ + isSelfPaced: boolean; + sections: any; + subsection: any; + units: any; + videos: any; + }> { const { data } = await getAuthenticatedHttpClient() .get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all })); return camelCaseObject(data); } -/** @typedef {object} courseLaunchData - * @property {boolean} isSelfPaced - * @property {object} dates - * @property {object} assignments - * @property {object} grades - * @property {number} grades.sum_of_weights - * @property {object} certificates - * @property {object} updates - * @property {object} proctoring - */ +interface CourseLaunchData { + isSelfPaced: boolean; + dates: object; + assignments: object; + grades: { + sum_of_weights: number; + }; + certificates: object; + updates: object; + proctoring: object; +} /** * Get course launch. * @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options - * @returns {Promise} + * @returns {Promise} */ export async function getCourseLaunch({ courseId, gradedOnly, validateOras, all, -}) { +}: { courseId: string; gradedOnly: boolean; validateOras: boolean; all: boolean; }): Promise { const { data } = await getAuthenticatedHttpClient() .get(getCourseLaunchApiUrl({ courseId, gradedOnly, validateOras, all, @@ -121,7 +128,7 @@ export async function getCourseLaunch({ * @param {string} courseId * @returns {Promise} */ -export async function enableCourseHighlightsEmails(courseId) { +export async function enableCourseHighlightsEmails(courseId: string): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseBlockApiUrl(courseId), { publish: 'republish', @@ -138,7 +145,7 @@ export async function enableCourseHighlightsEmails(courseId) { * @param {string} reindexLink * @returns {Promise} */ -export async function restartIndexingOnCourse(reindexLink) { +export async function restartIndexingOnCourse(reindexLink: string): Promise { const { data } = await getAuthenticatedHttpClient() .get(getCourseReindexApiUrl(reindexLink)); @@ -146,49 +153,11 @@ export async function restartIndexingOnCourse(reindexLink) { } /** - * @typedef {Object} section - * @property {string} id - * @property {string} displayName - * @property {string} category - * @property {boolean} hasChildren - * @property {string} editedOn - * @property {boolean} published - * @property {string} publishedOn - * @property {string} studioUrl - * @property {boolean} releasedToStudents - * @property {string} releaseDate - * @property {string} visibilityState - * @property {boolean} hasExplicitStaffLock - * @property {string} start - * @property {boolean} graded - * @property {string} dueDate - * @property {null} due - * @property {null} relativeWeeksDue - * @property {null} format - * @property {string[]} courseGraders - * @property {boolean} hasChanges - * @property {object} actions - * @property {null} explanatoryMessage - * @property {object[]} userPartitions - * @property {string} showCorrectness - * @property {string[]} highlights - * @property {boolean} highlightsEnabled - * @property {boolean} highlightsPreviewOnly - * @property {string} highlightsDocUrl - * @property {object} childInfo - * @property {boolean} ancestorHasStaffLock - * @property {boolean} staffOnlyMessage - * @property {boolean} hasPartitionGroupComponents - * @property {object} userPartitionInfo - * @property {boolean} enableCopyPasteUnits - */ - -/** - * Get course section + * Get course Xblock * @param {string} itemId - * @returns {Promise
} + * @returns {Promise} */ -export async function getCourseItem(itemId) { +export async function getCourseItem(itemId: string): Promise { const { data } = await getAuthenticatedHttpClient() .get(getXBlockApiUrl(itemId)); return camelCaseObject(data); @@ -200,7 +169,10 @@ export async function getCourseItem(itemId) { * @param {Array} highlights * @returns {Promise} */ -export async function updateCourseSectionHighlights(sectionId, highlights) { +export async function updateCourseSectionHighlights( + sectionId: string, + highlights: Array, +): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(sectionId), { publish: 'republish', @@ -217,7 +189,7 @@ export async function updateCourseSectionHighlights(sectionId, highlights) { * @param {string} sectionId * @returns {Promise} */ -export async function publishCourseSection(sectionId) { +export async function publishCourseSection(sectionId: string): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(sectionId), { publish: 'make_public', @@ -233,7 +205,11 @@ export async function publishCourseSection(sectionId) { * @param {string} startDatetime * @returns {Promise} */ -export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) { +export async function configureCourseSection( + sectionId: string, + isVisibleToStaffOnly: boolean, + startDatetime: string, +): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(sectionId), { publish: 'republish', @@ -269,24 +245,24 @@ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, st * @returns {Promise} */ export async function configureCourseSubsection( - itemId, - isVisibleToStaffOnly, - releaseDate, - graderType, - dueDate, - isTimeLimited, - isProctoredExam, - isOnboardingExam, - isPracticeExam, - examReviewRules, - defaultTimeLimitMin, - hideAfterDue, - showCorrectness, - isPrereq, - prereqUsageKey, - prereqMinScore, - prereqMinCompletion, -) { + itemId: string, + isVisibleToStaffOnly: string, + releaseDate: string, + graderType: string, + dueDate: string, + isTimeLimited: boolean, + isProctoredExam: boolean, + isOnboardingExam: boolean, + isPracticeExam: boolean, + examReviewRules: string, + defaultTimeLimitMin: number, + hideAfterDue: string, + showCorrectness: string, + isPrereq: boolean, + prereqUsageKey: string, + prereqMinScore: number, + prereqMinCompletion: number, +): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(itemId), { publish: 'republish', @@ -318,9 +294,15 @@ export async function configureCourseSubsection( * @param {string} unitId * @param {boolean} isVisibleToStaffOnly * @param {object} groupAccess + * @param {boolean} discussionEnabled * @returns {Promise} */ -export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess, discussionEnabled) { +export async function configureCourseUnit( + unitId: string, + isVisibleToStaffOnly: boolean, + groupAccess: object, + discussionEnabled: boolean, +): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(unitId), { publish: 'republish', @@ -341,7 +323,10 @@ export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAcc * @param {string} displayName * @returns {Promise} */ -export async function editItemDisplayName(itemId, displayName) { +export async function editItemDisplayName( + itemId: string, + displayName: string, +): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(itemId), { metadata: { @@ -357,7 +342,7 @@ export async function editItemDisplayName(itemId, displayName) { * @param {string} itemId * @returns {Promise} */ -export async function deleteCourseItem(itemId) { +export async function deleteCourseItem(itemId: string): Promise { const { data } = await getAuthenticatedHttpClient() .delete(getCourseItemApiUrl(itemId)); @@ -368,9 +353,9 @@ export async function deleteCourseItem(itemId) { * Duplicate course section * @param {string} itemId * @param {string} parentId - * @returns {Promise} + * @returns {Promise} */ -export async function duplicateCourseItem(itemId, parentId) { +export async function duplicateCourseItem(itemId: string, parentId: string): Promise { const { data } = await getAuthenticatedHttpClient() .post(getXBlockBaseApiUrl(), { duplicate_source_locator: itemId, @@ -387,7 +372,7 @@ export async function duplicateCourseItem(itemId, parentId) { * @param {string} displayName * @returns {Promise} */ -export async function addNewCourseItem(parentLocator, category, displayName) { +export async function addNewCourseItem(parentLocator: string, category: string, displayName: string): Promise { const { data } = await getAuthenticatedHttpClient() .post(getXBlockBaseApiUrl(), { parent_locator: parentLocator, @@ -404,7 +389,7 @@ export async function addNewCourseItem(parentLocator, category, displayName) { * @param {Array} children list of sections id's * @returns {Promise} */ -export async function setSectionOrderList(courseId, children) { +export async function setSectionOrderList(courseId: string, children: Array): Promise { const { data } = await getAuthenticatedHttpClient() .put(getCourseBlockApiUrl(courseId), { children, @@ -419,7 +404,7 @@ export async function setSectionOrderList(courseId, children) { * @param {Array} children list of sections id's * @returns {Promise} */ -export async function setCourseItemOrderList(itemId, children) { +export async function setCourseItemOrderList(itemId: string, children: Array): Promise { const { data } = await getAuthenticatedHttpClient() .put(getCourseItemApiUrl(itemId), { children, @@ -434,7 +419,10 @@ export async function setCourseItemOrderList(itemId, children) { * @param {string} videoSharingOption * @returns {Promise} */ -export async function setVideoSharingOption(courseId, videoSharingOption) { +export async function setVideoSharingOption( + courseId: string, + videoSharingOption: string, +): Promise { const { data } = await getAuthenticatedHttpClient() .post(getCourseBlockApiUrl(courseId), { metadata: { @@ -450,7 +438,7 @@ export async function setVideoSharingOption(courseId, videoSharingOption) { * @param {string} parentLocator * @returns {Promise} */ -export async function pasteBlock(parentLocator) { +export async function pasteBlock(parentLocator: string): Promise { const { data } = await getAuthenticatedHttpClient() .post(getXBlockBaseApiUrl(), { parent_locator: parentLocator, @@ -465,7 +453,7 @@ export async function pasteBlock(parentLocator) { * @param {string} url * @returns void */ -export async function dismissNotification(url) { +export async function dismissNotification(url: string) { await getAuthenticatedHttpClient() .delete(url); } @@ -473,9 +461,10 @@ export async function dismissNotification(url) { /** * Downloads the file of the exported tags * @param {string} courseId The ID of the content + * @param {string} courseName * @returns void */ -export async function getTagsExportFile(courseId, courseName) { +export async function getTagsExportFile(courseId: string, courseName: string) { // Gets exported tags and builds the blob to download CSV file. // This can be done with this code: // `window.location.href = exportTags(contentId);` diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts new file mode 100644 index 000000000..f4d777d6d --- /dev/null +++ b/src/course-outline/data/apiHooks.ts @@ -0,0 +1,24 @@ +import { useMutation } from '@tanstack/react-query'; +import { createCourseXblock } from '@src/course-unit/data/api'; + +export const courseOutlineQueryKeys = { + all: ['courseOutline'], + /** + * Base key for data specific to a course in outline + */ + contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId], +}; + +/** + * Hook to create an XBLOCK in a course . + * The `locator` is the ID of the parent block where this new XBLOCK should be created. + * Can also be used to import block from library by passing `libraryContentKey` in request body + */ +export const useCreateCourseBlock = ( + callback?: ((locator?: string, parentLocator?: string) => void), +) => useMutation({ + mutationFn: createCourseXblock, + onSettled: async (data) => { + callback?.(data.locator, data.parent_locator); + }, +}); diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.ts similarity index 100% rename from src/course-outline/data/selectors.js rename to src/course-outline/data/selectors.ts diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.ts similarity index 57% rename from src/course-outline/data/slice.js rename to src/course-outline/data/slice.ts index 95d3c4235..ede7ad5d7 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.ts @@ -1,142 +1,157 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; +import { RequestStatus } from '@src/data/constants'; import { VIDEO_SHARING_OPTIONS } from '../constants'; -import { RequestStatus } from '../../data/constants'; +import { CourseOutlineState } from './types'; + +const initialState = { + loadingStatus: { + outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS, + reIndexLoadingStatus: RequestStatus.IN_PROGRESS, + fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS, + courseLaunchQueryStatus: RequestStatus.IN_PROGRESS, + }, + errors: { + outlineIndexApi: null, + reindexApi: null, + sectionLoadingApi: null, + courseLaunchApi: null, + }, + outlineIndexData: {}, + savingStatus: '', + statusBarData: { + courseReleaseDate: '', + highlightsEnabledForMessaging: false, + isSelfPaced: false, + checklist: { + totalCourseLaunchChecks: 0, + completedCourseLaunchChecks: 0, + totalCourseBestPracticesChecks: 0, + completedCourseBestPracticesChecks: 0, + }, + videoSharingEnabled: false, + videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo, + }, + sectionsList: [], + isCustomRelativeDatesActive: false, + currentSection: {}, + currentSubsection: {}, + currentItem: {}, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + allowMoveUp: false, + allowMoveDown: false, + }, + enableProctoredExams: false, + pasteFileNotices: {}, + createdOn: null, +} satisfies CourseOutlineState as unknown as CourseOutlineState; const slice = createSlice({ name: 'courseOutline', - initialState: { - loadingStatus: { - outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS, - reIndexLoadingStatus: RequestStatus.IN_PROGRESS, - fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS, - courseLaunchQueryStatus: RequestStatus.IN_PROGRESS, - }, - errors: { - outlineIndexApi: null, - reindexApi: null, - sectionLoadingApi: null, - courseLaunchApi: null, - }, - outlineIndexData: {}, - savingStatus: '', - statusBarData: { - courseReleaseDate: '', - highlightsEnabledForMessaging: false, - isSelfPaced: false, - checklist: { - totalCourseLaunchChecks: 0, - completedCourseLaunchChecks: 0, - totalCourseBestPracticesChecks: 0, - completedCourseBestPracticesChecks: 0, - }, - videoSharingEnabled: false, - videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo, - }, - sectionsList: [], - isCustomRelativeDatesActive: false, - currentSection: {}, - currentSubsection: {}, - currentItem: {}, - actions: { - deletable: true, - draggable: true, - childAddable: true, - duplicable: true, - }, - enableProctoredExams: false, - pasteFileNotices: {}, - createdOn: null, - }, + initialState, reducers: { - fetchOutlineIndexSuccess: (state, { payload }) => { + fetchOutlineIndexSuccess: (state: CourseOutlineState, { payload }) => { state.outlineIndexData = payload; state.sectionsList = payload.courseStructure?.childInfo?.children || []; state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; state.enableProctoredExams = payload.courseStructure?.enableProctoredExams; state.createdOn = payload.createdOn; }, - updateOutlineIndexLoadingStatus: (state, { payload }) => { + updateOutlineIndexLoadingStatus: (state: CourseOutlineState, { payload }) => { state.loadingStatus = { ...state.loadingStatus, outlineIndexLoadingStatus: payload.status, }; state.errors.outlineIndexApi = payload.errors || null; }, - updateReindexLoadingStatus: (state, { payload }) => { + updateReindexLoadingStatus: (state: CourseOutlineState, { payload }) => { state.loadingStatus = { ...state.loadingStatus, reIndexLoadingStatus: payload.status, }; state.errors.reindexApi = payload.errors || null; }, - updateFetchSectionLoadingStatus: (state, { payload }) => { + updateFetchSectionLoadingStatus: (state: CourseOutlineState, { payload }) => { state.loadingStatus = { ...state.loadingStatus, fetchSectionLoadingStatus: payload.status, }; state.errors.sectionLoadingApi = payload.errors || null; }, - updateCourseLaunchQueryStatus: (state, { payload }) => { + updateCourseLaunchQueryStatus: (state: CourseOutlineState, { payload }) => { state.loadingStatus = { ...state.loadingStatus, courseLaunchQueryStatus: payload.status, }; state.errors.courseLaunchApi = payload.errors || null; }, - dismissError: (state, { payload }) => { + dismissError: (state: CourseOutlineState, { payload }) => { state.errors[payload] = null; }, - updateStatusBar: (state, { payload }) => { + updateStatusBar: (state: CourseOutlineState, { payload }) => { state.statusBarData = { ...state.statusBarData, ...payload, }; }, - updateCourseActions: (state, { payload }) => { + updateCourseActions: (state: CourseOutlineState, { payload }) => { state.actions = { ...state.actions, ...payload, }; }, - fetchStatusBarChecklistSuccess: (state, { payload }) => { + fetchStatusBarChecklistSuccess: (state: CourseOutlineState, { payload }) => { state.statusBarData.checklist = { ...state.statusBarData.checklist, ...payload, }; }, - fetchStatusBarSelfPacedSuccess: (state, { payload }) => { + fetchStatusBarSelfPacedSuccess: (state: CourseOutlineState, { payload }) => { state.statusBarData.isSelfPaced = payload.isSelfPaced; }, - updateSavingStatus: (state, { payload }) => { + updateSavingStatus: (state: CourseOutlineState, { payload }) => { state.savingStatus = payload.status; }, - updateSectionList: (state, { payload }) => { + updateSectionList: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.map((section) => (section.id in payload ? payload[section.id] : section)); }, - setCurrentItem: (state, { payload }) => { + setCurrentItem: (state: CourseOutlineState, { payload }) => { state.currentItem = payload; }, - reorderSectionList: (state, { payload }) => { + reorderSectionList: (state: CourseOutlineState, { payload }) => { const sectionsList = [...state.sectionsList]; sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id)); state.sectionsList = [...sectionsList]; }, - setCurrentSection: (state, { payload }) => { + setCurrentSection: (state: CourseOutlineState, { payload }) => { state.currentSection = payload; }, - setCurrentSubsection: (state, { payload }) => { + setCurrentSubsection: (state: CourseOutlineState, { payload }) => { state.currentSubsection = payload; }, - addSection: (state, { payload }) => { + addSection: (state: CourseOutlineState, { payload }) => { state.sectionsList = [ ...state.sectionsList, payload, ]; }, - addSubsection: (state, { payload }) => { + resetScrollField: (state) => { + state.sectionsList = state.sectionsList.map((section) => { + section.shouldScroll = false; + section.childInfo.children.map((subsection) => { + subsection.shouldScroll = false; + return subsection; + }); + return section; + }); + }, + addSubsection: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.map((section) => { if (section.id === payload.parentLocator) { section.childInfo.children = [ @@ -147,12 +162,12 @@ const slice = createSlice({ return section; }); }, - deleteSection: (state, { payload }) => { + deleteSection: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.filter( ({ id }) => id !== payload.itemId, ); }, - deleteSubsection: (state, { payload }) => { + deleteSubsection: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.map((section) => { if (section.id !== payload.sectionId) { return section; @@ -163,7 +178,7 @@ const slice = createSlice({ return section; }); }, - deleteUnit: (state, { payload }) => { + deleteUnit: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.map((section) => { if (section.id !== payload.sectionId) { return section; @@ -180,7 +195,7 @@ const slice = createSlice({ return section; }); }, - duplicateSection: (state, { payload }) => { + duplicateSection: (state: CourseOutlineState, { payload }) => { state.sectionsList = state.sectionsList.reduce((result, currentValue) => { if (currentValue.id === payload.id) { return [...result, currentValue, payload.duplicatedItem]; @@ -188,12 +203,12 @@ const slice = createSlice({ return [...result, currentValue]; }, []); }, - setPasteFileNotices: (state, { payload }) => { + setPasteFileNotices: (state: CourseOutlineState, { payload }) => { state.pasteFileNotices = payload; }, - removePasteFileNotices: (state, { payload }) => { + removePasteFileNotices: (state: CourseOutlineState, { payload }) => { const pasteFileNotices = { ...state.pasteFileNotices }; - payload.forEach((key) => delete pasteFileNotices[key]); + payload.forEach((key: string | number) => delete pasteFileNotices[key]); state.pasteFileNotices = pasteFileNotices; }, }, @@ -221,11 +236,10 @@ export const { deleteUnit, duplicateSection, reorderSectionList, - reorderSubsectionList, - reorderUnitList, setPasteFileNotices, removePasteFileNotices, dismissError, + resetScrollField, } = slice.actions; export const { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.ts similarity index 75% rename from src/course-outline/data/thunk.js rename to src/course-outline/data/thunk.ts index 0ebd6cd80..c6de9b0a9 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.ts @@ -1,11 +1,12 @@ import { logError } from '@edx/frontend-platform/logging'; -import { RequestStatus } from '../../data/constants'; -import { NOTIFICATION_MESSAGES } from '../../constants'; -import { COURSE_BLOCK_NAMES } from '../constants'; +import { RequestStatus } from '@src/data/constants'; +import { NOTIFICATION_MESSAGES } from '@src/constants'; import { hideProcessingNotification, showProcessingNotification, -} from '../../generic/processing-notification/data/slice'; +} from '@src/generic/processing-notification/data/slice'; +import { createCourseXblock } from '@src/course-unit/data/api'; +import { COURSE_BLOCK_NAMES } from '../constants'; import { getCourseBestPracticesChecklist, getCourseLaunchChecklist, @@ -54,9 +55,14 @@ import { setPasteFileNotices, updateCourseLaunchQueryStatus, } from './slice'; -import { createCourseXblock } from '../../course-unit/data/api'; -export function fetchCourseOutlineIndexQuery(courseId) { +/** + * Action to fetch course outline. + * + * @param {string} courseId - ID of the course + * @returns {Object} - Object containing fetch course outline index query success or failure status + */ +export function fetchCourseOutlineIndexQuery(courseId: string): object { return async (dispatch) => { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); @@ -81,7 +87,7 @@ export function fetchCourseOutlineIndexQuery(courseId) { dispatch(updateCourseActions(actions)); dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL })); - } catch (error) { + } catch (error: any) { if (error.response && error.response.status === 403) { dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.DENIED, @@ -96,7 +102,7 @@ export function fetchCourseOutlineIndexQuery(courseId) { }; } -export function syncDiscussionsTopics(courseId) { +export function syncDiscussionsTopics(courseId: string) { return async () => { try { await createDiscussionsTopics(courseId); @@ -148,7 +154,7 @@ export function fetchCourseBestPracticesQuery({ }; } -export function enableCourseHighlightsEmailsQuery(courseId) { +export function enableCourseHighlightsEmailsQuery(courseId: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); @@ -165,7 +171,7 @@ export function enableCourseHighlightsEmailsQuery(courseId) { }; } -export function setVideoSharingOptionQuery(courseId, option) { +export function setVideoSharingOptionQuery(courseId: string, option: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); @@ -183,7 +189,7 @@ export function setVideoSharingOptionQuery(courseId, option) { }; } -export function fetchCourseReindexQuery(courseId, reindexLink) { +export function fetchCourseReindexQuery(reindexLink: string) { return async (dispatch) => { dispatch(updateReindexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); @@ -199,16 +205,36 @@ export function fetchCourseReindexQuery(courseId, reindexLink) { }; } -export function fetchCourseSectionQuery(sectionIds, shouldScroll = false) { +/** + * Fetches course sections and optionally scrolls to a specific subsection/unit. + */ +export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: { + subsectionId: string, + unitId?: string, +}) { return async (dispatch) => { dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS })); try { const sections = {}; const results = await Promise.all(sectionIds.map((sectionId) => getCourseItem(sectionId))); - results.forEach((data) => { - // eslint-disable-next-line no-param-reassign - data.shouldScroll = shouldScroll; - sections[data.id] = data; + results.forEach(section => { + if (scrollToId) { + const targetSubsection = section?.childInfo?.children?.find( + subsection => subsection.id === scrollToId.subsectionId, + ); + + if (targetSubsection) { + if (scrollToId.unitId) { + const targetUnit = targetSubsection?.childInfo?.children?.find(unit => unit.id === scrollToId.unitId); + if (targetUnit) { + targetUnit.shouldScroll = true; + } + } else { + targetSubsection.shouldScroll = true; + } + } + } + sections[section.id] = section; }); dispatch(updateSectionList(sections)); dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL })); @@ -221,7 +247,7 @@ export function fetchCourseSectionQuery(sectionIds, shouldScroll = false) { }; } -export function updateCourseSectionHighlightsQuery(sectionId, highlights) { +export function updateCourseSectionHighlightsQuery(sectionId: string, highlights: string[]) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); @@ -241,7 +267,7 @@ export function updateCourseSectionHighlightsQuery(sectionId, highlights) { }; } -export function publishCourseItemQuery(itemId, sectionId) { +export function publishCourseItemQuery(itemId: string, sectionId: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); @@ -261,7 +287,7 @@ export function publishCourseItemQuery(itemId, sectionId) { }; } -export function configureCourseItemQuery(sectionId, configureFn) { +export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); @@ -281,7 +307,7 @@ export function configureCourseItemQuery(sectionId, configureFn) { }; } -export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) { +export function configureCourseSectionQuery(sectionId: string, isVisibleToStaffOnly: boolean, startDatetime: string) { return async (dispatch) => { dispatch(configureCourseItemQuery( sectionId, @@ -291,24 +317,24 @@ export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, sta } export function configureCourseSubsectionQuery( - itemId, - sectionId, - isVisibleToStaffOnly, - releaseDate, - graderType, - dueDate, - isTimeLimited, - isProctoredExam, - isOnboardingExam, - isPracticeExam, - examReviewRules, - defaultTimeLimitMin, - hideAfterDue, - showCorrectness, - isPrereq, - prereqUsageKey, - prereqMinScore, - prereqMinCompletion, + itemId: string, + sectionId: string, + isVisibleToStaffOnly: string, + releaseDate: string, + graderType: string, + dueDate: string, + isTimeLimited: boolean, + isProctoredExam: boolean, + isOnboardingExam: boolean, + isPracticeExam: boolean, + examReviewRules: string, + defaultTimeLimitMin: number, + hideAfterDue: string, + showCorrectness: string, + isPrereq: boolean, + prereqUsageKey: string, + prereqMinScore: number, + prereqMinCompletion: number, ) { return async (dispatch) => { dispatch(configureCourseItemQuery( @@ -336,7 +362,13 @@ export function configureCourseSubsectionQuery( }; } -export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess, discussionEnabled) { +export function configureCourseUnitQuery( + itemId: string, + sectionId: string, + isVisibleToStaffOnly: boolean, + groupAccess: object, + discussionEnabled: boolean, +) { return async (dispatch) => { dispatch(configureCourseItemQuery( sectionId, @@ -345,7 +377,7 @@ export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly }; } -export function editCourseItemQuery(itemId, sectionId, displayName) { +export function editCourseItemQuery(itemId: string, sectionId: string, displayName: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); @@ -369,9 +401,8 @@ export function editCourseItemQuery(itemId, sectionId, displayName) { * Generic function to delete course item, see below wrapper funcs for specific implementations. * @param {string} itemId * @param {() => {}} deleteItemFn - * @returns {} */ -function deleteCourseItemQuery(itemId, deleteItemFn) { +function deleteCourseItemQuery(itemId: string, deleteItemFn: () => {}) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); @@ -388,7 +419,7 @@ function deleteCourseItemQuery(itemId, deleteItemFn) { }; } -export function deleteCourseSectionQuery(sectionId) { +export function deleteCourseSectionQuery(sectionId: string) { return async (dispatch) => { dispatch(deleteCourseItemQuery( sectionId, @@ -397,7 +428,7 @@ export function deleteCourseSectionQuery(sectionId) { }; } -export function deleteCourseSubsectionQuery(subsectionId, sectionId) { +export function deleteCourseSubsectionQuery(subsectionId: string, sectionId: string) { return async (dispatch) => { dispatch(deleteCourseItemQuery( subsectionId, @@ -406,7 +437,7 @@ export function deleteCourseSubsectionQuery(subsectionId, sectionId) { }; } -export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) { +export function deleteCourseUnitQuery(unitId: string, subsectionId: string, sectionId: string) { return async (dispatch) => { dispatch(deleteCourseItemQuery( unitId, @@ -420,9 +451,12 @@ export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) { * @param {string} itemId * @param {string} parentLocator * @param {(locator) => Promise} duplicateFn - * @returns {} */ -function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) { +function duplicateCourseItemQuery( + itemId: string, + parentLocator: string, + duplicateFn: (locator: string) => Promise, +) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); @@ -442,7 +476,7 @@ function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) { }; } -export function duplicateSectionQuery(sectionId, courseBlockId) { +export function duplicateSectionQuery(sectionId: string, courseBlockId: string) { return async (dispatch) => { dispatch(duplicateCourseItemQuery( sectionId, @@ -457,35 +491,40 @@ export function duplicateSectionQuery(sectionId, courseBlockId) { }; } -export function duplicateSubsectionQuery(subsectionId, sectionId) { +export function duplicateSubsectionQuery(subsectionId: string, sectionId: string) { return async (dispatch) => { dispatch(duplicateCourseItemQuery( subsectionId, sectionId, - async () => dispatch(fetchCourseSectionQuery([sectionId], true)), + async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], { + subsectionId: itemId, // To scroll to the newly duplicated subsection + })), )); }; } -export function duplicateUnitQuery(unitId, subsectionId, sectionId) { +export function duplicateUnitQuery(unitId: string, subsectionId: string, sectionId: string) { return async (dispatch) => { dispatch(duplicateCourseItemQuery( unitId, subsectionId, - async () => dispatch(fetchCourseSectionQuery([sectionId], true)), + async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], { + subsectionId, + unitId: itemId, // To scroll to the newly duplicated unit + })), )); }; } /** * Generic function to add any course item. See wrapper functions below for specific implementations. - * @param {string} parentLocator - * @param {string} category - * @param {string} displayName - * @param {(data) => {}} addItemFn - * @returns {} */ -function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) { +function addNewCourseItemQuery( + parentLocator: string, + category: string, + displayName: string, + addItemFn: (data: any) => Promise, +) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); @@ -509,7 +548,7 @@ function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) }; } -export function addNewSectionQuery(parentLocator) { +export function addNewSectionQuery(parentLocator: string) { return async (dispatch) => { dispatch(addNewCourseItemQuery( parentLocator, @@ -525,7 +564,7 @@ export function addNewSectionQuery(parentLocator) { }; } -export function addNewSubsectionQuery(parentLocator) { +export function addNewSubsectionQuery(parentLocator: string) { return async (dispatch) => { dispatch(addNewCourseItemQuery( parentLocator, @@ -541,7 +580,7 @@ export function addNewSubsectionQuery(parentLocator) { }; } -export function addNewUnitQuery(parentLocator, callback) { +export function addNewUnitQuery(parentLocator: string, callback: { (locator: any): void }) { return async (dispatch) => { dispatch(addNewCourseItemQuery( parentLocator, @@ -552,7 +591,15 @@ export function addNewUnitQuery(parentLocator, callback) { }; } -export function addUnitFromLibrary(body, callback) { +export function addUnitFromLibrary(body: { + type: string; + category?: string; + parentLocator: string; + displayName?: string; + boilerplate?: string; + stagedContent?: string; + libraryContentKey?: string; +}, callback: (arg0: any) => void) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); @@ -573,11 +620,16 @@ export function addUnitFromLibrary(body, callback) { } function setBlockOrderListQuery( - parentId, - blockIds, - apiFn, - restoreCallback, - successCallback, + parentId: string, + blockIds: string[], + apiFn: { + (courseId: string, children: string[]): Promise; + (itemId: string, children: string[]): Promise; + (itemId: string, children: string[]): Promise; + (arg0: any, arg1: any): Promise; + }, + restoreCallback: () => void, + successCallback: { (): any; (): void; (): void; (): void; }, ) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); @@ -599,7 +651,11 @@ function setBlockOrderListQuery( }; } -export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallback) { +export function setSectionOrderListQuery( + courseId: string, + sectionListIds: string[], + restoreCallback: () => void, +) { return async (dispatch) => { dispatch(setBlockOrderListQuery( courseId, @@ -612,10 +668,10 @@ export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallba } export function setSubsectionOrderListQuery( - sectionId, - prevSectionId, - subsectionListIds, - restoreCallback, + sectionId: string, + prevSectionId: string, + subsectionListIds: string[], + restoreCallback: () => void, ) { return async (dispatch) => { dispatch(setBlockOrderListQuery( @@ -635,11 +691,11 @@ export function setSubsectionOrderListQuery( } export function setUnitOrderListQuery( - sectionId, - subsectionId, - prevSectionId, - unitListIds, - restoreCallback, + sectionId: string, + subsectionId: string, + prevSectionId: string, + unitListIds: string[], + restoreCallback: () => void, ) { return async (dispatch) => { dispatch(setBlockOrderListQuery( @@ -658,15 +714,15 @@ export function setUnitOrderListQuery( }; } -export function pasteClipboardContent(parentLocator, sectionId) { +export function pasteClipboardContent(parentLocator: string, sectionId: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting)); try { - await pasteBlock(parentLocator).then(async (result) => { + await pasteBlock(parentLocator).then(async (result: any) => { if (result) { - dispatch(fetchCourseSectionQuery([sectionId], true)); + dispatch(fetchCourseSectionQuery([sectionId], { subsectionId: parentLocator, unitId: result.locator })); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(hideProcessingNotification()); dispatch(setPasteFileNotices(result?.staticFileNotices)); @@ -679,7 +735,7 @@ export function pasteClipboardContent(parentLocator, sectionId) { }; } -export function dismissNotificationQuery(url) { +export function dismissNotificationQuery(url: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts new file mode 100644 index 000000000..a2f2f267d --- /dev/null +++ b/src/course-outline/data/types.ts @@ -0,0 +1,64 @@ +import { XBlock, XBlockActions } from '@src/data/types'; + +export interface CourseStructure { + highlightsEnabledForMessaging: boolean, + videoSharingEnabled: boolean, + videoSharingOptions: string, + actions: XBlockActions, +} + +// TODO: Create interface for all `Object` fields in courseOutline +export interface CourseOutline { + courseReleaseDate: string; + courseStructure: CourseStructure; + deprecatedBlocksInfo: Object; + discussionsIncontextLearnmoreUrl: string; + initialState: Object; + initialUserClipboard: Object; + languageCode: string; + lmsLink: string; + mfeProctoredExamSettingsUrl: string; + notificationDismissUrl: string; + proctoringErrors: string[]; + reindexLink: string; + rerunNotificationId: null; +} + +export interface CourseOutlineState { + loadingStatus: { + outlineIndexLoadingStatus: string; + reIndexLoadingStatus: string; + fetchSectionLoadingStatus: string; + courseLaunchQueryStatus: string; + }; + errors: { + outlineIndexApi: null | object; + reindexApi: null | object; + sectionLoadingApi: null | object; + courseLaunchApi: null | object; + }; + outlineIndexData: object; + savingStatus: string; + statusBarData: { + courseReleaseDate: string; + highlightsEnabledForMessaging: boolean; + isSelfPaced: boolean; + checklist: { + totalCourseLaunchChecks: number; + completedCourseLaunchChecks: number; + totalCourseBestPracticesChecks: number; + completedCourseBestPracticesChecks: number; + }; + videoSharingEnabled: boolean; + videoSharingOptions: string; + }; + sectionsList: Array; + isCustomRelativeDatesActive: boolean; + currentSection: XBlock | {}; + currentSubsection: XBlock | {}; + currentItem: XBlock | {}; + actions: XBlockActions; + enableProctoredExams: boolean; + pasteFileNotices: object; + createdOn: null | Date; +} diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx deleted file mode 100644 index a98a46011..000000000 --- a/src/course-outline/empty-placeholder/EmptyPlaceholder.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Add as IconAdd } from '@openedx/paragon/icons/es5'; -import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon'; - -import messages from './messages'; - -const EmptyPlaceholder = ({ - onCreateNewSection, - childAddable, -}) => { - const intl = useIntl(); - - return ( -
-

{intl.formatMessage(messages.title)}

- {childAddable && ( - - {intl.formatMessage(messages.tooltip)} - - )} - > - - - )} -
- ); -}; - -EmptyPlaceholder.propTypes = { - onCreateNewSection: PropTypes.func.isRequired, - childAddable: PropTypes.bool.isRequired, -}; - -export default EmptyPlaceholder; diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.test.jsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.test.jsx deleted file mode 100644 index 45c6841fd..000000000 --- a/src/course-outline/empty-placeholder/EmptyPlaceholder.test.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; - -import EmptyPlaceholder from './EmptyPlaceholder'; -import messages from './messages'; - -const onCreateNewSectionMock = jest.fn(); - -const renderComponent = () => render( - - - , -); - -describe('', () => { - it('renders EmptyPlaceholder component correctly', () => { - const { getByText, getByRole } = renderComponent(); - - expect(getByText(messages.title.defaultMessage)).toBeInTheDocument(); - expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument(); - }); - - it('calls the onCreateNewSection function when the button is clicked', () => { - const { getByRole } = renderComponent(); - - const addButton = getByRole('button', { name: messages.button.defaultMessage }); - fireEvent.click(addButton); - expect(onCreateNewSectionMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.test.tsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.test.tsx new file mode 100644 index 000000000..0b458f394 --- /dev/null +++ b/src/course-outline/empty-placeholder/EmptyPlaceholder.test.tsx @@ -0,0 +1,28 @@ +import { Button } from '@openedx/paragon'; +import { + fireEvent, initializeMocks, render, screen, +} from '@src/testUtils'; + +import EmptyPlaceholder from './EmptyPlaceholder'; +import messages from './messages'; + +const onCreateNewSectionMock = jest.fn(); + +const renderComponent = () => render( + + + , +); + +describe('', () => { + it('renders EmptyPlaceholder component correctly', async () => { + initializeMocks(); + renderComponent(); + + expect(await screen.findByText(messages.title.defaultMessage)).toBeInTheDocument(); + const addButton = await screen.findByRole('button', { name: 'Create New Section' }); + expect(addButton).toBeInTheDocument(); + fireEvent.click(addButton); + expect(onCreateNewSectionMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/course-outline/empty-placeholder/EmptyPlaceholder.tsx b/src/course-outline/empty-placeholder/EmptyPlaceholder.tsx new file mode 100644 index 000000000..e283e25c8 --- /dev/null +++ b/src/course-outline/empty-placeholder/EmptyPlaceholder.tsx @@ -0,0 +1,21 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Stack } from '@openedx/paragon'; + +import messages from './messages'; + +interface Props { + children: React.ReactElement; +} + +const EmptyPlaceholder = ({ children }: Props) => { + const intl = useIntl(); + + return ( + +

{intl.formatMessage(messages.title)}

+ {children} +
+ ); +}; + +export default EmptyPlaceholder; diff --git a/src/course-outline/empty-placeholder/messages.js b/src/course-outline/empty-placeholder/messages.js index eccb564a8..274f3deb0 100644 --- a/src/course-outline/empty-placeholder/messages.js +++ b/src/course-outline/empty-placeholder/messages.js @@ -5,14 +5,6 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.empty-placeholder.title', defaultMessage: 'You haven\'t added any content to this course yet.', }, - button: { - id: 'course-authoring.course-outline.empty-placeholder.button.new-section', - defaultMessage: 'New section', - }, - tooltip: { - id: 'course-authoring.course-outline.empty-placeholder.button.tooltip', - defaultMessage: 'Click to add a new section', - }, }); export default messages; diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 88d202391..f5fea7d97 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -5,13 +5,16 @@ import { useToggle } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; import moment from 'moment'; -import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors'; -import { useWaffleFlags } from '../data/apiHooks'; -import { RequestStatus } from '../data/constants'; +import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors'; +import { useWaffleFlags } from '@src/data/apiHooks'; +import { RequestStatus } from '@src/data/constants'; import { COURSE_BLOCK_NAMES } from './constants'; import { + addSection, + addSubsection, setCurrentItem, setCurrentSection, + resetScrollField, updateSavingStatus, } from './data/slice'; import { @@ -55,8 +58,10 @@ import { setUnitOrderListQuery, pasteClipboardContent, dismissNotificationQuery, - addUnitFromLibrary, syncDiscussionsTopics, + syncDiscussionsTopics, } from './data/thunk'; +import { useCreateCourseBlock } from './data/apiHooks'; +import { getCourseItem } from './data/api'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); @@ -75,6 +80,7 @@ const useCourseOutline = ({ courseId }) => { mfeProctoredExamSettingsUrl, advanceSettingsUrl, } = useSelector(getOutlineIndexData); + /** Course usage key is different than courseKey and useful in using as parentLocator for imported sections */ const createdOn = useSelector(getCreatedOn); const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus); const statusBarData = useSelector(getStatusBarData); @@ -96,6 +102,11 @@ const useCourseOutline = ({ courseId }) => { const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [ + isAddLibrarySectionModalOpen, + openAddLibrarySectionModal, + closeAddLibrarySectionModal, + ] = useToggle(false); const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; @@ -131,17 +142,44 @@ const useCourseOutline = ({ courseId }) => { dispatch(addNewUnitQuery(subsectionId, openUnitPage)); }; - const handleAddUnitFromLibrary = (body) => { - dispatch(addUnitFromLibrary(body, openUnitPage)); + /** + * import a unit block from library and redirect user to this unit page. + */ + const handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage); + + const handleAddSubsectionFromLibrary = useCreateCourseBlock(async (locator, parentLocator) => { + try { + const data = await getCourseItem(locator); + data.shouldScroll = true; + // Page should scroll to newly added subsection. + dispatch(addSubsection({ parentLocator, data })); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }); + + const resetScrollState = () => { + dispatch(resetScrollField()); }; + const handleAddSectionFromLibrary = useCreateCourseBlock(async (locator) => { + try { + const data = await getCourseItem(locator); + // Page should scroll to newly added section. + data.shouldScroll = true; + dispatch(addSection(data)); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }); + const headerNavigationsActions = { handleNewSection: handleNewSectionSubmit, handleReIndex: () => { setDisableReindexButton(true); setShowSuccessAlert(false); - dispatch(fetchCourseReindexQuery(courseId, reindexLink)).then(() => { + dispatch(fetchCourseReindexQuery(reindexLink)).then(() => { setDisableReindexButton(false); }); }, @@ -305,6 +343,7 @@ const useCourseOutline = ({ courseId }) => { }, [reIndexLoadingStatus]); return { + courseUsageKey: courseStructure?.id, courseActions, savingStatus, sectionsList, @@ -320,6 +359,9 @@ const useCourseOutline = ({ courseId }) => { closePublishModal, isConfigureModalOpen, openConfigureModal, + isAddLibrarySectionModalOpen, + openAddLibrarySectionModal, + closeAddLibrarySectionModal, handleConfigureModalClose, headerNavigationsActions, handleEnableHighlightsSubmit, @@ -350,6 +392,8 @@ const useCourseOutline = ({ courseId }) => { openUnitPage, handleNewUnitSubmit, handleAddUnitFromLibrary, + handleAddSubsectionFromLibrary, + handleAddSectionFromLibrary, handleVideoSharingOptionChange, handlePasteClipboardClick, notificationDismissUrl, @@ -365,6 +409,7 @@ const useCourseOutline = ({ courseId }) => { handleSubsectionDragAndDrop, handleUnitDragAndDrop, errors, + resetScrollState, }; }; diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js index 511bf9466..f1f797e0d 100644 --- a/src/course-outline/messages.js +++ b/src/course-outline/messages.js @@ -28,6 +28,12 @@ const messages = defineMessages({ newSectionButton: { id: 'course-authoring.course-outline.section-list.button.new-section', defaultMessage: 'New section', + description: 'Text of button to create new section in course outline', + }, + useSectionFromLibraryButton: { + id: 'course-authoring.course-outline.button.use-section-from-library', + defaultMessage: 'Use section from library', + description: 'Text of the button to add a section from a library in a course.', }, exportTagsCreatingToastMessage: { id: 'course-authoring.course-outline.export-tags.toast.creating.message', @@ -44,6 +50,31 @@ const messages = defineMessages({ defaultMessage: 'An error has occurred creating the file', description: 'Error message in toast when exporting tags of a course', }, + newUnitButton: { + id: 'course-authoring.course-outline.button.new-unit', + defaultMessage: 'New unit', + description: 'Message of the button to create a new unit in a subsection.', + }, + useUnitFromLibraryButton: { + id: 'course-authoring.course-outline.button.use-unit-from-library', + defaultMessage: 'Use unit from library', + description: 'Message of the button to add a new unit from a library in a subsection.', + }, + newSubsectionButton: { + id: 'course-authoring.course-outline.button.new-subsection', + defaultMessage: 'New subsection', + description: 'Text of button to create new subsection in a section', + }, + useSubsectionFromLibraryButton: { + id: 'course-authoring.course-outline.button.use-subsection-from-library', + defaultMessage: 'Use subsection from library', + description: 'Message of the button to add a new subsection from a library in a subsection.', + }, + sectionPickerModalTitle: { + id: 'course-authoring.course-outline.button.section-modal.title', + defaultMessage: 'Select section', + description: 'Section modal picker title text in outline', + }, }); export default messages; diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx deleted file mode 100644 index 5d3b5fbb5..000000000 --- a/src/course-outline/section-card/SectionCard.jsx +++ /dev/null @@ -1,322 +0,0 @@ -// @ts-check -import React, { - useContext, useEffect, useState, useRef, -} from 'react'; -import PropTypes from 'prop-types'; -import { useDispatch } from 'react-redux'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Bubble, Button, useToggle } from '@openedx/paragon'; -import { Add as IconAdd } from '@openedx/paragon/icons'; -import { useSearchParams } from 'react-router-dom'; -import classNames from 'classnames'; - -import { setCurrentItem, setCurrentSection } from '../data/slice'; -import { RequestStatus } from '../../data/constants'; -import CardHeader from '../card-header/CardHeader'; -import SortableItem from '../drag-helper/SortableItem'; -import { DragContext } from '../drag-helper/DragContextProvider'; -import TitleButton from '../card-header/TitleButton'; -import XBlockStatus from '../xblock-status/XBlockStatus'; -import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; -import messages from './messages'; - -const SectionCard = ({ - section, - isSelfPaced, - isCustomRelativeDatesActive, - children, - index, - canMoveItem, - onOpenHighlightsModal, - onOpenPublishModal, - onOpenConfigureModal, - onEditSectionSubmit, - savingStatus, - onOpenDeleteModal, - onDuplicateSubmit, - isSectionsExpanded, - onNewSubsectionSubmit, - onOrderChange, -}) => { - const currentRef = useRef(null); - const intl = useIntl(); - const dispatch = useDispatch(); - const { activeId, overId } = useContext(DragContext); - const [searchParams] = useSearchParams(); - const locatorId = searchParams.get('show'); - const isScrolledToElement = locatorId === section.id; - - // Expand the section if a search result should be shown/scrolled to - const containsSearchResult = () => { - if (locatorId) { - const subsections = section.childInfo?.children; - if (subsections) { - for (let i = 0; i < subsections.length; i++) { - const subsection = subsections[i]; - - // Check if the search result is one of the subsections - const matchedSubsection = subsection.id === locatorId; - if (matchedSubsection) { - return true; - } - - // Check if the search result is one of the units - const matchedUnit = !!subsection.childInfo?.children?.filter((child) => child.id === locatorId).length; - if (matchedUnit) { - return true; - } - } - } - } - - return false; - }; - const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded); - const [isFormOpen, openForm, closeForm] = useToggle(false); - const namePrefix = 'section'; - - useEffect(() => { - setIsExpanded(isSectionsExpanded); - }, [isSectionsExpanded]); - - const { - id, - category, - displayName, - hasChanges, - published, - visibilityState, - highlights, - actions: sectionActions, - isHeaderVisible = true, - } = section; - - useEffect(() => { - if (activeId === id && isExpanded) { - setIsExpanded(false); - } else if (overId === id && !isExpanded) { - setIsExpanded(true); - } - }, [activeId, overId]); - - useEffect(() => { - if (currentRef.current && (section.shouldScroll || isScrolledToElement)) { - // Align element closer to the top of the screen if scrolling for search result - const alignWithTop = !!isScrolledToElement; - scrollToElement(currentRef.current, alignWithTop); - } - }, [isScrolledToElement]); - - useEffect(() => { - // If the locatorId is set/changed, we need to make sure that the section is expanded - // if it contains the result, in order to scroll to it - setIsExpanded((prevState) => containsSearchResult() || prevState); - }, [locatorId, setIsExpanded]); - - // re-create actions object for customizations - const actions = { ...sectionActions }; - // add actions to control display of move up & down menu buton. - actions.allowMoveUp = canMoveItem(index, -1); - actions.allowMoveDown = canMoveItem(index, 1); - - const sectionStatus = getItemStatus({ - published, - visibilityState, - hasChanges, - }); - - // remove border when section is expanded - const borderStyle = getItemStatusBorder(!isExpanded ? sectionStatus : ''); - - const handleExpandContent = () => { - setIsExpanded((prevState) => !prevState); - }; - - const handleClickMenuButton = () => { - dispatch(setCurrentItem(section)); - dispatch(setCurrentSection(section)); - }; - - const handleEditSubmit = (titleValue) => { - if (displayName !== titleValue) { - // both itemId and sectionId are same - onEditSectionSubmit(id, id, titleValue); - return; - } - - closeForm(); - }; - - const handleOpenHighlightsModal = () => { - onOpenHighlightsModal(section); - }; - - const handleNewSubsectionSubmit = () => { - onNewSubsectionSubmit(id); - }; - - const handleSectionMoveUp = () => { - onOrderChange(index, index - 1); - }; - - const handleSectionMoveDown = () => { - onOrderChange(index, index + 1); - }; - - useEffect(() => { - if (savingStatus === RequestStatus.SUCCESSFUL) { - closeForm(); - } - }, [savingStatus]); - - const titleComponent = ( - - ); - - const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); - - return ( - -
-
- {isHeaderVisible && ( - - )} -
-
- -
- -
- {isExpanded && ( -
- {children} - {actions.childAddable && ( - - )} -
- )} -
-
-
- ); -}; - -SectionCard.defaultProps = { - children: null, -}; - -SectionCard.propTypes = { - section: PropTypes.shape({ - id: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - category: PropTypes.string.isRequired, - published: PropTypes.bool.isRequired, - hasChanges: PropTypes.bool.isRequired, - visibilityState: PropTypes.string.isRequired, - highlights: PropTypes.arrayOf(PropTypes.string).isRequired, - shouldScroll: PropTypes.bool, - actions: PropTypes.shape({ - deletable: PropTypes.bool.isRequired, - draggable: PropTypes.bool.isRequired, - childAddable: PropTypes.bool.isRequired, - duplicable: PropTypes.bool.isRequired, - }).isRequired, - isHeaderVisible: PropTypes.bool, - childInfo: PropTypes.shape({ - children: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - childInfo: PropTypes.shape({ - children: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - }), - ).isRequired, - }).isRequired, - }), - ).isRequired, - }).isRequired, - }).isRequired, - isSelfPaced: PropTypes.bool.isRequired, - isCustomRelativeDatesActive: PropTypes.bool.isRequired, - children: PropTypes.node, - onOpenHighlightsModal: PropTypes.func.isRequired, - onOpenPublishModal: PropTypes.func.isRequired, - onOpenConfigureModal: PropTypes.func.isRequired, - onEditSectionSubmit: PropTypes.func.isRequired, - savingStatus: PropTypes.string.isRequired, - onOpenDeleteModal: PropTypes.func.isRequired, - onDuplicateSubmit: PropTypes.func.isRequired, - isSectionsExpanded: PropTypes.bool.isRequired, - onNewSubsectionSubmit: PropTypes.func.isRequired, - index: PropTypes.number.isRequired, - canMoveItem: PropTypes.func.isRequired, - onOrderChange: PropTypes.func.isRequired, -}; - -export default SectionCard; diff --git a/src/course-outline/section-card/SectionCard.test.jsx b/src/course-outline/section-card/SectionCard.test.tsx similarity index 51% rename from src/course-outline/section-card/SectionCard.test.jsx rename to src/course-outline/section-card/SectionCard.test.tsx index 48b946bc1..add4e5518 100644 --- a/src/course-outline/section-card/SectionCard.test.jsx +++ b/src/course-outline/section-card/SectionCard.test.tsx @@ -1,17 +1,9 @@ -import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; import { - act, render, fireEvent, within, -} 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -import initializeStore from '../../store'; + act, fireEvent, initializeMocks, render, screen, within, +} from '@src/testUtils'; +import { XBlock } from '@src/data/types'; import SectionCard from './SectionCard'; -let store; const mockPathname = '/foo-bar'; jest.mock('react-router-dom', () => ({ @@ -45,7 +37,7 @@ const subsection = { id: unit.id, }], }, -}; +} as XBlock; const section = { id: '123', @@ -72,90 +64,79 @@ const section = { }, }], }, -}; +} as XBlock; const onEditSectionSubmit = jest.fn(); -const queryClient = new QueryClient(); - -const renderComponent = (props, entry = '/') => render( - - - - - - children - - - - - , +const renderComponent = (props?: object, entry = '/') => render( + + children + , + { + path: '/', + routerProps: { + initialEntries: [entry], + }, + }, ); describe('', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); + initializeMocks(); }); it('render SectionCard component correctly', () => { - const { getByTestId } = renderComponent(); + renderComponent(); - expect(getByTestId('section-card-header')).toBeInTheDocument(); - expect(getByTestId('section-card__content')).toBeInTheDocument(); + expect(screen.getByTestId('section-card-header')).toBeInTheDocument(); + expect(screen.getByTestId('section-card__content')).toBeInTheDocument(); }); it('expands/collapses the card when the expand button is clicked', () => { - const { queryByTestId, getByTestId } = renderComponent(); + renderComponent(); - const expandButton = getByTestId('section-card-header__expanded-btn'); + const expandButton = screen.getByTestId('section-card-header__expanded-btn'); fireEvent.click(expandButton); - expect(queryByTestId('section-card__subsections')).not.toBeInTheDocument(); - expect(queryByTestId('new-subsection-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('section-card__subsections')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'New subsection' })).not.toBeInTheDocument(); fireEvent.click(expandButton); - expect(queryByTestId('section-card__subsections')).toBeInTheDocument(); - expect(queryByTestId('new-subsection-button')).toBeInTheDocument(); + expect(screen.queryByTestId('section-card__subsections')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'New subsection' })).toBeInTheDocument(); }); it('title only updates if changed', async () => { - const { findByTestId } = renderComponent(); + renderComponent(); - let editButton = await findByTestId('section-edit-button'); + let editButton = await screen.findByTestId('section-edit-button'); fireEvent.click(editButton); - let editField = await findByTestId('section-edit-field'); + let editField = await screen.findByTestId('section-edit-field'); fireEvent.blur(editField); expect(onEditSectionSubmit).not.toHaveBeenCalled(); - editButton = await findByTestId('section-edit-button'); + editButton = await screen.findByTestId('section-edit-button'); fireEvent.click(editButton); - editField = await findByTestId('section-edit-field'); + editField = await screen.findByTestId('section-edit-field'); fireEvent.change(editField, { target: { value: 'some random value' } }); fireEvent.blur(editField); expect(onEditSectionSubmit).toHaveBeenCalled(); @@ -172,7 +153,7 @@ describe('', () => { }); it('hides add new, duplicate & delete option based on childAddable, duplicable & deletable action flag', async () => { - const { findByTestId, queryByTestId } = renderComponent({ + renderComponent({ section: { ...section, actions: { @@ -183,32 +164,34 @@ describe('', () => { }, }, }); - const element = await findByTestId('section-card'); + const element = await screen.findByTestId('section-card'); const menu = await within(element).findByTestId('section-card-header__menu-button'); await act(async () => fireEvent.click(menu)); expect(within(element).queryByTestId('section-card-header__menu-duplicate-button')).not.toBeInTheDocument(); expect(within(element).queryByTestId('section-card-header__menu-delete-button')).not.toBeInTheDocument(); - expect(queryByTestId('new-subsection-button')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'New subsection' })).not.toBeInTheDocument(); }); it('check extended section when URL "show" param in subsection under section', async () => { const collapsedSections = { ...section }; + // @ts-ignore-next-line collapsedSections.isSectionsExpanded = false; - const { findByTestId } = renderComponent(collapsedSections, `?show=${subsection.id}`); + renderComponent(collapsedSections, `?show=${subsection.id}`); - const cardSubsections = await findByTestId('section-card__subsections'); - const newSubsectionButton = await findByTestId('new-subsection-button'); + const cardSubsections = await screen.findByTestId('section-card__subsections'); + const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' }); expect(cardSubsections).toBeInTheDocument(); expect(newSubsectionButton).toBeInTheDocument(); }); it('check extended section when URL "show" param in unit under section', async () => { const collapsedSections = { ...section }; + // @ts-ignore-next-line collapsedSections.isSectionsExpanded = false; - const { findByTestId } = renderComponent(collapsedSections, `?show=${unit.id}`); + renderComponent(collapsedSections, `?show=${unit.id}`); - const cardSubsections = await findByTestId('section-card__subsections'); - const newSubsectionButton = await findByTestId('new-subsection-button'); + const cardSubsections = await screen.findByTestId('section-card__subsections'); + const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' }); expect(cardSubsections).toBeInTheDocument(); expect(newSubsectionButton).toBeInTheDocument(); }); @@ -216,11 +199,12 @@ describe('', () => { it('check not extended section when URL "show" param not in section', async () => { const randomId = 'random-id'; const collapsedSections = { ...section }; + // @ts-ignore-next-line collapsedSections.isSectionsExpanded = false; - const { queryByTestId } = renderComponent(collapsedSections, `?show=${randomId}`); + renderComponent(collapsedSections, `?show=${randomId}`); - const cardSubsections = await queryByTestId('section-card__subsections'); - const newSubsectionButton = await queryByTestId('new-subsection-button'); + const cardSubsections = screen.queryByTestId('section-card__subsections'); + const newSubsectionButton = screen.queryByRole('button', { name: 'New subsection' }); expect(cardSubsections).toBeNull(); expect(newSubsectionButton).toBeNull(); }); diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx new file mode 100644 index 000000000..27eb2c99e --- /dev/null +++ b/src/course-outline/section-card/SectionCard.tsx @@ -0,0 +1,330 @@ +import { + useContext, useEffect, useState, useRef, useCallback, ReactNode, +} from 'react'; +import { useDispatch } from 'react-redux'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Bubble, Button, StandardModal, useToggle, +} from '@openedx/paragon'; +import { useSearchParams } from 'react-router-dom'; +import classNames from 'classnames'; + +import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice'; +import { RequestStatus } from '@src/data/constants'; +import CardHeader from '@src/course-outline/card-header/CardHeader'; +import SortableItem from '@src/course-outline/drag-helper/SortableItem'; +import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; +import TitleButton from '@src/course-outline/card-header/TitleButton'; +import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus'; +import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils'; +import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons'; +import { ContainerType } from '@src/generic/key-utils'; +import { ComponentPicker, SelectedComponent } from '@src/library-authoring'; +import { ContentType } from '@src/library-authoring/routes'; +import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { XBlock } from '@src/data/types'; +import messages from './messages'; + +interface SectionCardProps { + section: XBlock, + isSelfPaced: boolean, + isCustomRelativeDatesActive: boolean, + children: ReactNode, + onOpenHighlightsModal: (section: XBlock) => void, + onOpenPublishModal: () => void, + onOpenConfigureModal: () => void, + onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void, + savingStatus: string, + onOpenDeleteModal: () => void, + onDuplicateSubmit: () => void, + isSectionsExpanded: boolean, + onNewSubsectionSubmit: (id: string) => void, + onAddSubsectionFromLibrary: (props: object) => {}, + index: number, + canMoveItem: (oldIndex: number, newIndex: number) => boolean, + onOrderChange: (oldIndex: number, newIndex: number) => void, + resetScrollState: () => void, +} + +const SectionCard = ({ + section, + isSelfPaced, + isCustomRelativeDatesActive, + children, + index, + canMoveItem, + onOpenHighlightsModal, + onOpenPublishModal, + onOpenConfigureModal, + onEditSectionSubmit, + savingStatus, + onOpenDeleteModal, + onDuplicateSubmit, + isSectionsExpanded, + onNewSubsectionSubmit, + onAddSubsectionFromLibrary, + onOrderChange, + resetScrollState, +}: SectionCardProps) => { + const currentRef = useRef(null); + const intl = useIntl(); + const dispatch = useDispatch(); + const { activeId, overId } = useContext(DragContext); + const [searchParams] = useSearchParams(); + const locatorId = searchParams.get('show'); + const isScrolledToElement = locatorId === section.id; + const [ + isAddLibrarySubsectionModalOpen, + openAddLibrarySubsectionModal, + closeAddLibrarySubsectionModal, + ] = useToggle(false); + + // Expand the section if a search result should be shown/scrolled to + const containsSearchResult = () => { + if (locatorId) { + const subsections = section.childInfo?.children; + if (subsections) { + for (let i = 0; i < subsections.length; i++) { + const subsection = subsections[i]; + + // Check if the search result is one of the subsections + const matchedSubsection = subsection.id === locatorId; + if (matchedSubsection) { + return true; + } + + // Check if the search result is one of the units + const matchedUnit = !!subsection.childInfo?.children?.filter((child) => child.id === locatorId).length; + if (matchedUnit) { + return true; + } + } + } + } + + return false; + }; + const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded); + const [isFormOpen, openForm, closeForm] = useToggle(false); + const namePrefix = 'section'; + + useEffect(() => { + setIsExpanded(isSectionsExpanded); + }, [isSectionsExpanded]); + + const { + id, + category, + displayName, + hasChanges, + published, + visibilityState, + highlights, + actions: sectionActions, + isHeaderVisible = true, + } = section; + + useEffect(() => { + if (activeId === id && isExpanded) { + setIsExpanded(false); + } else if (overId === id && !isExpanded) { + setIsExpanded(true); + } + }, [activeId, overId]); + + useEffect(() => { + if (currentRef.current && (section.shouldScroll || isScrolledToElement)) { + // Align element closer to the top of the screen if scrolling for search result + const alignWithTop = !!isScrolledToElement; + scrollToElement(currentRef.current, alignWithTop, true); + resetScrollState(); + } + }, [isScrolledToElement]); + + useEffect(() => { + // If the locatorId is set/changed, we need to make sure that the section is expanded + // if it contains the result, in order to scroll to it + setIsExpanded((prevState) => containsSearchResult() || prevState); + }, [locatorId, setIsExpanded]); + + // re-create actions object for customizations + const actions = { ...sectionActions }; + // add actions to control display of move up & down menu buton. + actions.allowMoveUp = canMoveItem(index, -1); + actions.allowMoveDown = canMoveItem(index, 1); + + const sectionStatus = getItemStatus({ + published, + visibilityState, + hasChanges, + }); + + // remove border when section is expanded + const borderStyle = getItemStatusBorder(!isExpanded ? sectionStatus : ''); + + const handleExpandContent = () => { + setIsExpanded((prevState) => !prevState); + }; + + const handleClickMenuButton = () => { + dispatch(setCurrentItem(section)); + dispatch(setCurrentSection(section)); + }; + + const handleEditSubmit = (titleValue: string) => { + if (displayName !== titleValue) { + // both itemId and sectionId are same + onEditSectionSubmit(id, id, titleValue); + return; + } + + closeForm(); + }; + + const handleOpenHighlightsModal = () => { + onOpenHighlightsModal(section); + }; + + const handleNewSubsectionSubmit = () => { + onNewSubsectionSubmit(id); + }; + + const handleSectionMoveUp = () => { + onOrderChange(index, index - 1); + }; + + const handleSectionMoveDown = () => { + onOrderChange(index, index + 1); + }; + + /** + * Callback to handle the selection of a library subsection to be imported to course. + * @param {Object} selectedSubection - The selected subsection details. + * @returns {void} + */ + const handleSelectLibrarySubsection = useCallback((selectedSubection: SelectedComponent) => { + onAddSubsectionFromLibrary({ + type: COMPONENT_TYPES.libraryV2, + category: ContainerType.Sequential, + parentLocator: id, + libraryContentKey: selectedSubection.usageKey, + }); + closeAddLibrarySubsectionModal(); + }, [id, onAddSubsectionFromLibrary, closeAddLibrarySubsectionModal]); + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL) { + closeForm(); + } + }, [savingStatus]); + + const titleComponent = ( + + ); + + const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); + + return ( + <> + +
+
+ {isHeaderVisible && ( + + )} +
+
+ +
+ +
+ {isExpanded && ( +
+ {children} + {actions.childAddable && ( + + )} +
+ )} +
+
+
+ + + + + ); +}; + +export default SectionCard; diff --git a/src/course-outline/section-card/messages.js b/src/course-outline/section-card/messages.js index 4730c31a3..89575eec1 100644 --- a/src/course-outline/section-card/messages.js +++ b/src/course-outline/section-card/messages.js @@ -1,14 +1,15 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - newSubsectionButton: { - id: 'course-authoring.course-outline.section.button.new-subsection', - defaultMessage: 'New subsection', - }, sectionHighlightsBadge: { id: 'course-authoring.course-outline.section.badge.section-highlights', defaultMessage: 'Section highlights', }, + subsectionPickerModalTitle: { + id: 'course-authoring.course-outline.section.subsection-modal.title', + defaultMessage: 'Select subsection', + description: 'Subsection modal picker title text in outline', + }, }); export default messages; diff --git a/src/course-outline/subsection-card/SubsectionCard.test.jsx b/src/course-outline/subsection-card/SubsectionCard.test.tsx similarity index 55% rename from src/course-outline/subsection-card/SubsectionCard.test.jsx rename to src/course-outline/subsection-card/SubsectionCard.test.tsx index 59bfa08e5..836317405 100644 --- a/src/course-outline/subsection-card/SubsectionCard.test.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.test.tsx @@ -1,16 +1,10 @@ -import { MemoryRouter } from 'react-router-dom'; +import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { - act, render, fireEvent, within, screen, -} 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -import initializeStore from '../../store'; -import SubsectionCard from './SubsectionCard'; + act, fireEvent, initializeMocks, render, screen, within, +} from '@src/testUtils'; +import { XBlock } from '@src/data/types'; import cardHeaderMessages from '../card-header/messages'; -import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; +import SubsectionCard from './SubsectionCard'; let store; const mockPathname = '/foo-bar'; @@ -32,7 +26,7 @@ jest.mock('react-redux', () => ({ })); // Mock ComponentPicker to call onComponentSelected on click -jest.mock('../../library-authoring/component-picker', () => ({ +jest.mock('@src/library-authoring/component-picker', () => ({ ComponentPicker: (props) => { const onClick = () => { // eslint-disable-next-line react/prop-types @@ -53,7 +47,7 @@ const unit = { id: 'unit-1', }; -const subsection = { +const subsection: XBlock = { id: '123', displayName: 'Subsection Name', category: 'sequential', @@ -73,9 +67,9 @@ const subsection = { id: unit.id, }], }, -}; +} as XBlock; -const section = { +const section: XBlock = { id: '123', displayName: 'Section Name', published: true, @@ -87,83 +81,71 @@ const section = { id: subsection.id, }], }, -}; +} as XBlock; const onEditSubectionSubmit = jest.fn(); -const queryClient = new QueryClient(); -const renderComponent = (props, entry = '/') => render( - - - - - - children - - - - - , +const renderComponent = (props?: object, entry = '/') => render( + + children + , + { + path: '/', + routerProps: { + initialEntries: [entry], + }, + }, ); describe('', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); + const mocks = initializeMocks(); + store = mocks.reduxStore; }); it('render SubsectionCard component correctly', () => { - const { getByTestId } = renderComponent(); + renderComponent(); - expect(getByTestId('subsection-card-header')).toBeInTheDocument(); + expect(screen.getByTestId('subsection-card-header')).toBeInTheDocument(); }); it('expands/collapses the card when the subsection button is clicked', async () => { - const { queryByTestId, findByTestId } = renderComponent(); + renderComponent(); - const expandButton = await findByTestId('subsection-card-header__expanded-btn'); + const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn'); fireEvent.click(expandButton); - expect(queryByTestId('subsection-card__units')).toBeInTheDocument(); - expect(queryByTestId('new-unit-button')).toBeInTheDocument(); + expect(screen.queryByTestId('subsection-card__units')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'New unit' })).toBeInTheDocument(); fireEvent.click(expandButton); - expect(queryByTestId('subsection-card__units')).not.toBeInTheDocument(); - expect(queryByTestId('new-unit-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('subsection-card__units')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'New unit' })).not.toBeInTheDocument(); }); it('updates current section, subsection and item', async () => { - const { findByTestId } = renderComponent(); + renderComponent(); - const menu = await findByTestId('subsection-card-header__menu'); + const menu = await screen.findByTestId('subsection-card-header__menu'); fireEvent.click(menu); const { currentSection, currentSubsection, currentItem } = store.getState().courseOutline; expect(currentSection).toEqual(section); @@ -172,35 +154,35 @@ describe('', () => { }); it('title only updates if changed', async () => { - const { findByTestId } = renderComponent(); + renderComponent(); - let editButton = await findByTestId('subsection-edit-button'); + let editButton = await screen.findByTestId('subsection-edit-button'); fireEvent.click(editButton); - let editField = await findByTestId('subsection-edit-field'); + let editField = await screen.findByTestId('subsection-edit-field'); fireEvent.blur(editField); expect(onEditSubectionSubmit).not.toHaveBeenCalled(); - editButton = await findByTestId('subsection-edit-button'); + editButton = await screen.findByTestId('subsection-edit-button'); fireEvent.click(editButton); - editField = await findByTestId('subsection-edit-field'); + editField = await screen.findByTestId('subsection-edit-field'); fireEvent.change(editField, { target: { value: 'some random value' } }); fireEvent.keyDown(editField, { key: 'Enter', keyCode: 13 }); expect(onEditSubectionSubmit).toHaveBeenCalled(); }); it('hides header based on isHeaderVisible flag', async () => { - const { queryByTestId } = renderComponent({ + renderComponent({ subsection: { ...subsection, isHeaderVisible: false, }, }); - expect(queryByTestId('subsection-card-header')).not.toBeInTheDocument(); + expect(screen.queryByTestId('subsection-card-header')).not.toBeInTheDocument(); }); it('hides add new, duplicate & delete option based on childAddable, duplicable & deletable action flag', async () => { - const { findByTestId, queryByTestId } = renderComponent({ + renderComponent({ subsection: { ...subsection, actions: { @@ -211,43 +193,43 @@ describe('', () => { }, }, }); - const element = await findByTestId('subsection-card'); + const element = await screen.findByTestId('subsection-card'); const menu = await within(element).findByTestId('subsection-card-header__menu-button'); await act(async () => fireEvent.click(menu)); expect(within(element).queryByTestId('subsection-card-header__menu-duplicate-button')).not.toBeInTheDocument(); expect(within(element).queryByTestId('subsection-card-header__menu-delete-button')).not.toBeInTheDocument(); - expect(queryByTestId('new-unit-button')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'New unit' })).not.toBeInTheDocument(); }); it('renders live status', async () => { - const { findByText } = renderComponent(); - expect(await findByText(cardHeaderMessages.statusBadgeLive.defaultMessage)).toBeInTheDocument(); + renderComponent(); + expect(await screen.findByText(cardHeaderMessages.statusBadgeLive.defaultMessage)).toBeInTheDocument(); }); it('renders published but live status', async () => { - const { findByText } = renderComponent({ + renderComponent({ subsection: { ...subsection, published: true, visibilityState: 'ready', }, }); - expect(await findByText(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument(); }); it('renders staff status', async () => { - const { findByText } = renderComponent({ + renderComponent({ subsection: { ...subsection, published: false, visibilityState: 'staff_only', }, }); - expect(await findByText(cardHeaderMessages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(cardHeaderMessages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument(); }); it('renders draft status', async () => { - const { findByText } = renderComponent({ + renderComponent({ subsection: { ...subsection, published: false, @@ -255,24 +237,24 @@ describe('', () => { hasChanges: true, }, }); - expect(await findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); }); it('check extended subsection when URL "show" param in subsection', async () => { - const { findByTestId } = renderComponent(null, `?show=${unit.id}`); + renderComponent(undefined, `?show=${unit.id}`); - const cardUnits = await findByTestId('subsection-card__units'); - const newUnitButton = await findByTestId('new-unit-button'); + const cardUnits = await screen.findByTestId('subsection-card__units'); + const newUnitButton = await screen.findByRole('button', { name: 'New unit' }); expect(cardUnits).toBeInTheDocument(); expect(newUnitButton).toBeInTheDocument(); }); it('check not extended subsection when URL "show" param not in subsection', async () => { const randomId = 'random-id'; - const { queryByTestId } = renderComponent(null, `?show=${randomId}`); + renderComponent(undefined, `?show=${randomId}`); - const cardUnits = await queryByTestId('subsection-card__units'); - const newUnitButton = await queryByTestId('new-unit-button'); + const cardUnits = screen.queryByTestId('subsection-card__units'); + const newUnitButton = screen.queryByRole('button', { name: 'New unit' }); expect(cardUnits).toBeNull(); expect(newUnitButton).toBeNull(); }); diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.tsx similarity index 61% rename from src/course-outline/subsection-card/SubsectionCard.jsx rename to src/course-outline/subsection-card/SubsectionCard.tsx index 3ce632af0..f764cfa0a 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -1,32 +1,60 @@ -// @ts-check import React, { - useContext, useEffect, useState, useRef, useCallback, + useContext, useEffect, useState, useRef, useCallback, ReactNode, } from 'react'; -import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useSearchParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Button, StandardModal, useToggle } from '@openedx/paragon'; -import { Add as IconAdd } from '@openedx/paragon/icons'; +import { StandardModal, useToggle } from '@openedx/paragon'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; -import CourseOutlineSubsectionCardExtraActionsSlot from '../../plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot'; -import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; -import { RequestStatus } from '../../data/constants'; -import CardHeader from '../card-header/CardHeader'; -import SortableItem from '../drag-helper/SortableItem'; -import { DragContext } from '../drag-helper/DragContextProvider'; -import { useClipboard, PasteComponent } from '../../generic/clipboard'; -import TitleButton from '../card-header/TitleButton'; -import XBlockStatus from '../xblock-status/XBlockStatus'; -import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; +import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot'; +import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; +import { RequestStatus } from '@src/data/constants'; +import CardHeader from '@src/course-outline/card-header/CardHeader'; +import SortableItem from '@src/course-outline/drag-helper/SortableItem'; +import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; +import { useClipboard, PasteComponent } from '@src/generic/clipboard'; +import TitleButton from '@src/course-outline/card-header/TitleButton'; +import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus'; +import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils'; +import { ComponentPicker, SelectedComponent } from '@src/library-authoring'; +import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { ContainerType } from '@src/generic/key-utils'; +import { ContentType } from '@src/library-authoring/routes'; +import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons'; +import { XBlock } from '@src/data/types'; import messages from './messages'; -import { ComponentPicker } from '../../library-authoring'; -import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; -import { ContainerType } from '../../generic/key-utils'; -import { ContentType } from '../../library-authoring/routes'; -import { getStudioHomeData } from '../../studio-home/data/selectors'; + +interface SubsectionCardProps { + section: XBlock, + subsection: XBlock, + children: ReactNode + isSectionsExpanded: boolean, + isSelfPaced: boolean, + isCustomRelativeDatesActive: boolean, + onOpenPublishModal: () => void, + onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, + savingStatus: string, + onOpenDeleteModal: () => void, + onDuplicateSubmit: () => void, + onNewUnitSubmit: (subsectionId: string) => void, + onAddUnitFromLibrary: (options: { + type: string, + category?: string, + parentLocator: string, + displayName?: string, + boilerplate?: string, + stagedContent?: string, + libraryContentKey: string, + }) => void, + index: number, + getPossibleMoves: (index: number, step: number) => void, + onOrderChange: (section: XBlock, moveDetails: any) => void, + onOpenConfigureModal: () => void, + onPasteClick: (parentLocator: string, sectionId: string) => void, + resetScrollState: () => void, +} const SubsectionCard = ({ section, @@ -47,7 +75,8 @@ const SubsectionCard = ({ onOrderChange, onOpenConfigureModal, onPasteClick, -}) => { + resetScrollState, +}: SubsectionCardProps) => { const currentRef = useRef(null); const intl = useIntl(); const dispatch = useDispatch(); @@ -58,12 +87,6 @@ const SubsectionCard = ({ const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); - // WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below, - // as it has a useEffect that fetches course waffle flags whenever - // location.search is updated. Course search updates location.search when - // user types, which will then trigger the useEffect and reload the page. - // See https://github.com/openedx/frontend-app-authoring/pull/1938. - const { librariesV2Enabled } = useSelector(getStudioHomeData); const [ isAddLibraryUnitModalOpen, openAddLibraryUnitModal, @@ -121,7 +144,7 @@ const SubsectionCard = ({ dispatch(setCurrentItem(subsection)); }; - const handleEditSubmit = (titleValue) => { + const handleEditSubmit = (titleValue: string) => { if (displayName !== titleValue) { onEditSubmit(id, section.id, titleValue); return; @@ -167,12 +190,11 @@ const SubsectionCard = ({ useEffect(() => { // if this items has been newly added, scroll to it. - // we need to check section.shouldScroll as whole section is fetched when a - // subsection is duplicated under it. - if (currentRef.current && (section.shouldScroll || subsection.shouldScroll || isScrolledToElement)) { + if (currentRef.current && (subsection.shouldScroll || isScrolledToElement)) { // Align element closer to the top of the screen if scrolling for search result const alignWithTop = !!isScrolledToElement; - scrollToElement(currentRef.current, alignWithTop); + scrollToElement(currentRef.current, alignWithTop, true); + resetScrollState(); } }, [isScrolledToElement]); @@ -194,7 +216,7 @@ const SubsectionCard = ({ && !(isHeaderVisible === false) ); - const handleSelectLibraryUnit = useCallback((selectedUnit) => { + const handleSelectLibraryUnit = useCallback((selectedUnit: SelectedComponent) => { onAddUnitFromLibrary({ type: COMPONENT_TYPES.libraryV2, category: ContainerType.Vertical, @@ -202,7 +224,7 @@ const SubsectionCard = ({ libraryContentKey: selectedUnit.usageKey, }); closeAddLibraryUnitModal(); - }, []); + }, [id, onAddUnitFromLibrary, closeAddLibraryUnitModal]); return ( <> @@ -265,36 +287,19 @@ const SubsectionCard = ({ {children} {actions.childAddable && ( <> - + {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && ( )} - {librariesV2Enabled && ( - - )} )} @@ -320,60 +325,4 @@ const SubsectionCard = ({ ); }; -SubsectionCard.defaultProps = { - children: null, -}; - -SubsectionCard.propTypes = { - section: PropTypes.shape({ - id: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - published: PropTypes.bool.isRequired, - hasChanges: PropTypes.bool.isRequired, - visibilityState: PropTypes.string.isRequired, - shouldScroll: PropTypes.bool, - }).isRequired, - subsection: PropTypes.shape({ - id: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - category: PropTypes.string.isRequired, - published: PropTypes.bool.isRequired, - hasChanges: PropTypes.bool.isRequired, - visibilityState: PropTypes.string.isRequired, - shouldScroll: PropTypes.bool, - enableCopyPasteUnits: PropTypes.bool, - proctoringExamConfigurationLink: PropTypes.string, - actions: PropTypes.shape({ - deletable: PropTypes.bool.isRequired, - draggable: PropTypes.bool.isRequired, - childAddable: PropTypes.bool.isRequired, - duplicable: PropTypes.bool.isRequired, - }).isRequired, - isHeaderVisible: PropTypes.bool, - childInfo: PropTypes.shape({ - children: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - }), - ).isRequired, - }).isRequired, - }).isRequired, - children: PropTypes.node, - isSectionsExpanded: PropTypes.bool.isRequired, - isSelfPaced: PropTypes.bool.isRequired, - isCustomRelativeDatesActive: PropTypes.bool.isRequired, - onOpenPublishModal: PropTypes.func.isRequired, - onEditSubmit: PropTypes.func.isRequired, - savingStatus: PropTypes.string.isRequired, - onOpenDeleteModal: PropTypes.func.isRequired, - onDuplicateSubmit: PropTypes.func.isRequired, - onNewUnitSubmit: PropTypes.func.isRequired, - onAddUnitFromLibrary: PropTypes.func.isRequired, - index: PropTypes.number.isRequired, - getPossibleMoves: PropTypes.func.isRequired, - onOrderChange: PropTypes.func.isRequired, - onOpenConfigureModal: PropTypes.func.isRequired, - onPasteClick: PropTypes.func.isRequired, -}; - export default SubsectionCard; diff --git a/src/course-outline/subsection-card/messages.js b/src/course-outline/subsection-card/messages.js index d932382d4..b4d251fb4 100644 --- a/src/course-outline/subsection-card/messages.js +++ b/src/course-outline/subsection-card/messages.js @@ -1,21 +1,11 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - newUnitButton: { - id: 'course-authoring.course-outline.subsection.button.new-unit', - defaultMessage: 'New unit', - description: 'Message of the button to create a new unit in a subsection.', - }, pasteButton: { id: 'course-authoring.course-outline.subsection.button.paste-unit', defaultMessage: 'Paste unit', description: 'Message of the button to paste a new unit in a subsection.', }, - useUnitFromLibraryButton: { - id: 'course-authoring.course-outline.subsection.button.use-unit-from-library', - defaultMessage: 'Use unit from library', - description: 'Message of the button to add a new unit from a library in a subsection.', - }, unitPickerModalTitle: { id: 'course-authoring.course-outline.subsection.unit.modal.single-title.text', defaultMessage: 'Select unit', diff --git a/src/course-outline/unit-card/UnitCard.test.jsx b/src/course-outline/unit-card/UnitCard.test.tsx similarity index 77% rename from src/course-outline/unit-card/UnitCard.test.jsx rename to src/course-outline/unit-card/UnitCard.test.tsx index 663fa59c2..06f8c0a60 100644 --- a/src/course-outline/unit-card/UnitCard.test.jsx +++ b/src/course-outline/unit-card/UnitCard.test.tsx @@ -1,21 +1,15 @@ import { - act, render, fireEvent, within, screen, - 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + act, fireEvent, initializeMocks, render, screen, waitFor, within, +} from '@src/testUtils'; -import initializeStore from '../../store'; +import { XBlock } from '@src/data/types'; import UnitCard from './UnitCard'; import cardMessages from '../card-header/messages'; -let store; const mockUseAcceptLibraryBlockChanges = jest.fn(); const mockUseIgnoreLibraryBlockChanges = jest.fn(); -jest.mock('../../course-unit/data/apiHooks', () => ({ +jest.mock('@src/course-unit/data/apiHooks', () => ({ useAcceptLibraryBlockChanges: () => ({ mutateAsync: mockUseAcceptLibraryBlockChanges, }), @@ -31,7 +25,7 @@ const section = { visibilityState: 'live', hasChanges: false, highlights: ['highlight 1', 'highlight 2'], -}; +} as XBlock; const subsection = { id: '12', @@ -39,7 +33,7 @@ const subsection = { published: true, visibilityState: 'live', hasChanges: false, -}; +} as XBlock; const unit = { id: '123', @@ -60,49 +54,36 @@ const unit = { upstreamRef: 'lct:org1:lib1:unit:1', versionSynced: 1, }, -}; +} as XBlock; -const queryClient = new QueryClient(); - -const renderComponent = (props) => render( - - - - `/some/${id}`} - isSelfPaced={false} - isCustomRelativeDatesActive={false} - {...props} - /> - - - , +const renderComponent = (props?: object) => render( + `/some/${id}`} + isSelfPaced={false} + isCustomRelativeDatesActive={false} + discussionsSettings={{ + providerType: '', + enableGradedUnits: false, + }} + {...props} + />, ); describe('', () => { beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - - store = initializeStore(); + initializeMocks(); }); it('render UnitCard component correctly', async () => { diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.tsx similarity index 62% rename from src/course-outline/unit-card/UnitCard.jsx rename to src/course-outline/unit-card/UnitCard.tsx index 25c9bfd5b..9b1a1a85d 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -1,28 +1,49 @@ -// @ts-check -import React, { +import { useCallback, useEffect, useMemo, useRef, } from 'react'; -import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; import { useToggle } from '@openedx/paragon'; import { isEmpty } from 'lodash'; import { useSearchParams } from 'react-router-dom'; -import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; -import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; -import { fetchCourseSectionQuery } from '../data/thunk'; -import { RequestStatus } from '../../data/constants'; -import { isUnitReadOnly } from '../../course-unit/data/utils'; -import CardHeader from '../card-header/CardHeader'; -import SortableItem from '../drag-helper/SortableItem'; -import TitleLink from '../card-header/TitleLink'; -import XBlockStatus from '../xblock-status/XBlockStatus'; -import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; -import { useClipboard } from '../../generic/clipboard'; -import { PreviewLibraryXBlockChanges } from '../../course-unit/preview-changes'; +import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; +import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; +import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; +import { RequestStatus } from '@src/data/constants'; +import { isUnitReadOnly } from '@src/course-unit/data/utils'; +import CardHeader from '@src/course-outline/card-header/CardHeader'; +import SortableItem from '@src/course-outline/drag-helper/SortableItem'; +import TitleLink from '@src/course-outline/card-header/TitleLink'; +import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus'; +import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils'; +import { useClipboard } from '@src/generic/clipboard'; +import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; +import { XBlock } from '@src/data/types'; + +interface UnitCardProps { + unit: XBlock; + subsection: XBlock; + section: XBlock; + onOpenPublishModal: () => void; + onOpenConfigureModal: () => void; + onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, + savingStatus: string; + onOpenDeleteModal: () => void; + onDuplicateSubmit: () => void; + getTitleLink: (locator: string) => string; + index: number; + getPossibleMoves: (index: number, step: number) => void, + onOrderChange: (section: XBlock, moveDetails: any) => void, + isSelfPaced: boolean; + isCustomRelativeDatesActive: boolean; + discussionsSettings: { + providerType: string; + enableGradedUnits: boolean; + }; +} const UnitCard = ({ unit, @@ -41,7 +62,7 @@ const UnitCard = ({ getTitleLink, onOrderChange, discussionsSettings, -}) => { +}: UnitCardProps) => { const currentRef = useRef(null); const dispatch = useDispatch(); const [searchParams] = useSearchParams(); @@ -68,7 +89,7 @@ const UnitCard = ({ } = unit; const blockSyncData = useMemo(() => { - if (!upstreamInfo.readyToSync) { + if (!upstreamInfo?.readyToSync) { return undefined; } return { @@ -108,7 +129,7 @@ const UnitCard = ({ dispatch(setCurrentSubsection(subsection)); }; - const handleEditSubmit = (titleValue) => { + const handleEditSubmit = (titleValue: string) => { if (displayName !== titleValue) { onEditSubmit(id, section.id, titleValue); return; @@ -129,8 +150,8 @@ const UnitCard = ({ copyToClipboard(id); }; - const handleOnPostChangeSync = useCallback(async () => { - await dispatch(fetchCourseSectionQuery([section.id])); + const handleOnPostChangeSync = useCallback(() => { + dispatch(fetchCourseSectionQuery([section.id])); }, [dispatch, section]); const titleComponent = ( @@ -151,12 +172,10 @@ const UnitCard = ({ useEffect(() => { // if this items has been newly added, scroll to it. - // we need to check section.shouldScroll as whole section is fetched when a - // unit is duplicated under it. - if (currentRef.current && (section.shouldScroll || unit.shouldScroll || isScrolledToElement)) { + if (currentRef.current && (unit.shouldScroll || isScrolledToElement)) { // Align element closer to the top of the screen if scrolling for search result const alignWithTop = !!isScrolledToElement; - scrollToElement(currentRef.current, alignWithTop); + scrollToElement(currentRef.current, alignWithTop, true); } }, [isScrolledToElement]); @@ -218,7 +237,7 @@ const UnitCard = ({ discussionsSettings={discussionsSettings} parentInfo={parentInfo} extraActionsComponent={extraActionsComponent} - readyToSync={upstreamInfo.readyToSync} + readyToSync={upstreamInfo?.readyToSync} />
{ * @param {Object} target - DOM Element * @param {boolean} alignWithTop (optional) - Whether top of the target will be aligned to * the top of viewpoint. (default: false) + * @param {boolean} highlight (optional) - Whether highlight the target after scrolling. + * (default: false) * @returns {undefined} */ -const scrollToElement = (target, alignWithTop = false) => { +const scrollToElement = (target, alignWithTop = false, highlight = false) => { if (target.getBoundingClientRect().bottom > window.innerHeight) { // if alignWithTop is set, the top of the target will be aligned to the top of visible area // of the scrollable ancestor, Otherwise, the bottom of the target will be aligned to the @@ -186,6 +188,10 @@ const scrollToElement = (target, alignWithTop = false) => { // The top of the target will be aligned to the top of the visible area of the scrollable ancestor target.scrollIntoView({ behavior: 'smooth' }); } + + if (highlight && !target.classList.contains('highlight')) { + target.classList.add('highlight'); + } }; /** diff --git a/src/course-outline/xblock-status/StatusMessages.jsx b/src/course-outline/xblock-status/StatusMessages.tsx similarity index 69% rename from src/course-outline/xblock-status/StatusMessages.jsx rename to src/course-outline/xblock-status/StatusMessages.tsx index dd0fc5390..547cc8441 100644 --- a/src/course-outline/xblock-status/StatusMessages.jsx +++ b/src/course-outline/xblock-status/StatusMessages.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; import { @@ -7,8 +6,23 @@ import { Groups as GroupsIcon, } from '@openedx/paragon/icons'; +import { UserPartitionInfoTypes, XBlockPrereqs } from '@src/data/types'; import messages from './messages'; +interface StatusMessagesProps { + isVertical: boolean; + staffOnlyMessage?: boolean, + prereq?: string, + prereqs?: XBlockPrereqs[], + userPartitionInfo?: UserPartitionInfoTypes, + hasPartitionGroupComponents?: boolean, +} + +interface StatusMessagesText { + icon: React.ComponentType; + text: string; +} + const StatusMessages = ({ isVertical, staffOnlyMessage, @@ -16,13 +30,13 @@ const StatusMessages = ({ prereqs, userPartitionInfo, hasPartitionGroupComponents, -}) => { +}: StatusMessagesProps) => { const intl = useIntl(); - const statusMessages = []; + const statusMessages: StatusMessagesText[] = []; if (prereq) { let prereqDisplayName = ''; - prereqs.forEach((block) => { + prereqs?.forEach((block) => { if (block.blockUsageKey === prereq) { prereqDisplayName = block.blockDisplayName; } @@ -34,7 +48,7 @@ const StatusMessages = ({ } if (!staffOnlyMessage && isVertical) { - const { selectedPartitionIndex, selectedGroupsLabel } = userPartitionInfo; + const { selectedPartitionIndex, selectedGroupsLabel } = userPartitionInfo || {}; if (selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex)) { statusMessages.push({ icon: GroupsIcon, @@ -63,27 +77,4 @@ const StatusMessages = ({ return null; }; -StatusMessages.defaultProps = { - staffOnlyMessage: false, - prereq: '', - prereqs: [], - userPartitionInfo: {}, - hasPartitionGroupComponents: false, -}; - -StatusMessages.propTypes = { - isVertical: PropTypes.bool.isRequired, - staffOnlyMessage: PropTypes.bool, - prereq: PropTypes.string, - prereqs: PropTypes.arrayOf(PropTypes.shape({ - blockUsageKey: PropTypes.string.isRequired, - blockDisplayName: PropTypes.string.isRequired, - })), - userPartitionInfo: PropTypes.shape({ - selectedPartitionIndex: PropTypes.number, - selectedGroupsLabel: PropTypes.string, - }), - hasPartitionGroupComponents: PropTypes.bool, -}; - export default StatusMessages; diff --git a/src/course-outline/xblock-status/XBlockStatus.jsx b/src/course-outline/xblock-status/XBlockStatus.tsx similarity index 65% rename from src/course-outline/xblock-status/XBlockStatus.jsx rename to src/course-outline/xblock-status/XBlockStatus.tsx index 77e81e071..1f30f49ce 100644 --- a/src/course-outline/xblock-status/XBlockStatus.jsx +++ b/src/course-outline/xblock-status/XBlockStatus.tsx @@ -1,6 +1,5 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - +import { ShowAnswerTypesKeys } from '@src/editors/data/constants/problem'; +import { XBlock } from '@src/data/types'; import { COURSE_BLOCK_NAMES } from '../constants'; import ReleaseStatus from './ReleaseStatus'; import GradingPolicyAlert from './GradingPolicyAlert'; @@ -8,13 +7,18 @@ import GradingTypeAndDueDate from './GradingTypeAndDueDate'; import StatusMessages from './StatusMessages'; import HideAfterDueMessage from './HideAfterDueMessage'; import NeverShowAssessmentResultMessage from './NeverShowAssessmentResultMessage'; -import { ShowAnswerTypesKeys } from '../../editors/data/constants/problem'; + +interface XBlockStatusProps { + isSelfPaced: boolean; + isCustomRelativeDatesActive: boolean, + blockData: XBlock, +} const XBlockStatus = ({ isSelfPaced, isCustomRelativeDatesActive, blockData, -}) => { +}: XBlockStatusProps) => { const { category, explanatoryMessage, @@ -89,41 +93,4 @@ const XBlockStatus = ({ ); }; -XBlockStatus.defaultProps = { - isCustomRelativeDatesActive: false, -}; - -XBlockStatus.propTypes = { - isSelfPaced: PropTypes.bool.isRequired, - isCustomRelativeDatesActive: PropTypes.bool, - blockData: PropTypes.shape({ - category: PropTypes.string.isRequired, - explanatoryMessage: PropTypes.string, - releasedToStudents: PropTypes.bool, - releaseDate: PropTypes.string, - isProctoredExam: PropTypes.bool, - isOnboardingExam: PropTypes.bool, - isPracticeExam: PropTypes.bool, - prereq: PropTypes.string, - prereqs: PropTypes.arrayOf(PropTypes.shape({ - blockUsageKey: PropTypes.string.isRequired, - blockDisplayName: PropTypes.string.isRequired, - })), - staffOnlyMessage: PropTypes.bool, - userPartitionInfo: PropTypes.shape({ - selectedPartitionIndex: PropTypes.number, - selectedGroupsLabel: PropTypes.string, - }), - hasPartitionGroupComponents: PropTypes.bool, - format: PropTypes.string, - dueDate: PropTypes.string, - relativeWeeksDue: PropTypes.number, - isTimeLimited: PropTypes.bool, - graded: PropTypes.bool, - courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired), - hideAfterDue: PropTypes.bool, - showCorrectness: PropTypes.string, - }).isRequired, -}; - export default XBlockStatus; diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts index 084577d16..89ac1b79b 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -1,16 +1,4 @@ -export interface GroupTypes { - id: number; - name: string; - selected: boolean; - deleted: boolean; -} - -export interface UserPartitionTypes { - id: number; - name: string; - scheme: string; - groups: Array; -} +import { UserPartitionInfoTypes, UserPartitionTypes, XBlockPrereqs } from '@src/data/types'; export interface XBlockActionsTypes { canCopy: boolean; @@ -50,27 +38,6 @@ export interface XBlockContainerIframeProps { handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void; } -export type UserPartitionInfoTypes = { - selectablePartitions: Array<{ - groups: Array<{ - deleted: boolean; - id: number; - name: string; - selected: boolean; - }>; - id: number; - name: string; - scheme: string; - }>; - selectedPartitionIndex: number; - selectedGroupsLabel: string; -}; - -export type PrereqTypes = { - blockDisplayName: string; - blockUsageKey: string; -}; - export type AccessManagedXBlockDataTypes = { id: string; displayName?: string; @@ -88,7 +55,7 @@ export type AccessManagedXBlockDataTypes = { userPartitionInfo?: UserPartitionInfoTypes; ancestorHasStaffLock?: boolean; isPrereq?: boolean; - prereqs?: PrereqTypes[]; + prereqs?: XBlockPrereqs[]; prereq?: number; prereqMinScore?: number; prereqMinCompletion?: number; diff --git a/src/data/types.ts b/src/data/types.ts new file mode 100644 index 000000000..f7fedfd82 --- /dev/null +++ b/src/data/types.ts @@ -0,0 +1,110 @@ +export interface GroupTypes { + id: number; + name: string; + selected: boolean; + deleted: boolean; +} + +export interface UserPartitionTypes { + id: number; + name: string; + scheme: string; + groups: Array; +} + +export type UserPartitionInfoTypes = { + selectablePartitions: Array<{ + groups: Array<{ + deleted: boolean; + id: number; + name: string; + selected: boolean; + }>; + id: number; + name: string; + scheme: string; + }>; + selectedPartitionIndex: number; + selectedGroupsLabel: string; +}; + +export interface XBlockActions { + deletable: boolean; + draggable: boolean; + childAddable: boolean; + duplicable: boolean; + allowMoveDown?: boolean; + allowMoveUp?: boolean; +} + +export interface XblockChildInfo { + displayName: string; + children: Array; +} + +export interface XBlockPrereqs { + blockUsageKey: string; + blockDisplayName: string; +} + +export interface UpstreeamInfo { + readyToSync: boolean, + upstreamRef: string, + versionSynced: number, +} + +export interface XBlock { + id: string; + locator: string; + usageKey: string; + displayName: string; + category: string; + hasChildren: boolean; + editedOn: string; + published: boolean; + publishedOn: string; + studioUrl: string; + releasedToStudents: boolean; + releaseDate: string; + visibilityState: string; + hasExplicitStaffLock: boolean; + start: string; + graded: boolean; + dueDate: string; + due?: string; + relativeWeeksDue?: number; + format?: string; + courseGraders: string[]; + hasChanges: boolean; + actions: XBlockActions; + explanatoryMessage?: string; + userPartitions: UserPartitionTypes[]; + showCorrectness: string; + highlights: string[]; + highlightsEnabled: boolean; + highlightsPreviewOnly: boolean; + highlightsDocUrl: string; + childInfo: XblockChildInfo; + ancestorHasStaffLock: boolean; + staffOnlyMessage: boolean; + hasPartitionGroupComponents: boolean; + userPartitionInfo?: UserPartitionInfoTypes; + enableCopyPasteUnits: boolean; + shouldScroll: boolean; + isHeaderVisible: boolean; + proctoringExamConfigurationLink?: string; + isTimeLimited?: boolean; + defaultTimeLimitMinutes?: number; + hideAfterDue?: boolean; + isProctoredExam?: boolean; + isPracticeExam?: boolean; + isOnboardingExam?: boolean; + examReviewRules?: string; + isPrereq?: boolean; + prereq?: string; + prereqs?: XBlockPrereqs[]; + prereqMinScore?: number; + prereqMinCompletion?: number; + discussionEnabled?: boolean; + upstreamInfo?: UpstreeamInfo; +} diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index bfc13677c..4769bdb35 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -257,7 +257,8 @@ const LibraryAuthoringPage = ({ // or when inside a specific Section or Subsection. const onlyOneType = ( insideCollections || insideUnits || insideSections || insideSubsections - || insideSection || insideSubsection + || insideSection || insideSubsection + || !([ContentType.home, ContentType.components].includes(activeKey)) ); const overrideTypesFilter = onlyOneType ? new TypesFilterData() diff --git a/src/library-authoring/index.tsx b/src/library-authoring/index.tsx index 91100f090..6c519f3c2 100644 --- a/src/library-authoring/index.tsx +++ b/src/library-authoring/index.tsx @@ -1,5 +1,6 @@ export { default as LibraryLayout } from './LibraryLayout'; export { ComponentPicker } from './component-picker'; +export { type SelectedComponent } from './common/context/ComponentPickerContext'; export { CreateLibrary } from './create-library'; export { libraryAuthoringQueryKeys, useContentLibraryV2List } from './data/apiHooks'; export { default as PreviewChangesEmbed } from './legacy-integration/PreviewChangesEmbed'; diff --git a/webpack.prod.config.js b/webpack.prod.config.js index c5e13b041..78bc2606c 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -4,6 +4,8 @@ const { createConfig } = require('@openedx/frontend-build'); const config = createConfig('webpack-prod', { resolve: { alias: { + // Within this app, we can use '@src/foo instead of relative URLs like '../../../foo' + '@src': path.resolve(__dirname, 'src/'), // Plugins can use 'CourseAuthoring' as an import alias for this app: CourseAuthoring: path.resolve(__dirname, 'src/'), },