diff --git a/src/course-outline/outline-sidebar/AddSidebar.tsx b/src/course-outline/outline-sidebar/AddSidebar.tsx index a84cc17d14..f6a2dcdd34 100644 --- a/src/course-outline/outline-sidebar/AddSidebar.tsx +++ b/src/course-outline/outline-sidebar/AddSidebar.tsx @@ -7,9 +7,9 @@ import contentMessages from '@src/library-authoring/add-content/messages'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { SidebarFilters } from '@src/library-authoring/library-filters/SidebarFilters'; import { - Button, Icon, Stack, Tab, Tabs, + Stack, Tab, Tabs, } from '@openedx/paragon'; -import { getIconBorderStyleColor, getItemIcon } from '@src/generic/block-type-utils'; +import { getItemIcon } from '@src/generic/block-type-utils'; import { useCallback, useEffect, useMemo, useState, } from 'react'; @@ -20,6 +20,7 @@ import { ContentType } from '@src/library-authoring/routes'; import { ComponentPicker } from '@src/library-authoring'; import { MultiLibraryProvider } from '@src/library-authoring/common/context/MultiLibraryContext'; import { COURSE_BLOCK_NAMES } from '@src/constants'; +import { BlockCardButton } from '@src/generic/sidebar/BlockCardButton'; import AlertMessage from '@src/generic/alert-message'; import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import { useOutlineSidebarContext } from './OutlineSidebarContext'; @@ -157,19 +158,12 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => { const disabled = handleAddSection.isPending || handleAddSubsection.isPending || handleAddAndOpenUnit.isPending; return ( - + /> ); }; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 8dd56ea400..245b84fa5a 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -1,3 +1,4 @@ +import fetchMock from 'fetch-mock-jest'; import userEvent from '@testing-library/user-event'; import { camelCaseObject, @@ -16,6 +17,7 @@ import { within, screen, } from '@src/testUtils'; +import mockResult from '@src/library-authoring/__mocks__/library-search.json'; import { IFRAME_FEATURE_POLICY } from '@src/constants'; import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import pasteComponentMessages from '@src/generic/clipboard/paste-component/messages'; @@ -23,7 +25,13 @@ import { getClipboardUrl } from '@src/generic/data/api'; import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { + mockContentLibrary, + mockGetContentLibraryV2List, + mockLibraryBlockMetadata, +} from '@src/library-authoring/data/api.mocks'; +import { mockContentSearchConfig } from '@src/search-manager/data/api.mock'; import { getCourseSectionVerticalApiUrl, getCourseVerticalChildrenApiUrl, @@ -77,6 +85,10 @@ const unitDisplayName = courseSectionVerticalMock.xblock_info.display_name; const mockedUsedNavigate = jest.fn(); const userName = 'openedx'; const handleConfigureSubmitMock = jest.fn(); +mockContentSearchConfig.applyMock(); +mockContentLibrary.applyMock(); +mockGetContentLibraryV2List.applyMock(); +mockLibraryBlockMetadata.applyMock(); const { block_id: id, @@ -95,6 +107,14 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedUsedNavigate, })); +jest.mock('@src/studio-home/hooks', () => ({ + useStudioHome: () => ({ + isLoadingPage: false, + isFailedLoadingPage: false, + librariesV2Enabled: true, + }), +})); + /** * Simulates receiving a post message event for testing purposes. * This can be used to mimic events like deletion or other actions @@ -2910,4 +2930,305 @@ describe('', () => { render(); expect(await screen.findByText('Access: 3 Groups')).toBeInTheDocument(); }); + + describe('Add sidebar', () => { + let user; + + const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; + const searchResult = { + ...mockResult, + results: [ + { + ...mockResult.results[0], + hits: mockResult.results[0].hits.slice(0, 10), + }, + { + ...mockResult.results[1], + }, + ], + }; + + beforeEach(async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + + // The Meilisearch client-side API uses fetch, not Axios. + fetchMock.mockReset(); + fetchMock.post(searchEndpoint, (_url, req) => { + const requestData = JSON.parse((req.body ?? '')); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise Instantsearch will update the UI and change the query, + // leading to unexpected results in the test cases. + const newMockResult = { ...searchResult }; + newMockResult.results[0].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + return newMockResult; + }); + + axiosMock + .onPost(postXBlockBaseApiUrl()) + .reply(200, courseCreateXblockMock); + + user = userEvent.setup(); + render(); + + // Moving to the add sidebar + const sidebarToggle = await screen.findByTestId('sidebar-toggle'); + expect(sidebarToggle).toBeInTheDocument(); + const addButton = within(sidebarToggle).getByRole('button', { name: 'Add' }); + expect(addButton).toBeInTheDocument(); + await user.click(addButton); + }); + + it('renders the add sidebar component without any errors', async () => { + // Check add new tab content + expect(await screen.findByRole('button', { name: 'Video' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Drag Drop' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Add New' })).toBeInTheDocument(); + const textCollapsible = screen.getByTestId('html-collapsible'); + expect(textCollapsible).toBeInTheDocument(); + const openResponseCollapsible = screen.getByTestId('openassessment-collapsible'); + expect(openResponseCollapsible).toBeInTheDocument(); + const problemCollapsible = screen.getByTestId('problem-collapsible'); + expect(problemCollapsible).toBeInTheDocument(); + + // Check text templates + await user.click(within(textCollapsible).getByText(/text/i)); + expect(within(textCollapsible).getByText('Raw HTML')); + expect(within(textCollapsible).getByText('IFrame Tool')); + expect(within(textCollapsible).getByText('Anonymous User ID')); + expect(within(textCollapsible).getByText('Announcement')); + + // Check Open response templates + await user.click(within(openResponseCollapsible).getByText(/open response/i)); + expect(within(openResponseCollapsible).getByText('Peer Assessment Only')); + expect(within(openResponseCollapsible).getByText('Self Assessment Only')); + expect(within(openResponseCollapsible).getByText('Staff Assessment Only')); + expect(within(openResponseCollapsible).getByText('Self Assessment to Peer Assessment')); + expect(within(openResponseCollapsible).getByText('Self Assessment to Staff Assessment')); + + // Check problem templates + await user.click(within(problemCollapsible).getByText(/problem/i)); + expect(within(problemCollapsible).getByText('Single select')); + expect(within(problemCollapsible).getByText('Multi-select')); + expect(within(problemCollapsible).getByText('Dropdown')); + expect(within(problemCollapsible).getByText('Text input')); + expect(within(problemCollapsible).getByText('Advanced Problem')); + + // Check Advanced blocks + const advancedButton = screen.getByRole('button', { name: 'Advanced' }); + expect(advancedButton).toBeInTheDocument(); + await user.click(advancedButton); + expect(await screen.findByRole('button', { name: 'Annotation' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Video' })).toBeInTheDocument(); + const backButton = screen.getByRole('button', { name: 'Back' }); + expect(backButton).toBeInTheDocument(); + await user.click(backButton); + expect(await screen.findByRole('button', { name: 'Advanced' })).toBeInTheDocument(); + + // Check existing tab content + const existingTab = screen.getByRole('tab', { name: 'Add Existing' }); + expect(existingTab).toBeInTheDocument(); + await user.click(existingTab); + expect(await screen.findByRole('button', { name: 'All libraries' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'See more' })).toBeInTheDocument(); + expect(screen.getByRole('search')).toBeInTheDocument(); + }); + + [ + { + name: 'Video', + blockType: 'video', + }, + { + name: 'Drag Drop', + blockType: 'drag-and-drop-v2', + }, + ].forEach(({ name, blockType }) => { + it(`calls appropriate handlers on new button click for ${name} block`, async () => { + const blockButton = await screen.findByRole('button', { name }); + expect(blockButton).toBeInTheDocument(); + + await user.click(blockButton); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + }); + expect(axiosMock.history.post[0].url).toBe(postXBlockBaseApiUrl()); + expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({ + category: blockType, + parent_locator: blockId, + type: blockType, + }); + }); + }); + + [ + { + name: 'Text', + blockType: 'html', + templates: [ + { + name: 'Raw HTML', + boilerplate: 'raw.yaml', + }, + { + name: 'IFrame Tool', + boilerplate: 'iframe.yaml', + }, + { + name: 'Anonymous User ID', + boilerplate: 'anon_user_id.yaml', + }, + { + name: 'Announcement', + boilerplate: 'announcement.yaml', + }, + ], + }, + { + name: 'Open Response', + blockType: 'openassessment', + templates: [ + { + name: 'Peer Assessment Only', + boilerplate: 'peer-assessment', + }, + { + name: 'Self Assessment Only', + boilerplate: 'self-assessment', + }, + { + name: 'Staff Assessment Only', + boilerplate: 'staff-assessment', + }, + { + name: 'Self Assessment to Peer Assessment', + boilerplate: 'self-to-peer', + }, + { + name: 'Self Assessment to Staff Assessment', + boilerplate: 'self-to-staff', + }, + ], + }, + ].forEach(({ name, blockType, templates }) => { + templates.forEach((template) => { + it(`calls appropriate handlers on new button click for ${name} block with ${template.name} template`, async () => { + const collapsible = screen.getByTestId(`${blockType}-collapsible`); + expect(collapsible).toBeInTheDocument(); + await user.click(within(collapsible).getByText(name)); + const templateButton = within(collapsible).getByText(template.name); + expect(templateButton).toBeInTheDocument(); + await user.click(templateButton); + + await waitFor(() => { + expect(axiosMock.history.post[0].url).toBe(postXBlockBaseApiUrl()); + }); + + expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({ + category: blockType, + parent_locator: blockId, + boilerplate: template.boilerplate, + ...(blockType !== 'openassessment' ? { type: blockType } : {}), + }); + }); + }); + }); + + [ + { + name: 'Annotation', + blockType: 'annotatable', + }, + { + name: 'Video', + blockType: 'videoalpha', + }, + ].forEach(({ name, blockType }) => { + it(`calls appropriate handlers on new button click for Advanced ${name} block`, async () => { + const advancedButton = await screen.findByRole('button', { name: 'Advanced' }); + expect(advancedButton).toBeInTheDocument(); + await user.click(advancedButton); + + const blockButton = await screen.findByRole('button', { name }); + expect(blockButton).toBeInTheDocument(); + await user.click(blockButton); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + }); + expect(axiosMock.history.post[0].url).toBe(postXBlockBaseApiUrl()); + expect(JSON.parse(axiosMock.history.post[0].data)).toMatchObject({ + category: blockType, + parent_locator: blockId, + type: blockType, + }); + }); + }); + + it('calls appropriate handlers on existing button click', async () => { + // Check existing tab content + await user.click(await screen.findByRole('tab', { name: 'Add Existing' })); + + // Add text + const textCard = await screen.findByText(/introduction to testing/i); + expect(textCard).toBeInTheDocument(); + await user.click(textCard); + const addButton = await screen.findByRole('button', { name: 'Add to Course' }); + expect(addButton).toBeInTheDocument(); + await user.click(addButton); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + }); + expect(axiosMock.history.post[0].url).toBe(postXBlockBaseApiUrl()); + expect(JSON.parse(axiosMock.history.post[0].data)).toEqual({ + category: 'html', + parent_locator: blockId, + library_content_key: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', + type: 'library_v2', + }); + }); + }); + + it('not render add sidebar in units from libraries (read-only)', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + render(); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(courseId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + upstreamInfo: { + ...courseSectionVerticalMock.xblock_info, + upstreamRef: 'lct:org:lib:unit:unit-1', + upstreamLink: 'some-link', + }, + }, + }); + await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch); + + expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument(); + + // Does not render the "Add Components" section + expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument(); + + // Does not render the Add button in the header to open the add sidebar + expect(screen.queryByText('Add')).not.toBeInTheDocument(); + + // Does not render the Add button in the navbar. + const sidebarToggle = await screen.findByTestId('sidebar-toggle'); + expect(sidebarToggle).toBeInTheDocument(); + expect(within(sidebarToggle).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + }); }); diff --git a/src/course-unit/CourseUnit.tsx b/src/course-unit/CourseUnit.tsx index 20771737a7..f60338790c 100644 --- a/src/course-unit/CourseUnit.tsx +++ b/src/course-unit/CourseUnit.tsx @@ -40,7 +40,7 @@ import AddComponent from './add-component/AddComponent'; import HeaderTitle from './header-title/HeaderTitle'; import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import Sequence from './course-sequence'; -import { useCourseUnit, useScrollToLastPosition } from './hooks'; +import { useCourseUnit, useHandleCreateNewCourseXBlock, useScrollToLastPosition } from './hooks'; import messages from './messages'; import { PasteNotificationAlert } from './clipboard'; import XBlockContainerIframe from './xblock-container-iframe'; @@ -200,7 +200,6 @@ const CourseUnit = () => { handleTitleEditSubmit, headerNavigationsActions, handleTitleEdit, - handleCreateNewCourseXBlock, handleConfigureSubmit, courseVerticalChildren, canPasteComponent, @@ -214,6 +213,8 @@ const CourseUnit = () => { addComponentTemplateData, } = useCourseUnit({ courseId, blockId }); + const handleCreateNewCourseXBlock = useHandleCreateNewCourseXBlock({ blockId }); + const readOnly = !!courseUnit.readOnly; useEffect(() => { @@ -240,7 +241,7 @@ const CourseUnit = () => { } return ( - +
@@ -360,6 +361,17 @@ const CourseUnit = () => { handleConfigureSubmit={handleConfigureSubmit} /> )} + {!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData + && /* istanbul ignore next */ ( + handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }) + } + text={intl.formatMessage(messages.pasteButtonText)} + /> + )} {!readOnly && blockId && ( { addComponentTemplateData={addComponentTemplateData} /> )} - {!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData && ( - handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }) - } - text={intl.formatMessage(messages.pasteButtonText)} - /> - )} render( , ); +jest.mock('../unit-sidebar/UnitSidebarContext', () => ({ + useUnitSidebarContext: () => ({ + readOnly: false, + setCurrentPageKey: mockSetCurrentPageKey, + }), +})); + describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + it('render HeaderNavigations component correctly', () => { - const { getByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); + renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); - expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.previewButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.previewButton.defaultMessage })).toBeInTheDocument(); }); - it('calls the correct handlers when clicking buttons for unit page', () => { - const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); + it('calls the correct handlers when clicking buttons for unit page', async () => { + const user = userEvent.setup(); + renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); - const viewLiveButton = getByRole('button', { name: messages.viewLiveButton.defaultMessage }); - fireEvent.click(viewLiveButton); + const viewLiveButton = screen.getByRole('button', { name: messages.viewLiveButton.defaultMessage }); + await user.click(viewLiveButton); expect(handleViewLiveFn).toHaveBeenCalledTimes(1); - const previewButton = getByRole('button', { name: messages.previewButton.defaultMessage }); - fireEvent.click(previewButton); + const previewButton = screen.getByRole('button', { name: messages.previewButton.defaultMessage }); + await user.click(previewButton); expect(handlePreviewFn).toHaveBeenCalledTimes(1); - const editButton = queryByRole('button', { name: messages.editButton.defaultMessage }); + const editButton = screen.queryByRole('button', { name: messages.editButton.defaultMessage }); expect(editButton).not.toBeInTheDocument(); }); ['libraryContent', 'splitTest'].forEach((category) => { - it(`calls the correct handlers when clicking buttons for ${category} page`, () => { - const { getByRole, queryByRole } = renderComponent({ category: COURSE_BLOCK_NAMES[category].id }); + it(`calls the correct handlers when clicking buttons for ${category} page`, async () => { + const user = userEvent.setup(); + renderComponent({ category: COURSE_BLOCK_NAMES[category].id }); - const editButton = getByRole('button', { name: messages.editButton.defaultMessage }); - fireEvent.click(editButton); - expect(handleViewLiveFn).toHaveBeenCalledTimes(1); + const editButton = await screen.findByRole('button', { name: messages.editButton.defaultMessage }); + await user.click(editButton); + expect(handleEditFn).toHaveBeenCalledTimes(1); [messages.viewLiveButton.defaultMessage, messages.previewButton.defaultMessage].forEach((btnName) => { - expect(queryByRole('button', { name: btnName })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: btnName })).not.toBeInTheDocument(); }); }); }); + + it('click Info button should open info sidebar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + + const user = userEvent.setup(); + renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); + + const infoButton = screen.getByRole('button', { name: /unit info/i }); + expect(infoButton).toBeInTheDocument(); + await user.click(infoButton); + + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('info'); + }); + + it('click Add button should open add sidebar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); + + const user = userEvent.setup(); + renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id }); + + const addButton = screen.getByRole('button', { name: /add/i }); + expect(addButton).toBeInTheDocument(); + await user.click(addButton); + + expect(mockSetCurrentPageKey).toHaveBeenCalledWith('add'); + }); }); diff --git a/src/course-unit/header-navigations/HeaderNavigations.tsx b/src/course-unit/header-navigations/HeaderNavigations.tsx index 17c726c4e5..a0985b2f9a 100644 --- a/src/course-unit/header-navigations/HeaderNavigations.tsx +++ b/src/course-unit/header-navigations/HeaderNavigations.tsx @@ -9,6 +9,7 @@ import { COURSE_BLOCK_NAMES } from '@src/constants'; import messages from './messages'; import { isUnitPageNewDesignEnabled } from '../utils'; +import { useUnitSidebarContext } from '../unit-sidebar/UnitSidebarContext'; type HeaderNavigationActions = { handleViewLive: () => void; @@ -35,6 +36,8 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat handleEdit, } = headerNavigationsActions; + const { setCurrentPageKey, readOnly } = useUnitSidebarContext(); + const showNewDesignButtons = isUnitPageNewDesignEnabled(); return ( @@ -49,15 +52,19 @@ const HeaderNavigations = ({ headerNavigationsActions, category }: HeaderNavigat - + {!readOnly && ( + + )} )} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.tsx similarity index 92% rename from src/course-unit/hooks.jsx rename to src/course-unit/hooks.tsx index 0a58e22599..4103f7c87c 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.tsx @@ -7,6 +7,7 @@ import { useToggle } from '@openedx/paragon'; import { camelCaseObject } from '@edx/frontend-platform/utils'; import { useUnlinkDownstream } from '@src/generic/unlink-modal'; +import { DeprecatedReduxState } from '@src/store'; import { RequestStatus } from '@src/data/constants'; import { useClipboard } from '@src/generic/clipboard'; import { useEventListener } from '@src/generic/hooks'; @@ -45,7 +46,10 @@ import { updateQueryPendingStatus, } from './data/slice'; -export const useCourseUnit = ({ courseId, blockId }) => { +export const useCourseUnit = ({ + courseId, + blockId, +}: { courseId: string, blockId: string }) => { const dispatch = useDispatch(); const [searchParams] = useSearchParams(); const { sendMessageToIframe } = useIframe(); @@ -61,7 +65,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const courseVerticalChildren = useSelector(getCourseVerticalChildren); const staticFileNotices = useSelector(getStaticFileNotices); const navigate = useNavigate(); - const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen); + const isTitleEditFormOpen = useSelector((state: DeprecatedReduxState) => state.courseUnit.isTitleEditFormOpen); const canEdit = useSelector(getCanEdit); const courseOutlineInfo = useSelector(getCourseOutlineInfo); const movedXBlockParams = useSelector(getMovedXBlockParams); @@ -132,10 +136,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { } }; - const handleCreateNewCourseXBlock = (body, callback) => ( - dispatch(createNewCourseXBlock(body, callback, blockId, sendMessageToIframe)) - ); - const { mutateAsync: unlinkDownstream } = useUnlinkDownstream(); const unitXBlockActions = { @@ -266,7 +266,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { headerNavigationsActions, handleTitleEdit, handleTitleEditSubmit, - handleCreateNewCourseXBlock, handleConfigureSubmit, courseVerticalChildren, canPasteComponent, @@ -282,6 +281,17 @@ export const useCourseUnit = ({ courseId, blockId }) => { }; }; +export const useHandleCreateNewCourseXBlock = ({ blockId }: { blockId: string }) => { + const dispatch = useDispatch(); + const { sendMessageToIframe } = useIframe(); + + // oxlint-disable typescript-eslint(await-thenable) + return async (body: object, callback?: (args: { courseKey: string, locator: string }) => void) => ( + // eslint-disable-next-line @typescript-eslint/return-await + await dispatch(createNewCourseXBlock(body, callback, blockId, sendMessageToIframe)) + ); +}; + /** * Custom hook that restores the scroll position from `localStorage` after a page reload. * It listens for a `plugin.resize` message event and scrolls the window to the saved position @@ -291,7 +301,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { * The key used to store the last scroll position in `localStorage`. */ export const useScrollToLastPosition = (storageKey = 'createXBlockLastYPosition') => { - const timeoutRef = useRef(null); + const timeoutRef = useRef | null>(null); const [hasLastPosition, setHasLastPosition] = useState(() => !!localStorage.getItem(storageKey)); const scrollToLastPosition = useCallback(() => { @@ -314,7 +324,6 @@ export const useScrollToLastPosition = (storageKey = 'createXBlockLastYPosition' if (timeoutRef.current) { clearTimeout(timeoutRef.current); } - timeoutRef.current = setTimeout(scrollToLastPosition, 1000); } }, [scrollToLastPosition]); diff --git a/src/course-unit/unit-sidebar/AddSidebar.tsx b/src/course-unit/unit-sidebar/AddSidebar.tsx new file mode 100644 index 0000000000..bfea7dedbc --- /dev/null +++ b/src/course-unit/unit-sidebar/AddSidebar.tsx @@ -0,0 +1,367 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { + Button, + Icon, + Stack, StandardModal, Tab, Tabs, useToggle, +} from '@openedx/paragon'; +import { ChevronLeft, ChevronRight } from '@openedx/paragon/icons'; +import { getConfig } from '@edx/frontend-platform'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; + +import { getItemIcon } from '@src/generic/block-type-utils'; +import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; +import { MultiLibraryProvider } from '@src/library-authoring/common/context/MultiLibraryContext'; +import { ComponentPicker, SelectedComponent } from '@src/library-authoring'; +import { ContentType } from '@src/library-authoring/routes'; +import { SidebarFilters } from '@src/library-authoring/library-filters/SidebarFilters'; +import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; +import { BlockCardButton, BlockTemplate } from '@src/generic/sidebar/BlockCardButton'; +import { useWaffleFlags } from '@src/data/apiHooks'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import EditorPage from '@src/editors/EditorPage'; +import VideoSelectorPage from '@src/editors/VideoSelectorPage'; +import { useIframe } from '@src/generic/hooks/context/hooks'; +import { ProblemTypeKeys } from '@src/editors/data/constants/problem'; +import problemMessages from '@src/editors/containers/ProblemEditor/components/SelectTypeModal/content/messages'; + +import { getCourseSectionVertical, getCourseUnitData } from '../data/selectors'; +import { useUnitSidebarContext } from './UnitSidebarContext'; +import messages from './messages'; +import { useHandleCreateNewCourseXBlock } from '../hooks'; +import { messageTypes } from '../constants'; +import { fetchCourseSectionVerticalData } from '../data/thunk'; + +/** + * Tab of the add sidebar to add new content to the unit + */ +const AddNewContent = () => { + const intl = useIntl(); + const dispatch = useDispatch(); + const { sendMessageToIframe } = useIframe(); + const { blockId } = useParams(); + const { courseId } = useCourseAuthoringContext(); + const courseUnit = useSelector(getCourseUnitData); + const { componentTemplates = {} } = useSelector(getCourseSectionVertical); + const [blockType, setBlockType] = useState(null); + const [newBlockId, setNewBlockId] = useState(null); + const [editorExtraProps, setEditorExtraProps] = useState | null>(null); + const { useVideoGalleryFlow } = useWaffleFlags(courseId ?? undefined); + const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle(); + const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); + const [isAdvancedPageOpen, showAdvancedPage, closeAdvancedPage] = useToggle(); + + /** The ID of the subsection (`sequential`) that is the parent of the unit we're adding to */ + const parentSubsectionId = courseUnit?.ancestorInfo?.ancestors?.[0]?.id; + + // Build problem templates + const problemTemplates: BlockTemplate[] = []; + Object.values(ProblemTypeKeys).map((key) => ( + problemTemplates.push({ + displayName: intl.formatMessage(problemMessages[`problemType.${key}.title`]), + boilerplateName: key, + }) + )); + + // Pre-process block templates + const templatesByType = componentTemplates.reduce((acc, item) => { + let result = item; + // (1) All types have at least one template of the same type. + // In that case, it's left empty to avoid rendering that single template. + // (2) Set the problem templates required for this component. + if (item.type === 'problem') { + result = { + ...item, + templates: problemTemplates, + }; + } else if (item.templates.length === 1) { + result = { + ...item, + templates: [], + }; + } + + return { + ...acc, + [item.type]: result, + }; + }, {}); + + if (courseId === undefined) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Error: route is missing courseId.'); + } + + if (blockId === undefined) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Error: route is missing blockId.'); + } + + const handleCreateXBlock = useHandleCreateNewCourseXBlock({ blockId }); + + const onXBlockSave = useCallback(/* istanbul ignore next */ () => { + closeXBlockEditorModal(); + closeVideoSelectorModal(); + sendMessageToIframe(messageTypes.refreshXBlock, null); + dispatch(fetchCourseSectionVerticalData(blockId, parentSubsectionId)); + }, [closeXBlockEditorModal, sendMessageToIframe]); + + const onXBlockCancel = useCallback(/* istanbul ignore next */ () => { + closeXBlockEditorModal(); + closeVideoSelectorModal(); + dispatch(fetchCourseSectionVerticalData(blockId, parentSubsectionId)); + }, [closeXBlockEditorModal, sendMessageToIframe, blockId, parentSubsectionId]); + + /* eslint-disable no-void */ + const handleSelection = useCallback((type: string, moduleName?: string) => { + switch (type) { + case COMPONENT_TYPES.dragAndDrop: + void handleCreateXBlock({ type, parentLocator: blockId }); + break; + case COMPONENT_TYPES.problem: + void handleCreateXBlock({ type, parentLocator: blockId }, ({ locator }) => { + setEditorExtraProps({ problemType: moduleName }); + setBlockType(type); + setNewBlockId(locator); + showXBlockEditorModal(); + }); + break; + case COMPONENT_TYPES.video: + void handleCreateXBlock( + { type, parentLocator: blockId }, + /* istanbul ignore next */ ({ locator }) => { + setBlockType(type); + setNewBlockId(locator); + if (useVideoGalleryFlow) { + showVideoSelectorModal(); + } else { + showXBlockEditorModal(); + } + }, + ); + break; + case COMPONENT_TYPES.openassessment: + void handleCreateXBlock({ boilerplate: moduleName, category: type, parentLocator: blockId }); + break; + case COMPONENT_TYPES.html: + void handleCreateXBlock({ + type, + boilerplate: moduleName, + parentLocator: blockId, + }, /* istanbul ignore next */ ({ locator }) => { + setBlockType(type); + setNewBlockId(locator); + showXBlockEditorModal(); + }); + break; + case COMPONENT_TYPES.advanced: + void handleCreateXBlock({ type: moduleName, category: moduleName, parentLocator: blockId }); + break; + /* istanbul ignore next */ + default: + break; + } + }, [blockId]); + + const blockTypes = [ + { + blockType: 'html', + name: intl.formatMessage(messages.sidebarAddTextButton), + }, + { + blockType: 'video', + name: intl.formatMessage(messages.sidebarAddVideoButton), + }, + { + blockType: 'problem', + name: intl.formatMessage(messages.sidebarAddProblemButton), + }, + { + blockType: 'drag-and-drop-v2', + name: intl.formatMessage(messages.sidebarAddDragDropButton), + }, + { + blockType: 'openassessment', + name: intl.formatMessage(messages.sidebarAddOpenResponseButton), + }, + ]; + + // Render add advanced blocks page + if (isAdvancedPageOpen) { + return ( + + + + + + + + {templatesByType.advanced?.templates.map((advancedTypeObj) => ( + handleSelection('advanced', advancedTypeObj.category)} + /> + ))} + + + ); + } + + // Render add default blocks page + return ( + <> + + {blockTypes.map((blockTypeObj) => ( + handleSelection(blockTypeObj.blockType)} + onClickTemplate={(boilerplateName: string) => handleSelection(blockTypeObj.blockType, boilerplateName)} + /> + ))} + {templatesByType.advanced?.templates?.length > 0 && ( + } + /> + )} + + +
+ onXBlockSave} + /> +
+
+ {isXBlockEditorModalOpen && courseId && blockType && newBlockId && ( +
+ onXBlockSave} + extraProps={editorExtraProps} + /> +
+ )} + + ); +}; + +/** + * Tab of the add sidebar to add a content library in the unit + * + * Uses `ComponentPicker` + */ +const AddLibraryContent = () => { + const { blockId } = useParams(); + + if (blockId === undefined) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Error: route is missing blockId.'); + } + + const handleCreateXBlock = useHandleCreateNewCourseXBlock({ blockId }); + + const handleSelection = useCallback(async (selection: SelectedComponent) => { + await handleCreateXBlock({ + type: COMPONENT_TYPES.libraryV2, + category: selection.blockType, + parentLocator: blockId, + libraryContentKey: selection.usageKey, + }); + }, [blockId]); + + return ( + + + + ); +}; + +/** + * Main component of the Add Sidebar for the unit page + */ +export const AddSidebar = () => { + const intl = useIntl(); + const unitData = useSelector(getCourseUnitData); + + const { + currentTabKey, + setCurrentTabKey, + } = useUnitSidebarContext(); + + useEffect(() => { + if (currentTabKey === undefined) { + // Set default Tab key + setCurrentTabKey('add-new'); + } + }, []); + + return ( +
+ + + + + +
+ +
+
+ +
+ +
+
+
+
+
+
+ ); +}; diff --git a/src/course-unit/unit-sidebar/UnitSidebar.tsx b/src/course-unit/unit-sidebar/UnitSidebar.tsx index 19a3668718..f6d3c597a6 100644 --- a/src/course-unit/unit-sidebar/UnitSidebar.tsx +++ b/src/course-unit/unit-sidebar/UnitSidebar.tsx @@ -1,34 +1,47 @@ import { Sidebar } from '@src/generic/sidebar'; import LegacySidebar, { LegacySidebarProps } from '../legacy-sidebar'; -import { useUnitSidebarContext } from './UnitSidebarContext'; +import { UnitSidebarPageKeys, useUnitSidebarContext } from './UnitSidebarContext'; import { isUnitPageNewDesignEnabled } from '../utils'; -import { UNIT_SIDEBAR_PAGES } from './constants'; +import { useUnitSidebarPages } from './sidebarPages'; export type UnitSidebarProps = { legacySidebarProps: LegacySidebarProps, }; +/** + * Main component of the Sidebar for the Unit + */ export const UnitSidebar = ({ legacySidebarProps, // Can be deleted when the legacy sidebar is deprecated }: UnitSidebarProps) => { const { currentPageKey, setCurrentPageKey, + setCurrentTabKey, isOpen, toggle, } = useUnitSidebarContext(); + const sidebarPages = useUnitSidebarPages(); + if (!isUnitPageNewDesignEnabled()) { return ( ); } + const handleChangePage = (key: UnitSidebarPageKeys) => { + // Resets the tab key + setCurrentTabKey(undefined); + // Change the page + setCurrentPageKey(key); + }; + return ( diff --git a/src/course-unit/unit-sidebar/UnitSidebarContext.tsx b/src/course-unit/unit-sidebar/UnitSidebarContext.tsx index eb707a9785..13c1975441 100644 --- a/src/course-unit/unit-sidebar/UnitSidebarContext.tsx +++ b/src/course-unit/unit-sidebar/UnitSidebarContext.tsx @@ -4,27 +4,36 @@ import { import { SidebarPage } from '@src/generic/sidebar'; import { useToggle } from '@openedx/paragon'; -export type UnitSidebarPageKeys = 'info'; +export type UnitSidebarPageKeys = 'info' | 'add'; export type UnitSidebarPages = Record; interface UnitSidebarContextData { currentPageKey: UnitSidebarPageKeys; setCurrentPageKey: (pageKey: UnitSidebarPageKeys) => void; currentTabKey?: string; - setCurrentTabKey: (tabKey: string) => void; + setCurrentTabKey: (tabKey: string | undefined) => void; isOpen: boolean; open: () => void; toggle: () => void; + readOnly: boolean; } const UnitSidebarContext = createContext(undefined); -export const UnitSidebarProvider = ({ children }: { children?: React.ReactNode }) => { +export const UnitSidebarProvider = ({ + children, + readOnly, +}: { + children?: React.ReactNode, + readOnly: boolean, +}) => { const [currentPageKey, setCurrentPageKeyState] = useState('info'); const [currentTabKey, setCurrentTabKey] = useState(); const [isOpen, open,, toggle] = useToggle(true); const setCurrentPageKey = useCallback(/* istanbul ignore next */ (pageKey: UnitSidebarPageKeys) => { + // Reset tab + setCurrentTabKey(undefined); setCurrentPageKeyState(pageKey); open(); }, [open]); @@ -38,6 +47,7 @@ export const UnitSidebarProvider = ({ children }: { children?: React.ReactNode } isOpen, open, toggle, + readOnly, }), [ currentPageKey, @@ -47,6 +57,7 @@ export const UnitSidebarProvider = ({ children }: { children?: React.ReactNode } isOpen, open, toggle, + readOnly, ], ); diff --git a/src/course-unit/unit-sidebar/constants.ts b/src/course-unit/unit-sidebar/constants.ts deleted file mode 100644 index 8bfbe7e003..0000000000 --- a/src/course-unit/unit-sidebar/constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Info } from '@openedx/paragon/icons'; -import { SidebarPage } from '@src/generic/sidebar'; -import messages from './messages'; -import { UnitInfoSidebar } from './unit-info/UnitInfoSidebar'; - -export type UnitSidebarPageKeys = 'info'; - -/** - * Sidebar pages for the unit sidebar - * - * This has been separated from the context to avoid a cyclical import - * if you want to use the context in the sidebar pages. - */ -export const UNIT_SIDEBAR_PAGES: Record = { - info: { - component: UnitInfoSidebar, - icon: Info, - title: messages.sidebarButtonInfo, - }, -}; diff --git a/src/course-unit/unit-sidebar/messages.ts b/src/course-unit/unit-sidebar/messages.ts index 96c3326ce1..b5e372b79b 100644 --- a/src/course-unit/unit-sidebar/messages.ts +++ b/src/course-unit/unit-sidebar/messages.ts @@ -6,6 +6,66 @@ const messages = defineMessages({ defaultMessage: 'Info', description: 'Label of the button for the Info sidebar', }, + sidebarButtonAdd: { + id: 'course-authoring.unit-page.sidebar.add.sidebar-button-add', + defaultMessage: 'Add', + description: 'Label of the button for the Add sidebar', + }, + sidebarAddNewTab: { + id: 'course-authoring.unit-page.sidebar.add.tab.add-new', + defaultMessage: 'Add New', + description: 'Label of tab in the sidebar for add new content.', + }, + sidebarAddExistingTab: { + id: 'course-authoring.unit-page.sidebar.add.tab.add-existing', + defaultMessage: 'Add Existing', + description: 'Label of tab in the sidebar for add existing content.', + }, + sidebarAddTextButton: { + id: 'course-authoring.unit-page.sidebar.add.new.text', + defaultMessage: 'Text', + description: 'Label for the button to create a new Text block', + }, + sidebarAddProblemButton: { + id: 'course-authoring.unit-page.sidebar.add.new.problem', + defaultMessage: 'Problem', + description: 'Label for the button to create a new Problem block', + }, + sidebarAddVideoButton: { + id: 'course-authoring.unit-page.sidebar.add.new.video', + defaultMessage: 'Video', + description: 'Label for the button to create a new Video block', + }, + sidebarAddOpenResponseButton: { + id: 'course-authoring.unit-page.sidebar.add.new.open-response', + defaultMessage: 'Open Response', + description: 'Label for the button to create a new Open Response block', + }, + sidebarAddDragDropButton: { + id: 'course-authoring.unit-page.sidebar.add.new.drag-and-drop', + defaultMessage: 'Drag Drop', + description: 'Label for the button to create a new Drag and Drop block', + }, + sidebarAddAdvancedButton: { + id: 'course-authoring.unit-page.sidebar.add.new.advanced', + defaultMessage: 'Advanced', + description: 'Label for the button to open the Advanced blocks list', + }, + videoPickerModalTitle: { + id: 'course-authoring.course-unit.sidebar.modal.video-title.text', + defaultMessage: 'Select video', + description: 'Video picker modal title.', + }, + sidebarAddBackButton: { + id: 'course-authoring.course-unit.sidebar.add.back.button', + defaultMessage: 'Back', + description: 'Label for the button to go back from the add advanced block page', + }, + sidebarAddAdvancedBlocksTitle: { + id: 'course-authoring.course-unit.sidebar.add.back.button', + defaultMessage: 'Advanced Blocks', + description: 'Title for the add advanced blocks page in the unit sidebar', + }, }); export default messages; diff --git a/src/course-unit/unit-sidebar/sidebarPages.ts b/src/course-unit/unit-sidebar/sidebarPages.ts new file mode 100644 index 0000000000..1d09d85c7f --- /dev/null +++ b/src/course-unit/unit-sidebar/sidebarPages.ts @@ -0,0 +1,36 @@ +import { Info, Plus } from '@openedx/paragon/icons'; +import { SidebarPage } from '@src/generic/sidebar'; +import messages from './messages'; +import { UnitInfoSidebar } from './unit-info/UnitInfoSidebar'; +import { AddSidebar } from './AddSidebar'; +import { useUnitSidebarContext } from './UnitSidebarContext'; + +export type UnitSidebarPages = { + info: SidebarPage; + align?: SidebarPage; + add?: SidebarPage; +}; + +/** + * Sidebar pages for the unit sidebar + * + * This has been separated from the context to avoid a cyclical import + * if you want to use the context in the sidebar pages. + */ +export const useUnitSidebarPages = (): UnitSidebarPages => { + const { readOnly } = useUnitSidebarContext(); + return { + info: { + component: UnitInfoSidebar, + icon: Info, + title: messages.sidebarButtonInfo, + }, + ...(!readOnly && { + add: { + component: AddSidebar, + icon: Plus, + title: messages.sidebarButtonAdd, + }, + }), + }; +}; diff --git a/src/editors/Editor.tsx b/src/editors/Editor.tsx index f939decfd6..4708796a6c 100644 --- a/src/editors/Editor.tsx +++ b/src/editors/Editor.tsx @@ -25,6 +25,7 @@ const Editor: React.FC = ({ studioEndpointUrl, onClose = null, returnFunction = null, + extraProps, }) => { const dispatch = useDispatch(); const loading = hooks.useInitializeApp({ @@ -54,7 +55,7 @@ const Editor: React.FC = ({ ); } - return ; + return ; }; export default Editor; diff --git a/src/editors/EditorComponent.ts b/src/editors/EditorComponent.ts index e670ff6f5d..c82d9e3b1d 100644 --- a/src/editors/EditorComponent.ts +++ b/src/editors/EditorComponent.ts @@ -3,4 +3,5 @@ export interface EditorComponent { onClose: (() => void) | null; // TODO: get a better type for the 'result' here returnFunction?: (() => (result: any) => void) | null; + extraProps?: Record | null; } diff --git a/src/editors/EditorPage.tsx b/src/editors/EditorPage.tsx index 47c0ff4792..1eb01607af 100644 --- a/src/editors/EditorPage.tsx +++ b/src/editors/EditorPage.tsx @@ -28,6 +28,7 @@ const EditorPage: React.FC = ({ studioEndpointUrl = null, onClose = null, returnFunction = null, + extraProps = null, }) => ( = ({ lmsEndpointUrl, studioEndpointUrl, returnFunction, + extraProps, }} /> diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.tsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.tsx index 1881035f91..701d9c53de 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.tsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/index.tsx @@ -4,6 +4,7 @@ import { Row, Stack } from '@openedx/paragon'; import { AdvancedProblemType, + AdvanceProblemKeys, isAdvancedProblemType, ProblemType, ProblemTypeKeys, @@ -16,12 +17,16 @@ import * as hooks from './hooks'; interface Props { onClose: (() => void) | null; + openAdvanced?: boolean; } const SelectTypeModal: React.FC = ({ onClose, + openAdvanced = false, }) => { - const [selected, setSelected] = React.useState(ProblemTypeKeys.SINGLESELECT); + const [selected, setSelected] = React.useState( + openAdvanced ? AdvanceProblemKeys.BLANK : ProblemTypeKeys.SINGLESELECT, + ); hooks.useArrowNav(selected, setSelected); return ( diff --git a/src/editors/containers/ProblemEditor/index.tsx b/src/editors/containers/ProblemEditor/index.tsx index 66b1360913..4e6ccb3d34 100644 --- a/src/editors/containers/ProblemEditor/index.tsx +++ b/src/editors/containers/ProblemEditor/index.tsx @@ -1,20 +1,32 @@ import React, { useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { Spinner } from '@openedx/paragon'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + EditorState, selectors, actions, thunkActions, +} from '@src/editors/data/redux'; +import { RequestKeys } from '@src/editors/data/constants/requests'; +import { EditorComponent } from '@src/editors/EditorComponent'; + import SelectTypeModal from './components/SelectTypeModal'; import EditProblemView from './components/EditProblemView'; -import { EditorState, selectors, thunkActions } from '../../data/redux'; -import { RequestKeys } from '../../data/constants/requests'; import messages from './messages'; -import type { EditorComponent } from '../../EditorComponent'; +import * as hooks from './components/SelectTypeModal/hooks'; export interface Props extends EditorComponent {} +/** + * Renders the form with all field to edit a problem + * + * When create a new problem, seet extraProps.problemType to skip the select step + * and go directly to the edit page using the given problem type. + */ const ProblemEditor: React.FC = ({ onClose, returnFunction = null, + extraProps = null, }) => { + const intl = useIntl(); const dispatch = useDispatch(); const blockFinished = useSelector((state: EditorState) => selectors.app.shouldCreateBlock(state) @@ -27,13 +39,33 @@ const ProblemEditor: React.FC = ({ const problemType = useSelector(selectors.problem.problemType); const blockValue = useSelector(selectors.app.blockValue); + const updateField = React.useCallback((data) => dispatch(actions.problem.updateField(data)), [dispatch]); + const setBlockTitle = React.useCallback((title) => dispatch(actions.app.setBlockTitle(title)), [dispatch]); + const advancedSettingsFinished = useSelector((state: EditorState) => selectors.app.shouldCreateBlock(state) || selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchAdvancedSettings })); useEffect(() => { - if (blockFinished && !blockFailed) { - dispatch(thunkActions.problem.initializeProblem(blockValue)); - } + const run = async () => { + if (blockFinished && !blockFailed) { + // Await initialize problem and set a new problem type if applicable + // oxlint-disable-next-line @typescript-eslint/await-thenable + await dispatch(thunkActions.problem.initializeProblem(blockValue)); + + if (extraProps?.problemType && extraProps.problemType !== 'advanced') { + hooks.onSelect({ + selected: extraProps.problemType, + updateField, + setBlockTitle, + defaultSettings: {}, + formatMessage: intl.formatMessage, + })(); + } + } + }; + + // eslint-disable-next-line no-void + void run(); }, [blockFinished, blockFailed, blockValue, dispatch]); if (!blockFinished || !advancedSettingsFinished) { @@ -57,7 +89,7 @@ const ProblemEditor: React.FC = ({ } if (problemType === null) { - return (); + return (); } return (); diff --git a/src/editors/data/redux/thunkActions/problem.test.ts b/src/editors/data/redux/thunkActions/problem.test.ts index c4172f3917..92a55ee6b4 100644 --- a/src/editors/data/redux/thunkActions/problem.test.ts +++ b/src/editors/data/redux/thunkActions/problem.test.ts @@ -160,21 +160,24 @@ describe('problem thunkActions', () => { }); describe('fetchAdvanceSettings', () => { - it('dispatches fetchAdvanceSettings action', () => { - fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); + it('dispatches fetchAdvanceSettings action', async () => { + // eslint-disable-next-line no-void + void fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; expect(dispatchedAction.fetchAdvanceSettings).not.toEqual(undefined); }); - it('dispatches actions.problem.updateField and loadProblem on success', () => { + it('dispatches actions.problem.updateField and loadProblem on success', async () => { dispatch.mockClear(); - fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); + // eslint-disable-next-line no-void + void fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; dispatchedAction.fetchAdvanceSettings.onSuccess({ data: { key: 'test', max_attempts: 1 } }); expect(dispatch).toHaveBeenCalledWith(actions.problem.load(undefined)); }); - it('calls loadProblem on failure', () => { + it('calls loadProblem on failure', async () => { dispatch.mockClear(); - fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); + // eslint-disable-next-line no-void + void fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled: true })(dispatch); [[dispatchedAction]] = dispatch.mock.calls; dispatchedAction.fetchAdvanceSettings.onFailure(); expect(dispatch).toHaveBeenCalledWith(actions.problem.load(undefined)); diff --git a/src/editors/data/redux/thunkActions/problem.ts b/src/editors/data/redux/thunkActions/problem.ts index 24068fc908..2711c3b772 100644 --- a/src/editors/data/redux/thunkActions/problem.ts +++ b/src/editors/data/redux/thunkActions/problem.ts @@ -1,15 +1,16 @@ import { get, isEmpty } from 'lodash'; + import { camelizeKeys, convertMarkdownToXml } from '@src/editors/utils'; +import { OLXParser } from '@src/editors/containers/ProblemEditor/data/OLXParser'; +import { parseSettings } from '@src/editors/containers/ProblemEditor/data/SettingsParser'; +import { fetchEditorContent } from '@src/editors/containers/ProblemEditor/components/EditProblemView/hooks'; +import ReactStateOLXParser from '@src/editors/containers/ProblemEditor/data/ReactStateOLXParser'; +import { isLibraryKey } from '@src/generic/key-utils'; import { actions as problemActions } from '../problem'; import { actions as requestActions } from '../requests'; import { selectors as appSelectors } from '../app'; import * as requests from './requests'; -import { isLibraryKey } from '../../../../generic/key-utils'; -import { OLXParser } from '../../../containers/ProblemEditor/data/OLXParser'; -import { parseSettings } from '../../../containers/ProblemEditor/data/SettingsParser'; import { ProblemTypeKeys } from '../../constants/problem'; -import ReactStateOLXParser from '../../../containers/ProblemEditor/data/ReactStateOLXParser'; -import { fetchEditorContent } from '../../../containers/ProblemEditor/components/EditProblemView/hooks'; import { RequestKeys } from '../../constants/requests'; // Similar to `import { actions, selectors } from '..';` but avoid circular imports: @@ -96,28 +97,49 @@ export const loadProblem = ({ } }; -export const fetchAdvancedSettings = ({ rawOLX, rawSettings, isMarkdownEditorEnabled }) => (dispatch) => { +export const fetchAdvancedSettings = ({ + rawOLX, + rawSettings, + isMarkdownEditorEnabled, +}) => (dispatch) => new Promise((resolve) => { const advancedProblemSettingKeys = ['max_attempts', 'showanswer', 'show_reset_button', 'rerandomize']; + dispatch(requests.fetchAdvancedSettings({ onSuccess: (response) => { const defaultSettings = {}; + Object.entries(response.data as Record).forEach(([key, value]) => { if (advancedProblemSettingKeys.includes(key)) { defaultSettings[key] = value.value; } }); - dispatch(actions.problem.updateField({ defaultSettings: camelizeKeys(defaultSettings) })); + + dispatch(actions.problem.updateField({ + defaultSettings: camelizeKeys(defaultSettings), + })); + loadProblem({ - rawOLX, rawSettings, defaultSettings, isMarkdownEditorEnabled, + rawOLX, + rawSettings, + defaultSettings, + isMarkdownEditorEnabled, })(dispatch); + + resolve(true); }, + onFailure: () => { loadProblem({ - rawOLX, rawSettings, defaultSettings: {}, isMarkdownEditorEnabled, + rawOLX, + rawSettings, + defaultSettings: {}, + isMarkdownEditorEnabled, })(dispatch); + + resolve(false); }, })); -}; +}); export const initializeProblem = (blockValue) => (dispatch, getState) => { const rawOLX = get(blockValue, 'data.data', ''); @@ -129,13 +151,12 @@ export const initializeProblem = (blockValue) => (dispatch, getState) => { // So proceed with loading the problem. // Though first we need to fake the request or else the problem type selection UI won't display: dispatch(actions.requests.completeRequest({ requestKey: RequestKeys.fetchAdvancedSettings, response: {} })); - dispatch(loadProblem({ + return dispatch(loadProblem({ rawOLX, rawSettings, defaultSettings: {}, isMarkdownEditorEnabled, })); - } else { - // Load the defaults (for max_attempts, etc.) from the course's advanced settings, then proceed: - dispatch(fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled })); } + // Load the defaults (for max_attempts, etc.) from the course's advanced settings, then proceed: + return dispatch(fetchAdvancedSettings({ rawOLX, rawSettings, isMarkdownEditorEnabled })); }; export default { diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index 93c1bf8ea8..f9dd23cfdb 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -89,13 +89,3 @@ export const COMPONENT_TYPE_STYLE_COLOR_MAP = { collection: 'component-style-collection', other: 'component-style-other', }; - -export const ICON_BORDER_STYLE_COLOR_MAP = { - vertical: 'icon-with-border-vertical', - unit: 'icon-with-border-vertical', - sequential: 'icon-with-border-sequential', - subsection: 'icon-with-border-sequential', - chapter: 'icon-with-border-chapter', - section: 'icon-with-border-chapter', - other: 'icon-with-border-other', -}; diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss index eed04b4b73..b6fb806085 100644 --- a/src/generic/block-type-utils/index.scss +++ b/src/generic/block-type-utils/index.scss @@ -1,5 +1,40 @@ +:root { + --content-library-component-default-color: #646464; + --content-library-component-default-color-light: #7E7E7E; + --content-library-component-default-color-light-focus: #979797; + --content-library-component-default-color-dark: #3E3E3E; + --content-library-component-primary-color: #005C9E; + --content-library-component-primary-color-light: #007AD1; + --content-library-component-primary-color-light-focus: #0597FF; + --content-library-component-primary-color-dark: #002F52; + --content-library-component-html-color: #9747FF; + --content-library-component-html-color-light: #B47AFF; + --content-library-component-html-color-light-focus: #D1ADFF; + --content-library-component-html-color-dark: #6C00FA; + --content-library-component-video-color: #358F0A; + --content-library-component-video-color-light: #47BF0D; + --content-library-component-video-color-light-focus: #58EE11; + --content-library-component-video-color-dark: #1B4805; + --content-library-collection-color: #FFCD29; + --content-library-collection-color-light: #FFD95C; + --content-library-collection-color-light-focus: #FFDF75; + --content-library-collection-color-dark: #DCA800; + --content-library-container-unit-color: #0B8E77; + --content-library-container-unit-color-light: #0FBD9F; + --content-library-container-unit-color-light-focus: #12EDC6; + --content-library-container-unit-color-dark: #06473C; + --content-library-container-subsection-color: #EA3E3E; + --content-library-container-subsection-color-light: #EF6C6C; + --content-library-container-subsection-color-light-focus: #F49A9A; + --content-library-container-subsection-color-dark: #C61616; + --content-library-container-section-color: #45009E; + --content-library-container-section-color-light: #5B00D1; + --content-library-container-section-color-light-focus: #7205FF; + --content-library-container-section-color-dark: #240052; +} + .component-style-default { - background-color: #005C9E; + background-color: var(--content-library-component-primary-color); .pgn__icon:not(.btn-icon-before) { color: white; @@ -7,16 +42,16 @@ .btn-icon { &:hover, &:active, &:focus { - background-color: darken(#005C9E, 15%); + background-color: var(--content-library-component-primary-color-dark); } } .btn { - background-color: lighten(#005C9E, 10%); + background-color: var(--content-library-component-primary-color-light); border: 0; &:hover, &:active, &:focus { - background-color: lighten(#005C9E, 20%); + background-color: var(--content-library-component-primary-color-light-focus); border: 1px solid var(--pgn-color-primary-base); margin: -1px; } @@ -28,7 +63,7 @@ } .component-style-html { - background-color: #9747FF; + background-color: var(--content-library-component-html-color); .pgn__icon:not(.btn-icon-before) { color: white; @@ -36,16 +71,16 @@ .btn-icon { &:hover, &:active, &:focus { - background-color: darken(#9747FF, 15%); + background-color: var(--content-library-component-html-color-dark); } } .btn { - background-color: lighten(#9747FF, 10%); + background-color: var(--content-library-component-html-color-light); border: 0; &:hover, &:active, &:focus { - background-color: lighten(#9747FF, 20%); + background-color: var(--content-library-component-html-color-light-focus); border: 1px solid var(--pgn-color-primary-base); margin: -1px; } @@ -57,7 +92,7 @@ } .component-style-collection { - background-color: #FFCD29; + background-color: var(--content-library-collection-color); .pgn__icon:not(.btn-icon-before) { color: black; @@ -65,16 +100,16 @@ .btn-icon { &:hover, &:active, &:focus { - background-color: darken(#FFCD29, 15%); + background-color: var(--content-library-collection-color-dark); } } .btn { - background-color: lighten(#FFCD29, 10%); + background-color: var(--content-library-collection-color-light); border: 0; &:hover, &:active, &:focus { - background-color: lighten(#FFCD29, 20%); + background-color: var(--content-library-collection-color-light-focus); border: 1px solid var(--pgn-color-primary-base); margin: -1px; } @@ -86,7 +121,7 @@ } .component-style-video { - background-color: #358F0A; + background-color: var(--content-library-component-video-color); .pgn__icon:not(.btn-icon-before) { color: white; @@ -94,16 +129,16 @@ .btn-icon { &:hover, &:active, &:focus { - background-color: darken(#358F0A, 15%); + background-color: var(--content-library-component-video-color-dark); } } .btn { - background-color: lighten(#358F0A, 10%); + background-color: var(--content-library-component-video-color-light); border: 0; &:hover, &:active, &:focus { - background-color: lighten(#358F0A, 20%); + background-color: var(--content-library-component-video-color-light-focus); border: 1px solid var(--pgn-color-primary-base); margin: -1px; } @@ -115,7 +150,7 @@ } .component-style-vertical { - background-color: #0B8E77; + background-color: var(--content-library-container-unit-color); .pgn__icon:not(.btn-icon-before) { color: white; @@ -123,16 +158,16 @@ .btn-icon { &:hover, &:active, &:focus { - background-color: darken(#0B8E77, 15%); + background-color: var(--content-library-container-unit-color-dark); } } .btn { - background-color: lighten(#0B8E77, 10%); + background-color: var(--content-library-container-unit-color-light); border: 0; &:hover, &:active, &:focus { - background-color: lighten(#0B8E77, 20%); + background-color: var(--content-library-container-unit-color-light-focus); border: 1px solid var(--pgn-color-primary-base); margin: -1px; } @@ -144,7 +179,7 @@ } .component-style-sequential { - background-color: #EA3E3E; + background-color: var(--content-library-container-subsection-color); .pgn__icon:not(.btn-icon-before) { color: white; @@ -152,16 +187,16 @@ .btn-icon { &:hover, &:active, &:focus { - background-color: darken(#EA3E3E, 15%); + background-color: var(--content-library-container-subsection-color-dark); } } .btn { - background-color: lighten(#EA3E3E, 10%); + background-color: var(--content-library-container-subsection-color-light); border: 0; &:hover, &:active, &:focus { - background-color: lighten(#EA3E3E, 20%); + background-color: var(--content-library-container-subsection-color-light-focus); border: 1px solid var(--pgn-color-primary-base); margin: -1px; } @@ -173,7 +208,7 @@ } .component-style-chapter { - background-color: #45009E; + background-color: var(--content-library-container-section-color); .pgn__icon:not(.btn-icon-before) { color: white; @@ -181,17 +216,17 @@ .btn-icon { &:hover, &:active, &:focus { - background-color: darken(#45009E, 15%); + background-color: var(--content-library-container-section-color-dark); } } .btn { - background-color: lighten(#45009E, 10%); + background-color: var(--content-library-container-section-color-light); border: 0; color: white; &:hover, &:active, &:focus { - background-color: lighten(#45009E, 20%); + background-color: var(--content-library-container-section-color-light-focus); border: 1px solid var(--pgn-color-primary-base); margin: -1px; } @@ -203,7 +238,7 @@ } .component-style-other { - background-color: #646464; + background-color: var(--content-library-component-default-color); .pgn__icon:not(.btn-icon-before) { color: white; @@ -211,16 +246,16 @@ .btn-icon { &:hover, &:active, &:focus { - background-color: darken(#646464, 15%); + background-color: var(--content-library-component-default-color-dark); } } .btn { - background-color: lighten(#646464, 10%); + background-color: var(--content-library-component-default-color-light); border: 0; &:hover, &:active, &:focus { - background-color: lighten(#646464, 20%); + background-color: var(--content-library-component-default-color-light-focus); border: 1px solid var(--pgn-color-primary-base); margin: -1px; } @@ -231,38 +266,61 @@ } } -.icon-with-border-chapter { +.icon-with-border { background-color: white; - border: 1px solid #45009E; + border: 1px solid var(--content-library-component-default-color); .pgn__icon { - color: #45009E; + color: var(--content-library-component-default-color); } } -.icon-with-border-sequential { - background-color: white; - border: 1px solid #EA3E3E; +.icon-with-border-problem, +.icon-with-border-drag-and-drop-v2, +.icon-with-border-openassessment { + border: 1px solid var(--content-library-component-primary-color); .pgn__icon { - color: #EA3E3E; + color: var(--content-library-component-primary-color); } } -.icon-with-border-vertical { - background-color: white; - border: 1px solid #0B8E77; +.icon-with-border-chapter { + border: 1px solid var(--content-library-container-section-color); .pgn__icon { - color: #0B8E77; + color: var(--content-library-container-section-color); } } -.icon-with-border-default { - background-color: white; - border: 1px solid #005C9E; +.icon-with-border-sequential { + border: 1px solid var(--content-library-container-subsection-color); + + .pgn__icon { + color: var(--content-library-container-subsection-color); + } +} + +.icon-with-border-vertical { + border: 1px solid var(--content-library-container-unit-color); + + .pgn__icon { + color: var(--content-library-container-unit-color); + } +} + +.icon-with-border-html { + border: 1px solid var(--content-library-component-html-color); + + .pgn__icon { + color: var(--content-library-component-html-color); + } +} + +.icon-with-border-video { + border: 1px solid var(--content-library-component-video-color); .pgn__icon { - color: #005C9E; + color: var(--content-library-component-video-color); } } diff --git a/src/generic/block-type-utils/index.tsx b/src/generic/block-type-utils/index.tsx index 103f90c7ff..a4e2ce4b6f 100644 --- a/src/generic/block-type-utils/index.tsx +++ b/src/generic/block-type-utils/index.tsx @@ -6,7 +6,6 @@ import { COMPONENT_TYPE_ICON_MAP, STRUCTURAL_TYPE_ICONS, COMPONENT_TYPE_STYLE_COLOR_MAP, - ICON_BORDER_STYLE_COLOR_MAP, } from './constants'; import messages from './messages'; @@ -19,10 +18,6 @@ export function getComponentStyleColor(blockType: string): string { return COMPONENT_TYPE_STYLE_COLOR_MAP[blockType] ?? COMPONENT_TYPE_STYLE_COLOR_MAP.other; } -export function getIconBorderStyleColor(blockType: string): string { - return ICON_BORDER_STYLE_COLOR_MAP[blockType] ?? ICON_BORDER_STYLE_COLOR_MAP.other; -} - interface ComponentIconProps { blockType: string; iconTitle: string; diff --git a/src/generic/sidebar/BlockCardButton.tsx b/src/generic/sidebar/BlockCardButton.tsx new file mode 100644 index 0000000000..569fdf0ac9 --- /dev/null +++ b/src/generic/sidebar/BlockCardButton.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { + Button, Chip, Collapsible, Icon, Stack, +} from '@openedx/paragon'; +import { getItemIcon } from '../block-type-utils'; + +export type BlockTemplate = { + displayName: string; + boilerplateName: string; +}; + +export interface BlockCardButtonProps { + name: string; + blockType: string; + onClick: () => void; + disabled?: boolean; + templates?: BlockTemplate[]; + onClickTemplate?: (boilerplateName: string) => void; + actionIcon?: React.ReactElement; +} + +/** + * Renders a Card button with icon, name and templates of a block type + */ +export const BlockCardButton = ({ + name, + blockType, + onClick, + templates, + disabled = false, + onClickTemplate, + actionIcon, +}: BlockCardButtonProps) => { + const titleComponent = ( + + + + + + {name} + + + ); + + if (templates?.length) { + return ( +
+ + + {templates.map((template) => ( + onClickTemplate?.(template.boilerplateName)}> + {template.displayName} + + ))} + + +
+ ); + } + + return ( + + ); +};