Merge pull request #801 from k88hudson/gh701

Add new context menu
This commit is contained in:
Kate Hudson 2016-06-08 14:08:34 -04:00
Родитель 22abcd4cac 60df5e3be3
Коммит 28d578a65b
26 изменённых файлов: 570 добавлений и 141 удалений

Просмотреть файл

@ -18,6 +18,7 @@ const am = new ActionManager([
"NOTIFY_BLOCK_URL",
"NOTIFY_UNBLOCK_URL",
"NOTIFY_UNBLOCK_ALL",
"NOTIFY_BOOKMARK_ADD",
"NOTIFY_BOOKMARK_DELETE",
"NOTIFY_HISTORY_DELETE",
"NOTIFY_HISTORY_DELETE_CANCELLED",
@ -27,7 +28,8 @@ const am = new ActionManager([
"SEARCH_STATE_RESPONSE",
"NOTIFY_ROUTE_CHANGE",
"NOTIFY_PERFORMANCE",
"NOTIFY_USER_EVENT"
"NOTIFY_USER_EVENT",
"NOTIFY_OPEN_WINDOW"
]);
// This is a a set of actions that have sites in them,
@ -118,8 +120,12 @@ function RequestExperiments() {
return RequestExpect("EXPERIMENTS_REQUEST", "EXPERIMENTS_RESPONSE");
}
function NotifyBookmarkDelete(data) {
return Notify("NOTIFY_BOOKMARK_DELETE", data);
function NotifyBookmarkAdd(url) {
return Notify("NOTIFY_BOOKMARK_ADD", url);
}
function NotifyBookmarkDelete(bookmarkGuid) {
return Notify("NOTIFY_BOOKMARK_DELETE", bookmarkGuid);
}
function NotifyHistoryDelete(data) {
@ -167,6 +173,10 @@ function NotifyEvent(data) {
return Notify("NOTIFY_USER_EVENT", data);
}
function NotifyOpenWindow(data) {
return Notify("NOTIFY_OPEN_WINDOW", data);
}
am.defineActions({
Notify,
Response,
@ -182,12 +192,14 @@ am.defineActions({
NotifyBlockURL,
NotifyUnblockURL,
NotifyUnblockAll,
NotifyBookmarkAdd,
NotifyBookmarkDelete,
NotifyHistoryDelete,
NotifyPerformSearch,
NotifyRouteChange,
NotifyPerf,
NotifyEvent
NotifyEvent,
NotifyOpenWindow
});
module.exports = am;

Просмотреть файл

@ -11,16 +11,19 @@ const constants = {
defaultPage: DEFAULT_PAGE,
pages: new Set(Array.from(urlPatternToPageMap.values()).concat(DEFAULT_PAGE)),
events: new Set([
"BLOCK",
"BOOKMARK_ADD",
"BOOKMARK_DELETE",
"CLICK",
"DELETE",
"BOOKMARK_DELETE",
"BLOCK",
"UNBLOCK",
"UNBLOCK_ALL",
"SHARE",
"LOAD_MORE",
"LOAD_MORE_SCROLL",
"SEARCH"
"OPEN_NEW_WINDOW",
"OPEN_PRIVATE_WINDOW",
"SEARCH",
"SHARE",
"UNBLOCK",
"UNBLOCK_ALL"
]),
sources: new Set([
"TOP_SITES",

Просмотреть файл

@ -3,7 +3,8 @@ const {connect} = require("react-redux");
const {justDispatch} = require("selectors/selectors");
const {actions} = require("common/action-manager");
const SiteIcon = require("components/SiteIcon/SiteIcon");
const DeleteMenu = require("components/DeleteMenu/DeleteMenu");
const LinkMenu = require("components/LinkMenu/LinkMenu");
const LinkMenuButton = require("components/LinkMenuButton/LinkMenuButton");
const {prettyUrl, getRandomFromTimestamp} = require("lib/utils");
const moment = require("moment");
const classNames = require("classnames");
@ -33,9 +34,6 @@ const ActivityFeedItem = React.createClass({
showDate: false
};
},
onDeleteClick() {
this.setState({showContextMenu: true});
},
render() {
const site = this.props;
const title = site.title || site.provider_display || (site.parsedUrl && site.parsedUrl.hostname);
@ -63,7 +61,7 @@ const ActivityFeedItem = React.createClass({
dateLabel = moment(date).format("h:mm A");
}
return (<li className={classNames("feed-item", {bookmark: site.bookmarkGuid, fixed: this.state.showContextMenu})}>
return (<li className={classNames("feed-item", {bookmark: site.bookmarkGuid, active: this.state.showContextMenu})}>
<a onClick={this.props.onClick} href={site.url} ref="link">
<span className="star" hidden={!site.bookmarkGuid} />
{icon}
@ -77,19 +75,15 @@ const ActivityFeedItem = React.createClass({
</div>
</div>
</a>
<div className="action-items-container">
<div className="action-item icon-delete" ref="delete" onClick={this.onDeleteClick}></div>
<div className="action-item icon-share" ref="share" onClick={() => this.props.onShare(site.url)}></div>
<div className="action-item icon-more" onClick={() => alert("Sorry. We are still working on this feature.")}></div>
</div>
<DeleteMenu
<LinkMenuButton onClick={() => this.setState({showContextMenu: true})} />
<LinkMenu
visible={this.state.showContextMenu}
onUpdate={val => this.setState({showContextMenu: val})}
url={site.url}
bookmarkGuid={site.bookmarkGuid}
allowBlock={this.props.page === "NEW_TAB"}
site={site}
page={this.props.page}
index={this.props.index}
source={this.props.source}
index={this.props.index}
/>
</li>);
}

Просмотреть файл

@ -21,6 +21,7 @@
}
.feed-item {
@include link-menu-button;
display: flex;
align-items: center;
position: relative;
@ -132,7 +133,7 @@
}
}
&.fixed,
&.active,
&:hover {
background: $feed-hover-color;
border: 1px solid $faintest-black;

Просмотреть файл

@ -21,10 +21,21 @@ const ContextMenu = React.createClass({
return (<span hidden={!this.props.visible} className="context-menu">
<ul>
{this.props.options.map((option, i) => {
return (<li key={i}><a className="context-menu-link" onClick={() => {
if (option.type === "separator") {
return (<li key={i} className="separator" />);
}
return (<li key={i}><a
className="context-menu-link"
ref={option.ref}
onClick={() => {
this.props.onUpdate(false);
option.onClick();
}}>{option.label}</a></li>);
if (option.userEvent) {
this.props.onUserEvent(option.userEvent);
}
}}>
{option.label}
</a></li>);
})}
</ul>
</span>);
@ -34,9 +45,13 @@ const ContextMenu = React.createClass({
ContextMenu.propTypes = {
visible: React.PropTypes.bool,
onUpdate: React.PropTypes.func.isRequired,
onUserEvent: React.PropTypes.func,
options: React.PropTypes.arrayOf(React.PropTypes.shape({
label: React.PropTypes.string.isRequired,
onClick: React.PropTypes.func.isRequired
type: React.PropTypes.string,
label: React.PropTypes.string,
onClick: React.PropTypes.func,
userEvent: React.PropTypes.string,
ref: React.PropTypes.string
})).isRequired
};

Просмотреть файл

@ -3,8 +3,9 @@
position: absolute;
font-size: $context-menu-font-size;
box-shadow: $context-menu-shadow;
top: 100%;
right: 0;
top: ($link-menu-button-size / 4);
left: 100%;
margin-left: 5px;
z-index: 10000;
background: $bg-grey;
border-radius: $context-menu-border-radius;
@ -15,6 +16,11 @@
list-style: none;
> li {
&.separator {
margin: $context-menu-outer-padding 0;
border-bottom: 1px solid $context-menu-border-color;
}
> a {
cursor: pointer;
color: inherit;

Просмотреть файл

@ -0,0 +1,106 @@
const React = require("react");
const {connect} = require("react-redux");
const ContextMenu = require("components/ContextMenu/ContextMenu");
const {actions} = require("common/action-manager");
const {FIRST_RUN_TYPE} = require("lib/first-run-data");
const LinkMenu = React.createClass({
getDefaultProps() {
return {
visible: false,
allowBlock: true
};
},
userEvent(event) {
const {page, source, index, Experiments, dispatch} = this.props;
if (page && source) {
let payload = {
event,
page: page,
source: source,
action_position: index
};
if (["BLOCK", "DELETE"].includes(event) && Experiments.data.reverseMenuOptions) {
payload.experiment_id = Experiments.data.id;
}
dispatch(actions.NotifyEvent(payload));
}
},
getOptions() {
const {site, allowBlock, Experiments, dispatch} = this.props;
const isNotDefault = site.type !== FIRST_RUN_TYPE;
// Don't add delete options for default links
// that show up if your history is empty
const deleteOptions = isNotDefault ? [
{type: "separator"},
allowBlock && {
ref: "dismiss",
label: "Dismiss",
userEvent: "BLOCK",
onClick: () => dispatch(actions.NotifyBlockURL(site.url))
},
{
ref: "delete",
label: "Delete from History",
userEvent: "DELETE",
onClick: () => dispatch(actions.NotifyHistoryDelete(site.url))
}
] : [];
if (Experiments.data.reverseMenuOptions) {
deleteOptions.reverse();
}
return [
(site.bookmarkGuid ? {
ref: "removeBookmark",
label: "Remove Bookmark",
userEvent: "BOOKMARK_DELETE",
onClick: () => dispatch(actions.NotifyBookmarkDelete(site.bookmarkGuid))
} : {
ref: "addBookmark",
label: "Bookmark",
userEvent: "BOOKMARK_ADD",
onClick: () => dispatch(actions.NotifyBookmarkAdd(site.url))
}),
{type: "separator"},
{
ref: "openWindow",
label: "Open in a New Window",
userEvent: "OPEN_NEW_WINDOW",
onClick: () => dispatch(actions.NotifyOpenWindow({url: site.url}))
},
{
ref: "openPrivate",
label: "Open in a Private Window",
userEvent: "OPEN_PRIVATE_WINDOW",
onClick: () => dispatch(actions.NotifyOpenWindow({url: site.url, isPrivate: true}))
}]
.concat(deleteOptions).filter(o => o);
},
render() {
return (<ContextMenu
visible={this.props.visible}
onUpdate={this.props.onUpdate}
onUserEvent={this.userEvent}
options={this.getOptions()} />);
}
});
LinkMenu.propTypes = {
visible: React.PropTypes.bool,
onUpdate: React.PropTypes.func.isRequired,
allowBlock: React.PropTypes.bool,
site: React.PropTypes.shape({
url: React.PropTypes.string.isRequired,
bookmarkGuid: React.PropTypes.string
}).isRequired,
// This is for events
page: React.PropTypes.string,
source: React.PropTypes.string,
index: React.PropTypes.number
};
module.exports = connect(({Experiments}) => ({Experiments}))(LinkMenu);

Просмотреть файл

@ -0,0 +1,15 @@
const React = require("react");
const LinkMenuButton = React.createClass({
render() {
return (<button className="link-menu-button" onClick={e => {e.preventDefault(); this.props.onClick(e);}}>
<span className="sr-only">Open context menu</span>
</button>);
}
});
LinkMenuButton.propTypes = {
onClick: React.PropTypes.func.isRequired
};
module.exports = LinkMenuButton;

Просмотреть файл

@ -0,0 +1,21 @@
.link-menu-button {
cursor: pointer;
position: absolute;
top: -($link-menu-button-size / 2);
right: -($link-menu-button-size / 2);
width: $link-menu-button-size;
height: $link-menu-button-size;
background-color: $white;
background-image: url('img/glyph-more-16.svg');
background-position: center center;
background-repeat: no-repeat;
background-clip: padding-box;
border: $link-menu-button-border;
border-radius: 100%;
box-shadow: $link-menu-button-boxshadow;
transform: scale(0.25);
opacity: 0;
transition-property: transform, opacity;
transition-duration: 200ms;
z-index: 399;
}

Просмотреть файл

@ -4,9 +4,9 @@ const {justDispatch} = require("selectors/selectors");
const {actions} = require("common/action-manager");
const moment = require("moment");
const SiteIcon = require("components/SiteIcon/SiteIcon");
const DeleteMenu = require("components/DeleteMenu/DeleteMenu");
const LinkMenu = require("components/LinkMenu/LinkMenu");
const LinkMenuButton = require("components/LinkMenuButton/LinkMenuButton");
const classNames = require("classnames");
const {FIRST_RUN_TYPE} = require("lib/first-run-data");
const DEFAULT_LENGTH = 3;
@ -66,15 +66,11 @@ const SpotlightItem = React.createClass({
</div>
<div className="inner-border" />
</a>
<div
hidden={site.type === FIRST_RUN_TYPE}
className="tile-close-icon" ref="delete"
onClick={() => this.setState({showContextMenu: true})} />
<DeleteMenu
<LinkMenuButton onClick={() => this.setState({showContextMenu: true})} />
<LinkMenu
visible={this.state.showContextMenu}
onUpdate={val => this.setState({showContextMenu: val})}
url={site.url}
bookmarkGuid={site.bookmarkGuid}
site={site}
page={this.props.page}
index={this.props.index}
source={this.props.source}

Просмотреть файл

@ -9,16 +9,11 @@
margin: 0;
padding: 0;
}
.context-menu {
top: 14px;
right: -12px;
}
}
.spotlight-item {
@include item-shadow;
@include tile-close;
@include link-menu-button;
background: $white;
display: inline-block;
margin-right: $base-gutter;

Просмотреть файл

@ -3,10 +3,10 @@ const {connect} = require("react-redux");
const {justDispatch} = require("selectors/selectors");
const {actions} = require("common/action-manager");
const classNames = require("classnames");
const DeleteMenu = require("components/DeleteMenu/DeleteMenu");
const LinkMenu = require("components/LinkMenu/LinkMenu");
const LinkMenuButton = require("components/LinkMenuButton/LinkMenuButton");
const SiteIcon = require("components/SiteIcon/SiteIcon");
const DEFAULT_LENGTH = 6;
const {FIRST_RUN_TYPE} = require("lib/first-run-data");
const TopSites = React.createClass({
getInitialState() {
@ -44,21 +44,20 @@ const TopSites = React.createClass({
return (<div className="tile-outer" key={site.guid || site.cacheKey || i}>
<a onClick={() => this.onClick(i)} className={classNames("tile", {active: isActive})} href={site.url}>
<SiteIcon className="tile-img-container" site={site} faviconSize={32} showTitle />
<div hidden={site.type === FIRST_RUN_TYPE} className="tile-close-icon" onClick={(ev) => {
<LinkMenuButton onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({showContextMenu: true, activeTile: i});
}}></div>
}} />
<div className="inner-border" />
</a>
<DeleteMenu
<LinkMenu
visible={isActive}
onUpdate={val => this.setState({showContextMenu: val})}
url={site.url}
bookmarkGuid={site.bookmarkGuid}
site={site}
page={this.props.page}
index={i}
source="TOP_SITES"
index={i}
/>
</div>);
})}

Просмотреть файл

@ -7,15 +7,11 @@
}
}
.context-menu {
top: 14px;
right: 12px;
}
// This is a container for the delete menu
.tile-outer {
position: relative;
display: inline-block;
margin: 0 $tile-gutter 0 0;
@media (min-width: $break-point) {
display: block;
}
@ -23,14 +19,13 @@
.tile {
@include item-shadow;
@include tile-close;
@include link-menu-button;
display: inline-flex;
flex-shrink: 0;
border-radius: $border-radius;
flex-direction: column;
font-size: $tile-font-size;
height: $tile-height;
margin: 0 $tile-gutter 0 0;
position: relative;
text-decoration: none;
width: $tile-width;

Просмотреть файл

@ -76,3 +76,4 @@ a {
@import './components/Loader/Loader';
@import './components/LoadMore/LoadMore';
@import './components/ContextMenu/ContextMenu';
@import './components/LinkMenuButton/LinkMenuButton';

Просмотреть файл

@ -36,11 +36,22 @@ module.exports = function setRowsOrError(requestType, responseType, querySize) {
}
}
break;
case am.type("RECEIVE_BOOKMARKS_CHANGES"):
state.rows = prevState.rows.map(site => {
if (site.type === "history" && site.url === action.data.url) {
const {bookmarkGuid, bookmarkTitle, lastModified} = action.data;
const frecency = typeof action.data.frecency !== "undefined" ? action.data.frecency : site.frecency;
return Object.assign({}, site, {bookmarkGuid, bookmarkTitle, frecency, bookmarkDateCreated: lastModified});
} else {
return site;
}
});
break;
case am.type("NOTIFY_BLOCK_URL"):
case am.type("NOTIFY_HISTORY_DELETE"):
state.rows = prevState.rows.filter(val => val.url !== action.data);
break;
case am.type("NOTIFY_BOOKMARK_DELETE"):
case requestType === am.type("RECENT_BOOKMARKS_REQUEST") && am.type("NOTIFY_BOOKMARK_DELETE"):
state.rows = prevState.rows.filter(val => val.bookmarkGuid !== action.data);
break;
default:

Просмотреть файл

@ -41,8 +41,10 @@ $tile-border: 1px $white;
$tile-title-bg-color: rgba($white, 0.6);
$tile-title-hover-color: $white;
$tile-title-font-size: 11px;
$tile-close-border: 1px solid rgba($black, 0.2);
$tile-close-boxshadow: 0 2px 0 rgba($black, 0.1);
$link-menu-button-size: 27px;
$link-menu-button-border: 1px solid rgba($black, 0.2);
$link-menu-button-boxshadow: 0 2px 0 rgba($black, 0.1);
$feed-font-size: 12px;
$feed-date-font-color: #BFBFBF;
@ -93,7 +95,8 @@ $item-shadow-hover: 0 1px 0 0 $faintest-black, 0 0 0 5px $faintest-black;
$loader-size: 16px;
$context-menu-shadow: 0 5px 10px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(0, 0, 0, 0.2);
$context-menu-border-color: rgba(0, 0, 0, 0.2);
$context-menu-shadow: 0 5px 10px rgba(0, 0, 0, 0.3), 0 0 0 1px $context-menu-border-color;
$context-menu-font-size: 14px;
$context-menu-border-radius: 5px;
$context-menu-outer-padding: 5px;
@ -109,35 +112,12 @@ $context-menu-item-padding: 3px 20px;
}
}
@mixin tile-close {
.tile-close-icon {
cursor: pointer;
position: absolute;
top: -14px;
right: -14px;
width: 28px;
height: 28px;
background-color: $white;
background-image: url('img/glyph-delete-16.svg');
background-position: center center;
background-repeat: no-repeat;
background-clip: padding-box;
border: $tile-close-border;
border-radius: 100%;
box-shadow: $tile-close-boxshadow;
transform: scale(0.25);
opacity: 0;
transition-property: transform, opacity;
transition-duration: 200ms;
z-index: 399;
}
@mixin link-menu-button {
&:hover,
&.active {
.tile-close-icon {
.link-menu-button {
transform: scale(1);
opacity: 1;
transition-delay: 500ms;
}
}
}

Просмотреть файл

@ -1,7 +1,8 @@
const {assert} = require("chai");
const ConnectedActivityFeed = require("components/ActivityFeed/ActivityFeed");
const {ActivityFeedItem, GroupedActivityFeed, groupSitesBySession} = ConnectedActivityFeed;
const DeleteMenu = require("components/DeleteMenu/DeleteMenu");
const LinkMenu = require("components/LinkMenu/LinkMenu");
const LinkMenuButton = require("components/LinkMenuButton/LinkMenuButton");
const SiteIcon = require("components/SiteIcon/SiteIcon");
const React = require("react");
const ReactDOM = require("react-dom");
@ -77,12 +78,12 @@ describe("ActivityFeedItem", function() {
instance = renderWithProvider(<ActivityFeedItem {...fakeSiteWithBookmark} />);
assert.include(ReactDOM.findDOMNode(instance).className, "bookmark");
});
it("should show the delete menu when the delete button is clicked", () => {
it("should show the link menu when the link button is clicked", () => {
const item = renderWithProvider(<ActivityFeedItem {...fakeSite} />);
const button = item.refs.delete;
const button = ReactDOM.findDOMNode(TestUtils.findRenderedComponentWithType(item, LinkMenuButton));
TestUtils.Simulate.click(button);
const deleteMenu = TestUtils.findRenderedComponentWithType(item, DeleteMenu);
assert.equal(deleteMenu.props.visible, true);
const menu = TestUtils.findRenderedComponentWithType(item, LinkMenu);
assert.equal(menu.props.visible, true);
});
it("should render date if showDate=true", () => {
const item = renderWithProvider(<ActivityFeedItem showDate={true} {...fakeSite} />);
@ -171,20 +172,6 @@ describe("GroupedActivityFeed", function() {
const link = TestUtils.scryRenderedComponentsWithType(instance, ActivityFeedItem)[0].refs.link;
TestUtils.Simulate.click(link);
});
it("should send an event onShare", done => {
function dispatch(a) {
if (a.type === "NOTIFY_USER_EVENT") {
assert.equal(a.data.event, "SHARE");
assert.equal(a.data.page, "NEW_TAB");
assert.equal(a.data.source, "ACTIVITY_FEED");
assert.equal(a.data.action_position, 2);
done();
}
}
instance = renderWithProvider(<GroupedActivityFeed dispatch={dispatch} page={"NEW_TAB"} sites={sites} />);
const link = TestUtils.scryRenderedComponentsWithType(instance, ActivityFeedItem)[2].refs.share;
TestUtils.Simulate.click(link);
});
});
});

Просмотреть файл

@ -44,17 +44,34 @@ describe("ContextMenu", () => {
setup({options});
assert.equal(links.length, options.length);
});
it("should add a ref for options if provided", () => {
const options = [
{label: "Test", onClick: () => {}, ref: "foo"}
];
setup({options});
assert.ok(instance.refs.foo);
});
it("should call the onClick function when an option button is clicked", done => {
setup({options: [{label: "Foo", onClick() {
done();
}}]});
TestUtils.Simulate.click(links[0]);
});
it("should call the onUserEvent function when an option button is clicked and has a userEvent", done => {
setup({
onUserEvent: type => {
assert.equal(type, "FOO");
done();
},
options: [{label: "Foo", userEvent: "FOO", onClick() {}}]
});
TestUtils.Simulate.click(links[0]);
});
it("should call onUpdate with false when an option is clicked", done => {
setup({
visible: true,
options: [{label: "Test", onClick: () => {}}],
onUpdate: value => {
onUpdate: (value) => {
assert.isFalse(value);
done();
}

Просмотреть файл

@ -0,0 +1,173 @@
const {assert} = require("chai");
const React = require("react");
const TestUtils = require("react-addons-test-utils");
const {renderWithProvider} = require("test/test-utils");
const LinkMenu = require("components/LinkMenu/LinkMenu");
const ContextMenu = require("components/ContextMenu/ContextMenu");
const {FIRST_RUN_TYPE} = require("lib/first-run-data");
const DEFAULT_PROPS = {
onUpdate: () => {},
site: {
url: "https://foo.com"
},
page: "NEW_TAB",
source: "ACTIVITY_FEED",
index: 3
};
const EXPERIMENT_DATA = {Experiments: {data: {id: "exp-01", reverseMenuOptions: true}}};
describe("LinkMenu", () => {
let instance;
let contextMenu;
function setup(custom = {}, customProvider = {}) {
const props = Object.assign({}, DEFAULT_PROPS, custom);
instance = renderWithProvider(<LinkMenu {...props} />, customProvider);
contextMenu = TestUtils.findRenderedComponentWithType(instance, ContextMenu);
}
beforeEach(setup);
it("should render a ContextMenu", () => {
assert.ok(contextMenu);
});
it("should pass visible, onUpdate props to ContextMenu", () => {
const onUpdate = () => {};
setup({visible: true, onUpdate});
assert.isTrue(contextMenu.props.visible, "visible");
assert.equal(contextMenu.props.onUpdate, onUpdate, "onUpdate");
});
it("should show 'Add Bookmark' and hide 'Remove Bookmark' for a non-bookmark", () => {
assert.ok(contextMenu.refs.addBookmark, "show addBoomark");
assert.isUndefined(contextMenu.refs.removeBookmark, "hide removeBookmark");
});
it("should hide 'Add Bookmark' and show 'Remove Bookmark' for a bookmark", () => {
setup({site: {url: "https://foo.com", bookmarkGuid: "asdasd23123"}});
assert.isUndefined(contextMenu.refs.addBookmark, "hide addBoomark");
assert.ok(contextMenu.refs.removeBookmark, "show removeBookmark");
});
it("should hide delete options for FIRST_RUN_TYPE", () => {
setup({site: {url: "https://foo.com", type: FIRST_RUN_TYPE}});
assert.isUndefined(contextMenu.refs.dismiss, "hide dismiss");
assert.isUndefined(contextMenu.refs.delete, "hide delete");
});
it("should hide dismiss option if allowBlock is false", () => {
setup({allowBlock: false});
assert.isUndefined(contextMenu.refs.dismiss, "hide dismiss");
});
describe("individual options", () => {
// Checks to make sure each action
// 1. Fires a custom action (options.event)
// 2. Has the right event data (options.eventData)
// 3. Fires a NOTIFY_USER_EVENT with type options.userEvent
// When options.ref is clicked
function checkOption(options) {
it(`should ${options.ref}`, done => {
let count = 0;
setup(options.props || {}, {dispatch(action) {
if (action.type === options.event) {
assert.deepEqual(action.data, options.eventData, "event data");
count++;
}
if (action.type === "NOTIFY_USER_EVENT") {
assert.equal(action.data.event, options.userEvent);
assert.equal(action.data.page, DEFAULT_PROPS.page);
assert.equal(action.data.source, DEFAULT_PROPS.source);
assert.equal(action.data.action_position, DEFAULT_PROPS.index);
count++;
}
if (count === 2) {
done();
}
}});
TestUtils.Simulate.click(contextMenu.refs[options.ref]);
});
}
checkOption({
ref: "removeBookmark",
props: {site: {url: "https://foo.com", bookmarkGuid: "foo123"}},
event: "NOTIFY_BOOKMARK_DELETE",
eventData: "foo123",
userEvent: "BOOKMARK_DELETE"
});
checkOption({
ref: "addBookmark",
event: "NOTIFY_BOOKMARK_ADD",
eventData: DEFAULT_PROPS.site.url,
userEvent: "BOOKMARK_ADD"
});
checkOption({
ref: "openWindow",
event: "NOTIFY_OPEN_WINDOW",
eventData: {url: DEFAULT_PROPS.site.url},
userEvent: "OPEN_NEW_WINDOW"
});
checkOption({
ref: "openPrivate",
event: "NOTIFY_OPEN_WINDOW",
eventData: {url: DEFAULT_PROPS.site.url, isPrivate: true},
userEvent: "OPEN_PRIVATE_WINDOW"
});
checkOption({
ref: "dismiss",
event: "NOTIFY_BLOCK_URL",
eventData: DEFAULT_PROPS.site.url,
userEvent: "BLOCK"
});
checkOption({
ref: "delete",
event: "NOTIFY_HISTORY_DELETE",
eventData: DEFAULT_PROPS.site.url,
userEvent: "DELETE"
});
});
describe("experiment", () => {
it("should reverse delete options", () => {
setup({}, {getState() {
return {Experiments: {data: {reverseMenuOptions: true}}};
}});
let deleteIndex;
let dismissIndex;
contextMenu.props.options.forEach((o, i) => {
if (o.ref === "dismiss") {
dismissIndex = i;
} else if (o.ref === "delete") {
deleteIndex = i;
}
});
assert.isBelow(deleteIndex, dismissIndex);
});
it("should not send the experiment id with non-delete user events", done => {
setup({}, {
getState: () => EXPERIMENT_DATA,
dispatch(action) {
if (action.type === "NOTIFY_USER_EVENT") {
assert.isUndefined(action.data.experiment_id);
done();
}
}
});
TestUtils.Simulate.click(contextMenu.refs.openWindow);
});
it("should send the experiment id with user events", done => {
setup({}, {
getState: () => EXPERIMENT_DATA,
dispatch(action) {
if (action.type === "NOTIFY_USER_EVENT") {
assert.equal(action.data.experiment_id, "exp-01");
done();
}
}
});
TestUtils.Simulate.click(contextMenu.refs.delete);
});
});
});

Просмотреть файл

@ -0,0 +1,22 @@
const {assert} = require("chai");
const React = require("react");
const ReactDOM = require("react-dom");
const TestUtils = require("react-addons-test-utils");
const LinkMenuButton = require("components/LinkMenuButton/LinkMenuButton");
describe("LinkMenuButton", () => {
it("should render", () => {
let instance = TestUtils.renderIntoDocument(<LinkMenuButton onClick={() => {}} />);
assert.ok(instance);
});
it("should preventDefault and call onClick when clicked", done => {
let preventDefaultCalled = false;
let instance = TestUtils.renderIntoDocument(<LinkMenuButton onClick={e => {
assert.isTrue(preventDefaultCalled, "preventDefault was called");
done();
}} />);
TestUtils.Simulate.click(ReactDOM.findDOMNode(instance), {preventDefault: () => {
preventDefaultCalled = true;
}});
});
});

Просмотреть файл

@ -2,13 +2,13 @@ const {assert} = require("chai");
const moment = require("moment");
const ConnectedSpotlight = require("components/Spotlight/Spotlight");
const {Spotlight, SpotlightItem} = ConnectedSpotlight;
const DeleteMenu = require("components/DeleteMenu/DeleteMenu");
const LinkMenu = require("components/LinkMenu/LinkMenu");
const LinkMenuButton = require("components/LinkMenuButton/LinkMenuButton");
const React = require("react");
const ReactDOM = require("react-dom");
const TestUtils = require("react-addons-test-utils");
const SiteIcon = require("components/SiteIcon/SiteIcon");
const {mockData, faker, renderWithProvider} = require("test/test-utils");
const firstRunData = require("lib/first-run-data");
const fakeSpotlightItems = mockData.Spotlight.rows;
const fakeSiteWithImage = faker.createSite();
fakeSiteWithImage.bestImage = fakeSiteWithImage.images[0];
@ -102,15 +102,11 @@ describe("SpotlightItem", function() {
instance = renderWithProvider(<SpotlightItem {...props} />);
assert.equal(instance.refs.contextMessage.textContent, "Visited recently");
});
it("should not show delete icon for first run items", () => {
instance = renderWithProvider(<SpotlightItem {...firstRunData.Highlights[0]} />);
assert.equal(instance.refs.delete.hidden, true);
});
it("should show delete menu when delete icon is pressed", () => {
const button = instance.refs.delete;
it("should show link menu when link button is pressed", () => {
const button = ReactDOM.findDOMNode(TestUtils.findRenderedComponentWithType(instance, LinkMenuButton));
TestUtils.Simulate.click(button);
const deleteMenu = TestUtils.findRenderedComponentWithType(instance, DeleteMenu);
assert.equal(deleteMenu.props.visible, true);
const menu = TestUtils.findRenderedComponentWithType(instance, LinkMenu);
assert.equal(menu.props.visible, true);
});
});
});

Просмотреть файл

@ -3,10 +3,10 @@ const TestUtils = require("react-addons-test-utils");
const React = require("react");
const ReactDOM = require("react-dom");
const {overrideConsoleError, renderWithProvider} = require("test/test-utils");
const firstRunData = require("lib/first-run-data");
const ConnectedTopSites = require("components/TopSites/TopSites");
const {TopSites} = ConnectedTopSites;
const DeleteMenu = require("components/DeleteMenu/DeleteMenu");
const LinkMenu = require("components/LinkMenu/LinkMenu");
const LinkMenuButton = require("components/LinkMenuButton/LinkMenuButton");
const SiteIcon = require("components/SiteIcon/SiteIcon");
const fakeProps = {
@ -58,17 +58,11 @@ describe("TopSites", () => {
assert.include(linkEls[1].href, fakeProps.sites[1].url);
});
it("should hide the delete button for first run items", () => {
topSites = renderWithProvider(<TopSites sites={firstRunData.TopSites} />);
const button = TestUtils.scryRenderedDOMComponentsWithClass(topSites, "tile-close-icon")[0];
assert.equal(button.hidden, true);
});
it("should show delete menu when delete button is clicked", () => {
const button = TestUtils.scryRenderedDOMComponentsWithClass(topSites, "tile-close-icon")[0];
const button = ReactDOM.findDOMNode(TestUtils.scryRenderedComponentsWithType(topSites, LinkMenuButton)[0]);
TestUtils.Simulate.click(button);
const deleteMenu = TestUtils.scryRenderedComponentsWithType(topSites, DeleteMenu)[0];
assert.equal(deleteMenu.props.visible, true);
const menu = TestUtils.scryRenderedComponentsWithType(topSites, LinkMenu)[0];
assert.equal(menu.props.visible, true);
});
});

Просмотреть файл

@ -5,6 +5,7 @@ const RESPONSE_TYPE = "RECENT_LINKS_RESPONSE";
describe("setRowsOrError", () => {
let reducer;
beforeEach(() => {
reducer = setRowsOrError(REQUEST_TYPE, RESPONSE_TYPE);
});
@ -103,6 +104,49 @@ describe("setRowsOrError", () => {
assert.isTrue(state.canLoadMore);
});
it("should set bookmark status of history items on RECEIVE_BOOKMARKS_CHANGES", () => {
const action = {type: "RECEIVE_BOOKMARKS_CHANGES", data: {
bookmarkGuid: "bookmark123",
lastModified: 1234124,
frecency: 200,
bookmarkTitle: "foo",
url: "https://foo.com"
}};
const prevRows = [
{type: "history", url: "blah.com"},
{type: "history", url: "https://foo.com", frecency: 1}
];
const result = reducer(Object.assign({}, setRowsOrError.DEFAULTS, {rows: prevRows}), action);
const newRow = result.rows[1];
assert.equal(newRow.bookmarkGuid, action.data.bookmarkGuid, "should have the right bookmarkGuid");
assert.equal(newRow.bookmarkDateCreated, action.data.lastModified, "should have the right bookmarkDateCreated");
assert.equal(newRow.frecency, action.data.frecency, "should have the right frecency");
assert.equal(newRow.bookmarkTitle, action.data.bookmarkTitle, "should have the right bookmarkTitle");
});
it("should remove bookmark status of history items on RECEIVE_BOOKMARKS_CHANGES", () => {
const action = {type: "RECEIVE_BOOKMARKS_CHANGES", data: {
url: "https://foo.com"
}};
const prevRows = [
{type: "history", url: "blah.com"},
{
type: "history",
bookmarkGuid: "bookmark123",
bookmarkDateCreated: 1234124,
frecency: 200,
bookmarkTitle: "foo",
url: "https://foo.com"
}
];
const result = reducer(Object.assign({}, setRowsOrError.DEFAULTS, {rows: prevRows}), action);
const newRow = result.rows[1];
assert.isUndefined(newRow.bookmarkGuid, "should remove bookmarkGuid");
assert.isUndefined(newRow.bookmarkDateCreated, "should remove bookmarkDateCreated");
assert.equal(newRow.frecency, prevRows[1].frecency, "should not change the frecency");
assert.isUndefined(newRow.bookmarkTitle, "should remove bookmarkTitle");
});
((event) => {
it(`should remove a row removed via ${event}`, () => {
const action = {type: event, data: "http://foo.com"};
@ -112,13 +156,12 @@ describe("setRowsOrError", () => {
});
})("NOTIFY_HISTORY_DELETE", "NOTIFY_BLOCK_URL");
((event) => {
it(`should remove a row removed via ${event}`, () => {
const action = {type: event, data: "boorkmarkFOO"};
it("should remove \"NOTIFY_BOOKMARK_DELETE\" if request type is \"RECENT_BOOKMARKS_REQUEST\"", () => {
reducer = setRowsOrError("RECENT_BOOKMARKS_REQUEST", "RECENT_LINKS_RESPONSE");
const action = {type: "NOTIFY_BOOKMARK_DELETE", data: "boorkmarkFOO"};
const prevRows = [{bookmarkGuid: "boorkmarkFOO"}, {bookmarkGuid: "boorkmarkBAR"}];
const state = reducer(Object.assign({}, setRowsOrError.DEFAULTS, {rows: prevRows}), action);
assert.deepEqual(state.rows, [{bookmarkGuid: "boorkmarkBAR"}]);
});
})("NOTIFY_BOOKMARK_DELETE", "NOTIFY_BLOCK_URL");
});

Просмотреть файл

@ -10,6 +10,7 @@ const {ActionButton} = require("sdk/ui/button/action");
const tabs = require("sdk/tabs");
const simplePrefs = require("sdk/simple-prefs");
const privateBrowsing = require("sdk/private-browsing");
const windows = require("sdk/windows").browserWindows;
const {Memoizer} = require("lib/Memoizer");
const {PlacesProvider} = require("lib/PlacesProvider");
const {SearchProvider} = require("lib/SearchProvider");
@ -128,6 +129,15 @@ ActivityStreams.prototype = {
}
},
_respondOpenWindow({msg}) {
if (msg.type === am.type("NOTIFY_OPEN_WINDOW")) {
windows.open({
url: msg.data.url,
isPrivate: msg.data.isPrivate
});
}
},
/**
* Responds to places requests
*/
@ -153,6 +163,9 @@ ActivityStreams.prototype = {
this._processAndSendLinks(links, "HIGHLIGHTS_LINKS_RESPONSE", worker, msg.meta);
});
break;
case am.type("NOTIFY_BOOKMARK_ADD"):
PlacesProvider.links.asyncAddBookmark(msg.data);
break;
case am.type("NOTIFY_BOOKMARK_DELETE"):
PlacesProvider.links.asyncDeleteBookmark(msg.data);
break;
@ -288,6 +301,7 @@ ActivityStreams.prototype = {
this._respondToPlacesRequests(args);
this._respondToSearchRequests(args);
this._logPerfMeter(args);
this._respondOpenWindow(args);
};
this.on(CONTENT_TO_ADDON, this._contentToAddonHandlers);
},

Просмотреть файл

@ -277,6 +277,16 @@ Links.prototype = {
return Bookmarks.remove(bookmarkGuid);
},
/**
* Adds a bookmark
*
* @param {String} url the url to bookmark
* @returns {Promise} Returns a promise set to an object representing the bookmark
*/
asyncAddBookmark: function PlacesProvider_asyncAddBookmark(url) {
return Bookmarks.insert({url, type: Bookmarks.TYPE_BOOKMARK, parentGuid: Bookmarks.unfiledGuid});
},
/**
* Removes a history link
*

Просмотреть файл

@ -238,6 +238,29 @@ exports.test_Links_getFrecentLinks = function*(assert) {
assert.equal(links[3].url, "https://mozilla4.com/3", "Expected 4th link");
};
exports.test_Links_asyncAddBookmark = function*(assert) {
let provider = PlacesProvider.links;
let bookmarks = [
"https://mozilla1.com/0",
"https://mozilla1.com/1"
];
let links = yield provider.getRecentBookmarks();
assert.equal(links.length, 0, "empty bookmarks yields empty links");
let bookmarksSize = yield provider.getBookmarksSize();
assert.equal(bookmarksSize, 0, "empty bookmarks yields 0 size");
for (let url of bookmarks) {
yield provider.asyncAddBookmark(url);
}
links = yield provider.getRecentBookmarks();
assert.equal(links.length, 2, "2 bookmarks on bookmark list");
bookmarksSize = yield provider.getBookmarksSize();
assert.equal(bookmarksSize, 2, "size 2 for 2 bookmarks added");
};
exports.test_Links_asyncDeleteBookmark = function*(assert) {
let provider = PlacesProvider.links;