Skip to content

Commit

Permalink
feat: insert link plugin tinymce
Browse files Browse the repository at this point in the history
  • Loading branch information
johnvente committed Jan 18, 2024
1 parent 4653322 commit d3cded8
Show file tree
Hide file tree
Showing 28 changed files with 1,398 additions and 20 deletions.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"fast-xml-parser": "^4.0.10",
"frontend-components-tinymce-advanced-plugins": "^1.0.2",
"lodash-es": "^4.17.21",
"lodash.clonedeep": "^4.5.0",
"lodash.flatten": "^4.4.0",
"moment": "^2.29.4",
"moment-shortformat": "^2.1.0",
Expand Down
1 change: 1 addition & 0 deletions src/editors/data/constants/tinyMCE.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const buttons = StrictDict({
undo: 'undo',
underline: 'underline',
a11ycheck: 'a11ycheck',
insertLink: 'insertlink',
});

export const plugins = listKeyStore([
Expand Down
41 changes: 41 additions & 0 deletions src/editors/sharedComponents/InsertLinkModal/BlockLink/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import { LinkOff } from '@edx/paragon/icons';
import { formatBlockPath } from '../utils';

import './index.scss';

const BlockLink = ({ path, onCloseLink }) => {
const { title, subTitle } = formatBlockPath(path);
return (
<div className="link-container d-flex row p-4 rounded border border-gray-400 mx-4 mt-3">
<div className="col-10">
<p className="text-left">{subTitle}</p>
<p className="h2 w-20 title">{title}</p>
</div>
<div className="col-2">
<Button
variant="tertiary"
className="d-flex justify-content-center align-items-center"
data-testid="close-link-button"
size="lg"
iconBefore={LinkOff}
onClick={onCloseLink}
>
&nbsp;
</Button>
</div>
</div>
);
};

BlockLink.defaultProps = {
onCloseLink: () => {},
};

BlockLink.propTypes = {
path: PropTypes.string.isRequired,
onCloseLink: PropTypes.func,
};

export default BlockLink;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.link-container {
.title {
overflow-wrap: break-word;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import { formatBlockPath } from '../utils';
import BlockLink from './index';

Enzyme.configure({ adapter: new Adapter() });

describe('BlockLink Component', () => {
const defaultProps = {
path: 'Some Path',
onCloseLink: jest.fn(),
};

test('renders with default props', () => {
const wrapper = shallow(<BlockLink {...defaultProps} />);
expect(wrapper.text()).toContain('Some Path');
});

test('renders correctly with custom path', () => {
const customProps = {
...defaultProps,
path: 'Custom Path',
};
const wrapper = shallow(<BlockLink {...customProps} />);
expect(wrapper.text()).toContain('Custom Path');
});

test('calls onCloseLink when the button is clicked', () => {
const wrapper = shallow(<BlockLink {...defaultProps} />);
wrapper.find({ 'data-testid': 'close-link-button' }).simulate('click');
expect(defaultProps.onCloseLink).toHaveBeenCalledTimes(1);
});

test('renders with valid title and subtitle', () => {
const customProps = {
path: 'Root Section / Child 1',
onCloseLink: jest.fn(),
};
const wrapper = shallow(<BlockLink {...customProps} />);
const { title, subTitle } = formatBlockPath(customProps.path);

expect(wrapper.text()).toContain(title);
expect(wrapper.text()).toContain(subTitle);
});
});
150 changes: 150 additions & 0 deletions src/editors/sharedComponents/InsertLinkModal/BlocksList/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { Button, TransitionReplace, ActionRow } from '@edx/paragon';
import { ArrowForwardIos, ArrowBack } from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';

import {
blockTypes,
getSectionsList,
getChildrenFromList,
} from '../utils';

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

const BlocksList = ({ blocks, onBlockSelected }) => {
const intl = useIntl();
const messagesTest = {
[blockTypes.section]: intl.formatMessage(
messages.blocksListSubsectionTitle,
),
[blockTypes.subsection]: intl.formatMessage(messages.blocksListUnitTitle),
[blockTypes.unit]: intl.formatMessage(messages.blocksListUnitTitle),
};

const [blockState, setBlockState] = useState({
blockSelected: {},
type: blockTypes.subsection,
hasNavigated: false,
blocksNavigated: [],
});

const sections = getSectionsList(blocks);
const subSections = getChildrenFromList(
blockState.blockSelected,
blocks,
);
const listItems = blockState.hasNavigated ? subSections : sections;

const isBlockSelectedUnit = blockState.type === blockTypes.unit;
const blockNameButtonClass = isBlockSelectedUnit ? 'col-12' : 'col-11';

const handleSelectBlock = (block, navigate = false) => {
if (navigate) {
setBlockState({
...blockState,
blocksNavigated: [...blockState.blocksNavigated, block.id],
blockSelected: block,
type: block.type,
hasNavigated: true,
});
} else {
onBlockSelected(block);
}
};

const handleGoBack = () => {
const newValue = blockState.blocksNavigated.filter(
(id) => id !== blockState.blockSelected.id,
);
if (newValue.length) {
const lastBlockIndex = newValue.length - 1;
const blockId = newValue[lastBlockIndex];
const newBlock = blocks[blockId];
setBlockState({
...blockState,
type: newBlock.type,
hasNavigated: true,
blockSelected: newBlock,
blocksNavigated: newValue,
});
} else {
setBlockState({
...blockState,
type: blockState.section,
hasNavigated: false,
blockSelected: {},
});
}
};

return (
<>
{blockState.hasNavigated && (
<ActionRow className="w-100 d-flex justify-content-space-between p-3">
<Button
variant="tertiary"
className="col-1"
onClick={handleGoBack}
iconBefore={ArrowBack}
>
&nbsp;
</Button>

<p className="col-11 text-center">{messagesTest[blockState.type]}</p>
</ActionRow>
)}
<div className="block-list-container">
{listItems.map((block) => (
<TransitionReplace key={`transition_${block.id}`}>
<ActionRow
key={block.id}
className="w-100 d-flex justify-content-space-between p-3"
>
<Button
variant="tertiary"
className={`${blockNameButtonClass} py-4`}
onClick={() => handleSelectBlock(block)}
>
<span className="w-100 text-left">{block.displayName}</span>
</Button>
{!isBlockSelectedUnit && (
<Button
variant="tertiary"
className="col-1 py-4"
onClick={() => handleSelectBlock(block, true)}
iconAfter={ArrowForwardIos}
>
&nbsp;
</Button>
)}
</ActionRow>
</TransitionReplace>
))}
</div>
</>
);
};

BlocksList.defaultProps = {
onBlockSelected: () => {},
};

const blockShape = PropTypes.shape({
id: PropTypes.string.isRequired,
blockId: PropTypes.string.isRequired,
lmsWebUrl: PropTypes.string.isRequired,
legacyWebUrl: PropTypes.string.isRequired,
studentViewUrl: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
children: PropTypes.arrayOf(PropTypes.string),
});

BlocksList.propTypes = {
blocks: PropTypes.objectOf(blockShape).isRequired,
onBlockSelected: PropTypes.func,
};

export default BlocksList;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.block-list-container {
height: 200px;
overflow-y: auto;
overflow-x: none;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import Enzyme, { shallow } from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';

import BlocksList from '.';

Enzyme.configure({ adapter: new Adapter() });

const mockBlocks = {
block1: { id: 'block1', path: 'Block 1', type: 'section' },
block2: { id: 'block2', path: 'Block 2', type: 'subsection' },
};

describe('BlocksList Component', () => {
// eslint-disable-next-line react/prop-types
const IntlProviderWrapper = ({ children }) => (
<IntlProvider locale="en" messages={{}}>
{children}
</IntlProvider>
);

test('renders without crashing', () => {
const wrapper = shallow(
<IntlProviderWrapper>
<BlocksList blocks={mockBlocks} onBlockSelected={() => {}} />
</IntlProviderWrapper>,
);
expect(wrapper.exists()).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

const messages = defineMessages({
blocksListSubsectionTitle: {
id: 'blocks.list.subsection.title',
defaultMessage: 'Subsections',
description: 'Title for the subsections blocks',
},
blocksListUnitTitle: {
id: 'blocks.list.unit.title',
defaultMessage: 'Units',
description: 'Title for the units blocks',
},
});

export default messages;
47 changes: 47 additions & 0 deletions src/editors/sharedComponents/InsertLinkModal/FilterBlock/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Button } from '@edx/paragon';
import PropTypes from 'prop-types';

import { formatBlockPath } from '../utils';

const FilterBlock = ({ block, onBlockFilterClick }) => {
const { title, subTitle } = formatBlockPath(block.path);

const handleBlockClick = () => {
onBlockFilterClick(block);
};

return (
<Button
key={`filtered_block_${block.id}`}
variant="tertiary"
className="d-flex flex-column w-100 align-items-start p-3"
onClick={handleBlockClick}
data-testid="filter-block-item"
>
<span className="h5 text-left">{subTitle}</span>
<span className="h3">{title}</span>
</Button>
);
};

FilterBlock.defaultProps = {
onBlockFilterClick: () => {},
};

const blockShape = PropTypes.shape({
id: PropTypes.string.isRequired,
blockId: PropTypes.string.isRequired,
lmsWebUrl: PropTypes.string.isRequired,
legacyWebUrl: PropTypes.string.isRequired,
studentViewUrl: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
children: PropTypes.arrayOf(PropTypes.string),
});

FilterBlock.propTypes = {
block: PropTypes.objectOf(blockShape).isRequired,
onBlockFilterClick: PropTypes.func,
};

export default FilterBlock;
Loading

0 comments on commit d3cded8

Please sign in to comment.