From b89fea38c0528ae3b7ec651f712053775f7b677d Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Tue, 21 Jul 2015 14:16:11 -0700 Subject: [PATCH] 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 --- .../category-picker/lib/category-picker.cjsx | 205 ++++++++++++++++++ .../category-picker/lib/main.cjsx | 12 + .../category-picker/package.json | 11 + .../stylesheets/category-picker.less | 4 + .../composer/spec/composer-view-spec.cjsx | 11 +- internal_packages/message-list/lib/main.cjsx | 12 + .../lib/message-toolbar-items.cjsx | 16 +- .../lib/thread-archive-button.cjsx | 6 +- .../message-list/lib/thread-star-button.cjsx | 2 +- .../spec/message-toolbar-items-spec.cjsx | 18 +- .../thread-list/lib/thread-list-store.coffee | 37 +++- internal_packages/tooltip/lib/tooltip.cjsx | 13 +- keymaps/base.cson | 1 + keymaps/linux.cson | 1 + keymaps/win32.cson | 1 + src/components/popover.cjsx | 31 ++- src/flux/actions.coffee | 3 + src/flux/tasks/change-folder-task.coffee | 2 +- 18 files changed, 336 insertions(+), 50 deletions(-) create mode 100644 internal_packages/category-picker/lib/category-picker.cjsx create mode 100644 internal_packages/category-picker/lib/main.cjsx create mode 100755 internal_packages/category-picker/package.json create mode 100644 internal_packages/category-picker/stylesheets/category-picker.less diff --git a/internal_packages/category-picker/lib/category-picker.cjsx b/internal_packages/category-picker/lib/category-picker.cjsx new file mode 100644 index 000000000..03086d07e --- /dev/null +++ b/internal_packages/category-picker/lib/category-picker.cjsx @@ -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 = + + headerComponents = [ + + ] + + + categoryDatum.id } + itemContent={@_itemContent} + itemChecked={ (categoryDatum) -> categoryDatum.usage > 0 } + onSelect={@_onSelectCategory} + /> + + + _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) => + {categoryDatum.display_name} + + _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 diff --git a/internal_packages/category-picker/lib/main.cjsx b/internal_packages/category-picker/lib/main.cjsx new file mode 100644 index 000000000..ea4a37b9d --- /dev/null +++ b/internal_packages/category-picker/lib/main.cjsx @@ -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) diff --git a/internal_packages/category-picker/package.json b/internal_packages/category-picker/package.json new file mode 100755 index 000000000..257926a62 --- /dev/null +++ b/internal_packages/category-picker/package.json @@ -0,0 +1,11 @@ +{ + "name": "category-picker", + "version": "0.1.0", + "main": "./lib/main", + "description": "Label & Folder Picker", + "license": "Proprietary", + "private": true, + "engines": { + "atom": "*" + } +} diff --git a/internal_packages/category-picker/stylesheets/category-picker.less b/internal_packages/category-picker/stylesheets/category-picker.less new file mode 100644 index 000000000..1d0653412 --- /dev/null +++ b/internal_packages/category-picker/stylesheets/category-picker.less @@ -0,0 +1,4 @@ +@import "ui-variables"; + +.category-picker { +} diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index 4a451b423..32f12ba93 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -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] diff --git a/internal_packages/message-list/lib/main.cjsx b/internal_packages/message-list/lib/main.cjsx index 34dcff23f..53f302c05 100644 --- a/internal_packages/message-list/lib/main.cjsx +++ b/internal_packages/message-list/lib/main.cjsx @@ -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 diff --git a/internal_packages/message-list/lib/message-toolbar-items.cjsx b/internal_packages/message-list/lib/message-toolbar-items.cjsx index b61fcfbf4..40248c300 100644 --- a/internal_packages/message-list/lib/message-toolbar-items.cjsx +++ b/internal_packages/message-list/lib/message-toolbar-items.cjsx @@ -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
- - +
componentDidMount: => diff --git a/internal_packages/message-list/lib/thread-archive-button.cjsx b/internal_packages/message-list/lib/thread-archive-button.cjsx index 9dd31ac71..387797ed2 100644 --- a/internal_packages/message-list/lib/thread-archive-button.cjsx +++ b/internal_packages/message-list/lib/thread-archive-button.cjsx @@ -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: =>