Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/editors/sharedComponents/CodeEditor/EditorToolbar.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
.editor-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px;
background: #D1DAE5;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering if we can use design tokens here instead of the raw values on this file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not get it. can you please give me example?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the pr on which design tokens were added to the repo #2187, it is basically to use predefined variables instead of hardcoded values, but is just a nice to have.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

border: 1px solid #D0D5DD;
border-bottom: none;
border-radius: 4px 4px 0 0;

.toolbar-button {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
border-radius: 3px;
padding: 6px 5px;
font-size: 11px;
font-weight: 500;
color: #222222;
cursor: pointer;
transition: background .2s;

svg {
font-size: 14px;
}

&:hover {
background: #202021;
}

&:active {
background: #CBD5E1;
}
}

.toolbar-separator {
width: 1px;
height: 20px;
background: #CCCCCC;
margin: 0 4px;
}

.editor-icon {
font-weight: bold;
font-size: 12px;
}

.editor-btn-dropdown {
gap: 0;
}
}
136 changes: 136 additions & 0 deletions src/editors/sharedComponents/CodeEditor/EditorToolbar.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import EditorToolbar from './EditorToolbar';
import messages from './messages';

describe('<EditorToolbar />', () => {
let mockDispatch;
let mockFocus;
let mockEditor;

const renderWithIntl = (ui) => render(
<IntlProvider
locale="en"
messages={Object.fromEntries(
Object.entries(messages).map(([, v]) => [v.id, v.defaultMessage]),
)}
>
{ui}
</IntlProvider>,
);

beforeEach(() => {
mockDispatch = jest.fn();
mockFocus = jest.fn();
mockEditor = {
state: {
selection: { main: { from: 0, to: 0 } },
},
dispatch: mockDispatch,
focus: mockFocus,
};
});

it('renders all toolbar buttons', () => {
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);

expect(screen.getByRole('button', { name: /Heading/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Multiple Choice/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Checkboxes/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Text Input/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Numerical Input/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Dropdown/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Explanation/i })).toBeInTheDocument();
});

it('inserts heading text when heading button is clicked', () => {
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
fireEvent.click(screen.getByRole('button', { name: /Heading/i }));

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({ insert: '## Heading\n\n' }),
}),
);
expect(mockFocus).toHaveBeenCalled();
});

it('inserts multiple choice when multiple choice button is clicked', () => {
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
fireEvent.click(screen.getByRole('button', { name: /Multiple Choice/i }));

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({
insert: expect.stringContaining('( ) Option 1'),
}),
}),
);
});

it('inserts checkboxes when checkboxes button is clicked', () => {
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
fireEvent.click(screen.getByRole('button', { name: /Checkboxes/i }));

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({
insert: expect.stringContaining('[ ] Incorrect'),
}),
}),
);
});

it('inserts text input when text input button is clicked', () => {
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
fireEvent.click(screen.getByRole('button', { name: /Text Input/i }));

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({
insert: expect.stringContaining('Type your answer here:'),
}),
}),
);
});

it('inserts numerical input when numerical input button is clicked', () => {
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
fireEvent.click(screen.getByRole('button', { name: /Numerical Input/i }));

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({
insert: expect.stringContaining('= 100'),
}),
}),
);
});

it('inserts dropdown when dropdown button is clicked', () => {
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
fireEvent.click(screen.getByRole('button', { name: /Dropdown/i }));

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({
insert: expect.stringContaining('[Dropdown:'),
}),
}),
);
});

it('inserts explanation when explanation button is clicked', () => {
renderWithIntl(<EditorToolbar editorRef={mockEditor} />);
fireEvent.click(screen.getByRole('button', { name: /Explanation/i }));

expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({
insert: expect.stringContaining('>> Add explanation'),
}),
}),
);
});
});
88 changes: 88 additions & 0 deletions src/editors/sharedComponents/CodeEditor/EditorToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from 'react';
import { Button, Icon } from '@openedx/paragon';
import { EditorView } from '@codemirror/view';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
CheckBoxIcon, Lightbulb, ArrowDropDown, ViewList,
} from '@openedx/paragon/icons';

import messages from './messages';
import {
HEADING,
MULTIPLE_CHOICE,
CHECKBOXES,
TEXT_INPUT,
NUMERICAL_INPUT,
DROPDOWN,
EXPLANATION,
} from './constants';

import './EditorToolbar.scss';

// ✅ Define props type
interface EditorToolbarProps {
editorRef: EditorView | null;
}

const EditorToolbar: React.FC<EditorToolbarProps> = ({ editorRef }) => {
const intl = useIntl();

const insertAtCursor = (text: string) => {
if (!editorRef) { return; }
const { from, to } = editorRef.state.selection.main;
editorRef.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
});
editorRef.focus();
};

return (
<div className="editor-toolbar">
<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(HEADING)}>
<span className="editor-icon">{'<H>'}</span>{' '}
{intl.formatMessage(messages.editorToolbarHeadingButtonLabel)}
</Button>

<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(MULTIPLE_CHOICE)}>
<Icon src={ViewList} size="sm" className="toolbar-icon" />{' '}
{intl.formatMessage(messages.editorToolbarMultipleChoiceButtonLabel)}
</Button>

<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(CHECKBOXES)}>
<Icon src={CheckBoxIcon} size="sm" className="toolbar-icon" />{' '}
{intl.formatMessage(messages.editorToolbarCheckboxButtonLabel)}
</Button>

<span className="toolbar-separator" />

<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(TEXT_INPUT)}>
<span className="editor-icon">ABC</span>{' '}
{intl.formatMessage(messages.editorToolbarTextButtonLabel)}
</Button>

<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(NUMERICAL_INPUT)}>
<span className="editor-icon">123</span>{' '}
{intl.formatMessage(messages.editorToolbarNumericalButtonLabel)}
</Button>

<Button
type="button"
className="toolbar-button editor-btn-dropdown"
onClick={() => insertAtCursor(DROPDOWN)}
>
<Icon src={ArrowDropDown} size="sm" className="toolbar-icon" />{' '}
{intl.formatMessage(messages.editorToolbarDropdownButtonLabel)}
</Button>

<span className="toolbar-separator" />

<Button type="button" className="toolbar-button" onClick={() => insertAtCursor(EXPLANATION)}>
<Icon src={Lightbulb} size="sm" className="toolbar-icon" />{' '}
{intl.formatMessage(messages.editorToolbarExplanationButtonLabel)}
</Button>
</div>
);
};

export default EditorToolbar;
8 changes: 8 additions & 0 deletions src/editors/sharedComponents/CodeEditor/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ const alphanumericMap = {
quot: '"',
};
export default alphanumericMap;

export const HEADING = '## Heading\n\n';
export const MULTIPLE_CHOICE = '( ) Option 1\n( ) Option 2\n(x) Correct\n';
export const CHECKBOXES = '[ ] Incorrect\n[x] Correct\n';
export const TEXT_INPUT = 'Type your answer here: ___\n';
export const NUMERICAL_INPUT = '= 100 +-5\n';
export const DROPDOWN = '[Dropdown: Option A\nOption B\nCorrect Option* ]\n';
export const EXPLANATION = '>> Add explanation text here <<\n';
2 changes: 2 additions & 0 deletions src/editors/sharedComponents/CodeEditor/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const createCodeMirrorDomNode = ({
initialText,
upstreamRef,
lang,
onReady,
}) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
Expand All @@ -117,6 +118,7 @@ export const createCodeMirrorDomNode = ({
const view = new EditorView({ state: newState, parent: ref.current });
// eslint-disable-next-line no-param-reassign
upstreamRef.current = view;
onReady?.(view);
view.focus();

return () => {
Expand Down
10 changes: 8 additions & 2 deletions src/editors/sharedComponents/CodeEditor/index.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';

import {
Button,
} from '@openedx/paragon';

import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import MarkdownToolbar from './EditorToolbar';

import messages from './messages';
import './index.scss';

Expand All @@ -19,13 +21,17 @@ const CodeEditor = ({
const intl = useIntl();
const DOMref = useRef();
const btnRef = useRef();

const [editor, setEditor] = useState(null);

hooks.createCodeMirrorDomNode({
ref: DOMref, initialText: value, upstreamRef: innerRef, lang,
ref: DOMref, initialText: value, upstreamRef: innerRef, lang, onReady: setEditor,
});
const { showBtnEscapeHTML, hideBtn } = hooks.prepareShowBtnEscapeHTML();

return (
<div>
{lang === 'markdown' && <MarkdownToolbar editorRef={editor} />}
<div id="CodeMirror" ref={DOMref} />
{showBtnEscapeHTML && lang !== 'markdown' && (
<Button
Expand Down
35 changes: 35 additions & 0 deletions src/editors/sharedComponents/CodeEditor/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,41 @@ const messages = defineMessages({
defaultMessage: 'Unescape HTML Literals',
description: 'Label For escape special html charectars button',
},
editorToolbarHeadingButtonLabel: {
id: 'authoring.texteditor.codeEditor.toolbar.headingButton',
defaultMessage: 'Heading',
description: 'Label for toolbar heading button',
},
editorToolbarMultipleChoiceButtonLabel: {
id: 'authoring.texteditor.codeEditor.toolbar.multipleChoiceButton',
defaultMessage: 'Multiple Choice',
description: 'Label for toolbar multiple choice button',
},
editorToolbarCheckboxButtonLabel: {
id: 'authoring.texteditor.codeEditor.toolbar.checkboxButton',
defaultMessage: 'Checkboxes',
description: 'Label for toolbar checkboxes button',
},
editorToolbarTextButtonLabel: {
id: 'authoring.texteditor.codeEditor.toolbar.textButton',
defaultMessage: 'Text Input',
description: 'Label for toolbar text button',
},
editorToolbarNumericalButtonLabel: {
id: 'authoring.texteditor.codeEditor.toolbar.numericalButton',
defaultMessage: 'Numerical Input',
description: 'Label for toolbar numerical button',
},
editorToolbarDropdownButtonLabel: {
id: 'authoring.texteditor.codeEditor.toolbar.dropdownButton',
defaultMessage: 'Dropdown',
description: 'Label for toolbar dropdown button',
},
editorToolbarExplanationButtonLabel: {
id: 'authoring.texteditor.codeEditor.toolbar.explanationButton',
defaultMessage: 'Explanation',
description: 'Label for toolbar explanation button',
},
});

export default messages;