Fix Bug 1402654 - Allow custom image / thumbnail for an edited top site.
This commit is contained in:
Родитель
9632262414
Коммит
b69291f6f3
|
@ -179,6 +179,7 @@ and losing focus. | :one:
|
|||
| `topsites_data_late_by_ms` | [Optional] Time in ms it took for TopSites to become initialized | :one:
|
||||
| `topstories.domain.affinity.calculation.ms` | [Optional] Time in ms it took for domain affinities to be calculated | :one:
|
||||
| `topsites_first_painted_ts` | [Optional][Service Counter][Server Alert for too many omissions] Timestamp of when the Top Sites element finished painting (possibly with only placeholder screenshots) | :one:
|
||||
| `custom_screenshot` | [Optional] Number of topsites that display a custom screenshot. | :one:
|
||||
| `screenshot_with_icon` | [Optional] Number of topsites that display a screenshot and a favicon. | :one:
|
||||
| `screenshot` | [Optional] Number of topsites that display only a screenshot. | :one:
|
||||
| `tippytop` | [Optional] Number of topsites that display a tippytop icon. | :one:
|
||||
|
|
|
@ -284,6 +284,15 @@ A user event ping includes some basic metadata (tab id, addon version, etc.) as
|
|||
}
|
||||
```
|
||||
|
||||
#### Requesting a custom screenshot preview
|
||||
|
||||
```js
|
||||
{
|
||||
"event": "PREVIEW_REQUEST",
|
||||
"source": "TOP_SITES"
|
||||
}
|
||||
```
|
||||
|
||||
## Session end pings
|
||||
|
||||
When a session ends, the browser will send a `"activity_stream_session"` ping to our metrics servers. This ping contains the length of the session, a unique reason for why the session ended, and some additional metadata.
|
||||
|
@ -349,9 +358,10 @@ perf: {
|
|||
// and is therefore just showing placeholder screenshots.
|
||||
"topsites_first_painted_ts": 5,
|
||||
|
||||
// The 5 different types of TopSites icons and how many of each kind did the
|
||||
// The 6 different types of TopSites icons and how many of each kind did the
|
||||
// user see.
|
||||
"topsites_icon_stats": {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 2,
|
||||
"screenshot": 1,
|
||||
"tippytop": 2,
|
||||
|
|
|
@ -58,6 +58,9 @@ for (const type of [
|
|||
"PLACES_SAVED_TO_POCKET",
|
||||
"PREFS_INITIAL_VALUES",
|
||||
"PREF_CHANGED",
|
||||
"PREVIEW_REQUEST",
|
||||
"PREVIEW_REQUEST_CANCEL",
|
||||
"PREVIEW_RESPONSE",
|
||||
"RICH_ICON_MISSING",
|
||||
"SAVE_SESSION_PERF_DATA",
|
||||
"SAVE_TO_POCKET",
|
||||
|
|
|
@ -92,9 +92,46 @@ function TopSites(prevState = INITIAL_STATE.TopSites, action) {
|
|||
case at.TOP_SITES_PREFS_UPDATED:
|
||||
return Object.assign({}, prevState, {pref: action.data.pref});
|
||||
case at.TOP_SITES_EDIT:
|
||||
return Object.assign({}, prevState, {editForm: {index: action.data.index}});
|
||||
return Object.assign({}, prevState, {
|
||||
editForm: {
|
||||
index: action.data.index,
|
||||
previewResponse: null
|
||||
}
|
||||
});
|
||||
case at.TOP_SITES_CANCEL_EDIT:
|
||||
return Object.assign({}, prevState, {editForm: null});
|
||||
case at.PREVIEW_RESPONSE:
|
||||
if (!prevState.editForm || action.data.url !== prevState.editForm.previewUrl) {
|
||||
return prevState;
|
||||
}
|
||||
return Object.assign({}, prevState, {
|
||||
editForm: {
|
||||
index: prevState.editForm.index,
|
||||
previewResponse: action.data.preview,
|
||||
previewUrl: action.data.url
|
||||
}
|
||||
});
|
||||
case at.PREVIEW_REQUEST:
|
||||
if (!prevState.editForm) {
|
||||
return prevState;
|
||||
}
|
||||
return Object.assign({}, prevState, {
|
||||
editForm: {
|
||||
index: prevState.editForm.index,
|
||||
previewResponse: null,
|
||||
previewUrl: action.data.url
|
||||
}
|
||||
});
|
||||
case at.PREVIEW_REQUEST_CANCEL:
|
||||
if (!prevState.editForm) {
|
||||
return prevState;
|
||||
}
|
||||
return Object.assign({}, prevState, {
|
||||
editForm: {
|
||||
index: prevState.editForm.index,
|
||||
previewResponse: null
|
||||
}
|
||||
});
|
||||
case at.SCREENSHOT_UPDATED:
|
||||
newRows = prevState.rows.map(row => {
|
||||
if (row && row.url === action.data.url) {
|
||||
|
|
|
@ -58,7 +58,7 @@ export class TopSiteLink extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const {children, className, isDraggable, link, onClick, title} = this.props;
|
||||
const {children, className, defaultStyle, isDraggable, link, onClick, title} = this.props;
|
||||
const topSiteOuterClassName = `top-site-outer${className ? ` ${className}` : ""}${link.isDragged ? " dragged" : ""}`;
|
||||
const {tippyTopIcon, faviconSize} = link;
|
||||
const [letterFallback] = title;
|
||||
|
@ -67,7 +67,16 @@ export class TopSiteLink extends React.PureComponent {
|
|||
let showSmallFavicon = false;
|
||||
let smallFaviconStyle;
|
||||
let smallFaviconFallback;
|
||||
if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
|
||||
if (defaultStyle) { // force no styles (letter fallback) even if the link has imagery
|
||||
smallFaviconFallback = false;
|
||||
} else if (link.customScreenshotURL) {
|
||||
// assume high quality custom screenshot and use rich icon styles and class names
|
||||
imageClassName = "top-site-icon rich-icon";
|
||||
imageStyle = {
|
||||
backgroundColor: link.backgroundColor,
|
||||
backgroundImage: `url(${link.screenshot})`
|
||||
};
|
||||
} else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
|
||||
// styles and class names for top sites with rich icons
|
||||
imageClassName = "top-site-icon rich-icon";
|
||||
imageStyle = {
|
||||
|
@ -288,7 +297,15 @@ export class _TopSiteList extends React.PureComponent {
|
|||
this.dropped = true;
|
||||
this.props.dispatch(ac.AlsoToMain({
|
||||
type: at.TOP_SITES_INSERT,
|
||||
data: {site: {url: this.state.draggedSite.url, label: this.state.draggedTitle}, index, draggedFromIndex: this.state.draggedIndex}
|
||||
data: {
|
||||
site: {
|
||||
url: this.state.draggedSite.url,
|
||||
label: this.state.draggedTitle,
|
||||
customScreenshotURL: this.state.draggedSite.customScreenshotURL
|
||||
},
|
||||
index,
|
||||
draggedFromIndex: this.state.draggedIndex
|
||||
}
|
||||
}));
|
||||
this.userEvent("DROP", index);
|
||||
}
|
||||
|
|
|
@ -12,13 +12,20 @@ export class TopSiteForm extends React.PureComponent {
|
|||
this.state = {
|
||||
label: site ? (site.label || site.hostname) : "",
|
||||
url: site ? site.url : "",
|
||||
validationError: false
|
||||
validationError: false,
|
||||
customScreenshotUrl: site ? site.customScreenshotURL : "",
|
||||
showCustomScreenshotForm: site ? site.customScreenshotURL : false
|
||||
};
|
||||
this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this);
|
||||
this.onLabelChange = this.onLabelChange.bind(this);
|
||||
this.onUrlChange = this.onUrlChange.bind(this);
|
||||
this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
|
||||
this.onClearUrlClick = this.onClearUrlClick.bind(this);
|
||||
this.onDoneButtonClick = this.onDoneButtonClick.bind(this);
|
||||
this.onCustomScreenshotUrlChange = this.onCustomScreenshotUrlChange.bind(this);
|
||||
this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
|
||||
this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this);
|
||||
this.validateUrl = this.validateUrl.bind(this);
|
||||
}
|
||||
|
||||
onLabelChange(event) {
|
||||
|
@ -39,6 +46,26 @@ export class TopSiteForm extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
onEnableScreenshotUrlForm() {
|
||||
this.setState({showCustomScreenshotForm: true});
|
||||
}
|
||||
|
||||
_updateCustomScreenshotInput(customScreenshotUrl) {
|
||||
this.setState({
|
||||
customScreenshotUrl,
|
||||
validationError: false
|
||||
});
|
||||
this.props.dispatch({type: at.PREVIEW_REQUEST_CANCEL});
|
||||
}
|
||||
|
||||
onCustomScreenshotUrlChange(event) {
|
||||
this._updateCustomScreenshotInput(event.target.value);
|
||||
}
|
||||
|
||||
onClearScreenshotInput() {
|
||||
this._updateCustomScreenshotInput("");
|
||||
}
|
||||
|
||||
onCancelButtonClick(ev) {
|
||||
ev.preventDefault();
|
||||
this.props.onClose();
|
||||
|
@ -54,6 +81,12 @@ export class TopSiteForm extends React.PureComponent {
|
|||
site.label = this.state.label;
|
||||
}
|
||||
|
||||
if (this.state.customScreenshotUrl) {
|
||||
site.customScreenshotURL = this.cleanUrl(this.state.customScreenshotUrl);
|
||||
} else if (this.props.site && this.props.site.customScreenshotURL) {
|
||||
// Used to flag that previously cached screenshot should be removed
|
||||
site.customScreenshotURL = null;
|
||||
}
|
||||
this.props.dispatch(ac.AlsoToMain({
|
||||
type: at.TOP_SITES_PIN,
|
||||
data: {site, index}
|
||||
|
@ -68,6 +101,20 @@ export class TopSiteForm extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
onPreviewButtonClick(event) {
|
||||
event.preventDefault();
|
||||
if (this.validateForm()) {
|
||||
this.props.dispatch(ac.AlsoToMain({
|
||||
type: at.PREVIEW_REQUEST,
|
||||
data: {url: this.cleanUrl(this.state.customScreenshotUrl)}
|
||||
}));
|
||||
this.props.dispatch(ac.UserEvent({
|
||||
source: TOP_SITES_SOURCE,
|
||||
event: "PREVIEW_REQUEST"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
cleanUrl(url) {
|
||||
// If we are missing a protocol, prepend http://
|
||||
if (!url.startsWith("http:") && !url.startsWith("https:")) {
|
||||
|
@ -84,16 +131,66 @@ export class TopSiteForm extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
validateCustomScreenshotUrl() {
|
||||
const {customScreenshotUrl} = this.state;
|
||||
return !customScreenshotUrl || this.validateUrl(customScreenshotUrl);
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
const validate = this.validateUrl(this.state.url);
|
||||
this.setState({validationError: !validate});
|
||||
const validate = this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl();
|
||||
|
||||
if (!validate) {
|
||||
this.setState({validationError: true});
|
||||
}
|
||||
|
||||
return validate;
|
||||
}
|
||||
|
||||
_renderCustomScreenshotInput() {
|
||||
const {customScreenshotUrl} = this.state;
|
||||
const requestFailed = this.props.previewResponse === "";
|
||||
const validationError = (this.state.validationError && !this.validateCustomScreenshotUrl()) || requestFailed;
|
||||
// Set focus on error if the url field is valid or when the input is first rendered and is empty
|
||||
const shouldFocus = (validationError && this.validateUrl(this.state.url)) || !customScreenshotUrl;
|
||||
const isLoading = this.props.previewResponse === null &&
|
||||
customScreenshotUrl && this.props.previewUrl === this.cleanUrl(customScreenshotUrl);
|
||||
|
||||
if (!this.state.showCustomScreenshotForm) {
|
||||
return (<a className="enable-custom-image-input" onClick={this.onEnableScreenshotUrlForm}>
|
||||
<FormattedMessage id="topsites_form_use_image_link" />
|
||||
</a>);
|
||||
}
|
||||
return (<div className="custom-image-input-container">
|
||||
<TopSiteFormInput
|
||||
errorMessageId={requestFailed ? "topsites_form_image_validation" : "topsites_form_url_validation"}
|
||||
loading={isLoading}
|
||||
onChange={this.onCustomScreenshotUrlChange}
|
||||
onClear={this.onClearScreenshotInput}
|
||||
shouldFocus={shouldFocus}
|
||||
typeUrl={true}
|
||||
value={customScreenshotUrl}
|
||||
validationError={validationError}
|
||||
titleId="topsites_form_image_url_label"
|
||||
placeholderId="topsites_form_url_placeholder"
|
||||
intl={this.props.intl} />
|
||||
</div>);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {customScreenshotUrl} = this.state;
|
||||
const requestFailed = this.props.previewResponse === "";
|
||||
// For UI purposes, editing without an existing link is "add"
|
||||
const showAsAdd = !this.props.site;
|
||||
|
||||
const previous = (this.props.site && this.props.site.customScreenshotURL) || "";
|
||||
const changed = customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous;
|
||||
// Preview mode if changes were made to the custom screenshot URL and no preview was received yet
|
||||
// or the request failed
|
||||
const previewMode = changed && !this.props.previewResponse;
|
||||
const previewLink = Object.assign({}, this.props.site);
|
||||
if (this.props.previewResponse) {
|
||||
previewLink.screenshot = this.props.previewResponse;
|
||||
previewLink.customScreenshotURL = this.props.previewUrl;
|
||||
}
|
||||
return (
|
||||
<form className="topsite-form">
|
||||
<div className="form-input-container">
|
||||
|
@ -108,25 +205,33 @@ export class TopSiteForm extends React.PureComponent {
|
|||
placeholderId="topsites_form_title_placeholder"
|
||||
intl={this.props.intl} />
|
||||
<TopSiteFormInput onChange={this.onUrlChange}
|
||||
shouldFocus={this.state.validationError && !this.validateUrl(this.state.url)}
|
||||
value={this.state.url}
|
||||
onClear={this.onClearUrlClick}
|
||||
validationError={this.state.validationError}
|
||||
validationError={this.state.validationError && !this.validateUrl(this.state.url)}
|
||||
titleId="topsites_form_url_label"
|
||||
typeUrl={true}
|
||||
placeholderId="topsites_form_url_placeholder"
|
||||
errorMessageId="topsites_form_url_validation"
|
||||
intl={this.props.intl} />
|
||||
{this._renderCustomScreenshotInput()}
|
||||
</div>
|
||||
<TopSiteLink link={this.props.site || {}} title={this.state.label} />
|
||||
<TopSiteLink link={previewLink}
|
||||
defaultStyle={requestFailed}
|
||||
title={this.state.label} />
|
||||
</div>
|
||||
</div>
|
||||
<section className="actions">
|
||||
<button className="cancel" type="button" onClick={this.onCancelButtonClick}>
|
||||
<FormattedMessage id="topsites_form_cancel_button" />
|
||||
</button>
|
||||
<button className="done" type="submit" onClick={this.onDoneButtonClick}>
|
||||
<FormattedMessage id={showAsAdd ? "topsites_form_add_button" : "topsites_form_save_button"} />
|
||||
</button>
|
||||
{previewMode ?
|
||||
<button className="done preview" type="submit" onClick={this.onPreviewButtonClick}>
|
||||
<FormattedMessage id="topsites_form_preview_button" />
|
||||
</button> :
|
||||
<button className="done" type="submit" onClick={this.onDoneButtonClick}>
|
||||
<FormattedMessage id={showAsAdd ? "topsites_form_add_button" : "topsites_form_save_button"} />
|
||||
</button>}
|
||||
</section>
|
||||
</form>
|
||||
);
|
||||
|
@ -134,6 +239,6 @@ export class TopSiteForm extends React.PureComponent {
|
|||
}
|
||||
|
||||
TopSiteForm.defaultProps = {
|
||||
TopSite: null,
|
||||
site: null,
|
||||
index: -1
|
||||
};
|
||||
|
|
|
@ -4,13 +4,29 @@ import React from "react";
|
|||
export class TopSiteFormInput extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {validationError: this.props.validationError};
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onMount = this.onMount.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.validationError && !this.props.validationError) {
|
||||
if (nextProps.shouldFocus && !this.props.shouldFocus) {
|
||||
this.input.focus();
|
||||
}
|
||||
if (nextProps.validationError && !this.props.validationError) {
|
||||
this.setState({validationError: true});
|
||||
}
|
||||
// If the component is in an error state but the value was cleared by the parent
|
||||
if (this.state.validationError && !nextProps.value) {
|
||||
this.setState({validationError: false});
|
||||
}
|
||||
}
|
||||
|
||||
onChange(ev) {
|
||||
if (this.state.validationError) {
|
||||
this.setState({validationError: false});
|
||||
}
|
||||
this.props.onChange(ev);
|
||||
}
|
||||
|
||||
onMount(input) {
|
||||
|
@ -19,17 +35,21 @@ export class TopSiteFormInput extends React.PureComponent {
|
|||
|
||||
render() {
|
||||
const showClearButton = this.props.value && this.props.onClear;
|
||||
const {validationError, typeUrl} = this.props;
|
||||
const {typeUrl} = this.props;
|
||||
const {validationError} = this.state;
|
||||
|
||||
return (<label><FormattedMessage id={this.props.titleId} />
|
||||
<div className={`field ${typeUrl ? "url" : ""}${validationError ? " invalid" : ""}`}>
|
||||
{showClearButton &&
|
||||
<div className="icon icon-clear-input" onClick={this.props.onClear} />}
|
||||
{this.props.loading ?
|
||||
<div className="loading-container"><div className="loading-animation" /></div> :
|
||||
showClearButton && <div className="icon icon-clear-input" onClick={this.props.onClear} />}
|
||||
<input type="text"
|
||||
value={this.props.value}
|
||||
ref={this.onMount}
|
||||
onChange={this.props.onChange}
|
||||
placeholder={this.props.intl.formatMessage({id: this.props.placeholderId})} />
|
||||
onChange={this.onChange}
|
||||
placeholder={this.props.intl.formatMessage({id: this.props.placeholderId})}
|
||||
autoFocus={this.props.shouldFocus}
|
||||
disabled={this.props.loading} />
|
||||
{validationError &&
|
||||
<aside className="error-tooltip">
|
||||
<FormattedMessage id={this.props.errorMessageId} />
|
||||
|
|
|
@ -10,6 +10,9 @@ import {TopSiteForm} from "./TopSiteForm";
|
|||
import {TopSiteList} from "./TopSite";
|
||||
|
||||
function topSiteIconType(link) {
|
||||
if (link.customScreenshotURL) {
|
||||
return "custom_screenshot";
|
||||
}
|
||||
if (link.tippyTopIcon || link.faviconRef === "tippytop") {
|
||||
return "tippytop";
|
||||
}
|
||||
|
@ -37,6 +40,7 @@ function countTopSitesIconsTypes(topSites) {
|
|||
};
|
||||
|
||||
return topSites.reduce(countTopSitesTypes, {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
|
@ -119,10 +123,10 @@ export class _TopSites extends React.PureComponent {
|
|||
<div className="modal">
|
||||
<TopSiteForm
|
||||
site={props.TopSites.rows[editForm.index]}
|
||||
index={editForm.index}
|
||||
onClose={this.onFormClose}
|
||||
dispatch={this.props.dispatch}
|
||||
intl={this.props.intl} />
|
||||
intl={this.props.intl}
|
||||
{...editForm} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -153,7 +153,7 @@ $half-base-gutter: $base-gutter / 2;
|
|||
}
|
||||
|
||||
.rich-icon {
|
||||
background-size: $rich-icon-size;
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
offset-inline-start: 0;
|
||||
top: 0;
|
||||
|
@ -302,7 +302,7 @@ $half-base-gutter: $base-gutter / 2;
|
|||
.form-input-container {
|
||||
max-width: $form-width + 3 * $form-spacing + $rich-icon-size;
|
||||
margin: 0 auto;
|
||||
padding: $form-spacing $form-spacing 40px;
|
||||
padding: $form-spacing;
|
||||
|
||||
.top-site-outer {
|
||||
padding: 0;
|
||||
|
@ -337,20 +337,71 @@ $half-base-gutter: $base-gutter / 2;
|
|||
transform: translateY(-50%);
|
||||
top: 50%;
|
||||
offset-inline-end: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
& + input:dir(ltr) {
|
||||
padding-right: 32px;
|
||||
}
|
||||
.url {
|
||||
input:dir(ltr) {
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
& + input:dir(rtl) {
|
||||
padding-left: 32px;
|
||||
input:dir(rtl) {
|
||||
padding-left: 32px;
|
||||
|
||||
&:not(:placeholder-shown) {
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.url input:not(:placeholder-shown):dir(rtl) {
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
.enable-custom-image-input {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: $blue-60;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-image-input-container {
|
||||
margin-top: 4px;
|
||||
|
||||
.loading-container {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
transform: translateY(-50%);
|
||||
top: 50%;
|
||||
offset-inline-end: 8px;
|
||||
}
|
||||
|
||||
// This animation is derived from Firefox's tab loading animation
|
||||
// See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216
|
||||
.loading-animation {
|
||||
@keyframes tab-throbber-animation {
|
||||
100% { transform: translateX(-960px); }
|
||||
}
|
||||
|
||||
@keyframes tab-throbber-animation-rtl {
|
||||
100% { transform: translateX(960px); }
|
||||
}
|
||||
|
||||
width: 960px;
|
||||
height: 16px;
|
||||
-moz-context-properties: fill;
|
||||
fill: $blue-50;
|
||||
background-image: url('chrome://browser/skin/tabbrowser/loading.svg');
|
||||
animation: tab-throbber-animation 1.05s steps(60) infinite;
|
||||
|
||||
&:dir(rtl) {
|
||||
animation-name: tab-throbber-animation-rtl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
|
@ -361,11 +412,18 @@ $half-base-gutter: $base-gutter / 2;
|
|||
padding: 0 8px;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
font-size: 15px;
|
||||
|
||||
&:focus {
|
||||
border: $input-border-active;
|
||||
box-shadow: $input-focus-boxshadow;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
border: $input-border;
|
||||
box-shadow: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -55,8 +55,8 @@ $text-secondary: $grey-50;
|
|||
$input-border: solid 1px $grey-90-20;
|
||||
$input-border-active: solid 1px $blue-50;
|
||||
$input-error-border: solid 1px $red-60;
|
||||
$input-error-boxshadow: 0 0 0 2px rgba($red-60, 0.35);
|
||||
$input-focus-boxshadow: 0 0 0 2px rgba($blue-50, 0.35);
|
||||
$input-error-boxshadow: 0 0 0 1px $red-60, 0 0 0 4px rgba($red-60, 0.3);
|
||||
$input-focus-boxshadow: 0 0 0 1px $blue-50, 0 0 0 4px rgba($blue-50, 0.3);
|
||||
|
||||
$white: #FFF;
|
||||
$border-radius: 3px;
|
||||
|
|
|
@ -27,7 +27,7 @@ const DEFAULT_SITES_PREF = "default.sites";
|
|||
const DEFAULT_TOP_SITES = [];
|
||||
const FRECENCY_THRESHOLD = 100 + 1; // 1 visit (skip first-run/one-time pages)
|
||||
const MIN_FAVICON_SIZE = 96;
|
||||
const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot"];
|
||||
const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot", "customScreenshot"];
|
||||
const PINNED_FAVICON_PROPS_TO_MIGRATE = ["favicon", "faviconRef", "faviconSize"];
|
||||
const SECTION_ID = "topsites";
|
||||
|
||||
|
@ -72,7 +72,13 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
|
||||
filterForThumbnailExpiration(callback) {
|
||||
const {rows} = this.store.getState().TopSites;
|
||||
callback(rows.map(site => site.url));
|
||||
callback(rows.reduce((acc, site) => {
|
||||
acc.push(site.url);
|
||||
if (site.customScreenshotURL) {
|
||||
acc.push(site.customScreenshotURL);
|
||||
}
|
||||
return acc;
|
||||
}, []));
|
||||
}
|
||||
|
||||
async getLinksWithDefaults(action) {
|
||||
|
@ -134,7 +140,12 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
// Now, get a tippy top icon, a rich icon, or screenshot for every item
|
||||
for (const link of withPinned) {
|
||||
if (link) {
|
||||
this._fetchIcon(link);
|
||||
// If there is a custom screenshot this is the only image we display
|
||||
if (link.customScreenshotURL) {
|
||||
this._fetchScreenshot(link, link.customScreenshotURL);
|
||||
} else {
|
||||
this._fetchIcon(link);
|
||||
}
|
||||
|
||||
// Remove internal properties that might be updated after dispatch
|
||||
delete link.__sharedCache;
|
||||
|
@ -190,14 +201,36 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
this._requestRichIcon(link.url);
|
||||
|
||||
// Also request a screenshot if we don't have one yet
|
||||
if (!link.screenshot) {
|
||||
const {url} = link;
|
||||
await Screenshots.maybeCacheScreenshot(link, url, "screenshot",
|
||||
screenshot => this.store.dispatch(ac.BroadcastToContent({
|
||||
data: {screenshot, url},
|
||||
type: at.SCREENSHOT_UPDATED
|
||||
})));
|
||||
await this._fetchScreenshot(link, link.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch, cache and broadcast a screenshot for a specific topsite.
|
||||
* @param link cached topsite object
|
||||
* @param url where to fetch the image from
|
||||
*/
|
||||
async _fetchScreenshot(link, url) {
|
||||
if (link.screenshot) {
|
||||
return;
|
||||
}
|
||||
await Screenshots.maybeCacheScreenshot(link, url, "screenshot",
|
||||
screenshot => this.store.dispatch(ac.BroadcastToContent({
|
||||
data: {screenshot, url: link.url},
|
||||
type: at.SCREENSHOT_UPDATED
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch screenshot preview to target or notify if request failed.
|
||||
* @param customScreenshotURL {string} The URL used to capture the screenshot
|
||||
* @param target {string} Id of content process where to dispatch the result
|
||||
*/
|
||||
async getScreenshotPreview(url, target) {
|
||||
const preview = await Screenshots.getScreenshotForURL(url) || "";
|
||||
this.store.dispatch(ac.OnlyToOneContent({
|
||||
data: {url, preview},
|
||||
type: at.PREVIEW_RESPONSE
|
||||
}, target));
|
||||
}
|
||||
|
||||
_requestRichIcon(url) {
|
||||
|
@ -224,23 +257,41 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
|
||||
/**
|
||||
* Pin a site at a specific position saving only the desired keys.
|
||||
* @param customScreenshotURL {string} User set URL of preview image for site
|
||||
* @param label {string} User set string of custom site name
|
||||
*/
|
||||
_pinSiteAt({label, url}, index) {
|
||||
async _pinSiteAt({customScreenshotURL, label, url}, index) {
|
||||
const toPin = {url};
|
||||
if (label) {
|
||||
toPin.label = label;
|
||||
}
|
||||
if (customScreenshotURL) {
|
||||
toPin.customScreenshotURL = customScreenshotURL;
|
||||
}
|
||||
NewTabUtils.pinnedLinks.pin(toPin, index);
|
||||
|
||||
await this._clearLinkCustomScreenshot({customScreenshotURL, url});
|
||||
}
|
||||
|
||||
async _clearLinkCustomScreenshot(site) {
|
||||
// If screenshot url changed or was removed we need to update the cached link obj
|
||||
if (site.customScreenshotURL !== undefined) {
|
||||
const pinned = await this.pinnedCache.request();
|
||||
const link = pinned.find(pin => pin && pin.url === site.url);
|
||||
if (link && link.customScreenshotURL !== site.customScreenshotURL) {
|
||||
link.__sharedCache.updateLink("screenshot", undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a pin action of a site to a position.
|
||||
*/
|
||||
pin(action) {
|
||||
async pin(action) {
|
||||
const {site, index} = action.data;
|
||||
// If valid index provided, pin at that position
|
||||
if (index >= 0) {
|
||||
this._pinSiteAt(site, index);
|
||||
await this._pinSiteAt(site, index);
|
||||
this._broadcastPinnedSitesUpdated();
|
||||
} else {
|
||||
this.insert(action);
|
||||
|
@ -301,7 +352,7 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
/**
|
||||
* Handle an insert (drop/add) action of a site.
|
||||
*/
|
||||
insert(action) {
|
||||
async insert(action) {
|
||||
let {index} = action.data;
|
||||
// Treat invalid pin index values (e.g., -1, undefined) as insert in the first position
|
||||
if (!(index > 0)) {
|
||||
|
@ -313,6 +364,8 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
this._insertPin(
|
||||
action.data.site, index,
|
||||
action.data.draggedFromIndex !== undefined ? action.data.draggedFromIndex : this.store.getState().Prefs.values.topSitesRows * TOP_SITES_MAX_SITES_PER_ROW);
|
||||
|
||||
await this._clearLinkCustomScreenshot(action.data.site);
|
||||
this._broadcastPinnedSitesUpdated();
|
||||
}
|
||||
|
||||
|
@ -358,6 +411,9 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
case at.TOP_SITES_INSERT:
|
||||
this.insert(action);
|
||||
break;
|
||||
case at.PREVIEW_REQUEST:
|
||||
this.getScreenshotPreview(action.data.url, action.meta.fromTarget);
|
||||
break;
|
||||
case at.UNINIT:
|
||||
this.uninit();
|
||||
break;
|
||||
|
|
|
@ -76,10 +76,12 @@ export const UserEventAction = Joi.object().keys({
|
|||
"BOOKMARK_DELETE",
|
||||
"BOOKMARK_ADD",
|
||||
"PIN",
|
||||
"PREVIEW_REQUEST",
|
||||
"UNPIN",
|
||||
"SAVE_TO_POCKET",
|
||||
"SECTION_MENU_MOVE_UP",
|
||||
"SECTION_MENU_MOVE_DOWN",
|
||||
"SCREENSHOT_REQUEST",
|
||||
"SECTION_MENU_REMOVE",
|
||||
"SECTION_MENU_COLLAPSE",
|
||||
"SECTION_MENU_EXPAND",
|
||||
|
@ -170,6 +172,7 @@ export const SessionPing = Joi.object().keys(Object.assign({}, baseKeys, {
|
|||
|
||||
// Information about the quality of TopSites images and icons.
|
||||
topsites_icon_stats: Joi.object().keys({
|
||||
custom_screenshot: Joi.number(),
|
||||
rich_icon: Joi.number(),
|
||||
screenshot: Joi.number(),
|
||||
screenshot_with_icon: Joi.number(),
|
||||
|
|
|
@ -54,6 +54,48 @@ describe("Reducers", () => {
|
|||
const nextState = TopSites(undefined, {type: at.TOP_SITES_CANCEL_EDIT});
|
||||
assert.isNull(nextState.editForm);
|
||||
});
|
||||
it("should preserve the editForm.index", () => {
|
||||
const actionTypes = [at.PREVIEW_RESPONSE, at.PREVIEW_REQUEST, at.PREVIEW_REQUEST_CANCEL];
|
||||
actionTypes.forEach(type => {
|
||||
const oldState = {editForm: {index: 0, previewUrl: "foo"}};
|
||||
const action = {type, data: {url: "foo"}};
|
||||
const nextState = TopSites(oldState, action);
|
||||
assert.equal(nextState.editForm.index, 0);
|
||||
});
|
||||
});
|
||||
it("should set previewResponse on PREVIEW_RESPONSE", () => {
|
||||
const oldState = {editForm: {previewUrl: "url"}};
|
||||
const action = {type: at.PREVIEW_RESPONSE, data: {preview: "data:123", url: "url"}};
|
||||
const nextState = TopSites(oldState, action);
|
||||
assert.propertyVal(nextState.editForm, "previewResponse", "data:123");
|
||||
});
|
||||
it("should return previous state if action url does not match expected", () => {
|
||||
const oldState = {editForm: {previewUrl: "foo"}};
|
||||
const action = {type: at.PREVIEW_RESPONSE, data: {url: "bar"}};
|
||||
const nextState = TopSites(oldState, action);
|
||||
assert.equal(nextState, oldState);
|
||||
});
|
||||
it("should return previous state if editForm is not set", () => {
|
||||
const actionTypes = [at.PREVIEW_RESPONSE, at.PREVIEW_REQUEST, at.PREVIEW_REQUEST_CANCEL];
|
||||
actionTypes.forEach(type => {
|
||||
const oldState = {editForm: null};
|
||||
const action = {type, data: {url: "bar"}};
|
||||
const nextState = TopSites(oldState, action);
|
||||
assert.equal(nextState, oldState, type);
|
||||
});
|
||||
});
|
||||
it("should set previewResponse to null on PREVIEW_REQUEST", () => {
|
||||
const oldState = {editForm: {previewResponse: "foo"}};
|
||||
const action = {type: at.PREVIEW_REQUEST, data: {}};
|
||||
const nextState = TopSites(oldState, action);
|
||||
assert.propertyVal(nextState.editForm, "previewResponse", null);
|
||||
});
|
||||
it("should set previewUrl on PREVIEW_REQUEST", () => {
|
||||
const oldState = {editForm: {}};
|
||||
const action = {type: at.PREVIEW_REQUEST, data: {url: "bar"}};
|
||||
const nextState = TopSites(oldState, action);
|
||||
assert.propertyVal(nextState.editForm, "previewUrl", "bar");
|
||||
});
|
||||
it("should add screenshots for SCREENSHOT_UPDATED", () => {
|
||||
const oldState = {rows: [{url: "foo.com"}, {url: "bar.com"}]};
|
||||
const action = {type: at.SCREENSHOT_UPDATED, data: {url: "bar.com", screenshot: "data:123"}};
|
||||
|
|
|
@ -97,6 +97,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
|
@ -117,6 +118,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 1,
|
||||
"tippytop": 0,
|
||||
|
@ -127,6 +129,27 @@ describe("<TopSites>", () => {
|
|||
}
|
||||
}));
|
||||
});
|
||||
it("should correctly count TopSite images - custom_screenshot", () => {
|
||||
const rows = [{customScreenshotURL: true}];
|
||||
sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
|
||||
wrapper.instance()._dispatchTopSitesStats();
|
||||
|
||||
assert.calledOnce(DEFAULT_PROPS.dispatch);
|
||||
assert.calledWithExactly(DEFAULT_PROPS.dispatch, ac.AlsoToMain({
|
||||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 1,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
"rich_icon": 0,
|
||||
"no_image": 0
|
||||
},
|
||||
topsites_pinned: 0
|
||||
}
|
||||
}));
|
||||
});
|
||||
it("should correctly count TopSite images - screenshot + favicon", () => {
|
||||
const rows = [{screenshot: true, faviconSize: MIN_CORNER_FAVICON_SIZE}];
|
||||
sandbox.stub(DEFAULT_PROPS.TopSites, "rows").value(rows);
|
||||
|
@ -137,6 +160,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 1,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
|
@ -157,6 +181,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
|
@ -177,6 +202,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 2,
|
||||
|
@ -197,6 +223,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
|
@ -217,6 +244,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
|
@ -238,6 +266,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
|
@ -258,6 +287,7 @@ describe("<TopSites>", () => {
|
|||
type: at.SAVE_SESSION_PERF_DATA,
|
||||
data: {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 0,
|
||||
"screenshot": 0,
|
||||
"tippytop": 0,
|
||||
|
@ -565,6 +595,101 @@ describe("<TopSiteForm>", () => {
|
|||
assert.isFalse(wrapper.instance().validateForm());
|
||||
assert.isTrue(wrapper.state().validationError);
|
||||
});
|
||||
|
||||
it("should return true for a correct custom screenshot URL", () => {
|
||||
wrapper.setState({customScreenshotUrl: "foo"});
|
||||
|
||||
assert.isTrue(wrapper.instance().validateForm());
|
||||
});
|
||||
|
||||
it("should return false for a incorrect custom screenshot URL", () => {
|
||||
wrapper.setState({customScreenshotUrl: " "});
|
||||
|
||||
assert.isFalse(wrapper.instance().validateForm());
|
||||
});
|
||||
|
||||
it("should return true for an empty custom screenshot URL", () => {
|
||||
wrapper.setState({customScreenshotURL: ""});
|
||||
|
||||
assert.isTrue(wrapper.instance().validateForm());
|
||||
});
|
||||
});
|
||||
|
||||
describe("#previewButton", () => {
|
||||
beforeEach(() => setup({
|
||||
site: {customScreenshotURL: "http://foo.com"},
|
||||
previewResponse: null
|
||||
}));
|
||||
|
||||
it("should render the preview button on invalid urls", () => {
|
||||
assert.equal(0, wrapper.find(".preview").length);
|
||||
|
||||
wrapper.setState({customScreenshotUrl: " "});
|
||||
|
||||
assert.equal(1, wrapper.find(".preview").length);
|
||||
});
|
||||
|
||||
it("should render the preview button when input value updated", () => {
|
||||
assert.equal(0, wrapper.find(".preview").length);
|
||||
|
||||
wrapper.setState({customScreenshotUrl: "http://baz.com", screenshotPreview: null});
|
||||
|
||||
assert.equal(1, wrapper.find(".preview").length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview request", () => {
|
||||
beforeEach(() => {
|
||||
setup({
|
||||
site: {customScreenshotURL: "http://foo.com", url: "http://foo.com"},
|
||||
previewResponse: null
|
||||
});
|
||||
});
|
||||
|
||||
it("shouldn't dispatch a request for invalid urls", () => {
|
||||
wrapper.setState({customScreenshotUrl: " ", url: "foo"});
|
||||
|
||||
wrapper.find(".preview").simulate("click");
|
||||
|
||||
assert.notCalled(wrapper.props().dispatch);
|
||||
});
|
||||
|
||||
it("should dispatch a PREVIEW_REQUEST", () => {
|
||||
wrapper.setState({customScreenshotUrl: "screenshot"});
|
||||
wrapper.find(".preview").simulate("click");
|
||||
|
||||
assert.calledTwice(wrapper.props().dispatch);
|
||||
assert.calledWith(wrapper.props().dispatch, ac.AlsoToMain({
|
||||
type: at.PREVIEW_REQUEST,
|
||||
data: {url: "http://screenshot"}
|
||||
}));
|
||||
assert.calledWith(wrapper.props().dispatch, ac.UserEvent({
|
||||
event: "PREVIEW_REQUEST",
|
||||
source: "TOP_SITES"
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("#TopSiteLink", () => {
|
||||
it("should display a TopSiteLink preview", () => {
|
||||
assert.equal(wrapper.find(TopSiteLink).length, 1);
|
||||
});
|
||||
|
||||
it("should display the preview screenshot", () => {
|
||||
wrapper.setProps({site: {tippyTopIcon: "bar"}});
|
||||
|
||||
assert.equal(wrapper.find(".top-site-icon").getDOMNode().style["background-image"], "url(\"bar\")");
|
||||
|
||||
wrapper.setProps({previewResponse: "foo", previewUrl: "foo"});
|
||||
|
||||
assert.equal(wrapper.find(".top-site-icon").getDOMNode().style["background-image"], "url(\"foo\")");
|
||||
});
|
||||
|
||||
it("should not render any icon on error", () => {
|
||||
wrapper.setProps({previewResponse: ""});
|
||||
|
||||
assert.equal(wrapper.find(".top-site-icon").length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#addMode", () => {
|
||||
|
@ -580,24 +705,23 @@ describe("<TopSiteForm>", () => {
|
|||
assert.equal(wrapper.findWhere(n => n.length && n.props().id === "topsites_form_save_button").length, 0);
|
||||
assert.equal(wrapper.findWhere(n => n.length && n.props().id === "topsites_form_add_button").length, 1);
|
||||
});
|
||||
it("should not render a preview button", () => {
|
||||
assert.equal(0, wrapper.find(".custom-image-input-container").length);
|
||||
});
|
||||
it("should call onClose if Cancel button is clicked", () => {
|
||||
wrapper.find(".cancel").simulate("click");
|
||||
assert.calledOnce(wrapper.instance().props.onClose);
|
||||
});
|
||||
it("should show error and not call onClose or dispatch if URL is empty", () => {
|
||||
assert.isFalse(wrapper.state().validationError);
|
||||
it("should set validationError if url is empty", () => {
|
||||
assert.equal(wrapper.state().validationError, false);
|
||||
wrapper.find(".done").simulate("click");
|
||||
assert.isTrue(wrapper.state().validationError);
|
||||
assert.notCalled(wrapper.instance().props.onClose);
|
||||
assert.notCalled(wrapper.instance().props.dispatch);
|
||||
assert.equal(wrapper.state().validationError, true);
|
||||
});
|
||||
it("should show error and not call onClose or dispatch if URL is invalid", () => {
|
||||
it("should set validationError if url is invalid", () => {
|
||||
wrapper.setState({"url": "not valid"});
|
||||
assert.isFalse(wrapper.state().validationError);
|
||||
assert.equal(wrapper.state().validationError, false);
|
||||
wrapper.find(".done").simulate("click");
|
||||
assert.isTrue(wrapper.state().validationError);
|
||||
assert.notCalled(wrapper.instance().props.onClose);
|
||||
assert.notCalled(wrapper.instance().props.dispatch);
|
||||
assert.equal(wrapper.state().validationError, true);
|
||||
});
|
||||
it("should call onClose and dispatch with right args if URL is valid", () => {
|
||||
wrapper.setState({"url": "valid.com", "label": "a label"});
|
||||
|
@ -632,10 +756,17 @@ describe("<TopSiteForm>", () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
it("should open the custom screenshot input", () => {
|
||||
assert.isFalse(wrapper.state().showCustomScreenshotForm);
|
||||
|
||||
wrapper.find(".enable-custom-image-input").simulate("click");
|
||||
|
||||
assert.isTrue(wrapper.state().showCustomScreenshotForm);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit existing Topsite", () => {
|
||||
beforeEach(() => setup({site: {url: "https://foo.bar", label: "baz"}, index: 0}));
|
||||
beforeEach(() => setup({site: {url: "https://foo.bar", label: "baz", customScreenshotURL: "http://foo"}, index: 7}));
|
||||
|
||||
it("should render the component", () => {
|
||||
assert.ok(wrapper.find(TopSiteForm));
|
||||
|
@ -647,29 +778,23 @@ describe("<TopSiteForm>", () => {
|
|||
assert.equal(wrapper.findWhere(n => n.props().id === "topsites_form_add_button").length, 0);
|
||||
assert.equal(wrapper.findWhere(n => n.props().id === "topsites_form_save_button").length, 1);
|
||||
});
|
||||
it("should have the correct button text (if editing a placeholder)", () => {
|
||||
wrapper.setProps({site: null});
|
||||
|
||||
assert.equal(wrapper.findWhere(n => n.props().id === "topsites_form_save_button").length, 0);
|
||||
assert.equal(wrapper.findWhere(n => n.props().id === "topsites_form_add_button").length, 1);
|
||||
});
|
||||
it("should call onClose if Cancel button is clicked", () => {
|
||||
wrapper.find(".cancel").simulate("click");
|
||||
assert.calledOnce(wrapper.instance().props.onClose);
|
||||
});
|
||||
it("should show error and not call onClose or dispatch if URL is empty", () => {
|
||||
wrapper.setState({"url": ""});
|
||||
assert.isFalse(wrapper.state().validationError);
|
||||
assert.equal(wrapper.state().validationError, false);
|
||||
wrapper.find(".done").simulate("click");
|
||||
assert.isTrue(wrapper.state().validationError);
|
||||
assert.equal(wrapper.state().validationError, true);
|
||||
assert.notCalled(wrapper.instance().props.onClose);
|
||||
assert.notCalled(wrapper.instance().props.dispatch);
|
||||
});
|
||||
it("should show error and not call onClose or dispatch if URL is invalid", () => {
|
||||
wrapper.setState({"url": "not valid"});
|
||||
assert.isFalse(wrapper.state().validationError);
|
||||
assert.equal(wrapper.state().validationError, false);
|
||||
wrapper.find(".done").simulate("click");
|
||||
assert.isTrue(wrapper.state().validationError);
|
||||
assert.equal(wrapper.state().validationError, true);
|
||||
assert.notCalled(wrapper.instance().props.onClose);
|
||||
assert.notCalled(wrapper.instance().props.dispatch);
|
||||
});
|
||||
|
@ -680,7 +805,7 @@ describe("<TopSiteForm>", () => {
|
|||
assert.calledWith(
|
||||
wrapper.instance().props.dispatch,
|
||||
{
|
||||
data: {site: {label: "baz", url: "https://foo.bar"}, index: 0},
|
||||
data: {site: {label: "baz", url: "https://foo.bar", customScreenshotURL: "http://foo"}, index: 7},
|
||||
meta: {from: "ActivityStream:Content", to: "ActivityStream:Main"},
|
||||
type: at.TOP_SITES_PIN
|
||||
}
|
||||
|
@ -688,12 +813,26 @@ describe("<TopSiteForm>", () => {
|
|||
assert.calledWith(
|
||||
wrapper.instance().props.dispatch,
|
||||
{
|
||||
data: {action_position: 0, source: "TOP_SITES", event: "TOP_SITES_EDIT"},
|
||||
data: {action_position: 7, source: "TOP_SITES", event: "TOP_SITES_EDIT"},
|
||||
meta: {from: "ActivityStream:Content", to: "ActivityStream:Main"},
|
||||
type: at.TELEMETRY_USER_EVENT
|
||||
}
|
||||
);
|
||||
});
|
||||
it("should set customScreenshotURL to null if it was removed", () => {
|
||||
wrapper.setState({customScreenshotUrl: ""});
|
||||
|
||||
wrapper.find(".done").simulate("click");
|
||||
|
||||
assert.calledWith(
|
||||
wrapper.instance().props.dispatch,
|
||||
{
|
||||
data: {site: {label: "baz", url: "https://foo.bar", customScreenshotURL: null}, index: 7},
|
||||
meta: {from: "ActivityStream:Content", to: "ActivityStream:Main"},
|
||||
type: at.TOP_SITES_PIN
|
||||
}
|
||||
);
|
||||
});
|
||||
it("should call onClose and dispatch with right args if URL is valid (negative index)", () => {
|
||||
wrapper.setProps({index: -1});
|
||||
wrapper.find(".done").simulate("click");
|
||||
|
@ -702,7 +841,7 @@ describe("<TopSiteForm>", () => {
|
|||
assert.calledWith(
|
||||
wrapper.instance().props.dispatch,
|
||||
{
|
||||
data: {site: {label: "baz", url: "https://foo.bar"}, index: -1},
|
||||
data: {site: {label: "baz", url: "https://foo.bar", customScreenshotURL: "http://foo"}, index: -1},
|
||||
meta: {from: "ActivityStream:Content", to: "ActivityStream:Main"},
|
||||
type: at.TOP_SITES_PIN
|
||||
}
|
||||
|
@ -714,12 +853,45 @@ describe("<TopSiteForm>", () => {
|
|||
assert.calledWith(
|
||||
wrapper.instance().props.dispatch,
|
||||
{
|
||||
data: {site: {url: "https://foo.bar"}, index: 0},
|
||||
data: {site: {url: "https://foo.bar", customScreenshotURL: "http://foo"}, index: 7},
|
||||
meta: {from: "ActivityStream:Content", to: "ActivityStream:Main"},
|
||||
type: at.TOP_SITES_PIN
|
||||
}
|
||||
);
|
||||
});
|
||||
it("should render the save button if custom screenshot request finished", () => {
|
||||
wrapper.setState({customScreenshotUrl: "foo", screenshotPreview: "custom"});
|
||||
assert.equal(0, wrapper.find(".preview").length);
|
||||
assert.equal(1, wrapper.find(".done").length);
|
||||
});
|
||||
it("should render the save button if custom screenshot url was cleared", () => {
|
||||
wrapper.setState({customScreenshotUrl: ""});
|
||||
wrapper.setProps({site: {customScreenshotURL: "foo"}});
|
||||
assert.equal(0, wrapper.find(".preview").length);
|
||||
assert.equal(1, wrapper.find(".done").length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#previewMode", () => {
|
||||
beforeEach(() => setup({previewResponse: null}));
|
||||
|
||||
it("should transition from save to preview", () => {
|
||||
wrapper.setProps({site: {url: "https://foo.bar", customScreenshotURL: "baz"}, index: 7});
|
||||
|
||||
assert.equal(wrapper.findWhere(n => n.length && n.props().id === "topsites_form_save_button").length, 1);
|
||||
|
||||
wrapper.setState({customScreenshotUrl: "foo"});
|
||||
|
||||
assert.equal(wrapper.findWhere(n => n.length && n.props().id === "topsites_form_preview_button").length, 1);
|
||||
});
|
||||
|
||||
it("should transition from add to preview", () => {
|
||||
assert.equal(wrapper.findWhere(n => n.length && n.props().id === "topsites_form_add_button").length, 1);
|
||||
|
||||
wrapper.setState({customScreenshotUrl: "foo"});
|
||||
|
||||
assert.equal(wrapper.findWhere(n => n.length && n.props().id === "topsites_form_preview_button").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#validateUrl", () => {
|
||||
|
@ -815,14 +987,14 @@ describe("<TopSiteList>", () => {
|
|||
const wrapper = shallow(<TopSiteList {...DEFAULT_PROPS} dispatch={dispatch} />);
|
||||
const instance = wrapper.instance();
|
||||
const index = 7;
|
||||
const link = {url: "https://foo.com"};
|
||||
const link = {url: "https://foo.com", customScreenshotURL: "foo"};
|
||||
const title = "foo";
|
||||
instance.onDragEvent({type: "dragstart"}, index, link, title);
|
||||
dispatch.reset();
|
||||
instance.onDragEvent({type: "drop"}, 3);
|
||||
assert.calledTwice(dispatch);
|
||||
assert.calledWith(dispatch, {
|
||||
data: {draggedFromIndex: 7, index: 3, site: {label: "foo", url: "https://foo.com"}},
|
||||
data: {draggedFromIndex: 7, index: 3, site: {label: "foo", url: "https://foo.com", customScreenshotURL: "foo"}},
|
||||
meta: {from: "ActivityStream:Content", to: "ActivityStream:Main"},
|
||||
type: "TOP_SITES_INSERT"
|
||||
});
|
||||
|
@ -939,6 +1111,21 @@ describe("#TopSiteFormInput", () => {
|
|||
|
||||
assert.equal(wrapper.find(".icon-clear-input").length, 1);
|
||||
});
|
||||
|
||||
it("should show the loading indicator", () => {
|
||||
assert.equal(wrapper.find(".loading-container").length, 0);
|
||||
|
||||
wrapper.setProps({loading: true});
|
||||
|
||||
assert.equal(wrapper.find(".loading-container").length, 1);
|
||||
});
|
||||
it("should disable the input when loading indicator is present", () => {
|
||||
assert.isFalse(wrapper.find("input").getDOMNode().disabled);
|
||||
|
||||
wrapper.setProps({loading: true});
|
||||
|
||||
assert.isTrue(wrapper.find("input").getDOMNode().disabled);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with error", () => {
|
||||
|
@ -956,5 +1143,11 @@ describe("#TopSiteFormInput", () => {
|
|||
it("should render the error message", () => {
|
||||
assert.equal(wrapper.findWhere(n => n.props().id === "topsites_form_url_validation").length, 1);
|
||||
});
|
||||
|
||||
it("should reset the error state on value change", () => {
|
||||
wrapper.find("input").simulate("change", {target: {value: "bar"}});
|
||||
|
||||
assert.isFalse(wrapper.state().validationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -194,6 +194,7 @@ describe("TelemetryFeed", () => {
|
|||
const session = instance.addSession(portID, "about:home");
|
||||
instance.saveSessionPerfData("foo", {
|
||||
topsites_icon_stats: {
|
||||
"custom_screenshot": 0,
|
||||
"screenshot_with_icon": 2,
|
||||
"screenshot": 1,
|
||||
"tippytop": 2,
|
||||
|
|
|
@ -61,7 +61,7 @@ describe("Top Sites Feed", () => {
|
|||
};
|
||||
fakeScreenshot = {
|
||||
getScreenshotForURL: sandbox.spy(() => Promise.resolve(FAKE_SCREENSHOT)),
|
||||
maybeCacheScreenshot: Screenshots.maybeCacheScreenshot,
|
||||
maybeCacheScreenshot: sandbox.spy(Screenshots.maybeCacheScreenshot),
|
||||
_shouldGetScreenshots: sinon.stub().returns(true)
|
||||
};
|
||||
filterAdultStub = sinon.stub().returns([]);
|
||||
|
@ -145,14 +145,14 @@ describe("Top Sites Feed", () => {
|
|||
});
|
||||
describe("#filterForThumbnailExpiration", () => {
|
||||
it("should pass rows.urls to the callback provided", () => {
|
||||
const rows = [{url: "foo.com"}, {"url": "bar.com"}];
|
||||
const rows = [{url: "foo.com"}, {"url": "bar.com", "customScreenshotURL": "custom"}];
|
||||
feed.store.state.TopSites = {rows};
|
||||
const stub = sinon.stub();
|
||||
|
||||
feed.filterForThumbnailExpiration(stub);
|
||||
|
||||
assert.calledOnce(stub);
|
||||
assert.calledWithExactly(stub, rows.map(r => r.url));
|
||||
assert.calledWithExactly(stub, ["foo.com", "bar.com", "custom"]);
|
||||
});
|
||||
});
|
||||
describe("#getLinksWithDefaults", () => {
|
||||
|
@ -161,9 +161,6 @@ describe("Top Sites Feed", () => {
|
|||
});
|
||||
|
||||
describe("general", () => {
|
||||
beforeEach(() => {
|
||||
sandbox.stub(fakeScreenshot, "maybeCacheScreenshot");
|
||||
});
|
||||
it("should get the links from NewTabUtils", async () => {
|
||||
const result = await feed.getLinksWithDefaults();
|
||||
const reference = links.map(site => Object.assign({}, site, {hostname: shortURLStub(site)}));
|
||||
|
@ -402,6 +399,15 @@ describe("Top Sites Feed", () => {
|
|||
assert.calledWith(feed._fetchIcon, link);
|
||||
});
|
||||
});
|
||||
it("should call _fetchScreenshot when customScreenshotURL is set", async () => {
|
||||
links = [];
|
||||
fakeNewTabUtils.pinnedLinks.links = [{url: "foo", customScreenshotURL: "custom"}];
|
||||
sinon.stub(feed, "_fetchScreenshot");
|
||||
|
||||
await feed.getLinksWithDefaults();
|
||||
|
||||
assert.calledWith(feed._fetchScreenshot, sinon.match.object, "custom");
|
||||
});
|
||||
});
|
||||
describe("#refresh", () => {
|
||||
beforeEach(() => {
|
||||
|
@ -466,6 +472,27 @@ describe("Top Sites Feed", () => {
|
|||
}));
|
||||
});
|
||||
});
|
||||
describe("#getScreenshotPreview", () => {
|
||||
it("should dispatch preview if request is succesful", async () => {
|
||||
await feed.getScreenshotPreview("custom", 1234);
|
||||
|
||||
assert.calledOnce(feed.store.dispatch);
|
||||
assert.calledWithExactly(feed.store.dispatch, ac.OnlyToOneContent({
|
||||
data: {preview: FAKE_SCREENSHOT, url: "custom"},
|
||||
type: at.PREVIEW_RESPONSE
|
||||
}, 1234));
|
||||
});
|
||||
it("should return empty string if request fails", async () => {
|
||||
fakeScreenshot.getScreenshotForURL = sandbox.stub().returns(Promise.resolve(null));
|
||||
await feed.getScreenshotPreview("custom", 1234);
|
||||
|
||||
assert.calledOnce(feed.store.dispatch);
|
||||
assert.calledWithExactly(feed.store.dispatch, ac.OnlyToOneContent({
|
||||
data: {preview: "", url: "custom"},
|
||||
type: at.PREVIEW_RESPONSE
|
||||
}, 1234));
|
||||
});
|
||||
});
|
||||
describe("#_fetchIcon", () => {
|
||||
it("should reuse screenshot on the link", () => {
|
||||
const link = {screenshot: "reuse.png"};
|
||||
|
@ -535,7 +562,33 @@ describe("Top Sites Feed", () => {
|
|||
assert.notProperty(link, "tippyTopIcon");
|
||||
});
|
||||
});
|
||||
describe("#_fetchScreenshot", () => {
|
||||
it("should call maybeCacheScreenshot", async () => {
|
||||
const updateLink = sinon.stub();
|
||||
const link = {customScreenshotURL: "custom", __sharedCache: {updateLink}};
|
||||
await feed._fetchScreenshot(link, "custom");
|
||||
|
||||
assert.calledOnce(fakeScreenshot.maybeCacheScreenshot);
|
||||
assert.calledWithExactly(fakeScreenshot.maybeCacheScreenshot, link, link.customScreenshotURL,
|
||||
"screenshot", sinon.match.func);
|
||||
});
|
||||
it("should not call maybeCacheScreenshot if screenshot is set", async () => {
|
||||
const updateLink = sinon.stub();
|
||||
const link = {customScreenshotURL: "custom", __sharedCache: {updateLink}, screenshot: true};
|
||||
await feed._fetchScreenshot(link, "custom");
|
||||
|
||||
assert.notCalled(fakeScreenshot.maybeCacheScreenshot);
|
||||
});
|
||||
});
|
||||
describe("#onAction", () => {
|
||||
it("should call getScreenshotPreview on PREVIEW_REQUEST", () => {
|
||||
sandbox.stub(feed, "getScreenshotPreview");
|
||||
|
||||
feed.onAction({type: at.PREVIEW_REQUEST, data: {url: "foo"}, meta: {fromTarget: 1234}});
|
||||
|
||||
assert.calledOnce(feed.getScreenshotPreview);
|
||||
assert.calledWithExactly(feed.getScreenshotPreview, "foo", 1234);
|
||||
});
|
||||
it("should refresh on SYSTEM_TICK", async () => {
|
||||
sandbox.stub(feed, "refresh");
|
||||
|
||||
|
@ -553,20 +606,36 @@ describe("Top Sites Feed", () => {
|
|||
assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
|
||||
assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, pinAction.data.site, pinAction.data.index);
|
||||
});
|
||||
it("should trigger refresh on TOP_SITES_PIN", () => {
|
||||
sinon.stub(feed, "refresh");
|
||||
it("should call pin on TOP_SITES_PIN", () => {
|
||||
sinon.stub(feed, "pin");
|
||||
const pinExistingAction = {type: at.TOP_SITES_PIN, data: {site: FAKE_LINKS[4], index: 4}};
|
||||
|
||||
feed.onAction(pinExistingAction);
|
||||
|
||||
assert.calledOnce(feed.pin);
|
||||
});
|
||||
it("should trigger refresh on TOP_SITES_PIN", async () => {
|
||||
sinon.stub(feed, "refresh");
|
||||
const pinExistingAction = {type: at.TOP_SITES_PIN, data: {site: FAKE_LINKS[4], index: 4}};
|
||||
|
||||
await feed.pin(pinExistingAction);
|
||||
|
||||
assert.calledOnce(feed.refresh);
|
||||
});
|
||||
it("should trigger refresh on TOP_SITES_INSERT", () => {
|
||||
sinon.stub(feed, "refresh");
|
||||
it("should call insert on TOP_SITES_INSERT", async () => {
|
||||
sinon.stub(feed, "insert");
|
||||
const addAction = {type: at.TOP_SITES_INSERT, data: {site: {url: "foo.com"}}};
|
||||
|
||||
feed.onAction(addAction);
|
||||
|
||||
assert.calledOnce(feed.insert);
|
||||
});
|
||||
it("should trigger refresh on TOP_SITES_INSERT", async () => {
|
||||
sinon.stub(feed, "refresh");
|
||||
const addAction = {type: at.TOP_SITES_INSERT, data: {site: {url: "foo.com"}}};
|
||||
|
||||
await feed.insert(addAction);
|
||||
|
||||
assert.calledOnce(feed.refresh);
|
||||
});
|
||||
it("should call unpin with correct parameters on TOP_SITES_UNPIN", () => {
|
||||
|
@ -700,12 +769,36 @@ describe("Top Sites Feed", () => {
|
|||
});
|
||||
});
|
||||
describe("#pin", () => {
|
||||
it("should pin site in specified slot empty pinned list", () => {
|
||||
const site = {url: "foo.bar", label: "foo"};
|
||||
feed.pin({data: {index: 2, site}});
|
||||
it("should pin site in specified slot empty pinned list", async () => {
|
||||
const site = {url: "foo.bar", label: "foo", customScreenshotURL: "screenshot"};
|
||||
await feed.pin({data: {index: 2, site}});
|
||||
assert.calledOnce(fakeNewTabUtils.pinnedLinks.pin);
|
||||
assert.calledWith(fakeNewTabUtils.pinnedLinks.pin, site, 2);
|
||||
});
|
||||
it("should lookup the link object to update the custom screenshot", async () => {
|
||||
const site = {url: "foo.bar", label: "foo", customScreenshotURL: "screenshot"};
|
||||
sandbox.spy(feed.pinnedCache, "request");
|
||||
|
||||
await feed.pin({data: {index: 2, site}});
|
||||
|
||||
assert.calledOnce(feed.pinnedCache.request);
|
||||
});
|
||||
it("should lookup the link object to update the custom screenshot", async () => {
|
||||
const site = {url: "foo.bar", label: "foo", customScreenshotURL: null};
|
||||
sandbox.spy(feed.pinnedCache, "request");
|
||||
|
||||
await feed.pin({data: {index: 2, site}});
|
||||
|
||||
assert.calledOnce(feed.pinnedCache.request);
|
||||
});
|
||||
it("should not do a link object lookup if custom screenshot field is not set", async () => {
|
||||
const site = {url: "foo.bar", label: "foo"};
|
||||
sandbox.spy(feed.pinnedCache, "request");
|
||||
|
||||
await feed.pin({data: {index: 2, site}});
|
||||
|
||||
assert.notCalled(feed.pinnedCache.request);
|
||||
});
|
||||
it("should pin site in specified slot of pinned list that is free", () => {
|
||||
fakeNewTabUtils.pinnedLinks.links = [null, {url: "example.com"}];
|
||||
const site = {url: "foo.bar", label: "foo"};
|
||||
|
@ -758,6 +851,34 @@ describe("Top Sites Feed", () => {
|
|||
assert.notCalled(feed.insert);
|
||||
});
|
||||
});
|
||||
describe("clearLinkCustomScreenshot", () => {
|
||||
it("should remove cached screenshot if custom url changes", async () => {
|
||||
const stub = sandbox.stub();
|
||||
sandbox.stub(feed.pinnedCache, "request").returns(Promise.resolve([{
|
||||
url: "foo",
|
||||
customScreenshotURL: "old_screenshot",
|
||||
__sharedCache: {updateLink: stub}
|
||||
}]));
|
||||
|
||||
await feed._clearLinkCustomScreenshot({url: "foo", customScreenshotURL: "new_screenshot"});
|
||||
|
||||
assert.calledOnce(stub);
|
||||
assert.calledWithExactly(stub, "screenshot", undefined);
|
||||
});
|
||||
it("should remove cached screenshot if custom url is removed", async () => {
|
||||
const stub = sandbox.stub();
|
||||
sandbox.stub(feed.pinnedCache, "request").returns(Promise.resolve([{
|
||||
url: "foo",
|
||||
customScreenshotURL: "old_screenshot",
|
||||
__sharedCache: {updateLink: stub}
|
||||
}]));
|
||||
|
||||
await feed._clearLinkCustomScreenshot({url: "foo", customScreenshotURL: "new_screenshot"});
|
||||
|
||||
assert.calledOnce(stub);
|
||||
assert.calledWithExactly(stub, "screenshot", undefined);
|
||||
});
|
||||
});
|
||||
describe("#drop", () => {
|
||||
it("should correctly handle different index values", () => {
|
||||
let index = -1;
|
||||
|
|
Загрузка…
Ссылка в новой задаче