feat(labels): add a new label/folder picker

Summary:
This is the initial diff for the label picker UI. This is all of the
functionality and none of the CSS.

Test Plan: todo

Reviewers: bengotow

Reviewed By: bengotow

Subscribers: sdw

Differential Revision: https://phab.nylas.com/D1761
This commit is contained in:
Evan Morikawa 2015-07-21 14:16:11 -07:00
parent a9d6795347
commit b89fea38c0
18 changed files with 336 additions and 50 deletions

View file

@ -0,0 +1,205 @@
_ = require 'underscore'
React = require 'react'
{Actions,
TaskQueue,
CategoryStore,
NamespaceStore,
ChangeLabelsTask,
ChangeFolderTask,
FocusedContentStore} = require 'nylas-exports'
{Menu,
Popover,
RetinaImg} = require 'nylas-component-kit'
# This changes the category on one or more threads.
#
# See internal_packages/thread-list/lib/thread-buttons.cjsx
# See internal_packages/message-list/lib/thread-tags-button.cjsx
# See internal_packages/message-list/lib/thread-archive-button.cjsx
# See internal_packages/message-list/lib/message-toolbar-items.cjsx
class CategoryPicker extends React.Component
@displayName: "CategoryPicker"
@containerRequired: false
@propTypes:
selection: React.PropTypes.object.isRequired
constructor: (@props) ->
@state = _.extend @_recalculateState(@props), searchValue: ""
componentDidMount: =>
@unsubscribers = []
@unsubscribers.push CategoryStore.listen @_onStoreChanged
@unsubscribers.push NamespaceStore.listen @_onStoreChanged
@unsubscribers.push FocusedContentStore.listen @_onStoreChanged
@_commandUnsubscriber = atom.commands.add 'body',
"application:change-category": @_onChangeCategory
# If the threads we're picking categories for change, (like when they
# get their categories updated), we expect our parents to pass us new
# props. We don't listen to the DatabaseStore ourselves.
componentWillReceiveProps: (nextProps) ->
@setState @_recalculateState(nextProps)
componentWillUnmount: =>
return unless @unsubscribers
unsubscribe() for unsubscribe in @unsubscribers
@_commandUnsubscriber.dispose()
render: =>
button = <button className="btn btn-toolbar" data-tooltip={@_tooltipLabel()}>
<RetinaImg name="toolbar-tags.png" mode={RetinaImg.Mode.ContentIsMask}/>
<RetinaImg name="toolbar-chevron.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
headerComponents = [
<input type="text"
tabIndex="1"
key="textfield"
className="search"
value={@state.searchValue}
onChange={@_onSearchValueChange}/>
]
<Popover className="tag-picker"
ref="popover"
onOpened={@_onPopoverOpened}
direction="down"
buttonComponent={button}>
<Menu ref="menu"
headerComponents={headerComponents}
footerComponents={[]}
items={@state.categoryData}
itemKey={ (categoryDatum) -> categoryDatum.id }
itemContent={@_itemContent}
itemChecked={ (categoryDatum) -> categoryDatum.usage > 0 }
onSelect={@_onSelectCategory}
/>
</Popover>
_tooltipLabel: ->
return "" unless @_namespace
if @_namespace.usesLabels()
return "Apply Labels"
else if @_namespace.usesFolders()
return "Move to Folder"
_onChangeCategory: =>
return unless @_threads().length > 0
@refs.popover.open()
_itemContent: (categoryDatum) =>
<span className="category-item">{categoryDatum.display_name}</span>
_onSelectCategory: (categoryDatum) =>
return unless @_threads().length > 0
return unless @_namespace
@refs.menu.setSelectedItem(null)
if @_namespace.usesLabels()
if categoryDatum.usage > 0
task = new ChangeLabelsTask
labelsToRemove: [categoryDatum.id]
threadIds: @_threadIds()
else
task = new ChangeLabelsTask
labelsToAdd: [categoryDatum.id]
threadIds: @_threadIds()
else if @_namespace.usesFolders()
task = new ChangeFolderTask
folderOrId: categoryDatum.id
threadIds: @_threadIds()
if @props.thread
Actions.moveThread(@props.thread, task)
else if @props.selection
Actions.moveThreads(@_threads(), task)
else throw new Error("Invalid organizationUnit")
TaskQueue.enqueue(task)
_onStoreChanged: =>
@setState @_recalculateState(@props)
_onSearchValueChange: (event) =>
@setState @_recalculateState(@props, searchValue: event.target.value)
_onPopoverOpened: =>
@setState @_recalculateState(@props, searchValue: "")
_recalculateState: (props=@props, {searchValue}={}) =>
searchValue = searchValue ? @state?.searchValue ? ""
if @_threads(props).length is 0
return {categoryData: [], searchValue}
@_namespace = NamespaceStore.current()
return unless @_namespace
categories = CategoryStore.getCategories()
usageCount = @_categoryUsageCount(props, categories)
categoryData = _.chain(categories)
.filter(@_isUserFacing)
.filter(_.partial(@_isInSearch, searchValue))
.map(_.partial(@_extendCategoryWithUsage, usageCount))
.value()
return {categoryData, searchValue}
_categoryUsageCount: (props, categories) =>
categoryUsageCount = {}
_.flatten(@_threads(props).map(@_threadCategories)).forEach (category) ->
categoryUsageCount[category.id] ?= 0
categoryUsageCount[category.id] += 1
return categoryUsageCount
_isInSearch: (searchValue, category) ->
searchTerm = searchValue.trim().toLowerCase()
return true if searchTerm.length is 0
catName = category.displayName.trim().toLowerCase()
wordIndices = []
# Where a non-word character is followed by a word character
# We don't use \b (word boundary) because we want to split on slashes
# and dashes and other non word things
re = /\W[\w\d]/g
while match = re.exec(catName) then wordIndices.push(match.index)
# To shift to the start of each word.
wordIndices = wordIndices.map (i) -> i += 1
# Always include the start
wordIndices.push(0)
return catName.indexOf(searchTerm) in wordIndices
_isUserFacing: (category) =>
hiddenCategories = ["inbox", "all", "archive", "drafts", "sent"]
return category.name not in hiddenCategories
_extendCategoryWithUsage: (usageCount, category, i, categories) ->
cat = category.toJSON()
usage = usageCount[cat.id] ? 0
cat.usage = usage
cat.totalUsage = categories.length
return cat
_threadCategories: (thread) =>
if @_namespace.usesLabels()
return (thread.labels ? [])
else if @_namespace.usesFolders()
return (thread.folders ? [])
else throw new Error("Invalid organizationUnit")
_threads: (props=@props) =>
if props.selection then return (props.selection.items() ? [])
else if props.thread then return [props.thread]
else return []
_threadIds: =>
@_threads().map (thread) -> thread.id
module.exports = CategoryPicker

View file

@ -0,0 +1,12 @@
CategoryPicker = require "./category-picker"
{ComponentRegistry,
WorkspaceStore} = require 'nylas-exports'
module.exports =
activate: (@state={}) ->
ComponentRegistry.register CategoryPicker,
roles: ['thread:BulkAction', 'message:Toolbar']
deactivate: ->
ComponentRegistry.unregister(CategoryPicker)

View file

@ -0,0 +1,11 @@
{
"name": "category-picker",
"version": "0.1.0",
"main": "./lib/main",
"description": "Label & Folder Picker",
"license": "Proprietary",
"private": true,
"engines": {
"atom": "*"
}
}

View file

@ -0,0 +1,4 @@
@import "ui-variables";
.category-picker {
}

View file

@ -110,11 +110,20 @@ useDraft = (draftAttributes={}) ->
@draft = new Message _.extend({draft: true, body: ""}, draftAttributes)
draft = @draft
proxy = draftStoreProxyStub(DRAFT_LOCAL_ID, @draft)
spyOn(DraftStore, "sessionForLocalId").andCallFake -> new Promise (resolve, reject) -> resolve(proxy)
spyOn(ComposerView.prototype, "componentWillMount").andCallFake ->
@_prepareForDraft(DRAFT_LOCAL_ID)
@_setupSession(proxy)
# Normally when sessionForLocalId resolves, it will call `_setupSession`
# and pass the new session proxy. However, in our faked
# `componentWillMount`, we manually call sessionForLocalId to make this
# part of the test synchronous. We need to make the `then` block of the
# sessionForLocalId do nothing so `_setupSession` is not called twice!
spyOn(DraftStore, "sessionForLocalId").andCallFake ->
then: ->
useFullDraft = ->
useDraft.call @,
from: [u1]

View file

@ -4,6 +4,9 @@ MessageToolbarItems = require "./message-toolbar-items"
WorkspaceStore} = require 'nylas-exports'
SidebarThreadParticipants = require "./sidebar-thread-participants"
ThreadStarButton = require './thread-star-button'
ThreadArchiveButton = require './thread-archive-button'
module.exports =
item: null # The DOM item the main React component renders into
@ -18,8 +21,17 @@ module.exports =
ComponentRegistry.register SidebarThreadParticipants,
location: WorkspaceStore.Location.MessageListSidebar
ComponentRegistry.register ThreadStarButton,
role: 'message:Toolbar'
ComponentRegistry.register ThreadArchiveButton,
role: 'message:Toolbar'
deactivate: ->
ComponentRegistry.unregister MessageList
ComponentRegistry.unregister ThreadStarButton
ComponentRegistry.unregister ThreadArchiveButton
ComponentRegistry.unregister MessageToolbarItems
ComponentRegistry.unregister SidebarThreadParticipants
serialize: -> @state

View file

@ -1,11 +1,15 @@
_ = require 'underscore'
React = require 'react'
classNames = require 'classnames'
{Actions, Utils, FocusedContentStore, WorkspaceStore} = require 'nylas-exports'
{RetinaImg, Popover, Menu} = require 'nylas-component-kit'
ThreadArchiveButton = require './thread-archive-button'
ThreadStarButton = require './thread-star-button'
{Actions,
WorkspaceStore,
FocusedContentStore} = require 'nylas-exports'
{Menu,
Popover,
RetinaImg,
InjectedComponentSet} = require 'nylas-component-kit'
class MessageToolbarItems extends React.Component
@displayName: "MessageToolbarItems"
@ -20,8 +24,8 @@ class MessageToolbarItems extends React.Component
"hidden": !@state.thread
<div className={classes}>
<ThreadArchiveButton />
<ThreadStarButton ref="starButton" thread={@state.thread} />
<InjectedComponentSet matching={role: "message:Toolbar"}
exposedProps={thread: @state.thread}/>
</div>
componentDidMount: =>

View file

@ -3,8 +3,8 @@ React = require 'react'
{Actions, DOMUtils} = require 'nylas-exports'
{RetinaImg} = require 'nylas-component-kit'
class ArchiveButton extends React.Component
@displayName: "ArchiveButton"
class ThreadArchiveButton extends React.Component
@displayName: "ThreadArchiveButton"
render: =>
<button className="btn btn-toolbar btn-archive"
@ -19,4 +19,4 @@ class ArchiveButton extends React.Component
e.stopPropagation()
module.exports = ArchiveButton
module.exports = ThreadArchiveButton

View file

@ -10,7 +10,7 @@ class StarButton extends React.Component
render: =>
selected = @props.thread? and @props.thread.starred
<button className="btn btn-toolbar"
<button className="btn btn-toolbar btn-star"
data-tooltip="Star"
onClick={@_onStarToggle}>
<RetinaImg name="toolbar-star.png" mode={RetinaImg.Mode.ContentIsMask} selected={selected} />

View file

@ -1,9 +1,9 @@
React = require "react/addons"
ReactTestUtils = React.addons.TestUtils
TestUtils = React.addons.TestUtils
{Thread, FocusedContentStore, Actions} = require "nylas-exports"
MessageToolbarItems = require '../lib/message-toolbar-items'
StarButton = require '../lib/thread-star-button'
test_thread = (new Thread).fromJSON({
"id" : "thread_12345"
@ -19,25 +19,19 @@ test_thread_starred = (new Thread).fromJSON({
describe "MessageToolbarItem starring", ->
it "stars a thread if the star button is clicked and thread is unstarred", ->
spyOn(FocusedContentStore, "focused").andCallFake ->
test_thread
spyOn(Actions, 'queueTask')
messageToolbarItems = TestUtils.renderIntoDocument(<MessageToolbarItems />)
starButton = TestUtils.renderIntoDocument(<StarButton thread={test_thread}/>)
starButton = React.findDOMNode(messageToolbarItems.refs.starButton)
TestUtils.Simulate.click starButton
TestUtils.Simulate.click React.findDOMNode(starButton)
expect(Actions.queueTask.mostRecentCall.args[0].objects).toEqual([test_thread])
expect(Actions.queueTask.mostRecentCall.args[0].newValues).toEqual(starred: true)
it "unstars a thread if the star button is clicked and thread is starred", ->
spyOn(FocusedContentStore, "focused").andCallFake ->
test_thread_starred
spyOn(Actions, 'queueTask')
messageToolbarItems = TestUtils.renderIntoDocument(<MessageToolbarItems />)
starButton = TestUtils.renderIntoDocument(<StarButton thread={test_thread_starred}/>)
starButton = React.findDOMNode(messageToolbarItems.refs.starButton)
TestUtils.Simulate.click starButton
TestUtils.Simulate.click React.findDOMNode(starButton)
expect(Actions.queueTask.mostRecentCall.args[0].objects).toEqual([test_thread_starred])
expect(Actions.queueTask.mostRecentCall.args[0].newValues).toEqual(starred: false)

View file

@ -25,8 +25,12 @@ class ThreadListStore extends NylasStore
@listenTo Actions.archiveAndPrevious, @_onArchiveAndPrev
@listenTo Actions.archiveAndNext, @_onArchiveAndNext
@listenTo Actions.archiveSelection, @_onArchiveSelection
@listenTo Actions.moveThreads, @_onMoveThreads
@listenTo Actions.archive, @_onArchive
@listenTo Actions.moveThread, @_onMoveThread
@listenTo Actions.toggleStarSelection, @_onToggleStarSelection
@listenTo Actions.toggleStarFocused, @_onToggleStarFocused
@ -143,13 +147,26 @@ class ThreadListStore extends NylasStore
_onArchive: ->
@_archiveAndShiftBy('auto')
_onArchiveSelection: ->
selectedThreads = @_view.selection.items()
selectedThreadIds = selectedThreads.map (thread) -> thread.id
_onArchiveAndPrev: ->
@_archiveAndShiftBy(-1)
_onArchiveAndNext: ->
@_archiveAndShiftBy(1)
_archiveAndShiftBy: (offset) ->
focused = FocusedContentStore.focused('thread')
return unless focused
task = ArchiveThreadHelper.getArchiveTask([focused])
@_moveAndShiftBy(offset, task)
_onMoveThread: (thread, task) ->
@_moveAndShiftBy('auto', task)
_onMoveThreads: (threads, task) ->
selectedThreadIds = threads.map (thread) -> thread.id
focusedId = FocusedContentStore.focusedId('thread')
keyboardId = FocusedContentStore.keyboardCursorId('thread')
task = ArchiveThreadHelper.getArchiveTask(selectedThreads)
task.waitForPerformLocal().then =>
if focusedId in selectedThreadIds
Actions.setFocus(collection: 'thread', item: null)
@ -159,13 +176,12 @@ class ThreadListStore extends NylasStore
Actions.queueTask(task)
@_view.selection.clear()
_onArchiveAndPrev: ->
@_archiveAndShiftBy(-1)
_onArchiveSelection: ->
selectedThreads = @_view.selection.items()
task = ArchiveThreadHelper.getArchiveTask(selectedThreads)
@_onMoveThreads(selectedThreads, task)
_onArchiveAndNext: ->
@_archiveAndShiftBy(1)
_archiveAndShiftBy: (offset) ->
_moveAndShiftBy: (offset, task) ->
layoutMode = WorkspaceStore.layoutMode()
focused = FocusedContentStore.focused('thread')
explicitOffset = if offset is "auto" then false else true
@ -196,7 +212,6 @@ class ThreadListStore extends NylasStore
nextFocus = null
# Archive the current thread
task = ArchiveThreadHelper.getArchiveTask([focused])
task.waitForPerformLocal().then ->
Actions.setFocus(collection: 'thread', item: nextFocus)
Actions.setCursorPosition(collection: 'thread', item: nextKeyboard)

View file

@ -51,9 +51,12 @@ class Tooltip extends React.Component
# This are public methods so they can be bound to the window event
# listeners.
onMouseOver: (e) =>
target = @_elementWithTooltip(e.target)
if target and DOMUtils.nodeIsVisible(target) then @_onTooltipEnter(target)
else if @state.display then @_hideTooltip()
elWithTooltip = @_elementWithTooltip(e.target)
if elWithTooltip and DOMUtils.nodeIsVisible(elWithTooltip)
if elWithTooltip isnt @_lastTarget
@_onTooltipEnter(elWithTooltip)
else
@_hideTooltip() if @state.display
onMouseOut: (e) =>
if @_elementWithTooltip(e.fromElement) and not @_elementWithTooltip(e.toElement)
@ -69,6 +72,7 @@ class Tooltip extends React.Component
return target
_onTooltipEnter: (target) =>
@_lastTarget = target
@_enteredTooltip = true
clearTimeout(@_showTimeout)
clearTimeout(@_showDelayTimeout)
@ -91,6 +95,8 @@ class Tooltip extends React.Component
_showTooltip: (target) =>
return unless DOMUtils.nodeIsVisible(target)
content = target.dataset.tooltip
return if (content ? "").trim().toLowerCase().length is 0
guessedWidth = @_guessWidth(content)
dim = target.getBoundingClientRect()
left = dim.left + dim.width / 2
@ -146,6 +152,7 @@ class Tooltip extends React.Component
document.getElementsByTagName('body')[0].getBoundingClientRect().height
_hideTooltip: =>
@_lastTarget = null
@setState
top: 0
left: 0

View file

@ -12,6 +12,7 @@
'R' : 'application:reply-all' # Nylas Mail
'a' : 'application:reply-all' # Gmail
'f' : 'application:forward' # Gmail
'l' : 'application:change-category' # Gmail
'escape': 'application:pop-sheet'
'u' : 'application:pop-sheet' # Gmail

View file

@ -7,6 +7,7 @@
'ctrl-r': 'application:reply' # Outlook
'ctrl-R': 'application:reply-all' # Outlook
'ctrl-F': 'application:forward' # Outlook
'ctrl-shift-v': 'application:change-category' # Outlook
# Linux application keys
'ctrl-q': 'application:quit'

View file

@ -7,6 +7,7 @@
'ctrl-r': 'application:reply' # Outlook
'ctrl-R': 'application:reply-all' # Outlook
'ctrl-F': 'application:forward' # Outlook
'ctrl-shift-v': 'application:change-category' # Outlook
# Windows application keys
'ctrl-q': 'application:quit'

View file

@ -78,7 +78,13 @@ class Popover extends React.Component
componentWillUnmount: =>
@subscriptions?.dispose()
componentDidUpdate: ->
if @_focusOnOpen
@_focusImportantElement()
@_focusOnOpen = false
open: =>
@_focusOnOpen = true
@setState
showing: true
if @props.onOpened?
@ -88,6 +94,19 @@ class Popover extends React.Component
@setState
showing: false
_focusImportantElement: =>
# Automatically focus the element inside us with the lowest tab index
node = React.findDOMNode(@refs.popover)
# _.sortBy ranks in ascending numerical order.
matches = _.sortBy node.querySelectorAll("[tabIndex], input"), (node) ->
if node.tabIndex > 0
return node.tabIndex
else if node.nodeName is "INPUT"
return 1000000
else return 1000001
matches[0]?.focus()
render: =>
wrappedButtonComponent = []
if @props.buttonComponent
@ -137,18 +156,6 @@ class Popover extends React.Component
_onClick: =>
if not @state.showing
@open()
setTimeout =>
# Automatically focus the element inside us with the lowest tab index
node = React.findDOMNode(@refs.popover)
# _.sortBy ranks in ascending numerical order.
matches = _.sortBy node.querySelectorAll("[tabIndex], input"), (node) ->
if node.tabIndex > 0
return node.tabIndex
else if node.nodeName is "INPUT"
return 1000000
else return 1000001
matches[0]?.focus()
else
@close()

View file

@ -322,6 +322,9 @@ class Actions
@toggleStarFocused: ActionScopeWindow
@deleteSelection: ActionScopeWindow
@moveThread: ActionScopeWindow
@moveThreads: ActionScopeWindow
###
Public: Updates the search query in the app's main search bar with the provided query text.

View file

@ -29,7 +29,7 @@ class ChangeFolderTask extends ChangeCategoryTask
folder: Promise.resolve(@folderOrId)
else
return Promise.props
folder: DatabaseStore.find(Folder, @folderOrId.id)
folder: DatabaseStore.find(Folder, @folderOrId)
# Called from super-class's `performRemote`
rollbackLocal: ->