diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2017-11-17 10:52:03 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2017-11-21 16:13:19 +0100 |
| commit | 83bd8f5c4fd4c47f8752a1a6ae1734f6dc75a455 (patch) | |
| tree | e149b08393831d95649deb98d31d853b5c7007b7 | |
| parent | 3c1e0d96705532674b513b77d0a89c1f86e458dd (diff) | |
| download | mullvadvpn-83bd8f5c4fd4c47f8752a1a6ae1734f6dc75a455.tar.xz mullvadvpn-83bd8f5c4fd4c47f8752a1a6ae1734f6dc75a455.zip | |
Implement account dropdown for account history on Login view
| -rw-r--r-- | app/assets/images/icon-close-sml.svg | 1 | ||||
| -rw-r--r-- | app/components/Login.css | 130 | ||||
| -rw-r--r-- | app/components/Login.js | 359 | ||||
| -rw-r--r-- | app/containers/LoginPage.js | 1 |
4 files changed, 336 insertions, 155 deletions
diff --git a/app/assets/images/icon-close-sml.svg b/app/assets/images/icon-close-sml.svg new file mode 100644 index 0000000000..d06479241b --- /dev/null +++ b/app/assets/images/icon-close-sml.svg @@ -0,0 +1 @@ +<svg width="16" height="16" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icon close sml</title><desc>Created with Sketch.</desc><g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity=".2"><g id="Views/Login-Inputted-filltered-prev-IDs" transform="translate(-268.000000, -403.000000)" fill="#294D73"><g id="Input" transform="translate(24.000000, 338.000000)"><g id="suggestions" transform="translate(0.000000, 49.000000)"><g id="1"><path d="M253,24 L255.290281,21.7097195 C255.681035,21.3189651 255.684193,20.6841926 255.294624,20.2946243 L255.705376,20.7053757 C255.316406,20.3164062 254.682248,20.3177522 254.290281,20.7097195 L252,23 L249.709719,20.7097195 C249.317752,20.3177522 248.683594,20.3164062 248.294624,20.7053757 L248.705376,20.2946243 C248.315807,20.6841926 248.318965,21.3189651 248.709719,21.7097195 L251,24 L248.709719,26.2902805 C248.318965,26.6810349 248.315807,27.3158074 248.705376,27.7053757 L248.294624,27.2946243 C248.683594,27.6835938 249.317752,27.6822478 249.709719,27.2902805 L252,25 L254.290281,27.2902805 C254.682248,27.6822478 255.316406,27.6835938 255.705376,27.2946243 L255.294624,27.7053757 C255.684193,27.3158074 255.681035,26.6810349 255.290281,26.2902805 L253,24 Z M252,32 C247.58208,32 244,28.41792 244,24 C244,19.58208 247.58208,16 252,16 C256.41792,16 260,19.58208 260,24 C260,28.41792 256.41792,32 252,32 Z" id="icon-close-sml"/></g></g></g></g></g></svg>
\ No newline at end of file diff --git a/app/components/Login.css b/app/components/Login.css index 0b1842c9f4..9311212ec9 100644 --- a/app/components/Login.css +++ b/app/components/Login.css @@ -58,46 +58,57 @@ margin-bottom: 8px; } -.login-form__input-wrap { - border: 2px solid transparent; - background-color: #FFFFFF; - background-clip: content-box; - border-radius: 8px; +.login-form__account-input-container { + /* + Provides an anchor for input-container + to be properly positioned when it gains absolute + position to overlap the footer view. + */ + position: relative; + + /* push border to the outer side of the layout grid */ margin-left: -2px; margin-right: -2px; - display: flex; - flex-direction: row; - overflow: hidden; - transition-duration: 0.3s; - transition-property: background-color, border-color; - transition-timing-function: ease-in-out; +} - /* this fixes border-radius clipping bug when button animates opacity */ - -webkit-backface-visibility: hidden; - -webkit-transform: translate3d(0, 0, 0); +.login-form__account-input-group { + border: 2px solid transparent; + border-radius: 8px; + transition: border 0.25s ease-in-out; + overflow: hidden; } -.login-form__input-wrap--active { +.login-form__account-input-group--active { + position: absolute; border-color: rgba(25,46,69,0.4); } -.login-form__input-wrap--inactive { +.login-form__account-input-group--inactive { opacity: 0.6; } -.login-form__input-field::-webkit-input-placeholder { - color: rgba(41,77,115,0.4); +.login-form__account-input-group--error { + border-color: rgba(208,2,27,0.4); } -.login-form__fields--invisible { - visibility: hidden; +.login-form__account-input-group--error .login-form__account-input-textfield { + color: #D0021B; +} + +.login-form__account-input-backdrop { + background-color: #FFFFFF; + display: flex; + flex-direction: row; + transition: background-color 0.25s ease-in-out; } -.login-form__input-field { +.login-form__account-input-textfield::-webkit-input-placeholder { + color: rgba(41,77,115,0.4); +} + +.login-form__account-input-textfield { width: 100%; - border-radius: 8px; border: 0; - overflow: hidden; padding: 10px 12px 12px 12px; font-family: DINPro; font-size: 20px; @@ -109,19 +120,11 @@ transition: 0.3s color ease-in-out; } -.login-form__input-field--inactive { +.login-form__account-input-textfield--inactive { background-color: rgba(255,255,255,0.6); } -.login-form__input-wrap--error { - border-color: rgba(208,2,27,0.4); -} - -.login-form__input-wrap--error .login-form__input-field { - color: #D0021B; -} - -.login-form__submit { +.login-form__account-input-button { flex: 0 0 auto; border: 0; width: 48px; @@ -131,29 +134,78 @@ transition-timing-function: ease-in-out; } -.login-form__submit-icon { +.login-form__account-input-button-icon { display: inline-block; vertical-align: middle; } -.login-form__submit-icon path { +.login-form__account-input-button-icon path { fill: rgba(41,77,115,0.2); transition: fill 0.3s ease-in-out; } -.login-form__submit--active { +.login-form__account-input-button--active { background-color: #44ad4d; } -.login-form__submit--active .login-form__submit-icon path { +.login-form__account-input-button--active .login-form__account-input-button-icon path { fill: #fff; } -.login-form__submit--active:hover { +.login-form__account-input-button--active:hover { background-color: rgba(68, 173, 76, 0.9); } -.login-form__submit--invisible { +.login-form__account-input-button--invisible { visibility: hidden; opacity: 0; } + +.login-form__account-dropdown-container { + overflow: hidden; + transition: height 0.25s ease-in-out; +} + +.login-form__account-dropdown__item { + display: flex; + flex-direction: row; + border-top: 1px solid rgba(25,46,69,0.4); + background-color: rgba(255,255,255,0.6); + background-clip: padding-box; + transition: background-color 0.25s ease-in-out; +} + +.login-form__account-dropdown__item:hover { + background-color: rgba(255,255,255,0.65); +} + +.login-form__account-dropdown__label { + flex: 1 1 auto; + font-family: DINPro; + font-size: 20px; + font-weight: 900; + line-height: 26px; + color: #294D73; + border: 0; + background: transparent; + padding: 10px 12px 12px 12px; + text-align: left; +} + +.login-form__account-dropdown__remove { + background: transparent; + border: 0; + padding: 10px 12px 12px 12px; + + /* center SVG within button */ + display: flex; + justify-content: center; +} + +.login-form__account-dropdown__remove path { + transition: fill-opacity 0.25s ease-in-out; +} + +.login-form__account-dropdown__remove:hover path { + fill-opacity: 1; +}
\ No newline at end of file diff --git a/app/components/Login.js b/app/components/Login.js index 784d92daa8..c133954eed 100644 --- a/app/components/Login.js +++ b/app/components/Login.js @@ -2,18 +2,22 @@ import React, { Component } from 'react'; import { Layout, Container, Header } from './Layout'; import AccountInput from './AccountInput'; +import { formatAccount } from '../lib/formatters'; import ExternalLinkSVG from '../assets/images/icon-extLink.svg'; import LoginArrowSVG from '../assets/images/icon-arrow.svg'; +import RemoveAccountSVG from '../assets/images/icon-close-sml.svg'; -import type { AccountReduxState, LoginState } from '../redux/account/reducers'; +import type { AccountReduxState } from '../redux/account/reducers'; +import type { AccountToken } from '../lib/ipc-facade'; export type LoginPropTypes = { account: AccountReduxState, - onLogin: (accountToken: string) => void, + onLogin: (accountToken: AccountToken) => void, onSettings: ?(() => void), onFirstChangeAfterFailure: () => void, onExternalLink: (type: string) => void, - onAccountTokenChange: (string) => void, + onAccountTokenChange: (accountToken: AccountToken) => void, + onRemoveAccountTokenFromHistory: (accountToken: AccountToken) => void, }; export default class Login extends Component { @@ -21,75 +25,22 @@ export default class Login extends Component { state = { notifyOnFirstChangeAfterFailure: false, isActive: false, + dropdownHeight: 0 }; - onCreateAccount = () => this.props.onExternalLink('createAccount'); - onFocus = () => this.setState({ isActive: true }); - onBlur = () => this.setState({ isActive: false }); - onLogin = () => { - const accountToken = this.props.account.accountToken; - if(accountToken && accountToken.length > 0) { - this.props.onLogin(accountToken); + constructor(props: LoginPropTypes) { + super(props); + if(props.account.status === 'failed') { + this.state.notifyOnFirstChangeAfterFailure = true; } } - onInputChange = (val: string) => { - // notify delegate on first change after login failure - if(this.state.notifyOnFirstChangeAfterFailure) { - this.setState({ notifyOnFirstChangeAfterFailure: false }); - this.props.onFirstChangeAfterFailure(); - } - this.props.onAccountTokenChange(val); - } - - formTitle(s: LoginState): string { - switch(s) { - case 'logging in': return 'Logging in...'; - case 'failed': return 'Login failed'; - case 'ok': return 'Login successful'; - default: return 'Login'; - } - } - - formSubtitle(s: LoginState, e: ?Error): string { - switch(s) { - case 'failed': return (e && e.message) || 'Unknown error'; - case 'logging in': return 'Checking account number'; - default: return 'Enter your account number'; - } - } - - inputWrapClass(s: LoginState): string { - const classes = ['login-form__input-wrap']; - - if(this.state.isActive) { - classes.push('login-form__input-wrap--active'); - } - - switch(s) { - case 'logging in': - classes.push('login-form__input-wrap--inactive'); - break; - case 'failed': - classes.push('login-form__input-wrap--error'); - break; - } - - return classes.join(' '); + componentDidMount() { + this._updateDropdownHeight(); } - submitClass(s: LoginState, accountToken: ?string): string { - const classes = ['login-form__submit']; - - if(accountToken && accountToken.length > 0) { - classes.push('login-form__submit--active'); - } - - if(s === 'logging in') { - classes.push('login-form__submit--invisible'); - } - - return classes.join(' '); + componentDidUpdate() { + this._updateDropdownHeight(); } componentWillReceiveProps(nextProps: LoginPropTypes) { @@ -101,38 +52,25 @@ export default class Login extends Component { } } - render(): React.Element<*> { - const { status } = this.props.account; - const title = this.formTitle(status); - - const shouldShowLoginForm = status !== 'ok'; - const shouldShowFooter = status === 'none' || status === 'failed'; - - const statusIcon = this._getStatusIcon(); - - const loginFormClass = shouldShowLoginForm ? '' : 'login-form__fields--invisible'; - const loginForm = this._createLoginForm(); - - const footerClass = shouldShowFooter ? '' : 'login-footer--invisible'; - const footer = this._createFooter(); - + render() { + const footerClass = this._shouldShowFooter() ? '' : 'login-footer--invisible'; return ( <Layout> <Header showSettings={ true } onSettings={ this.props.onSettings } /> <Container> <div className="login"> <div className="login-form"> - { statusIcon } + { this._getStatusIcon() } - <div className="login-form__title">{ title }</div> + <div className="login-form__title">{ this._formTitle() }</div> - <div className={ 'login-form__fields ' + loginFormClass }> - { loginForm } - </div> + {this._shouldShowLoginForm() && <div className='login-form__fields'> + { this._createLoginForm() } + </div>} </div> <div className={ 'login-footer ' + footerClass }> - { footer } + { this._createFooter() } </div> </div> </Container> @@ -140,11 +78,67 @@ export default class Login extends Component { ); } - _getStatusIcon(): React.Element<*> { - const statusIconPath = this._getStatusIconPath(); + _onCreateAccount = () => this.props.onExternalLink('createAccount'); + _onFocus = () => this.setState({ isActive: true }); + _onBlur = (e) => { + const relatedTarget = e.relatedTarget; + + // restore focus if click happened within dropdown + if(relatedTarget && this._isWithinDropdown(relatedTarget)) { + e.target.focus(); + return; + } + + this.setState({ isActive: false }); + } + + _onLogin = () => { + const accountToken = this.props.account.accountToken; + if(accountToken && accountToken.length > 0) { + this.props.onLogin(accountToken); + } + } + _onInputChange = (value: string) => { + // notify delegate on first change after login failure + if(this.state.notifyOnFirstChangeAfterFailure) { + this.setState({ notifyOnFirstChangeAfterFailure: false }); + this.props.onFirstChangeAfterFailure(); + } + this.props.onAccountTokenChange(value); + } + + _formTitle() { + switch(this.props.account.status) { + case 'logging in': + return 'Logging in...'; + case 'failed': + return 'Login failed'; + case 'ok': + return 'Login successful'; + default: + return 'Login'; + } + } + + _formSubtitle() { + const { status, error } = this.props.account; + switch(status) { + case 'failed': + return (error && error.message) || 'Unknown error'; + case 'logging in': + return 'Checking account number'; + default: + return 'Enter your account number'; + } + } + + _getStatusIcon() { + const statusIconPath = this._getStatusIconPath(); return <div className="login-form__status-icon"> - <img src={ statusIconPath } alt="" /> + { statusIconPath ? + <img src={ statusIconPath } alt="" /> : + null } </div>; } @@ -161,48 +155,137 @@ export default class Login extends Component { } } - _createLoginForm(): React.Element<*> { - const { status, error } = this.props.account; - const accountToken = this.props.account.accountToken; + _accountInputGroupClass(): string { + const classes = ['login-form__account-input-group']; + if(this.state.isActive) { + classes.push('login-form__account-input-group--active'); + } + + switch(this.props.account.status) { + case 'logging in': + classes.push('login-form__account-input-group--inactive'); + break; + case 'failed': + classes.push('login-form__account-input-group--error'); + break; + } + + return classes.join(' '); + } + + _accountInputButtonClass(): string { + const { accountToken, status } = this.props.account; + const classes = ['login-form__account-input-button']; + + if(accountToken && accountToken.length > 0) { + classes.push('login-form__account-input-button--active'); + } + + if(status === 'logging in') { + classes.push('login-form__account-input-button--invisible'); + } + + return classes.join(' '); + } + + _shouldEnableAccountInput() { + // enable account input always except when "logging in" + return this.props.account.status !== 'logging in'; + } + + _shouldShowAccountHistory() { + return this._shouldEnableAccountInput() && + this.state.isActive && + this.props.account.accountHistory.length > 0; + } + + _shouldShowLoginForm() { + return this.props.account.status !== 'ok'; + } - const inputDisabled = status === 'logging in'; + _shouldShowFooter() { + const { status } = this.props.account; + return (status === 'none' || status === 'failed') && !this._shouldShowAccountHistory(); + } - const subtitle = this.formSubtitle(status, error); + // helper function to calculate and save dropdown element's height + // this is a no-op of the height didn't change since last update + _updateDropdownHeight() { + const element = this._accountDropdownElement; + if(element && this.state.dropdownHeight !== element.clientHeight) { + this.setState({ + dropdownHeight: element.clientHeight + }); + } + } + + // returns true if DOM node is within dropdown hierarchy + _isWithinDropdown(relatedTarget) { + const dropdownElement = this._accountDropdownElement; + return dropdownElement && dropdownElement.contains(relatedTarget); + } - const inputWrapClass = this.inputWrapClass(status); - const submitClass = this.submitClass(status, accountToken); + // container element used for measuring the height of the accounts dropdown + _accountDropdownElement: ?HTMLElement; + _onAccountDropdownContainerRef = ref => this._accountDropdownElement = ref; - const autoFocusRef = input => { - if(status === 'failed' && input) { + _onSelectAccountFromHistory = (accountToken) => { + this.props.onAccountTokenChange(accountToken); + this.props.onLogin(accountToken); + } + + _createLoginForm() { + const { accountHistory, accountToken } = this.props.account; + const dropdownStyles = { + height: this._shouldShowAccountHistory() ? this.state.dropdownHeight : 0 + }; + + // auto-focus on account input when failed to log in + // do not refactor this into instance method, + // it has to be new function each time to be called on each render + const autoFocusOnFailure = (input) => { + if(this.props.account.status === 'failed' && input) { input.focus(); } }; return <div> - <div className="login-form__subtitle">{ subtitle }</div> - <div className={ inputWrapClass }> - <AccountInput className="login-form__input-field" - type="text" - placeholder="e.g 0000 0000 0000" - onFocus={ this.onFocus } - onBlur={ this.onBlur } - onChange={ this.onInputChange } - onEnter={ this.onLogin } - value={ accountToken || '' } - disabled={ inputDisabled } - autoFocus={ true } - ref={ autoFocusRef } /> - <button className={ submitClass } onClick={ this.onLogin }> - <LoginArrowSVG className="login-form__submit-icon" /> - </button> + <div className="login-form__subtitle">{ this._formSubtitle() }</div> + <div className="login-form__account-input-container"> + <div className={ this._accountInputGroupClass() }> + <div className="login-form__account-input-backdrop"> + <AccountInput className="login-form__account-input-textfield" + type="text" + placeholder="e.g 0000 0000 0000" + onFocus={ this._onFocus } + onBlur={ this._onBlur } + onChange={ this._onInputChange } + onEnter={ this._onLogin } + value={ accountToken || '' } + disabled={ !this._shouldEnableAccountInput() } + autoFocus={ true } + ref={ autoFocusOnFailure } /> + <button className={ this._accountInputButtonClass() } onClick={ this._onLogin }> + <LoginArrowSVG className="login-form__account-input-button-icon" /> + </button> + </div> + <div style={ dropdownStyles } className="login-form__account-dropdown-container"> + <div ref={ this._onAccountDropdownContainerRef }> + { <AccountDropdown + items={ accountHistory.slice().reverse() } + onSelect={ this._onSelectAccountFromHistory } + onRemove={ this.props.onRemoveAccountTokenFromHistory } /> } + </div> + </div> + </div> </div> </div>; } - _createFooter(): React.Element<*> { + _createFooter() { return <div> <div className="login-footer__prompt">{ 'Don\'t have an account number?' }</div> - <button className="button button--primary" onClick={ this.onCreateAccount }> + <button className="button button--primary" onClick={ this._onCreateAccount }> <span className="button-label">Create account</span> <ExternalLinkSVG className="button-icon button-icon--16" /> </button> @@ -210,3 +293,47 @@ export default class Login extends Component { } } +class AccountDropdown extends Component { + props: { + items: Array<AccountToken>, + onSelect: ((value: AccountToken) => void), + onRemove: ((value: AccountToken) => void) + }; + + render() { + const uniqueItems = [...new Set(this.props.items)]; + return ( + <div className="login-form__account-dropdown"> + { uniqueItems.map(token => ( + <AccountDropdownItem key={ token } + value={ token } + label={ formatAccount(token) } + onSelect={ this.props.onSelect } + onRemove={ this.props.onRemove } /> + )) } + </div> + ); + } +} + +class AccountDropdownItem extends Component { + props: { + label: string, + value: AccountToken, + onRemove: (value: AccountToken) => void, + onSelect: (value: AccountToken) => void + }; + + render() { + return ( + <div className="login-form__account-dropdown__item"> + <button className="login-form__account-dropdown__label" + onClick={ () => this.props.onSelect(this.props.value) }>{ this.props.label }</button> + <button className="login-form__account-dropdown__remove" + onClick={ () => this.props.onRemove(this.props.value) }> + <RemoveAccountSVG /> + </button> + </div> + ); + } +} diff --git a/app/containers/LoginPage.js b/app/containers/LoginPage.js index 457be7a6f2..f7df9c5595 100644 --- a/app/containers/LoginPage.js +++ b/app/containers/LoginPage.js @@ -16,6 +16,7 @@ const mapDispatchToProps = (dispatch, props) => { onFirstChangeAfterFailure: () => dispatch(accountActions.resetLoginError()), onExternalLink: (type) => shell.openExternal(links[type]), onAccountTokenChange: (token) => dispatch(accountActions.updateAccountToken(token)), + onRemoveAccountTokenFromHistory: (token) => backend.removeAccountFromHistory(token), }; }; |
