diff --git a/common/action-manager.js b/common/action-manager.js
index acb571f1c..5813579f7 100644
--- a/common/action-manager.js
+++ b/common/action-manager.js
@@ -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;
diff --git a/common/event-constants.js b/common/event-constants.js
index 5c5e12476..341ce2c65 100644
--- a/common/event-constants.js
+++ b/common/event-constants.js
@@ -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",
diff --git a/content-src/components/ActivityFeed/ActivityFeed.js b/content-src/components/ActivityFeed/ActivityFeed.js
index 268183d6f..08abccf41 100644
--- a/content-src/components/ActivityFeed/ActivityFeed.js
+++ b/content-src/components/ActivityFeed/ActivityFeed.js
@@ -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 (
+ return (
{icon}
@@ -77,19 +75,15 @@ const ActivityFeedItem = React.createClass({
-
-
-
this.props.onShare(site.url)}>
-
alert("Sorry. We are still working on this feature.")}>
-
- this.setState({showContextMenu: true})} />
+ 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}
/>
);
}
diff --git a/content-src/components/ActivityFeed/ActivityFeed.scss b/content-src/components/ActivityFeed/ActivityFeed.scss
index a68ebd2b3..0dbffc6f8 100644
--- a/content-src/components/ActivityFeed/ActivityFeed.scss
+++ b/content-src/components/ActivityFeed/ActivityFeed.scss
@@ -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;
diff --git a/content-src/components/ContextMenu/ContextMenu.js b/content-src/components/ContextMenu/ContextMenu.js
index 602bf7578..61fae0632 100644
--- a/content-src/components/ContextMenu/ContextMenu.js
+++ b/content-src/components/ContextMenu/ContextMenu.js
@@ -21,10 +21,21 @@ const ContextMenu = React.createClass({
return (
);
@@ -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
};
diff --git a/content-src/components/ContextMenu/ContextMenu.scss b/content-src/components/ContextMenu/ContextMenu.scss
index 01b4df8df..6986b857b 100644
--- a/content-src/components/ContextMenu/ContextMenu.scss
+++ b/content-src/components/ContextMenu/ContextMenu.scss
@@ -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;
diff --git a/content-src/components/LinkMenu/LinkMenu.js b/content-src/components/LinkMenu/LinkMenu.js
new file mode 100644
index 000000000..8e606f139
--- /dev/null
+++ b/content-src/components/LinkMenu/LinkMenu.js
@@ -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 ();
+ }
+});
+
+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);
diff --git a/content-src/components/LinkMenuButton/LinkMenuButton.js b/content-src/components/LinkMenuButton/LinkMenuButton.js
new file mode 100644
index 000000000..01f0fa9ad
--- /dev/null
+++ b/content-src/components/LinkMenuButton/LinkMenuButton.js
@@ -0,0 +1,15 @@
+const React = require("react");
+
+const LinkMenuButton = React.createClass({
+ render() {
+ return ();
+ }
+});
+
+LinkMenuButton.propTypes = {
+ onClick: React.PropTypes.func.isRequired
+};
+
+module.exports = LinkMenuButton;
diff --git a/content-src/components/LinkMenuButton/LinkMenuButton.scss b/content-src/components/LinkMenuButton/LinkMenuButton.scss
new file mode 100644
index 000000000..4cedea771
--- /dev/null
+++ b/content-src/components/LinkMenuButton/LinkMenuButton.scss
@@ -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;
+}
diff --git a/content-src/components/Spotlight/Spotlight.js b/content-src/components/Spotlight/Spotlight.js
index d2253ada8..a9c49a59e 100644
--- a/content-src/components/Spotlight/Spotlight.js
+++ b/content-src/components/Spotlight/Spotlight.js
@@ -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({
- this.setState({showContextMenu: true})} />
-
this.setState({showContextMenu: true})} />
+ this.setState({showContextMenu: val})}
- url={site.url}
- bookmarkGuid={site.bookmarkGuid}
+ site={site}
page={this.props.page}
index={this.props.index}
source={this.props.source}
diff --git a/content-src/components/Spotlight/Spotlight.scss b/content-src/components/Spotlight/Spotlight.scss
index abde920f8..9961bf473 100644
--- a/content-src/components/Spotlight/Spotlight.scss
+++ b/content-src/components/Spotlight/Spotlight.scss
@@ -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;
diff --git a/content-src/components/TopSites/TopSites.js b/content-src/components/TopSites/TopSites.js
index 48aebef1d..103932207 100644
--- a/content-src/components/TopSites/TopSites.js
+++ b/content-src/components/TopSites/TopSites.js
@@ -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 ();
})}
diff --git a/content-src/components/TopSites/TopSites.scss b/content-src/components/TopSites/TopSites.scss
index 4cc49570b..532db4ed1 100644
--- a/content-src/components/TopSites/TopSites.scss
+++ b/content-src/components/TopSites/TopSites.scss
@@ -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;
diff --git a/content-src/main.scss b/content-src/main.scss
index 0a95a2731..0d30b0d42 100644
--- a/content-src/main.scss
+++ b/content-src/main.scss
@@ -76,3 +76,4 @@ a {
@import './components/Loader/Loader';
@import './components/LoadMore/LoadMore';
@import './components/ContextMenu/ContextMenu';
+@import './components/LinkMenuButton/LinkMenuButton';
diff --git a/content-src/reducers/SetRowsOrError.js b/content-src/reducers/SetRowsOrError.js
index 45584b0d9..523f858a6 100644
--- a/content-src/reducers/SetRowsOrError.js
+++ b/content-src/reducers/SetRowsOrError.js
@@ -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:
diff --git a/content-src/styles/variables.scss b/content-src/styles/variables.scss
index f661fc1d4..d0648f7db 100644
--- a/content-src/styles/variables.scss
+++ b/content-src/styles/variables.scss
@@ -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;
}
}
}
diff --git a/content-test/components/ActivityFeed.test.js b/content-test/components/ActivityFeed.test.js
index b78a6024d..5e47e9542 100644
--- a/content-test/components/ActivityFeed.test.js
+++ b/content-test/components/ActivityFeed.test.js
@@ -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();
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();
- 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();
@@ -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();
- const link = TestUtils.scryRenderedComponentsWithType(instance, ActivityFeedItem)[2].refs.share;
- TestUtils.Simulate.click(link);
- });
});
});
diff --git a/content-test/components/ContextMenu.test.js b/content-test/components/ContextMenu.test.js
index 5f8cef737..b3697cd5c 100644
--- a/content-test/components/ContextMenu.test.js
+++ b/content-test/components/ContextMenu.test.js
@@ -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();
}
diff --git a/content-test/components/LinkMenu.test.js b/content-test/components/LinkMenu.test.js
new file mode 100644
index 000000000..88ea8a495
--- /dev/null
+++ b/content-test/components/LinkMenu.test.js
@@ -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(, 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);
+ });
+ });
+});
diff --git a/content-test/components/LinkMenuButton.test.js b/content-test/components/LinkMenuButton.test.js
new file mode 100644
index 000000000..314f92982
--- /dev/null
+++ b/content-test/components/LinkMenuButton.test.js
@@ -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( {}} />);
+ assert.ok(instance);
+ });
+ it("should preventDefault and call onClick when clicked", done => {
+ let preventDefaultCalled = false;
+ let instance = TestUtils.renderIntoDocument( {
+ assert.isTrue(preventDefaultCalled, "preventDefault was called");
+ done();
+ }} />);
+ TestUtils.Simulate.click(ReactDOM.findDOMNode(instance), {preventDefault: () => {
+ preventDefaultCalled = true;
+ }});
+ });
+});
diff --git a/content-test/components/Spotlight.test.js b/content-test/components/Spotlight.test.js
index e332da8c5..d194cb277 100644
--- a/content-test/components/Spotlight.test.js
+++ b/content-test/components/Spotlight.test.js
@@ -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();
assert.equal(instance.refs.contextMessage.textContent, "Visited recently");
});
- it("should not show delete icon for first run items", () => {
- instance = renderWithProvider();
- 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);
});
});
});
diff --git a/content-test/components/TopSites.test.js b/content-test/components/TopSites.test.js
index 79292de28..bed2e9aea 100644
--- a/content-test/components/TopSites.test.js
+++ b/content-test/components/TopSites.test.js
@@ -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();
- 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);
});
});
diff --git a/content-test/reducers/SetRowsOrError.test.js b/content-test/reducers/SetRowsOrError.test.js
index 05725dffe..3ad29961f 100644
--- a/content-test/reducers/SetRowsOrError.test.js
+++ b/content-test/reducers/SetRowsOrError.test.js
@@ -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"};
- 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");
+ 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"}]);
+ });
});
diff --git a/lib/ActivityStreams.js b/lib/ActivityStreams.js
index 001ac386b..00ef26afb 100644
--- a/lib/ActivityStreams.js
+++ b/lib/ActivityStreams.js
@@ -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);
},
diff --git a/lib/PlacesProvider.js b/lib/PlacesProvider.js
index 70e693a5c..0bcb9de8e 100644
--- a/lib/PlacesProvider.js
+++ b/lib/PlacesProvider.js
@@ -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
*
diff --git a/test/test-PlacesProvider.js b/test/test-PlacesProvider.js
index 150217f19..889e9c0af 100644
--- a/test/test-PlacesProvider.js
+++ b/test/test-PlacesProvider.js
@@ -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;