feat(sidebar): Hierarchical folders/labels in the sidebar, rendering perf

Summary:
Fix label sorting... apparently we just synced them in creation date order

Allow labels / folders to be nested using separators `.`, `/`, and `\`

Allow collapsing of nested labels in sidebar

Add overflow hidden to some core flexboxes, which dramatically reduces repaints because it knows columns will not overflow into other columns

Prevent scroll region contents from re-rendering all the time, not sure why this works

Add test for account sidebar store

Test Plan: Run new test of AccountSidebarStore

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2181
This commit is contained in:
Ben Gotow 2015-10-22 10:53:57 -07:00
parent 53137a9bfa
commit a83201ef7e
10 changed files with 416 additions and 48 deletions

View file

@ -16,6 +16,7 @@ class AccountSidebarMailViewItem extends React.Component
@propTypes:
select: React.PropTypes.bool
item: React.PropTypes.object.isRequired
mailView: React.PropTypes.object.isRequired
constructor: (@props) ->
@ -53,7 +54,7 @@ class AccountSidebarMailViewItem extends React.Component
{unread}
<div className="icon">{@_renderIcon()}</div>
<div className="name">{@props.mailView.name}</div>
<div className="name">{@props.item.name}</div>
</DropZone>
_renderIcon: ->

View file

@ -47,13 +47,43 @@ class AccountSidebarStore extends NylasStore
userCategories = CategoryStore.getUserCategories()
userCategoryItems = _.map(userCategories, @_sidebarItemForCategory)
# Compute hierarchy for userCategoryItems using known "path" separators
# NOTE: This code uses the fact that userCategoryItems is a sorted set, eg:
#
# Inbox
# Inbox.FolderA
# Inbox.FolderA.FolderB
# Inbox.FolderB
#
userCategoryItemsHierarchical = []
userCategoryItemsSeen = {}
for category in userCategories
# https://regex101.com/r/jK8cC2/1
itemKey = category.displayName.replace(/[./\\]/g, '/')
parent = null
parentComponents = itemKey.split('/')
for i in [parentComponents.length..1] by -1
parentKey = parentComponents[0...i].join('/')
parent = userCategoryItemsSeen[parentKey]
break if parent
if parent
itemDisplayName = category.displayName.substr(parentKey.length+1)
item = @_sidebarItemForCategory(category, itemDisplayName)
parent.children.push(item)
else
item = @_sidebarItemForCategory(category)
userCategoryItemsHierarchical.push(item)
userCategoryItemsSeen[itemKey] = item
# Our drafts are displayed via the `DraftListSidebarItem` which
# is loading into the `Drafts` Sheet.
standardCategories = CategoryStore.getStandardCategories()
standardCategories = _.reject standardCategories, (category) =>
category.name is "drafts"
standardCategoryItems = _.map(standardCategories, @_sidebarItemForCategory)
standardCategoryItems = _.map standardCategories, (cat) => @_sidebarItemForCategory(cat)
starredItem = @_sidebarItemForMailView('starred', MailViewFilter.forStarred())
# Find root views and add them to the bottom of the list (Drafts, etc.)
@ -80,16 +110,20 @@ class AccountSidebarStore extends NylasStore
@_sections.push
label: CategoryStore.categoryLabel()
items: userCategoryItems
items: userCategoryItemsHierarchical
@trigger()
_sidebarItemForMailView: (id, filter) =>
new WorkspaceStore.SidebarItem({id: id, name: filter.name, mailViewFilter: filter})
_sidebarItemForCategory: (category) =>
filter = MailViewFilter.forCategory(category)
@_sidebarItemForMailView(category.id, filter)
new WorkspaceStore.SidebarItem
id: id,
name: filter.name,
mailViewFilter: filter
_sidebarItemForCategory: (category, shortenedName) =>
new WorkspaceStore.SidebarItem
id: category.id,
name: shortenedName || category.displayName
mailViewFilter: MailViewFilter.forCategory(category)
module.exports = new AccountSidebarStore()

View file

@ -1,4 +1,5 @@
React = require 'react'
_ = require 'underscore'
{Actions, MailViewFilter, WorkspaceStore} = require("nylas-exports")
{ScrollRegion, Flexbox} = require("nylas-component-kit")
SidebarDividerItem = require("./account-sidebar-divider-item")
@ -7,6 +8,21 @@ AccountSidebarStore = require ("./account-sidebar-store")
AccountSidebarMailViewItem = require("./account-sidebar-mail-view-item")
{RetinaImg} = require 'nylas-component-kit'
class DisclosureTriangle extends React.Component
@displayName: 'DisclosureTriangle'
@propTypes:
collapsed: React.PropTypes.bool
visible: React.PropTypes.bool
onToggleCollapsed: React.PropTypes.func.isRequired
render: ->
classnames = "disclosure-triangle"
classnames += " visible" if @props.visible
classnames += " collapsed" if @props.collapsed
<div className={classnames} onClick={@props.onToggleCollapsed}><div></div></div>
class AccountSidebar extends React.Component
@displayName: 'AccountSidebar'
@ -17,6 +33,7 @@ class AccountSidebar extends React.Component
constructor: (@props) ->
@state = @_getStateFromStores()
@state.collapsed = {}
componentDidMount: =>
@unsubscribers = []
@ -36,41 +53,66 @@ class AccountSidebar extends React.Component
@state.sections.map (section) =>
<section key={section.label}>
<div className="heading">{section.label}</div>
{@_itemComponents(section)}
{@_itemComponents(section.items)}
</section>
_itemComponents: (section) =>
section.items.map (item) =>
unless item instanceof WorkspaceStore.SidebarItem
throw new Error("AccountSidebar:_itemComponents: sections contained an \
item which was not a SidebarItem")
_itemComponents: (items) =>
components = []
if item.component
Component = item.component
<Component
key={item.id}
item={item}
select={item.id is @state.selected?.id } />
items.forEach (item) =>
components.push(
<span key={item.id} className="item-container">
<DisclosureTriangle
collapsed={@state.collapsed[item.id]}
visible={item.children.length > 0}
onToggleCollapsed={ => @_onToggleCollapsed(item.id)}/>
{@_itemComponent(item)}
</span>
)
else if item.mailViewFilter
<AccountSidebarMailViewItem
key={item.id}
mailView={item.mailViewFilter}
select={item.mailViewFilter.isEqual(@state.selected)} />
if item.children.length and not @state.collapsed[item.id]
components.push(
<section key={"#{item.id}-children"}>
{@_itemComponents(item.children)}
</section>
)
else if item.sheet
<SidebarSheetItem
key={item.id}
item={item}
select={item.sheet.id is @state.selected?.id} />
components
else
throw new Error("AccountSidebar:_itemComponents: each item must have a \
custom component, or a sheet or mailViewFilter")
_itemComponent: (item) =>
unless item instanceof WorkspaceStore.SidebarItem
throw new Error("AccountSidebar:_itemComponents: sections contained an \
item which was not a SidebarItem")
if item.component
Component = item.component
<Component
item={item}
select={item.id is @state.selected?.id } />
else if item.mailViewFilter
<AccountSidebarMailViewItem
item={item}
mailView={item.mailViewFilter}
select={item.mailViewFilter.isEqual(@state.selected)} />
else if item.sheet
<SidebarSheetItem
item={item}
select={item.sheet.id is @state.selected?.id} />
else
throw new Error("AccountSidebar:_itemComponents: each item must have a \
custom component, or a sheet or mailViewFilter")
_onStoreChange: =>
@setState @_getStateFromStores()
_onToggleCollapsed: (itemId) =>
collapsed = _.clone(@state.collapsed)
collapsed[itemId] = !collapsed[itemId]
@setState({collapsed})
_getStateFromStores: =>
sections: AccountSidebarStore.sections()
selected: AccountSidebarStore.selected()

View file

@ -1,12 +1,254 @@
AccountSidebarStore = require '../lib/account-sidebar-store'
{Folder, WorkspaceStore, CategoryStore} = require 'nylas-exports'
describe "AccountSidebarStore", ->
xit "should update it's selected ID when the focusCategory action fires", ->
true
describe "sections", ->
it "should return the correct output", ->
atom.testOrganizationUnit = 'folder'
xit "should update when the DatabaseStore emits changes to tags", ->
true
spyOn(CategoryStore, 'getStandardCategories').andCallFake ->
return [
new Folder(displayName:'Inbox', clientId: '1', name: 'inbox')
new Folder(displayName:'Sent', clientId: '3', name: 'sent')
new Folder(displayName:'Important', clientId: '4', name: 'important')
]
xit "should update when the AccountStore emits", ->
true
spyOn(CategoryStore, 'getUserCategories').andCallFake ->
return [
new Folder(displayName:'A', clientId: 'a')
new Folder(displayName:'B', clientId: 'b')
new Folder(displayName:'A/B', clientId: 'a+b')
new Folder(displayName:'A.D', clientId: 'a+d')
new Folder(displayName:'A\\E', clientId: 'a+e')
new Folder(displayName:'B/C', clientId: 'b+c')
new Folder(displayName:'A/B/C', clientId: 'a+b+c')
new Folder(displayName:'A/B-C', clientId: 'a+b-c')
]
xit "should provide an array of sections to the sidebar view", ->
true
spyOn(WorkspaceStore, 'sidebarItems').andCallFake ->
return [
new WorkspaceStore.SidebarItem
component: {}
sheet: 'stub'
id: 'Drafts'
name: 'Drafts'
]
expected = [
{
label: 'Mailboxes',
items: [
{
id: '1',
name: 'Inbox',
mailViewFilter: {
name: 'Inbox',
category: {
client_id: '1',
name: 'inbox',
display_name: 'Inbox',
id: '1'
},
iconName: 'inbox.png'
},
children: [
]
},
{
id: 'starred',
name: 'Starred',
mailViewFilter: {
name: 'Starred',
iconName: 'starred.png'
},
children: [
]
},
{
id: '3',
name: 'Sent',
mailViewFilter: {
name: 'Sent',
category: {
client_id: '3',
name: 'sent',
display_name: 'Sent',
id: '3'
},
iconName: 'sent.png'
},
children: [
]
},
{
id: '4',
name: 'Important',
mailViewFilter: {
name: 'Important',
category: {
client_id: '4',
name: 'important',
display_name: 'Important',
id: '4'
},
iconName: 'important.png'
},
children: [
]
},
{
id: 'Drafts',
component: {
},
name: 'Drafts',
sheet: 'stub',
children: [
]
}
]
},
{
label: 'Folders',
items: [
{
id: 'a',
name: 'A',
mailViewFilter: {
name: 'A',
category: {
client_id: 'a',
display_name: 'A',
id: 'a'
},
iconName: 'folder.png'
},
children: [
{
id: 'a+b',
name: 'B',
mailViewFilter: {
name: 'A/B',
category: {
client_id: 'a+b',
display_name: 'A/B',
id: 'a+b'
},
iconName: 'folder.png'
},
children: [
{
id: 'a+b+c',
name: 'C',
mailViewFilter: {
name: 'A/B/C',
category: {
client_id: 'a+b+c',
display_name: 'A/B/C',
id: 'a+b+c'
},
iconName: 'folder.png'
},
children: [
]
}
]
},
{
id: 'a+d',
name: 'D',
mailViewFilter: {
name: 'A.D',
category: {
client_id: 'a+d',
display_name: 'A.D',
id: 'a+d'
},
iconName: 'folder.png'
},
children: [
]
},
{
id: 'a+e',
name: 'E',
mailViewFilter: {
name: 'A\\E',
category: {
client_id: 'a+e',
display_name: 'A\\E',
id: 'a+e'
},
iconName: 'folder.png'
},
children: [
]
},
{
id: 'a+b-c',
name: 'B-C',
mailViewFilter: {
name: 'A/B-C',
category: {
client_id: 'a+b-c',
display_name: 'A/B-C',
id: 'a+b-c'
},
iconName: 'folder.png'
},
children: [
]
}
]
},
{
id: 'b',
name: 'B',
mailViewFilter: {
name: 'B',
category: {
client_id: 'b',
display_name: 'B',
id: 'b'
},
iconName: 'folder.png'
},
children: [
{
id: 'b+c',
name: 'C',
mailViewFilter: {
name: 'B/C',
category: {
client_id: 'b+c',
display_name: 'B/C',
id: 'b+c'
},
iconName: 'folder.png'
},
children: [
]
}
]
}
]
}
]
AccountSidebarStore._refreshSections()
# Converting to JSON removes keys whose values are `undefined`,
# makes the output smaller and easier to visually compare.
output = JSON.parse(JSON.stringify(AccountSidebarStore.sections()))
expect(output).toEqual(expected)

View file

@ -8,7 +8,12 @@
background-color: @source-list-bg;
section {
padding-bottom: @padding-base-vertical;
margin-bottom: @padding-base-vertical;
section {
padding-left: @padding-base-horizontal * 1.3;
margin-bottom: 0;
}
}
.heading {
@ -20,15 +25,45 @@
letter-spacing: -0.2px;
}
.item-container {
display:flex;
align-items: center;
.disclosure-triangle {
flex-shrink: 0;
padding:7px;
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 {
color: @text-color-subtle;
flex: 1;
img.content-mask {
background-color: @text-color-subtle;
vertical-align: text-bottom;
}
font-size: @font-size-small;
font-weight: 400;
padding: 0 @spacing-standard;
padding-right: @spacing-standard;
line-height: @line-height-large * 1.1;
clear: both;
@ -42,7 +77,7 @@
float: left;
}
.name {
padding-left: @padding-small-horizontal;
padding-left: @padding-small-horizontal * 0.85;
position:relative;
top:1px;
overflow: hidden;

View file

@ -138,7 +138,17 @@ class CategoryStore extends NylasStore
# Compute user categories
userCategories = _.reject _.values(@_categoryCache), (cat) =>
cat.name in @StandardCategoryNames or cat.name in @HiddenCategoryNames
userCategories = _.sortBy(userCategories, 'displayName')
userCategories = userCategories.sort (catA, catB) =>
nameA = catA.displayName
nameB = catB.displayName
# Categories that begin with [, like [Mailbox]/For Later
# should appear at the bottom, because they're likely autogenerated.
nameA = "ZZZ"+nameA if nameA[0] is '['
nameB = "ZZZ"+nameB if nameB[0] is '['
nameA.localeCompare(nameB)
@_userCategories = _.compact(userCategories)
# Compute hidden categories

View file

@ -8,10 +8,11 @@ Location = {}
SidebarItems = {}
class WorkspaceSidebarItem
constructor: ({@id, @component, @icon, @name, @sheet, @mailViewFilter, @section}) ->
constructor: ({@id, @component, @icon, @name, @sheet, @mailViewFilter, @section, @children}) ->
if not @sheet and not @mailViewFilter and not @component
throw new Error("WorkspaceSidebarItem: You must provide either a sheet \
component, or a mailViewFilter for the sidebar item named #{@name}")
@children ||= []
###
Public: The WorkspaceStore manages Sheets and layout modes in the application.

View file

@ -34,7 +34,7 @@ class SheetContainer extends React.Component
sheetElements = @_sheetElements()
<Flexbox direction="column" className="layout-mode-#{@state.mode}">
<Flexbox direction="column" className="layout-mode-#{@state.mode}" style={overflow: 'hidden'}>
{@_toolbarContainerElement()}
<div name="Header" style={order:1, zIndex: 2}>

View file

@ -60,7 +60,7 @@ class Sheet extends React.Component
style={style}
className={"sheet mode-#{@state.mode}"}
data-id={@props.data.id}>
<Flexbox direction="row">
<Flexbox direction="row" style={overflow: 'hidden'}>
{@_columnFlexboxElements()}
</Flexbox>
</div>
@ -83,6 +83,7 @@ class Sheet extends React.Component
style =
height: '100%'
minWidth: minWidth
overflow: 'hidden'
if maxWidth < FLEX
style.width = maxWidth
else

View file

@ -51,7 +51,9 @@
height: 100%;
width: 100%;
overflow-y: scroll;
z-index: 1; // Important so that content does not repaint with container
}
.scroll-region-content-inner {
transform:translate3d(0,0,0);
}