Commit abe75126 authored by Robert Knight's avatar Robert Knight

Implement new groups menu with actions based on `Menu` component

Implement a new version of the groups menu based on the new drop-down
menu component.

This re-introduces the ability to leave a group and go to the activity
page for a group from the groups menu.
parent 7479fadd
'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'; 'use strict';
const classnames = require('classnames');
const propTypes = require('prop-types'); 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 useStore = require('../store/use-store');
const { orgName } = require('../util/group-list-item-common'); const { orgName } = require('../util/group-list-item-common');
const { withServices } = require('../util/service-context'); 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, group, groups }) {
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 ? false : undefined);
const focusedGroupId = useStore(store => store.focusedGroupId());
const isSelected = group.id === focusedGroupId;
const actions = useStore(store => ({ const actions = useStore(store => ({
clearDirectLinkedGroupFetchFailed: store.clearDirectLinkedGroupFetchFailed, clearDirectLinkedGroupFetchFailed: store.clearDirectLinkedGroupFetchFailed,
clearDirectLinkedIds: store.clearDirectLinkedIds, clearDirectLinkedIds: store.clearDirectLinkedIds,
...@@ -22,44 +39,72 @@ function GroupListItem({ analytics, group }) { ...@@ -22,44 +39,72 @@ function GroupListItem({ analytics, group }) {
actions.focusGroup(group.id); actions.focusGroup(group.id);
}; };
const focusedGroupId = useStore(store => store.focusedGroupId()); const leaveGroup = () => {
const isSelected = group.id === focusedGroupId; const message = `Are you sure you want to leave the group "${group.name}"?`;
const groupOrgName = orgName(group); if (window.confirm(message)) {
analytics.track(analytics.events.GROUP_LEAVE);
groups.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 ( return (
<div <Fragment>
className={classnames({ <MenuItem
'group-list-item__item': true, icon={group.logo || null}
'is-selected': isSelected, iconAlt={orgName(group)}
})} isDisabled={!isSelectable}
onClick={focusGroup} isExpanded={isExpanded}
tabIndex="0" isSelected={isSelected}
> isSubmenuVisible={isExpanded}
{/* the group icon */} label={group.name}
<div className="group-list-item__icon-container"> onClick={isSelectable ? focusGroup : toggleSubmenu}
{group.logo && ( onToggleSubmenu={toggleSubmenu}
<img />
className="group-list-item__icon group-list-item__icon--organization" {isExpanded && (
alt={groupOrgName} <Fragment>
src={group.logo} <ul onClick={collapseSubmenu}>
/> {activityUrl && (
)} <li>
</div> <MenuItem
{/* the group name */} href={activityUrl}
<div className="group-list-item__details"> icon="share"
<a isSubmenuItem={true}
className="group-list-item__name-link" label="View group activity"
href="" />
title={ </li>
group.type === 'private' )}
? `Show and create annotations in ${group.name}` {canLeaveGroup && (
: 'Show public annotations' <li>
} <MenuItem
> icon="leave"
{group.name} isSubmenuItem={true}
</a> label="Leave group"
</div> onClick={leaveGroup}
</div> />
</li>
)}
</ul>
{!isSelectable && (
<p className="group-list-item__footer">
This group is restricted to specific URLs.
</p>
)}
</Fragment>
)}
</Fragment>
); );
} }
...@@ -67,8 +112,9 @@ GroupListItem.propTypes = { ...@@ -67,8 +112,9 @@ GroupListItem.propTypes = {
group: propTypes.object.isRequired, group: propTypes.object.isRequired,
analytics: propTypes.object.isRequired, analytics: propTypes.object.isRequired,
groups: propTypes.object.isRequired,
}; };
GroupListItem.injectedProps = ['analytics']; GroupListItem.injectedProps = ['analytics', 'groups'];
module.exports = withServices(GroupListItem); module.exports = withServices(GroupListItem);
'use strict'; 'use strict';
const { Fragment, createElement } = require('preact'); const { createElement } = require('preact');
const propTypes = require('prop-types'); const propTypes = require('prop-types');
const GroupListItem = require('./group-list-item'); 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. * A labeled section of the groups list.
*/ */
function GroupListSection({ groups, heading }) { function GroupListSection({ groups, heading }) {
const isSelectable = groupId => {
const group = groups.find(g => g.id === groupId);
return !group.scopes.enforced || group.isScopedToUri;
};
return ( return (
<Fragment> <MenuSection heading={heading}>
<h2 className="group-list-section__heading">{heading}</h2> {groups.map(group => (
<ul className="group-list-section__content"> <GroupListItem key={group.id} group={group} />
{groups.map(group => ( ))}
<li </MenuSection>
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>
))}
</ul>
</Fragment>
); );
} }
......
'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" 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'); ...@@ -8,12 +8,6 @@ const groupsByOrganization = require('../util/group-organizations');
const groupOrganizations = memoize(groupsByOrganization); const groupOrganizations = memoize(groupsByOrganization);
const myGroupOrgs = memoize(groupsByOrganization);
const featuredGroupOrgs = memoize(groupsByOrganization);
const currentlyViewingGroupOrgs = memoize(groupsByOrganization);
// @ngInject // @ngInject
function GroupListController( function GroupListController(
$window, $window,
...@@ -21,8 +15,7 @@ function GroupListController( ...@@ -21,8 +15,7 @@ function GroupListController(
features, features,
groups, groups,
settings, settings,
serviceUrl, serviceUrl
store
) { ) {
this.groups = groups; this.groups = groups;
...@@ -66,18 +59,6 @@ function GroupListController( ...@@ -66,18 +59,6 @@ function GroupListController(
return groupOrganizations(this.groups.all()); 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() { this.viewGroupActivity = function() {
analytics.track(analytics.events.GROUP_VIEW_ACTIVITY); analytics.track(analytics.events.GROUP_VIEW_ACTIVITY);
}; };
......
'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'));
});
});
...@@ -9,9 +9,21 @@ const { events } = require('../../services/analytics'); ...@@ -9,9 +9,21 @@ const { events } = require('../../services/analytics');
describe('GroupListItem', () => { describe('GroupListItem', () => {
let fakeAnalytics; let fakeAnalytics;
let fakeGroups;
let fakeStore; let fakeStore;
let fakeGroupListItemCommon; let fakeGroupListItemCommon;
const fakeGroup = {
id: 'groupid',
name: 'Test',
links: {
html: 'https://annotate.com/groups/groupid',
},
scopes: {
enforced: false,
},
};
beforeEach(() => { beforeEach(() => {
fakeStore = { fakeStore = {
focusGroup: sinon.stub(), focusGroup: sinon.stub(),
...@@ -29,6 +41,10 @@ describe('GroupListItem', () => { ...@@ -29,6 +41,10 @@ describe('GroupListItem', () => {
orgName: sinon.stub(), orgName: sinon.stub(),
}; };
fakeGroups = {
leave: sinon.stub(),
};
GroupListItem.$imports.$mock({ GroupListItem.$imports.$mock({
'../util/group-list-item-common': fakeGroupListItemCommon, '../util/group-list-item-common': fakeGroupListItemCommon,
'../store/use-store': callback => callback(fakeStore), '../store/use-store': callback => callback(fakeStore),
...@@ -43,55 +59,58 @@ describe('GroupListItem', () => { ...@@ -43,55 +59,58 @@ describe('GroupListItem', () => {
return mount( return mount(
<GroupListItem <GroupListItem
group={fakeGroup} group={fakeGroup}
groups={fakeGroups}
analytics={fakeAnalytics} analytics={fakeAnalytics}
store={fakeStore}
/> />
); );
}; };
it('changes the focused group when group is clicked', () => { it('changes the focused group when group is clicked', () => {
const fakeGroup = { id: 'groupid' };
const wrapper = createGroupListItem(fakeGroup); 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(fakeStore.focusGroup, fakeGroup.id);
assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.GROUP_SWITCH); assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.GROUP_SWITCH);
}); });
it('clears the direct linked ids from the store when the group is clicked', () => { it('clears the direct linked ids from the store when the group is clicked', () => {
const fakeGroup = { id: 'groupid' };
const wrapper = createGroupListItem(fakeGroup); const wrapper = createGroupListItem(fakeGroup);
wrapper.find('.group-list-item__item').simulate('click'); wrapper
.find('MenuItem')
.props()
.onClick();
assert.calledOnce(fakeStore.clearDirectLinkedIds); assert.calledOnce(fakeStore.clearDirectLinkedIds);
}); });
it('clears the direct-linked group fetch failed from the store when the group is clicked', () => { it('clears the direct-linked group fetch failed from the store when the group is clicked', () => {
const fakeGroup = { id: 'groupid' };
const wrapper = createGroupListItem(fakeGroup); const wrapper = createGroupListItem(fakeGroup);
wrapper.find('.group-list-item__item').simulate('click'); wrapper
.find('MenuItem')
.props()
.onClick();
assert.calledOnce(fakeStore.clearDirectLinkedGroupFetchFailed); assert.calledOnce(fakeStore.clearDirectLinkedGroupFetchFailed);
}); });
it('sets alt text for organization logo', () => { it('sets alt text for organization logo', () => {
const fakeGroup = { const group = {
id: 'groupid', ...fakeGroup,
// Dummy scheme to avoid actually trying to load image. // Dummy scheme to avoid actually trying to load image.
logo: 'dummy://hypothes.is/logo.svg', logo: 'dummy://hypothes.is/logo.svg',
organization: { name: 'org' }, organization: { name: 'org' },
}; };
fakeGroupListItemCommon.orgName fakeGroupListItemCommon.orgName
.withArgs(fakeGroup) .withArgs(group)
.returns(fakeGroup.organization.name); .returns(group.organization.name);
const wrapper = createGroupListItem(fakeGroup); const wrapper = createGroupListItem(group);
const altText = wrapper.find('img').props().alt; const altText = wrapper.find('MenuItem').prop('iconAlt');
assert.equal(altText, fakeGroup.organization.name); assert.equal(altText, group.organization.name);
}); });
describe('selected state', () => { describe('selected state', () => {
...@@ -109,12 +128,11 @@ describe('GroupListItem', () => { ...@@ -109,12 +128,11 @@ describe('GroupListItem', () => {
].forEach(({ description, focusedGroupId, expectedIsSelected }) => { ].forEach(({ description, focusedGroupId, expectedIsSelected }) => {
it(description, () => { it(description, () => {
fakeStore.focusedGroupId.returns(focusedGroupId); fakeStore.focusedGroupId.returns(focusedGroupId);
const fakeGroup = { id: 'groupid' };
const wrapper = createGroupListItem(fakeGroup); const wrapper = createGroupListItem(fakeGroup);
assert.equal( assert.equal(
wrapper.find('.group-list-item__item').hasClass('is-selected'), wrapper.find('MenuItem').prop('isSelected'),
expectedIsSelected expectedIsSelected
); );
}); });
......
...@@ -5,60 +5,34 @@ const { createElement } = require('preact'); ...@@ -5,60 +5,34 @@ const { createElement } = require('preact');
const GroupListSection = require('../group-list-section'); const GroupListSection = require('../group-list-section');
const GroupListItem = require('../group-list-item'); const GroupListItem = require('../group-list-item');
const GroupListItemOutOfScope = require('../group-list-item-out-of-scope'); const MenuSection = require('../menu-section');
describe('GroupListSection', () => { describe('GroupListSection', () => {
const createGroupListSection = groups => { const testGroups = [
return shallow( {
<GroupListSection groups={groups} analytics={{}} store={{}} /> id: 'group1',
); name: 'Group 1',
}; },
{
id: 'group2',
name: 'Group 2',
},
];
describe('group item types', () => { const createGroupListSection = ({
[ groups = testGroups,
{ heading = 'Test section',
description: } = {}) => {
'renders GroupListItem if group is out of scope but scope is not enforced', return shallow(<GroupListSection groups={groups} heading={heading} />);
scopesEnforced: false, };
expectedIsSelectable: [true, true],
},
{
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,
},
];
const wrapper = createGroupListSection(groups); it('renders heading', () => {
const wrapper = createGroupListSection();
assert.equal(wrapper.find(MenuSection).prop('heading'), 'Test section');
});
// Check that the correct group item components were rendered for it('renders groups', () => {
// each group, depending on whether the group can be annotated in on const wrapper = createGroupListSection();
// the current document. assert.equal(wrapper.find(GroupListItem).length, testGroups.length);
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);
});
});
}); });
}); });
...@@ -51,7 +51,6 @@ describe('groupList', function() { ...@@ -51,7 +51,6 @@ describe('groupList', function() {
let fakeServiceUrl; let fakeServiceUrl;
let fakeSettings; let fakeSettings;
let fakeFeatures; let fakeFeatures;
let fakeStore;
before(function() { before(function() {
angular angular
...@@ -67,12 +66,6 @@ describe('groupList', function() { ...@@ -67,12 +66,6 @@ describe('groupList', function() {
flagEnabled: sinon.stub().returns(false), flagEnabled: sinon.stub().returns(false),
}; };
fakeStore = {
getCurrentlyViewingGroups: sinon.stub().returns([]),
getFeaturedGroups: sinon.stub().returns([]),
getMyGroups: sinon.stub().returns([]),
};
fakeAnalytics = { fakeAnalytics = {
track: sinon.stub(), track: sinon.stub(),
events, events,
...@@ -88,7 +81,6 @@ describe('groupList', function() { ...@@ -88,7 +81,6 @@ describe('groupList', function() {
serviceUrl: fakeServiceUrl, serviceUrl: fakeServiceUrl,
settings: fakeSettings, settings: fakeSettings,
features: fakeFeatures, features: fakeFeatures,
store: fakeStore,
}); });
}); });
...@@ -372,70 +364,6 @@ describe('groupList', function() { ...@@ -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', () => { describe('group menu visibility', () => {
it('is hidden when third party service and only one group', function() { it('is hidden when third party service and only one group', function() {
// Configure third party service. // Configure third party service.
...@@ -506,42 +434,6 @@ describe('groupList', function() { ...@@ -506,42 +434,6 @@ describe('groupList', function() {
assert.lengthOf(dropdownMenu, 1); assert.lengthOf(dropdownMenu, 1);
assert.lengthOf(dropdownOptions, 2); 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 => { [false, true].forEach(isEnabled => {
......
'use strict';
const { shallow } = require('enzyme');
const { createElement } = require('preact');
const GroupList = require('../group-list-v2');
describe('GroupList', () => {
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 }),
};
GroupList.$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
});
});
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');
});
});
...@@ -160,8 +160,8 @@ function startAngularApp(config) { ...@@ -160,8 +160,8 @@ function startAngularApp(config) {
.component('excerpt', require('./components/excerpt')) .component('excerpt', require('./components/excerpt'))
.component('groupList', require('./components/group-list')) .component('groupList', require('./components/group-list'))
.component( .component(
'groupListSection', 'groupListV2',
wrapReactComponent(require('./components/group-list-section')) wrapReactComponent(require('./components/group-list-v2'))
) )
.component('helpLink', require('./components/help-link')) .component('helpLink', require('./components/help-link'))
.component('helpPanel', require('./components/help-panel')) .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. --> <!-- Show a drop down menu if showGroupsMenu is true. -->
<div <div
class="dropdown-toggle" class="dropdown-toggle"
...@@ -132,56 +137,7 @@ ...@@ -132,56 +137,7 @@
</div> </div>
</li> </li>
</ul> </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> </div>
<group-list-v2
ng-if="vm.isFeatureFlagEnabled('community_groups')"
></group-list-v2>
...@@ -248,17 +248,6 @@ html { ...@@ -248,17 +248,6 @@ html {
} }
// Row in a dropdown menu // 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{ .dropdown-menu__row--no-border{
border: none; border: none;
} }
......
/* The group. */ // Footer to display at the bottom of a menu item.
.group-list-item__footer {
.group-list-item { background-color: $grey-1;
display: flex; margin: 0;
flex-direction: row; padding-top: 15px;
flex-grow: 1;
}
.group-list-item__item {
border: solid 1px transparent;
display: flex;
flex-direction: row;
flex-grow: 1;
margin: 1px;
padding: 10px; 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 { // Align the left edge of the footer text with menu item labels above.
flex-grow: 1; padding-left: 35px;
flex-shrink: 1; white-space: normal;
font-weight: 500;
} }
/* 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;
}
...@@ -48,33 +48,6 @@ $group-list-spacing-below: 50px; ...@@ -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 { .group-icon-container {
margin-right: 10px; margin-right: 10px;
} }
...@@ -97,27 +70,6 @@ $group-list-spacing-below: 50px; ...@@ -97,27 +70,6 @@ $group-list-spacing-below: 50px;
flex-grow: 1; flex-grow: 1;
flex-shrink: 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 { .new-group-btn {
background-color: $gray-lightest; background-color: $gray-lightest;
......
...@@ -26,7 +26,6 @@ $base-line-height: 20px; ...@@ -26,7 +26,6 @@ $base-line-height: 20px;
@import './components/group-list'; @import './components/group-list';
@import './components/group-list-item'; @import './components/group-list-item';
@import './components/group-list-item-out-of-scope'; @import './components/group-list-item-out-of-scope';
@import './components/group-list-section';
@import './components/help-panel'; @import './components/help-panel';
@import './components/loggedout-message'; @import './components/loggedout-message';
@import './components/login-control'; @import './components/login-control';
......
...@@ -126,7 +126,7 @@ $highlight-color-second: rgba(206, 206, 60, 0.4); ...@@ -126,7 +126,7 @@ $highlight-color-second: rgba(206, 206, 60, 0.4);
$highlight-color-third: rgba(192, 192, 49, 0.4); $highlight-color-third: rgba(192, 192, 49, 0.4);
$highlight-color-focus: rgba(156, 230, 255, 0.5); $highlight-color-focus: rgba(156, 230, 255, 0.5);
$top-bar-height: 40px; $top-bar-height: 40px;
$group-list-width: 300px; $group-list-width: 280px;
// Mixins // 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