Commit fed2b97b authored by Robert Knight's avatar Robert Knight

Make `MenuItem` responsible for rendering associated submenus

To ensure consistency between different menus that contain items with
submenus, move the responsibility for rendering the submenu and its
container to the `MenuItem` component. The component that renders the
top-level `MenuItem` must supply a `renderSubmenu` prop that renders the
submenu on-demand.
parent 9028ce26
...@@ -9,7 +9,6 @@ const { withServices } = require('../util/service-context'); ...@@ -9,7 +9,6 @@ const { withServices } = require('../util/service-context');
const { copyText } = require('../util/copy-to-clipboard'); const { copyText } = require('../util/copy-to-clipboard');
const MenuItem = require('./menu-item'); const MenuItem = require('./menu-item');
const Slider = require('./slider');
/** /**
* An item in the groups selection menu. * An item in the groups selection menu.
...@@ -80,7 +79,6 @@ function GroupListItem({ ...@@ -80,7 +79,6 @@ function GroupListItem({
const collapseSubmenu = () => onExpand(false); const collapseSubmenu = () => onExpand(false);
return ( return (
<Fragment>
<MenuItem <MenuItem
icon={group.logo || 'blank'} icon={group.logo || 'blank'}
iconAlt={orgName(group)} iconAlt={orgName(group)}
...@@ -91,9 +89,8 @@ function GroupListItem({ ...@@ -91,9 +89,8 @@ function GroupListItem({
label={group.name} label={group.name}
onClick={isSelectable ? focusGroup : toggleSubmenu} onClick={isSelectable ? focusGroup : toggleSubmenu}
onToggleSubmenu={toggleSubmenu} onToggleSubmenu={toggleSubmenu}
/> renderSubmenu={() => (
<Slider visible={isExpanded}> <Fragment>
<div className="group-list-item__submenu">
<ul onClick={collapseSubmenu}> <ul onClick={collapseSubmenu}>
{activityUrl && ( {activityUrl && (
<li> <li>
...@@ -131,9 +128,9 @@ function GroupListItem({ ...@@ -131,9 +128,9 @@ function GroupListItem({
This group is restricted to specific URLs. This group is restricted to specific URLs.
</p> </p>
)} )}
</div>
</Slider>
</Fragment> </Fragment>
)}
/>
); );
} }
......
'use strict'; 'use strict';
const classnames = require('classnames'); const classnames = require('classnames');
const { createElement } = require('preact'); const { Fragment, createElement } = require('preact');
const propTypes = require('prop-types'); const propTypes = require('prop-types');
const { onActivate } = require('../util/on-activate'); const { onActivate } = require('../util/on-activate');
const Slider = require('./slider');
const SvgIcon = require('./svg-icon'); const SvgIcon = require('./svg-icon');
/** /**
...@@ -20,9 +21,8 @@ const SvgIcon = require('./svg-icon'); ...@@ -20,9 +21,8 @@ const SvgIcon = require('./svg-icon');
* The icon can either be an external SVG image, referenced by URL, or a named * The icon can either be an external SVG image, referenced by URL, or a named
* icon rendered by an `SvgIcon`. * icon rendered by an `SvgIcon`.
* *
* For items that have submenus, the `MenuItem` only provides an indicator as * For items that have submenus, the `MenuItem` will call the `renderSubmenu`
* to whether the submenu is open. The container is responsible for displaying * prop to render the content of the submenu, when the submenu is visible.
* the submenu items beneath the current item.
*/ */
function MenuItem({ function MenuItem({
href, href,
...@@ -36,6 +36,7 @@ function MenuItem({ ...@@ -36,6 +36,7 @@ function MenuItem({
label, label,
onClick, onClick,
onToggleSubmenu, onToggleSubmenu,
renderSubmenu,
}) { }) {
const iconClass = 'menu-item__icon'; const iconClass = 'menu-item__icon';
const iconIsUrl = icon && icon.indexOf('/') !== -1; const iconIsUrl = icon && icon.indexOf('/') !== -1;
...@@ -58,6 +59,9 @@ function MenuItem({ ...@@ -58,6 +59,9 @@ function MenuItem({
const rightIcon = isSubmenuItem ? renderedIcon : null; const rightIcon = isSubmenuItem ? renderedIcon : null;
return ( return (
// Wrapper element is a `<div>` rather than a `Fragment` to work around
// limitations of Enzyme's shallow rendering.
<div>
<div <div
aria-checked={isSelected} aria-checked={isSelected}
className={classnames('menu-item', { className={classnames('menu-item', {
...@@ -105,6 +109,12 @@ function MenuItem({ ...@@ -105,6 +109,12 @@ function MenuItem({
</div> </div>
)} )}
</div> </div>
{typeof isSubmenuVisible === 'boolean' && (
<Slider visible={isSubmenuVisible}>
<div className="menu-item__submenu">{renderSubmenu()}</div>
</Slider>
)}
</div>
); );
} }
...@@ -171,6 +181,12 @@ MenuItem.propTypes = { ...@@ -171,6 +181,12 @@ MenuItem.propTypes = {
* state of the menu. * state of the menu.
*/ */
onToggleSubmenu: propTypes.func, onToggleSubmenu: propTypes.func,
/**
* Function called to render the item's submenu when `isSubmenuVisible`
* is `true`.
*/
renderSubmenu: propTypes.func,
}; };
module.exports = MenuItem; module.exports = MenuItem;
...@@ -69,7 +69,6 @@ describe('GroupListItem', () => { ...@@ -69,7 +69,6 @@ describe('GroupListItem', () => {
GroupListItem.$imports.$mock({ GroupListItem.$imports.$mock({
'./menu-item': FakeMenuItem, './menu-item': FakeMenuItem,
'./slider': FakeSlider,
'../util/copy-to-clipboard': { '../util/copy-to-clipboard': {
copyText: fakeCopyText, copyText: fakeCopyText,
}, },
...@@ -236,19 +235,29 @@ describe('GroupListItem', () => { ...@@ -236,19 +235,29 @@ describe('GroupListItem', () => {
assert.isUndefined(wrapper.find('MenuItem').prop('isExpanded')); assert.isUndefined(wrapper.find('MenuItem').prop('isExpanded'));
}); });
function getSubmenu(wrapper) {
const renderSubmenu = wrapper
.find('MenuItem')
.first()
.prop('renderSubmenu');
return mount(<div>{renderSubmenu()}</div>);
}
it('does not show link to activity page if not available', () => { it('does not show link to activity page if not available', () => {
fakeGroup.links.html = null; fakeGroup.links.html = null;
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
assert.isFalse(wrapper.exists('MenuItem[label="View group activity"]')); const submenu = getSubmenu(wrapper);
assert.isFalse(submenu.exists('MenuItem[label="View group activity"]'));
}); });
it('shows link to activity page if available', () => { it('shows link to activity page if available', () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
assert.isTrue(wrapper.exists('MenuItem[label="View group activity"]')); const submenu = getSubmenu(wrapper);
assert.isTrue(submenu.exists('MenuItem[label="View group activity"]'));
}); });
it('does not show "Leave" action if user cannot leave', () => { it('does not show "Leave" action if user cannot leave', () => {
...@@ -256,7 +265,8 @@ describe('GroupListItem', () => { ...@@ -256,7 +265,8 @@ describe('GroupListItem', () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
assert.isFalse(wrapper.exists('MenuItem[label="Leave group"]')); const submenu = getSubmenu(wrapper);
assert.isFalse(submenu.exists('MenuItem[label="Leave group"]'));
}); });
it('shows "Leave" action if user can leave', () => { it('shows "Leave" action if user can leave', () => {
...@@ -264,14 +274,18 @@ describe('GroupListItem', () => { ...@@ -264,14 +274,18 @@ describe('GroupListItem', () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
assert.isTrue(wrapper.exists('MenuItem[label="Leave group"]')); const submenu = getSubmenu(wrapper);
assert.isTrue(submenu.exists('MenuItem[label="Leave group"]'));
}); });
it('prompts to leave group if "Leave" action is clicked', () => { it('prompts to leave group if "Leave" action is clicked', () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
clickMenuItem(wrapper, 'Leave group');
const submenu = getSubmenu(wrapper);
clickMenuItem(submenu, 'Leave group');
assert.called(window.confirm); assert.called(window.confirm);
assert.notCalled(fakeGroupsService.leave); assert.notCalled(fakeGroupsService.leave);
}); });
...@@ -281,7 +295,10 @@ describe('GroupListItem', () => { ...@@ -281,7 +295,10 @@ describe('GroupListItem', () => {
isExpanded: true, isExpanded: true,
}); });
window.confirm.returns(true); window.confirm.returns(true);
clickMenuItem(wrapper, 'Leave group');
const submenu = getSubmenu(wrapper);
clickMenuItem(submenu, 'Leave group');
assert.called(window.confirm); assert.called(window.confirm);
assert.calledWith(fakeGroupsService.leave, fakeGroup.id); assert.calledWith(fakeGroupsService.leave, fakeGroup.id);
}); });
...@@ -316,7 +333,9 @@ describe('GroupListItem', () => { ...@@ -316,7 +333,9 @@ describe('GroupListItem', () => {
.prop('isDisabled'), .prop('isDisabled'),
expectDisabled expectDisabled
); );
assert.equal(wrapper.exists('.group-list-item__footer'), expectDisabled);
const submenu = getSubmenu(wrapper);
assert.equal(submenu.exists('.group-list-item__footer'), expectDisabled);
}); });
}); });
...@@ -348,7 +367,8 @@ describe('GroupListItem', () => { ...@@ -348,7 +367,8 @@ describe('GroupListItem', () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
const copyAction = wrapper const submenu = getSubmenu(wrapper);
const copyAction = submenu
.find('MenuItem') .find('MenuItem')
.filterWhere(n => n.prop('label').startsWith('Copy')); .filterWhere(n => n.prop('label').startsWith('Copy'));
...@@ -364,7 +384,7 @@ describe('GroupListItem', () => { ...@@ -364,7 +384,7 @@ describe('GroupListItem', () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
clickMenuItem(wrapper, 'Copy invite link'); clickMenuItem(getSubmenu(wrapper), 'Copy invite link');
assert.calledWith(fakeCopyText, 'https://annotate.com/groups/groupid'); assert.calledWith(fakeCopyText, 'https://annotate.com/groups/groupid');
assert.calledWith(fakeFlash.info, 'Copied link for "Test"'); assert.calledWith(fakeFlash.info, 'Copied link for "Test"');
}); });
...@@ -374,7 +394,7 @@ describe('GroupListItem', () => { ...@@ -374,7 +394,7 @@ describe('GroupListItem', () => {
const wrapper = createGroupListItem(fakeGroup, { const wrapper = createGroupListItem(fakeGroup, {
isExpanded: true, isExpanded: true,
}); });
clickMenuItem(wrapper, 'Copy invite link'); clickMenuItem(getSubmenu(wrapper), 'Copy invite link');
assert.calledWith(fakeCopyText, 'https://annotate.com/groups/groupid'); assert.calledWith(fakeCopyText, 'https://annotate.com/groups/groupid');
assert.calledWith(fakeFlash.error, 'Unable to copy link'); assert.calledWith(fakeFlash.error, 'Unable to copy link');
}); });
......
...@@ -56,7 +56,11 @@ describe('MenuItem', () => { ...@@ -56,7 +56,11 @@ describe('MenuItem', () => {
}); });
it('shows the submenu indicator if `isSubmenuVisible` is a boolean', () => { it('shows the submenu indicator if `isSubmenuVisible` is a boolean', () => {
const wrapper = createMenuItem({ isSubmenuVisible: true }); const wrapper = createMenuItem({
isSubmenuVisible: true,
// eslint-disable-next-line react/display-name
renderSubmenu: () => <div>Submenu</div>,
});
assert.isTrue(wrapper.exists('SvgIcon[name="collapse-menu"]')); assert.isTrue(wrapper.exists('SvgIcon[name="collapse-menu"]'));
wrapper.setProps({ isSubmenuVisible: false }); wrapper.setProps({ isSubmenuVisible: false });
...@@ -70,7 +74,12 @@ describe('MenuItem', () => { ...@@ -70,7 +74,12 @@ describe('MenuItem', () => {
it('calls the `onToggleSubmenu` callback when the submenu toggle is clicked', () => { it('calls the `onToggleSubmenu` callback when the submenu toggle is clicked', () => {
const onToggleSubmenu = sinon.stub(); const onToggleSubmenu = sinon.stub();
const wrapper = createMenuItem({ isSubmenuVisible: true, onToggleSubmenu }); const wrapper = createMenuItem({
isSubmenuVisible: true,
onToggleSubmenu,
// eslint-disable-next-line react/display-name
renderSubmenu: () => <div>Submenu</div>,
});
wrapper.find('.menu-item__toggle').simulate('click'); wrapper.find('.menu-item__toggle').simulate('click');
assert.called(onToggleSubmenu); assert.called(onToggleSubmenu);
}); });
...@@ -98,4 +107,44 @@ describe('MenuItem', () => { ...@@ -98,4 +107,44 @@ describe('MenuItem', () => {
// The actual icon for the submenu should be shown on the right. // The actual icon for the submenu should be shown on the right.
assert.equal(iconSpaces.at(1).children().length, 1); assert.equal(iconSpaces.at(1).children().length, 1);
}); });
it('does not render submenu content if `isSubmenuVisible` is undefined', () => {
const wrapper = createMenuItem({});
assert.isFalse(wrapper.exists('Slider'));
});
it('shows submenu content if `isSubmenuVisible` is true', () => {
const wrapper = createMenuItem({
isSubmenuVisible: true,
// eslint-disable-next-line react/display-name
renderSubmenu: () => <div>Submenu content</div>,
});
assert.equal(wrapper.find('Slider').prop('visible'), true);
assert.equal(
wrapper
.find('Slider')
.children()
.text(),
'Submenu content'
);
});
it('hides submenu content if `isSubmenuVisible` is false', () => {
const wrapper = createMenuItem({
isSubmenuVisible: false,
// eslint-disable-next-line react/display-name
renderSubmenu: () => <div>Submenu content</div>,
});
assert.equal(wrapper.find('Slider').prop('visible'), false);
// The submenu content may still be rendered if the submenu is currently
// collapsing.
assert.equal(
wrapper
.find('Slider')
.children()
.text(),
'Submenu content'
);
});
}); });
.group-list-item__submenu {
border-bottom: solid 1px $grey-2;
}
// Footer to display at the bottom of a menu item. // Footer to display at the bottom of a menu item.
.group-list-item__footer { .group-list-item__footer {
background-color: $grey-1; background-color: $grey-1;
......
...@@ -148,3 +148,8 @@ $menu-item-padding: 10px; ...@@ -148,3 +148,8 @@ $menu-item-padding: 10px;
height: 12px; height: 12px;
} }
} }
// The container for open submenus
.menu-item__submenu {
border-bottom: solid 1px $grey-2;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment