Unverified Commit bc7085ce authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #1148 from hypothesis/new-group-menu

Implement new groups menu design built on groups menu
parents 521b1ffd 6ad6dd58
'use strict';
const classnames = require('classnames');
const { Fragment, createElement } = require('preact');
const { useState } = require('preact/hooks');
const propTypes = require('prop-types');
const {
orgName,
trackViewGroupActivity,
} = require('../util/group-list-item-common');
const { withServices } = require('../util/service-context');
const outOfScopeIcon = (
<svg
className="svg-icon group-list-item-out-of-scope__icon--unavailable"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0V0z" />
<path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" />
</svg>
);
function GroupListItemOutOfScope({ analytics, group }) {
const [isExpanded, setExpanded] = useState(false);
const toggleGroupDetails = event => {
event.stopPropagation();
setExpanded(!isExpanded);
};
const groupOrgName = orgName(group);
const trackViewActivity = event => {
event.stopPropagation();
trackViewGroupActivity(analytics);
};
return (
<div
className="group-list-item__item group-list-item-out-of-scope__item"
onClick={toggleGroupDetails}
tabIndex="0"
>
{/* the group icon */}
<div className="group-list-item__icon-container">
{group.logo && (
<img
className="group-list-item__icon group-list-item__icon--organization"
alt={groupOrgName}
src={group.logo}
/>
)}
</div>
{/* the group name */}
<div
className={classnames({
'group-list-item-out-of-scope__details': true,
expanded: isExpanded,
})}
>
{outOfScopeIcon}
<a
className="group-list-item__name-link"
href=""
title="This URL cannot be annotated in this group."
>
{group.name}
</a>
<br />
{/* explanation of why group is not available */}
{!isExpanded && (
<p className="group-list-item-out-of-scope__details-toggle">
Why is this group unavailable?
</p>
)}
{isExpanded && (
<Fragment>
<p className="group-list-item-out-of-scope__details-unavailable-message">
This group has been restricted to selected URLs by its
administrators.
</p>
{group.links.html && (
<p className="group-list-item-out-of-scope__details-actions">
<a
className="button button--text group-list-item-out-of-scope__details-group-page-link"
href={group.links.html}
target="_blank"
onClick={trackViewActivity}
rel="noopener noreferrer"
>
Go to group page
</a>
</p>
)}
</Fragment>
)}
</div>
</div>
);
}
GroupListItemOutOfScope.propTypes = {
group: propTypes.object,
analytics: propTypes.object,
};
GroupListItemOutOfScope.injectedProps = ['analytics'];
module.exports = withServices(GroupListItemOutOfScope);
'use strict';
const classnames = require('classnames');
const propTypes = require('prop-types');
const { createElement } = require('preact');
const { Fragment, createElement } = require('preact');
const { useState } = require('preact/hooks');
const useStore = require('../store/use-store');
const { orgName } = require('../util/group-list-item-common');
const { withServices } = require('../util/service-context');
function GroupListItem({ analytics, group }) {
const MenuItem = require('./menu-item');
/**
* An item in the groups selection menu.
*
* The item has a primary action which selects the group, along with a set of
* secondary actions accessible via a toggle menu.
*/
function GroupListItem({
analytics,
defaultSubmenuOpen = false,
group,
groups: groupsService,
}) {
const canLeaveGroup = group.type === 'private';
const activityUrl = group.links.html;
const hasActionMenu = activityUrl || canLeaveGroup;
const isSelectable = !group.scopes.enforced || group.isScopedToUri;
const [isExpanded, setExpanded] = useState(
hasActionMenu ? defaultSubmenuOpen : undefined
);
const focusedGroupId = useStore(store => store.focusedGroupId());
const isSelected = group.id === focusedGroupId;
const actions = useStore(store => ({
clearDirectLinkedGroupFetchFailed: store.clearDirectLinkedGroupFetchFailed,
clearDirectLinkedIds: store.clearDirectLinkedIds,
......@@ -22,53 +46,86 @@ function GroupListItem({ analytics, group }) {
actions.focusGroup(group.id);
};
const focusedGroupId = useStore(store => store.focusedGroupId());
const isSelected = group.id === focusedGroupId;
const groupOrgName = orgName(group);
const leaveGroup = () => {
const message = `Are you sure you want to leave the group "${group.name}"?`;
if (window.confirm(message)) {
analytics.track(analytics.events.GROUP_LEAVE);
groupsService.leave(group.id);
}
};
const toggleSubmenu = event => {
event.stopPropagation();
// Prevents group items opening a new window when clicked.
// TODO - Fix this more cleanly in `MenuItem`.
event.preventDefault();
setExpanded(!isExpanded);
};
// Close the submenu when any clicks happen which close the top-level menu.
const collapseSubmenu = () => setExpanded(false);
return (
<div
className={classnames({
'group-list-item__item': true,
'is-selected': isSelected,
})}
onClick={focusGroup}
tabIndex="0"
>
{/* the group icon */}
<div className="group-list-item__icon-container">
{group.logo && (
<img
className="group-list-item__icon group-list-item__icon--organization"
alt={groupOrgName}
src={group.logo}
<Fragment>
<MenuItem
icon={group.logo || null}
iconAlt={orgName(group)}
isDisabled={!isSelectable}
isExpanded={isExpanded}
isSelected={isSelected}
isSubmenuVisible={isExpanded}
label={group.name}
onClick={isSelectable ? focusGroup : toggleSubmenu}
onToggleSubmenu={toggleSubmenu}
/>
{isExpanded && (
<Fragment>
<ul onClick={collapseSubmenu}>
{activityUrl && (
<li>
<MenuItem
href={activityUrl}
icon="share"
isSubmenuItem={true}
label="View group activity"
/>
</li>
)}
</div>
{/* the group name */}
<div className="group-list-item__details">
<a
className="group-list-item__name-link"
href=""
title={
group.type === 'private'
? `Show and create annotations in ${group.name}`
: 'Show public annotations'
}
>
{group.name}
</a>
</div>
</div>
{canLeaveGroup && (
<li>
<MenuItem
icon="leave"
isSubmenuItem={true}
label="Leave group"
onClick={leaveGroup}
/>
</li>
)}
</ul>
{!isSelectable && (
<p className="group-list-item__footer">
This group is restricted to specific URLs.
</p>
)}
</Fragment>
)}
</Fragment>
);
}
GroupListItem.propTypes = {
group: propTypes.object.isRequired,
/** Whether the submenu is open when the item is initially rendered. */
defaultSubmenuOpen: propTypes.bool,
// Injected services.
analytics: propTypes.object.isRequired,
groups: propTypes.object.isRequired,
};
GroupListItem.injectedProps = ['analytics'];
GroupListItem.injectedProps = ['analytics', 'groups'];
module.exports = withServices(GroupListItem);
'use strict';
const { Fragment, createElement } = require('preact');
const { createElement } = require('preact');
const propTypes = require('prop-types');
const GroupListItem = require('./group-list-item');
const GroupListItemOutOfScope = require('./group-list-item-out-of-scope');
const MenuSection = require('./menu-section');
/**
* A labeled section of the groups list.
*/
function GroupListSection({ groups, heading }) {
const isSelectable = groupId => {
const group = groups.find(g => g.id === groupId);
return !group.scopes.enforced || group.isScopedToUri;
};
return (
<Fragment>
<h2 className="group-list-section__heading">{heading}</h2>
<ul className="group-list-section__content">
<MenuSection heading={heading}>
{groups.map(group => (
<li
className="dropdown-menu__row dropdown-menu__row--no-border dropdown-menu__row--unpadded"
key={group.id}
>
{isSelectable(group.id) ? (
<GroupListItem className="group-list-item" group={group} />
) : (
<GroupListItemOutOfScope
className="group-list-item-out-of-scope"
group={group}
/>
)}
</li>
<GroupListItem key={group.id} group={group} />
))}
</ul>
</Fragment>
</MenuSection>
);
}
......
'use strict';
const { createElement } = require('preact');
const { useMemo } = require('preact/hooks');
const propTypes = require('prop-types');
const isThirdPartyService = require('../util/is-third-party-service');
const { isThirdPartyUser } = require('../util/account-id');
const groupsByOrganization = require('../util/group-organizations');
const useStore = require('../store/use-store');
const { withServices } = require('../util/service-context');
const serviceConfig = require('../service-config');
const Menu = require('./menu');
const MenuItem = require('./menu-item');
const GroupListSection = require('./group-list-section');
/**
* Return the custom icon for the top bar configured by the publisher in
* the Hypothesis client configuration.
*/
function publisherProvidedIcon(settings) {
const svc = serviceConfig(settings);
return svc && svc.icon ? svc.icon : null;
}
/**
* Menu allowing the user to select which group to show and also access
* additional actions related to groups.
*/
function GroupList({ serviceUrl, settings }) {
const currentGroups = useStore(store => store.getCurrentlyViewingGroups());
const featuredGroups = useStore(store => store.getFeaturedGroups());
const myGroups = useStore(store => store.getMyGroups());
const focusedGroup = useStore(store => store.focusedGroup());
const userid = useStore(store => store.profile().userid);
const myGroupsSorted = useMemo(() => groupsByOrganization(myGroups), [
myGroups,
]);
const featuredGroupsSorted = useMemo(
() => groupsByOrganization(featuredGroups),
[featuredGroups]
);
const currentGroupsSorted = useMemo(
() => groupsByOrganization(currentGroups),
[currentGroups]
);
const { authDomain } = settings;
const canCreateNewGroup = userid && !isThirdPartyUser(userid, authDomain);
const newGroupLink = serviceUrl('groups.new');
let label;
if (focusedGroup) {
const icon = focusedGroup.organization.logo;
label = (
<span>
<img
className="group-list-label__icon group-list-label__icon--organization"
src={icon || publisherProvidedIcon(settings)}
/>
<span className="group-list-label__label">{focusedGroup.name}</span>
</span>
);
} else {
label = <span></span>;
}
// If there is only one group and no actions available for that group,
// just show the group name as a label.
const actionsAvailable = !isThirdPartyService(settings);
if (
!actionsAvailable &&
currentGroups.length + featuredGroups.length + myGroups.length < 2
) {
return label;
}
return (
<Menu
align="left"
contentClass="group-list-v2__content"
label={label}
title="Select group"
>
{currentGroupsSorted.length > 0 && (
<GroupListSection
heading="Currently Viewing"
groups={currentGroupsSorted}
/>
)}
{featuredGroupsSorted.length > 0 && (
<GroupListSection
heading="Featured Groups"
groups={featuredGroupsSorted}
/>
)}
{myGroupsSorted.length > 0 && (
<GroupListSection heading="My Groups" groups={myGroupsSorted} />
)}
{canCreateNewGroup && (
<MenuItem
icon="add-group"
href={newGroupLink}
label="New private group"
style="shaded"
/>
)}
</Menu>
);
}
GroupList.propTypes = {
serviceUrl: propTypes.func,
settings: propTypes.object,
};
GroupList.injectedProps = ['serviceUrl', 'settings'];
module.exports = withServices(GroupList);
......@@ -8,12 +8,6 @@ const groupsByOrganization = require('../util/group-organizations');
const groupOrganizations = memoize(groupsByOrganization);
const myGroupOrgs = memoize(groupsByOrganization);
const featuredGroupOrgs = memoize(groupsByOrganization);
const currentlyViewingGroupOrgs = memoize(groupsByOrganization);
// @ngInject
function GroupListController(
$window,
......@@ -21,8 +15,7 @@ function GroupListController(
features,
groups,
settings,
serviceUrl,
store
serviceUrl
) {
this.groups = groups;
......@@ -66,18 +59,6 @@ function GroupListController(
return groupOrganizations(this.groups.all());
};
this.currentlyViewingGroupOrganizations = function() {
return currentlyViewingGroupOrgs(store.getCurrentlyViewingGroups());
};
this.featuredGroupOrganizations = function() {
return featuredGroupOrgs(store.getFeaturedGroups());
};
this.myGroupOrganizations = function() {
return myGroupOrgs(store.getMyGroups());
};
this.viewGroupActivity = function() {
analytics.track(analytics.events.GROUP_VIEW_ACTIVITY);
};
......
......@@ -45,6 +45,7 @@ let ignoreNextClick = false;
function Menu({
align = 'left',
children,
contentClass,
defaultOpen = false,
label,
menuIndicator = true,
......@@ -117,7 +118,9 @@ function Menu({
>
{label}
{menuIndicator && (
<span className="menu__toggle-arrow">
<span
className={classnames('menu__toggle-arrow', isOpen && 'is-open')}
>
<SvgIcon name="expand-menu" className="menu__toggle-icon" />
</span>
)}
......@@ -128,7 +131,8 @@ function Menu({
<div
className={classnames(
'menu__content',
`menu__content--align-${align}`
`menu__content--align-${align}`,
contentClass
)}
role="menu"
>
......@@ -161,10 +165,12 @@ Menu.propTypes = {
* These are typically `MenuSection` and `MenuItem` components, but other
* custom content is also allowed.
*/
children: propTypes.oneOfType([
propTypes.object,
propTypes.arrayOf(propTypes.object),
]),
children: propTypes.any,
/**
* Additional CSS classes to apply to the menu.
*/
contentClass: propTypes.string,
/**
* Whether the menu is open or closed when initially rendered.
......
'use strict';
const { mount } = require('enzyme');
const { createElement } = require('preact');
const { events } = require('../../services/analytics');
const GroupListItemOutOfScope = require('../group-list-item-out-of-scope');
describe('GroupListItemOutOfScope', () => {
let fakeAnalytics;
let fakeGroupListItemCommon;
const fakeGroup = {
id: 'groupid',
links: {
html: 'https://hypothes.is/groups/groupid',
},
logo: 'dummy://hypothes.is/logo.svg',
organization: { name: 'org' },
};
// Click on the item to expand or collapse it.
const toggle = wrapper =>
wrapper
.find('div')
.first()
.simulate('click');
beforeEach(() => {
fakeAnalytics = {
track: sinon.stub(),
events,
};
fakeGroupListItemCommon = {
orgName: sinon.stub(),
trackViewGroupActivity: sinon.stub(),
};
GroupListItemOutOfScope.$imports.$mock({
'../util/group-list-item-common': fakeGroupListItemCommon,
});
});
afterEach(() => {
GroupListItemOutOfScope.$imports.$restore();
});
const createGroupListItemOutOfScope = fakeGroup => {
return mount(
<GroupListItemOutOfScope analytics={fakeAnalytics} group={fakeGroup} />
);
};
it('calls trackViewGroupActivity when "Go to group page" link is clicked', () => {
const wrapper = createGroupListItemOutOfScope(fakeGroup);
toggle(wrapper);
const link = wrapper
.find('a')
.filterWhere(link => link.text() === 'Go to group page');
link.simulate('click');
assert.calledWith(
fakeGroupListItemCommon.trackViewGroupActivity,
fakeAnalytics
);
});
it('does not show "Go to group page" link if the group has no HTML link', () => {
const group = { ...fakeGroup, links: {} };
const wrapper = createGroupListItemOutOfScope(group);
const link = wrapper
.find('a')
.filterWhere(link => link.text() === 'Go to group page');
assert.isFalse(link.exists());
});
it('sets alt text of logo', () => {
fakeGroupListItemCommon.orgName
.withArgs(fakeGroup)
.returns(fakeGroup.organization.name);
const wrapper = createGroupListItemOutOfScope(fakeGroup);
const orgName = wrapper.find('img').props().alt;
assert.equal(orgName, fakeGroup.organization.name);
});
it('toggles expanded state when clicked', () => {
const wrapper = createGroupListItemOutOfScope(fakeGroup);
assert.isFalse(wrapper.exists('.expanded'));
toggle(wrapper);
assert.isTrue(wrapper.exists('.expanded'));
toggle(wrapper);
assert.isFalse(wrapper.exists('.expanded'));
});
});
'use strict';
const { createElement } = require('preact');
const { act } = require('preact/test-utils');
const { mount } = require('enzyme');
const GroupListItem = require('../group-list-item');
......@@ -9,10 +10,24 @@ const { events } = require('../../services/analytics');
describe('GroupListItem', () => {
let fakeAnalytics;
let fakeGroupsService;
let fakeStore;
let fakeGroupListItemCommon;
let fakeGroup;
beforeEach(() => {
fakeGroup = {
id: 'groupid',
name: 'Test',
links: {
html: 'https://annotate.com/groups/groupid',
},
scopes: {
enforced: false,
},
type: 'private',
};
fakeStore = {
focusGroup: sinon.stub(),
focusedGroupId: sinon.stub().returns('groupid'),
......@@ -29,69 +44,90 @@ describe('GroupListItem', () => {
orgName: sinon.stub(),
};
fakeGroupsService = {
leave: sinon.stub(),
};
function FakeMenuItem() {
return null;
}
FakeMenuItem.displayName = 'MenuItem';
GroupListItem.$imports.$mock({
'./menu-item': FakeMenuItem,
'../util/group-list-item-common': fakeGroupListItemCommon,
'../store/use-store': callback => callback(fakeStore),
});
sinon.stub(window, 'confirm').returns(false);
});
afterEach(() => {
GroupListItem.$imports.$restore();
window.confirm.restore();
});
const createGroupListItem = fakeGroup => {
const createGroupListItem = (fakeGroup, props = {}) => {
// nb. Mount rendering is used here with a manually mocked `MenuItem`
// because `GroupListItem` renders multiple top-level elements (wrapped in
// a fragment) and `wrapper.update()` cannot be used in that case when using
// shallow rendering.
return mount(
<GroupListItem
group={fakeGroup}
groups={fakeGroupsService}
analytics={fakeAnalytics}
store={fakeStore}
{...props}
/>
);
};
it('changes the focused group when group is clicked', () => {
const fakeGroup = { id: 'groupid' };
const wrapper = createGroupListItem(fakeGroup);
wrapper.find('.group-list-item__item').simulate('click');
wrapper
.find('MenuItem')
.props()
.onClick();
assert.calledWith(fakeStore.focusGroup, fakeGroup.id);
assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.GROUP_SWITCH);
});
it('clears the direct linked ids from the store when the group is clicked', () => {
const fakeGroup = { id: 'groupid' };
const wrapper = createGroupListItem(fakeGroup);
wrapper.find('.group-list-item__item').simulate('click');
wrapper
.find('MenuItem')
.props()
.onClick();
assert.calledOnce(fakeStore.clearDirectLinkedIds);
});
it('clears the direct-linked group fetch failed from the store when the group is clicked', () => {
const fakeGroup = { id: 'groupid' };
const wrapper = createGroupListItem(fakeGroup);
wrapper.find('.group-list-item__item').simulate('click');
wrapper
.find('MenuItem')
.props()
.onClick();
assert.calledOnce(fakeStore.clearDirectLinkedGroupFetchFailed);
});
it('sets alt text for organization logo', () => {
const fakeGroup = {
id: 'groupid',
const group = {
...fakeGroup,
// Dummy scheme to avoid actually trying to load image.
logo: 'dummy://hypothes.is/logo.svg',
organization: { name: 'org' },
};
fakeGroupListItemCommon.orgName
.withArgs(fakeGroup)
.returns(fakeGroup.organization.name);
.withArgs(group)
.returns(group.organization.name);
const wrapper = createGroupListItem(fakeGroup);
const altText = wrapper.find('img').props().alt;
const wrapper = createGroupListItem(group);
const altText = wrapper.find('MenuItem').prop('iconAlt');
assert.equal(altText, fakeGroup.organization.name);
assert.equal(altText, group.organization.name);
});
describe('selected state', () => {
......@@ -109,15 +145,135 @@ describe('GroupListItem', () => {
].forEach(({ description, focusedGroupId, expectedIsSelected }) => {
it(description, () => {
fakeStore.focusedGroupId.returns(focusedGroupId);
const fakeGroup = { id: 'groupid' };
const wrapper = createGroupListItem(fakeGroup);
assert.equal(
wrapper.find('.group-list-item__item').hasClass('is-selected'),
wrapper.find('MenuItem').prop('isSelected'),
expectedIsSelected
);
});
});
});
it('toggles submenu when toggle is clicked', () => {
const wrapper = createGroupListItem(fakeGroup);
const toggleSubmenu = () => {
const dummyEvent = new Event();
act(() => {
wrapper
.find('MenuItem')
.first()
.props()
.onToggleSubmenu(dummyEvent);
});
wrapper.update();
};
toggleSubmenu();
assert.isTrue(wrapper.exists('ul'));
toggleSubmenu();
assert.isFalse(wrapper.exists('ul'));
});
it('does not show submenu toggle if there are no available actions', () => {
fakeGroup.links.html = null;
fakeGroup.type = 'open';
const wrapper = createGroupListItem(fakeGroup);
assert.isUndefined(wrapper.find('MenuItem').prop('isExpanded'));
});
it('does not show link to activity page if not available', () => {
fakeGroup.links.html = null;
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
});
assert.isFalse(wrapper.exists('MenuItem[label="View group activity"]'));
});
it('shows link to activity page if available', () => {
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
});
assert.isTrue(wrapper.exists('MenuItem[label="View group activity"]'));
});
it('does not show "Leave" action if user cannot leave', () => {
fakeGroup.type = 'open';
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
});
assert.isFalse(wrapper.exists('MenuItem[label="Leave group"]'));
});
it('shows "Leave" action if user can leave', () => {
fakeGroup.type = 'private';
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
});
assert.isTrue(wrapper.exists('MenuItem[label="Leave group"]'));
});
it('prompts to leave group if "Leave" action is clicked', () => {
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
});
act(() => {
wrapper
.find('MenuItem[label="Leave group"]')
.props()
.onClick();
});
assert.called(window.confirm);
assert.notCalled(fakeGroupsService.leave);
});
it('leaves group if "Leave" is clicked and user confirms', () => {
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
});
window.confirm.returns(true);
act(() => {
wrapper
.find('MenuItem[label="Leave group"]')
.props()
.onClick();
});
assert.called(window.confirm);
assert.calledWith(fakeGroupsService.leave, fakeGroup.id);
});
[
{
enforced: false,
isScopedToUri: false,
expectDisabled: false,
},
{
enforced: true,
isScopedToUri: false,
expectDisabled: true,
},
{
enforced: true,
isScopedToUri: true,
expectDisabled: false,
},
].forEach(({ enforced, isScopedToUri, expectDisabled }) => {
it('disables menu item and shows note in submenu if group is not selectable', () => {
fakeGroup.scopes.enforced = enforced;
fakeGroup.isScopedToUri = isScopedToUri;
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
});
assert.equal(
wrapper
.find('MenuItem')
.first()
.prop('isDisabled'),
expectDisabled
);
assert.equal(wrapper.exists('.group-list-item__footer'), expectDisabled);
});
});
});
......@@ -5,60 +5,34 @@ const { createElement } = require('preact');
const GroupListSection = require('../group-list-section');
const GroupListItem = require('../group-list-item');
const GroupListItemOutOfScope = require('../group-list-item-out-of-scope');
const MenuSection = require('../menu-section');
describe('GroupListSection', () => {
const createGroupListSection = groups => {
return shallow(
<GroupListSection groups={groups} analytics={{}} store={{}} />
);
};
describe('group item types', () => {
[
const testGroups = [
{
description:
'renders GroupListItem if group is out of scope but scope is not enforced',
scopesEnforced: false,
expectedIsSelectable: [true, true],
id: 'group1',
name: 'Group 1',
},
{
description:
'renders GroupListItemOutOfScope if group is out of scope and scope is enforced',
scopesEnforced: true,
expectedIsSelectable: [true, false],
},
].forEach(({ description, scopesEnforced, expectedIsSelectable }) => {
it(description, () => {
const groups = [
{
isScopedToUri: true,
scopes: { enforced: scopesEnforced },
id: 0,
},
{
isScopedToUri: false,
scopes: { enforced: scopesEnforced },
id: 1,
id: 'group2',
name: 'Group 2',
},
];
const wrapper = createGroupListSection(groups);
const createGroupListSection = ({
groups = testGroups,
heading = 'Test section',
} = {}) => {
return shallow(<GroupListSection groups={groups} heading={heading} />);
};
// Check that the correct group item components were rendered for
// each group, depending on whether the group can be annotated in on
// the current document.
const itemTypes = wrapper
.findWhere(
n =>
n.type() === GroupListItem || n.type() === GroupListItemOutOfScope
)
.map(item => item.type());
const expectedItemTypes = groups.map(g =>
expectedIsSelectable[g.id] ? GroupListItem : GroupListItemOutOfScope
);
assert.deepEqual(itemTypes, expectedItemTypes);
});
it('renders heading', () => {
const wrapper = createGroupListSection();
assert.equal(wrapper.find(MenuSection).prop('heading'), 'Test section');
});
it('renders groups', () => {
const wrapper = createGroupListSection();
assert.equal(wrapper.find(GroupListItem).length, testGroups.length);
});
});
......@@ -51,7 +51,6 @@ describe('groupList', function() {
let fakeServiceUrl;
let fakeSettings;
let fakeFeatures;
let fakeStore;
before(function() {
angular
......@@ -67,12 +66,6 @@ describe('groupList', function() {
flagEnabled: sinon.stub().returns(false),
};
fakeStore = {
getCurrentlyViewingGroups: sinon.stub().returns([]),
getFeaturedGroups: sinon.stub().returns([]),
getMyGroups: sinon.stub().returns([]),
};
fakeAnalytics = {
track: sinon.stub(),
events,
......@@ -88,7 +81,6 @@ describe('groupList', function() {
serviceUrl: fakeServiceUrl,
settings: fakeSettings,
features: fakeFeatures,
store: fakeStore,
});
});
......@@ -372,70 +364,6 @@ describe('groupList', function() {
);
});
describe('group section visibility', () => {
[
{
description:
'shows Currently Viewing section when there are currently viewing groups',
currentlyViewingGroups: [publicGroup],
featuredGroups: [restrictedGroup],
myGroups: [],
expectedSections: ["'Currently Viewing'", "'Featured Groups'"],
},
{
description:
'shows Featured Groups section when there are featured groups',
currentlyViewingGroups: [],
featuredGroups: [restrictedGroup],
myGroups: [publicGroup],
expectedSections: ["'Featured Groups'", "'My Groups'"],
},
{
description: 'shows My Groups section when there are my groups',
currentlyViewingGroups: [],
featuredGroups: [],
myGroups: [publicGroup, privateGroup],
expectedSections: ["'My Groups'"],
},
].forEach(
({
description,
currentlyViewingGroups,
featuredGroups,
myGroups,
expectedSections,
}) => {
it(description, () => {
fakeFeatures.flagEnabled.withArgs('community_groups').returns(true);
// In order to show the group drop down there must be at least two groups.
groups = currentlyViewingGroups
.concat(featuredGroups)
.concat(myGroups);
fakeStore.getCurrentlyViewingGroups.returns(currentlyViewingGroups);
fakeStore.getFeaturedGroups.returns(featuredGroups);
fakeStore.getMyGroups.returns(myGroups);
const element = createGroupList();
const showGroupsMenu = element.ctrl.showGroupsMenu();
const dropdownToggle = element.find('.dropdown-toggle');
const arrowIcon = element.find('.h-icon-arrow-drop-down');
const groupListSection = element.find('group-list-section');
assert.isTrue(showGroupsMenu);
assert.lengthOf(dropdownToggle, 1);
assert.lengthOf(arrowIcon, 1);
assert.lengthOf(groupListSection, expectedSections.length);
groupListSection.each(function() {
assert.isTrue(
expectedSections.includes(this.getAttribute('heading'))
);
});
});
}
);
});
describe('group menu visibility', () => {
it('is hidden when third party service and only one group', function() {
// Configure third party service.
......@@ -506,42 +434,6 @@ describe('groupList', function() {
assert.lengthOf(dropdownMenu, 1);
assert.lengthOf(dropdownOptions, 2);
});
it('is shown when community_groups feature flag is on and there are multiple groups', function() {
fakeFeatures.flagEnabled.withArgs('community_groups').returns(true);
groups = [publicGroup, restrictedGroup];
fakeStore.getMyGroups.returns(groups);
const element = createGroupList();
const showGroupsMenu = element.ctrl.showGroupsMenu();
const dropdownToggle = element.find('.dropdown-toggle');
const arrowIcon = element.find('.h-icon-arrow-drop-down');
const groupListSection = element.find('.group-list-section');
assert.isTrue(showGroupsMenu);
assert.lengthOf(dropdownToggle, 1);
assert.lengthOf(arrowIcon, 1);
assert.lengthOf(groupListSection, 1);
});
it('is not shown when community_groups feature flag is on and there is only one group', function() {
fakeFeatures.flagEnabled.withArgs('community_groups').returns(true);
groups = [publicGroup];
fakeStore.getMyGroups.returns(groups);
const element = createGroupList();
const showGroupsMenu = element.ctrl.showGroupsMenu();
const dropdownToggle = element.find('.dropdown-toggle');
const arrowIcon = element.find('.h-icon-arrow-drop-down');
const groupListSection = element.find('.group-list-section');
assert.isFalse(showGroupsMenu);
assert.lengthOf(dropdownToggle, 0);
assert.lengthOf(arrowIcon, 0);
assert.lengthOf(groupListSection, 0);
});
});
[false, true].forEach(isEnabled => {
......
'use strict';
const { shallow } = require('enzyme');
const { createElement } = require('preact');
const GroupList = require('../group-list-v2');
describe('GroupList', () => {
let fakeServiceConfig;
let fakeServiceUrl;
let fakeSettings;
let fakeStore;
const testGroup = {
id: 'testgroup',
name: 'Test group',
organization: { id: 'testorg', name: 'Test Org' },
};
function createGroupList() {
return shallow(
<GroupList serviceUrl={fakeServiceUrl} settings={fakeSettings} />
).dive();
}
beforeEach(() => {
fakeServiceUrl = sinon.stub();
fakeSettings = {
authDomain: 'hypothes.is',
};
fakeStore = {
getCurrentlyViewingGroups: sinon.stub().returns([]),
getFeaturedGroups: sinon.stub().returns([]),
getMyGroups: sinon.stub().returns([]),
focusedGroup: sinon.stub().returns(testGroup),
profile: sinon.stub().returns({ userid: null }),
};
fakeServiceConfig = sinon.stub().returns(null);
GroupList.$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
'../service-config': fakeServiceConfig,
});
});
afterEach(() => {
GroupList.$imports.$restore();
});
it('displays no sections if there are no groups', () => {
const wrapper = createGroupList();
assert.isFalse(wrapper.exists('GroupListSection'));
});
it('displays "Currently Viewing" section if there are currently viewing groups', () => {
fakeStore.getCurrentlyViewingGroups.returns([testGroup]);
const wrapper = createGroupList();
assert.isTrue(
wrapper.exists('GroupListSection[heading="Currently Viewing"]')
);
});
it('displays "Featured Groups" section if there are featured groups', () => {
fakeStore.getFeaturedGroups.returns([testGroup]);
const wrapper = createGroupList();
assert.isTrue(
wrapper.exists('GroupListSection[heading="Featured Groups"]')
);
});
it('displays "My Groups" section if user is a member of any groups', () => {
fakeStore.getMyGroups.returns([testGroup]);
const wrapper = createGroupList();
assert.isTrue(wrapper.exists('GroupListSection[heading="My Groups"]'));
});
it('sorts groups within each section by organization', () => {
const testGroups = [
{
...testGroup,
id: 'zzz',
},
{
...testGroup,
id: 'aaa',
},
];
fakeStore.getMyGroups.returns(testGroups);
fakeStore.getCurrentlyViewingGroups.returns(testGroups);
fakeStore.getFeaturedGroups.returns(testGroups);
const fakeGroupOrganizations = groups =>
groups.sort((a, b) => a.id.localeCompare(b.id));
GroupList.$imports.$mock({
'../util/group-organizations': fakeGroupOrganizations,
});
const wrapper = createGroupList();
const sections = wrapper.find('GroupListSection');
assert.equal(sections.length, 3);
sections.forEach(section => {
assert.deepEqual(
section.prop('groups'),
fakeGroupOrganizations(testGroups)
);
});
});
[
{
userid: null,
expectNewGroupButton: false,
},
{
userid: 'acct:john@hypothes.is',
expectNewGroupButton: true,
},
{
userid: 'acct:john@otherpublisher.org',
expectNewGroupButton: false,
},
].forEach(({ userid, expectNewGroupButton }) => {
it('displays "New private group" button if user is logged in with first-party account', () => {
fakeStore.profile.returns({ userid });
const wrapper = createGroupList();
const newGroupButton = wrapper.find(
'MenuItem[label="New private group"]'
);
assert.equal(newGroupButton.length, expectNewGroupButton ? 1 : 0);
});
});
it('opens new window at correct URL when "New private group" is clicked', () => {
fakeServiceUrl
.withArgs('groups.new')
.returns('https://example.com/groups/new');
fakeStore.profile.returns({ userid: 'jsmith@hypothes.is' });
const wrapper = createGroupList();
const newGroupButton = wrapper.find('MenuItem[label="New private group"]');
assert.equal(newGroupButton.props().href, 'https://example.com/groups/new');
});
it('displays the group name and icon as static text if there is only one group and no actions available', () => {
GroupList.$imports.$mock({
'../util/is-third-party-service': () => true,
});
const wrapper = createGroupList();
assert.equal(wrapper.text(), 'Test group');
});
it('renders a placeholder if groups have not loaded yet', () => {
fakeStore.focusedGroup.returns(null);
const wrapper = createGroupList();
const label = wrapper.find('Menu').prop('label');
assert.equal(shallow(label).text(), '…');
});
it('renders the publisher-provided icon in the toggle button', () => {
fakeServiceConfig.returns({ icon: 'test-icon' });
const wrapper = createGroupList();
const label = wrapper.find('Menu').prop('label');
const img = shallow(label).find('img');
assert.equal(img.prop('src'), 'test-icon');
});
});
......@@ -72,6 +72,12 @@ describe('Menu', () => {
assert.isTrue(wrapper.exists(TestMenuItem));
});
it('flips toggle arrow when open', () => {
const wrapper = createMenu({ defaultOpen: true });
const toggle = wrapper.find('.menu__toggle-arrow');
assert.isTrue(toggle.hasClass('is-open'));
});
let e;
[
new Event('mousedown'),
......@@ -126,4 +132,13 @@ describe('Menu', () => {
wrapper.setProps({ align: 'right' });
assert.isTrue(wrapper.exists('.menu__content--align-right'));
});
it('applies custom content class', () => {
const wrapper = createMenu({
defaultOpen: true,
contentClass: 'special-menu',
});
const content = wrapper.find('.menu__content');
assert.isTrue(content.hasClass('special-menu'));
});
});
......@@ -160,8 +160,8 @@ function startAngularApp(config) {
.component('excerpt', require('./components/excerpt'))
.component('groupList', require('./components/group-list'))
.component(
'groupListSection',
wrapReactComponent(require('./components/group-list-section'))
'groupListV2',
wrapReactComponent(require('./components/group-list-v2'))
)
.component('helpLink', require('./components/help-link'))
.component('helpPanel', require('./components/help-panel'))
......
<div class="pull-right" dropdown keyboard-nav>
<div
class="pull-right"
dropdown
keyboard-nav
ng-if="!vm.isFeatureFlagEnabled('community_groups')"
>
<!-- Show a drop down menu if showGroupsMenu is true. -->
<div
class="dropdown-toggle"
......@@ -132,56 +137,7 @@
</div>
</li>
</ul>
<!-- Show new menu if community_groups feature flag is enabled. -->
<div
class="dropdown-menu"
role="menu"
ng-if="vm.showGroupsMenu() && vm.isFeatureFlagEnabled('community_groups')"
>
<!-- Currently Viewing -->
<group-list-section
class="group-list-section"
heading="'Currently Viewing'"
groups="vm.currentlyViewingGroupOrganizations()"
ng-if="vm.currentlyViewingGroupOrganizations().length > 0"
>
</group-list-section>
<!-- Featured Groups -->
<group-list-section
class="group-list-section"
heading="'Featured Groups'"
groups="vm.featuredGroupOrganizations()"
ng-if="vm.featuredGroupOrganizations().length > 0"
>
</group-list-section>
<!-- My Groups -->
<group-list-section
class="group-list-section"
heading="'My Groups'"
groups="vm.myGroupOrganizations()"
ng-if="vm.myGroupOrganizations().length > 0"
>
</group-list-section>
<ul class="dropdown-menu__section dropdown-menu__section--no-header">
<li
ng-if="vm.auth.status === 'logged-in' && !vm.isThirdPartyUser()"
class="dropdown-community-groups-menu__row dropdown-menu__row--unpadded new-group-btn"
>
<div
class="group-item-community-groups"
ng-click="vm.createNewGroup()"
tabindex="0"
>
<div class="group-icon-container"><i class="h-icon-add"></i></div>
<div class="group-details-community-groups">
New private group
</div>
</div>
</li>
</ul>
</div>
</div>
<group-list-v2
ng-if="vm.isFeatureFlagEnabled('community_groups')"
></group-list-v2>
......@@ -248,17 +248,6 @@ html {
}
// Row in a dropdown menu
.dropdown-community-groups-menu__row {
display: flex;
flex-direction: row;
align-items: center;
padding-left: 8px;
padding-right: 16px;
min-height: 40px;
min-width: 120px;
}
.dropdown-menu__row--no-border{
border: none;
}
......
/* The group. */
.group-list-item {
display: flex;
flex-direction: row;
flex-grow: 1;
}
.group-list-item__item {
border: solid 1px transparent;
display: flex;
flex-direction: row;
flex-grow: 1;
margin: 1px;
// Footer to display at the bottom of a menu item.
.group-list-item__footer {
background-color: $grey-1;
margin: 0;
padding-top: 15px;
padding: 10px;
cursor: pointer;
&:focus {
outline: none;
@include focus-outline;
}
&:hover {
background: $gray-lightest;
}
&.is-selected {
background: $gray-lightest;
}
}
.group-list-item__icon-container {
margin-right: 10px;
width: 15px;
height: 15px;
}
// the icon indicating the type of group currently selected at
// the top of the groups list
.group-list-item__icon {
color: $color-gray;
display: inline-block;
margin-right: 4px;
position: relative;
vertical-align: baseline;
// align the base of the chat-heads icon for groups
// with the baseline of the group name label
transform: translateY(1px);
}
.group-list-item__icon--organization {
height: 15px;
width: 15px;
top: 2px;
}
// the name of a group in the groups drop-down list
// and 'Post to <Group>' button for saving annotations
.group-list-item__name-link {
white-space: nowrap;
color: inherit;
}
.group-list-item__details {
flex-grow: 1;
flex-shrink: 1;
font-weight: 500;
// Align the left edge of the footer text with menu item labels above.
padding-left: 35px;
white-space: normal;
}
/* The groups section. */
.group-list-section__content {
border-bottom: solid 1px rgba(0, 0, 0, 0.15);
}
.group-list-section__heading {
color: $gray-light;
font-size: $body1-font-size;
line-height: 1;
margin: 1px 1px 0;
padding: 12px 10px 0;
text-transform: uppercase;
}
.group-list-v2__content {
min-width: 250px;
}
......@@ -48,33 +48,6 @@ $group-list-spacing-below: 50px;
}
}
.group-item-community-groups {
border: solid 1px transparent;
display: flex;
flex-direction: row;
flex-grow: 1;
margin: 1px;
padding: 10px;
cursor: pointer;
&:focus {
outline: none;
@include focus-outline;
}
&:hover {
background: $gray-lightest;
}
&.is-selected {
.group-name-link {
font-size: $body2-font-size;
font-weight: 600;
}
}
}
.group-icon-container {
margin-right: 10px;
}
......@@ -97,27 +70,6 @@ $group-list-spacing-below: 50px;
flex-grow: 1;
flex-shrink: 1;
}
.group-details-community-groups {
flex-grow: 1;
flex-shrink: 1;
font-weight: 500;
.group-details__unavailable-message,
.group-details__actions {
display: none;
}
&.expanded {
.group-details__toggle {
display: none;
}
.group-details__unavailable-message,
.group-details__actions {
display: block;
}
}
}
.new-group-btn {
background-color: $gray-lightest;
......
......@@ -91,6 +91,8 @@ $menu-item-padding: 10px;
// Toggle button used to expand or collapse the submenu associated with a menu
// item.
.menu-item__toggle {
@include outline-on-keyboard-focus;
display: flex;
flex-direction: column;
justify-content: center;
......
......@@ -4,12 +4,15 @@
// Toggle button that opens the menu.
.menu__toggle {
@include outline-on-keyboard-focus;
appearance: none;
border: none;
background: none;
padding: 0;
color: inherit;
display: flex;
align-items: center;
}
.menu__toggle-icon {
......@@ -23,6 +26,11 @@
width: 10px;
height: 10px;
margin-left: 5px;
&.is-open {
// Flip the indicator when the menu is open.
transform: rotateX(180deg);
}
}
// Triangular indicator at the top of the menu that associates it with the
......
......@@ -24,9 +24,9 @@ $base-line-height: 20px;
@import './components/dropdown-menu-btn';
@import './components/excerpt';
@import './components/group-list';
@import './components/group-list-v2';
@import './components/group-list-item';
@import './components/group-list-item-out-of-scope';
@import './components/group-list-section';
@import './components/help-panel';
@import './components/loggedout-message';
@import './components/login-control';
......
......@@ -126,7 +126,7 @@ $highlight-color-second: rgba(206, 206, 60, 0.4);
$highlight-color-third: rgba(192, 192, 49, 0.4);
$highlight-color-focus: rgba(156, 230, 255, 0.5);
$top-bar-height: 40px;
$group-list-width: 300px;
$group-list-width: 280px;
// Mixins
// ------
......
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