feat: Unit Add sidebar [FC-0114] (#2837)

Implements the new Unit add Sidebar
This commit is contained in:
Chris Chávez
2026-02-09 13:17:04 -05:00
committed by GitHub
parent a16087a1d0
commit 2d5421e09f
23 changed files with 1213 additions and 178 deletions

View File

@@ -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>
/>
);
};

View File

@@ -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();
});
});

View File

@@ -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}

View File

@@ -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');
});
});

View File

@@ -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>

View File

@@ -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]);

View 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>
);
};

View File

@@ -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}
/>

View File

@@ -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,
],
);

View File

@@ -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,
},
};

View File

@@ -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;

View 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,
},
}),
};
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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} />);

View File

@@ -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));

View File

@@ -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 {

View File

@@ -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',
};

View File

@@ -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);
}
}

View File

@@ -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;

View 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>
);
};