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
This commit is contained in:
Juan Tejada 2016-03-09 10:01:18 -08:00
parent 6cb2791642
commit c6acca8ca3
30 changed files with 1476 additions and 1442 deletions

View file

@ -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 <span>{part[0]}<strong>{part.slice(1)}</strong></span>
}
return <strong>{part}</strong>
}
return part
});
return <span>{parts}</span>;
};
_renderFolderIcon = (item)=> {
return (
<RetinaImg
name={`${item.name}.png`}
fallback={'folder.png'}
mode={RetinaImg.Mode.ContentIsMask} />
)
};
_renderCheckbox = (item)=> {
const styles = {}
let checkStatus;
styles.backgroundColor = item.backgroundColor
if (item.usage === 0) {
checkStatus = <span />
} else if (item.usage < item.numThreads) {
checkStatus = (
<RetinaImg
className="check-img dash"
name="tagging-conflicted.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={() => this._onSelectCategory(item)}/>
)
} else {
checkStatus = (
<RetinaImg
className="check-img check"
name="tagging-checkmark.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={() => this._onSelectCategory(item)}/>
)
}
return (
<div className="check-wrap" style={styles}>
<RetinaImg
className="check-img check"
name="tagging-checkbox.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={() => this._onSelectCategory(item)}/>
{checkStatus}
</div>
)
};
_renderCreateNewItem = ({searchValue})=> {
const {account} = this.props
let picName = ''
if (account) {
picName = account.usesLabels() ? 'tag' : 'folder'
}
return (
<div className="category-item category-create-new">
<RetinaImg
name={`${picName}.png`}
className={`category-create-new-${picName}`}
mode={RetinaImg.Mode.ContentIsMask} />
<div className="category-display-name">
<strong>&ldquo;{searchValue}&rdquo;</strong> (create new)
</div>
</div>
)
};
_renderItem = (item)=> {
if (item.divider) {
return <Menu.Item key={item.id} divider={item.divider} />
} 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 <span />
}
return (
<div className="category-item">
{icon}
<div className="category-display-name">
{this._renderBoldedSearchResults(item)}
</div>
</div>
)
};
render() {
const {account} = this.props
let placeholder = ''
if (account) {
placeholder = account.usesLabels() ? 'Label as' : 'Move to folder'
}
const headerComponents = [
<input
type="text"
tabIndex="1"
key="textfield"
className="search"
placeholder={placeholder}
value={this.state.searchValue}
onChange={this._onSearchValueChange} />,
]
return (
<div className="category-picker-popover">
<Menu
ref="menu"
headerComponents={headerComponents}
footerComponents={[]}
items={this.state.categoryData}
itemKey={item => item.id}
itemContent={this._renderItem}
onSelect={this._onSelectCategory}
defaultSelectedIndex={this.state.searchValue === "" ? -1 : 0}
/>
</div>
)
}
}

View file

@ -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 <span></span> 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 = (
<button className={btnClasses} title={tooltip}>
<RetinaImg name={img} mode={RetinaImg.Mode.ContentIsMask}/>
</button>
)
headerComponents = [
<input type="text"
tabIndex="1"
key="textfield"
className="search"
placeholder={placeholder}
value={@state.searchValue}
onChange={@_onSearchValueChange}/>
]
<KeyCommandsRegion globalHandlers={@_keymapHandlers()}>
<Popover className="category-picker"
ref="popover"
onOpened={@_onPopoverOpened}
onClosed={@_onPopoverClosed}
direction="down-align-left"
style={order: -103}
buttonComponent={button}>
<Menu ref="menu"
headerComponents={headerComponents}
footerComponents={[]}
items={@state.categoryData}
itemKey={ (item) -> item.id }
itemContent={@_renderItemContent}
onSelect={@_onSelectCategory}
defaultSelectedIndex={if @state.searchValue is "" then -1 else 0}
/>
</Popover>
</KeyCommandsRegion>
_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 <Menu.Item key={item.id} divider={item.divider} />
else if item.newCategoryItem
return @_renderCreateNewItem(item)
if @_account?.usesLabels()
icon = @_renderCheckbox(item)
else if @_account?.usesFolders()
icon = @_renderFolderIcon(item)
else return <span></span>
<div className="category-item">
{icon}
<div className="category-display-name">
{@_renderBoldedSearchResults(item)}
</div>
</div>
_renderCreateNewItem: ({searchValue, name}) =>
if @_account?.usesLabels()
picName = "tag"
else if @_account?.usesFolders()
picName = "folder"
<div className="category-item category-create-new">
<RetinaImg className={"category-create-new-#{picName}"}
name={"#{picName}.png"}
mode={RetinaImg.Mode.ContentIsMask} />
<div className="category-display-name">
<strong>&ldquo;{searchValue}&rdquo;</strong> (create new)
</div>
</div>
_renderCheckbox: (item) ->
styles = {}
styles.backgroundColor = item.backgroundColor
if item.usage is 0
checkStatus = <span></span>
else if item.usage < item.numThreads
checkStatus = <RetinaImg
className="check-img dash"
name="tagging-conflicted.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={=> @_onSelectCategory(item)}/>
else
checkStatus = <RetinaImg
className="check-img check"
name="tagging-checkmark.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={=> @_onSelectCategory(item)}/>
<div className="check-wrap" style={styles}>
<RetinaImg
className="check-img check"
name="tagging-checkbox.png"
mode={RetinaImg.Mode.ContentPreserve}
onClick={=> @_onSelectCategory(item)}/>
{checkStatus}
</div>
_renderFolderIcon: (item) ->
<RetinaImg name={"#{item.name}.png"} fallback={'folder.png'} mode={RetinaImg.Mode.ContentIsMask} />
_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 <span>{part[0]}<strong>{part[1..-1]}</strong></span>
else
return <strong>{part}</strong>
else return part
return <span>{parts}</span>
_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(
<CategoryPickerPopover
threads={@_threads}
account={@_account} />,
{originRect: buttonRect, direction: 'down'}
)
return
render: =>
return <span /> 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 (
<KeyCommandsRegion style={order: -103} globalHandlers={@_keymapHandlers()}>
<button
ref="button"
title={tooltip}
onClick={@_onOpenCategoryPopover}
className={btnClasses} >
<RetinaImg name={img} mode={RetinaImg.Mode.ContentIsMask}/>
</button>
</KeyCommandsRegion>
)
module.exports = CategoryPicker

View file

@ -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(
<CategoryPicker thread={@testThread} />
<CategoryPickerPopover threads={[@testThread]} account={@account} />
)
@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(
<CategoryPicker thread={@testThread} />
<CategoryPickerPopover threads={[@testThread]} account={@account} />
)
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", ->

View file

@ -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;

View file

@ -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 = (
<button className="btn btn-toolbar narrow" title="Insert email template…">
<RetinaImg url="nylas://composer-templates/assets/icon-composer-templates@2x.png" mode={RetinaImg.Mode.ContentIsMask}/>
&nbsp;
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
);
_onClickButton = ()=> {
const buttonRect = React.findDOMNode(this).getBoundingClientRect()
Actions.openPopover(
this._renderPopover(),
{originRect: buttonRect, direction: 'up'}
)
};
_renderPopover() {
const headerComponents = [
<input type="text"
tabIndex="1"
key="textfield"
className="search"
value={this.state.searchValue}
onChange={this._onSearchValueChange}/>,
<input
type="text"
tabIndex="1"
key="textfield"
className="search"
value={this.state.searchValue}
onChange={this._onSearchValueChange} />,
];
const footerComponents = [
@ -86,19 +87,30 @@ class TemplatePicker extends React.Component {
];
return (
<Popover ref="popover" className="template-picker pull-right" buttonComponent={button}>
<Menu ref="menu"
headerComponents={headerComponents}
footerComponents={footerComponents}
items={this.state.templates}
itemKey={ (item)=> item.id }
itemContent={ (item)=> item.name }
onSelect={this._onChooseTemplate.bind(this)}
/>
</Popover>
<Menu
className="template-picker"
headerComponents={headerComponents}
footerComponents={footerComponents}
items={this.state.templates}
itemKey={ (item)=> item.id }
itemContent={ (item)=> item.name }
onSelect={this._onChooseTemplate.bind(this)}
/>
);
}
render() {
return (
<button
className="btn btn-toolbar narrow pull-right"
onClick={this._onClickButton}
title="Insert email template…">
<RetinaImg url="nylas://composer-templates/assets/icon-composer-templates@2x.png" mode={RetinaImg.Mode.ContentIsMask}/>
&nbsp;
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
);
}
}
export default TemplatePicker;

View file

@ -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;
}
}

View file

@ -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
#
# `<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)

View file

@ -1,312 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Translation Plugin</title>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, target-densitydpi=160dpi, initial-scale=1.0; maximum-scale=1.0; user-scalable=0;">
<link rel="stylesheet" media="all" href="docco.css" />
</head>
<body>
<div id="container">
<div id="background"></div>
<ul class="sections">
<li id="section-1">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-1">&#182;</a>
</div>
<h1 id="translation-plugin">Translation Plugin</h1>
<p>Last Revised: April 23, 2015 by Ben Gotow</p>
<p>TranslateButton is a simple React component that allows you to select
a language from a popup menu and translates draft text into that language.</p>
</div>
<div class="content"><div class='highlight'><pre>
request = <span class="hljs-built_in">require</span> <span class="hljs-string">'request'</span>
{Utils,
React,
ComponentRegistry,
DraftStore} = <span class="hljs-built_in">require</span> <span class="hljs-string">'nylas-exports'</span>
{Menu,
RetinaImg,
Popover} = <span class="hljs-built_in">require</span> <span class="hljs-string">'nylas-component-kit'</span>
YandexTranslationURL = <span class="hljs-string">'https://translate.yandex.net/api/v1.5/tr.json/translate'</span>
YandexTranslationKey = <span class="hljs-string">'trnsl.1.1.20150415T044616Z.24814c314120d022.0a339e2bc2d2337461a98d5ec9863fc46e42735e'</span>
YandexLanguages =
<span class="hljs-string">'English'</span>: <span class="hljs-string">'en'</span>
<span class="hljs-string">'Spanish'</span>: <span class="hljs-string">'es'</span>
<span class="hljs-string">'Russian'</span>: <span class="hljs-string">'ru'</span>
<span class="hljs-string">'Chinese'</span>: <span class="hljs-string">'zh'</span>
<span class="hljs-string">'French'</span>: <span class="hljs-string">'fr'</span>
<span class="hljs-string">'German'</span>: <span class="hljs-string">'de'</span>
<span class="hljs-string">'Italian'</span>: <span class="hljs-string">'it'</span>
<span class="hljs-string">'Japanese'</span>: <span class="hljs-string">'ja'</span>
<span class="hljs-string">'Portuguese'</span>: <span class="hljs-string">'pt'</span>
<span class="hljs-string">'Korean'</span>: <span class="hljs-string">'ko'</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">TranslateButton</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">React</span>.<span class="hljs-title">Component</span></span></pre></div></div>
</li>
<li id="section-2">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-2">&#182;</a>
</div>
<p>Adding a <code>displayName</code> makes debugging React easier</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-property">@displayName</span>: <span class="hljs-string">'TranslateButton'</span></pre></div></div>
</li>
<li id="section-3">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-3">&#182;</a>
</div>
<p>Since our button is being injected into the Composer Footer,
we receive the local id of the current draft as a <code>prop</code> (a read-only
property). Since our code depends on this prop, we mark it as a requirement.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-property">@propTypes</span>:
<span class="hljs-attribute">draftLocalId</span>: React.PropTypes.string.isRequired</pre></div></div>
</li>
<li id="section-4">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-4">&#182;</a>
</div>
<p>The <code>render</code> method returns a React Virtual DOM element. This code looks
like HTML, but dont be fooled. The CJSX preprocessor converts</p>
<p><code>&lt;a href=&quot;http://facebook.github.io/react/&quot;&gt;Hello!&lt;/a&gt;</code></p>
<p>into Javascript objects which describe the HTML you want:</p>
<p><code>React.createElement(&#39;a&#39;, {href: &#39;http://facebook.github.io/react/&#39;}, &#39;Hello!&#39;)</code></p>
<p>Were rendering a <code>Popover</code> with a <code>Menu</code> inside. These components are part
of N1s standard <code>nylas-component-kit</code> library, and make it easy to build
interfaces that match the rest of N1s UI.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-attribute">render</span>: <span class="hljs-function">=&gt;</span>
React.createElement(Popover, {<span class="hljs-string">"ref"</span>: <span class="hljs-string">"popover"</span>, \
<span class="hljs-string">"className"</span>: <span class="hljs-string">"translate-language-picker pull-right"</span>, \
<span class="hljs-string">"buttonComponent"</span>: (<span class="hljs-property">@_renderButton</span>())},
React.createElement(Menu, {<span class="hljs-string">"items"</span>: ( Object.keys(YandexLanguages) ), \
<span class="hljs-string">"itemKey"</span>: <span class="hljs-function"><span class="hljs-params">( (item) -&gt; item )</span>, \
"itemContent": <span class="hljs-params">( (item) -&gt; item )</span>, \
"onSelect": <span class="hljs-params">(<span class="hljs-property">@_onTranslate</span>)</span>
})
)
</span></pre></div></div>
</li>
<li id="section-5">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-5">&#182;</a>
</div>
<p>Helper method to render the button that will activate the popover. Using the
<code>RetinaImg</code> component makes it easy to display an image from our package.
<code>RetinaImg</code> will automatically chose the best image format for our display.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-attribute">_renderButton</span>: <span class="hljs-function">=&gt;</span>
React.createElement(<span class="hljs-string">"button"</span>, {<span class="hljs-string">"className"</span>: <span class="hljs-string">"btn btn-toolbar"</span>}, <span class="hljs-string">"""
Translate
"""</span>, React.createElement(RetinaImg, {<span class="hljs-string">"name"</span>: <span class="hljs-string">"toolbar-chevron.png"</span>})
)
<span class="hljs-attribute">_onTranslate</span>: <span class="hljs-function"><span class="hljs-params">(lang)</span> =&gt;</span>
<span class="hljs-property">@refs</span>.popover.close()</pre></div></div>
</li>
<li id="section-6">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-6">&#182;</a>
</div>
<p>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.</p>
</div>
<div class="content"><div class='highlight'><pre> session = DraftStore.sessionForLocalId(<span class="hljs-property">@props</span>.draftLocalId)
session.prepare().<span class="hljs-keyword">then</span> =&gt;
body = session.draft().body
bodyQuoteStart = Utils.quotedTextIndex(body)</pre></div></div>
</li>
<li id="section-7">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-7">&#182;</a>
</div>
<p>Identify the text we want to translate. We need to make sure we
dont translate quoted text.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-keyword">if</span> bodyQuoteStart &gt; <span class="hljs-number">0</span>
text = body.substr(<span class="hljs-number">0</span>, bodyQuoteStart)
<span class="hljs-keyword">else</span>
text = body
query =
<span class="hljs-attribute">key</span>: YandexTranslationKey
<span class="hljs-attribute">lang</span>: YandexLanguages[lang]
<span class="hljs-attribute">text</span>: text
<span class="hljs-attribute">format</span>: <span class="hljs-string">'html'</span></pre></div></div>
</li>
<li id="section-8">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-8">&#182;</a>
</div>
<p>Use Nodes <code>request</code> library to perform the translation using the Yandex API.</p>
</div>
<div class="content"><div class='highlight'><pre> request {<span class="hljs-attribute">url</span>: YandexTranslationURL, <span class="hljs-attribute">qs</span>: query}, <span class="hljs-function"><span class="hljs-params">(error, resp, data)</span> =&gt;</span>
<span class="hljs-keyword">return</span> <span class="hljs-property">@_onError</span>(error) <span class="hljs-keyword">unless</span> resp.statusCode <span class="hljs-keyword">is</span> <span class="hljs-number">200</span>
json = JSON.parse(data)</pre></div></div>
</li>
<li id="section-9">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-9">&#182;</a>
</div>
<p>The new text of the draft is our translated response, plus any quoted text
that we didnt process.</p>
</div>
<div class="content"><div class='highlight'><pre> translated = json.text.join(<span class="hljs-string">''</span>)
translated += body.substr(bodyQuoteStart) <span class="hljs-keyword">if</span> bodyQuoteStart &gt; <span class="hljs-number">0</span></pre></div></div>
</li>
<li id="section-10">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-10">&#182;</a>
</div>
<p>To update the draft, we add the new body to its session. The session object
automatically marshalls changes to the database and ensures that others accessing
the same draft are notified of changes.</p>
</div>
<div class="content"><div class='highlight'><pre> session.changes.add(<span class="hljs-attribute">body</span>: translated)
session.changes.commit()
<span class="hljs-attribute">_onError</span>: <span class="hljs-function"><span class="hljs-params">(error)</span> =&gt;</span>
<span class="hljs-property">@refs</span>.popover.close()
dialog = <span class="hljs-built_in">require</span>(<span class="hljs-string">'remote'</span>).<span class="hljs-built_in">require</span>(<span class="hljs-string">'dialog'</span>)
dialog.showErrorBox(<span class="hljs-string">'Geolocation Failed'</span>, error.toString())
<span class="hljs-built_in">module</span>.exports =</pre></div></div>
</li>
<li id="section-11">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-11">&#182;</a>
</div>
<p>Activate is called when the package is loaded. If your package previously
saved state using <code>serialize</code> it is provided.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-attribute">activate</span>: <span class="hljs-function"><span class="hljs-params">(<span class="hljs-property">@state</span>)</span> -&gt;</span>
ComponentRegistry.register TranslateButton,
<span class="hljs-attribute">role</span>: <span class="hljs-string">'Composer:ActionButton'</span></pre></div></div>
</li>
<li id="section-12">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-12">&#182;</a>
</div>
<p>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.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-attribute">serialize</span>: <span class="hljs-function">-&gt;</span></pre></div></div>
</li>
<li id="section-13">
<div class="annotation">
<div class="pilwrap ">
<a class="pilcrow" href="#section-13">&#182;</a>
</div>
<p>This <strong>optional</strong> 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.</p>
</div>
<div class="content"><div class='highlight'><pre> <span class="hljs-attribute">deactivate</span>: <span class="hljs-function">-&gt;</span>
ComponentRegistry.unregister(TranslateButton)</pre></div></div>
</li>
</ul>
</div>
</body>
</html>

View file

@ -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 = [
<span>Translate:</span>,
];
return (
<Menu
className="translate-language-picker"
items={ Object.keys(YandexLanguages) }
itemKey={ (item)=> 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
// `<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 `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 (
<button
className="btn btn-toolbar"
className="btn btn-toolbar pull-right"
onClick={this._onClickTranslateButton}
title="Translate email body…">
<RetinaImg
mode={RetinaImg.Mode.ContentIsMask}
@ -108,37 +145,6 @@ class TranslateButton extends React.Component {
name="icon-composer-dropdown.png"
mode={RetinaImg.Mode.ContentIsMask}/>
</button>
)
}
// 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() {
const headerComponents = [
<span>Translate:</span>,
];
return (
<Popover ref="popover"
className="translate-language-picker pull-right"
buttonComponent={this._renderButton()}>
<Menu items={ Object.keys(YandexLanguages) }
itemKey={ (item)=> item }
itemContent={ (item)=> item }
headerComponents={headerComponents}
defaultSelectedIndex={-1}
onSelect={this._onTranslate}
/>
</Popover>
);
}
}

View file

@ -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

View file

@ -1,7 +1,7 @@
@import "ui-variables";
@import "ui-mixins";
.translate-language-picker .menu {
.translate-language-picker {
.footer-container {
display: none;
}

View file

@ -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()
}

View file

@ -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(
<SendLaterPopover
scheduledDate={this.state.scheduledDate}
onSendLater={this.onSendLater}
onCancelSendLater={this.onCancelSendLater} />,
{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 (
<button className={className} title="Saving send date...">
<RetinaImg
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentDark}
style={{width: 14, height: 14}}/>
</button>
);
}
let dateInterpretation;
if (scheduledDate) {
className += ' btn-enabled';
const momentDate = DateUtils.futureDateFromString(scheduledDate);
if (momentDate) {
dateInterpretation = <span className="at">Sending in {momentDate.fromNow(true)}</span>;
}
}
return (
<button className={className} title="Send later…" onClick={this.onClick}>
<RetinaImg name="icon-composer-sendlater.png" mode={RetinaImg.Mode.ContentIsMask}/>
{dateInterpretation}
<span>&nbsp;</span>
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
);
}
}
SendLaterButton.containerStyles = {
order: -99,
};
export default SendLaterButton;

View file

@ -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 (
<div className="send-later-option">{optionKey}<span className="time">{formatted}</span></div>
);
}
renderButton() {
const {scheduledDate} = this.state;
let className = 'btn btn-toolbar btn-send-later';
if (scheduledDate === 'saving') {
return (
<button className={className} title="Saving send date...">
<RetinaImg
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentDark}
style={{width: 14, height: 14}}/>
</button>
);
}
let dateInterpretation;
if (scheduledDate) {
className += ' btn-enabled';
const momentDate = DateUtils.futureDateFromString(scheduledDate);
if (momentDate) {
dateInterpretation = <span className="at">Sending in {momentDate.fromNow(true)}</span>;
}
}
return (
<button className={className} title="Send later…">
<RetinaImg name="icon-composer-sendlater.png" mode={RetinaImg.Mode.ContentIsMask}/>
{dateInterpretation}
<span>&nbsp;</span>
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
</button>
<div className="send-later-option">
{optionKey}
<span className="time">{formatted}</span>
</div>
);
}
render() {
const headerComponents = [
<span>Send later:</span>,
<span key="send-later-header">Send later:</span>,
]
const footerComponents = [
<div key="divider" className="divider" />,
@ -137,11 +67,11 @@ class SendLaterPopover extends Component {
onSubmitDate={this.onSelectCustomOption} />,
];
if (this.state.scheduledDate) {
if (this.props.scheduledDate) {
footerComponents.push(<div key="divider-unschedule" className="divider" />)
footerComponents.push(
<div className="cancel-section" key="cancel-section">
<button className="btn" onClick={this.onCancelSendLater}>
<button className="btn btn-cancel" onClick={this.props.onCancelSendLater}>
Unschedule Send
</button>
</div>
@ -149,28 +79,21 @@ class SendLaterPopover extends Component {
}
return (
<Popover
ref="popover"
style={{order: -103}}
className="send-later"
buttonComponent={this.renderButton()}>
<Menu ref="menu"
items={ Object.keys(SendLaterOptions) }
itemKey={ (item)=> item }
itemContent={this.renderMenuOption}
defaultSelectedIndex={-1}
headerComponents={headerComponents}
footerComponents={footerComponents}
onSelect={this.onSelectMenuOption}
/>
</Popover>
<div
className="send-later">
<Menu
ref="menu"
items={Object.keys(SendLaterOptions)}
itemKey={item => item}
itemContent={this.renderMenuOption}
defaultSelectedIndex={-1}
headerComponents={headerComponents}
footerComponents={footerComponents}
onSelect={this.onSelectMenuOption} />
</div>
);
}
}
SendLaterPopover.containerStyles = {
order: -99,
};
export default SendLaterPopover

View file

@ -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(<SendLaterButton {...props} draftClientId="1" />);
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()
});
});
});

View file

@ -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(<SendLaterPopover {...props} draftClientId="1" />);
if (props.initialState) {
popover.setState(props.initialState)
}
return popover
return renderIntoDocument(
<SendLaterPopover
scheduledDate={null}
onSendLater={()=>{}}
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()
});
});
});

View file

@ -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(
<SnoozePopoverBody
<SnoozePopover
threads={[item]}
swipeCallback={callback}
closePopover={Actions.closePopover}/>,
rect,
"right"
swipeCallback={callback} />,
{originRect, direction: 'right', fallbackDirection: 'down'}
)
return props

View file

@ -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()

View file

@ -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(
<SnoozePopover
threads={this.props.threads}
closePopover={Actions.closePopover} />,
{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 <span />;
}
return (
<div
title="Snooze"
className={"snooze-button " + this.props.className}
onClick={this.onClick}>
{this.props.renderImage ?
<RetinaImg
name="toolbar-snooze.png"
mode={RetinaImg.Mode.ContentIsMask} /> :
void 0
}
</div>
);
}
}
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 <span />;
}
return (
<SnoozeButton
direction="left"
renderImage={false}
threads={[this.props.thread]}
className="btn action action-snooze"
getBoundingClientRect={this.getBoundingClientRect} />
);
}
}
export class BulkThreadSnooze extends Component {
static displayName = 'BulkThreadSnooze';
static propTypes = {
items: PropTypes.array,
};
static containerRequired = false;
render() {
if (!FocusedPerspectiveStore.current().isInbox()) {
return <span />;
}
return (
<SnoozeButton threads={this.props.items}/>
);
}
}
export class ToolbarSnooze extends Component {
static displayName = 'ToolbarSnooze';
static propTypes = {
thread: PropTypes.object,
};
static containerRequired = false;
render() {
if (!FocusedPerspectiveStore.current().isInbox()) {
return <span />;
}
return (
<SnoozeButton threads={[this.props.thread]}/>
);
}
}

View file

@ -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 (
<div
key={itemLabel}
className="snooze-item"
onClick={this.onSnooze.bind(this, date, itemLabel)}>
<RetinaImg
url={iconPath}
mode={RetinaImg.Mode.ContentIsMask} />
{itemLabel}
</div>
)
};
renderRow = (options, idx)=> {
const items = _.map(options, this.renderItem);
return (
<div key={`snooze-popover-row-${idx}`} className="snooze-row">
{items}
</div>
);
};
render() {
const rows = SnoozeOptions.map(this.renderRow);
return (
<div className="snooze-container" tabIndex="-1">
{rows}
<DateInput
className="snooze-input"
dateFormat={DATE_FORMAT_LONG}
onSubmitDate={this.onSelectCustomDate} />
</div>
);
}
}
export default SnoozePopoverBody;

View file

@ -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 (
<div
key={itemLabel}
className="snooze-item"
onClick={this.onSnooze.bind(this, date, itemLabel)}>
<RetinaImg
url={iconPath}
mode={RetinaImg.Mode.ContentIsMask} />
{itemLabel}
</div>
)
};
renderRow = (options, idx)=> {
const items = _.map(options, this.renderItem);
return (
<div key={`snooze-popover-row-${idx}`} className="snooze-row">
{items}
</div>
);
};
render() {
const {buttonComponent, direction, popoverStyle, pointerStyle, threads} = this.props
const rows = SnoozeOptions.map(this.renderRow);
return (
<Popover
ref="popover"
className="snooze-popover"
direction={direction || 'down-align-left'}
buttonComponent={buttonComponent}
popoverStyle={popoverStyle}
pointerStyle={pointerStyle}
onOpened={()=> Actions.closePopover()}>
<SnoozePopoverBody
threads={threads}
closePopover={this.closePopover}/>
</Popover>
<div className="snooze-popover" tabIndex="-1">
{rows}
<DateInput
className="snooze-input"
dateFormat={DATE_FORMAT_LONG}
onSubmitDate={this.onSelectCustomDate} />
</div>
);
}

View file

@ -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(
<SnoozePopoverBody threads={[thread]} closePopover={Actions.closePopover}/>,
rect,
"left"
);
};
static containerRequired = false;
render() {
if (!FocusedPerspectiveStore.current().isInbox()) {
return <span />;
}
return <div title="Snooze" className="btn action action-snooze" onClick={this.onClick}/>
}
}
export default QuickActionSnoozeButton

View file

@ -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 = (
<button
className="btn btn-toolbar btn-snooze"
title="Snooze">
<RetinaImg
name="toolbar-snooze.png"
mode={RetinaImg.Mode.ContentIsMask} />
</button>
)
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 <span />;
}
return (
<SnoozePopover
direction="down"
buttonComponent={toolbarButton}
threads={this.props.items} />
);
}
}
export class ToolbarSnooze extends Component {
static displayName = 'ToolbarSnooze';
static propTypes = {
thread: PropTypes.object,
};
static containerRequired = false;
render() {
if (!FocusedPerspectiveStore.current().isInbox()) {
return <span />;
}
const pointerStyle = {
right: 18,
display: 'block',
};
const popoverStyle = {
transform: 'translate(0, 15px)',
}
return (
<SnoozePopover
pointerStyle={pointerStyle}
popoverStyle={popoverStyle}
buttonComponent={toolbarButton}
threads={[this.props.thread]} />
);
}
}

View file

@ -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;

View file

@ -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(<FixedPopover {...props}/>);
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
});
});

View file

@ -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 <span />;
}
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 (
<div
style={containerStyle}
className="fixed-popover-container"
onKeyDown={this._onKeyDown}
onBlur={this._onBlur}>
<div className="fixed-popover" style={popoverStyle}>
{children}
<div>
<div ref="blurTrap" className="fixed-popover-blur-trap" style={blurTrapStyle}/>
<div
style={containerStyle}
className="fixed-popover-container"
onKeyDown={this.onKeyDown}
onBlur={this.onBlur}>
<div ref="popover" className="fixed-popover" style={popoverStyle}>
{children}
</div>
<div className="fixed-popover-pointer" style={pointerStyle} />
<div className="fixed-popover-pointer shadow" style={pointerStyle} />
</div>
<div className="fixed-popover-pointer" style={pointerStyle} />
<div className="fixed-popover-pointer shadow" style={pointerStyle} />
</div>
);
}
}
export default FixedPopover;

View file

@ -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)

View file

@ -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) {

View file

@ -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'

View file

@ -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;
}
}
}
}