feat(prefs): Allow tabs with an accounts submenu

Summary:
This diff:
- Improves the styling of the tabs in the preferences sidebar.
- Adds an optional param to section cofnig that puts an "account" submenu beneath the tab item.
- Renames preferences "sections" => "tabs", and renames the PreferencesSectionStore to PreferencesUIStore. I think we should include "UI" in more of our stores, and I think "tabs" is a good idea because it's unambigious—there's no way you could confuse it for a "section" of the NylasEnv.config tree or think it deals with actually saving prefs.

Test Plan: Inspect visually

Reviewers: evan, juan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2296
This commit is contained in:
Ben Gotow 2015-11-30 11:43:49 -08:00
parent 847ac1f10e
commit d7d5ed2832
15 changed files with 249 additions and 157 deletions

View file

@ -119,7 +119,7 @@ class AccountSwitcher extends React.Component
@setState(showing: false)
_onManageAccounts: =>
Actions.switchPreferencesSection('Accounts')
Actions.switchPreferencesTab('Accounts')
Actions.openPreferences()
@setState(showing: false)

View file

@ -51,31 +51,6 @@
.item-container {
display:flex;
.disclosure-triangle {
flex-shrink: 0;
padding:7px;
padding-top:12px;
width:20px;
visibility: hidden;
div {
transform:rotate(90deg);
transition: transform 90ms linear;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 7px solid @text-color-very-subtle;
}
&.visible {
visibility: visible;
}
&.collapsed {
div {
transform:rotate(0deg);
}
}
}
}
.item {

View file

@ -1,34 +1,34 @@
{PreferencesSectionStore, DraftStore} = require 'nylas-exports'
{PreferencesUIStore, DraftStore} = require 'nylas-exports'
SignatureDraftExtension = require './signature-draft-extension'
module.exports =
activate: (@state={}) ->
DraftStore.registerExtension(SignatureDraftExtension)
@sectionConfig = new PreferencesSectionStore.SectionConfig
@preferencesTab = new PreferencesUIStore.TabItem
# TODO: Fix RetinaImg to handle plugin images
icon: ->
if process.platform is "win32"
"nylas://composer-signature/images/ic-settings-signatures-win32@2x.png"
else
"nylas://composer-signature/images/ic-settings-signatures@2x.png"
sectionId: "Signatures"
tabId: "Signatures"
displayName: "Signatures"
component: require "./preferences-signatures"
# TODO Re-enable when fixed!
# PreferencesSectionStore.registerPreferenceSection(@sectionConfig)
# PreferencesUIStore.registerPreferencesTab(@preferencesTab)
## TODO
# PreferencesSectionStore.registerPreferences "composer-signatures", [
# PreferencesUIStore.registerPreferences "composer-signatures", [
# {
# section: PreferencesSectionStore.Section.Signatures
# section: PreferencesUIStore.Section.Signatures
# type: "richtext"
# label: "Signature:"
# perAccount: true
# defaultValue: "- Sent from N1"
# }, {
# section: PreferencesSectionStore.Section.Signatures
# section: PreferencesUIStore.Section.Signatures
# type: "toggle"
# label: "Include on replies"
# defaultValue: true
@ -37,6 +37,6 @@ module.exports =
deactivate: ->
DraftStore.unregisterExtension(SignatureDraftExtension)
PreferencesSectionStore.unregisterPreferenceSection(@sectionConfig.sectionId)
PreferencesUIStore.unregisterPreferencesTab(@preferencesTab.sectionId)
serialize: -> @state

View file

@ -1,4 +1,4 @@
{PreferencesSectionStore,
{PreferencesUIStore,
Actions,
WorkspaceStore,
ComponentRegistry} = require 'nylas-exports'
@ -9,25 +9,22 @@ module.exports =
activate: ->
React = require 'react'
Cfg = PreferencesSectionStore.SectionConfig
Cfg = PreferencesUIStore.TabItem
PreferencesSectionStore.registerPreferenceSection(new Cfg {
icon: 'ic-settings-general.png'
sectionId: 'General'
PreferencesUIStore.registerPreferencesTab(new Cfg {
tabId: 'General'
displayName: 'General'
component: require './tabs/preferences-general'
order: 1
})
PreferencesSectionStore.registerPreferenceSection(new Cfg {
icon: 'ic-settings-accounts.png'
sectionId: 'Accounts'
PreferencesUIStore.registerPreferencesTab(new Cfg {
tabId: 'Accounts'
displayName: 'Accounts'
component: require './tabs/preferences-accounts'
order: 2
})
PreferencesSectionStore.registerPreferenceSection(new Cfg {
icon: 'ic-settings-shortcuts.png'
sectionId: 'Shortcuts'
PreferencesUIStore.registerPreferencesTab(new Cfg {
tabId: 'Shortcuts'
displayName: 'Shortcuts'
component: require './tabs/preferences-keymaps'
order: 3

View file

@ -1,7 +1,10 @@
React = require 'react'
_ = require 'underscore'
{RetinaImg, Flexbox, ConfigPropContainer, ScrollRegion} = require 'nylas-component-kit'
{PreferencesSectionStore} = require 'nylas-exports'
{RetinaImg,
Flexbox,
ConfigPropContainer,
ScrollRegion} = require 'nylas-component-kit'
{PreferencesUIStore} = require 'nylas-exports'
PreferencesSidebar = require './preferences-sidebar'
@ -14,27 +17,28 @@ class PreferencesRoot extends React.Component
componentDidMount: =>
@unlisteners = []
@unlisteners.push PreferencesSectionStore.listen =>
@unlisteners.push PreferencesUIStore.listen =>
@setState(@getStateFromStores())
componentWillUnmount: =>
unlisten() for unlisten in @unlisteners
getStateFromStores: =>
sections: PreferencesSectionStore.sections()
activeSectionId: PreferencesSectionStore.activeSectionId()
tabs: PreferencesUIStore.tabs()
selection: PreferencesUIStore.selection()
render: =>
section = _.find @state.sections, ({sectionId}) => sectionId is @state.activeSectionId
tabId = @state.selection.get('tabId')
tab = @state.tabs.find (s) => s.tabId is tabId
if section
bodyElement = <section.component />
if tab
bodyElement = <tab.component accountId={@state.selection.get('accountId')} />
else
bodyElement = <div>No Section Active</div>
bodyElement = <div></div>
<Flexbox direction="row" className="preferences-wrap">
<PreferencesSidebar sections={@state.sections}
activeSectionId={@state.activeSectionId} />
<PreferencesSidebar tabs={@state.tabs}
selection={@state.selection} />
<ScrollRegion className="preferences-content">
<ConfigPropContainer>{bodyElement}</ConfigPropContainer>
</ScrollRegion>

View file

@ -1,30 +1,96 @@
React = require 'react'
Immutable = require 'immutable'
_ = require 'underscore'
{RetinaImg, Flexbox} = require 'nylas-component-kit'
{Actions} = require 'nylas-exports'
classNames = require 'classnames'
{RetinaImg, Flexbox, DisclosureTriangle} = require 'nylas-component-kit'
{Actions, AccountStore} = require 'nylas-exports'
{PreferencesUIStore} = require 'nylas-exports'
class PreferencesSidebarItem extends React.Component
@displayName: 'PreferencesSidebarItem'
@propTypes:
accounts: React.PropTypes.array
selection: React.PropTypes.instanceOf(Immutable.Map).isRequired
tabItem: React.PropTypes.instanceOf(PreferencesUIStore.TabItem).isRequired
constructor: ->
@state =
collapsed: true
render: =>
{tabId, displayName, componentRequiresAccount} = @props.tabItem
subitems = @_renderSubitems()
subitemsComponent = <ul className="subitems">{subitems}</ul>
if @state.collapsed
subitemsComponent = false
classes = classNames
"item": true
"active": tabId is @props.selection.get('tabId')
"has-subitems": subitems isnt false
<div key={tabId} className={classes} onClick={@_onClick}>
<DisclosureTriangle
collapsed={@state.collapsed}
visible={subitems isnt false}
onToggleCollapsed={@_onClick} />
<div className="name">{displayName}</div>
{subitemsComponent}
</div>
_renderSubitems: =>
if @props.tabItem.componentRequiresAccount
@props.accounts.map (account) =>
classes = classNames
"subitem": true
"active": account.id is @props.selection.get('accountId')
<li className={classes} onClick={ (event) => @_onClickAccount(event, account.id)}>
{account.emailAddress}
</li>
else
return false
_onClick: =>
if @props.tabItem.componentRequiresAccount
@setState(collapsed: !@state.collapsed)
else
Actions.switchPreferencesTab(@props.tabItem.tabId)
_onClickAccount: (event, accountId) =>
Actions.switchPreferencesTab(@props.tabItem.tabId, {accountId})
event.stopPropagation()
class PreferencesSidebar extends React.Component
@displayName: 'PreferencesSidebar'
@propTypes:
sections: React.PropTypes.array.isRequired
activeSectionId: React.PropTypes.string
tabs: React.PropTypes.instanceOf(Immutable.List).isRequired
selection: React.PropTypes.instanceOf(Immutable.Map).isRequired
constructor: ->
@state =
accounts: AccountStore.items()
componentDidMount: =>
@unsub = AccountStore.listen @_onAccountsChanged
componentWillUnmount: =>
@unsub?()
render: =>
<div className="preferences-sidebar">
{ @props.sections.map ({sectionId, displayName}) =>
classname = "item"
classname += " active" if sectionId is @props.activeSectionId
<div key={sectionId}
className={classname}
onClick={ => Actions.switchPreferencesSection(sectionId) }>
<div className="name">
{displayName}
</div>
</div>
{ @props.tabs.map (tabItem) =>
<PreferencesSidebarItem
tabItem={tabItem}
accounts={@state.accounts}
selection={@props.selection} />
}
</div>
_onAccountsChanged: =>
@setState(accounts: AccountStore.items())
module.exports = PreferencesSidebar

View file

@ -7,6 +7,7 @@ class PreferencesMailRules extends React.Component
render: =>
<div className="container-mail-rules">
{@props.accountId}
</div>
module.exports = PreferencesMailRules

View file

@ -37,12 +37,48 @@
height: 100%;
.item {
padding: @padding-large-vertical @padding-large-horizontal;
border-bottom: 1px solid @border-color-divider;
cursor: default;
}
.item.active {
background: @background-primary;
&.active {
background: @background-primary;
&:not(.has-subitems) {
border-right:3px solid @component-active-color;
.name {
color: @component-active-color;
}
}
.subitem.active {
background: @background-primary;
border-right:3px solid @component-active-color;
color: @component-active-color;
}
}
.disclosure-triangle {
float: left;
padding-top:16px;
}
.name {
padding: @padding-large-vertical @padding-large-horizontal;
}
.subitems {
padding-left: 0;
box-shadow: inset 0 2px 1px rgba(0, 0, 0, 0.15);
background: darken(@background-secondary, 3%);
list-style-type: none;
font-size: 0.95em;
margin: 0;
.subitem {
padding: @padding-large-vertical * 0.8 @padding-large-horizontal + 4;
border-top: 1px solid @border-color-divider;
border-right:3px solid transparent;
}
}
}
}

View file

@ -29,6 +29,7 @@
"fstream": "0.1.24",
"grim": "1.5.0",
"guid": "0.0.10",
"immutable": "3.7.5",
"inflection": "^1.7",
"jasmine-json": "~0.0",
"jasmine-tagged": "^1.1.2",

View file

@ -137,7 +137,7 @@ class Actions
*Scope: Window*
###
@switchPreferencesSection: ActionScopeWindow
@switchPreferencesTab: ActionScopeWindow
###
Public: Clear the developer console for the current window.

View file

@ -1,80 +0,0 @@
_ = require 'underscore'
NylasStore = require 'nylas-store'
Actions = require '../actions'
class SectionConfig
constructor: (opts={}) ->
opts.order ?= Infinity
_.extend(@, opts)
nameOrUrl: ->
if _.isFunction(@icon)
icon = @icon()
else
icon = @icon
if icon.indexOf("nylas://") is 0
return {url: icon}
else
return {name: icon}
class PreferencesSectionStore extends NylasStore
constructor: ->
@_sectionConfigs = []
@_activeSectionId = null
@_accumulateAndTrigger ?= _.debounce(( => @trigger()), 20)
@Section = {}
@SectionConfig = SectionConfig
@listenTo Actions.switchPreferencesSection, (sectionName) =>
@_activeSectionId = sectionName
@trigger()
sections: =>
@_sectionConfigs
activeSectionId: =>
@_activeSectionId
###
Public: Register a new top-level section to preferences
- `sectionConfig` a `PreferencesSectionStore.SectionConfig` object
- `icon` A `nylas://` url or image name. Can be a function that
resolves to one of these
schema definitions on the PreferencesSectionStore.Section.MySectionId
- `sectionId` A unique name to access the Section by
- `displayName` The display name. This may go through i18n.
- `component` The Preference section's React Component.
Most Preference sections include an area where a {PreferencesForm} is
rendered. This is a type of {GeneratedForm} that uses the schema passed
into {PreferencesSectionStore::registerPreferences}
Note that `icon` gets passed into the `url` field of a {RetinaImg}. This
will, in an ideal case, expect to find the following images:
- my-icon-darwin@1x.png
- my-icon-darwin@2x.png
- my-icon-win32@1x.png
- my-icon-win32@2x.png
###
registerPreferenceSection: (sectionConfig) ->
@Section[sectionConfig.sectionId] = sectionConfig.sectionId
@_sectionConfigs.push(sectionConfig)
@_sectionConfigs = _.sortBy(@_sectionConfigs, "order")
if @_sectionConfigs.length is 1
@_activeSectionId = sectionConfig.sectionId
@_accumulateAndTrigger()
unregisterPreferenceSection: (sectionId) ->
delete @Section[sectionId]
@_sectionConfigs = _.reject @_sectionConfigs, (sectionConfig) ->
sectionConfig.sectionId is sectionId
@_accumulateAndTrigger()
module.exports = new PreferencesSectionStore()

View file

@ -0,0 +1,64 @@
_ = require 'underscore'
NylasStore = require 'nylas-store'
AccountStore = require './account-store'
Actions = require '../actions'
Immutable = require 'immutable'
class TabItem
constructor: (opts={}) ->
opts.order ?= Infinity
_.extend(@, opts)
class PreferencesUIStore extends NylasStore
constructor: ->
@_tabs = Immutable.List()
@_selection = Immutable.Map({
tabId: null
accountId: AccountStore.current()?.id
})
@_triggerDebounced ?= _.debounce(( => @trigger()), 20)
@listenTo AccountStore, =>
@_selection = @_selection.set('accountId', AccountStore.current()?.id)
@trigger()
@listenTo Actions.switchPreferencesTab, (tabId, options = {}) =>
@_selection = @_selection.set('tabId', tabId)
if options.accountId
@_selection = @_selection.set('accountId', options.accountId)
@trigger()
tabs: =>
@_tabs
selection: =>
@_selection
###
Public: Register a new top-level section to preferences
- `tabItem` a `PreferencesUIStore.TabItem` object
schema definitions on the PreferencesUIStore.Section.MySectionId
- `tabId` A unique name to access the Section by
- `displayName` The display name. This may go through i18n.
- `component` The Preference section's React Component.
Most Preference sections include an area where a {PreferencesForm} is
rendered. This is a type of {GeneratedForm} that uses the schema passed
into {PreferencesUIStore::registerPreferences}
###
registerPreferencesTab: (tabItem) ->
@_tabs = @_tabs.push(tabItem).sort (a, b) =>
a.order > b.order
if @_tabs.size is 1
@_selection = @_selection.set('tabId', tabItem.tabId)
@_triggerDebounced()
unregisterPreferencesTab: (tabId) ->
@_tabs = @_tabs.filter (s) => s.tabId isnt tabId
@_triggerDebounced()
module.exports = new PreferencesUIStore()
module.exports.TabItem = TabItem

View file

@ -114,7 +114,7 @@ class NylasExports
@require "FocusedContactsStore", 'flux/stores/focused-contacts-store'
@require "MessageBodyProcessor", 'flux/stores/message-body-processor'
@require "MessageStoreExtension", 'flux/stores/message-store-extension'
@require "PreferencesSectionStore", 'flux/stores/preferences-section-store'
@require "PreferencesUIStore", 'flux/stores/preferences-ui-store'
# React Components
@get "React", -> require 'react' # Our version of React for 3rd party use

View file

@ -0,0 +1,27 @@
@import "ui-variables";
@import "ui-mixins";
.disclosure-triangle {
flex-shrink: 0;
padding:7px;
padding-top:12px;
width:20px;
visibility: hidden;
div {
transform:rotate(90deg);
transition: transform 90ms linear;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 7px solid @text-color-very-subtle;
}
&.visible {
visibility: visible;
}
&.collapsed {
div {
transform:rotate(0deg);
}
}
}

View file

@ -20,6 +20,7 @@
@import "components/tokenizing-text-field";
@import "components/extra";
@import "components/list-tabular";
@import "components/disclosure-triangle";
@import "components/button-dropdown";
@import "components/scroll-region";
@import "components/spinner";