mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-04 03:34:28 +08:00
feat(mail-merge): Add mail merge plugin
Summary: Adds Mail Merge Plugin - Adds new table components to component kit - Adds new extension points to allow dragging and dropping into composer contenteditable and participant fields and customizing participant fields - Adds new decorators and other misc updates - #1608 Test Plan: TODO Reviewers: bengotow, evan Reviewed By: bengotow, evan Differential Revision: https://phab.nylas.com/D2895
This commit is contained in:
parent
a8413c8e2f
commit
021eac7679
41 changed files with 2004 additions and 97 deletions
BIN
internal_packages/composer-mail-merge/icon.png
Normal file
BIN
internal_packages/composer-mail-merge/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
|
@ -0,0 +1,27 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
|
||||
|
||||
|
||||
class MailMergeButton extends Component {
|
||||
static displayName = 'MailMergeButton'
|
||||
|
||||
static propTypes = {
|
||||
session: PropTypes.object,
|
||||
draftClientId: PropTypes.string,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
const {draftClientId, session} = props
|
||||
this.session = mailMergeSessionForDraft(draftClientId, session)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="btn btn-small" onClick={this.session.toggleWorkspace}>Merge</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default MailMergeButton
|
|
@ -0,0 +1,41 @@
|
|||
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
|
||||
import {DataTransferTypes} from './mail-merge-constants'
|
||||
|
||||
|
||||
export const name = 'MailMergeComposerExtension'
|
||||
|
||||
function updateCursorPosition({editor, event}) {
|
||||
const {clientX, clientY} = event
|
||||
const range = document.caretRangeFromPoint(clientX, clientY);
|
||||
range.collapse()
|
||||
editor.select(range)
|
||||
return range
|
||||
}
|
||||
|
||||
export function onDragOver({editor, event}) {
|
||||
updateCursorPosition({editor, event})
|
||||
}
|
||||
|
||||
export function onDrop({editor, event}) {
|
||||
const {dataTransfer} = event
|
||||
const range = updateCursorPosition({editor, event})
|
||||
|
||||
const colIdx = dataTransfer.getData(DataTransferTypes.ColIdx)
|
||||
const draftClientId = dataTransfer.getData(DataTransferTypes.DraftId)
|
||||
const mailMergeSession = mailMergeSessionForDraft(draftClientId)
|
||||
|
||||
if (!mailMergeSession) {
|
||||
return
|
||||
}
|
||||
|
||||
const newNode = document.createElement('span')
|
||||
newNode.setAttribute('class', 'mail-merge-token')
|
||||
newNode.setAttribute('contenteditable', false)
|
||||
newNode.setAttribute('tabindex', -1)
|
||||
newNode.setAttribute('style', 'border: 1px solid red;')
|
||||
newNode.setAttribute('data-col-idx', colIdx)
|
||||
newNode.setAttribute('data-draft-client-id', draftClientId)
|
||||
|
||||
range.insertNode(newNode)
|
||||
mailMergeSession.linkToDraft({colIdx, field: 'body'})
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import plugin from '../package.json'
|
||||
|
||||
export const PLUGIN_ID = plugin.appId[NylasEnv.config.get("env")];
|
||||
export const PLUGIN_NAME = "Mail Merge"
|
||||
export const DEBUG = true
|
||||
|
||||
export const ParticipantFields = ['to', 'cc', 'bcc']
|
||||
|
||||
export const DataTransferTypes = {
|
||||
ColIdx: 'mail-merge:col-idx',
|
||||
DraftId: 'mail-merge:draft-client-id',
|
||||
}
|
||||
|
||||
export const TableActionNames = [
|
||||
'addColumn',
|
||||
'removeColumn',
|
||||
'addRow',
|
||||
'removeRow',
|
||||
'updateCell',
|
||||
'shiftSelection',
|
||||
'setSelection',
|
||||
]
|
||||
|
||||
export const WorkspaceActionNames = [
|
||||
'toggleWorkspace',
|
||||
'linkToDraft',
|
||||
'unlinkFromDraft',
|
||||
]
|
||||
|
||||
export const ActionNames = [...TableActionNames, ...WorkspaceActionNames]
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
import MailMergeWorkspace from './mail-merge-workspace'
|
||||
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
|
||||
|
||||
|
||||
class MailMergeContainer extends Component {
|
||||
static displayName = 'MailMergeContainer'
|
||||
|
||||
static propTypes = {
|
||||
draftClientId: PropTypes.string,
|
||||
session: PropTypes.object,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
const {draftClientId, session} = props
|
||||
this.unsubscribers = []
|
||||
this.session = mailMergeSessionForDraft(draftClientId, session)
|
||||
this.state = this.session.state
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribers = [
|
||||
this.session.listen(::this.onSessionChange),
|
||||
]
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
// Make sure we only update if new state has been set
|
||||
// We do not care about our other props
|
||||
return (
|
||||
this.props.draftClientId !== nextProps.draftClientId ||
|
||||
this.state !== nextState
|
||||
)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribers.forEach(unsub => unsub())
|
||||
}
|
||||
|
||||
onSessionChange() {
|
||||
this.setState(this.session.state)
|
||||
// Nasty side effects
|
||||
this.updateComposerBody(this.session.state)
|
||||
}
|
||||
|
||||
updateComposerBody({tableData, selection, linkedFields}) {
|
||||
// TODO I don't want to reach into the DOM :(
|
||||
const {rows} = tableData
|
||||
const {draftClientId} = this.props
|
||||
|
||||
linkedFields.body.forEach((colIdx) => {
|
||||
const selector = `[contenteditable] .mail-merge-token[data-col-idx="${colIdx}"][data-draft-client-id="${draftClientId}"]`
|
||||
const nodes = Array.from(document.querySelectorAll(selector))
|
||||
const selectionValue = rows[selection.row][colIdx] || "No value selected"
|
||||
nodes.forEach(node => { node.innerText = selectionValue })
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const {draftClientId} = this.props
|
||||
return (
|
||||
<MailMergeWorkspace
|
||||
{...this.state}
|
||||
session={this.session}
|
||||
draftClientId={draftClientId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
MailMergeContainer.containerRequired = false
|
||||
|
||||
export default MailMergeContainer
|
|
@ -0,0 +1,118 @@
|
|||
import NylasStore from 'nylas-store'
|
||||
import * as TableDataReducer from './table-data-reducer'
|
||||
import * as WorkspaceDataReducer from './workspace-data-reducer'
|
||||
import {ActionNames, PLUGIN_ID, DEBUG} from './mail-merge-constants'
|
||||
|
||||
|
||||
const sessions = new Map()
|
||||
|
||||
function computeNextState({name, args = []}, currentState = {}, reducers = []) {
|
||||
if (reducers.length === 0) {
|
||||
return currentState
|
||||
}
|
||||
return reducers.reduce((state, reducer) => {
|
||||
const reduced = (reducer[name] || () => state)(state, ...args)
|
||||
return {...state, ...reduced}
|
||||
}, currentState)
|
||||
}
|
||||
|
||||
export class MailMergeDraftEditingSession extends NylasStore {
|
||||
|
||||
constructor(session) {
|
||||
super()
|
||||
this._session = session
|
||||
this._reducers = [
|
||||
TableDataReducer,
|
||||
WorkspaceDataReducer,
|
||||
]
|
||||
this._state = {}
|
||||
this.initializeState()
|
||||
this.initializeActionHandlers()
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this._state
|
||||
}
|
||||
|
||||
draft() {
|
||||
return this._session.draft()
|
||||
}
|
||||
|
||||
initializeState() {
|
||||
const draft = this._session.draft()
|
||||
const savedMetadata = draft.metadataForPluginId(PLUGIN_ID)
|
||||
const shouldLoadSavedData = (
|
||||
savedMetadata &&
|
||||
savedMetadata.tableData &&
|
||||
savedMetadata.linkedFields
|
||||
)
|
||||
const action = {name: 'initialState'}
|
||||
if (shouldLoadSavedData) {
|
||||
const loadedState = this.dispatch({name: 'fromJSON'}, savedMetadata)
|
||||
this._state = this.dispatch(action, loadedState)
|
||||
} else {
|
||||
this._state = this.dispatch(action)
|
||||
}
|
||||
}
|
||||
|
||||
initializeActionHandlers() {
|
||||
ActionNames.forEach((actionName) => {
|
||||
// TODO ES6 Proxies would be nice here
|
||||
this[actionName] = this.actionHandler(actionName).bind(this)
|
||||
})
|
||||
}
|
||||
|
||||
dispatch(action, initialState = this._state) {
|
||||
const newState = computeNextState(action, initialState, this._reducers)
|
||||
if (DEBUG) {
|
||||
console.log('--> action', action.name)
|
||||
console.dir(action)
|
||||
console.log('--> prev state')
|
||||
console.dir(initialState)
|
||||
console.log('--> new state')
|
||||
console.dir(newState)
|
||||
}
|
||||
return newState
|
||||
}
|
||||
|
||||
actionHandler(actionName) {
|
||||
return (...args) => {
|
||||
this._state = this.dispatch({name: actionName, args})
|
||||
|
||||
// Defer calling `saveToSession` to make sure our state changes are triggered
|
||||
// before the draft changes
|
||||
this.trigger()
|
||||
setImmediate(this.saveToDraftSession.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
saveToDraftSession() {
|
||||
// TODO
|
||||
// - What should we save in metadata?
|
||||
// - The entire table data?
|
||||
// - A reference to a statically hosted file?
|
||||
// - Attach csv as a file to the "base" or "template" draft?
|
||||
// const draft = this._session.draft()
|
||||
const {linkedFields, tableData} = this._state
|
||||
const draftChanges = this.dispatch({name: 'toDraft', args: [this._state]}, {})
|
||||
const serializedState = this.dispatch({name: 'toJSON'}, {linkedFields, tableData})
|
||||
|
||||
this._session.changes.add(draftChanges)
|
||||
this._session.changes.addPluginMetadata(PLUGIN_ID, serializedState)
|
||||
this._session.changes.commit()
|
||||
// TODO
|
||||
// Do I need to call this._session.changes.commit?
|
||||
}
|
||||
}
|
||||
|
||||
export function mailMergeSessionForDraft(draftId, draftSession) {
|
||||
if (sessions.has(draftId)) {
|
||||
return sessions.get(draftId)
|
||||
}
|
||||
if (!draftSession) {
|
||||
return null
|
||||
}
|
||||
const sess = new MailMergeDraftEditingSession(draftSession)
|
||||
sessions.set(draftId, sess)
|
||||
return sess
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
import React, {Component, PropTypes} from 'react';
|
||||
import classnames from 'classnames'
|
||||
import {RegExpUtils} from 'nylas-exports'
|
||||
import {DropZone, TokenizingTextField} from 'nylas-component-kit'
|
||||
import {DataTransferTypes} from './mail-merge-constants'
|
||||
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
|
||||
|
||||
|
||||
class MailMergeParticipantToken extends Component {
|
||||
static propTypes = {
|
||||
token: PropTypes.shape({
|
||||
selectionValue: PropTypes.any,
|
||||
}),
|
||||
}
|
||||
|
||||
render() {
|
||||
const {token: {selectionValue}} = this.props
|
||||
if (!selectionValue) {
|
||||
return <span>No value selected</span>
|
||||
}
|
||||
return <span>{selectionValue}</span>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MailMergeParticipantsTextField extends Component {
|
||||
static displayName = 'MailMergeParticipantsTextField'
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
field: PropTypes.string,
|
||||
session: PropTypes.object,
|
||||
draftClientId: PropTypes.string,
|
||||
onAdd: PropTypes.func,
|
||||
onRemove: PropTypes.func,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.session = mailMergeSessionForDraft(props.draftClientId, props.session)
|
||||
this.state = {isDropping: false, ...this.session.state}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribers = [
|
||||
this.session.listen(::this.onSessionChange),
|
||||
]
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribers.forEach(unsub => unsub())
|
||||
}
|
||||
|
||||
// Called when a token is dragged and dropped in a new field
|
||||
onAddToken(...args) {
|
||||
const tokenToAdd = args[0][0]
|
||||
if (args.length > 1 || !tokenToAdd) { return }
|
||||
|
||||
const {field} = this.props
|
||||
const {colIdx} = tokenToAdd
|
||||
this.session.unlinkFromDraft({colIdx, field: tokenToAdd.field})
|
||||
this.session.linkToDraft({colIdx, field})
|
||||
}
|
||||
|
||||
onRemoveToken([tokenToDelete]) {
|
||||
const {field} = this.props
|
||||
const {colIdx} = tokenToDelete
|
||||
this.session.unlinkFromDraft({colIdx, field})
|
||||
}
|
||||
|
||||
onDrop(event) {
|
||||
const {dataTransfer} = event
|
||||
const {field} = this.props
|
||||
const colIdx = dataTransfer.getData(DataTransferTypes.ColIdx)
|
||||
this.session.linkToDraft({colIdx, field})
|
||||
}
|
||||
|
||||
onSessionChange() {
|
||||
this.setState(this.session.state)
|
||||
}
|
||||
|
||||
onDragStateChange({isDropping}) {
|
||||
this.setState({isDropping})
|
||||
}
|
||||
|
||||
tokenIsValid({selectionValue}) {
|
||||
return (
|
||||
selectionValue &&
|
||||
selectionValue.match(RegExpUtils.emailRegex()) != null
|
||||
)
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.refs.textField.focus()
|
||||
}
|
||||
|
||||
shouldAcceptDrop(event) {
|
||||
const {dataTransfer} = event
|
||||
return !!dataTransfer.getData(DataTransferTypes.ColIdx)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {field, className} = this.props
|
||||
const {isWorkspaceOpen, tableData: {rows}, selection, linkedFields, isDropping} = this.state
|
||||
|
||||
if (!isWorkspaceOpen) {
|
||||
return <TokenizingTextField ref="textField" {...this.props} />
|
||||
}
|
||||
|
||||
const classes = classnames({
|
||||
'mail-merge-participants-text-field': true,
|
||||
'is-dropping': isDropping,
|
||||
[className]: true,
|
||||
})
|
||||
const tokens = (
|
||||
Array.from(linkedFields[field])
|
||||
.map(colIdx => ({field, colIdx, selectionValue: rows[selection.row][colIdx]}))
|
||||
)
|
||||
|
||||
return (
|
||||
<DropZone
|
||||
onDrop={::this.onDrop}
|
||||
onDragStateChange={::this.onDragStateChange}
|
||||
shouldAcceptDrop={::this.shouldAcceptDrop}
|
||||
>
|
||||
<TokenizingTextField
|
||||
{...this.props}
|
||||
ref="textField"
|
||||
className={classes}
|
||||
tokens={tokens}
|
||||
tokenKey={(f) => `${f.colIdx}-${f.field}`}
|
||||
tokenRenderer={MailMergeParticipantToken}
|
||||
tokenIsValid={::this.tokenIsValid}
|
||||
onRequestCompletions={() => []}
|
||||
completionNode={() => <span />}
|
||||
onAdd={::this.onAddToken}
|
||||
onRemove={::this.onRemoveToken}
|
||||
/>
|
||||
</DropZone>
|
||||
)
|
||||
}
|
||||
}
|
||||
MailMergeParticipantsTextField.containerRequired = false
|
||||
|
||||
export default MailMergeParticipantsTextField
|
|
@ -0,0 +1,71 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
import {RetinaImg} from 'nylas-component-kit'
|
||||
import {sendMassEmail} from './mail-merge-utils'
|
||||
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
|
||||
|
||||
|
||||
class MailMergeSendButton extends Component {
|
||||
static displayName = 'MailMergeSendButton'
|
||||
|
||||
static propTypes = {
|
||||
draft: PropTypes.object,
|
||||
session: PropTypes.object,
|
||||
isValidDraft: PropTypes.func,
|
||||
fallback: PropTypes.func,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
const {draftClientId, session} = props
|
||||
this.session = mailMergeSessionForDraft(draftClientId, session)
|
||||
this.state = {isWorkspaceOpen: this.session.state.isWorkspaceOpen}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.session.listen(::this.onSessionChange)
|
||||
}
|
||||
|
||||
onSessionChange() {
|
||||
this.setState({isWorkspaceOpen: this.session.state.isWorkspaceOpen})
|
||||
}
|
||||
|
||||
onClick() {
|
||||
const {draft} = this.props
|
||||
sendMassEmail(draft.clientId)
|
||||
}
|
||||
|
||||
primaryClick() {
|
||||
this.onClick()
|
||||
}
|
||||
|
||||
render() {
|
||||
const {isWorkspaceOpen} = this.state
|
||||
if (!isWorkspaceOpen) {
|
||||
const Fallback = this.props.fallback
|
||||
return <Fallback {...this.props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className={"btn btn-toolbar btn-normal btn-emphasis btn-text btn-send"}
|
||||
style={{order: -100}}
|
||||
onClick={::this.onClick}
|
||||
>
|
||||
<span>
|
||||
<RetinaImg
|
||||
name="icon-composer-send.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
<span className="text">Send All</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MailMergeSendButton.containerRequired = false
|
||||
|
||||
export default MailMergeSendButton
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
import {EditableTable, RetinaImg} from 'nylas-component-kit'
|
||||
import {DataTransferTypes} from './mail-merge-constants'
|
||||
|
||||
|
||||
function Input({isHeader, colIdx, onDragStart, ...props}) {
|
||||
if (!isHeader) {
|
||||
return <input {...props} />
|
||||
}
|
||||
const _onDragStart = event => onDragStart(event, colIdx)
|
||||
|
||||
return (
|
||||
<div draggable className="header-cell" onDragStart={_onDragStart}>
|
||||
<div className="header-token">
|
||||
<RetinaImg name="icon-composer-overflow.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
<input {...props} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
class MailMergeTable extends Component {
|
||||
|
||||
static propTypes = {
|
||||
tableData: EditableTable.propTypes.tableData,
|
||||
selection: PropTypes.object,
|
||||
draftClientId: PropTypes.string,
|
||||
onShiftSelection: PropTypes.func,
|
||||
}
|
||||
|
||||
onDragColumn(event, colIdx) {
|
||||
const {draftClientId} = this.props
|
||||
event.dataTransfer.effectAllowed = "move"
|
||||
event.dataTransfer.dragEffect = "move"
|
||||
event.dataTransfer.setData(DataTransferTypes.DraftId, draftClientId)
|
||||
event.dataTransfer.setData(DataTransferTypes.ColIdx, colIdx)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="mail-merge-table">
|
||||
<EditableTable
|
||||
{...this.props}
|
||||
displayHeader
|
||||
displayNumbers
|
||||
inputProps={{onDragStart: ::this.onDragColumn}}
|
||||
InputRenderer={Input}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default MailMergeTable
|
|
@ -0,0 +1,75 @@
|
|||
import {Utils, Actions, Contact, DatabaseStore} from 'nylas-exports'
|
||||
import {DataTransferTypes, ParticipantFields} from './mail-merge-constants'
|
||||
import {mailMergeSessionForDraft} from './mail-merge-draft-editing-session'
|
||||
|
||||
|
||||
export function contactFromColIdx(colIdx, email) {
|
||||
return new Contact({
|
||||
name: email || '',
|
||||
email: email || 'No value selected',
|
||||
clientId: `${DataTransferTypes.ColIdx}:${colIdx}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function colIdxFromContact(contact) {
|
||||
const {clientId} = contact
|
||||
if (!clientId.startsWith(DataTransferTypes.ColIdx)) {
|
||||
return null
|
||||
}
|
||||
return contact.clientId.split(':')[2]
|
||||
}
|
||||
|
||||
export function bodyTokenRegex(draftClientId, colIdx) {
|
||||
// TODO update this regex for when it doesn't contain the style tag
|
||||
const reStr = `<span class="mail-merge-token" contenteditable="false" tabindex="-1" style="border: 1px solid red;" data-col-idx="${colIdx}" data-draft-client-id="${draftClientId}">[^]*</span>`
|
||||
return new RegExp(reStr)
|
||||
}
|
||||
|
||||
function buildDraft(baseDraft, {sendData, linkedFields}) {
|
||||
const baseId = baseDraft.clientId
|
||||
const draftToSend = baseDraft.clone()
|
||||
draftToSend.clientId = Utils.generateTempId()
|
||||
|
||||
// Replace tokens inside body with values from table data
|
||||
draftToSend.body = Array.from(linkedFields.body).reduce((currentBody, colIdx) => {
|
||||
const fieldValue = sendData[colIdx] || ""
|
||||
const wrappedValue = `<span>${fieldValue}</span>`
|
||||
return currentBody.replace(bodyTokenRegex(baseId, colIdx), wrappedValue)
|
||||
}, draftToSend.body)
|
||||
|
||||
// Update participant values
|
||||
ParticipantFields.forEach((field) => {
|
||||
draftToSend[field] = Array.from(linkedFields[field]).map((colIdx) => {
|
||||
const value = sendData[colIdx] || ""
|
||||
return new Contact({name: value, email: value})
|
||||
})
|
||||
})
|
||||
return draftToSend
|
||||
}
|
||||
|
||||
export function sendMassEmail(draftClientId) {
|
||||
const mailMergeSession = mailMergeSessionForDraft(draftClientId)
|
||||
if (!mailMergeSession) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO If send later metadata is present on the base draft,
|
||||
// handle it correctly instead of sending immediately
|
||||
const baseDraft = mailMergeSession.draft()
|
||||
const {tableData: {rows}, linkedFields} = mailMergeSession.state
|
||||
|
||||
const draftsData = rows.slice(1)
|
||||
const draftsToSend = draftsData.map((rowData) => (
|
||||
buildDraft(baseDraft, {sendData: rowData, linkedFields})
|
||||
))
|
||||
Promise.all(
|
||||
draftsToSend.map((draft) => {
|
||||
return DatabaseStore.inTransaction((t) => {
|
||||
return t.persistModel(draft)
|
||||
.then(() => {
|
||||
Actions.sendDraft(draft.clientId)
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
import MailMergeTable from './mail-merge-table'
|
||||
|
||||
|
||||
class MailMergeWorkspace extends Component {
|
||||
static displayName = 'MailMergeWorkspace'
|
||||
|
||||
static propTypes = {
|
||||
isWorkspaceOpen: PropTypes.bool,
|
||||
tableData: MailMergeTable.propTypes.tableData,
|
||||
selection: PropTypes.object,
|
||||
draftClientId: PropTypes.string,
|
||||
session: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {session, draftClientId, isWorkspaceOpen, tableData, selection, ...otherProps} = this.props
|
||||
if (!isWorkspaceOpen) {
|
||||
return false
|
||||
}
|
||||
|
||||
const {row} = selection
|
||||
const {rows} = tableData
|
||||
return (
|
||||
<div className="mail-merge-workspace">
|
||||
<div className="selection-controls">
|
||||
Recipient
|
||||
<span>
|
||||
<span onClick={()=> session.shiftSelection({row: -1})}>{'<'}</span>
|
||||
{row}
|
||||
<span onClick={()=> session.shiftSelection({row: 1})}>{'>'}</span>
|
||||
</span>
|
||||
of {rows.length - 1}
|
||||
</div>
|
||||
<MailMergeTable
|
||||
{...otherProps}
|
||||
selection={selection}
|
||||
tableData={tableData}
|
||||
draftClientId={draftClientId}
|
||||
onCellEdited={session.updateCell}
|
||||
onSetSelection={session.setSelection}
|
||||
onShiftSelection={session.shiftSelection}
|
||||
onAddColumn={session.addColumn}
|
||||
onRemoveColumn={session.removeColumn}
|
||||
onAddRow={session.addRow}
|
||||
onRemoveRow={session.removeRow}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default MailMergeWorkspace
|
35
internal_packages/composer-mail-merge/lib/main.es6
Normal file
35
internal_packages/composer-mail-merge/lib/main.es6
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {ExtensionRegistry, ComponentRegistry} from 'nylas-exports'
|
||||
import MailMergeButton from './mail-merge-button'
|
||||
import MailMergeSendButton from './mail-merge-send-button'
|
||||
import MailMergeParticipantsTextField from './mail-merge-participants-text-field'
|
||||
import MailMergeContainer from './mail-merge-container'
|
||||
import * as ComposerExtension from './mail-merge-composer-extension'
|
||||
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(MailMergeContainer,
|
||||
{role: 'Composer:Footer'});
|
||||
|
||||
ComponentRegistry.register(MailMergeButton,
|
||||
{role: 'Composer:ActionButton'});
|
||||
|
||||
ComponentRegistry.register(MailMergeSendButton,
|
||||
{role: 'Composer:SendActionButton'});
|
||||
|
||||
ComponentRegistry.register(MailMergeParticipantsTextField,
|
||||
{role: 'Composer:ParticipantsTextField'});
|
||||
|
||||
ExtensionRegistry.Composer.register(ComposerExtension)
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(MailMergeContainer)
|
||||
ComponentRegistry.unregister(MailMergeButton)
|
||||
ComponentRegistry.unregister(MailMergeSendButton)
|
||||
ComponentRegistry.unregister(MailMergeParticipantsTextField)
|
||||
ExtensionRegistry.Composer.unregister(ComposerExtension)
|
||||
}
|
||||
|
||||
export function serialize() {
|
||||
|
||||
}
|
146
internal_packages/composer-mail-merge/lib/table-data-reducer.es6
Normal file
146
internal_packages/composer-mail-merge/lib/table-data-reducer.es6
Normal file
|
@ -0,0 +1,146 @@
|
|||
|
||||
function updateColumns({columns}, {colIdx, value}) {
|
||||
const newColumns = columns.slice(0)
|
||||
newColumns[colIdx] = value
|
||||
return newColumns
|
||||
}
|
||||
|
||||
function updateRows({rows}, {row, col, value}) {
|
||||
const newRows = rows.slice(0)
|
||||
newRows[row][col] = value
|
||||
return newRows
|
||||
}
|
||||
|
||||
export function initialState(savedData) {
|
||||
if (savedData && savedData.tableData) {
|
||||
return {
|
||||
tableData: savedData.tableData,
|
||||
selection: {
|
||||
row: 1,
|
||||
col: 0,
|
||||
key: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
tableData: {
|
||||
columns: ['email'],
|
||||
rows: [
|
||||
['email'],
|
||||
[null],
|
||||
],
|
||||
},
|
||||
selection: {
|
||||
row: 1,
|
||||
col: 0,
|
||||
key: 'Enter',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function addColumn({selection, tableData}) {
|
||||
const {columns, rows} = tableData
|
||||
const newColumns = columns.concat([''])
|
||||
return {
|
||||
tableData: {
|
||||
...tableData,
|
||||
rows: rows.map(row => row.concat(null)),
|
||||
columns: newColumns,
|
||||
},
|
||||
selection: {
|
||||
...selection,
|
||||
row: 0,
|
||||
col: newColumns.length - 1,
|
||||
key: 'Enter',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function removeColumn({selection, tableData}) {
|
||||
const {rows, columns} = tableData
|
||||
const newSelection = {...selection, key: null}
|
||||
if (newSelection.col === columns.length - 1) {
|
||||
newSelection.col--
|
||||
}
|
||||
return {
|
||||
tableData: {
|
||||
...tableData,
|
||||
rows: rows.map(row => row.slice(0, columns.length - 1)),
|
||||
columns: columns.slice(0, columns.length - 1),
|
||||
},
|
||||
selection: newSelection,
|
||||
}
|
||||
}
|
||||
|
||||
export function addRow({selection, tableData}) {
|
||||
const {rows, columns} = tableData
|
||||
const newRows = rows.concat([columns.map(() => null)])
|
||||
return {
|
||||
tableData: {
|
||||
...tableData,
|
||||
rows: newRows,
|
||||
},
|
||||
selection: {
|
||||
...selection,
|
||||
row: newRows.length - 1,
|
||||
key: 'Enter',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function removeRow({selection, tableData}) {
|
||||
const {rows} = tableData
|
||||
const newSelection = {...selection, key: null}
|
||||
if (newSelection.row === rows.length - 1) {
|
||||
newSelection.row--
|
||||
}
|
||||
return {
|
||||
tableData: {
|
||||
...tableData,
|
||||
rows: rows.slice(0, rows.length - 1),
|
||||
},
|
||||
selection: newSelection,
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCell({tableData, selection}, {row, col, value}) {
|
||||
const newSelection = {...selection, key: null}
|
||||
if (row === 0) {
|
||||
return {
|
||||
tableData: {
|
||||
...tableData,
|
||||
rows: updateRows(tableData, {row, col, value}),
|
||||
columns: updateColumns(tableData, {col, value}),
|
||||
},
|
||||
selection: newSelection,
|
||||
}
|
||||
}
|
||||
return {
|
||||
tableData: {
|
||||
...tableData,
|
||||
rows: updateRows(tableData, {row, col, value}),
|
||||
},
|
||||
selection: newSelection,
|
||||
}
|
||||
}
|
||||
|
||||
export function setSelection({selection}, newSelection) {
|
||||
return {
|
||||
selection: {...newSelection},
|
||||
}
|
||||
}
|
||||
|
||||
export function shiftSelection({tableData, selection}, deltas) {
|
||||
const rowLen = tableData.rows.length
|
||||
const colLen = tableData.columns.length
|
||||
const shift = (len, idx, delta = 0) => Math.min(len - 1, Math.max(0, idx + (delta)))
|
||||
|
||||
return {
|
||||
selection: {
|
||||
...selection,
|
||||
row: shift(rowLen, selection.row, deltas.row),
|
||||
col: shift(colLen, selection.col, deltas.col),
|
||||
key: deltas.key,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import _ from 'underscore'
|
||||
import {contactFromColIdx} from './mail-merge-utils'
|
||||
import {ParticipantFields} from './mail-merge-constants'
|
||||
|
||||
|
||||
export function toDraft(draft, {tableData, selection, linkedFields}) {
|
||||
const contactsPerField = ParticipantFields.map((field) => (
|
||||
[...linkedFields[field]].map(colIdx => {
|
||||
const selectionValue = tableData.rows[selection.row][colIdx]
|
||||
return contactFromColIdx(colIdx, selectionValue)
|
||||
})
|
||||
))
|
||||
return _.object(ParticipantFields, contactsPerField)
|
||||
}
|
||||
|
||||
export function toJSON({linkedFields}) {
|
||||
return {
|
||||
linkedFields: {
|
||||
to: [...linkedFields.to],
|
||||
cc: [...linkedFields.cc],
|
||||
bcc: [...linkedFields.bcc],
|
||||
body: [...linkedFields.body],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function fromJSON({linkedFields}) {
|
||||
return {
|
||||
linkedFields: {
|
||||
to: new Set(linkedFields.to),
|
||||
cc: new Set(linkedFields.cc),
|
||||
bcc: new Set(linkedFields.bcc),
|
||||
body: new Set(linkedFields.body),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function initialState(savedData) {
|
||||
if (savedData && savedData.linkedFields) {
|
||||
return {
|
||||
isWorkspaceOpen: true,
|
||||
linkedFields: savedData.linkedFields,
|
||||
}
|
||||
}
|
||||
return {
|
||||
isWorkspaceOpen: false,
|
||||
linkedFields: {
|
||||
to: new Set(),
|
||||
cc: new Set(),
|
||||
bcc: new Set(),
|
||||
body: new Set(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleWorkspace({isWorkspaceOpen}) {
|
||||
return {isWorkspaceOpen: !isWorkspaceOpen}
|
||||
}
|
||||
|
||||
export function linkToDraft({linkedFields}, {colIdx, field}) {
|
||||
const linkedField = linkedFields[field]
|
||||
linkedField.add(colIdx)
|
||||
return {
|
||||
linkedFields: {
|
||||
...linkedFields,
|
||||
[field]: linkedField,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function unlinkFromDraft({linkedFields}, {colIdx, field}) {
|
||||
const linkedField = linkedFields[field]
|
||||
linkedField.delete(colIdx)
|
||||
return {
|
||||
linkedFields: {
|
||||
...linkedFields,
|
||||
[field]: linkedField,
|
||||
},
|
||||
}
|
||||
}
|
21
internal_packages/composer-mail-merge/package.json
Normal file
21
internal_packages/composer-mail-merge/package.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "composer-mail-merge",
|
||||
"title":"Mail Merge",
|
||||
"description": "The easiest way to merge mail",
|
||||
"main": "./lib/main",
|
||||
"version": "0.1.0",
|
||||
"appId": {
|
||||
"production": "dhswok21e1i115pxzykh9yv5y",
|
||||
"staging": "someid"
|
||||
},
|
||||
"engines": {
|
||||
"nylas": ">=0.4.0"
|
||||
},
|
||||
"icon": "./icon.png",
|
||||
"isOptional": true,
|
||||
"windowTypes": {
|
||||
"default": true,
|
||||
"composer": true
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
@import 'ui-variables';
|
||||
|
||||
.mail-merge-workspace {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 200px;
|
||||
width: 98%;
|
||||
background: white;
|
||||
z-index: 1;
|
||||
border-top: 1px solid lightgrey;
|
||||
padding-top: @padding-base-vertical;
|
||||
|
||||
.mail-merge-table {
|
||||
height: 85%;
|
||||
|
||||
.editable-table-container>.key-commands-region {
|
||||
width: initial;
|
||||
}
|
||||
|
||||
.editable-table {
|
||||
max-width: 700px;
|
||||
|
||||
.table-row-header.selected, th.selected, td.selected {
|
||||
background: initial;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
input {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:not(.numbered-cell) {
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 35px;
|
||||
|
||||
.header-token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: fadeout(red, 90%);
|
||||
border: 1px solid lightgrey;
|
||||
border-radius: 5px;
|
||||
width: 65%;
|
||||
height: 20px;
|
||||
|
||||
img {
|
||||
background-color: black;
|
||||
}
|
||||
input {
|
||||
text-align: left;
|
||||
height: 100%;
|
||||
line-height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mail-merge-participants-text-field {
|
||||
&.is-dropping {
|
||||
border: red 1px solid;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {Utils} from 'nylas-exports';
|
||||
import {InjectedComponentSet} from 'nylas-component-kit';
|
||||
import {DropZone, InjectedComponentSet} from 'nylas-component-kit';
|
||||
|
||||
const NUM_TO_DISPLAY_MAX = 999;
|
||||
|
||||
|
@ -13,12 +13,16 @@ export default class CollapsedParticipants extends React.Component {
|
|||
to: React.PropTypes.array,
|
||||
cc: React.PropTypes.array,
|
||||
bcc: React.PropTypes.array,
|
||||
onDrop: React.PropTypes.func,
|
||||
onDragChange: React.PropTypes.func,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
onDrop: () => {},
|
||||
onDragChange: () => {},
|
||||
}
|
||||
|
||||
constructor(props = {}) {
|
||||
|
@ -148,13 +152,19 @@ export default class CollapsedParticipants extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
ref="participantsWrap"
|
||||
className="collapsed-composer-participants">
|
||||
{this._renderNumRemaining()}
|
||||
{toDisplay}
|
||||
</div>
|
||||
<DropZone
|
||||
shouldAcceptDrop={() => true}
|
||||
onDragStateChange={this.props.onDragChange}
|
||||
onDrop={this.props.onDrop}
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
ref="participantsWrap"
|
||||
className="collapsed-composer-participants">
|
||||
{this._renderNumRemaining()}
|
||||
{toDisplay}
|
||||
</div>
|
||||
</DropZone>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, {Component, PropTypes} from 'react';
|
||||
import {ContenteditableExtension, ExtensionRegistry, DOMUtils} from 'nylas-exports';
|
||||
import {ScrollRegion, Contenteditable} from 'nylas-component-kit';
|
||||
import {DropZone, ScrollRegion, Contenteditable} from 'nylas-component-kit';
|
||||
|
||||
/**
|
||||
* Renders the text editor for the composer
|
||||
|
@ -209,6 +209,13 @@ class ComposerEditor extends Component {
|
|||
this.refs.contenteditable._onDOMMutated(mutations);
|
||||
}
|
||||
|
||||
_onDrop(event) {
|
||||
this.refs.contenteditable._onDrop(event)
|
||||
}
|
||||
|
||||
_onDragOver(event) {
|
||||
this.refs.contenteditable._onDragOver(event)
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
|
@ -291,17 +298,26 @@ class ComposerEditor extends Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<Contenteditable
|
||||
ref="contenteditable"
|
||||
value={this.props.body}
|
||||
onChange={this.props.onBodyChanged}
|
||||
onFilePaste={this.props.onFilePaste}
|
||||
onSelectionRestored={this._ensureSelectionVisible}
|
||||
initialSelectionSnapshot={this.props.initialSelectionSnapshot}
|
||||
extensions={[this._coreExtension].concat(this.state.extensions)} />
|
||||
<DropZone
|
||||
className="composer-inner-wrap"
|
||||
onDrop={::this._onDrop}
|
||||
onDragOver={::this._onDragOver}
|
||||
shouldAcceptDrop={() => true}
|
||||
>
|
||||
<Contenteditable
|
||||
ref="contenteditable"
|
||||
value={this.props.body}
|
||||
onChange={this.props.onBodyChanged}
|
||||
onFilePaste={this.props.onFilePaste}
|
||||
onSelectionRestored={this._ensureSelectionVisible}
|
||||
initialSelectionSnapshot={this.props.initialSelectionSnapshot}
|
||||
extensions={[this._coreExtension].concat(this.state.extensions)}
|
||||
/>
|
||||
</DropZone>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
ComposerEditor.containerRequired = false
|
||||
|
||||
export default ComposerEditor;
|
||||
|
|
|
@ -3,15 +3,14 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
import AccountContactField from './account-contact-field';
|
||||
import {Utils, Actions, AccountStore} from 'nylas-exports';
|
||||
import {KeyCommandsRegion, ParticipantsTextField} from 'nylas-component-kit';
|
||||
import {KeyCommandsRegion, ParticipantsTextField, ListensToFluxStore} from 'nylas-component-kit';
|
||||
|
||||
import CollapsedParticipants from './collapsed-participants';
|
||||
import ComposerHeaderActions from './composer-header-actions';
|
||||
|
||||
import ConnectToFlux from './decorators/connect-to-flux';
|
||||
import Fields from './fields';
|
||||
|
||||
const ScopedFromField = ConnectToFlux(AccountContactField, {
|
||||
const ScopedFromField = ListensToFluxStore(AccountContactField, {
|
||||
stores: [AccountStore],
|
||||
getStateFromStores: (props) => {
|
||||
const savedOrReplyToThread = !!props.draft.threadId;
|
||||
|
@ -27,7 +26,6 @@ export default class ComposerHeader extends React.Component {
|
|||
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
|
||||
session: React.PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
|
@ -148,6 +146,15 @@ export default class ComposerHeader extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
_onDragCollapsedParticipants({isDropping}) {
|
||||
if (isDropping) {
|
||||
this.setState({
|
||||
participantsFocused: true,
|
||||
enabledFields: [...Fields.ParticipantFields, Fields.Subject],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_renderParticipants = () => {
|
||||
let content = null;
|
||||
if (this.state.participantsFocused) {
|
||||
|
@ -158,6 +165,7 @@ export default class ComposerHeader extends React.Component {
|
|||
to={this.props.draft.to}
|
||||
cc={this.props.draft.cc}
|
||||
bcc={this.props.draft.bcc}
|
||||
onDragChange={::this._onDragCollapsedParticipants}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -210,7 +218,10 @@ export default class ComposerHeader extends React.Component {
|
|||
field="to"
|
||||
change={this._onChangeParticipants}
|
||||
className="composer-participant-field to-field"
|
||||
participants={{to, cc, bcc}} />
|
||||
participants={{to, cc, bcc}}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
/>
|
||||
)
|
||||
|
||||
if (this.state.enabledFields.includes(Fields.Cc)) {
|
||||
|
@ -222,7 +233,10 @@ export default class ComposerHeader extends React.Component {
|
|||
change={this._onChangeParticipants}
|
||||
onEmptied={ () => this.hideField(Fields.Cc) }
|
||||
className="composer-participant-field cc-field"
|
||||
participants={{to, cc, bcc}} />
|
||||
participants={{to, cc, bcc}}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -235,7 +249,10 @@ export default class ComposerHeader extends React.Component {
|
|||
change={this._onChangeParticipants}
|
||||
onEmptied={ () => this.hideField(Fields.Bcc) }
|
||||
className="composer-participant-field bcc-field"
|
||||
participants={{to, cc, bcc}} />
|
||||
participants={{to, cc, bcc}}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -81,9 +81,10 @@ export default class ComposerView extends React.Component {
|
|||
}
|
||||
|
||||
focus() {
|
||||
if (ReactDOM.findDOMNode(this).contains(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
// TODO is it safe to remove this?
|
||||
// if (ReactDOM.findDOMNode(this).contains(document.activeElement)) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (this.props.draft.to.length === 0) {
|
||||
this.refs.header.showAndFocusField(Fields.To);
|
||||
|
@ -344,11 +345,22 @@ export default class ComposerView extends React.Component {
|
|||
|
||||
<div style={{order: 0, flex: 1}} />
|
||||
|
||||
<SendActionButton
|
||||
tabIndex={-1}
|
||||
draft={this.props.draft}
|
||||
|
||||
<InjectedComponent
|
||||
ref="sendActionButton"
|
||||
isValidDraft={this._isValidDraft}
|
||||
tabIndex={-1}
|
||||
style={{order: -100}}
|
||||
matching={{role: "Composer:SendActionButton"}}
|
||||
fallback={SendActionButton}
|
||||
requiredMethods={[
|
||||
'primaryClick',
|
||||
]}
|
||||
exposedProps={{
|
||||
draft: this.props.draft,
|
||||
draftClientId: this.props.draft.clientId,
|
||||
session: this.props.session,
|
||||
isValidDraft: this._isValidDraft,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -10,14 +10,9 @@ export default class SendActionButton extends React.Component {
|
|||
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object,
|
||||
style: React.PropTypes.object,
|
||||
isValidDraft: React.PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
style: {},
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
|
@ -37,7 +32,9 @@ export default class SendActionButton extends React.Component {
|
|||
this.unsub();
|
||||
}
|
||||
|
||||
primaryClick = () => {
|
||||
static containerRequired = false
|
||||
|
||||
primaryClick() {
|
||||
this._onPrimaryClick();
|
||||
}
|
||||
|
||||
|
|
|
@ -344,6 +344,12 @@ class Contenteditable extends React.Component
|
|||
_onKeyDown: (event) =>
|
||||
@dispatchEventToExtensions("onKeyDown", event)
|
||||
|
||||
_onDragOver: (event) =>
|
||||
@dispatchEventToExtensions("onDragOver", event)
|
||||
|
||||
_onDrop: (event) =>
|
||||
@dispatchEventToExtensions("onDrop", event)
|
||||
|
||||
# We must set the `inCompositionEvent` flag in addition to tearing down
|
||||
# the selecton listeners. While the composition event is in progress, we
|
||||
# want to ignore any input events we get.
|
||||
|
|
|
@ -91,7 +91,7 @@ class EditorAPI
|
|||
indent: -> @_ec("indent", false)
|
||||
insertHorizontalRule: -> @_ec("insertHorizontalRule", false)
|
||||
|
||||
insertHTML: (html, {selectInsertion}) ->
|
||||
insertHTML: (html, {selectInsertion} = {}) ->
|
||||
if selectInsertion
|
||||
wrappedHtml = """<span id="tmp-html-insertion-wrap">#{html}</span>"""
|
||||
@_ec("insertHTML", false, wrappedHtml)
|
||||
|
|
75
src/components/decorators/auto-focuses.es6
Normal file
75
src/components/decorators/auto-focuses.es6
Normal file
|
@ -0,0 +1,75 @@
|
|||
import _ from 'underscore'
|
||||
import React, {Component} from 'react'
|
||||
import {findDOMNode} from 'react-dom'
|
||||
|
||||
|
||||
const FOCUSABLE_SELECTOR = 'input, textarea, [contenteditable], [tabIndex]'
|
||||
|
||||
function AutoFocuses(ComposedComponent, {onMount = true, onUpdate = true} = {}) {
|
||||
return class extends Component {
|
||||
static displayName = ComposedComponent.displayName
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true
|
||||
if (onMount) {
|
||||
this.focusElementWithTabIndex()
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (onUpdate) {
|
||||
this.focusElementWithTabIndex()
|
||||
}
|
||||
}
|
||||
|
||||
isFocusable(currentNode = findDOMNode(this)) {
|
||||
currentNode.focus()
|
||||
return document.activeElement === currentNode
|
||||
}
|
||||
|
||||
focusElementWithTabIndex() {
|
||||
if (!this.mounted) {
|
||||
return
|
||||
}
|
||||
// Automatically focus the element inside us with the lowest tab index
|
||||
const currentNode = findDOMNode(this);
|
||||
if (currentNode.contains(document.activeElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isFocusable(currentNode)) {
|
||||
currentNode.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// _.sortBy ranks in ascending numerical order.
|
||||
const focusable = currentNode.querySelectorAll(FOCUSABLE_SELECTOR);
|
||||
const matches = _.sortBy(focusable, (node)=> {
|
||||
if (node.tabIndex > 0) {
|
||||
return node.tabIndex;
|
||||
} else if (node.nodeName === "INPUT") {
|
||||
return 1000000
|
||||
}
|
||||
return 1000001
|
||||
})
|
||||
if (matches[0]) {
|
||||
matches[0].focus();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ComposedComponent
|
||||
{...this.props}
|
||||
focusElementWithTabIndex={::this.focusElementWithTabIndex}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutoFocuses
|
8
src/components/decorators/compose.es6
Normal file
8
src/components/decorators/compose.es6
Normal file
|
@ -0,0 +1,8 @@
|
|||
|
||||
export default function compose(BaseComponent, ...decorators) {
|
||||
const ComposedComponent =
|
||||
decorators.reduce((comp, decorator) => decorator(comp), BaseComponent)
|
||||
ComposedComponent.propTypes = BaseComponent.propTypes
|
||||
ComposedComponent.displayName = BaseComponent.displayName
|
||||
return ComposedComponent
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import React, {Component} from 'react';
|
||||
|
||||
export default (ComposedComponent, {stores, getStateFromStores}) => class extends React.Component {
|
||||
export default (ComposedComponent, {stores, getStateFromStores}) => class extends Component {
|
||||
static displayName = ComposedComponent.displayName;
|
||||
|
||||
static containerRequired = false;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -30,6 +31,6 @@ export default (ComposedComponent, {stores, getStateFromStores}) => class extend
|
|||
}
|
||||
|
||||
render() {
|
||||
return <ComposedComponent ref="composed" {...this.props} {...this.state} />;
|
||||
return <ComposedComponent {...this.props} {...this.state} />;
|
||||
}
|
||||
};
|
68
src/components/decorators/listens-to-movement-keys.es6
Normal file
68
src/components/decorators/listens-to-movement-keys.es6
Normal file
|
@ -0,0 +1,68 @@
|
|||
import React from 'react'
|
||||
import KeyCommandsRegion from '../key-commands-region'
|
||||
|
||||
function ListensToMovementKeys(ComposedComponent) {
|
||||
return class extends ComposedComponent {
|
||||
static displayName = ComposedComponent.displayName
|
||||
|
||||
localKeyHandlers() {
|
||||
return {
|
||||
'core:previous-item': (event) => {
|
||||
if (!(this.refs.composed || {}).onArrowUp) { return }
|
||||
event.stopPropagation();
|
||||
this.refs.composed.onArrowUp(event)
|
||||
},
|
||||
'core:next-item': (event) => {
|
||||
if (!(this.refs.composed || {}).onArrowDown) { return }
|
||||
event.stopPropagation();
|
||||
this.refs.composed.onArrowDown(event)
|
||||
},
|
||||
'core:move-left': (event) => {
|
||||
if (!(this.refs.composed || {}).onArrowDown) { return }
|
||||
event.stopPropagation();
|
||||
this.refs.composed.onArrowLeft(event)
|
||||
},
|
||||
'core:move-right': (event) => {
|
||||
if (!(this.refs.composed || {}).onArrowDown) { return }
|
||||
event.stopPropagation();
|
||||
this.refs.composed.onArrowRight(event)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
if (['Enter', 'Return'].includes(event.key)) {
|
||||
if (!(this.refs.composed || {}).onEnter) { return }
|
||||
event.stopPropagation();
|
||||
this.refs.composed.onEnter(event)
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
if (event.shiftKey) {
|
||||
if (!(this.refs.composed || {}).onShiftTab) { return }
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.refs.composed.onShiftTab(event)
|
||||
} else {
|
||||
if (!(this.refs.composed || {}).onTab) { return }
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.refs.composed.onTab(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<KeyCommandsRegion
|
||||
tabIndex="0"
|
||||
localHandlers={this.localKeyHandlers()}
|
||||
onKeyDown={::this.onKeyDown}
|
||||
>
|
||||
<ComposedComponent ref="composed" {...this.props} />
|
||||
</KeyCommandsRegion>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ListensToMovementKeys
|
|
@ -5,13 +5,17 @@ class DropZone extends React.Component
|
|||
@propTypes:
|
||||
shouldAcceptDrop: React.PropTypes.func.isRequired
|
||||
onDrop: React.PropTypes.func.isRequired
|
||||
onDragOver: React.PropTypes.func
|
||||
onDragStateChange: React.PropTypes.func
|
||||
|
||||
@defaultProps:
|
||||
onDragOver: ->
|
||||
|
||||
constructor: ->
|
||||
|
||||
render: ->
|
||||
otherProps = _.omit(@props, Object.keys(@constructor.propTypes))
|
||||
<div {...otherProps} onDragEnter={@_onDragEnter} onDragLeave={@_onDragLeave} onDrop={@_onDrop}>
|
||||
<div {...otherProps} onDragOver={@props.onDragOver} onDragEnter={@_onDragEnter} onDragLeave={@_onDragLeave} onDrop={@_onDrop}>
|
||||
{@props.children}
|
||||
</div>
|
||||
|
||||
|
|
149
src/components/editable-table.jsx
Normal file
149
src/components/editable-table.jsx
Normal file
|
@ -0,0 +1,149 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import SelectableTable, {SelectableCell} from './selectable-table'
|
||||
|
||||
|
||||
class EditableCell extends Component {
|
||||
|
||||
static propTypes = {
|
||||
tableData: SelectableCell.propTypes.tableData,
|
||||
rowIdx: SelectableCell.propTypes.colIdx,
|
||||
colIdx: SelectableCell.propTypes.colIdx,
|
||||
isHeader: PropTypes.bool,
|
||||
inputProps: PropTypes.object,
|
||||
InputRenderer: SelectableTable.propTypes.RowRenderer,
|
||||
onAddRow: PropTypes.func,
|
||||
onCellEdited: PropTypes.func,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
inputProps: {},
|
||||
InputRenderer: 'input',
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.shouldFocusInput()) {
|
||||
ReactDOM.findDOMNode(this.refs.inputContainer).querySelector('input').focus()
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.shouldFocusInput()) {
|
||||
ReactDOM.findDOMNode(this.refs.inputContainer).querySelector('input').focus()
|
||||
}
|
||||
}
|
||||
|
||||
onInputBlur(event) {
|
||||
const {target: {value}} = event
|
||||
const {tableData: {rows}, rowIdx, colIdx, onCellEdited} = this.props
|
||||
if (value && value !== rows[rowIdx][colIdx]) {
|
||||
onCellEdited({row: rowIdx, col: colIdx, value})
|
||||
}
|
||||
}
|
||||
|
||||
onInputKeyDown(event) {
|
||||
const {key} = event
|
||||
const {onAddRow} = this.props
|
||||
|
||||
if (['Enter', 'Return'].includes(key)) {
|
||||
if (this.refs.cell.isInLastRow()) {
|
||||
event.stopPropagation()
|
||||
onAddRow()
|
||||
}
|
||||
} else if (key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
ReactDOM.findDOMNode(this.refs.inputContainer).focus()
|
||||
}
|
||||
}
|
||||
|
||||
shouldFocusInput() {
|
||||
return (
|
||||
this.refs.cell.isSelectedUsingKey('Tab') ||
|
||||
this.refs.cell.isSelectedUsingKey('Enter') ||
|
||||
this.refs.cell.isSelectedUsingKey('Return')
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {rowIdx, colIdx, tableData: {rows}, isHeader, inputProps, InputRenderer} = this.props
|
||||
const cellValue = rows[rowIdx][colIdx]
|
||||
|
||||
return (
|
||||
<SelectableCell ref="cell" {...this.props}>
|
||||
<div ref="inputContainer" tabIndex="0">
|
||||
<InputRenderer
|
||||
type="text"
|
||||
defaultValue={cellValue}
|
||||
rowIdx={rowIdx}
|
||||
colIdx={colIdx}
|
||||
isHeader={isHeader}
|
||||
onKeyDown={::this.onInputKeyDown}
|
||||
onBlur={::this.onInputBlur}
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
</SelectableCell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class EditableTable extends Component {
|
||||
static displayName = 'EditableTable'
|
||||
|
||||
static propTypes = {
|
||||
tableData: SelectableTable.propTypes.tableData,
|
||||
inputProps: PropTypes.object,
|
||||
InputRenderer: PropTypes.any,
|
||||
onCellEdited: PropTypes.func,
|
||||
onAddColumn: PropTypes.func,
|
||||
onRemoveColumn: PropTypes.func,
|
||||
onAddRow: PropTypes.func,
|
||||
onRemoveRow: PropTypes.func,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onCellEdited: () => {},
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
inputProps,
|
||||
InputRenderer,
|
||||
onCellEdited,
|
||||
onAddRow,
|
||||
onRemoveRow,
|
||||
onAddColumn,
|
||||
onRemoveColumn,
|
||||
...otherProps,
|
||||
} = this.props
|
||||
|
||||
const tableProps = {
|
||||
...otherProps,
|
||||
className: "editable-table",
|
||||
extraProps: {
|
||||
onAddRow,
|
||||
onRemoveRow,
|
||||
onCellEdited,
|
||||
inputProps,
|
||||
InputRenderer,
|
||||
},
|
||||
CellRenderer: EditableCell,
|
||||
}
|
||||
|
||||
if (!onAddColumn || !onRemoveColumn) {
|
||||
return <SelectableTable {...tableProps} />
|
||||
}
|
||||
return (
|
||||
<div className="editable-table-container">
|
||||
<SelectableTable {...tableProps} />
|
||||
<div className="column-actions">
|
||||
<div className="btn btn-small" onClick={onAddColumn}>+</div>
|
||||
<div className="btn btn-small" onClick={onRemoveColumn}>-</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default EditableTable
|
|
@ -2,6 +2,8 @@ import _ from 'underscore';
|
|||
import React, {Component, PropTypes} from 'react';
|
||||
import {findDOMNode} from 'react-dom';
|
||||
import Actions from '../flux/actions';
|
||||
import AutoFocuses from './decorators/auto-focuses'
|
||||
import compose from './decorators/compose'
|
||||
|
||||
|
||||
const Directions = {
|
||||
|
@ -41,6 +43,7 @@ class FixedPopover extends Component {
|
|||
height: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
}),
|
||||
focusElementWithTabIndex: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
@ -57,7 +60,6 @@ class FixedPopover extends Component {
|
|||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
this.focusElementWithTabIndex()
|
||||
findDOMNode(this.refs.popoverContainer).addEventListener('animationend', this.onAnimationEnd)
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
_.defer(this.onPopoverRendered)
|
||||
|
@ -76,7 +78,6 @@ class FixedPopover extends Component {
|
|||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.focusElementWithTabIndex()
|
||||
_.defer(this.onPopoverRendered)
|
||||
}
|
||||
|
||||
|
@ -87,7 +88,7 @@ class FixedPopover extends Component {
|
|||
}
|
||||
|
||||
onAnimationEnd = () => {
|
||||
_.defer(this.focusElementWithTabIndex);
|
||||
_.defer(this.props.focusElementWithTabIndex);
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
|
@ -342,4 +343,4 @@ class FixedPopover extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
export default FixedPopover;
|
||||
export default compose(FixedPopover, AutoFocuses)
|
||||
|
|
|
@ -59,9 +59,12 @@ class InjectedComponent extends React.Component
|
|||
exposedProps: React.PropTypes.object
|
||||
fallback: React.PropTypes.func
|
||||
onComponentDidRender: React.PropTypes.func
|
||||
style: React.PropTypes.object
|
||||
requiredMethods: React.PropTypes.arrayOf(React.PropTypes.string)
|
||||
|
||||
@defaultProps:
|
||||
style: {}
|
||||
exposedProps: {}
|
||||
requiredMethods: []
|
||||
onComponentDidRender: ->
|
||||
|
||||
|
@ -89,7 +92,7 @@ class InjectedComponent extends React.Component
|
|||
render: =>
|
||||
return <div></div> unless @state.component
|
||||
|
||||
exposedProps = @props.exposedProps ? {}
|
||||
exposedProps = Object.assign({}, @props.exposedProps, {fallback: @props.fallback})
|
||||
className = @props.className ? ""
|
||||
className += " registered-region-visible" if @state.visible
|
||||
|
||||
|
@ -100,6 +103,8 @@ class InjectedComponent extends React.Component
|
|||
element = (
|
||||
<UnsafeComponent
|
||||
ref="inner"
|
||||
style={@props.style}
|
||||
className={className}
|
||||
key={component.displayName}
|
||||
component={component}
|
||||
onComponentDidRender={@props.onComponentDidRender}
|
||||
|
@ -107,13 +112,13 @@ class InjectedComponent extends React.Component
|
|||
)
|
||||
|
||||
if @state.visible
|
||||
<div className={className}>
|
||||
<div className={className} style={@props.style}>
|
||||
{element}
|
||||
<InjectedComponentLabel matching={@props.matching} {...exposedProps} />
|
||||
<span style={clear:'both'}/>
|
||||
</div>
|
||||
else
|
||||
<div className={className}>
|
||||
<div className={className} style={@props.style}>
|
||||
{element}
|
||||
</div>
|
||||
|
||||
|
@ -154,7 +159,7 @@ class InjectedComponent extends React.Component
|
|||
if @state.component?
|
||||
component = @state.component
|
||||
@props.requiredMethods.forEach (method) =>
|
||||
isMethodDefined = @state.component.prototype[method]?
|
||||
isMethodDefined = component.prototype[method]?
|
||||
unless isMethodDefined
|
||||
throw new Error(
|
||||
"#{component.name} must implement method `#{method}` when registering
|
||||
|
|
|
@ -3,7 +3,33 @@ import _ from 'underscore';
|
|||
|
||||
import {remote} from 'electron';
|
||||
import {Utils, Contact, ContactStore} from 'nylas-exports';
|
||||
import {TokenizingTextField, Menu, InjectedComponentSet} from 'nylas-component-kit';
|
||||
import {TokenizingTextField, Menu, InjectedComponent, InjectedComponentSet} from 'nylas-component-kit';
|
||||
|
||||
class TokenRenderer extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
token: React.PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {email, name} = this.props.token
|
||||
let chipText = email;
|
||||
if (name && (name.length > 0) && (name !== email)) {
|
||||
chipText = name;
|
||||
}
|
||||
return (
|
||||
<div className="participant">
|
||||
<InjectedComponentSet
|
||||
matching={{role: "Composer:RecipientChip"}}
|
||||
exposedProps={{contact: this.props.token}}
|
||||
direction="column"
|
||||
inline
|
||||
/>
|
||||
<span className="participant-primary">{chipText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class ParticipantsTextField extends React.Component {
|
||||
static displayName = 'ParticipantsTextField';
|
||||
|
@ -28,6 +54,10 @@ export default class ParticipantsTextField extends React.Component {
|
|||
onEmptied: React.PropTypes.func,
|
||||
|
||||
onFocus: React.PropTypes.func,
|
||||
|
||||
draft: React.PropTypes.object,
|
||||
|
||||
session: React.PropTypes.object,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -50,24 +80,6 @@ export default class ParticipantsTextField extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
_tokenNode = (p) => {
|
||||
let chipText = p.email;
|
||||
if (p.name && (p.name.length > 0) && (p.name !== p.email)) {
|
||||
chipText = p.name;
|
||||
}
|
||||
return (
|
||||
<div className="participant">
|
||||
<InjectedComponentSet
|
||||
matching={{role: "Composer:RecipientChip"}}
|
||||
exposedProps={{contact: p}}
|
||||
direction="column"
|
||||
inline
|
||||
/>
|
||||
<span className="participant-primary">{chipText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_tokensForString = (string, options = {}) => {
|
||||
// If the input is a string, parse out email addresses and build
|
||||
// an array of contact objects. For each email address wrapped in
|
||||
|
@ -176,24 +188,34 @@ export default class ParticipantsTextField extends React.Component {
|
|||
render() {
|
||||
const classSet = {};
|
||||
classSet[this.props.field] = true;
|
||||
// TODO Ahh now that this component is part of the component kit this
|
||||
// injected region feels out of place
|
||||
return (
|
||||
<div className={this.props.className}>
|
||||
<TokenizingTextField
|
||||
<InjectedComponent
|
||||
ref="textField"
|
||||
tokens={this.props.participants[this.props.field]}
|
||||
tokenKey={ (p) => p.email }
|
||||
tokenIsValid={ (p) => ContactStore.isValidContact(p) }
|
||||
tokenNode={this._tokenNode}
|
||||
onRequestCompletions={ (input) => ContactStore.searchContacts(input) }
|
||||
completionNode={this._completionNode}
|
||||
onAdd={this._add}
|
||||
onRemove={this._remove}
|
||||
onEdit={this._edit}
|
||||
onEmptied={this.props.onEmptied}
|
||||
onFocus={this.props.onFocus}
|
||||
onTokenAction={this._onShowContextMenu}
|
||||
menuClassSet={classSet}
|
||||
menuPrompt={this.props.field}
|
||||
matching={{role: 'Composer:ParticipantsTextField'}}
|
||||
fallback={TokenizingTextField}
|
||||
exposedProps={{
|
||||
tokens: this.props.participants[this.props.field],
|
||||
tokenKey: (p) => p.email,
|
||||
tokenIsValid: (p) => ContactStore.isValidContact(p),
|
||||
tokenRenderer: TokenRenderer,
|
||||
onRequestCompletions: (input) => ContactStore.searchContacts(input),
|
||||
completionNode: this._completionNode,
|
||||
onAdd: this._add,
|
||||
onRemove: this._remove,
|
||||
onEdit: this._edit,
|
||||
onEmptied: this.props.onEmptied,
|
||||
onFocus: this.props.onFocus,
|
||||
onTokenAction: this._onShowContextMenu,
|
||||
menuClassSet: classSet,
|
||||
menuPrompt: this.props.field,
|
||||
field: this.props.field,
|
||||
draft: this.props.draft,
|
||||
draftClientId: this.props.draft.clientId,
|
||||
session: this.props.session,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
197
src/components/selectable-table.jsx
Normal file
197
src/components/selectable-table.jsx
Normal file
|
@ -0,0 +1,197 @@
|
|||
import _ from 'underscore'
|
||||
import React, {Component, PropTypes} from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import classnames from 'classnames';
|
||||
import compose from './decorators/compose';
|
||||
import AutoFocuses from './decorators/auto-focuses';
|
||||
import ListensToMovementKeys from './decorators/listens-to-movement-keys';
|
||||
import Table, {TableRow, TableCell} from './table'
|
||||
|
||||
|
||||
export class SelectableCell extends Component {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
tableData: Table.propTypes.tableData,
|
||||
rowIdx: TableCell.propTypes.rowIdx,
|
||||
colIdx: TableCell.propTypes.colIdx,
|
||||
selection: PropTypes.object,
|
||||
onSetSelection: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
}
|
||||
|
||||
onClickCell() {
|
||||
const {selection, rowIdx, colIdx, onSetSelection} = this.props
|
||||
if (_.isEqual(selection, {row: rowIdx, col: colIdx})) { return }
|
||||
onSetSelection({row: rowIdx, col: colIdx, key: null})
|
||||
}
|
||||
|
||||
isSelected() {
|
||||
const {selection, rowIdx, colIdx} = this.props
|
||||
return (
|
||||
selection && selection.row === rowIdx && selection.col === colIdx
|
||||
)
|
||||
}
|
||||
|
||||
isSelectedUsingKey(key) {
|
||||
const {selection} = this.props
|
||||
return this.isSelected() && selection.key === key
|
||||
}
|
||||
|
||||
isInLastRow() {
|
||||
const {rowIdx, tableData: {rows}} = this.props
|
||||
return rowIdx === rows.length - 1;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {className} = this.props
|
||||
const classes = classnames({
|
||||
[className]: true,
|
||||
'selected': this.isSelected(),
|
||||
})
|
||||
return (
|
||||
<TableCell
|
||||
{...this.props}
|
||||
className={classes}
|
||||
onClick={::this.onClickCell}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class SelectableRow extends Component {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
tableData: Table.propTypes.tableData,
|
||||
selection: PropTypes.object,
|
||||
rowIdx: TableRow.propTypes.rowIdx,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.isSelected()) {
|
||||
ReactDOM.findDOMNode(this)
|
||||
.scrollIntoViewIfNeeded(false)
|
||||
}
|
||||
}
|
||||
|
||||
isSelected() {
|
||||
const {selection, rowIdx} = this.props
|
||||
return selection && selection.row === rowIdx
|
||||
}
|
||||
|
||||
render() {
|
||||
const {className} = this.props
|
||||
const classes = classnames({
|
||||
[className]: true,
|
||||
'selected': this.isSelected(),
|
||||
})
|
||||
return (
|
||||
<TableRow
|
||||
{...this.props}
|
||||
className={classes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SelectableTable extends Component {
|
||||
static displayName = 'SelectableTable'
|
||||
|
||||
static propTypes = {
|
||||
tableData: Table.propTypes.tableData,
|
||||
extraProps: PropTypes.object,
|
||||
RowRenderer: Table.propTypes.RowRenderer,
|
||||
CellRenderer: Table.propTypes.CellRenderer,
|
||||
selection: PropTypes.shape({
|
||||
row: PropTypes.number,
|
||||
col: PropTypes.number,
|
||||
}).isRequired,
|
||||
onSetSelection: PropTypes.func.isRequired,
|
||||
onShiftSelection: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
extraProps: {},
|
||||
RowRenderer: SelectableRow,
|
||||
CellRenderer: SelectableCell,
|
||||
}
|
||||
|
||||
onArrowUp({key}) {
|
||||
const {onShiftSelection} = this.props
|
||||
onShiftSelection({row: -1, key})
|
||||
}
|
||||
|
||||
onArrowDown({key}) {
|
||||
const {onShiftSelection} = this.props
|
||||
onShiftSelection({row: 1, key})
|
||||
}
|
||||
|
||||
onArrowLeft({key}) {
|
||||
const {onShiftSelection} = this.props
|
||||
onShiftSelection({col: -1, key})
|
||||
}
|
||||
|
||||
onArrowRight({key}) {
|
||||
const {onShiftSelection} = this.props
|
||||
onShiftSelection({col: 1, key})
|
||||
}
|
||||
|
||||
onEnter({key}) {
|
||||
const {onShiftSelection} = this.props
|
||||
onShiftSelection({row: 1, key})
|
||||
}
|
||||
|
||||
onTab({key}) {
|
||||
const {tableData, selection, onShiftSelection} = this.props
|
||||
const colLen = tableData.columns.length
|
||||
if (selection.col === colLen - 1) {
|
||||
onShiftSelection({row: 1, col: -(colLen - 1), key})
|
||||
} else {
|
||||
onShiftSelection({col: 1, key})
|
||||
}
|
||||
}
|
||||
|
||||
onShiftTab({key}) {
|
||||
const {tableData, selection, onShiftSelection} = this.props
|
||||
const colLen = tableData.columns.length
|
||||
if (selection.col === 0) {
|
||||
onShiftSelection({row: -1, col: colLen - 1, key})
|
||||
} else {
|
||||
onShiftSelection({col: -1, key})
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {selection, onSetSelection, onShiftSelection, extraProps, RowRenderer, CellRenderer} = this.props
|
||||
const selectionProps = {
|
||||
selection,
|
||||
onSetSelection,
|
||||
onShiftSelection,
|
||||
}
|
||||
|
||||
return (
|
||||
<Table
|
||||
{...this.props}
|
||||
extraProps={{...extraProps, ...selectionProps}}
|
||||
RowRenderer={RowRenderer}
|
||||
CellRenderer={CellRenderer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default compose(
|
||||
SelectableTable,
|
||||
ListensToMovementKeys,
|
||||
(Comp) => AutoFocuses(Comp, {onUpdate: false})
|
||||
)
|
176
src/components/table.jsx
Normal file
176
src/components/table.jsx
Normal file
|
@ -0,0 +1,176 @@
|
|||
import _ from 'underscore'
|
||||
import classnames from 'classnames'
|
||||
import React, {Component, PropTypes} from 'react'
|
||||
|
||||
|
||||
// TODO Ugh gross. Use flow
|
||||
const RowDataType = PropTypes.arrayOf(PropTypes.node)
|
||||
const RendererType = PropTypes.oneOfType([PropTypes.func, PropTypes.string])
|
||||
const IndexType = PropTypes.oneOfType([PropTypes.number, PropTypes.string])
|
||||
const TablePropTypes = {
|
||||
idx: IndexType,
|
||||
renderer: RendererType,
|
||||
tableData: PropTypes.shape({
|
||||
rows: PropTypes.arrayOf(RowDataType),
|
||||
columns: RowDataType,
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
export class TableCell extends Component {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
isHeader: PropTypes.bool,
|
||||
tableData: TablePropTypes.tableData.isRequired,
|
||||
rowIdx: TablePropTypes.idx.isRequired,
|
||||
colIdx: TablePropTypes.idx.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
}
|
||||
|
||||
render() {
|
||||
const {className, isHeader, children, ...props} = this.props
|
||||
const CellTag = isHeader ? 'th' : 'td'
|
||||
return (
|
||||
<CellTag {...props} className={`table-cell ${className}`} >
|
||||
{children}
|
||||
</CellTag>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class TableRow extends Component {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
isHeader: PropTypes.bool,
|
||||
displayNumbers: PropTypes.bool,
|
||||
tableData: TablePropTypes.tableData.isRequired,
|
||||
rowIdx: TablePropTypes.idx.isRequired,
|
||||
extraProps: PropTypes.object,
|
||||
CellRenderer: TablePropTypes.renderer,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
extraProps: {},
|
||||
CellRenderer: TableCell,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {className, displayNumbers, isHeader, tableData, rowIdx, extraProps, CellRenderer, ...props} = this.props
|
||||
const classes = classnames({
|
||||
'table-row': true,
|
||||
'table-row-header': isHeader,
|
||||
[className]: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<tr className={classes} {...props} >
|
||||
{displayNumbers ?
|
||||
<TableCell
|
||||
className="numbered-cell"
|
||||
rowIdx={null}
|
||||
colIdx={null}
|
||||
tableData={{}}
|
||||
isHeader={isHeader}
|
||||
>
|
||||
{isHeader ? '' : rowIdx}
|
||||
</TableCell> :
|
||||
null
|
||||
}
|
||||
{_.times(tableData.columns.length, (colIdx) => {
|
||||
const cellProps = {tableData, rowIdx, colIdx, ...extraProps}
|
||||
return (
|
||||
<CellRenderer key={`cell-${rowIdx}-${colIdx}`} {...cellProps}>
|
||||
{tableData.rows[rowIdx][colIdx]}
|
||||
</CellRenderer>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default class Table extends Component {
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
displayHeader: PropTypes.bool,
|
||||
displayNumbers: PropTypes.bool,
|
||||
tableData: TablePropTypes.tableData.isRequired,
|
||||
extraProps: PropTypes.object,
|
||||
RowRenderer: TablePropTypes.renderer,
|
||||
CellRenderer: TablePropTypes.renderer,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
extraProps: {},
|
||||
RowRenderer: TableRow,
|
||||
CellRenderer: TableCell,
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
const {tableData, displayNumbers, displayHeader, extraProps, RowRenderer, CellRenderer} = this.props
|
||||
const rows = displayHeader ? tableData.rows.slice(1) : tableData.rows
|
||||
|
||||
const rowElements = rows.map((row, idx) => {
|
||||
const rowIdx = displayHeader ? idx + 1 : idx;
|
||||
return (
|
||||
<RowRenderer
|
||||
key={`row-${rowIdx}`}
|
||||
rowIdx={rowIdx}
|
||||
displayNumbers={displayNumbers}
|
||||
tableData={tableData}
|
||||
extraProps={extraProps}
|
||||
CellRenderer={CellRenderer}
|
||||
{...extraProps}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
{rowElements}
|
||||
</tbody>
|
||||
)
|
||||
}
|
||||
|
||||
renderHeader() {
|
||||
const {tableData, displayNumbers, displayHeader, extraProps, RowRenderer, CellRenderer} = this.props
|
||||
if (!displayHeader) { return false }
|
||||
|
||||
const extraHeaderProps = {...extraProps, isHeader: true}
|
||||
return (
|
||||
<thead>
|
||||
<RowRenderer
|
||||
rowIdx={0}
|
||||
tableData={tableData}
|
||||
displayNumbers={displayNumbers}
|
||||
extraProps={extraHeaderProps}
|
||||
CellRenderer={CellRenderer}
|
||||
{...extraHeaderProps}
|
||||
/>
|
||||
</thead>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {className} = this.props
|
||||
|
||||
return (
|
||||
<div className={`nylas-table ${className}`}>
|
||||
<table>
|
||||
{this.renderHeader()}
|
||||
{this.renderBody()}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -147,10 +147,14 @@ Section: Component Kit
|
|||
class TokenizingTextField extends React.Component
|
||||
@displayName: "TokenizingTextField"
|
||||
|
||||
@containerRequired: false
|
||||
|
||||
# Exposed for tests
|
||||
@Token: Token
|
||||
|
||||
@propTypes:
|
||||
className: React.PropTypes.string
|
||||
|
||||
# An array of current tokens.
|
||||
#
|
||||
# A token is usually an object type like a `Contact`. The set of
|
||||
|
@ -181,7 +185,7 @@ class TokenizingTextField extends React.Component
|
|||
#
|
||||
# A function that is passed an object and should return React elements
|
||||
# to display that individual token.
|
||||
tokenNode: React.PropTypes.func.isRequired
|
||||
tokenRenderer: React.PropTypes.func.isRequired
|
||||
|
||||
# The function responsible for providing a list of possible options
|
||||
# given the current input.
|
||||
|
@ -251,6 +255,9 @@ class TokenizingTextField extends React.Component
|
|||
# A classSet hash applied to the Menu item
|
||||
menuClassSet: React.PropTypes.object
|
||||
|
||||
@defaultProps:
|
||||
className: ''
|
||||
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
inputValue: ""
|
||||
|
@ -263,21 +270,25 @@ class TokenizingTextField extends React.Component
|
|||
render: =>
|
||||
{Menu} = require 'nylas-component-kit'
|
||||
|
||||
classes = classNames _.extend {}, (@props.menuClassSet ? {}),
|
||||
classSet = {}
|
||||
classSet[@props.className] = true
|
||||
classes = classNames _.extend {}, classSet, (@props.menuClassSet ? {}),
|
||||
"tokenizing-field": true
|
||||
"native-key-bindings": true
|
||||
"focused": @state.focus
|
||||
"empty": (@state.inputValue ? "").trim().length is 0
|
||||
|
||||
<Menu className={classes} ref="completions"
|
||||
items={@state.completions}
|
||||
itemKey={ (item) -> item.id }
|
||||
itemContent={@props.completionNode}
|
||||
headerComponents={[@_fieldComponent()]}
|
||||
onFocus={@_onInputFocused}
|
||||
onBlur={@_onInputBlurred}
|
||||
onSelect={@_addToken}
|
||||
/>
|
||||
<Menu
|
||||
className={classes}
|
||||
ref="completions"
|
||||
items={@state.completions}
|
||||
itemKey={ (item) -> item.id }
|
||||
itemContent={@props.completionNode}
|
||||
headerComponents={[@_fieldComponent()]}
|
||||
onFocus={@_onInputFocused}
|
||||
onBlur={@_onInputBlurred}
|
||||
onSelect={@_addToken}
|
||||
/>
|
||||
|
||||
_fieldComponent: =>
|
||||
<div key="field-component" ref="field-drop-target" onClick={@_onClick} onDrop={@_onDrop}>
|
||||
|
@ -337,6 +348,8 @@ class TokenizingTextField extends React.Component
|
|||
if @props.tokenIsValid
|
||||
valid = @props.tokenIsValid(item)
|
||||
|
||||
TokenRenderer = @props.tokenRenderer
|
||||
|
||||
<Token item={item}
|
||||
key={key}
|
||||
valid={valid}
|
||||
|
@ -344,7 +357,7 @@ class TokenizingTextField extends React.Component
|
|||
onSelected={@_selectToken}
|
||||
onEdited={@props.onEdit}
|
||||
onAction={@props.onTokenAction || @_showDefaultTokenMenu}>
|
||||
{@props.tokenNode(item)}
|
||||
<TokenRenderer token={item} />
|
||||
</Token>
|
||||
|
||||
# Maintaining Input State
|
||||
|
|
|
@ -135,6 +135,10 @@ class ContenteditableExtension
|
|||
###
|
||||
@onKeyDown: ({editor, event}) ->
|
||||
|
||||
@onDrop: ({editor, event}) ->
|
||||
|
||||
@onDragOver: ({editor, event}) ->
|
||||
|
||||
###
|
||||
Public: Override onShowContextMenu to add new menu items to the right click menu
|
||||
inside the contenteditable.
|
||||
|
|
|
@ -56,6 +56,11 @@ class NylasComponentKit
|
|||
@load "DateInput", "date-input"
|
||||
@load "DatePicker", "date-picker"
|
||||
@load "TimePicker", "time-picker"
|
||||
@load "Table", "table"
|
||||
@loadFrom "TableRow", "table"
|
||||
@loadFrom "TableCell", "table"
|
||||
@load "SelectableTable", "selectable-table"
|
||||
@load "EditableTable", "editable-table"
|
||||
|
||||
@load "ScrollRegion", 'scroll-region'
|
||||
@load "ResizableRegion", 'resizable-region'
|
||||
|
@ -73,6 +78,8 @@ class NylasComponentKit
|
|||
@load "NewsletterSignup", 'newsletter-signup'
|
||||
|
||||
# Higher order components
|
||||
@load "ListensToObservable", 'listens-to-observable'
|
||||
@load "ListensToObservable", 'decorators/listens-to-observable'
|
||||
@load "ListensToFluxStore", 'decorators/listens-to-flux-store'
|
||||
@load "ListensToMovementKeys", 'decorators/listens-to-movement-keys'
|
||||
|
||||
module.exports = new NylasComponentKit()
|
||||
|
|
23
static/components/editable-table.less
Normal file
23
static/components/editable-table.less
Normal file
|
@ -0,0 +1,23 @@
|
|||
|
||||
.editable-table-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
|
||||
.column-actions {
|
||||
display: flex;
|
||||
|
||||
.btn.btn-small {
|
||||
padding: 0;
|
||||
border-radius: 100%;
|
||||
text-align: center;
|
||||
margin-left: 3px;
|
||||
margin-top: 1px;
|
||||
width: 24px;
|
||||
line-height: 22px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
46
static/components/table.less
Normal file
46
static/components/table.less
Normal file
|
@ -0,0 +1,46 @@
|
|||
@import 'ui-variables';
|
||||
|
||||
.nylas-table {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: scroll;
|
||||
|
||||
.table-row {
|
||||
.table-cell {
|
||||
&>div {
|
||||
min-height: 20px;
|
||||
min-width: 100px;
|
||||
}
|
||||
border: 1px solid lightgrey;
|
||||
|
||||
input {
|
||||
border: none;
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
// TODO
|
||||
background: green;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.numbered-cell {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: fadeout(@accent-primary, 80%);
|
||||
.table-cell input {
|
||||
color: darken(@accent-primary-dark, 25%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -37,3 +37,5 @@
|
|||
@import "components/empty-list-state";
|
||||
@import "components/date-picker";
|
||||
@import "components/time-picker";
|
||||
@import "components/table";
|
||||
@import "components/editable-table";
|
||||
|
|
Loading…
Add table
Reference in a new issue