mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-29 16:06:31 +08:00
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:
parent
8b1e3c185d
commit
415d612458
25 changed files with 645 additions and 313 deletions
|
@ -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: =>
|
||||
|
|
|
@ -13,6 +13,7 @@ describe "AccountSwitcher", ->
|
|||
{
|
||||
emailAddress: "dillon@nylas.com",
|
||||
provider: "exchange"
|
||||
label: "work"
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
@ -141,7 +141,6 @@
|
|||
}
|
||||
|
||||
.name {
|
||||
text-transform: lowercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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: =>
|
||||
|
|
|
@ -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]} />
|
||||
)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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: ->
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
BIN
static/images/editable-list/edit-icon@2x.png
Normal file
BIN
static/images/editable-list/edit-icon@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
Loading…
Add table
Reference in a new issue