From c6acca8ca3f77239a3f61d6e44429f1be4a41d2d Mon Sep 17 00:00:00 2001 From: Juan Tejada Date: Wed, 9 Mar 2016 10:01:18 -0800 Subject: [PATCH] remove(popover): Remove Popover in favor of FixedPopover Summary: - FixedPopover now correctly adjusts itself when overflowing outside window, in all directions - Updates styles - Adds specs - Remove Popover and popover.less, and refactor all code that used it in favor of the new FixedPopover Test Plan: Unit tests Reviewers: drew, evan, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2697 --- .../lib/category-picker-popover.jsx | 361 +++++++++++++++++ .../category-picker/lib/category-picker.cjsx | 376 +++--------------- .../spec/category-picker-spec.cjsx | 16 +- .../stylesheets/category-picker.less | 10 +- .../lib/template-picker.jsx | 68 ++-- .../stylesheets/message-templates.less | 16 +- .../composer-translate/docs/main.coffee | 145 ------- .../composer-translate/docs/main.html | 312 --------------- .../composer-translate/lib/main.jsx | 88 ++-- .../composer-translate/spec/main-spec.coffee | 12 - .../stylesheets/translate.less | 2 +- internal_packages/send-later/lib/main.js | 6 +- .../send-later/lib/send-later-button.jsx | 110 +++++ .../send-later/lib/send-later-popover.jsx | 127 ++---- .../spec/send-later-button-spec.jsx | 105 +++++ .../spec/send-later-popover-spec.jsx | 116 ++---- .../thread-list/lib/thread-list.cjsx | 13 +- internal_packages/thread-snooze/lib/main.js | 7 +- .../thread-snooze/lib/snooze-buttons.jsx | 134 +++++++ .../thread-snooze/lib/snooze-popover-body.jsx | 128 ------ .../thread-snooze/lib/snooze-popover.jsx | 126 +++++- .../lib/snooze-quick-action-button.jsx | 41 -- .../lib/snooze-toolbar-components.jsx | 69 ---- .../stylesheets/snooze-popover.less | 11 +- spec/components/fixed-popover-spec.jsx | 172 ++++++++ src/components/fixed-popover.jsx | 278 +++++++++---- src/components/menu.cjsx | 1 + src/flux/stores/popover-store.jsx | 9 +- src/global/nylas-component-kit.coffee | 10 +- static/components/fixed-popover.less | 49 ++- 30 files changed, 1476 insertions(+), 1442 deletions(-) create mode 100644 internal_packages/category-picker/lib/category-picker-popover.jsx delete mode 100644 internal_packages/composer-translate/docs/main.coffee delete mode 100644 internal_packages/composer-translate/docs/main.html delete mode 100644 internal_packages/composer-translate/spec/main-spec.coffee create mode 100644 internal_packages/send-later/lib/send-later-button.jsx create mode 100644 internal_packages/send-later/spec/send-later-button-spec.jsx create mode 100644 internal_packages/thread-snooze/lib/snooze-buttons.jsx delete mode 100644 internal_packages/thread-snooze/lib/snooze-popover-body.jsx delete mode 100644 internal_packages/thread-snooze/lib/snooze-quick-action-button.jsx delete mode 100644 internal_packages/thread-snooze/lib/snooze-toolbar-components.jsx create mode 100644 spec/components/fixed-popover-spec.jsx diff --git a/internal_packages/category-picker/lib/category-picker-popover.jsx b/internal_packages/category-picker/lib/category-picker-popover.jsx new file mode 100644 index 000000000..7b01a07fa --- /dev/null +++ b/internal_packages/category-picker/lib/category-picker-popover.jsx @@ -0,0 +1,361 @@ +import _ from 'underscore' +import React, {Component, PropTypes} from 'react' +import { + Menu, + RetinaImg, + LabelColorizer, +} from 'nylas-component-kit' +import { + Utils, + Actions, + TaskQueueStatusStore, + DatabaseStore, + TaskFactory, + Category, + SyncbackCategoryTask, + CategoryStore, + FocusedPerspectiveStore, +} from 'nylas-exports' +import {Categories} from 'nylas-observables' + + +export default class CategoryPickerPopover extends Component { + + static propTypes = { + threads: PropTypes.array.isRequired, + account: PropTypes.object.isRequired, + }; + + constructor(props) { + super(props) + this._categories = [] + this._standardCategories = [] + this._userCategories = [] + this.state = this._recalculateState(this.props, {searchValue: ''}) + } + + componentDidMount() { + this._registerObservables() + } + + componentWillReceiveProps(nextProps) { + this._registerObservables(nextProps) + this.setState(this._recalculateState(nextProps)) + } + + componentWillUnmount() { + this._unregisterObservables() + } + + _registerObservables = (props = this.props)=> { + this._unregisterObservables() + this.disposables = [ + Categories.forAccount(props.account).subscribe(this._onCategoriesChanged), + ] + }; + + _unregisterObservables = ()=> { + if (this.disposables) { + this.disposables.forEach(disp => disp.dispose()) + } + }; + + _isInSearch = (searchValue, category)=> { + return Utils.wordSearchRegExp(searchValue).test(category.displayName) + }; + + _isUserFacing = (allInInbox, category)=> { + const currentCategories = FocusedPerspectiveStore.current().categories() || [] + const currentCategoryIds = _.pluck(currentCategories, 'id') + const {account} = this.props + let hiddenCategories = [] + + if (account) { + if (account.usesLabels()) { + hiddenCategories = ["all", "drafts", "sent", "archive", "starred", "important", "N1-Snoozed"] + if (allInInbox) { + hiddenCategories.push("inbox") + } + if (category.divider) { + return false + } + } else if (account.usesFolders()) { + hiddenCategories = ["drafts", "sent", "N1-Snoozed"] + } + } + return ( + (!hiddenCategories.includes(category.name)) && + (!hiddenCategories.includes(category.displayName)) && + (!currentCategoryIds.includes(category.id)) + ) + }; + + _itemForCategory = ({usageCount, numThreads}, category)=> { + if (category.divider) { + return category + } + const item = category.toJSON() + item.category = category + item.backgroundColor = LabelColorizer.backgroundColorDark(category) + item.usage = usageCount[category.id] || 0 + item.numThreads = numThreads + return item + }; + + _allInInbox = (usageCount, numThreads)=> { + const {account} = this.props + const inbox = CategoryStore.getStandardCategory(account, "inbox") + if (!inbox) return false + return usageCount[inbox.id] === numThreads + }; + + _categoryUsageCount = (props) => { + const {threads} = props + const categoryUsageCount = {} + _.flatten(_.pluck(threads, 'categories')).forEach((category)=> { + categoryUsageCount[category.id] = categoryUsageCount[category.id] || 0 + categoryUsageCount[category.id] += 1 + }) + return categoryUsageCount; + }; + + _recalculateState = (props = this.props, {searchValue = (this.state.searchValue || "")} = {})=> { + const {account, threads} = props + + const numThreads = threads.length + let categories; + + if (numThreads === 0) { + return {categoryData: [], searchValue} + } + + if (account.usesLabels()) { + categories = this._categories + } else { + categories = this._standardCategories + .concat([{divider: true, id: "category-divider"}]) + .concat(this._userCategories) + } + + const usageCount = this._categoryUsageCount(props, categories) + const allInInbox = this._allInInbox(usageCount, numThreads) + const displayData = {usageCount, numThreads} + + const categoryData = _.chain(categories) + .filter(_.partial(this._isUserFacing, allInInbox)) + .filter(_.partial(this._isInSearch, searchValue)) + .map(_.partial(this._itemForCategory, displayData)) + .value() + + if (searchValue.length > 0) { + const newItemData = { + searchValue: searchValue, + newCategoryItem: true, + id: "category-create-new", + } + categoryData.push(newItemData) + } + return {categoryData, searchValue} + }; + + _onCategoriesChanged = (categories)=> { + this._categories = categories + this._standardCategories = categories.filter((cat) => cat.isStandardCategory()) + this._userCategories = categories.filter((cat) => cat.isUserCategory()) + this.setState(this._recalculateState()) + }; + + _onSelectCategory = (item)=> { + const {account, threads} = this.props + + if (threads.length === 0) return; + this.refs.menu.setSelectedItem(null) + + if (item.newCategoryItem) { + const category = new Category({ + displayName: this.state.searchValue, + accountId: account.id, + }) + const syncbackTask = new SyncbackCategoryTask({category}) + + TaskQueueStatusStore.waitForPerformRemote(syncbackTask).then(()=> { + DatabaseStore.findBy(category.constructor, {clientId: category.clientId}) + .then((cat) => { + const applyTask = TaskFactory.taskForApplyingCategory({ + threads: threads, + category: cat, + }) + Actions.queueTask(applyTask) + }) + }) + Actions.queueTask(syncbackTask) + } else if (item.usage === threads.length) { + const applyTask = TaskFactory.taskForRemovingCategory({ + threads: threads, + category: item.category, + }) + Actions.queueTask(applyTask) + } else { + const applyTask = TaskFactory.taskForApplyingCategory({ + threads: threads, + category: item.category, + }) + Actions.queueTask(applyTask) + } + Actions.closePopover() + }; + + _onSearchValueChange = (event)=> { + this.setState( + this._recalculateState(this.props, {searchValue: event.target.value}) + ) + }; + + _renderBoldedSearchResults = (item)=> { + const name = item.display_name + const searchTerm = (this.state.searchValue || "").trim() + + if (searchTerm.length === 0) return name; + + const re = Utils.wordSearchRegExp(searchTerm) + const parts = name.split(re).map((part) => { + // The wordSearchRegExp looks for a leading non-word character to + // deterine if it's a valid place to search. As such, we need to not + // include that leading character as part of our match. + if (re.test(part)) { + if (/\W/.test(part[0])) { + return {part[0]}{part.slice(1)} + } + return {part} + } + return part + }); + return {parts}; + }; + + _renderFolderIcon = (item)=> { + return ( + + ) + }; + + _renderCheckbox = (item)=> { + const styles = {} + let checkStatus; + styles.backgroundColor = item.backgroundColor + + if (item.usage === 0) { + checkStatus = + } else if (item.usage < item.numThreads) { + checkStatus = ( + this._onSelectCategory(item)}/> + ) + } else { + checkStatus = ( + this._onSelectCategory(item)}/> + ) + } + + return ( +
+ this._onSelectCategory(item)}/> + {checkStatus} +
+ ) + }; + + _renderCreateNewItem = ({searchValue})=> { + const {account} = this.props + let picName = '' + if (account) { + picName = account.usesLabels() ? 'tag' : 'folder' + } + + return ( +
+ +
+ “{searchValue}” (create new) +
+
+ ) + }; + + _renderItem = (item)=> { + if (item.divider) { + return + } else if (item.newCategoryItem) { + return this._renderCreateNewItem(item) + } + + const {account} = this.props + let icon; + + if (account) { + icon = account.usesLabels() ? this._renderCheckbox(item) : this._renderFolderIcon(item); + } else { + return + } + + return ( +
+ {icon} +
+ {this._renderBoldedSearchResults(item)} +
+
+ ) + }; + + render() { + const {account} = this.props + let placeholder = '' + if (account) { + placeholder = account.usesLabels() ? 'Label as' : 'Move to folder' + } + + const headerComponents = [ + , + ] + + return ( +
+ item.id} + itemContent={this._renderItem} + onSelect={this._onSelectCategory} + defaultSelectedIndex={this.state.searchValue === "" ? -1 : 0} + /> +
+ ) + } +} diff --git a/internal_packages/category-picker/lib/category-picker.cjsx b/internal_packages/category-picker/lib/category-picker.cjsx index a68775318..b91346bc8 100644 --- a/internal_packages/category-picker/lib/category-picker.cjsx +++ b/internal_packages/category-picker/lib/category-picker.cjsx @@ -1,347 +1,83 @@ _ = require 'underscore' React = require 'react' -{Utils, - Category, - Thread, - Actions, - TaskQueue, - TaskFactory, +{Actions, AccountStore, - CategoryStore, - DatabaseStore, - WorkspaceStore, - SyncbackCategoryTask, - TaskQueueStatusStore, - FocusedPerspectiveStore} = require 'nylas-exports' + WorkspaceStore} = require 'nylas-exports' -{Menu, - Popover, - RetinaImg, - KeyCommandsRegion, - LabelColorizer} = require 'nylas-component-kit' +{RetinaImg, + KeyCommandsRegion} = require 'nylas-component-kit' + +CategoryPickerPopover = require './category-picker-popover' -{Categories} = require 'nylas-observables' # This changes the category on one or more threads. class CategoryPicker extends React.Component @displayName: "CategoryPicker" + @containerRequired: false - constructor: (@props) -> - @_account = AccountStore.accountForItems(@_threads(@props)) - @_categories = [] - @_standardCategories = [] - @_userCategories = [] - @state = _.extend(@_recalculateState(@props), searchValue: "") + @propTypes: + thread: React.PropTypes.object + items: React.PropTypes.array @contextTypes: sheetDepth: React.PropTypes.number - componentDidMount: => - @_registerObservables() + constructor: (@props) -> + @_threads = @_getThreads(@props) + @_account = AccountStore.accountForItems(@_threads) # 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) -> - @_account = AccountStore.accountForItems(@_threads(nextProps)) - @_registerObservables() - @setState @_recalculateState(nextProps) + @_threads = @_getThreads(nextProps) + @_account = AccountStore.accountForItems(@_threads) - componentWillUnmount: => - @_unregisterObservables() - - _registerObservables: => - @_unregisterObservables() - @disposables = [ - Categories.forAccount(@_account).subscribe(@_onCategoriesChanged) - ] - - _unregisterObservables: => - return unless @disposables - disp.dispose() for disp in @disposables - - _keymapHandlers: -> - "application:change-category": @_onOpenCategoryPopover - - render: => - return if @state.disabled or not @_account? - btnClasses = "btn btn-toolbar" - btnClasses += " btn-disabled" if @state.disabled - - if @_account?.usesLabels() - img = "toolbar-tag.png" - tooltip = "Apply Labels" - placeholder = "Label as" - else if @_account?.usesFolders() - img = "toolbar-movetofolder.png" - tooltip = "Move to Folder" - placeholder = "Move to folder" - else - img = "" - tooltip = "" - placeholder = "" - - if @state.isPopoverOpen then tooltip = "" - - button = ( - - ) - - headerComponents = [ - - ] - - - - item.id } - itemContent={@_renderItemContent} - onSelect={@_onSelectCategory} - defaultSelectedIndex={if @state.searchValue is "" then -1 else 0} - /> - - - - _onOpenCategoryPopover: => - return unless @_threads().length > 0 - return unless @context.sheetDepth is WorkspaceStore.sheetStack().length - 1 - @refs.popover.open() - return - - _renderItemContent: (item) => - if item.divider - return - else if item.newCategoryItem - return @_renderCreateNewItem(item) - - if @_account?.usesLabels() - icon = @_renderCheckbox(item) - else if @_account?.usesFolders() - icon = @_renderFolderIcon(item) - else return - -
- {icon} -
- {@_renderBoldedSearchResults(item)} -
-
- - _renderCreateNewItem: ({searchValue, name}) => - if @_account?.usesLabels() - picName = "tag" - else if @_account?.usesFolders() - picName = "folder" - -
- -
- “{searchValue}” (create new) -
-
- - _renderCheckbox: (item) -> - styles = {} - styles.backgroundColor = item.backgroundColor - - if item.usage is 0 - checkStatus = - else if item.usage < item.numThreads - checkStatus = @_onSelectCategory(item)}/> - else - checkStatus = @_onSelectCategory(item)}/> - -
- @_onSelectCategory(item)}/> - {checkStatus} -
- - _renderFolderIcon: (item) -> - - - _renderBoldedSearchResults: (item) -> - name = item.display_name - searchTerm = (@state.searchValue ? "").trim() - - return name if searchTerm.length is 0 - - re = Utils.wordSearchRegExp(searchTerm) - parts = name.split(re).map (part) -> - # The wordSearchRegExp looks for a leading non-word character to - # deterine if it's a valid place to search. As such, we need to not - # include that leading character as part of our match. - if re.test(part) - if /\W/.test(part[0]) - return {part[0]}{part[1..-1]} - else - return {part} - else return part - return {parts} - - _onSelectCategory: (item) => - threads = @_threads() - - return unless threads.length > 0 - return unless @_account - @refs.menu.setSelectedItem(null) - - if item.newCategoryItem - category = new Category - displayName: @state.searchValue, - accountId: @_account.id - - syncbackTask = new SyncbackCategoryTask({category}) - TaskQueueStatusStore.waitForPerformRemote(syncbackTask).then => - DatabaseStore.findBy(category.constructor, clientId: category.clientId).then (category) => - applyTask = TaskFactory.taskForApplyingCategory - threads: threads - category: category - Actions.queueTask(applyTask) - Actions.queueTask(syncbackTask) - - else if item.usage is threads.length - applyTask = TaskFactory.taskForRemovingCategory - threads: threads - category: item.category - Actions.queueTask(applyTask) - - else - applyTask = TaskFactory.taskForApplyingCategory - threads: threads - category: item.category - Actions.queueTask(applyTask) - - @refs.popover.close() - - _onSearchValueChange: (event) => - @setState @_recalculateState(@props, searchValue: event.target.value) - - _onPopoverOpened: => - @setState @_recalculateState(@props, searchValue: "") - @setState isPopoverOpen: true - - _onPopoverClosed: => - @setState isPopoverOpen: false - - _onCategoriesChanged: (categories) => - @_categories = categories - @_standardCategories = categories.filter (cat) -> cat.isStandardCategory() - @_userCategories = categories.filter (cat) -> cat.isUserCategory() - @setState @_recalculateState() - - _recalculateState: (props = @props, {searchValue}={}) => - return {disabled: true} unless @_account - threads = @_threads(props) - - searchValue = searchValue ? @state?.searchValue ? "" - numThreads = threads.length - if numThreads is 0 - return {categoryData: [], searchValue} - - if @_account.usesLabels() - categories = @_categories - else - categories = @_standardCategories - .concat([{divider: true, id: "category-divider"}]) - .concat(@_userCategories) - - usageCount = @_categoryUsageCount(props, categories) - - allInInbox = @_allInInbox(usageCount, numThreads) - - displayData = {usageCount, numThreads} - - categoryData = _.chain(categories) - .filter(_.partial(@_isUserFacing, allInInbox)) - .filter(_.partial(@_isInSearch, searchValue)) - .map(_.partial(@_itemForCategory, displayData)) - .value() - - if searchValue.length > 0 - newItemData = - searchValue: searchValue - newCategoryItem: true - id: "category-create-new" - categoryData.push(newItemData) - - return {categoryData, searchValue, disabled: false} - - _categoryUsageCount: (props, categories) => - categoryUsageCount = {} - _.flatten(_.pluck(@_threads(props), 'categories')).forEach (category) -> - categoryUsageCount[category.id] ?= 0 - categoryUsageCount[category.id] += 1 - return categoryUsageCount - - _isInSearch: (searchValue, category) -> - return Utils.wordSearchRegExp(searchValue).test(category.displayName) - - _isUserFacing: (allInInbox, category) => - hiddenCategories = [] - currentCategories = FocusedPerspectiveStore.current().categories() ? [] - currentCategoryIds = _.pluck(currentCategories, 'id') - - if @_account?.usesLabels() - hiddenCategories = ["all", "drafts", "sent", "archive", "starred", "important", "N1-Snoozed"] - hiddenCategories.push("inbox") if allInInbox - return false if category.divider - else if @_account?.usesFolders() - hiddenCategories = ["drafts", "sent", "N1-Snoozed"] - - return (category.name not in hiddenCategories) and (category.displayName not in hiddenCategories) and (category.id not in currentCategoryIds) - - _allInInbox: (usageCount, numThreads) -> - return unless @_account? - inbox = CategoryStore.getStandardCategory(@_account, "inbox") - return false unless inbox - return usageCount[inbox.id] is numThreads - - _itemForCategory: ({usageCount, numThreads}, category) -> - return category if category.divider - - item = category.toJSON() - item.category = category - item.backgroundColor = LabelColorizer.backgroundColorDark(category) - item.usage = usageCount[category.id] ? 0 - item.numThreads = numThreads - item - - _threads: (props = @props) => + _getThreads: (props = @props) => if props.items then return (props.items ? []) else if props.thread then return [props.thread] else return [] + _keymapHandlers: -> + "application:change-category": @_onOpenCategoryPopover + + _onOpenCategoryPopover: => + return unless @_threads.length > 0 + return unless @context.sheetDepth is WorkspaceStore.sheetStack().length - 1 + buttonRect = React.findDOMNode(@refs.button).getBoundingClientRect() + Actions.openPopover( + , + {originRect: buttonRect, direction: 'down'} + ) + return + + render: => + return unless @_account + btnClasses = "btn btn-toolbar btn-category-picker" + img = "" + tooltip = "" + if @_account.usesLabels() + img = "toolbar-tag.png" + tooltip = "Apply Labels" + else + img = "toolbar-movetofolder.png" + tooltip = "Move to Folder" + + return ( + + + + ) + + module.exports = CategoryPicker diff --git a/internal_packages/category-picker/spec/category-picker-spec.cjsx b/internal_packages/category-picker/spec/category-picker-spec.cjsx index b74e1d18a..c55ba6e8d 100644 --- a/internal_packages/category-picker/spec/category-picker-spec.cjsx +++ b/internal_packages/category-picker/spec/category-picker-spec.cjsx @@ -1,8 +1,7 @@ _ = require 'underscore' React = require "react/addons" ReactTestUtils = React.addons.TestUtils -CategoryPicker = require '../lib/category-picker' -{Popover} = require 'nylas-component-kit' +CategoryPickerPopover = require '../lib/category-picker-popover' {Utils, Category, @@ -20,7 +19,7 @@ CategoryPicker = require '../lib/category-picker' {Categories} = require 'nylas-observables' -describe 'CategoryPicker', -> +describe 'CategoryPickerPopover', -> beforeEach -> CategoryStore._categoryCache = {} @@ -44,6 +43,7 @@ describe 'CategoryPicker', -> ) spyOn(CategoryStore, "getStandardCategory").andReturn @inboxCategory spyOn(AccountStore, "accountForItems").andReturn @account + spyOn(Actions, "closePopover") # By default we're going to set to "inbox". This has implications for # what categories get filtered out of the list. @@ -55,12 +55,9 @@ describe 'CategoryPicker', -> @testThread = new Thread(id: 't1', subject: "fake", accountId: TEST_ACCOUNT_ID, categories: []) @picker = ReactTestUtils.renderIntoDocument( - + ) - @popover = ReactTestUtils.findRenderedComponentWithType @picker, Popover - @popover.open() - describe 'when using labels', -> beforeEach -> setupFor.call(@, "label") @@ -71,7 +68,7 @@ describe 'CategoryPicker', -> @testThread = new Thread(id: 't1', subject: "fake", accountId: TEST_ACCOUNT_ID, categories: []) @picker = ReactTestUtils.renderIntoDocument( - + ) it 'lists the desired categories', -> @@ -131,9 +128,8 @@ describe 'CategoryPicker', -> spyOn(Actions, "queueTask") it "closes the popover", -> - spyOn(@popover, "close") @picker._onSelectCategory { usage: 0, category: "asdf" } - expect(@popover.close).toHaveBeenCalled() + expect(Actions.closePopover).toHaveBeenCalled() describe "when selecting a category currently on all the selected items", -> it "fires a task to remove the category", -> diff --git a/internal_packages/category-picker/stylesheets/category-picker.less b/internal_packages/category-picker/stylesheets/category-picker.less index b234e572c..d5d6924b4 100644 --- a/internal_packages/category-picker/stylesheets/category-picker.less +++ b/internal_packages/category-picker/stylesheets/category-picker.less @@ -3,16 +3,16 @@ @popover-width: 250px; body.platform-win32 { - .category-picker { + .category-picker-popover { margin-left: 0; } } -.category-picker { - margin-left: @spacing-three-quarters; - .popover { - } +.sheet-toolbar .btn-category-picker:only-of-type { + margin-right: 0; +} +.category-picker-popover { .menu { background: @background-secondary; width: @popover-width; diff --git a/internal_packages/composer-templates/lib/template-picker.jsx b/internal_packages/composer-templates/lib/template-picker.jsx index 193cc78b4..3617ac2ce 100644 --- a/internal_packages/composer-templates/lib/template-picker.jsx +++ b/internal_packages/composer-templates/lib/template-picker.jsx @@ -1,5 +1,5 @@ import {Actions, React} from 'nylas-exports'; -import {Popover, Menu, RetinaImg} from 'nylas-component-kit'; +import {Menu, RetinaImg} from 'nylas-component-kit'; import TemplateStore from './template-store'; class TemplatePicker extends React.Component { @@ -51,33 +51,34 @@ class TemplatePicker extends React.Component { _onChooseTemplate = (template) => { Actions.insertTemplateId({templateId: template.id, draftClientId: this.props.draftClientId}); - return this.refs.popover.close(); + Actions.closePopover() }; _onManageTemplates = () => { - return Actions.showTemplates(); + Actions.showTemplates(); }; _onNewTemplate = () => { - return Actions.createTemplate({draftClientId: this.props.draftClientId}); + Actions.createTemplate({draftClientId: this.props.draftClientId}); }; - render() { - const button = ( - - ); + _onClickButton = ()=> { + const buttonRect = React.findDOMNode(this).getBoundingClientRect() + Actions.openPopover( + this._renderPopover(), + {originRect: buttonRect, direction: 'up'} + ) + }; + _renderPopover() { const headerComponents = [ - , + , ]; const footerComponents = [ @@ -86,19 +87,30 @@ class TemplatePicker extends React.Component { ]; return ( - - item.id } - itemContent={ (item)=> item.name } - onSelect={this._onChooseTemplate.bind(this)} - /> - + item.id } + itemContent={ (item)=> item.name } + onSelect={this._onChooseTemplate.bind(this)} + /> ); } + render() { + return ( + + ); + } } export default TemplatePicker; diff --git a/internal_packages/composer-templates/stylesheets/message-templates.less b/internal_packages/composer-templates/stylesheets/message-templates.less index b40c7fc37..39b8d4d6d 100755 --- a/internal_packages/composer-templates/stylesheets/message-templates.less +++ b/internal_packages/composer-templates/stylesheets/message-templates.less @@ -4,15 +4,13 @@ @code-bg-color: #fcf4db; .template-picker { - .menu { - .content-container { - height:150px; - width: 210px; - overflow-y:scroll; - } - .footer-container { - border-top: 1px solid @border-secondary-bg; - } + .content-container { + height:150px; + width: 210px; + overflow-y:scroll; + } + .footer-container { + border-top: 1px solid @border-secondary-bg; } } diff --git a/internal_packages/composer-translate/docs/main.coffee b/internal_packages/composer-translate/docs/main.coffee deleted file mode 100644 index 22017ac56..000000000 --- a/internal_packages/composer-translate/docs/main.coffee +++ /dev/null @@ -1,145 +0,0 @@ -# # Translation Plugin -# Last Revised: April 23, 2015 by Ben Gotow -# -# TranslateButton is a simple React component that allows you to select -# a language from a popup menu and translates draft text into that language. -# - -request = require 'request' - -{Utils, - React, - ComponentRegistry, - DraftStore} = require 'nylas-exports' -{Menu, - RetinaImg, - Popover} = require 'nylas-component-kit' - -YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate' -YandexTranslationKey = 'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e' -YandexLanguages = - 'English': 'en' - 'Spanish': 'es' - 'Russian': 'ru' - 'Chinese': 'zh' - 'French': 'fr' - 'German': 'de' - 'Italian': 'it' - 'Japanese': 'ja' - 'Portuguese': 'pt' - 'Korean': 'ko' - -class TranslateButton extends React.Component - - # Adding a `displayName` makes debugging React easier - @displayName: 'TranslateButton' - - # Since our button is being injected into the Composer Footer, - # we receive the local id of the current draft as a `prop` (a read-only - # property). Since our code depends on this prop, we mark it as a requirement. - # - @propTypes: - draftLocalId: React.PropTypes.string.isRequired - - # The `render` method returns a React Virtual DOM element. This code looks - # like HTML, but don't be fooled. The CJSX preprocessor converts - # - # `Hello!` - # - # into Javascript objects which describe the HTML you want: - # - # `React.createElement('a', {href: 'http://facebook.github.io/react/'}, 'Hello!')` - # - # We're rendering a `Popover` with a `Menu` inside. These components are part - # of N1's standard `nylas-component-kit` library, and make it easy to build - # interfaces that match the rest of N1's UI. - # - render: => - React.createElement(Popover, {"ref": "popover", \ - "className": "translate-language-picker pull-right", \ - "buttonComponent": (@_renderButton())}, - React.createElement(Menu, {"items": ( Object.keys(YandexLanguages) ), \ - "itemKey": ( (item) -> item ), \ - "itemContent": ( (item) -> item ), \ - "onSelect": (@_onTranslate) - }) - ) - - # Helper method to render the button that will activate the popover. Using the - # `RetinaImg` component makes it easy to display an image from our package. - # `RetinaImg` will automatically chose the best image format for our display. - # - _renderButton: => - React.createElement("button", {"className": "btn btn-toolbar"}, """ - Translate -""", React.createElement(RetinaImg, {"name": "toolbar-chevron.png"}) - ) - - _onTranslate: (lang) => - @refs.popover.close() - - # Obtain the session for the current draft. The draft session provides us - # the draft object and also manages saving changes to the local cache and - # Nilas API as multiple parts of the application touch the draft. - # - session = DraftStore.sessionForLocalId(@props.draftLocalId) - session.prepare().then => - body = session.draft().body - bodyQuoteStart = Utils.quotedTextIndex(body) - - # Identify the text we want to translate. We need to make sure we - # don't translate quoted text. - if bodyQuoteStart > 0 - text = body.substr(0, bodyQuoteStart) - else - text = body - - query = - key: YandexTranslationKey - lang: YandexLanguages[lang] - text: text - format: 'html' - - # Use Node's `request` library to perform the translation using the Yandex API. - request {url: YandexTranslationURL, qs: query}, (error, resp, data) => - return @_onError(error) unless resp.statusCode is 200 - json = JSON.parse(data) - - # The new text of the draft is our translated response, plus any quoted text - # that we didn't process. - translated = json.text.join('') - translated += body.substr(bodyQuoteStart) if bodyQuoteStart > 0 - - # To update the draft, we add the new body to it's session. The session object - # automatically marshalls changes to the database and ensures that others accessing - # the same draft are notified of changes. - session.changes.add(body: translated) - session.changes.commit() - - _onError: (error) => - @refs.popover.close() - dialog = require('remote').require('dialog') - dialog.showErrorBox('Geolocation Failed', error.toString()) - - -module.exports = - # Activate is called when the package is loaded. If your package previously - # saved state using `serialize` it is provided. - # - activate: (@state) -> - ComponentRegistry.register TranslateButton, - role: 'Composer:ActionButton' - - # Serialize is called when your package is about to be unmounted. - # You can return a state object that will be passed back to your package - # when it is re-activated. - # - serialize: -> - - # This **optional** method is called when the window is shutting down, - # or when your package is being updated or disabled. If your package is - # watching any files, holding external resources, providing commands or - # subscribing to events, release them here. - # - deactivate: -> - ComponentRegistry.unregister(TranslateButton) diff --git a/internal_packages/composer-translate/docs/main.html b/internal_packages/composer-translate/docs/main.html deleted file mode 100644 index 4de2ef3a7..000000000 --- a/internal_packages/composer-translate/docs/main.html +++ /dev/null @@ -1,312 +0,0 @@ - - - - - Translation Plugin - - - - - -
-
- -
    - - - -
  • -
    - -
    - -
    -

    Translation Plugin

    -

    Last Revised: April 23, 2015 by Ben Gotow

    -

    TranslateButton is a simple React component that allows you to select -a language from a popup menu and translates draft text into that language.

    - -
    - -
    -request = require 'request'
    -
    -{Utils,
    - React,
    - ComponentRegistry,
    - DraftStore} = require 'nylas-exports'
    -{Menu,
    - RetinaImg,
    - Popover} = require 'nylas-component-kit'
    -
    -YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate'
    -YandexTranslationKey = 'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e'
    -YandexLanguages =
    -  'English': 'en'
    -  'Spanish': 'es'
    -  'Russian': 'ru'
    -  'Chinese': 'zh'
    -  'French': 'fr'
    -  'German': 'de'
    -  'Italian': 'it'
    -  'Japanese': 'ja'
    -  'Portuguese': 'pt'
    -  'Korean': 'ko'
    -
    -class TranslateButton extends React.Component
    - -
  • - - -
  • -
    - -
    - -
    -

    Adding a displayName makes debugging React easier

    - -
    - -
      @displayName: 'TranslateButton'
    - -
  • - - -
  • -
    - -
    - -
    -

    Since our button is being injected into the Composer Footer, -we receive the local id of the current draft as a prop (a read-only -property). Since our code depends on this prop, we mark it as a requirement.

    - -
    - -
      @propTypes:
    -    draftLocalId: React.PropTypes.string.isRequired
    - -
  • - - -
  • -
    - -
    - -
    -

    The render method returns a React Virtual DOM element. This code looks -like HTML, but don’t be fooled. The CJSX preprocessor converts

    -

    <a href="http://facebook.github.io/react/">Hello!</a>

    -

    into Javascript objects which describe the HTML you want:

    -

    React.createElement('a', {href: 'http://facebook.github.io/react/'}, 'Hello!')

    -

    We’re rendering a Popover with a Menu inside. These components are part -of N1’s standard nylas-component-kit library, and make it easy to build -interfaces that match the rest of N1’s UI.

    - -
    - -
      render: =>
    -    React.createElement(Popover, {"ref": "popover",  \
    -             "className": "translate-language-picker pull-right",  \
    -             "buttonComponent": (@_renderButton())},
    -      React.createElement(Menu, {"items": ( Object.keys(YandexLanguages) ),  \
    -            "itemKey": ( (item) -> item ),  \
    -            "itemContent": ( (item) -> item ),  \
    -            "onSelect": (@_onTranslate)
    -            })
    -    )
    -
    -
    - -
  • - - -
  • -
    - -
    - -
    -

    Helper method to render the button that will activate the popover. Using the -RetinaImg component makes it easy to display an image from our package. -RetinaImg will automatically chose the best image format for our display.

    - -
    - -
      _renderButton: =>
    -    React.createElement("button", {"className": "btn btn-toolbar"}, """
    -      Translate
    -""", React.createElement(RetinaImg, {"name": "toolbar-chevron.png"})
    -    )
    -
    -  _onTranslate: (lang) =>
    -    @refs.popover.close()
    - -
  • - - -
  • -
    - -
    - -
    -

    Obtain the session for the current draft. The draft session provides us -the draft object and also manages saving changes to the local cache and -Nilas API as multiple parts of the application touch the draft.

    - -
    - -
        session = DraftStore.sessionForLocalId(@props.draftLocalId)
    -    session.prepare().then =>
    -      body = session.draft().body
    -      bodyQuoteStart = Utils.quotedTextIndex(body)
    - -
  • - - -
  • -
    - -
    - -
    -

    Identify the text we want to translate. We need to make sure we -don’t translate quoted text.

    - -
    - -
          if bodyQuoteStart > 0
    -        text = body.substr(0, bodyQuoteStart)
    -      else
    -        text = body
    -
    -      query =
    -        key: YandexTranslationKey
    -        lang: YandexLanguages[lang]
    -        text: text
    -        format: 'html'
    - -
  • - - -
  • -
    - -
    - -
    -

    Use Node’s request library to perform the translation using the Yandex API.

    - -
    - -
          request {url: YandexTranslationURL, qs: query}, (error, resp, data) =>
    -        return @_onError(error) unless resp.statusCode is 200
    -        json = JSON.parse(data)
    - -
  • - - -
  • -
    - -
    - -
    -

    The new text of the draft is our translated response, plus any quoted text -that we didn’t process.

    - -
    - -
            translated = json.text.join('')
    -        translated += body.substr(bodyQuoteStart) if bodyQuoteStart > 0
    - -
  • - - -
  • -
    - -
    - -
    -

    To update the draft, we add the new body to it’s session. The session object -automatically marshalls changes to the database and ensures that others accessing -the same draft are notified of changes.

    - -
    - -
            session.changes.add(body: translated)
    -        session.changes.commit()
    -
    -  _onError: (error) =>
    -    @refs.popover.close()
    -    dialog = require('remote').require('dialog')
    -    dialog.showErrorBox('Geolocation Failed', error.toString())
    -
    -
    -module.exports =
    - -
  • - - -
  • -
    - -
    - -
    -

    Activate is called when the package is loaded. If your package previously -saved state using serialize it is provided.

    - -
    - -
      activate: (@state) ->
    -    ComponentRegistry.register TranslateButton,
    -      role: 'Composer:ActionButton'
    - -
  • - - -
  • -
    - -
    - -
    -

    Serialize is called when your package is about to be unmounted. -You can return a state object that will be passed back to your package -when it is re-activated.

    - -
    - -
      serialize: ->
    - -
  • - - -
  • -
    - -
    - -
    -

    This optional method is called when the window is shutting down, -or when your package is being updated or disabled. If your package is -watching any files, holding external resources, providing commands or -subscribing to events, release them here.

    - -
    - -
      deactivate: ->
    -    ComponentRegistry.unregister(TranslateButton)
    - -
  • - -
-
- - diff --git a/internal_packages/composer-translate/lib/main.jsx b/internal_packages/composer-translate/lib/main.jsx index 0e536302c..16108a621 100644 --- a/internal_packages/composer-translate/lib/main.jsx +++ b/internal_packages/composer-translate/lib/main.jsx @@ -11,12 +11,12 @@ import { ComponentRegistry, QuotedHTMLTransformer, DraftStore, + Actions, } from 'nylas-exports'; import { Menu, RetinaImg, - Popover, } from 'nylas-component-kit'; const YandexTranslationURL = 'https://translate.yandex.net/api/v1.5/tr.json/translate'; @@ -37,23 +37,23 @@ const YandexLanguages = { class TranslateButton extends React.Component { // Adding a `displayName` makes debugging React easier - static displayName = 'TranslateButton' + static displayName = 'TranslateButton'; // Since our button is being injected into the Composer Footer, // we receive the local id of the current draft as a `prop` (a read-only // property). Since our code depends on this prop, we mark it as a requirement. static propTypes = { draftClientId: React.PropTypes.string.isRequired, - } + }; _onError(error) { - this.refs.popover.close(); + Actions.closePopover() const dialog = require('remote').require('dialog'); dialog.showErrorBox('Language Conversion Failed', error.toString()); } _onTranslate = (lang) => { - this.refs.popover.close(); + Actions.closePopover() // Obtain the session for the current draft. The draft session provides us // the draft object and also manages saving changes to the local cache and @@ -90,15 +90,52 @@ class TranslateButton extends React.Component { session.changes.commit(); }); }); + }; + + _onClickTranslateButton = ()=> { + const buttonRect = React.findDOMNode(this).getBoundingClientRect() + Actions.openPopover( + this._renderPopover(), + {originRect: buttonRect, direction: 'up'} + ) + }; + + // Helper method that will render the contents of our popover. + _renderPopover() { + const headerComponents = [ + Translate:, + ]; + return ( + item } + itemContent={ (item)=> item } + headerComponents={headerComponents} + defaultSelectedIndex={-1} + onSelect={this._onTranslate} + /> + ) } - // Helper method to render the button that will activate the popover. Using the - // `RetinaImg` component makes it easy to display an image from our package. - // `RetinaImg` will automatically chose the best image format for our display. - _renderButton() { + // The `render` method returns a React Virtual DOM element. This code looks + // like HTML, but don't be fooled. The JSX preprocessor converts + // `Hello!` + // into Javascript objects which describe the HTML you want: + // `React.createElement('a', {href: 'http://facebook.github.io/react/'}, 'Hello!')` + + // We're rendering a `Menu` inside our Popover, and using a `RetinaImg` for the button. + // These components are part of N1's standard `nylas-component-kit` library, + // and make it easy to build interfaces that match the rest of N1's UI. + // + // For example, using the `RetinaImg` component makes it easy to display an + // image from our package. `RetinaImg` will automatically chose the best image + // format for our display. + render() { return ( - ) - } - - // The `render` method returns a React Virtual DOM element. This code looks - // like HTML, but don't be fooled. The CJSX preprocessor converts - - // `Hello!` - - // into Javascript objects which describe the HTML you want: - - // `React.createElement('a', {href: 'http://facebook.github.io/react/'}, 'Hello!')` - - // We're rendering a `Popover` with a `Menu` inside. These components are part - // of N1's standard `nylas-component-kit` library, and make it easy to build - // interfaces that match the rest of N1's UI. - render() { - const headerComponents = [ - Translate:, - ]; - return ( - - item } - itemContent={ (item)=> item } - headerComponents={headerComponents} - defaultSelectedIndex={-1} - onSelect={this._onTranslate} - /> - ); } } diff --git a/internal_packages/composer-translate/spec/main-spec.coffee b/internal_packages/composer-translate/spec/main-spec.coffee deleted file mode 100644 index 6636dee73..000000000 --- a/internal_packages/composer-translate/spec/main-spec.coffee +++ /dev/null @@ -1,12 +0,0 @@ -describe "AccountSidebarStore", -> - xit "should update it's selected ID when the focusTag action fires", -> - true - - xit "should update when the DatabaseStore emits changes to tags", -> - true - - xit "should update when the NamespaceStore emits", -> - true - - xit "should provide an array of sections to the sidebar view", -> - true diff --git a/internal_packages/composer-translate/stylesheets/translate.less b/internal_packages/composer-translate/stylesheets/translate.less index 36e057b26..9686147c0 100644 --- a/internal_packages/composer-translate/stylesheets/translate.less +++ b/internal_packages/composer-translate/stylesheets/translate.less @@ -1,7 +1,7 @@ @import "ui-variables"; @import "ui-mixins"; -.translate-language-picker .menu { +.translate-language-picker { .footer-container { display: none; } diff --git a/internal_packages/send-later/lib/main.js b/internal_packages/send-later/lib/main.js index 580c07422..4e32ecc35 100644 --- a/internal_packages/send-later/lib/main.js +++ b/internal_packages/send-later/lib/main.js @@ -1,6 +1,6 @@ /** @babel */ import {ComponentRegistry} from 'nylas-exports' -import SendLaterPopover from './send-later-popover' +import SendLaterButton from './send-later-button' import SendLaterStore from './send-later-store' import SendLaterStatus from './send-later-status' @@ -8,12 +8,12 @@ export function activate() { this.store = new SendLaterStore() this.store.activate() - ComponentRegistry.register(SendLaterPopover, {role: 'Composer:ActionButton'}) + ComponentRegistry.register(SendLaterButton, {role: 'Composer:ActionButton'}) ComponentRegistry.register(SendLaterStatus, {role: 'DraftList:DraftStatus'}) } export function deactivate() { - ComponentRegistry.unregister(SendLaterPopover) + ComponentRegistry.unregister(SendLaterButton) ComponentRegistry.unregister(SendLaterStatus) this.store.deactivate() } diff --git a/internal_packages/send-later/lib/send-later-button.jsx b/internal_packages/send-later/lib/send-later-button.jsx new file mode 100644 index 000000000..77321cde9 --- /dev/null +++ b/internal_packages/send-later/lib/send-later-button.jsx @@ -0,0 +1,110 @@ +/** @babel */ +import Rx from 'rx-lite' +import React, {Component, PropTypes} from 'react' +import {Actions, DateUtils, Message, DatabaseStore} from 'nylas-exports' +import {RetinaImg} from 'nylas-component-kit' +import SendLaterPopover from './send-later-popover' +import SendLaterActions from './send-later-actions' +import {PLUGIN_ID} from './send-later-constants' + + +class SendLaterButton extends Component { + static displayName = 'SendLaterButton'; + + static propTypes = { + draftClientId: PropTypes.string.isRequired, + }; + + constructor() { + super() + this.state = { + scheduledDate: null, + } + } + + componentDidMount() { + this._subscription = Rx.Observable.fromQuery( + DatabaseStore.findBy(Message, {clientId: this.props.draftClientId}) + ).subscribe(this.onMessageChanged); + } + + componentWillUnmount() { + this._subscription.dispose(); + } + + onSendLater = (formattedDate, dateLabel)=> { + SendLaterActions.sendLater(this.props.draftClientId, formattedDate, dateLabel); + this.setState({scheduledDate: 'saving'}); + Actions.closePopover() + }; + + onCancelSendLater = ()=> { + SendLaterActions.cancelSendLater(this.props.draftClientId); + Actions.closePopover() + }; + + onClick = ()=> { + const buttonRect = React.findDOMNode(this).getBoundingClientRect() + Actions.openPopover( + , + {originRect: buttonRect, direction: 'up'} + ) + }; + + onMessageChanged = (message)=> { + if (!message) return; + const messageMetadata = message.metadataForPluginId(PLUGIN_ID) || {} + const nextScheduledDate = messageMetadata.sendLaterDate + + if (nextScheduledDate !== this.state.scheduledDate) { + const isComposer = NylasEnv.isComposerWindow() + const isFinishedSelecting = ((this.state.scheduledDate === 'saving') && (nextScheduledDate !== null)); + if (isComposer && isFinishedSelecting) { + NylasEnv.close(); + } + this.setState({scheduledDate: nextScheduledDate}); + } + }; + + render() { + const {scheduledDate} = this.state; + let className = 'btn btn-toolbar btn-send-later'; + + if (scheduledDate === 'saving') { + return ( + + ); + } + + let dateInterpretation; + if (scheduledDate) { + className += ' btn-enabled'; + const momentDate = DateUtils.futureDateFromString(scheduledDate); + if (momentDate) { + dateInterpretation = Sending in {momentDate.fromNow(true)}; + } + } + return ( + + ); + } +} + +SendLaterButton.containerStyles = { + order: -99, +}; + +export default SendLaterButton; diff --git a/internal_packages/send-later/lib/send-later-popover.jsx b/internal_packages/send-later/lib/send-later-popover.jsx index 77fe3dc96..787be2807 100644 --- a/internal_packages/send-later/lib/send-later-popover.jsx +++ b/internal_packages/send-later/lib/send-later-popover.jsx @@ -1,10 +1,7 @@ /** @babel */ -import Rx from 'rx-lite' import React, {Component, PropTypes} from 'react' -import {DateUtils, Message, DatabaseStore} from 'nylas-exports' -import {Popover, RetinaImg, Menu, DateInput} from 'nylas-component-kit' -import SendLaterActions from './send-later-actions' -import {PLUGIN_ID} from './send-later-constants' +import {DateUtils} from 'nylas-exports' +import {Menu, DateInput} from 'nylas-component-kit' const {DATE_FORMAT_SHORT, DATE_FORMAT_LONG} = DateUtils @@ -23,39 +20,9 @@ class SendLaterPopover extends Component { static displayName = 'SendLaterPopover'; static propTypes = { - draftClientId: PropTypes.string.isRequired, - }; - - constructor(props) { - super(props) - this.state = { - scheduledDate: null, - } - } - - componentDidMount() { - this._subscription = Rx.Observable.fromQuery( - DatabaseStore.findBy(Message, {clientId: this.props.draftClientId}) - ).subscribe(this.onMessageChanged); - } - - componentWillUnmount() { - this._subscription.dispose(); - } - - onMessageChanged = (message)=> { - if (!message) return; - const messageMetadata = message.metadataForPluginId(PLUGIN_ID) || {} - const nextScheduledDate = messageMetadata.sendLaterDate - - if (nextScheduledDate !== this.state.scheduledDate) { - const isComposer = NylasEnv.isComposerWindow() - const isFinishedSelecting = ((this.state.scheduledDate === 'saving') && (nextScheduledDate !== null)); - if (isComposer && isFinishedSelecting) { - NylasEnv.close(); - } - this.setState({scheduledDate: nextScheduledDate}); - } + scheduledDate: PropTypes.string, + onSendLater: PropTypes.func.isRequired, + onCancelSendLater: PropTypes.func.isRequired, }; onSelectMenuOption = (optionKey)=> { @@ -71,62 +38,25 @@ class SendLaterPopover extends Component { } }; - onCancelSendLater = ()=> { - SendLaterActions.cancelSendLater(this.props.draftClientId); - this.refs.popover.close(); - }; - selectDate = (date, dateLabel)=> { const formatted = DateUtils.format(date.utc()); - SendLaterActions.sendLater(this.props.draftClientId, formatted, dateLabel); - this.setState({scheduledDate: 'saving'}); - this.refs.popover.close(); + this.props.onSendLater(formatted, dateLabel); }; renderMenuOption(optionKey) { const date = SendLaterOptions[optionKey](); const formatted = DateUtils.format(date, DATE_FORMAT_SHORT); return ( -
{optionKey}{formatted}
- ); - } - - renderButton() { - const {scheduledDate} = this.state; - let className = 'btn btn-toolbar btn-send-later'; - - if (scheduledDate === 'saving') { - return ( - - ); - } - - let dateInterpretation; - if (scheduledDate) { - className += ' btn-enabled'; - const momentDate = DateUtils.futureDateFromString(scheduledDate); - if (momentDate) { - dateInterpretation = Sending in {momentDate.fromNow(true)}; - } - } - return ( - +
+ {optionKey} + {formatted} +
); } render() { const headerComponents = [ - Send later:, + Send later:, ] const footerComponents = [
, @@ -137,11 +67,11 @@ class SendLaterPopover extends Component { onSubmitDate={this.onSelectCustomOption} />, ]; - if (this.state.scheduledDate) { + if (this.props.scheduledDate) { footerComponents.push(
) footerComponents.push(
-
@@ -149,28 +79,21 @@ class SendLaterPopover extends Component { } return ( - - item } - itemContent={this.renderMenuOption} - defaultSelectedIndex={-1} - headerComponents={headerComponents} - footerComponents={footerComponents} - onSelect={this.onSelectMenuOption} - /> - +
+ item} + itemContent={this.renderMenuOption} + defaultSelectedIndex={-1} + headerComponents={headerComponents} + footerComponents={footerComponents} + onSelect={this.onSelectMenuOption} /> +
); } } -SendLaterPopover.containerStyles = { - order: -99, -}; - export default SendLaterPopover diff --git a/internal_packages/send-later/spec/send-later-button-spec.jsx b/internal_packages/send-later/spec/send-later-button-spec.jsx new file mode 100644 index 000000000..237c0a0bd --- /dev/null +++ b/internal_packages/send-later/spec/send-later-button-spec.jsx @@ -0,0 +1,105 @@ +import React, {addons} from 'react/addons'; +import {Rx, DatabaseStore, DateUtils, Actions} from 'nylas-exports' +import SendLaterButton from '../lib/send-later-button'; +import SendLaterActions from '../lib/send-later-actions'; +import {renderIntoDocument} from '../../../spec/nylas-test-utils' + +const {findDOMNode} = React; +const {TestUtils: { + findRenderedDOMComponentWithClass, +}} = addons; + +const makeButton = (props = {})=> { + const button = renderIntoDocument(); + if (props.initialState) { + button.setState(props.initialState) + } + return button +}; + +describe('SendLaterButton', ()=> { + beforeEach(()=> { + spyOn(DatabaseStore, 'findBy') + spyOn(Rx.Observable, 'fromQuery').andReturn(Rx.Observable.empty()) + spyOn(DateUtils, 'format').andReturn('formatted') + spyOn(SendLaterActions, 'sendLater') + }); + + describe('onMessageChanged', ()=> { + it('sets scheduled date correctly', ()=> { + const button = makeButton({initialState: {scheduledDate: 'old'}}) + const message = { + metadataForPluginId: ()=> ({sendLaterDate: 'date'}), + } + spyOn(button, 'setState') + spyOn(NylasEnv, 'isComposerWindow').andReturn(false) + + button.onMessageChanged(message) + + expect(button.setState).toHaveBeenCalledWith({scheduledDate: 'date'}) + }); + + it('closes window if window is composer window and saving has finished', ()=> { + const button = makeButton({initialState: {scheduledDate: 'saving'}}) + const message = { + metadataForPluginId: ()=> ({sendLaterDate: 'date'}), + } + spyOn(button, 'setState') + spyOn(NylasEnv, 'close') + spyOn(NylasEnv, 'isComposerWindow').andReturn(true) + + button.onMessageChanged(message) + + expect(button.setState).toHaveBeenCalledWith({scheduledDate: 'date'}) + expect(NylasEnv.close).toHaveBeenCalled() + }); + + it('does nothing if new date is the same as current date', ()=> { + const button = makeButton({initialState: {scheduledDate: 'date'}}) + const message = { + metadataForPluginId: ()=> ({sendLaterDate: 'date'}), + } + spyOn(button, 'setState') + + button.onMessageChanged(message) + + expect(button.setState).not.toHaveBeenCalled() + }); + }); + + describe('onSendLater', ()=> { + it('sets scheduled date to "saving" and dispatches action', ()=> { + const button = makeButton() + spyOn(button, 'setState') + spyOn(Actions, 'closePopover') + button.onSendLater({utc: ()=> 'utc'}) + + expect(SendLaterActions.sendLater).toHaveBeenCalled() + expect(button.setState).toHaveBeenCalledWith({scheduledDate: 'saving'}) + expect(Actions.closePopover).toHaveBeenCalled() + }); + }); + + describe('render', ()=> { + it('renders spinner if saving', ()=> { + const button = findDOMNode( + makeButton({initialState: {scheduledDate: 'saving'}}) + ) + expect(button.title).toEqual('Saving send date...') + }); + + it('renders date if message is scheduled', ()=> { + spyOn(DateUtils, 'futureDateFromString').andReturn({fromNow: ()=> '5 minutes'}) + const button = makeButton({initialState: {scheduledDate: 'date'}}) + const span = findDOMNode(findRenderedDOMComponentWithClass(button, 'at')) + expect(span.textContent).toEqual('Sending in 5 minutes') + }); + + it('does not render date if message is not scheduled', ()=> { + const button = makeButton({initialState: {scheduledDate: null}}) + expect(()=> { + findRenderedDOMComponentWithClass(button, 'at') + }).toThrow() + }); + }); +}); diff --git a/internal_packages/send-later/spec/send-later-popover-spec.jsx b/internal_packages/send-later/spec/send-later-popover-spec.jsx index 478f877b3..df603d332 100644 --- a/internal_packages/send-later/spec/send-later-popover-spec.jsx +++ b/internal_packages/send-later/spec/send-later-popover-spec.jsx @@ -1,106 +1,64 @@ import React, {addons} from 'react/addons'; -import {Rx, DatabaseStore, DateUtils} from 'nylas-exports' +import {DateUtils} from 'nylas-exports' import SendLaterPopover from '../lib/send-later-popover'; -import SendLaterActions from '../lib/send-later-actions'; import {renderIntoDocument} from '../../../spec/nylas-test-utils' const {findDOMNode} = React; const {TestUtils: { + Simulate, findRenderedDOMComponentWithClass, }} = addons; const makePopover = (props = {})=> { - const popover = renderIntoDocument(); - if (props.initialState) { - popover.setState(props.initialState) - } - return popover + return renderIntoDocument( + {}} + onCancelSendLater={()=>{}} + {...props} /> + ); }; describe('SendLaterPopover', ()=> { beforeEach(()=> { - spyOn(DatabaseStore, 'findBy') - spyOn(Rx.Observable, 'fromQuery').andReturn(Rx.Observable.empty()) spyOn(DateUtils, 'format').andReturn('formatted') - spyOn(SendLaterActions, 'sendLater') - }); - - describe('onMessageChanged', ()=> { - it('sets scheduled date correctly', ()=> { - const popover = makePopover({initialState: {scheduledDate: 'old'}}) - const message = { - metadataForPluginId: ()=> ({sendLaterDate: 'date'}), - } - spyOn(popover, 'setState') - spyOn(NylasEnv, 'isComposerWindow').andReturn(false) - - popover.onMessageChanged(message) - - expect(popover.setState).toHaveBeenCalledWith({scheduledDate: 'date'}) - }); - - it('closes window if window is composer window and saving has finished', ()=> { - const popover = makePopover({initialState: {scheduledDate: 'saving'}}) - const message = { - metadataForPluginId: ()=> ({sendLaterDate: 'date'}), - } - spyOn(popover, 'setState') - spyOn(NylasEnv, 'close') - spyOn(NylasEnv, 'isComposerWindow').andReturn(true) - - popover.onMessageChanged(message) - - expect(popover.setState).toHaveBeenCalledWith({scheduledDate: 'date'}) - expect(NylasEnv.close).toHaveBeenCalled() - }); - - it('does nothing if new date is the same as current date', ()=> { - const popover = makePopover({initialState: {scheduledDate: 'date'}}) - const message = { - metadataForPluginId: ()=> ({sendLaterDate: 'date'}), - } - spyOn(popover, 'setState') - - popover.onMessageChanged(message) - - expect(popover.setState).not.toHaveBeenCalled() - }); }); describe('selectDate', ()=> { - it('sets scheduled date to "saving" and dispatches action', ()=> { - const popover = makePopover() - spyOn(popover, 'setState') - spyOn(popover.refs.popover, 'close') - popover.selectDate({utc: ()=> 'utc'}) + it('calls props.onSendLtaer', ()=> { + const onSendLater = jasmine.createSpy('onSendLater') + const popover = makePopover({onSendLater}) + popover.selectDate({utc: ()=> 'utc'}, 'Custom') - expect(SendLaterActions.sendLater).toHaveBeenCalled() - expect(popover.setState).toHaveBeenCalledWith({scheduledDate: 'saving'}) - expect(popover.refs.popover.close).toHaveBeenCalled() + expect(onSendLater).toHaveBeenCalledWith('formatted', 'Custom') }); }); - describe('renderButton', ()=> { - it('renders spinner if saving', ()=> { - const popover = makePopover({initialState: {scheduledDate: 'saving'}}) + describe('onSelectCustomOption', ()=> { + it('selects date', ()=> { + const popover = makePopover() + spyOn(popover, 'selectDate') + popover.onSelectCustomOption('date', 'abc') + expect(popover.selectDate).toHaveBeenCalledWith('date', 'Custom') + }); + + it('throws error if date is invalid', ()=> { + spyOn(NylasEnv, 'showErrorDialog') + const popover = makePopover() + popover.onSelectCustomOption(null, 'abc') + expect(NylasEnv.showErrorDialog).toHaveBeenCalled() + }); + }); + + describe('render', ()=> { + it('renders cancel button if scheduled', ()=> { + const onCancelSendLater = jasmine.createSpy('onCancelSendLater') + const popover = makePopover({onCancelSendLater, scheduledDate: 'date'}) const button = findDOMNode( - findRenderedDOMComponentWithClass(popover, 'btn-send-later') + findRenderedDOMComponentWithClass(popover, 'btn-cancel') ) - expect(button.title).toEqual('Saving send date...') - }); - - it('renders date if message is scheduled', ()=> { - spyOn(DateUtils, 'futureDateFromString').andReturn({fromNow: ()=> '5 minutes'}) - const popover = makePopover({initialState: {scheduledDate: 'date'}}) - const span = findDOMNode(findRenderedDOMComponentWithClass(popover, 'at')) - expect(span.textContent).toEqual('Sending in 5 minutes') - }); - - it('does not render date if message is not scheduled', ()=> { - const popover = makePopover({initialState: {scheduledDate: null}}) - expect(()=> { - findRenderedDOMComponentWithClass(popover, 'at') - }).toThrow() + Simulate.click(button) + expect(onCancelSendLater).toHaveBeenCalled() }); }); }); diff --git a/internal_packages/thread-list/lib/thread-list.cjsx b/internal_packages/thread-list/lib/thread-list.cjsx index bb8991657..7fb51ba72 100644 --- a/internal_packages/thread-list/lib/thread-list.cjsx +++ b/internal_packages/thread-list/lib/thread-list.cjsx @@ -143,18 +143,15 @@ class ThreadList extends React.Component Actions.closePopover() props.onSwipeLeft = (callback) => # TODO this should be grabbed from elsewhere - {PopoverStore} = require 'nylas-exports' - SnoozePopoverBody = require '../../thread-snooze/lib/snooze-popover-body' + SnoozePopover = require '../../thread-snooze/lib/snooze-popover' element = document.querySelector("[data-item-id=\"#{item.id}\"]") - rect = element.getBoundingClientRect() + originRect = element.getBoundingClientRect() Actions.openPopover( - , - rect, - "right" + swipeCallback={callback} />, + {originRect, direction: 'right', fallbackDirection: 'down'} ) return props diff --git a/internal_packages/thread-snooze/lib/main.js b/internal_packages/thread-snooze/lib/main.js index 947c7e74b..25f6ce7e3 100644 --- a/internal_packages/thread-snooze/lib/main.js +++ b/internal_packages/thread-snooze/lib/main.js @@ -1,7 +1,6 @@ /** @babel */ import {ComponentRegistry} from 'nylas-exports'; -import {ToolbarSnooze, BulkThreadSnooze} from './snooze-toolbar-components'; -import SnoozeQuickActionButton from './snooze-quick-action-button' +import {ToolbarSnooze, BulkThreadSnooze, QuickActionSnooze} from './snooze-buttons'; import SnoozeMailLabel from './snooze-mail-label' import SnoozeStore from './snooze-store' @@ -11,14 +10,14 @@ export function activate() { this.snoozeStore.activate() ComponentRegistry.register(ToolbarSnooze, {role: 'message:Toolbar'}); - ComponentRegistry.register(SnoozeQuickActionButton, {role: 'ThreadListQuickAction'}); + ComponentRegistry.register(QuickActionSnooze, {role: 'ThreadListQuickAction'}); ComponentRegistry.register(BulkThreadSnooze, {role: 'thread:BulkAction'}); ComponentRegistry.register(SnoozeMailLabel, {role: 'Thread:MailLabel'}); } export function deactivate() { ComponentRegistry.unregister(ToolbarSnooze); - ComponentRegistry.unregister(SnoozeQuickActionButton); + ComponentRegistry.unregister(QuickActionSnooze); ComponentRegistry.unregister(BulkThreadSnooze); ComponentRegistry.unregister(SnoozeMailLabel); this.snoozeStore.deactivate() diff --git a/internal_packages/thread-snooze/lib/snooze-buttons.jsx b/internal_packages/thread-snooze/lib/snooze-buttons.jsx new file mode 100644 index 000000000..0c57b3ce8 --- /dev/null +++ b/internal_packages/thread-snooze/lib/snooze-buttons.jsx @@ -0,0 +1,134 @@ +/** @babel */ +import React, {Component, PropTypes} from 'react'; +import {Actions, FocusedPerspectiveStore} from 'nylas-exports'; +import {RetinaImg} from 'nylas-component-kit'; +import SnoozePopover from './snooze-popover'; + + +class SnoozeButton extends Component { + + static propTypes = { + className: PropTypes.string, + threads: PropTypes.array, + direction: PropTypes.string, + renderImage: PropTypes.bool, + getBoundingClientRect: PropTypes.func, + }; + + static defaultProps = { + className: 'btn btn-toolbar', + direction: 'down', + renderImage: true, + }; + + onClick = (event)=> { + event.stopPropagation() + const buttonRect = this.getBoundingClientRect() + Actions.openPopover( + , + {originRect: buttonRect, direction: this.props.direction} + ) + }; + + getBoundingClientRect = ()=> { + if (this.props.getBoundingClientRect) { + return this.props.getBoundingClientRect() + } + return React.findDOMNode(this).getBoundingClientRect() + }; + + render() { + if (!FocusedPerspectiveStore.current().isInbox()) { + return ; + } + return ( +
+ {this.props.renderImage ? + : + void 0 + } +
+ ); + } +} + + +export class QuickActionSnooze extends Component { + static displayName = 'QuickActionSnooze'; + + static propTypes = { + thread: PropTypes.object, + }; + + getBoundingClientRect = ()=> { + // Grab the parent node because of the zoom applied to this button. If we + // took this element directly, we'd have to divide everything by 2 + const element = React.findDOMNode(this).parentNode; + const {height, width, top, bottom, left, right} = element.getBoundingClientRect() + + // The parent node is a bit too much to the left, lets adjust this. + return {height, width, top, bottom, right, left: left + 5} + }; + + static containerRequired = false; + + render() { + if (!FocusedPerspectiveStore.current().isInbox()) { + return ; + } + return ( + + ); + } +} + + +export class BulkThreadSnooze extends Component { + static displayName = 'BulkThreadSnooze'; + + static propTypes = { + items: PropTypes.array, + }; + + static containerRequired = false; + + render() { + if (!FocusedPerspectiveStore.current().isInbox()) { + return ; + } + return ( + + ); + } +} + +export class ToolbarSnooze extends Component { + static displayName = 'ToolbarSnooze'; + + static propTypes = { + thread: PropTypes.object, + }; + + static containerRequired = false; + + render() { + if (!FocusedPerspectiveStore.current().isInbox()) { + return ; + } + return ( + + ); + } +} diff --git a/internal_packages/thread-snooze/lib/snooze-popover-body.jsx b/internal_packages/thread-snooze/lib/snooze-popover-body.jsx deleted file mode 100644 index 785b95650..000000000 --- a/internal_packages/thread-snooze/lib/snooze-popover-body.jsx +++ /dev/null @@ -1,128 +0,0 @@ -/** @babel */ -import _ from 'underscore'; -import React, {Component, PropTypes} from 'react'; -import {DateUtils, Actions} from 'nylas-exports' -import {RetinaImg, DateInput} from 'nylas-component-kit'; -import SnoozeActions from './snooze-actions' - -const {DATE_FORMAT_LONG} = DateUtils - - -const SnoozeOptions = [ - [ - 'Later today', - 'Tonight', - 'Tomorrow', - ], - [ - 'This weekend', - 'Next week', - 'Next month', - ], -] - -const SnoozeDatesFactory = { - 'Later today': DateUtils.laterToday, - 'Tonight': DateUtils.tonight, - 'Tomorrow': DateUtils.tomorrow, - 'This weekend': DateUtils.thisWeekend, - 'Next week': DateUtils.nextWeek, - 'Next month': DateUtils.nextMonth, -} - -const SnoozeIconNames = { - 'Later today': 'later', - 'Tonight': 'tonight', - 'Tomorrow': 'tomorrow', - 'This weekend': 'weekend', - 'Next week': 'week', - 'Next month': 'month', -} - - -class SnoozePopoverBody extends Component { - static displayName = 'SnoozePopoverBody'; - - static propTypes = { - threads: PropTypes.array.isRequired, - swipeCallback: PropTypes.func, - closePopover: PropTypes.func, - }; - - static defaultProps = { - swipeCallback: ()=> {}, - closePopover: ()=> {}, - }; - - constructor() { - super(); - this.didSnooze = false; - } - - componentWillUnmount() { - this.props.swipeCallback(this.didSnooze); - } - - onSnooze(date, itemLabel) { - const utcDate = date.utc(); - const formatted = DateUtils.format(utcDate); - SnoozeActions.snoozeThreads(this.props.threads, formatted, itemLabel); - this.didSnooze = true; - this.props.closePopover(); - - // if we're looking at a thread, go back to the main view. - // has no effect otherwise. - Actions.popSheet(); - } - - onSelectCustomDate = (date, inputValue)=> { - if (date) { - this.onSnooze(date, "Custom"); - } else { - NylasEnv.showErrorDialog(`Sorry, we can't parse ${inputValue} as a valid date.`); - } - }; - - renderItem = (itemLabel)=> { - const date = SnoozeDatesFactory[itemLabel](); - const iconName = SnoozeIconNames[itemLabel]; - const iconPath = `nylas://thread-snooze/assets/ic-snoozepopover-${iconName}@2x.png`; - return ( -
- - {itemLabel} -
- ) - }; - - renderRow = (options, idx)=> { - const items = _.map(options, this.renderItem); - return ( -
- {items} -
- ); - }; - - render() { - const rows = SnoozeOptions.map(this.renderRow); - - return ( -
- {rows} - -
- ); - } - -} - -export default SnoozePopoverBody; diff --git a/internal_packages/thread-snooze/lib/snooze-popover.jsx b/internal_packages/thread-snooze/lib/snooze-popover.jsx index fe5274e1d..5703d3b7a 100644 --- a/internal_packages/thread-snooze/lib/snooze-popover.jsx +++ b/internal_packages/thread-snooze/lib/snooze-popover.jsx @@ -1,8 +1,43 @@ /** @babel */ +import _ from 'underscore'; import React, {Component, PropTypes} from 'react'; -import {Actions} from 'nylas-exports'; -import {Popover} from 'nylas-component-kit'; -import SnoozePopoverBody from './snooze-popover-body'; +import {DateUtils, Actions} from 'nylas-exports' +import {RetinaImg, DateInput} from 'nylas-component-kit'; +import SnoozeActions from './snooze-actions' + +const {DATE_FORMAT_LONG} = DateUtils + + +const SnoozeOptions = [ + [ + 'Later today', + 'Tonight', + 'Tomorrow', + ], + [ + 'This weekend', + 'Next week', + 'Next month', + ], +] + +const SnoozeDatesFactory = { + 'Later today': DateUtils.laterToday, + 'Tonight': DateUtils.tonight, + 'Tomorrow': DateUtils.tomorrow, + 'This weekend': DateUtils.thisWeekend, + 'Next week': DateUtils.nextWeek, + 'Next month': DateUtils.nextMonth, +} + +const SnoozeIconNames = { + 'Later today': 'later', + 'Tonight': 'tonight', + 'Tomorrow': 'tomorrow', + 'This weekend': 'weekend', + 'Next week': 'week', + 'Next month': 'month', +} class SnoozePopover extends Component { @@ -10,32 +45,79 @@ class SnoozePopover extends Component { static propTypes = { threads: PropTypes.array.isRequired, - buttonComponent: PropTypes.object.isRequired, - direction: PropTypes.string, - pointerStyle: PropTypes.object, - popoverStyle: PropTypes.object, + swipeCallback: PropTypes.func, }; - closePopover = ()=> { - this.refs.popover.close(); + static defaultProps = { + swipeCallback: ()=> {}, + }; + + constructor() { + super(); + this.didSnooze = false; + } + + componentWillUnmount() { + this.props.swipeCallback(this.didSnooze); + } + + onSnooze(date, itemLabel) { + const utcDate = date.utc(); + const formatted = DateUtils.format(utcDate); + SnoozeActions.snoozeThreads(this.props.threads, formatted, itemLabel); + this.didSnooze = true; + Actions.closePopover(); + + // if we're looking at a thread, go back to the main view. + // has no effect otherwise. + Actions.popSheet(); + } + + onSelectCustomDate = (date, inputValue)=> { + if (date) { + this.onSnooze(date, "Custom"); + } else { + NylasEnv.showErrorDialog(`Sorry, we can't parse ${inputValue} as a valid date.`); + } + }; + + renderItem = (itemLabel)=> { + const date = SnoozeDatesFactory[itemLabel](); + const iconName = SnoozeIconNames[itemLabel]; + const iconPath = `nylas://thread-snooze/assets/ic-snoozepopover-${iconName}@2x.png`; + return ( +
+ + {itemLabel} +
+ ) + }; + + renderRow = (options, idx)=> { + const items = _.map(options, this.renderItem); + return ( +
+ {items} +
+ ); }; render() { - const {buttonComponent, direction, popoverStyle, pointerStyle, threads} = this.props + const rows = SnoozeOptions.map(this.renderRow); return ( - Actions.closePopover()}> - - +
+ {rows} + +
); } diff --git a/internal_packages/thread-snooze/lib/snooze-quick-action-button.jsx b/internal_packages/thread-snooze/lib/snooze-quick-action-button.jsx deleted file mode 100644 index 32f172bea..000000000 --- a/internal_packages/thread-snooze/lib/snooze-quick-action-button.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, {Component, PropTypes} from 'react'; -import {Actions, FocusedPerspectiveStore} from 'nylas-exports'; -import SnoozePopoverBody from './snooze-popover-body'; - - -class QuickActionSnoozeButton extends Component { - static displayName = 'QuickActionSnoozeButton'; - - static propTypes = { - thread: PropTypes.object, - }; - - onClick = (event)=> { - event.stopPropagation() - const {thread} = this.props; - - // Grab the parent node because of the zoom applied to this button. If we - // took this element directly, we'd have to divide everything by 2 - const element = React.findDOMNode(this).parentNode; - const {height, width, top, bottom, left, right} = element.getBoundingClientRect() - - // The parent node is a bit too much to the left, lets adjust this. - const rect = {height, width, top, bottom, right, left: left + 5} - Actions.openPopover( - , - rect, - "left" - ); - }; - - static containerRequired = false; - - render() { - if (!FocusedPerspectiveStore.current().isInbox()) { - return ; - } - return
- } -} - -export default QuickActionSnoozeButton diff --git a/internal_packages/thread-snooze/lib/snooze-toolbar-components.jsx b/internal_packages/thread-snooze/lib/snooze-toolbar-components.jsx deleted file mode 100644 index 32c3a0cee..000000000 --- a/internal_packages/thread-snooze/lib/snooze-toolbar-components.jsx +++ /dev/null @@ -1,69 +0,0 @@ -/** @babel */ -import React, {Component, PropTypes} from 'react'; -import {FocusedPerspectiveStore} from 'nylas-exports'; -import {RetinaImg} from 'nylas-component-kit'; -import SnoozePopover from './snooze-popover'; - - -const toolbarButton = ( - -) - -export class BulkThreadSnooze extends Component { - static displayName = 'BulkThreadSnooze'; - - static propTypes = { - selection: PropTypes.object, - items: PropTypes.array, - }; - - static containerRequired = false; - - render() { - if (!FocusedPerspectiveStore.current().isInbox()) { - return ; - } - return ( - - ); - } -} - -export class ToolbarSnooze extends Component { - static displayName = 'ToolbarSnooze'; - - static propTypes = { - thread: PropTypes.object, - }; - - static containerRequired = false; - - render() { - if (!FocusedPerspectiveStore.current().isInbox()) { - return ; - } - const pointerStyle = { - right: 18, - display: 'block', - }; - const popoverStyle = { - transform: 'translate(0, 15px)', - } - return ( - - ); - } -} diff --git a/internal_packages/thread-snooze/stylesheets/snooze-popover.less b/internal_packages/thread-snooze/stylesheets/snooze-popover.less index 94c374d77..4f1f810ba 100644 --- a/internal_packages/thread-snooze/stylesheets/snooze-popover.less +++ b/internal_packages/thread-snooze/stylesheets/snooze-popover.less @@ -6,18 +6,11 @@ background: url(@snooze-quickaction-img) center no-repeat, @background-gradient; } -.snooze-popover { +.snooze-button { order: -104; - - .btn-toolbar:only-of-type { - margin-right: 0; - } - .popover { - overflow: hidden; - } } -.snooze-container { +.snooze-popover { color: fadeout(@btn-default-text-color, 20%); display: flex; flex-direction: column; diff --git a/spec/components/fixed-popover-spec.jsx b/spec/components/fixed-popover-spec.jsx new file mode 100644 index 000000000..85cd9567f --- /dev/null +++ b/spec/components/fixed-popover-spec.jsx @@ -0,0 +1,172 @@ +import React from 'react'; +import FixedPopover from '../../src/components/fixed-popover'; +import {renderIntoDocument} from '../nylas-test-utils' + + +const {Directions: {Up, Down, Left, Right}} = FixedPopover + +const makePopover = (props = {})=> { + props.originRect = props.originRect ? props.originRect : {}; + const popover = renderIntoDocument(); + if (props.initialState) { + popover.setState(props.initialState) + } + return popover +}; + +describe('FixedPopover', ()=> { + describe('computeAdjustedOffsetAndDirection', ()=> { + beforeEach(()=> { + this.popover = makePopover() + this.PADDING = 10 + this.windowDimensions = { + height: 500, + width: 500, + } + }); + + const compute = (direction, {fallback, top, left, bottom, right})=> { + return this.popover.computeAdjustedOffsetAndDirection({ + direction, + windowDimensions: this.windowDimensions, + currentRect: { + top, + left, + bottom, + right, + }, + fallback, + offsetPadding: this.PADDING, + }) + } + + it('returns null when no overflows present', ()=> { + const res = compute(Up, {top: 10, left: 10, right: 20, bottom: 20}) + expect(res).toBe(null) + }); + + describe('when overflowing on 1 side of the window', ()=> { + it('returns fallback direction when it is specified', ()=> { + const {offset, direction} = compute(Up, {fallback: Left, top: -10, left: 10, right: 20, bottom: 10}) + expect(offset).toEqual({}) + expect(direction).toEqual(Left) + }); + + it('inverts direction if is Up and overflows on the top', ()=> { + const {offset, direction} = compute(Up, {top: -10, left: 10, right: 20, bottom: 10}) + expect(offset).toEqual({}) + expect(direction).toEqual(Down) + }); + + it('inverts direction if is Down and overflows on the bottom', ()=> { + const {offset, direction} = compute(Down, {top: 490, left: 10, right: 20, bottom: 510}) + expect(offset).toEqual({}) + expect(direction).toEqual(Up) + }); + + it('inverts direction if is Right and overflows on the right', ()=> { + const {offset, direction} = compute(Right, {top: 10, left: 490, right: 510, bottom: 20}) + expect(offset).toEqual({}) + expect(direction).toEqual(Left) + }); + + it('inverts direction if is Left and overflows on the left', ()=> { + const {offset, direction} = compute(Left, {top: 10, left: -10, right: 10, bottom: 20}) + expect(offset).toEqual({}) + expect(direction).toEqual(Right) + }); + + [Up, Down, Left, Right].forEach((dir)=> { + if (dir === Up || dir === Down) { + it('moves left if its overflowing on the right', ()=> { + const {offset, direction} = compute(dir, {top: 10, left: 490, right: 510, bottom: 20}) + expect(offset).toEqual({x: -20}) + expect(direction).toEqual(dir) + }); + + it('moves right if overflows on the left', ()=> { + const {offset, direction} = compute(dir, {top: 10, left: -10, right: 10, bottom: 20}) + expect(offset).toEqual({x: 20}) + expect(direction).toEqual(dir) + }); + } + + if (dir === Left || dir === Right) { + it('moves up if its overflowing on the bottom', ()=> { + const {offset, direction} = compute(dir, {top: 490, left: 10, right: 20, bottom: 510}) + expect(offset).toEqual({y: -20}) + expect(direction).toEqual(dir) + }); + + it('moves down if overflows on the top', ()=> { + const {offset, direction} = compute(dir, {top: -10, left: 10, right: 20, bottom: 10}) + expect(offset).toEqual({y: 20}) + expect(direction).toEqual(dir) + }); + } + }) + }) + + describe('when overflowing on 2 sides of the window', ()=> { + describe('when direction is up', ()=> { + it('computes correctly when it overflows up and right', ()=> { + const {offset, direction} = compute(Up, {top: -10, left: 10, right: 510, bottom: 10}) + expect(offset).toEqual({x: -20}) + expect(direction).toEqual(Down) + }); + + it('computes correctly when it overflows up and left', ()=> { + const {offset, direction} = compute(Up, {top: -10, left: -10, right: 10, bottom: 10}) + expect(offset).toEqual({x: 20}) + expect(direction).toEqual(Down) + }); + }); + + describe('when direction is right', ()=> { + it('computes correctly when it overflows right and up', ()=> { + const {offset, direction} = compute(Right, {top: -10, left: 490, right: 510, bottom: 10}) + expect(offset).toEqual({y: 20}) + expect(direction).toEqual(Left) + }); + + it('computes correctly when it overflows right and down', ()=> { + const {offset, direction} = compute(Right, {top: 490, left: 490, right: 510, bottom: 510}) + expect(offset).toEqual({y: -20}) + expect(direction).toEqual(Left) + }); + }); + + describe('when direction is left', ()=> { + it('computes correctly when it overflows left and up', ()=> { + const {offset, direction} = compute(Left, {top: -10, left: -10, right: 10, bottom: 10}) + expect(offset).toEqual({y: 20}) + expect(direction).toEqual(Right) + }); + + it('computes correctly when it overflows left and down', ()=> { + const {offset, direction} = compute(Left, {top: 490, left: -10, right: 10, bottom: 510}) + expect(offset).toEqual({y: -20}) + expect(direction).toEqual(Right) + }); + }); + + describe('when direction is down', ()=> { + it('computes correctly when it overflows down and left', ()=> { + const {offset, direction} = compute(Down, {top: 490, left: -10, right: 10, bottom: 510}) + expect(offset).toEqual({x: 20}) + expect(direction).toEqual(Up) + }); + + it('computes correctly when it overflows down and right', ()=> { + const {offset, direction} = compute(Down, {top: 490, left: 490, right: 510, bottom: 510}) + expect(offset).toEqual({x: -20}) + expect(direction).toEqual(Up) + }); + }); + }); + }); + + describe('computePopoverStyles', ()=> { + // TODO + }); +}); diff --git a/src/components/fixed-popover.jsx b/src/components/fixed-popover.jsx index 8f5e5913f..e3aad4451 100644 --- a/src/components/fixed-popover.jsx +++ b/src/components/fixed-popover.jsx @@ -2,17 +2,26 @@ import _ from 'underscore'; import React, {Component, PropTypes} from 'react'; import Actions from '../flux/actions'; -// TODO -// This is a temporary hack for the snooze popover -// This should be the actual dimensions of the rendered popover body -const OVERFLOW_LIMIT = 50; + +const Directions = { + Up: 'up', + Down: 'down', + Left: 'left', + Right: 'right', +}; + +const InverseDirections = { + [Directions.Up]: Directions.Down, + [Directions.Down]: Directions.Up, + [Directions.Left]: Directions.Right, + [Directions.Right]: Directions.Left, +}; + +const OFFSET_PADDING = 11.5; /** * Renders a popover absultely positioned in the window next to the provided * rect. - * This popover will not automatically be closed. The user must completely - * control the lifecycle of the Popover via `Actions.openPopover` and - * `Actions.closePopover` * If `Actions.openPopover` is called when the popover is already open, it will * close the previous one and open the new one. * @class FixedPopover @@ -23,6 +32,7 @@ class FixedPopover extends Component { className: PropTypes.string, children: PropTypes.element, direction: PropTypes.string, + fallbackDirection: PropTypes.string, originRect: PropTypes.shape({ bottom: PropTypes.number, top: PropTypes.number, @@ -35,17 +45,84 @@ class FixedPopover extends Component { constructor(props) { super(props); + this.updateCount = 0 + this.fallback = this.props.fallbackDirection; this.state = { - offset: 0, - dimensions: {}, + offset: {}, + direction: props.direction, + visible: false, }; } componentDidMount() { - this._focusImportantElement(); + this.focusElementWithTabIndex(); + _.defer(this.onPopoverRendered) } - _focusImportantElement = ()=> { + componentWillReceiveProps(nextProps) { + this.fallback = nextProps.fallbackDirection; + this.setState({direction: nextProps.direction}) + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + !_.isEqual(this.state, nextState) || + !_.isEqual(this.props, nextProps) + ) + } + + componentDidUpdate() { + this.focusElementWithTabIndex(); + _.defer(this.onPopoverRendered) + } + + onPopoverRendered = ()=> { + const {direction} = this.state + const currentRect = this.getCurrentRect() + const windowDimensions = this.getWindowDimensions() + const newState = this.computeAdjustedOffsetAndDirection({direction, windowDimensions, currentRect}) + if (newState) { + if (this.updateCount > 1) { + this.setState({direction: this.props.direction, offset: {}, visible: true}) + return + } + + // Reset fallback after using it once + this.fallback = null + this.updateCount++; + this.setState(newState); + } else { + this.setState({visible: true}) + } + }; + + onBlur = (event)=> { + const target = event.nativeEvent.relatedTarget; + if (!target || (!React.findDOMNode(this).contains(target))) { + Actions.closePopover(); + } + }; + + onKeyDown = (event)=> { + if (event.key === "Escape") { + Actions.closePopover(); + } + }; + + getCurrentRect = ()=> { + return React.findDOMNode(this.refs.popover).getBoundingClientRect(); + }; + + getWindowDimensions = ()=> { + return { + width: document.body.clientWidth, + height: document.body.clientHeight, + } + }; + + static Directions = Directions; + + focusElementWithTabIndex = ()=> { // Automatically focus the element inside us with the lowest tab index const popoverNode = React.findDOMNode(this); @@ -64,106 +141,134 @@ class FixedPopover extends Component { } }; - _getNewDirection = (direction, originRect, windowDimensions, limit = OVERFLOW_LIMIT)=> { - // TODO this is a hack. Implement proper repositioning - switch (direction) { - case 'right': - if ( - windowDimensions.width - (originRect.left + originRect.width) < limit || - originRect.top < limit * 2 - ) { - return 'down'; + computeOverflows = ({currentRect, windowDimensions})=> { + const overflows = { + top: currentRect.top < 0, + left: currentRect.left < 0, + bottom: currentRect.bottom > windowDimensions.height, + right: currentRect.right > windowDimensions.width, + } + const overflowValues = { + top: Math.abs(currentRect.top), + left: Math.abs(currentRect.left), + bottom: Math.abs(currentRect.bottom - windowDimensions.height), + right: Math.abs(currentRect.right - windowDimensions.width), + } + return {overflows, overflowValues} + }; + + computeAdjustedOffsetAndDirection = ({direction, currentRect, windowDimensions, fallback = this.fallback, offsetPadding = OFFSET_PADDING})=> { + const {overflows, overflowValues} = this.computeOverflows({currentRect, windowDimensions}) + const overflowCount = _.keys(_.pick(overflows, (val)=> val === true)).length + + if (overflowCount > 0) { + if (fallback) { + return {direction: fallback, offset: {}} } - if (windowDimensions.height - (originRect.top + originRect.height) < limit * 2) { - return 'up' + + const isHorizontalDirection = [Directions.Left, Directions.Right].includes(direction) + const isVerticalDirection = [Directions.Up, Directions.Down].includes(direction) + const shouldInvertDirection = ( + (isHorizontalDirection && (overflows.left || overflows.right)) || + (isVerticalDirection && (overflows.top || overflows.bottom)) + ) + const offset = {}; + let newDirection = direction; + + if (shouldInvertDirection) { + newDirection = InverseDirections[direction] } - break; - case 'down': - if (windowDimensions.height - (originRect.top + originRect.height) < limit * 4) { - return 'up' + + if (isHorizontalDirection && (overflows.top || overflows.bottom)) { + const overflowVal = (overflows.top ? overflowValues.top : overflowValues.bottom) + let offsetY = overflowVal + offsetPadding; + + offsetY = overflows.bottom ? -(offsetY) : offsetY; + offset.y = offsetY; } - break; - default: - break; + if (isVerticalDirection && (overflows.left || overflows.right)) { + const overflowVal = (overflows.left ? overflowValues.left : overflowValues.right) + let offsetX = overflowVal + offsetPadding; + + offsetX = overflows.right ? -(offsetX) : offsetX; + offset.x = offsetX; + } + return {offset, direction: newDirection} } return null; }; - _computePopoverPositions = (originRect, direction)=> { - const windowDimensions = { - width: document.body.clientWidth, - height: document.body.clientHeight, - } - const newDirection = this._getNewDirection(direction, originRect, windowDimensions); - if (newDirection != null) { - return this._computePopoverPositions(originRect, newDirection); - } + computePopoverStyles = ({originRect, direction, offset, visible})=> { + const {Up, Down, Left, Right} = Directions + let containerStyle = {}; let popoverStyle = {}; let pointerStyle = {}; - let containerStyle = {}; + switch (direction) { - case 'up': + case Up: containerStyle = { - bottom: (windowDimensions.height - originRect.top) + 10, + // Place container on the top left corner of the rect + top: originRect.top, left: originRect.left, } popoverStyle = { - transform: 'translate(-50%, -100%)', + // Center, place on top of container, and adjust 10px for the pointer + transform: `translate(${offset.x || 0}px) translate(-50%, calc(-100% - 10px))`, left: originRect.width / 2, } pointerStyle = { - transform: 'translate(-50%, 0)', + // Center, and place on top of our container + transform: 'translate(-50%, -100%)', left: originRect.width, // Don't divide by 2 because of zoom } break; - case 'down': + case Down: containerStyle = { + // Place container on the bottom left corner of the rect top: originRect.top + originRect.height, left: originRect.left, } popoverStyle = { - transform: 'translate(-50%, 10px)', + // Center and adjust 10px for the pointer (already positioned at the bottom of container) + transform: `translate(${offset.x || 0}px) translate(-50%, 10px)`, left: originRect.width / 2, } pointerStyle = { + // Center, already positioned at the bottom of container transform: 'translate(-50%, 0) rotateX(180deg)', left: originRect.width, // Don't divide by 2 because of zoom } break; - case 'left': + case Left: containerStyle = { + // Place container on the top left corner of the rect top: originRect.top, - right: (windowDimensions.width - originRect.left) + 10, - } - // TODO This is a hack for the snooze popover. Fix this - let popoverTop = originRect.height / 2; - let popoverTransform = 'translate(-100%, -50%)'; - if (originRect.top < OVERFLOW_LIMIT * 2) { - popoverTop = 0; - popoverTransform = 'translate(-100%, 0)'; - } else if (windowDimensions.height - originRect.bottom < OVERFLOW_LIMIT * 2) { - popoverTop = -190; - popoverTransform = 'translate(-100%, 0)'; + left: originRect.left, } popoverStyle = { - transform: popoverTransform, - top: popoverTop, + // Center, place on left of container, and adjust 10px for the pointer + transform: `translate(0, ${offset.y || 0}px) translate(calc(-100% - 10px), -50%)`, + top: originRect.height / 2, } pointerStyle = { - transform: 'translate(-13px, -50%) rotate(270deg)', + // Center, and place on left of our container (adjust for rotation) + transform: 'translate(calc(-100% + 13px), -50%) rotate(270deg)', top: originRect.height, // Don't divide by 2 because of zoom } break; - case 'right': + case Right: containerStyle = { + // Place container on the top right corner of the rect top: originRect.top, left: originRect.left + originRect.width, } popoverStyle = { - transform: 'translate(10px, -50%)', + // Center and adjust 10px for the pointer + transform: `translate(0, ${offset.y || 0}px) translate(10px, -50%)`, top: originRect.height / 2, } pointerStyle = { + // Center, already positioned at the right of container (adjust for rotation) transform: 'translate(-12px, -50%) rotate(90deg)', top: originRect.height, // Don't divide by 2 because of zoom } @@ -172,6 +277,8 @@ class FixedPopover extends Component { break; } + popoverStyle.visibility = visible ? 'visible' : 'hidden'; + // Set the zoom directly on the style element. Otherwise it won't work with // mask image of our shadow pointer element. This is probably a Chrome bug pointerStyle.zoom = 0.5; @@ -179,38 +286,35 @@ class FixedPopover extends Component { return {containerStyle, popoverStyle, pointerStyle}; }; - _onBlur = (event)=> { - const target = event.nativeEvent.relatedTarget; - if (!target || (!React.findDOMNode(this).contains(target))) { - Actions.closePopover(); - } - }; - - _onKeyDown = (event)=> { - if (event.key === "Escape") { - Actions.closePopover(); - } - }; - render() { - const {children, direction, originRect} = this.props; - const {containerStyle, popoverStyle, pointerStyle} = this._computePopoverPositions(originRect, direction); + const {offset, direction, visible} = this.state; + const {children, originRect} = this.props; + if (!originRect) { + return ; + } + + const blurTrapStyle = {top: originRect.top, left: originRect.left, height: originRect.height, width: originRect.width} + const {containerStyle, popoverStyle, pointerStyle} = ( + this.computePopoverStyles({originRect, direction, offset, visible}) + ); return ( -
-
- {children} +
+
+
+
+ {children} +
+
+
-
-
); } - } export default FixedPopover; diff --git a/src/components/menu.cjsx b/src/components/menu.cjsx index 2de9c9593..21dedf583 100644 --- a/src/components/menu.cjsx +++ b/src/components/menu.cjsx @@ -209,6 +209,7 @@ class Menu extends React.Component _onKeyDown: (event) => return if @props.items.length is 0 + event.stopPropagation() if event.key is "Enter" @_onEnter() else if event.key is "ArrowUp" or (event.key is "Tab" and event.shiftKey) diff --git a/src/flux/stores/popover-store.jsx b/src/flux/stores/popover-store.jsx index ebfce8fd8..f51cc7d72 100644 --- a/src/flux/stores/popover-store.jsx +++ b/src/flux/stores/popover-store.jsx @@ -1,7 +1,7 @@ import React from 'react'; import NylasStore from 'nylas-store' import Actions from '../actions' -import {FixedPopover} from 'nylas-component-kit'; +import FixedPopover from '../../components/fixed-popover' const CONTAINER_ID = "nylas-popover-container"; @@ -40,10 +40,11 @@ class PopoverStore extends NylasStore { }); }; - openPopover = (element, originRect, direction, callback = ()=> {})=> { + openPopover = (element, {originRect, direction, fallbackDirection, callback = ()=> {}})=> { const props = { - originRect: originRect, - direction: direction, + direction, + originRect, + fallbackDirection, }; if (this.isOpen) { diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee index c0e4efb28..cb97ae3b0 100644 --- a/src/global/nylas-component-kit.coffee +++ b/src/global/nylas-component-kit.coffee @@ -10,11 +10,19 @@ class NylasComponentKit exported = require "../components/#{path}" return exported[prop] + @loadDeprecated = (prop, path, {instead} = {}) -> + {deprecate} = require '../deprecate-utils' + Object.defineProperty @prototype, prop, + get: deprecate prop, instead, @, -> + exported = require "../components/#{path}" + return exported + enumerable: true + @load "Menu", 'menu' @load "DropZone", 'drop-zone' @load "Spinner", 'spinner' @load "Switch", 'switch' - @load "Popover", 'popover' + @loadDeprecated "Popover", 'popover', instead: 'Actions.openPopover' @load "FixedPopover", 'fixed-popover' @load "Modal", 'modal' @load "Flexbox", 'flexbox' diff --git a/static/components/fixed-popover.less b/static/components/fixed-popover.less index cb01ae308..2886988e1 100644 --- a/static/components/fixed-popover.less +++ b/static/components/fixed-popover.less @@ -1,10 +1,15 @@ @import "ui-variables"; - +@header-color: #afafaf; // TODO // Most of these styles are duplicated from the original popover.less // Eventually, we will get rid of the original popover and switch to this // implementation +.fixed-popover-blur-trap { + position: absolute; + z-index: 40; +} + .fixed-popover-container { position: absolute; z-index: 40; @@ -16,6 +21,34 @@ box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.15), 0 -0.5px 0 rgba(0, 0, 0, 0.15), 0.5px 0 0 rgba(0, 0, 0, 0.15), -0.5px 0 0 rgba(0, 0, 0, 0.15), 0 4px 7px rgba(0,0,0,0.15); overflow: hidden; + .menu { + z-index:1; + position: relative; + .content-container { + background: none; + } + .header-container { + border-top-left-radius: @border-radius-base; + border-top-right-radius: @border-radius-base; + background: none; + color: @header-color; + font-weight: bold; + border-bottom: none; + overflow: hidden; + padding: @padding-base-vertical * 1.5 @padding-base-horizontal; + } + .footer-container { + border-bottom-left-radius: @border-radius-base; + border-bottom-right-radius: @border-radius-base; + background: none; + + .item:last-child:hover { + border-bottom-left-radius: @border-radius-base; + border-bottom-right-radius: @border-radius-base; + } + } + } + input[type=text] { border: 1px solid darken(@background-secondary, 10%); border-radius: 3px; @@ -47,5 +80,17 @@ -webkit-mask-image: url('images/tooltip/tooltip-bg-pointer-shadow@2x.png'); background-color: fade(@black, 22%); } - +} + +body.platform-win32 { + .fixed-popover { + border-radius: 0; + + .menu { + .header-container, + .footer-container { + border-radius: 0; + } + } + } }