mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 15:56:10 +08:00
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:
parent
53137a9bfa
commit
a83201ef7e
|
@ -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: ->
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue