feat(account-prefs): Adds new page for Account in preferences

Summary:
Adds the new Account preferences page. This consists of two major React components,
PreferencesAccountList and PreferencesAccountDetails, both of which use EditableList.

I added a bunch of fixes and updated the API for EditableList, plus a bit of
refactoring for PreferencesAccount component, and a bunch of CSS so its a big diff.

The detailed changelog:

Updates to EditableList:
  - Fix bug updating selection state when arrows pressed to move selection
  - Add new props:
    - allowEmptySelection to allow the list to have no selection
    - createInputProps to pass aditional props to the createInput
  - Add scroll region for list items
  - Update styles and refactor render methods

Other Updates:
- Updates Account model to hold aliases and a label
  - Adds getter for label to default to email
- Update accountswitcher to display label, update styles and spec

- Refactor PreferencesAccounts component:
  - Splits it into smaller components,
  - Removes unused code
- Splits preferences styelsheets into smaller separate stylesheet for
  account page. Adds some updates and fixes (scroll-region padding)
- Update AccountStore to be able to perform updates on an account.
- Adds new Action to update account, and an action to remove account to
  be consistent with Action usage
- Adds components for Account list and Aliases list using EditableList

Test Plan: - All specs pass, but need to write new tests!

Reviewers: bengotow, evan

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2332
This commit is contained in:
Juan Tejada 2015-12-09 12:35:40 -08:00
parent 8b1e3c185d
commit 415d612458
25 changed files with 645 additions and 313 deletions

View file

@ -39,6 +39,7 @@ class AccountSwitcher extends React.Component
</div>
_renderPrimaryItem: =>
label = @state.account.label.trim()
<div className="item primary-item" onClick={@_toggleDropdown}>
{@_renderGravatarForAccount(@state.account)}
<div style={float: 'right', marginTop: -2}>
@ -47,13 +48,14 @@ class AccountSwitcher extends React.Component
mode={RetinaImg.Mode.ContentDark} />
</div>
<div className="name" style={lineHeight: "110%"}>
{@state.account.emailAddress.trim().toLowerCase()}
{label}
</div>
<div style={clear: "both"}></div>
</div>
_renderAccount: (account) =>
email = account.emailAddress.trim().toLowerCase()
label = account.label.trim()
classes = classNames
"active": account is @state.account
"item": true
@ -61,7 +63,7 @@ class AccountSwitcher extends React.Component
<div className={classes} onClick={ => @_onSwitchAccount(account)} key={email}>
{@_renderGravatarForAccount(account)}
<div className="name" style={lineHeight: "110%"}>{email}</div>
<div className="name" style={lineHeight: "110%"}>{label}</div>
<div style={clear: "both"}></div>
</div>
@ -115,7 +117,7 @@ class AccountSwitcher extends React.Component
@setState(showing: false)
_onSwitchAccount: (account) =>
Actions.selectAccountId(account.id)
Actions.selectAccount(account.id)
@setState(showing: false)
_onManageAccounts: =>

View file

@ -13,6 +13,7 @@ describe "AccountSwitcher", ->
{
emailAddress: "dillon@nylas.com",
provider: "exchange"
label: "work"
}
]

View file

@ -141,7 +141,6 @@
}
.name {
text-transform: lowercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View file

@ -9,21 +9,9 @@ class AccountContactField extends React.Component
@propTypes:
value: React.PropTypes.object
account: React.PropTypes.object,
onChange: React.PropTypes.func.isRequired
constructor: (@props) ->
@state = @getStateFromStores()
componentDidMount: =>
@unlisten = AccountStore.listen =>
@setState(@getStateFromStores())
componentWillUnmount: =>
@unlisten()
getStateFromStores: =>
accounts: AccountStore.items()
render: =>
<div className="composer-participant-field">
<div className="composer-field-label">{"From:"}</div>
@ -31,34 +19,30 @@ class AccountContactField extends React.Component
</div>
_renderFromPicker: ->
current = _.find @state.accounts, (acct) =>
acct.emailAddress is @props.value?.email
if current
currentLabel = current.me().toString()
if @props.account? && @props.value?
label = @props.value.toString()
if @props.account.aliases.length is 0
return @_renderAccountSpan(label)
return <ButtonDropdown
ref="dropdown"
bordered={false}
primaryItem={<span>{label}</span>}
menu={@_renderAliasesMenu(@props.account)}/>
else
currentLabel = "Please select one of your accounts"
# currentLabel = "Choose an account..."
return @_renderAccountSpan("Please select an account")
return <span className="from-picker" style={position: "relative", top: 6, left: "0.5em"}>{currentLabel}</span>
_renderAliasesMenu: (account) =>
<Menu
items={[account.me().toString()].concat account.aliases}
itemKey={ (alias) -> alias }
itemContent={ (alias) -> alias }
onSelect={@_onChooseAlias.bind(@, account)} />
# <ButtonDropdown
# ref="dropdown"
# bordered={false}
# primaryItem={<span>{currentLabel}</span>}
# menu={@_renderMenu()}/>
_renderAccountSpan: (label) ->
<span className="from-picker" style={position: "relative", top: 6, left: "0.5em"}>{label}</span>
_renderMenu: =>
others = _.reject @state.accounts, (acct) =>
acct.emailAddress is @props.value?.email
<Menu items={others}
itemKey={ (account) -> account.id }
itemContent={ (account) -> account.me().toString() }
onSelect={@_onChooseAccount} />
_onChooseAccount: (account) =>
@props.onChange(account.me())
_onChooseAlias: (account, alias) =>
@props.onChange(account.meUsingAlias(alias))
@refs.dropdown.toggleDropdown()

View file

@ -64,6 +64,7 @@ class ComposerView extends React.Component
body: ""
files: []
subject: ""
account: null
focusedField: Fields.To # Gets updated in @_initiallyFocusedField
enabledFields: [] # Gets updated in @_initiallyEnabledFields
showQuotedText: false
@ -216,7 +217,7 @@ class ComposerView extends React.Component
{@_renderContent()}
</ScrollRegion>
_renderContent: ->
_renderContent: =>
<div className="composer-centered">
{if @state.focusedField in Fields.ParticipantFields
<ExpandedParticipants
@ -224,6 +225,7 @@ class ComposerView extends React.Component
from={@state.from}
ref="expandedParticipants"
mode={@props.mode}
account={@state.account}
focusedField={@state.focusedField}
enabledFields={@state.enabledFields}
onPopoutComposer={@_onPopoutComposer}
@ -516,6 +518,7 @@ class ComposerView extends React.Component
body: draft.body
files: draft.files
subject: draft.subject
account: AccountStore.itemWithId(draft.accountId)
if !@state.populated
_.extend state,
@ -564,16 +567,16 @@ class ComposerView extends React.Component
# When the account store changes, the From field may or may not still
# be in scope. We need to make sure to update our enabled fields.
_onAccountStoreChanged: =>
if @_shouldShowFromField(@_proxy?.draft())
enabledFields = @state.enabledFields.concat [Fields.From]
enabledFields = if @_shouldShowFromField(@_proxy?.draft())
@state.enabledFields.concat [Fields.From]
else
enabledFields = _.without(@state.enabledFields, Fields.From)
@setState {enabledFields}
_.without(@state.enabledFields, Fields.From)
account = AccountStore.itemWithId @_proxy?.draft().accountId
@setState {enabledFields, account}
_shouldShowFromField: (draft) ->
return false unless draft
return AccountStore.items().length > 1 and
not draft.replyToMessageId and
draft.files.length is 0
_shouldEnableSubject: =>

View file

@ -17,6 +17,9 @@ class ExpandedParticipants extends React.Component
bcc: React.PropTypes.array
from: React.PropTypes.array
# The account to which the current draft belongs
account: React.PropTypes.object
# Either "fullwindow" or "inline"
mode: React.PropTypes.string
@ -143,6 +146,7 @@ class ExpandedParticipants extends React.Component
ref={Fields.From}
onChange={ (me) => @props.onChangeParticipants(from: [me]) }
onFocus={ => @props.onChangeFocusedField(Fields.From) }
account={@props.account}
value={@props.from?[0]} />
)

View file

@ -308,18 +308,18 @@ describe "populated composer", ->
makeComposer.call @
expect(@composer.state.enabledFields).not.toContain Fields.From
it "disables if it's a reply-to message", ->
spyOn(AccountStore, 'items').andCallFake -> [{id: 1}, {id: 2}]
useDraft.call @, replyToMessageId: "local-123", files: []
makeComposer.call @
expect(@composer.state.enabledFields).not.toContain Fields.From
it "disables if there are attached files", ->
spyOn(AccountStore, 'items').andCallFake -> [{id: 1}, {id: 2}]
useDraft.call @, replyToMessageId: null, files: [f1]
makeComposer.call @
expect(@composer.state.enabledFields).not.toContain Fields.From
it "enables if it's a reply-to message", ->
spyOn(AccountStore, 'items').andCallFake -> [{id: 1}, {id: 2}]
useDraft.call @, replyToMessageId: "local-123", files: []
makeComposer.call @
expect(@composer.state.enabledFields).toContain Fields.From
it "enables if requirements are met", ->
a1 = new Account()
a2 = new Account()

View file

@ -308,7 +308,7 @@ body.platform-win32 {
//////////////////////////////////
.composer-participant-field {
position: relative;
padding: 7px 0 0 0;
padding: 8px 0 0 0;
margin: 0 8+@spacing-standard;
flex-shrink: 0;
border-bottom: 1px solid @border-color-divider;
@ -317,6 +317,15 @@ body.platform-win32 {
.button-dropdown {
margin-left: 10px;
&:hover {
.primary-item,
.only-item {
border-radius: 4px;
}
}
.secondary-items {
border-radius: 4px;
}
}
.participant {

View file

@ -0,0 +1,110 @@
import _ from 'underscore';
import React, {Component, PropTypes} from 'react';
import {EditableList} from 'nylas-component-kit';
import {RegExpUtils} from 'nylas-exports';
class PreferencesAccountDetails extends Component {
static propTypes = {
account: PropTypes.object,
onAccountUpdated: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = _.clone(props.account);
}
componentWillReceiveProps(nextProps) {
this.setState(_.clone(nextProps.account));
}
componentWillUnmount() {
this._saveChanges();
}
// Helpers
_makeAlias(str, account = this.props.account) {
const emailRegex = RegExpUtils.emailRegex();
const match = emailRegex.exec(str);
if (!match) {
return `${str} <${account.emailAddress}>`;
}
const email = match[0];
let name = str.slice(0, Math.max(0, match.index - 1));
if (!name) {
name = account.name || 'No name provided';
}
// TODO Sanitize the name string
return `${name} <${email}>`;
}
_saveChanges = ()=> {
this.props.onAccountUpdated(this.props.account, this.state);
}
// Handlers
_onAccountLabelUpdated = (event)=> {
this.setState({label: event.target.value});
}
_onAccountAliasCreated = (newAlias)=> {
const coercedAlias = this._makeAlias(newAlias);
const aliases = this.state.aliases.concat([coercedAlias]);
this.setState({aliases}, ()=> {
this._saveChanges();
});
}
_onAccountAliasUpdated = (newAlias, alias, idx)=> {
const coercedAlias = this._makeAlias(newAlias);
const aliases = this.state.aliases.slice();
aliases[idx] = coercedAlias;
this.setState({aliases}, ()=> {
this._saveChanges();
});
}
_onAccountAliasRemoved = (alias, idx)=> {
const aliases = this.state.aliases.slice();
aliases.splice(idx, 1);
this.setState({aliases}, ()=> {
this._saveChanges();
});
}
render() {
const account = this.state;
const aliasPlaceholder = this._makeAlias(
`alias@${account.emailAddress.split('@')[1]}`
);
return (
<div className="account-details">
<h3>Account Label</h3>
<input
type="text"
value={account.label}
onBlur={this._saveChanges}
onChange={this._onAccountLabelUpdated} />
<h3>Aliases</h3>
<EditableList
showEditIcon
createInputProps={{placeholder: aliasPlaceholder}}
onItemCreated={this._onAccountAliasCreated}
onItemEdited={this._onAccountAliasUpdated}
onDeleteItem={this._onAccountAliasRemoved} >
{account.aliases}
</EditableList>
</div>
);
}
}
export default PreferencesAccountDetails;

View file

@ -0,0 +1,64 @@
import React, {Component, PropTypes} from 'react';
import {RetinaImg, Flexbox, EditableList} from 'nylas-component-kit';
class PreferencesAccountList extends Component {
static propTypes = {
accounts: PropTypes.array,
onAddAccount: PropTypes.func.isRequired,
onAccountSelected: PropTypes.func.isRequired,
onRemoveAccount: PropTypes.func.isRequired,
}
_onAccountSelected = (accountComp, idx)=> {
this.props.onAccountSelected(this.props.accounts[idx], idx);
}
_onRemoveAccount = (accountComp, idx)=> {
this.props.onRemoveAccount(this.props.accounts[idx], idx);
}
_renderAccount = (account)=> {
const label = account.label;
const accountSub = `${account.name || 'No name provided'} <${account.emailAddress}>`;
return (
<div
className="account"
key={account.id} >
<Flexbox direction="row" style={{alignItems: 'middle'}}>
<div style={{textAlign: 'center'}}>
<RetinaImg
name={`ic-settings-account-${account.provider}.png`}
fallback="ic-settings-account-imap.png"
mode={RetinaImg.Mode.ContentPreserve} />
</div>
<div style={{flex: 1, marginLeft: 10}}>
<div className="account-name">{label}</div>
<div className="account-subtext">{accountSub} ({account.displayProvider()})</div>
</div>
</Flexbox>
</div>
);
}
render() {
if (!this.props.accounts) {
return <div className="account-list"></div>;
}
return (
<div className="account-list">
<EditableList
allowEmptySelection={false}
onCreateItem={this.props.onAddAccount}
onItemSelected={this._onAccountSelected}
onDeleteItem={this._onRemoveAccount}>
{this.props.accounts.map(this._renderAccount)}
</EditableList>
</div>
);
}
}
export default PreferencesAccountList;

View file

@ -1,101 +1,60 @@
React = require 'react'
_ = require 'underscore'
{AccountStore, DatabaseStore, EdgehillAPI} = require 'nylas-exports'
{RetinaImg, Flexbox} = require 'nylas-component-kit'
{AccountStore, Actions} = require 'nylas-exports'
PreferencesAccountList = require './preferences-account-list'
PreferencesAccountDetails = require './preferences-account-details'
class PreferencesAccounts extends React.Component
@displayName: 'PreferencesAccounts'
constructor: (@props) ->
@state = @getStateFromStores()
@state.selected = @state.accounts[0]
componentDidMount: =>
@unsubscribe = AccountStore.listen @_onAccountChange
@unsubscribe = AccountStore.listen @_onAccountsChanged
componentWillUnmount: =>
@unsubscribe?()
render: =>
<section className="container-accounts">
<h2>Accounts</h2>
{@_renderAccounts()}
<div style={textAlign:"right", marginTop: '20'}>
<button className="btn btn-large" onClick={@_onAddAccount}>Add Account...</button>
</div>
{@_renderLinkedAccounts()}
</section>
_renderAccounts: =>
return false unless @state.accounts
<div>
{ @state.accounts.map (account) =>
<div className="well large" style={marginBottom:10} key={account.id}>
<Flexbox direction="row" style={alignItems: 'middle'}>
<div style={textAlign: "center"}>
<RetinaImg name={"ic-settings-account-#{account.provider}.png"}
fallback="ic-settings-account-imap.png"
mode={RetinaImg.Mode.ContentPreserve} />
</div>
<div style={flex: 1, marginLeft: 10}>
<div className="account-name">{account.emailAddress}</div>
<div className="account-subtext">{account.name || "No name provided."} ({account.displayProvider()})</div>
</div>
<div style={textAlign:"right", marginTop:10, display:'inline-block'}>
<button className="btn btn-large" onClick={ => @_onUnlinkAccount(account) }>Unlink</button>
</div>
</Flexbox>
</div>
}
</div>
_renderLinkedAccounts: =>
tokens = @getSecondaryTokens()
return false unless tokens.length > 0
<div>
<div className="section-header">
Linked Accounts:
</div>
{ tokens.map (token) =>
<div className="well small" key={token.id}>
{@_renderLinkedAccount(token)}
</div>
}
</div>
_renderLinkedAccount: (token) =>
<Flexbox direction="row" style={alignItems: "center"}>
<div>
<RetinaImg name={"ic-settings-account-#{token.provider}.png"} fallback="ic-settings-account-imap.png" />
</div>
<div style={flex: 1, marginLeft: 10}>
<div className="account-name">{token.provider}</div>
</div>
<div style={textAlign:"right"}>
<button onClick={ => @_onUnlinkToken(token) } className="btn btn-large">Unlink</button>
</div>
</Flexbox>
getStateFromStores: =>
accounts: AccountStore.items()
getSecondaryTokens: =>
return [] unless @props.config
tokens = @props.config.get('tokens') || []
tokens = tokens.filter (token) -> token.provider isnt 'nylas'
tokens
_onAccountsChanged: =>
@setState(@getStateFromStores())
# Update account list actions
#
_onAddAccount: =>
ipc = require('electron').ipcRenderer
ipc.send('command', 'application:add-account')
_onAccountChange: =>
@setState(@getStateFromStores())
_onAccountSelected: (account) =>
@setState(selected: account)
_onUnlinkAccount: (account) =>
AccountStore.removeAccountId(account.id)
_onRemoveAccount: (account) =>
Actions.removeAccount(account.id)
_onUnlinkToken: (token) =>
# Update account actions
#
_onAccountUpdated: (account, updates) =>
Actions.updateAccount(account.id, updates)
render: =>
<section className="preferences-accounts">
<h2>Accounts</h2>
<div className="accounts-content">
<PreferencesAccountList
accounts={@state.accounts}
onAddAccount={@_onAddAccount}
onAccountSelected={@_onAccountSelected}
onRemoveAccount={@_onRemoveAccount} />
<PreferencesAccountDetails
account={@state.selected}
onAccountUpdated={@_onAccountUpdated} />
</div>
</section>
module.exports = PreferencesAccounts

View file

@ -0,0 +1,68 @@
@import "ui-variables";
// Preferences Specific
.preferences-wrap {
.preferences-accounts {
.accounts-content {
display: flex;
.account-list {
width: 400px;
.items-wrapper {
height: 525px;
}
.account {
padding: 10px;
border-bottom: 1px solid @border-color-divider;
}
.account-name {
font-size: @font-size-large;
cursor:default;
overflow: hidden;
text-overflow: ellipsis;
}
.account-subtext {
font-size: @font-size-small;
cursor:default;
}
.btn-editable-list {
height: 35px;
width: 35px;
font-size: 1.3em;
}
}
.account-details {
width: 400px;
padding-top: 20px;
padding-left: @spacing-super-double;
padding-right: @spacing-super-double;
background-color: @gray-lighter;
border-top: 1px solid @border-color-divider;
border-right: 1px solid @border-color-divider;
border-bottom: 1px solid @border-color-divider;
.items-wrapper {
height: 180px;
}
&>h3 {
font-size: 1.2em;
&:first-child {
margin-top: 0;
}
}
&>input {
font-size: 0.9em;
width: 100%;
}
}
}
}
}

View file

@ -84,25 +84,11 @@
.preferences-content {
flex: 4;
.scroll-region-content {
&>.scroll-region-content {
padding: @padding-large-vertical*3 @padding-large-horizontal * 3;
}
}
.well {
border-radius: 7px;
border:1px solid @border-color-divider;
background-color: lighten(@background-secondary, 2%);
&.large {
padding: 20px;
padding-right:10px;
}
&.small {
padding:10px;
}
}
.appearance-mode-switch {
max-width:400px;
text-align: right;
@ -187,21 +173,6 @@
}
}
.container-accounts {
max-width: 600px;
.account-name {
font-size: @font-size-large;
cursor:default;
overflow: hidden;
text-overflow: ellipsis;
}
.account-subtext {
font-size: @font-size-small;
cursor:default;
}
}
.platform-note {
padding: @padding-base-vertical @padding-base-horizontal;
background: @background-secondary;

View file

@ -112,7 +112,7 @@ class ThreadListStore extends NylasStore
accountMatcher = (m) ->
m.attribute() is Thread.attributes.accountId and m.value() is accountId
return if @_view and _.find(@_view.matchers, accountMatcher)
return if @_view and _.find(@_view.matchers(), accountMatcher)
@createView()
_onDataChanged: (change) ->

View file

@ -56,7 +56,7 @@ module.exports =
else
NylasEnv.displayWindow()
if AccountStore.current().id isnt thread.accountId
Actions.selectAccountId(thread.accountId)
Actions.selectAccount(thread.accountId)
MailViewFilter filter = MailViewFilter.forCategory(thread.categoryNamed('inbox'))
Actions.focusMailView(filter)

View file

@ -138,7 +138,7 @@ describe('EditableList', ()=> {
const items = ['1', '2', '3'];
const list = makeList(items);
const innerList = findDOMNode(
findRenderedDOMComponentWithClass(list, 'items-wrapper')
findRenderedDOMComponentWithClass(list, 'scroll-region-content-inner')
);
expect(()=> {
findRenderedDOMComponentWithClass(list, 'create-item-input');

View file

@ -66,7 +66,7 @@ describe "AccountStore", ->
"auth_token": "auth-123"
"organization_unit": "label"
@instance = new @constructor
spyOn(@instance, "onSelectAccountId").andCallThrough()
spyOn(@instance, "_onSelectAccount").andCallThrough()
spyOn(@instance, "trigger")
@instance.addAccountFromJSON(@json)
@ -84,8 +84,8 @@ describe "AccountStore", ->
it "selects the account", ->
expect(@instance._index).toBe 0
expect(@instance.onSelectAccountId).toHaveBeenCalledWith("1234")
expect(@instance.onSelectAccountId.calls.length).toBe 1
expect(@instance._onSelectAccount).toHaveBeenCalledWith("1234")
expect(@instance._onSelectAccount.calls.length).toBe 1
it "triggers", ->
expect(@instance.trigger).toHaveBeenCalled()

View file

@ -1,5 +1,7 @@
import _ from 'underscore';
import classNames from 'classnames';
import ScrollRegion from './scroll-region';
import RetinaImg from './retina-img';
import React, {Component, PropTypes} from 'react';
class EditableList extends Component {
@ -12,7 +14,9 @@ class EditableList extends Component {
PropTypes.element,
])),
className: PropTypes.string,
createPlaceholder: PropTypes.string,
allowEmptySelection: PropTypes.bool,
showEditIcon: PropTypes.bool,
createInputProps: PropTypes.object,
onCreateItem: PropTypes.func,
onDeleteItem: PropTypes.func,
onItemEdited: PropTypes.func,
@ -24,7 +28,9 @@ class EditableList extends Component {
static defaultProps = {
children: [],
className: '',
createPlaceholder: '',
createInputProps: {},
allowEmptySelection: true,
showEditIcon: false,
onDeleteItem: ()=> {},
onItemEdited: ()=> {},
onItemSelected: ()=> {},
@ -36,41 +42,72 @@ class EditableList extends Component {
this._doubleClickedItem = false;
this.state = props.initialState || {
editing: null,
selected: null,
selected: (props.allowEmptySelection ? null : 0),
creatingItem: false,
};
}
_onInputBlur = ()=> {
this.setState({editing: null});
// Helpers
_createItem = (value)=> {
this.setState({creatingItem: false}, ()=> {
this.props.onItemCreated(value);
});
}
_onInputFocus = ()=> {
_updateItem = (value, originalItem, idx)=> {
this.setState({editing: null}, ()=> {
this.props.onItemEdited(value, originalItem, idx);
});
}
_selectItem = (item, idx)=> {
this.setState({selected: idx}, ()=> {
this.props.onItemSelected(item, idx);
});
}
_scrollTo = (idx)=> {
if (!idx) return;
const list = this.refs.itemsWrapper;
const nodes = React.findDOMNode(list).querySelectorAll('.list-item');
list.scrollTo(nodes[idx]);
}
// Handlers
_onEditInputBlur = (event, item, idx)=> {
this._updateItem(event.target.value, item, idx);
}
_onEditInputFocus = ()=> {
this._doubleClickedItem = false;
}
_onInputKeyDown = (event, item, idx)=> {
_onEditInputKeyDown = (event, item, idx)=> {
event.stopPropagation();
if (_.includes(['Enter', 'Return'], event.key)) {
this.setState({editing: null});
this.props.onItemEdited(event.target.value, item, idx);
this._updateItem(event.target.value, item, idx);
} else if (event.key === 'Escape') {
this.setState({editing: null});
}
}
_onCreateInputBlur = (event)=> {
this._createItem(event.target.value);
}
_onCreateInputKeyDown = (event)=> {
event.stopPropagation();
if (_.includes(['Enter', 'Return'], event.key)) {
this.setState({creatingItem: false});
this.props.onItemCreated(event.target.value);
this._createItem(event.target.value);
} else if (event.key === 'Escape') {
this.setState({creatingItem: false});
}
}
_onCreateInputBlur = ()=> {
this.setState({creatingItem: false});
}
_onItemClick = (event, item, idx)=> {
this._selectItem(item, idx);
}
@ -83,19 +120,20 @@ class EditableList extends Component {
}
_onListBlur = ()=> {
if (!this._doubleClickedItem) {
if (!this._doubleClickedItem && this.props.allowEmptySelection) {
this.setState({selected: null});
}
}
_onListKeyDown = (event)=> {
const len = this.props.children.size;
const len = this.props.children.length;
const handle = {
'ArrowUp': (sel)=> sel === 0 ? sel : sel - 1,
'ArrowUp': (sel)=> Math.max(0, sel - 1),
'ArrowDown': (sel)=> sel === len - 1 ? sel : sel + 1,
'Escape': ()=> null,
};
const selected = (handle[event.key] || ((sel)=> sel))(this.state.selected);
this._scrollTo(selected);
this._selectItem(this.props.children[selected], selected);
}
@ -109,42 +147,67 @@ class EditableList extends Component {
_onDeleteItem = ()=> {
const idx = this.state.selected;
const item = this.props.children[idx];
if (item) {
this.props.onDeleteItem(item, idx);
const selectedItem = this.props.children[idx];
if (selectedItem) {
// Move the selection 1 up after deleting
const len = this.props.children.length;
const selected = len === 1 ? null : Math.max(0, this.state.selected - 1);
this.setState({selected});
this.props.onDeleteItem(selectedItem, idx);
}
}
_selectItem = (item, idx)=> {
this.setState({selected: idx});
this.props.onItemSelected(item, idx);
// Renderers
_renderEditInput = (item, idx, handlers = {})=> {
const onInputBlur = handlers.onInputBlur || this._onEditInputBlur;
const onInputFocus = handlers.onInputFocus || this._onEditInputFocus;
const onInputKeyDown = handlers.onInputKeyDown || this._onEditInputKeyDown;
return (
<input
autoFocus
type="text"
placeholder={item}
onBlur={_.partial(onInputBlur, _, item, idx)}
onFocus={onInputFocus}
onKeyDown={_.partial(onInputKeyDown, _, item, idx)} />
);
}
_renderCreateInput = ()=> {
const props = _.extend(this.props.createInputProps, {
autoFocus: true,
type: 'text',
onBlur: this._onCreateInputBlur,
onKeyDown: this._onCreateInputKeyDown,
});
return (
<div className="create-item-input" key="create-item-input">
<input {...props}/>
</div>
);
}
_renderItem = (item, idx, {editing, selected} = this.state, handlers = {})=> {
const onClick = handlers.onClick || this._onItemClick;
const onDoubleClick = handlers.onDoubleClick || this._onItemDoubleClick;
const onInputBlur = handlers.onInputBlur || this._onInputBlur;
const onInputFocus = handlers.onInputFocus || this._onInputFocus;
const onInputKeyDown = handlers.onInputKeyDown || this._onInputKeyDown;
const classes = classNames({
'list-item': true,
'component-item': React.isValidElement(item),
'editable-item': !React.isValidElement(item),
'selected': selected === idx,
'with-edit-icon': this.props.showEditIcon && editing !== idx,
});
let itemToRender = item;
if (React.isValidElement(item)) {
itemToRender = item;
} else if (editing === idx) {
itemToRender = (
<input
autoFocus
type="text"
placeholder={item}
onBlur={onInputBlur}
onFocus={onInputFocus}
onKeyDown={_.partial(onInputKeyDown, _, item, idx)} />
);
itemToRender = this._renderEditInput(item, idx, handlers);
}
return (
@ -154,19 +217,25 @@ class EditableList extends Component {
onClick={_.partial(onClick, _, item, idx)}
onDoubleClick={_.partial(onDoubleClick, _, item, idx)}>
{itemToRender}
<RetinaImg
className="edit-icon"
name="edit-icon.png"
title="Edit Item"
mode={RetinaImg.Mode.ContentIsMask}
onClick={_.partial(onDoubleClick, _, item, idx)} />
</div>
);
}
_renderCreateItem = ()=> {
_renderButtons = ()=> {
return (
<div className="create-item-input" key="create-item-input">
<input
autoFocus
type="text"
placeholder={this.props.createPlaceholder}
onBlur={this._onCreateInputBlur}
onKeyDown={this._onCreateInputKeyDown} />
<div className="buttons-wrapper">
<div className="btn-editable-list" onClick={this._onCreateItem}>
<span>+</span>
</div>
<div className="btn-editable-list" onClick={this._onDeleteItem}>
<span></span>
</div>
</div>
);
}
@ -174,22 +243,21 @@ class EditableList extends Component {
render() {
let items = this.props.children.map((item, idx)=> this._renderItem(item, idx));
if (this.state.creatingItem === true) {
items = items.concat(this._renderCreateItem());
items = items.concat(this._renderCreateInput());
}
return (
<div className={`nylas-editable-list ${this.props.className}`}>
<div
<div
className={`nylas-editable-list ${this.props.className}`}
tabIndex="1"
onBlur={this._onListBlur}
onKeyDown={this._onListKeyDown} >
<ScrollRegion
className="items-wrapper"
tabIndex="1"
onBlur={this._onListBlur}
onKeyDown={this._onListKeyDown}>
ref="itemsWrapper" >
{items}
</div>
<div className="buttons-wrapper">
<button className="btn btn-small btn-editable-list" onClick={this._onCreateItem}>+</button>
<button className="btn btn-small btn-editable-list" onClick={this._onDeleteItem}></button>
</div>
</ScrollRegion>
{this._renderButtons()}
</div>
);
}

View file

@ -151,7 +151,25 @@ class Actions
*Scope: Window*
###
@selectAccountId: ActionScopeWindow
@selectAccount: ActionScopeWindow
###
Public: Remove the selected account
*Scope: Window*
###
@removeAccount: ActionScopeWindow
###
Public: Update the provided account
*Scope: Window*
```
Actions.updateAccount(account.id, {accountName: 'new'})
```
###
@updateAccount: ActionScopeWindow
###
Public: Select the provided sheet in the current window. This action changes

View file

@ -45,6 +45,23 @@ class Account extends Model
modelKey: 'organizationUnit'
jsonKey: 'organization_unit'
'label': Attributes.String
queryable: false
modelKey: 'label'
'aliases': Attributes.Object
queryable: false
modelKey: 'aliases'
constructor: ->
super
@aliases ||= []
@label ||= @emailAddress
fromJSON: (json) ->
json["label"] ||= json[@constructor.attributes['emailAddress'].jsonKey]
super(json)
# Returns a {Contact} model that represents the current user.
me: ->
Contact = require './contact'
@ -53,6 +70,11 @@ class Account extends Model
name: @name
email: @emailAddress
meUsingAlias: (alias) ->
Contact = require './contact'
return @me() unless alias
return Contact.fromString(alias)
usesLabels: ->
@organizationUnit is "label"

View file

@ -1,6 +1,7 @@
Model = require './model'
Utils = require './utils'
Attributes = require '../attributes'
RegExpUtils = require '../../regexp-utils'
_ = require 'underscore'
# Only load the AccountStore the first time we actually need it. This
@ -65,6 +66,20 @@ class Contact extends Model
setup: ->
['CREATE INDEX IF NOT EXISTS ContactEmailIndex ON Contact(account_id,email)']
@fromString: (string) ->
emailRegex = RegExpUtils.emailRegex()
match = emailRegex.exec(string)
if emailRegex.exec(string)
throw new Error('Error while calling Contact.fromString: string contains more than one email')
email = match[0]
name = string[0...match.index - 1]
name = name[0...-1] if name[name.length - 1] in ['<', '(']
name = name.trim()
return new Contact
accountId: @id
name: name
email: email
# Public: Returns a string of the format `Full Name <email@address.com>` if
# the contact has a populated name, just the email address otherwise.
toString: ->

View file

@ -26,13 +26,17 @@ class AccountStore
constructor: ->
@_load()
@listenTo Actions.selectAccountId, @onSelectAccountId
@listenTo Actions.selectAccount, @_onSelectAccount
@listenTo Actions.removeAccount, @_onRemoveAccount
@listenTo Actions.updateAccount, @_onUpdateAccount
NylasEnv.config.observe saveTokensKey, (updatedTokens) =>
return if _.isEqual(updatedTokens, @_tokens)
newAccountIds = _.keys(_.omit(updatedTokens, _.keys(@_tokens)))
@_load()
if newAccountIds.length > 0
Actions.selectAccountId(newAccountIds[0])
Actions.selectAccount(newAccountIds[0])
unless NylasEnv.isMainWindow()
NylasEnv.config.observe saveObjectsKey, => @_load()
@_setupFastAccountCommands()
@ -65,7 +69,7 @@ class AccountStore
_selectAccountByIndex: (index) =>
require('electron').ipcRenderer.send('command', 'application:show-main-window')
index = Math.min(@_accounts.length - 1, Math.max(0, index))
Actions.selectAccountId(@_accounts[index].id)
Actions.selectAccount(@_accounts[index].id)
_load: =>
@_accounts = []
@ -87,14 +91,23 @@ class AccountStore
# Inbound Events
onSelectAccountId: (id) =>
_onSelectAccount: (id) =>
idx = _.findIndex @_accounts, (a) -> a.id is id
return if idx is -1 or @_index is idx
NylasEnv.config.set(saveIndexKey, idx)
@_index = idx
@trigger()
removeAccountId: (id) =>
_onUpdateAccount: (id, updated) =>
idx = _.findIndex @_accounts, (a) -> a.id is id
account = @_accounts[idx]
return if !account
account = _.extend(account, updated)
@_accounts[idx] = account
NylasEnv.config.set(saveObjectsKey, @_accounts)
@trigger()
_onRemoveAccount: (id) =>
idx = _.findIndex @_accounts, (a) -> a.id is id
return if idx is -1
@ -107,7 +120,7 @@ class AccountStore
ipc.send('command', 'application:reset-config-and-relaunch')
else
if @_index is idx
Actions.selectAccountId(@_accounts[0].id)
Actions.selectAccount(@_accounts[0].id)
@trigger()
addAccountFromJSON: (json) =>
@ -119,7 +132,7 @@ class AccountStore
@_tokens[json.id] = json.auth_token
@_accounts.push((new Account).fromJSON(json))
@_save()
@onSelectAccountId(json.id)
@_onSelectAccount(json.id)
# Exposed Data
@ -132,6 +145,11 @@ class AccountStore
_.find @_accounts, (account) ->
Utils.emailIsEquivalent(email, account.emailAddress)
# Public: Returns the {Account} for the given account id, or null.
itemWithId: (accountId) =>
_.find @_accounts, (account) ->
accountId is account.accountId
# Public: Returns the currently active {Account}.
current: =>
@_accounts[@_index] || null

View file

@ -49,65 +49,65 @@ class SyncbackDraftTask extends Task
@getLatestLocalDraft().then (draft) =>
# The draft may have been deleted by another task. Nothing we can do.
return Promise.resolve() unless draft
@checkDraftFromMatchesAccount(draft).then (draft) =>
if draft.serverId
path = "/drafts/#{draft.serverId}"
method = 'PUT'
if draft.serverId
path = "/drafts/#{draft.serverId}"
method = 'PUT'
else
path = "/drafts"
method = 'POST'
payload = draft.toJSON()
@submittedBody = payload.body
# Not sure why were doing this, so commenting out for now
# delete payload['from']
# We keep this in memory as a fallback in case
# `getLatestLocalDraft` returns null after we make our API
# request.
oldDraft = draft
NylasAPI.makeRequest
accountId: draft.accountId
path: path
method: method
body: payload
returnsModel: false
.then (json) =>
# Important: There could be a significant delay between us initiating the save
# and getting JSON back from the server. Our local copy of the draft may have
# already changed more.
#
# The only fields we want to update from the server are the `id` and `version`.
#
# Also note that this *could* still rollback a save between the find / persist
# below. We currently have no way of locking between processes. Maybe a
# log-style data structure would be better suited for drafts.
#
DatabaseStore.atomically =>
@getLatestLocalDraft().then (draft) ->
if not draft then draft = oldDraft
draft.version = json.version
draft.serverId = json.id
DatabaseStore.persistModel(draft)
.then =>
return Promise.resolve(Task.Status.Success)
.catch APIError, (err) =>
if err.statusCode in [400, 404, 409] and err.requestOptions?.method is 'PUT'
@getLatestLocalDraft().then (draft) =>
if not draft then draft = oldDraft
@detatchFromRemoteID(draft).then ->
return Promise.resolve(Task.Status.Retry)
else
path = "/drafts"
method = 'POST'
payload = draft.toJSON()
@submittedBody = payload.body
delete payload['from']
# We keep this in memory as a fallback in case
# `getLatestLocalDraft` returns null after we make our API
# request.
oldDraft = draft
NylasAPI.makeRequest
accountId: draft.accountId
path: path
method: method
body: payload
returnsModel: false
.then (json) =>
# Important: There could be a significant delay between us initiating the save
# and getting JSON back from the server. Our local copy of the draft may have
# already changed more.
# NOTE: There's no offline handling. If we're offline
# SyncbackDraftTasks should always fail.
#
# The only fields we want to update from the server are the `id` and `version`.
#
# Also note that this *could* still rollback a save between the find / persist
# below. We currently have no way of locking between processes. Maybe a
# log-style data structure would be better suited for drafts.
#
DatabaseStore.atomically =>
@getLatestLocalDraft().then (draft) ->
if not draft then draft = oldDraft
draft.version = json.version
draft.serverId = json.id
DatabaseStore.persistModel(draft)
.then =>
return Promise.resolve(Task.Status.Success)
.catch APIError, (err) =>
if err.statusCode in [400, 404, 409] and err.requestOptions?.method is 'PUT'
@getLatestLocalDraft().then (draft) =>
if not draft then draft = oldDraft
@detatchFromRemoteID(draft).then ->
return Promise.resolve(Task.Status.Retry)
else
# NOTE: There's no offline handling. If we're offline
# SyncbackDraftTasks should always fail.
#
# We don't roll anything back locally, but this failure
# ensures that SendDraftTasks can never succeed while offline.
Promise.resolve([Task.Status.Failed, err])
# We don't roll anything back locally, but this failure
# ensures that SendDraftTasks can never succeed while offline.
Promise.resolve([Task.Status.Failed, err])
getLatestLocalDraft: =>
DatabaseStore.findBy(Message, clientId: @draftClientId).include(Message.attributes.body)

View file

@ -6,41 +6,55 @@
display: flex;
flex-direction: column;
border: 1px solid @border-secondary-bg;
background-color: @background-secondary;
min-height: 50px;
background-color: @background-primary;
height: 90px;
font-size: 0.9em;
.editable-item {
padding: @padding-small-vertical @padding-small-horizontal;
cursor: default;
.selected {
background-color: @component-active-color;
color: @component-active-bg;
}
&.selected {
background-color: @background-selected;
color: @text-color-selected;
.edit-icon {
display: none;
cursor: pointer;
}
.editable-item {
padding: (@padding-small-vertical - 1) @padding-small-horizontal;
cursor: default;
border-bottom: 1px solid @border-color-divider;
flex-shrink: 0;
&.selected.with-edit-icon {
display: flex;
align-items: center;
padding-right: 20px;
img.edit-icon {
display: inline;
background-color: @component-active-bg;
margin-left: auto;
}
}
&+.editable-item {
border-top: 1px solid @border-color-divider;
}
input {
&>input {
border: none;
padding: 0;
font-size: inherit;
background-color: @background-selected;
color: @text-color-selected;
background-color: @component-active-color;
color: @component-active-bg;
}
::-webkit-input-placeholder {
color: @text-color-inverse-very-subtle;
}
}
.editable-item+.create-item-input {
border-top: 1px solid @border-color-divider;
}
.create-item-input {
input {
padding: 0 @padding-small-vertical;
&>input {
padding: (@padding-small-vertical - 1) @padding-small-horizontal;
border: none;
border-bottom: 1px solid @border-color-divider;
font-size: inherit;
}
::-webkit-input-placeholder {
@ -50,17 +64,20 @@
}
.buttons-wrapper {
margin-top: @padding-xs-horizontal;
display: flex;
border: 1px solid @border-secondary-bg;
border-top: none;
background-color: @background-secondary;
.btn-editable-list {
height: 20px;
width: 30px;
text-align: center;
padding: 0 0 2px 0;
&+.btn-editable-list {
margin-left: 3px;
}
display: flex;
justify-content: center;
height: 25px;
width: 25px;
border-right: 1px solid #dddddd;
font-size: 1em;
cursor: default;
color: @text-color-subtle;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB