TopSiteForm.jsx (10526B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; 6 import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; 7 import React from "react"; 8 import { TOP_SITES_SOURCE } from "./TopSitesConstants"; 9 import { TopSiteFormInput } from "./TopSiteFormInput"; 10 import { TopSiteLink } from "./TopSite"; 11 12 export class TopSiteForm extends React.PureComponent { 13 constructor(props) { 14 super(props); 15 const { site } = props; 16 this.state = { 17 label: site ? site.label || site.hostname : "", 18 url: site ? site.url : "", 19 validationError: false, 20 customScreenshotUrl: site ? site.customScreenshotURL : "", 21 showCustomScreenshotForm: site ? site.customScreenshotURL : false, 22 hasURLChanged: false, 23 hasTitleChanged: false, 24 }; 25 this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this); 26 this.onLabelChange = this.onLabelChange.bind(this); 27 this.onUrlChange = this.onUrlChange.bind(this); 28 this.onCancelButtonClick = this.onCancelButtonClick.bind(this); 29 this.onClearUrlClick = this.onClearUrlClick.bind(this); 30 this.onDoneButtonClick = this.onDoneButtonClick.bind(this); 31 this.onCustomScreenshotUrlChange = 32 this.onCustomScreenshotUrlChange.bind(this); 33 this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this); 34 this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this); 35 this.validateUrl = this.validateUrl.bind(this); 36 } 37 38 onLabelChange(event) { 39 this.setState({ 40 label: event.target.value, 41 hasTitleChanged: true, 42 }); 43 } 44 45 onUrlChange(event) { 46 this.setState({ 47 url: event.target.value, 48 validationError: false, 49 hasURLChanged: true, 50 }); 51 } 52 53 onClearUrlClick() { 54 this.setState({ 55 url: "", 56 validationError: false, 57 }); 58 } 59 60 onEnableScreenshotUrlForm() { 61 this.setState({ showCustomScreenshotForm: true }); 62 } 63 64 _updateCustomScreenshotInput(customScreenshotUrl) { 65 this.setState({ 66 customScreenshotUrl, 67 validationError: false, 68 }); 69 this.props.dispatch({ type: at.PREVIEW_REQUEST_CANCEL }); 70 } 71 72 onCustomScreenshotUrlChange(event) { 73 this._updateCustomScreenshotInput(event.target.value); 74 } 75 76 onClearScreenshotInput() { 77 this._updateCustomScreenshotInput(""); 78 } 79 80 onCancelButtonClick(ev) { 81 ev.preventDefault(); 82 this.props.onClose(); 83 } 84 85 onDoneButtonClick(ev) { 86 ev.preventDefault(); 87 88 if (this.validateForm()) { 89 const site = { url: this.cleanUrl(this.state.url) }; 90 const { index } = this.props; 91 const isEdit = !!this.props.site; 92 93 if (this.state.label !== "") { 94 site.label = this.state.label; 95 } 96 97 if (this.state.customScreenshotUrl) { 98 site.customScreenshotURL = this.cleanUrl( 99 this.state.customScreenshotUrl 100 ); 101 } else if (this.props.site && this.props.site.customScreenshotURL) { 102 // Used to flag that previously cached screenshot should be removed 103 site.customScreenshotURL = null; 104 } 105 106 this.props.dispatch( 107 ac.AlsoToMain({ 108 type: at.TOP_SITES_PIN, 109 data: { site, index }, 110 }) 111 ); 112 113 if (isEdit) { 114 this.props.dispatch( 115 ac.UserEvent({ 116 source: TOP_SITES_SOURCE, 117 event: "TOP_SITES_EDIT", 118 action_position: index, 119 hasTitleChanged: this.state.hasTitleChanged, 120 hasURLChanged: this.state.hasURLChanged, 121 }) 122 ); 123 } else if (!isEdit) { 124 this.props.dispatch( 125 ac.UserEvent({ 126 source: TOP_SITES_SOURCE, 127 event: "TOP_SITES_ADD", 128 action_position: index, 129 }) 130 ); 131 } 132 133 this.props.onClose(); 134 } 135 } 136 137 onPreviewButtonClick(event) { 138 event.preventDefault(); 139 if (this.validateForm()) { 140 this.props.dispatch( 141 ac.AlsoToMain({ 142 type: at.PREVIEW_REQUEST, 143 data: { url: this.cleanUrl(this.state.customScreenshotUrl) }, 144 }) 145 ); 146 this.props.dispatch( 147 ac.UserEvent({ 148 source: TOP_SITES_SOURCE, 149 event: "PREVIEW_REQUEST", 150 }) 151 ); 152 } 153 } 154 155 cleanUrl(url) { 156 // If we are missing a protocol, prepend http:// 157 if (!url.startsWith("http:") && !url.startsWith("https:")) { 158 return `http://${url}`; 159 } 160 return url; 161 } 162 163 _tryParseUrl(url) { 164 try { 165 return new URL(url); 166 } catch (e) { 167 return null; 168 } 169 } 170 171 validateUrl(url) { 172 const validProtocols = ["http:", "https:"]; 173 const urlObj = 174 this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url)); 175 176 return urlObj && validProtocols.includes(urlObj.protocol); 177 } 178 179 validateCustomScreenshotUrl() { 180 const { customScreenshotUrl } = this.state; 181 return !customScreenshotUrl || this.validateUrl(customScreenshotUrl); 182 } 183 184 validateForm() { 185 const validate = 186 this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl(); 187 188 if (!validate) { 189 this.setState({ validationError: true }); 190 } 191 192 return validate; 193 } 194 195 _renderCustomScreenshotInput() { 196 const { customScreenshotUrl } = this.state; 197 const requestFailed = this.props.previewResponse === ""; 198 const validationError = 199 (this.state.validationError && !this.validateCustomScreenshotUrl()) || 200 requestFailed; 201 // Set focus on error if the url field is valid or when the input is first rendered and is empty 202 const shouldFocus = 203 (validationError && this.validateUrl(this.state.url)) || 204 !customScreenshotUrl; 205 const isLoading = 206 this.props.previewResponse === null && 207 customScreenshotUrl && 208 this.props.previewUrl === this.cleanUrl(customScreenshotUrl); 209 210 if (!this.state.showCustomScreenshotForm) { 211 return ( 212 <A11yLinkButton 213 onClick={this.onEnableScreenshotUrlForm} 214 className="enable-custom-image-input" 215 data-l10n-id="newtab-topsites-use-image-link" 216 /> 217 ); 218 } 219 return ( 220 <div className="custom-image-input-container"> 221 <TopSiteFormInput 222 errorMessageId={ 223 requestFailed 224 ? "newtab-topsites-image-validation" 225 : "newtab-topsites-url-validation" 226 } 227 loading={isLoading} 228 onChange={this.onCustomScreenshotUrlChange} 229 onClear={this.onClearScreenshotInput} 230 shouldFocus={shouldFocus} 231 typeUrl={true} 232 value={customScreenshotUrl} 233 validationError={validationError} 234 titleId="newtab-topsites-image-url-label" 235 placeholderId="newtab-topsites-url-input" 236 /> 237 </div> 238 ); 239 } 240 241 render() { 242 const { customScreenshotUrl } = this.state; 243 const requestFailed = this.props.previewResponse === ""; 244 // For UI purposes, editing without an existing link is "add" 245 const showAsAdd = !this.props.site; 246 const previous = 247 (this.props.site && this.props.site.customScreenshotURL) || ""; 248 const changed = 249 customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous; 250 // Preview mode if changes were made to the custom screenshot URL and no preview was received yet 251 // or the request failed 252 const previewMode = changed && !this.props.previewResponse; 253 const previewLink = Object.assign({}, this.props.site); 254 if (this.props.previewResponse) { 255 previewLink.screenshot = this.props.previewResponse; 256 previewLink.customScreenshotURL = this.props.previewUrl; 257 } 258 // Handles the form submit so an enter press performs the correct action 259 const onSubmit = previewMode 260 ? this.onPreviewButtonClick 261 : this.onDoneButtonClick; 262 263 const addTopsitesHeaderL10nId = "newtab-topsites-add-shortcut-header"; 264 const editTopsitesHeaderL10nId = "newtab-topsites-edit-shortcut-header"; 265 return ( 266 <form className="topsite-form" onSubmit={onSubmit}> 267 <div className="form-input-container"> 268 <h3 269 className="section-title grey-title" 270 data-l10n-id={ 271 showAsAdd ? addTopsitesHeaderL10nId : editTopsitesHeaderL10nId 272 } 273 /> 274 <div className="fields-and-preview"> 275 <div className="form-wrapper"> 276 <TopSiteFormInput 277 onChange={this.onLabelChange} 278 value={this.state.label} 279 titleId="newtab-topsites-title-label" 280 placeholderId="newtab-topsites-title-input" 281 autoFocusOnOpen={true} 282 /> 283 <TopSiteFormInput 284 onChange={this.onUrlChange} 285 shouldFocus={ 286 this.state.validationError && 287 !this.validateUrl(this.state.url) 288 } 289 value={this.state.url} 290 onClear={this.onClearUrlClick} 291 validationError={ 292 this.state.validationError && 293 !this.validateUrl(this.state.url) 294 } 295 titleId="newtab-topsites-url-label" 296 typeUrl={true} 297 placeholderId="newtab-topsites-url-input" 298 errorMessageId="newtab-topsites-url-validation" 299 /> 300 {this._renderCustomScreenshotInput()} 301 </div> 302 <TopSiteLink 303 link={previewLink} 304 defaultStyle={requestFailed} 305 title={this.state.label} 306 /> 307 </div> 308 </div> 309 <section className="actions"> 310 <button 311 className="cancel" 312 type="button" 313 onClick={this.onCancelButtonClick} 314 data-l10n-id="newtab-topsites-cancel-button" 315 /> 316 {previewMode ? ( 317 <button 318 className="done preview" 319 type="submit" 320 data-l10n-id="newtab-topsites-preview-button" 321 /> 322 ) : ( 323 <button 324 className="done" 325 type="submit" 326 data-l10n-id={ 327 showAsAdd 328 ? "newtab-topsites-add-button" 329 : "newtab-topsites-save-button" 330 } 331 /> 332 )} 333 </section> 334 </form> 335 ); 336 } 337 } 338 339 TopSiteForm.defaultProps = { 340 site: null, 341 index: -1, 342 };