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:
Juan Tejada 2016-04-22 18:23:00 -07:00
parent a8413c8e2f
commit 021eac7679
41 changed files with 2004 additions and 97 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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() {
}

View 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,
},
}
}

View file

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

View 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"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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
}

View file

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

View 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

View file

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

View 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

View file

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

View file

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

View file

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

View 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
View 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>
)
}
}

View file

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

View file

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

View file

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

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

View 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%);
}
}
}
}

View file

@ -37,3 +37,5 @@
@import "components/empty-list-state";
@import "components/date-picker";
@import "components/time-picker";
@import "components/table";
@import "components/editable-table";