feat: Unit Add sidebar [FC-0114] (#2837)
Implements the new Unit add Sidebar
This commit is contained in:
@@ -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 (
|
||||
<Button
|
||||
variant="tertiary shadow"
|
||||
className="mx-2 justify-content-start px-4 font-weight-bold"
|
||||
<BlockCardButton
|
||||
name={name}
|
||||
blockType={blockType}
|
||||
onClick={onCreateContent}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<span className={`p-2 rounded ${getIconBorderStyleColor(blockType)}`}>
|
||||
<Icon size="lg" src={getItemIcon(blockType)} />
|
||||
</span>
|
||||
{name}
|
||||
</Stack>
|
||||
</Button>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -2907,4 +2927,305 @@ describe('<CourseUnit />', () => {
|
||||
render(<RootWrapper />);
|
||||
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 <mark>...</mark> 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(<RootWrapper />);
|
||||
|
||||
// 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(<RootWrapper />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<UnitSidebarProvider>
|
||||
<UnitSidebarProvider readOnly={readOnly}>
|
||||
<Container fluid className="course-unit px-4">
|
||||
<section className="course-unit-container mb-4 mt-5">
|
||||
<TransitionReplace>
|
||||
@@ -360,6 +361,17 @@ const CourseUnit = () => {
|
||||
handleConfigureSubmit={handleConfigureSubmit}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData
|
||||
&& /* istanbul ignore next */ (
|
||||
<PasteComponent
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={
|
||||
/* istanbul ignore next */
|
||||
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
|
||||
}
|
||||
text={intl.formatMessage(messages.pasteButtonText)}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && blockId && (
|
||||
<AddComponent
|
||||
parentLocator={blockId}
|
||||
@@ -370,15 +382,6 @@ const CourseUnit = () => {
|
||||
addComponentTemplateData={addComponentTemplateData}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData && (
|
||||
<PasteComponent
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={
|
||||
() => handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId })
|
||||
}
|
||||
text={intl.formatMessage(messages.pasteButtonText)}
|
||||
/>
|
||||
)}
|
||||
<MoveModal
|
||||
isOpenModal={isMoveModalOpen}
|
||||
openModal={openMoveModal}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { render, initializeMocks, screen } from '@src/testUtils';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { COURSE_BLOCK_NAMES } from '../../constants';
|
||||
@@ -8,6 +10,7 @@ import messages from './messages';
|
||||
const handleViewLiveFn = jest.fn();
|
||||
const handlePreviewFn = jest.fn();
|
||||
const handleEditFn = jest.fn();
|
||||
const mockSetCurrentPageKey = jest.fn();
|
||||
|
||||
const headerNavigationsActions = {
|
||||
handleViewLive: handleViewLiveFn,
|
||||
@@ -25,40 +28,85 @@ const renderComponent = (props) => render(
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<HeaderNavigations />', () => {
|
||||
it('render HeaderNavigations component correctly', () => {
|
||||
const { getByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id });
|
||||
jest.mock('../unit-sidebar/UnitSidebarContext', () => ({
|
||||
useUnitSidebarContext: () => ({
|
||||
readOnly: false,
|
||||
setCurrentPageKey: mockSetCurrentPageKey,
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(getByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.previewButton.defaultMessage })).toBeInTheDocument();
|
||||
describe('<HeaderNavigations />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('calls the correct handlers when clicking buttons for unit page', () => {
|
||||
const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id });
|
||||
it('render HeaderNavigations component correctly', () => {
|
||||
renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id });
|
||||
|
||||
const viewLiveButton = getByRole('button', { name: messages.viewLiveButton.defaultMessage });
|
||||
fireEvent.click(viewLiveButton);
|
||||
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', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent({ unitCategory: COURSE_BLOCK_NAMES.vertical.id });
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={InfoOutline}
|
||||
onClick={() => setCurrentPageKey('info')}
|
||||
>
|
||||
{intl.formatMessage(messages.infoButton)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={Add}
|
||||
>
|
||||
{intl.formatMessage(messages.addButton)}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={Add}
|
||||
onClick={() => setCurrentPageKey('add')}
|
||||
>
|
||||
{intl.formatMessage(messages.addButton)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ButtonGroup>
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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]);
|
||||
367
src/course-unit/unit-sidebar/AddSidebar.tsx
Normal file
367
src/course-unit/unit-sidebar/AddSidebar.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [newBlockId, setNewBlockId] = useState<string | null>(null);
|
||||
const [editorExtraProps, setEditorExtraProps] = useState<Record<string, any> | 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 (
|
||||
<Stack>
|
||||
<Stack className="mb-2 text-primary-500" direction="horizontal" gap={1}>
|
||||
<Button
|
||||
className="text-primary-500"
|
||||
variant="tertiary"
|
||||
iconBefore={ChevronLeft}
|
||||
onClick={closeAdvancedPage}
|
||||
>
|
||||
<FormattedMessage {...messages.sidebarAddBackButton} />
|
||||
</Button>
|
||||
<Icon src={ChevronRight} />
|
||||
<FormattedMessage {...messages.sidebarAddAdvancedBlocksTitle} />
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
{templatesByType.advanced?.templates.map((advancedTypeObj) => (
|
||||
<BlockCardButton
|
||||
blockType={advancedTypeObj.category}
|
||||
name={advancedTypeObj.displayName}
|
||||
onClick={() => handleSelection('advanced', advancedTypeObj.category)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Render add default blocks page
|
||||
return (
|
||||
<>
|
||||
<Stack gap={2}>
|
||||
{blockTypes.map((blockTypeObj) => (
|
||||
<BlockCardButton
|
||||
{...blockTypeObj}
|
||||
templates={templatesByType[blockTypeObj.blockType].templates}
|
||||
onClick={() => handleSelection(blockTypeObj.blockType)}
|
||||
onClickTemplate={(boilerplateName: string) => handleSelection(blockTypeObj.blockType, boilerplateName)}
|
||||
/>
|
||||
))}
|
||||
{templatesByType.advanced?.templates?.length > 0 && (
|
||||
<BlockCardButton
|
||||
blockType="advanced"
|
||||
name={intl.formatMessage(messages.sidebarAddAdvancedButton)}
|
||||
onClick={showAdvancedPage}
|
||||
actionIcon={<Icon src={ChevronRight} />}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<StandardModal
|
||||
title={intl.formatMessage(messages.videoPickerModalTitle)}
|
||||
isOpen={isVideoSelectorModalOpen}
|
||||
onClose={closeVideoSelectorModal}
|
||||
isOverflowVisible={false}
|
||||
size="xl"
|
||||
>
|
||||
<div className="selector-page">
|
||||
<VideoSelectorPage
|
||||
blockId={newBlockId}
|
||||
courseId={courseId}
|
||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||
onCancel={closeVideoSelectorModal}
|
||||
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
|
||||
/>
|
||||
</div>
|
||||
</StandardModal>
|
||||
{isXBlockEditorModalOpen && courseId && blockType && newBlockId && (
|
||||
<div className="editor-page">
|
||||
<EditorPage
|
||||
courseId={courseId}
|
||||
blockType={blockType}
|
||||
blockId={newBlockId}
|
||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||
onClose={onXBlockCancel}
|
||||
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
|
||||
extraProps={editorExtraProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<MultiLibraryProvider>
|
||||
<ComponentPicker
|
||||
showOnlyPublished
|
||||
extraFilter={['type = "library_block"']}
|
||||
visibleTabs={[ContentType.home]}
|
||||
FiltersComponent={SidebarFilters}
|
||||
onComponentSelected={handleSelection}
|
||||
/>
|
||||
</MultiLibraryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div>
|
||||
<SidebarTitle
|
||||
title={unitData.displayName}
|
||||
icon={getItemIcon('unit')}
|
||||
/>
|
||||
<SidebarContent>
|
||||
<SidebarSection>
|
||||
<Tabs
|
||||
id="unit-add-sidebar"
|
||||
className="my-2 d-flex justify-content-around"
|
||||
activeKey={currentTabKey}
|
||||
onSelect={setCurrentTabKey}
|
||||
>
|
||||
<Tab
|
||||
eventKey="add-new"
|
||||
title={intl.formatMessage(messages.sidebarAddNewTab)}
|
||||
>
|
||||
<div className="mt-4">
|
||||
<AddNewContent />
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="add-existing"
|
||||
title={intl.formatMessage(messages.sidebarAddExistingTab)}
|
||||
>
|
||||
<div className="mt-4">
|
||||
<AddLibraryContent />
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</SidebarSection>
|
||||
</SidebarContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<LegacySidebar {...legacySidebarProps} />
|
||||
);
|
||||
}
|
||||
|
||||
const handleChangePage = (key: UnitSidebarPageKeys) => {
|
||||
// Resets the tab key
|
||||
setCurrentTabKey(undefined);
|
||||
// Change the page
|
||||
setCurrentPageKey(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
pages={UNIT_SIDEBAR_PAGES}
|
||||
pages={sidebarPages}
|
||||
currentPageKey={currentPageKey}
|
||||
setCurrentPageKey={setCurrentPageKey}
|
||||
setCurrentPageKey={handleChangePage}
|
||||
isOpen={isOpen}
|
||||
toggle={toggle}
|
||||
/>
|
||||
|
||||
@@ -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<UnitSidebarPageKeys, SidebarPage>;
|
||||
|
||||
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<UnitSidebarContextData | undefined>(undefined);
|
||||
|
||||
export const UnitSidebarProvider = ({ children }: { children?: React.ReactNode }) => {
|
||||
export const UnitSidebarProvider = ({
|
||||
children,
|
||||
readOnly,
|
||||
}: {
|
||||
children?: React.ReactNode,
|
||||
readOnly: boolean,
|
||||
}) => {
|
||||
const [currentPageKey, setCurrentPageKeyState] = useState<UnitSidebarPageKeys>('info');
|
||||
const [currentTabKey, setCurrentTabKey] = useState<string>();
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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<UnitSidebarPageKeys, SidebarPage> = {
|
||||
info: {
|
||||
component: UnitInfoSidebar,
|
||||
icon: Info,
|
||||
title: messages.sidebarButtonInfo,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
36
src/course-unit/unit-sidebar/sidebarPages.ts
Normal file
36
src/course-unit/unit-sidebar/sidebarPages.ts
Normal file
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -25,6 +25,7 @@ const Editor: React.FC<Props> = ({
|
||||
studioEndpointUrl,
|
||||
onClose = null,
|
||||
returnFunction = null,
|
||||
extraProps,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const loading = hooks.useInitializeApp({
|
||||
@@ -54,7 +55,7 @@ const Editor: React.FC<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <EditorComponent {...{ onClose, returnFunction }} />;
|
||||
return <EditorComponent {...{ onClose, returnFunction, extraProps }} />;
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
|
||||
@@ -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<string, any> | null;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const EditorPage: React.FC<Props> = ({
|
||||
studioEndpointUrl = null,
|
||||
onClose = null,
|
||||
returnFunction = null,
|
||||
extraProps = null,
|
||||
}) => (
|
||||
<Provider store={store}>
|
||||
<ErrorBoundary
|
||||
@@ -46,6 +47,7 @@ const EditorPage: React.FC<Props> = ({
|
||||
lmsEndpointUrl,
|
||||
studioEndpointUrl,
|
||||
returnFunction,
|
||||
extraProps,
|
||||
}}
|
||||
/>
|
||||
</EditorContextProvider>
|
||||
|
||||
@@ -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<Props> = ({
|
||||
onClose,
|
||||
openAdvanced = false,
|
||||
}) => {
|
||||
const [selected, setSelected] = React.useState<ProblemType | AdvancedProblemType>(ProblemTypeKeys.SINGLESELECT);
|
||||
const [selected, setSelected] = React.useState<ProblemType | AdvancedProblemType>(
|
||||
openAdvanced ? AdvanceProblemKeys.BLANK : ProblemTypeKeys.SINGLESELECT,
|
||||
);
|
||||
hooks.useArrowNav(selected, setSelected);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
}
|
||||
|
||||
if (problemType === null) {
|
||||
return (<SelectTypeModal onClose={onClose} />);
|
||||
return (<SelectTypeModal onClose={onClose} openAdvanced={extraProps?.problemType === 'advanced'} />);
|
||||
}
|
||||
|
||||
return (<EditProblemView returnFunction={returnFunction} />);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string, any>).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 {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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: #005C9E;
|
||||
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: var(--content-library-component-video-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
78
src/generic/sidebar/BlockCardButton.tsx
Normal file
78
src/generic/sidebar/BlockCardButton.tsx
Normal file
@@ -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 = (
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<span className={`icon-with-border icon-with-border-${blockType} p-2 rounded`}>
|
||||
<Icon size="lg" src={getItemIcon(blockType)} />
|
||||
</span>
|
||||
<span className="text-primary-700">
|
||||
{name}
|
||||
</span>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (templates?.length) {
|
||||
return (
|
||||
<div data-testid={`${blockType}-collapsible`}>
|
||||
<Collapsible
|
||||
styling="card-lg"
|
||||
className="mx-2 font-weight-bold shadow pl-1 rounded"
|
||||
title={titleComponent}
|
||||
>
|
||||
<Stack direction="horizontal" className="d-flex flex-wrap" gap={2}>
|
||||
{templates.map((template) => (
|
||||
<Chip onClick={() => onClickTemplate?.(template.boilerplateName)}>
|
||||
{template.displayName}
|
||||
</Chip>
|
||||
))}
|
||||
</Stack>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="mx-2 shadow border justify-content-between pl-4 font-weight-bold"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{titleComponent}
|
||||
<div className="mr-1">
|
||||
{actionIcon}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user