diff --git a/internal_packages/account-sidebar/lib/account-switcher.cjsx b/internal_packages/account-sidebar/lib/account-switcher.cjsx index 6968245b1..f97060586 100644 --- a/internal_packages/account-sidebar/lib/account-switcher.cjsx +++ b/internal_packages/account-sidebar/lib/account-switcher.cjsx @@ -39,6 +39,7 @@ class AccountSwitcher extends React.Component _renderPrimaryItem: => + label = @state.account.label.trim()
{@_renderGravatarForAccount(@state.account)}
@@ -47,13 +48,14 @@ class AccountSwitcher extends React.Component mode={RetinaImg.Mode.ContentDark} />
- {@state.account.emailAddress.trim().toLowerCase()} + {label}
_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
@_onSwitchAccount(account)} key={email}> {@_renderGravatarForAccount(account)} -
{email}
+
{label}
@@ -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: => diff --git a/internal_packages/account-sidebar/spec/account-switcher-spec.cjsx b/internal_packages/account-sidebar/spec/account-switcher-spec.cjsx index f15d6f4e4..1d990bc59 100644 --- a/internal_packages/account-sidebar/spec/account-switcher-spec.cjsx +++ b/internal_packages/account-sidebar/spec/account-switcher-spec.cjsx @@ -13,6 +13,7 @@ describe "AccountSwitcher", -> { emailAddress: "dillon@nylas.com", provider: "exchange" + label: "work" } ] diff --git a/internal_packages/account-sidebar/stylesheets/account-sidebar.less b/internal_packages/account-sidebar/stylesheets/account-sidebar.less index ec4785173..a32ec55f1 100644 --- a/internal_packages/account-sidebar/stylesheets/account-sidebar.less +++ b/internal_packages/account-sidebar/stylesheets/account-sidebar.less @@ -141,7 +141,6 @@ } .name { - text-transform: lowercase; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/internal_packages/composer/lib/account-contact-field.cjsx b/internal_packages/composer/lib/account-contact-field.cjsx index 1369ff47c..24589e5c0 100644 --- a/internal_packages/composer/lib/account-contact-field.cjsx +++ b/internal_packages/composer/lib/account-contact-field.cjsx @@ -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: =>
{"From:"}
@@ -31,34 +19,30 @@ class AccountContactField extends React.Component
_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 {label}} + menu={@_renderAliasesMenu(@props.account)}/> else - currentLabel = "Please select one of your accounts" - # currentLabel = "Choose an account..." + return @_renderAccountSpan("Please select an account") - return {currentLabel} + _renderAliasesMenu: (account) => + alias } + itemContent={ (alias) -> alias } + onSelect={@_onChooseAlias.bind(@, account)} /> - # {currentLabel}} - # menu={@_renderMenu()}/> + _renderAccountSpan: (label) -> + {label} - _renderMenu: => - others = _.reject @state.accounts, (acct) => - acct.emailAddress is @props.value?.email - - 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() diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 7a79738ae..d37caf7a8 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -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()} - _renderContent: -> + _renderContent: =>
{if @state.focusedField in Fields.ParticipantFields - 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: => diff --git a/internal_packages/composer/lib/expanded-participants.cjsx b/internal_packages/composer/lib/expanded-participants.cjsx index 10690ae60..17d7fd6c5 100644 --- a/internal_packages/composer/lib/expanded-participants.cjsx +++ b/internal_packages/composer/lib/expanded-participants.cjsx @@ -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]} /> ) diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index 1f4535f76..c1b3bdfe3 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -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() diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index 2244845da..960874028 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -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 { diff --git a/internal_packages/preferences/lib/tabs/preferences-account-details.jsx b/internal_packages/preferences/lib/tabs/preferences-account-details.jsx new file mode 100644 index 000000000..f635d585c --- /dev/null +++ b/internal_packages/preferences/lib/tabs/preferences-account-details.jsx @@ -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 ( +
+

Account Label

+ +

Aliases

+ + {account.aliases} + +
+ ); + } + +} + +export default PreferencesAccountDetails; diff --git a/internal_packages/preferences/lib/tabs/preferences-account-list.jsx b/internal_packages/preferences/lib/tabs/preferences-account-list.jsx new file mode 100644 index 000000000..7cce7e550 --- /dev/null +++ b/internal_packages/preferences/lib/tabs/preferences-account-list.jsx @@ -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 ( +
+ +
+ +
+
+
{label}
+
{accountSub} ({account.displayProvider()})
+
+
+
+ ); + } + + render() { + if (!this.props.accounts) { + return
; + } + return ( +
+ + {this.props.accounts.map(this._renderAccount)} + +
+ ); + } + +} + +export default PreferencesAccountList; diff --git a/internal_packages/preferences/lib/tabs/preferences-accounts.cjsx b/internal_packages/preferences/lib/tabs/preferences-accounts.cjsx index d8fa01481..8668f8471 100644 --- a/internal_packages/preferences/lib/tabs/preferences-accounts.cjsx +++ b/internal_packages/preferences/lib/tabs/preferences-accounts.cjsx @@ -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: => -
-

Accounts

- {@_renderAccounts()} -
- -
- - {@_renderLinkedAccounts()} -
- - _renderAccounts: => - return false unless @state.accounts - -
- { @state.accounts.map (account) => -
- -
- -
-
-
{account.emailAddress}
-
{account.name || "No name provided."} ({account.displayProvider()})
-
-
- -
-
-
- } -
- - _renderLinkedAccounts: => - tokens = @getSecondaryTokens() - return false unless tokens.length > 0 -
-
- Linked Accounts: -
- { tokens.map (token) => -
- {@_renderLinkedAccount(token)} -
- } -
- - _renderLinkedAccount: (token) => - -
- -
-
-
{token.provider}
-
-
- -
-
- 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: => +
+

Accounts

+
+ + +
+
module.exports = PreferencesAccounts diff --git a/internal_packages/preferences/stylesheets/preferences-accounts.less b/internal_packages/preferences/stylesheets/preferences-accounts.less new file mode 100644 index 000000000..822196f16 --- /dev/null +++ b/internal_packages/preferences/stylesheets/preferences-accounts.less @@ -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%; + } + } + } + } +} diff --git a/internal_packages/preferences/stylesheets/preferences.less b/internal_packages/preferences/stylesheets/preferences.less index 8cd5c0647..974acd2b4 100644 --- a/internal_packages/preferences/stylesheets/preferences.less +++ b/internal_packages/preferences/stylesheets/preferences.less @@ -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; diff --git a/internal_packages/thread-list/lib/thread-list-store.coffee b/internal_packages/thread-list/lib/thread-list-store.coffee index 4d2d0a49f..8a9f04b8c 100644 --- a/internal_packages/thread-list/lib/thread-list-store.coffee +++ b/internal_packages/thread-list/lib/thread-list-store.coffee @@ -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) -> diff --git a/internal_packages/unread-notifications/lib/main.coffee b/internal_packages/unread-notifications/lib/main.coffee index 5b30b594c..cccaf9831 100644 --- a/internal_packages/unread-notifications/lib/main.coffee +++ b/internal_packages/unread-notifications/lib/main.coffee @@ -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) diff --git a/spec/components/editable-list-spec.jsx b/spec/components/editable-list-spec.jsx index d9df8fec5..9177bab4c 100644 --- a/spec/components/editable-list-spec.jsx +++ b/spec/components/editable-list-spec.jsx @@ -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'); diff --git a/spec/stores/account-store-spec.coffee b/spec/stores/account-store-spec.coffee index 99b9dcae5..0686d8179 100644 --- a/spec/stores/account-store-spec.coffee +++ b/spec/stores/account-store-spec.coffee @@ -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() diff --git a/src/components/editable-list.jsx b/src/components/editable-list.jsx index 128ac8b04..24b97bcb9 100644 --- a/src/components/editable-list.jsx +++ b/src/components/editable-list.jsx @@ -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 ( + + ); + } + + _renderCreateInput = ()=> { + const props = _.extend(this.props.createInputProps, { + autoFocus: true, + type: 'text', + onBlur: this._onCreateInputBlur, + onKeyDown: this._onCreateInputKeyDown, + }); + + return ( +
+ +
+ ); } _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 = ( - - ); + 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} +
); } - _renderCreateItem = ()=> { + _renderButtons = ()=> { return ( -
- +
+
+ + +
+
+ +
); } @@ -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 ( -
-
+ + ref="itemsWrapper" > {items} -
-
- - -
+ + {this._renderButtons()}
); } diff --git a/src/flux/actions.coffee b/src/flux/actions.coffee index 3b2033180..7b854017d 100644 --- a/src/flux/actions.coffee +++ b/src/flux/actions.coffee @@ -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 diff --git a/src/flux/models/account.coffee b/src/flux/models/account.coffee index 68f2f56dd..56b9c5f29 100644 --- a/src/flux/models/account.coffee +++ b/src/flux/models/account.coffee @@ -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" diff --git a/src/flux/models/contact.coffee b/src/flux/models/contact.coffee index 93b2f8b31..c0b5ba7f8 100644 --- a/src/flux/models/contact.coffee +++ b/src/flux/models/contact.coffee @@ -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 ` if # the contact has a populated name, just the email address otherwise. toString: -> diff --git a/src/flux/stores/account-store.coffee b/src/flux/stores/account-store.coffee index 86a74be1b..c5fe40da9 100644 --- a/src/flux/stores/account-store.coffee +++ b/src/flux/stores/account-store.coffee @@ -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 diff --git a/src/flux/tasks/syncback-draft.coffee b/src/flux/tasks/syncback-draft.coffee index d5bd99aed..0fb2621c4 100644 --- a/src/flux/tasks/syncback-draft.coffee +++ b/src/flux/tasks/syncback-draft.coffee @@ -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) diff --git a/static/components/editable-list.less b/static/components/editable-list.less index c63bd11c1..908fffe02 100644 --- a/static/components/editable-list.less +++ b/static/components/editable-list.less @@ -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; } } } diff --git a/static/images/editable-list/edit-icon@2x.png b/static/images/editable-list/edit-icon@2x.png new file mode 100644 index 000000000..3465070fe Binary files /dev/null and b/static/images/editable-list/edit-icon@2x.png differ