mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 15:56:10 +08:00
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:
parent
6cb2791642
commit
c6acca8ca3
|
@ -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>“{searchValue}”</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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>“{searchValue}”</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
|
||||
|
|
|
@ -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", ->
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}/>
|
||||
|
||||
<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}/>
|
||||
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TemplatePicker;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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">¶</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">¶</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">¶</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">¶</a>
|
||||
</div>
|
||||
<p>The <code>render</code> method returns a React Virtual DOM element. This code looks
|
||||
like HTML, but don’t be fooled. The CJSX preprocessor converts</p>
|
||||
<p><code><a href="http://facebook.github.io/react/">Hello!</a></code></p>
|
||||
<p>into Javascript objects which describe the HTML you want:</p>
|
||||
<p><code>React.createElement('a', {href: 'http://facebook.github.io/react/'}, 'Hello!')</code></p>
|
||||
<p>We’re rendering a <code>Popover</code> with a <code>Menu</code> inside. These components are part
|
||||
of N1’s standard <code>nylas-component-kit</code> library, and make it easy to build
|
||||
interfaces that match the rest of N1’s UI.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="content"><div class='highlight'><pre> <span class="hljs-attribute">render</span>: <span class="hljs-function">=></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) -> item )</span>, \
|
||||
"itemContent": <span class="hljs-params">( (item) -> 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">¶</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">=></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> =></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">¶</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> =>
|
||||
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">¶</a>
|
||||
</div>
|
||||
<p>Identify the text we want to translate. We need to make sure we
|
||||
don’t translate quoted text.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="content"><div class='highlight'><pre> <span class="hljs-keyword">if</span> bodyQuoteStart > <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">¶</a>
|
||||
</div>
|
||||
<p>Use Node’s <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> =></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">¶</a>
|
||||
</div>
|
||||
<p>The new text of the draft is our translated response, plus any quoted text
|
||||
that we didn’t 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 > <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">¶</a>
|
||||
</div>
|
||||
<p>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.</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> =></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">¶</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> -></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">¶</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">-></span></pre></div></div>
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li id="section-13">
|
||||
<div class="annotation">
|
||||
|
||||
<div class="pilwrap ">
|
||||
<a class="pilcrow" href="#section-13">¶</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">-></span>
|
||||
ComponentRegistry.unregister(TranslateButton)</pre></div></div>
|
||||
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -1,7 +1,7 @@
|
|||
@import "ui-variables";
|
||||
@import "ui-mixins";
|
||||
|
||||
.translate-language-picker .menu {
|
||||
.translate-language-picker {
|
||||
.footer-container {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
110
internal_packages/send-later/lib/send-later-button.jsx
Normal file
110
internal_packages/send-later/lib/send-later-button.jsx
Normal 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> </span>
|
||||
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SendLaterButton.containerStyles = {
|
||||
order: -99,
|
||||
};
|
||||
|
||||
export default SendLaterButton;
|
|
@ -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> </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
|
||||
|
|
105
internal_packages/send-later/spec/send-later-button-spec.jsx
Normal file
105
internal_packages/send-later/spec/send-later-button-spec.jsx
Normal 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()
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
134
internal_packages/thread-snooze/lib/snooze-buttons.jsx
Normal file
134
internal_packages/thread-snooze/lib/snooze-buttons.jsx
Normal 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]}/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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]} />
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
172
spec/components/fixed-popover-spec.jsx
Normal file
172
spec/components/fixed-popover-spec.jsx
Normal 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
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue