mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-29 16:06:31 +08:00
refactor(signatures): Removed old signature imgs and made static empty signatures page
Summary: Refactored signature preferences page to allow more signatures than the previous 1-1 mapping for signatures and accounts. Created a multi select dropdown of the accounts for which a certain signature is set as default for. Added a button into the draft header From field to toggle between saved signatures. refactor(signatures): Add basic add/remove capabilities to static refactor(signatures): Hooked up signature actions and signature store for basic functionality fix(signatures): Cleaned up signature store and started on multiselectdropdown fix(signatures): Add multi signature toggle select to multiselect dropdown build(signatures): Built framework for multiselect dropdown build(signatures): Toggle button functionality for dropdown build(signatures): Build multi select from components and add debounce refactor(signatures): Move signature actions and signature store into flux fix(signatures): Styled composer signatures button/dropdown and fixed preferences checkmarks build(signatures): Finish main functionality, about to refactor composer signature button into injected component fix(signatures): Changed position styles fix(signatures): Fixed background color for dropdown button when blurred build(signatures): Began to write tests for signatures store, preferences and dropdown Test Plan: Wrote tests for preferences signatures, signature store, signature composer dropdown and refactored tests for signature composer extension. For signature composer extension I removed applyTransformsToDraft and unapplyTransformsToDraft and tested by sending emails with signatures to different providers to make sure the <signature> tag caused problems. Reviewers: bengotow, juan Reviewed By: juan Differential Revision: https://phab.nylas.com/D3073
This commit is contained in:
parent
b03a352ee6
commit
bd361c8abb
33 changed files with 1047 additions and 293 deletions
Binary file not shown.
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
Before Width: | Height: | Size: 8.3 KiB |
|
@ -1,7 +1,7 @@
|
|||
import {PreferencesUIStore, ExtensionRegistry} from 'nylas-exports';
|
||||
import {PreferencesUIStore, ExtensionRegistry, SignatureStore, ComponentRegistry} from 'nylas-exports';
|
||||
|
||||
import SignatureComposerExtension from './signature-composer-extension';
|
||||
import SignatureStore from './signature-store';
|
||||
import SignatureComposerDropdown from './signature-composer-dropdown';
|
||||
import PreferencesSignatures from "./preferences-signatures";
|
||||
|
||||
export function activate() {
|
||||
|
@ -14,12 +14,18 @@ export function activate() {
|
|||
ExtensionRegistry.Composer.register(SignatureComposerExtension);
|
||||
PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
|
||||
SignatureStore.activate();
|
||||
|
||||
ComponentRegistry.register(SignatureComposerDropdown, {
|
||||
role: 'Composer:FromFieldComponents',
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ExtensionRegistry.Composer.unregister(SignatureComposerExtension);
|
||||
PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.sectionId);
|
||||
SignatureStore.deactivate();
|
||||
|
||||
ComponentRegistry.unregister(SignatureComposerDropdown);
|
||||
}
|
||||
|
||||
export function serialize() {
|
||||
|
|
|
@ -1,121 +1,210 @@
|
|||
import React from 'react';
|
||||
import {Contenteditable} from 'nylas-component-kit';
|
||||
import {AccountStore} from 'nylas-exports';
|
||||
import SignatureStore from './signature-store';
|
||||
import SignatureActions from './signature-actions';
|
||||
import _ from 'underscore';
|
||||
import {
|
||||
Flexbox,
|
||||
RetinaImg,
|
||||
EditableList,
|
||||
Contenteditable,
|
||||
MultiselectDropdown,
|
||||
} from 'nylas-component-kit';
|
||||
import {AccountStore, SignatureStore, Actions} from 'nylas-exports';
|
||||
|
||||
|
||||
export default class PreferencesSignatures extends React.Component {
|
||||
static displayName = 'PreferencesSignatures';
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = this._getStateFromStores();
|
||||
constructor() {
|
||||
super()
|
||||
this.state = this._getStateFromStores()
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.usub = AccountStore.listen(this._onChange);
|
||||
this.unsubscribers = [
|
||||
SignatureStore.listen(this._onChange),
|
||||
]
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.usub();
|
||||
this.unsubscribers.forEach(unsubscribe => unsubscribe());
|
||||
}
|
||||
|
||||
|
||||
_onChange = () => {
|
||||
this.setState(this._getStateFromStores());
|
||||
this.setState(this._getStateFromStores())
|
||||
}
|
||||
|
||||
_getStateFromStores() {
|
||||
const accounts = AccountStore.accounts();
|
||||
const state = this.state || {};
|
||||
|
||||
let {currentAccountId} = state;
|
||||
if (!accounts.find(acct => acct.id === currentAccountId)) {
|
||||
currentAccountId = accounts[0].id;
|
||||
}
|
||||
const signatures = SignatureStore.getSignatures()
|
||||
const accounts = AccountStore.accounts()
|
||||
const selected = SignatureStore.selectedSignature()
|
||||
const defaults = SignatureStore.getDefaults()
|
||||
return {
|
||||
accounts,
|
||||
currentAccountId,
|
||||
currentSignature: SignatureStore.signatureForAccountId(currentAccountId),
|
||||
editAsHTML: state.editAsHTML,
|
||||
};
|
||||
signatures: signatures,
|
||||
selectedSignature: selected,
|
||||
defaults: defaults,
|
||||
accounts: accounts,
|
||||
editAsHTML: this.state ? this.state.editAsHTML : false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_onCreateButtonClick = () => {
|
||||
this._onAddSignature()
|
||||
}
|
||||
|
||||
_onAddSignature = () => {
|
||||
Actions.addSignature()
|
||||
}
|
||||
|
||||
_onDeleteSignature = (signature) => {
|
||||
Actions.removeSignature(signature)
|
||||
}
|
||||
|
||||
_onEditSignature = (edit) => {
|
||||
let editedSig;
|
||||
if (typeof edit === "object") {
|
||||
editedSig = {
|
||||
title: this.state.selectedSignature.title,
|
||||
body: edit.target.value,
|
||||
}
|
||||
} else {
|
||||
editedSig = {
|
||||
title: edit,
|
||||
body: this.state.selectedSignature.body,
|
||||
}
|
||||
}
|
||||
Actions.updateSignature(editedSig, this.state.selectedSignature.id)
|
||||
}
|
||||
|
||||
_onSelectSignature = (sig) => {
|
||||
Actions.selectSignature(sig.id)
|
||||
}
|
||||
|
||||
_onToggleAccount = (accountId) => {
|
||||
Actions.toggleAccount(accountId)
|
||||
}
|
||||
|
||||
_onToggleEditAsHTML = () => {
|
||||
const toggled = !this.state.editAsHTML
|
||||
this.setState({editAsHTML: toggled})
|
||||
}
|
||||
|
||||
_renderListItemContent = (sig) => {
|
||||
return sig.title
|
||||
}
|
||||
|
||||
_renderSignatureToolbar() {
|
||||
return (
|
||||
<div className="editable-toolbar">
|
||||
<div className="account-picker">
|
||||
Default for: {this._renderAccountPicker()}
|
||||
</div>
|
||||
<div className="render-mode">
|
||||
<input type="checkbox" id="render-mode" checked={this.state.editAsHTML} onClick={this._onToggleEditAsHTML} />
|
||||
<label>Edit raw HTML</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
_selectItemKey = (account) => {
|
||||
return account.accountId
|
||||
}
|
||||
|
||||
_isChecked = (account) => {
|
||||
if (this.state.defaults[account.accountId] === this.state.selectedSignature.id) return true
|
||||
return false
|
||||
}
|
||||
|
||||
_numSelected() {
|
||||
const sel = _.filter(this.state.accounts, (account) => {
|
||||
return this._isChecked(account)
|
||||
})
|
||||
const numSelected = sel.length
|
||||
return numSelected.toString() + (numSelected === 1 ? " Account" : " Accounts")
|
||||
}
|
||||
|
||||
_renderAccountPicker() {
|
||||
const options = this.state.accounts.map(account =>
|
||||
<option value={account.id} key={account.id}>{account.label}</option>
|
||||
);
|
||||
|
||||
const buttonText = this._numSelected()
|
||||
return (
|
||||
<select value={this.state.currentAccountId} onChange={this._onSelectAccount} style={{minWidth: 200}}>
|
||||
{options}
|
||||
</select>
|
||||
);
|
||||
<MultiselectDropdown
|
||||
className="account-dropdown"
|
||||
items={this.state.accounts}
|
||||
itemChecked={this._isChecked}
|
||||
onToggleItem={this._onToggleAccount}
|
||||
itemKey={this._selectItemKey}
|
||||
current={this.selectedSignature}
|
||||
buttonText={buttonText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
_renderEditableSignature() {
|
||||
const selectedBody = this.state.selectedSignature ? this.state.selectedSignature.body : ""
|
||||
return (
|
||||
<Contenteditable
|
||||
tabIndex={-1}
|
||||
ref="signatureInput"
|
||||
value={this.state.currentSignature}
|
||||
onChange={this._onEditSignature}
|
||||
value={selectedBody}
|
||||
spellcheck={false}
|
||||
onChange={this._onEditSignature}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
_renderHTMLSignature() {
|
||||
return (
|
||||
<textarea
|
||||
ref="signatureHTMLInput"
|
||||
value={this.state.currentSignature}
|
||||
value={this.state.selectedSignature.body}
|
||||
onChange={this._onEditSignature}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
_onEditSignature = (event) => {
|
||||
const html = event.target.value;
|
||||
this.setState({currentSignature: html});
|
||||
|
||||
SignatureActions.setSignatureForAccountId({
|
||||
accountId: this.state.currentAccountId,
|
||||
signature: html,
|
||||
});
|
||||
}
|
||||
|
||||
_onSelectAccount = (event) => {
|
||||
const accountId = event.target.value;
|
||||
this.setState({
|
||||
currentSignature: SignatureStore.signatureForAccountId(accountId),
|
||||
currentAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
_renderModeToggle() {
|
||||
const label = this.state.editAsHTML ? "Edit live preview" : "Edit raw HTML";
|
||||
const action = () => {
|
||||
this.setState({editAsHTML: !this.state.editAsHTML});
|
||||
return;
|
||||
};
|
||||
|
||||
_renderSignatures() {
|
||||
const sigArr = _.values(this.state.signatures)
|
||||
if (sigArr.length === 0) {
|
||||
return (
|
||||
<div className="empty-list">
|
||||
<RetinaImg
|
||||
className="icon-signature"
|
||||
name="signatures-big.png"
|
||||
mode={RetinaImg.Mode.ContentDark}
|
||||
/>
|
||||
<h2>No signatures</h2>
|
||||
<button className="btn btn-small btn-create-signature" onMouseDown={this._onCreateButtonClick}>
|
||||
Create a new signature
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a onClick={action}>{label}</a>
|
||||
);
|
||||
<Flexbox>
|
||||
<EditableList
|
||||
showEditIcon
|
||||
className="signature-list"
|
||||
items={sigArr}
|
||||
itemContent={this._renderListItemContent}
|
||||
onCreateItem={this._onAddSignature}
|
||||
onDeleteItem={this._onDeleteSignature}
|
||||
onItemEdited={this._onEditSignature}
|
||||
onSelectItem={this._onSelectSignature}
|
||||
selected={this.state.selectedSignature}
|
||||
/>
|
||||
<div className="signature-wrap">
|
||||
{this.state.editAsHTML ? this._renderHTMLSignature() : this._renderEditableSignature()}
|
||||
{this._renderSignatureToolbar()}
|
||||
</div>
|
||||
</Flexbox>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const rawText = this.state.editAsHTML ? "Raw HTML " : "";
|
||||
return (
|
||||
<section className="container-signatures">
|
||||
<div className="section-title">
|
||||
{rawText}Signature for: {this._renderAccountPicker()}
|
||||
</div>
|
||||
<div className="signature-wrap">
|
||||
{this.state.editAsHTML ? this._renderHTMLSignature() : this._renderEditableSignature()}
|
||||
</div>
|
||||
<div className="toggle-mode" style={{marginTop: "1em"}}>{this._renderModeToggle()}</div>
|
||||
</section>
|
||||
<div className="preferences-signatures-container">
|
||||
<section>
|
||||
{this._renderSignatures()}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import Reflux from 'reflux';
|
||||
|
||||
const ActionNames = [
|
||||
'setSignatureForAccountId',
|
||||
];
|
||||
|
||||
const Actions = Reflux.createActions(ActionNames);
|
||||
ActionNames.forEach((name) => {
|
||||
Actions[name].sync = true;
|
||||
});
|
||||
|
||||
export default Actions;
|
|
@ -0,0 +1,145 @@
|
|||
import {
|
||||
React,
|
||||
Actions,
|
||||
SignatureStore,
|
||||
} from 'nylas-exports'
|
||||
|
||||
import {
|
||||
Menu,
|
||||
RetinaImg,
|
||||
ButtonDropdown,
|
||||
} from 'nylas-component-kit'
|
||||
import SignatureUtils from './signature-utils'
|
||||
import _ from 'underscore'
|
||||
|
||||
|
||||
export default class SignatureComposerDropdown extends React.Component {
|
||||
static displayName = 'SignatureComposerDropdown'
|
||||
|
||||
static containerRequired = false
|
||||
|
||||
static propTypes = {
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
session: React.PropTypes.object.isRequired,
|
||||
currentAccount: React.PropTypes.object,
|
||||
accounts: React.PropTypes.array,
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.state = this._getStateFromStores()
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this.unsubscribers = [
|
||||
SignatureStore.listen(this._onChange),
|
||||
]
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps) {
|
||||
if (previousProps.currentAccount.accountId !== this.props.currentAccount.accountId) {
|
||||
const newAccountDefaultSignature = SignatureStore.signatureForAccountId(this.props.currentAccount.accountId)
|
||||
this._changeSignature(newAccountDefaultSignature)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribers.forEach(unsubscribe => unsubscribe())
|
||||
}
|
||||
|
||||
_onChange = () => {
|
||||
this.setState(this._getStateFromStores())
|
||||
}
|
||||
|
||||
|
||||
_getStateFromStores() {
|
||||
const signatures = SignatureStore.getSignatures()
|
||||
return {
|
||||
signatures: signatures,
|
||||
}
|
||||
}
|
||||
|
||||
_renderSigItem = (sigItem) => {
|
||||
return (
|
||||
<span className={`signature-title-${sigItem.title}`}>{sigItem.title}</span>
|
||||
)
|
||||
}
|
||||
|
||||
_changeSignature = (sig) => {
|
||||
let body;
|
||||
if (sig) {
|
||||
body = SignatureUtils.applySignature(this.props.draft.body, sig.body)
|
||||
} else {
|
||||
body = SignatureUtils.applySignature(this.props.draft.body, '')
|
||||
}
|
||||
this.props.session.changes.add({body})
|
||||
}
|
||||
|
||||
_isSelected = (sigObj) => {
|
||||
// http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
|
||||
const escapeRegExp = (str) => {
|
||||
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
|
||||
}
|
||||
const signatureRegex = new RegExp(escapeRegExp(`<signature>${sigObj.body}</signature>`))
|
||||
const signatureLocation = signatureRegex.exec(this.props.draft.body)
|
||||
if (signatureLocation) return true
|
||||
return false
|
||||
}
|
||||
|
||||
_onClickNoSignature = () => {
|
||||
this._changeSignature({body: ''})
|
||||
}
|
||||
|
||||
_onClickEditSignatures() {
|
||||
Actions.switchPreferencesTab('Signatures')
|
||||
Actions.openPreferences()
|
||||
}
|
||||
|
||||
_renderSignatures() {
|
||||
const header = [<div className="item item-none" key="none" onMouseDown={this._onClickNoSignature}><span>No signature</span></div>]
|
||||
const footer = [<div className="item item-edit" key="edit" onMouseDown={this._onClickEditSignatures}><span>Edit Signatures...</span></div>]
|
||||
|
||||
const sigItems = _.values(this.state.signatures)
|
||||
return (
|
||||
<Menu
|
||||
headerComponents={header}
|
||||
footerComponents={footer}
|
||||
items={sigItems}
|
||||
itemKey={sigItem => sigItem.id}
|
||||
itemContent={this._renderSigItem}
|
||||
onSelect={this._changeSignature}
|
||||
itemChecked={this._isSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
_renderSignatureIcon() {
|
||||
return (
|
||||
<RetinaImg
|
||||
className="signature-button"
|
||||
name="top-signature-dropdown.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const sigs = this.state.signatures;
|
||||
const icon = this._renderSignatureIcon()
|
||||
|
||||
if (!_.isEmpty(sigs)) {
|
||||
return (
|
||||
<div className="signature-button-dropdown">
|
||||
<ButtonDropdown
|
||||
primaryItem={icon}
|
||||
menu={this._renderSignatures()}
|
||||
bordered={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -1,30 +1,13 @@
|
|||
import {ComposerExtension} from 'nylas-exports';
|
||||
import {ComposerExtension, SignatureStore} from 'nylas-exports';
|
||||
import SignatureUtils from './signature-utils';
|
||||
import SignatureStore from './signature-store';
|
||||
|
||||
export default class SignatureComposerExtension extends ComposerExtension {
|
||||
static prepareNewDraft = ({draft}) => {
|
||||
const accountId = draft.accountId;
|
||||
const signature = SignatureStore.signatureForAccountId(accountId);
|
||||
if (!signature) {
|
||||
const signatureObj = SignatureStore.signatureForAccountId(accountId);
|
||||
if (!signatureObj) {
|
||||
return;
|
||||
}
|
||||
draft.body = SignatureUtils.applySignature(draft.body, signature);
|
||||
}
|
||||
|
||||
static applyTransformsToDraft = ({draft}) => {
|
||||
const nextDraft = draft.clone();
|
||||
nextDraft.body = nextDraft.body.replace(/<\/?signature[^>]*>/g, (match) =>
|
||||
`<!-- ${match} -->`
|
||||
);
|
||||
return nextDraft;
|
||||
}
|
||||
|
||||
static unapplyTransformsToDraft = ({draft}) => {
|
||||
const nextDraft = draft.clone();
|
||||
nextDraft.body = nextDraft.body.replace(/<!-- (<\/?signature[^>]*>) -->/g, (match, node) =>
|
||||
node
|
||||
);
|
||||
return nextDraft;
|
||||
draft.body = SignatureUtils.applySignature(draft.body, signatureObj.body);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
import {DraftStore, AccountStore, Actions} from 'nylas-exports';
|
||||
import SignatureUtils from './signature-utils';
|
||||
import SignatureActions from './signature-actions';
|
||||
|
||||
|
||||
class SignatureStore {
|
||||
|
||||
DefaultSignature = "Sent from <a href=\"https://nylas.com/n1?ref=n1\">Nylas N1</a>, the extensible, open source mail client.";
|
||||
|
||||
constructor() {
|
||||
this.unsubscribes = [];
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.unsubscribes.push(
|
||||
SignatureActions.setSignatureForAccountId.listen(this._onSetSignatureForAccountId)
|
||||
);
|
||||
this.unsubscribes.push(
|
||||
Actions.draftParticipantsChanged.listen(this._onParticipantsChanged)
|
||||
);
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.unsubscribes.forEach(unsub => unsub());
|
||||
}
|
||||
|
||||
signatureForAccountId(accountId) {
|
||||
if (!accountId) {
|
||||
return this.DefaultSignature;
|
||||
}
|
||||
const saved = NylasEnv.config.get(`nylas.account-${accountId}.signature`);
|
||||
if (saved === undefined) {
|
||||
return this.DefaultSignature;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
_onParticipantsChanged = (draftClientId, changes) => {
|
||||
if (!changes.from) { return; }
|
||||
|
||||
DraftStore.sessionForClientId(draftClientId).then((session) => {
|
||||
const draft = session.draft();
|
||||
const {accountId} = AccountStore.accountForEmail(changes.from[0].email);
|
||||
const signature = this.signatureForAccountId(accountId);
|
||||
|
||||
const body = SignatureUtils.applySignature(draft.body, signature);
|
||||
session.changes.add({body});
|
||||
});
|
||||
}
|
||||
|
||||
_onSetSignatureForAccountId = ({signature, accountId}) => {
|
||||
// NylasEnv.config.set is internally debounced 100ms
|
||||
NylasEnv.config.set(`nylas.account-${accountId}.signature`, signature)
|
||||
}
|
||||
}
|
||||
|
||||
export default new SignatureStore();
|
|
@ -7,10 +7,10 @@ export default {
|
|||
|
||||
let newBody = body;
|
||||
let paddingBefore = '';
|
||||
let paddingAfter = '';
|
||||
|
||||
// Remove any existing signature in the body
|
||||
newBody = newBody.replace(signatureRegex, "");
|
||||
const signatureInPrevious = newBody !== body
|
||||
|
||||
// http://www.regexpal.com/?fam=94390
|
||||
// prefer to put the signature one <br> before the beginning of the quote,
|
||||
|
@ -18,13 +18,11 @@ export default {
|
|||
let insertionPoint = newBody.search(RegExpUtils.n1QuoteStartRegex());
|
||||
if (insertionPoint === -1) {
|
||||
insertionPoint = newBody.length;
|
||||
paddingBefore = '<br/><br/>';
|
||||
} else {
|
||||
paddingAfter = '<br/>';
|
||||
if (!signatureInPrevious) paddingBefore = '<br><br>'
|
||||
}
|
||||
|
||||
const contentBefore = newBody.slice(0, insertionPoint);
|
||||
const contentAfter = newBody.slice(insertionPoint);
|
||||
return `${contentBefore}${paddingBefore}<signature>${signature}${paddingAfter}</signature>${contentAfter}`;
|
||||
return `${contentBefore}${paddingBefore}<signature>${signature}</signature>${contentAfter}`;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/* eslint quote-props: 0 */
|
||||
import PreferencesSignatures from '../lib/preferences-signatures.jsx';
|
||||
import ReactTestUtils from 'react-addons-test-utils';
|
||||
import React from 'react';
|
||||
import {SignatureStore, Actions} from 'nylas-exports';
|
||||
|
||||
const SIGNATURES = {
|
||||
'1': {
|
||||
id: '1',
|
||||
title: 'one',
|
||||
body: 'first test signature!',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
title: 'two',
|
||||
body: 'Here is my second sig!',
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
11: '1',
|
||||
22: '2',
|
||||
}
|
||||
|
||||
|
||||
const makeComponent = (props = {}) => {
|
||||
return ReactTestUtils.renderIntoDocument(<PreferencesSignatures {...props} />)
|
||||
}
|
||||
|
||||
describe('PreferencesSignatures', function preferencesSignatures() {
|
||||
this.component = null
|
||||
|
||||
describe('when there are no signatures', () => {
|
||||
it('should add a signature when you click the button', () => {
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn({})
|
||||
spyOn(SignatureStore, 'selectedSignature')
|
||||
spyOn(SignatureStore, 'getDefaults').andReturn({})
|
||||
this.component = makeComponent()
|
||||
spyOn(Actions, 'addSignature')
|
||||
this.button = ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'btn-create-signature')
|
||||
ReactTestUtils.Simulate.mouseDown(this.button)
|
||||
expect(Actions.addSignature).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when there are signatures', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES)
|
||||
spyOn(SignatureStore, 'selectedSignature').andReturn(SIGNATURES['1'])
|
||||
spyOn(SignatureStore, 'getDefaults').andReturn(DEFAULTS)
|
||||
this.component = makeComponent()
|
||||
})
|
||||
it('should add a signature when you click the plus button', () => {
|
||||
spyOn(Actions, 'addSignature')
|
||||
this.plus = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'btn-editable-list')[0]
|
||||
ReactTestUtils.Simulate.click(this.plus)
|
||||
expect(Actions.addSignature).toHaveBeenCalled()
|
||||
})
|
||||
it('should delete a signature when you click the minus button', () => {
|
||||
spyOn(Actions, 'removeSignature')
|
||||
this.minus = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'btn-editable-list')[1]
|
||||
ReactTestUtils.Simulate.click(this.minus)
|
||||
expect(Actions.removeSignature).toHaveBeenCalledWith(SIGNATURES['1'])
|
||||
})
|
||||
it('should toggle default status when you click an email on the dropdown', () => {
|
||||
spyOn(Actions, 'toggleAccount')
|
||||
this.account = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'item')[0]
|
||||
ReactTestUtils.Simulate.mouseDown(this.account)
|
||||
expect(Actions.toggleAccount).toHaveBeenCalledWith('test-account-server-id')
|
||||
})
|
||||
it('should set the selected signature when you click on one that is not currently selected', () => {
|
||||
spyOn(Actions, 'selectSignature')
|
||||
this.item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'list-item')[1]
|
||||
ReactTestUtils.Simulate.click(this.item)
|
||||
expect(Actions.selectSignature).toHaveBeenCalledWith('2')
|
||||
})
|
||||
it('should modify the signature body when edited', () => {
|
||||
spyOn(Actions, 'updateSignature')
|
||||
const newText = 'Changed <strong>NEW 1 HTML</strong><br>'
|
||||
this.component._onEditSignature({target: {value: newText}});
|
||||
expect(Actions.updateSignature).toHaveBeenCalled()
|
||||
})
|
||||
it('should modify the signature title when edited', () => {
|
||||
spyOn(Actions, 'updateSignature')
|
||||
const newTitle = 'Changed'
|
||||
this.component._onEditSignature(newTitle)
|
||||
expect(Actions.updateSignature).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,58 @@
|
|||
/* eslint quote-props: 0 */
|
||||
|
||||
import React from 'react';
|
||||
import SignatureComposerDropdown from '../lib/signature-composer-dropdown'
|
||||
import {renderIntoDocument} from '../../../spec/nylas-test-utils'
|
||||
import ReactTestUtils from 'react-addons-test-utils'
|
||||
import {SignatureStore} from 'nylas-exports';
|
||||
|
||||
const SIGNATURES = {
|
||||
'1': {
|
||||
id: '1',
|
||||
title: 'one',
|
||||
body: 'first test signature!',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
title: 'two',
|
||||
body: 'Here is my second sig!',
|
||||
},
|
||||
}
|
||||
|
||||
describe('SignatureComposerDropdown', function signatureComposerDropdown() {
|
||||
beforeEach(() => {
|
||||
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES)
|
||||
spyOn(SignatureStore, 'selectedSignature')
|
||||
this.session = {
|
||||
changes: {
|
||||
add: jasmine.createSpy('add'),
|
||||
},
|
||||
}
|
||||
this.draft = {
|
||||
body: "draft body",
|
||||
}
|
||||
this.button = renderIntoDocument(<SignatureComposerDropdown draft={this.draft} session={this.session} />)
|
||||
})
|
||||
describe('the button dropdown', () => {
|
||||
it('calls add signature with the correct signature', () => {
|
||||
const sigToAdd = SIGNATURES['2']
|
||||
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
|
||||
this.signature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, `signature-title-${sigToAdd.title}`)
|
||||
ReactTestUtils.Simulate.mouseDown(this.signature)
|
||||
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature>${sigToAdd.body}</signature>`})
|
||||
})
|
||||
it('calls add signature with nothing when no signature is clicked and there is no current signature', () => {
|
||||
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
|
||||
this.noSignature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'item-none')
|
||||
ReactTestUtils.Simulate.mouseDown(this.noSignature)
|
||||
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature></signature>`})
|
||||
})
|
||||
it('finds and removes the signature when no signature is clicked and there is a current signature', () => {
|
||||
this.draft = 'draft body<signature>Remove me</signature>'
|
||||
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
|
||||
this.noSignature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'item-none')
|
||||
ReactTestUtils.Simulate.mouseDown(this.noSignature)
|
||||
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature></signature>`})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,26 +1,23 @@
|
|||
import {Message} from 'nylas-exports';
|
||||
import {Message, SignatureStore} from 'nylas-exports';
|
||||
import SignatureComposerExtension from '../lib/signature-composer-extension';
|
||||
import SignatureStore from '../lib/signature-store';
|
||||
|
||||
const TEST_SIGNATURE = '<div class="something">This is my signature.</div>';
|
||||
const TEST_ID = 1
|
||||
const TEST_SIGNATURE = {
|
||||
id: TEST_ID,
|
||||
title: 'test-sig',
|
||||
body: '<div class="something">This is my signature.</div>',
|
||||
}
|
||||
|
||||
const TEST_SIGNATURES = {}
|
||||
TEST_SIGNATURES[TEST_ID] = TEST_SIGNATURE
|
||||
|
||||
describe('SignatureComposerExtension', function signatureComposerExtension() {
|
||||
describe("applyTransformsToDraft", () => {
|
||||
it("should unwrap the signature and remove the custom DOM element", () => {
|
||||
const a = new Message({
|
||||
draft: true,
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
body: `This is a test! <signature>${TEST_SIGNATURE}<br/></signature><div class="gmail_quote">Hello world</div>`,
|
||||
});
|
||||
const out = SignatureComposerExtension.applyTransformsToDraft({draft: a});
|
||||
expect(out.body).toEqual(`This is a test! <!-- <signature> -->${TEST_SIGNATURE}<br/><!-- </signature> --><div class="gmail_quote">Hello world</div>`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareNewDraft", () => {
|
||||
describe("when a signature is defined", () => {
|
||||
beforeEach(() => {
|
||||
spyOn(NylasEnv.config, 'get').andCallFake(() => TEST_SIGNATURE);
|
||||
spyOn(NylasEnv.config, 'get').andCallFake(() => TEST_SIGNATURES);
|
||||
spyOn(SignatureStore, 'signatureForAccountId').andReturn(TEST_SIGNATURE)
|
||||
SignatureStore.activate()
|
||||
});
|
||||
|
||||
it("should insert the signature at the end of the message or before the first quoted text block and have a newline", () => {
|
||||
|
@ -36,31 +33,31 @@ describe('SignatureComposerExtension', function signatureComposerExtension() {
|
|||
});
|
||||
|
||||
SignatureComposerExtension.prepareNewDraft({draft: a});
|
||||
expect(a.body).toEqual(`This is a test! <signature>${TEST_SIGNATURE}<br/></signature><div class="gmail_quote">Hello world</div>`);
|
||||
expect(a.body).toEqual(`This is a test! <signature>${TEST_SIGNATURE.body}</signature><div class="gmail_quote">Hello world</div>`);
|
||||
SignatureComposerExtension.prepareNewDraft({draft: b});
|
||||
expect(b.body).toEqual(`This is a another test.<br/><br/><signature>${TEST_SIGNATURE}</signature>`);
|
||||
expect(b.body).toEqual(`This is a another test.<br><br><signature>${TEST_SIGNATURE.body}</signature>`);
|
||||
});
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
name: 'With blockquote',
|
||||
body: `This is a test! <signature><div>SIG</div></signature><div class="gmail_quote">Hello world</div>`,
|
||||
expected: `This is a test! <signature>${TEST_SIGNATURE}<br/></signature><div class="gmail_quote">Hello world</div>`,
|
||||
expected: `This is a test! <signature>${TEST_SIGNATURE.body}</signature><div class="gmail_quote">Hello world</div>`,
|
||||
},
|
||||
{
|
||||
name: 'Populated signature div',
|
||||
body: `This is a test! <signature><br/><br/><div>SIG</div></signature>`,
|
||||
expected: `This is a test! <br/><br/><signature>${TEST_SIGNATURE}</signature>`,
|
||||
body: `This is a test! <signature><div>SIG</div></signature>`,
|
||||
expected: `This is a test! <signature>${TEST_SIGNATURE.body}</signature>`,
|
||||
},
|
||||
{
|
||||
name: 'Empty signature div',
|
||||
body: 'This is a test! <signature></signature>',
|
||||
expected: `This is a test! <br/><br/><signature>${TEST_SIGNATURE}</signature>`,
|
||||
expected: `This is a test! <signature>${TEST_SIGNATURE.body}</signature>`,
|
||||
},
|
||||
{
|
||||
name: 'With newlines',
|
||||
body: 'This is a test!<br/> <signature>\n<br>\n<div>SIG</div>\n</signature>',
|
||||
expected: `This is a test!<br/> <br/><br/><signature>${TEST_SIGNATURE}</signature>`,
|
||||
expected: `This is a test!<br/> <signature>${TEST_SIGNATURE.body}</signature>`,
|
||||
},
|
||||
]
|
||||
|
||||
|
@ -76,38 +73,5 @@ describe('SignatureComposerExtension', function signatureComposerExtension() {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when no signature is present in the config file", () => {
|
||||
beforeEach(() => {
|
||||
spyOn(NylasEnv.config, 'get').andCallFake(() => undefined);
|
||||
});
|
||||
|
||||
it("should insert the default signature", () => {
|
||||
const a = new Message({
|
||||
draft: true,
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
body: 'This is a test! <div class="gmail_quote">Hello world</div>',
|
||||
});
|
||||
SignatureComposerExtension.prepareNewDraft({draft: a});
|
||||
expect(a.body).toEqual(`This is a test! <signature>${SignatureStore.DefaultSignature}<br/></signature><div class="gmail_quote">Hello world</div>`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("when a blank signature is present in the config file", () => {
|
||||
beforeEach(() => {
|
||||
spyOn(NylasEnv.config, 'get').andCallFake(() => "");
|
||||
});
|
||||
|
||||
it("should insert nothing", () => {
|
||||
const a = new Message({
|
||||
draft: true,
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
body: 'This is a test! <div class="gmail_quote">Hello world</div>',
|
||||
});
|
||||
SignatureComposerExtension.prepareNewDraft({draft: a});
|
||||
expect(a.body).toEqual(`This is a test! <div class="gmail_quote">Hello world</div>`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/* eslint quote-props: 0 */
|
||||
import {SignatureStore} from 'nylas-exports'
|
||||
|
||||
let SIGNATURES = {
|
||||
'1': {
|
||||
id: '1',
|
||||
title: 'one',
|
||||
body: 'first test signature!',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
title: 'two',
|
||||
body: 'Here is my second sig!',
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
11: '2',
|
||||
22: '2',
|
||||
33: null,
|
||||
}
|
||||
|
||||
describe('SignatureStore', function signatureStore() {
|
||||
beforeEach(() => {
|
||||
spyOn(NylasEnv.config, 'get').andCallFake(() => SIGNATURES)
|
||||
|
||||
spyOn(SignatureStore, '_saveSignatures').andCallFake(() => {
|
||||
NylasEnv.config.set(`nylas.signatures`, SignatureStore.signatures)
|
||||
})
|
||||
spyOn(SignatureStore, 'signatureForAccountId').andCallFake((accountId) => SIGNATURES[DEFAULTS[accountId]])
|
||||
spyOn(SignatureStore, 'selectedSignature').andCallFake(() => SIGNATURES['1'])
|
||||
SignatureStore.activate()
|
||||
})
|
||||
|
||||
|
||||
describe('signatureForAccountId', () => {
|
||||
it('should return the default signature for that account', () => {
|
||||
const titleForAccount11 = SignatureStore.signatureForAccountId(11).title
|
||||
expect(titleForAccount11).toEqual(SIGNATURES['2'].title)
|
||||
const account22Def = SignatureStore.signatureForAccountId(33)
|
||||
expect(account22Def).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSignature', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(NylasEnv.config, 'set').andCallFake((notImportant, newObject) => {
|
||||
SIGNATURES = newObject
|
||||
})
|
||||
})
|
||||
it('should remove the signature from our list of signatures', () => {
|
||||
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId]
|
||||
SignatureStore._onRemoveSignature(toRemove)
|
||||
expect(SIGNATURES['1']).toEqual(undefined)
|
||||
})
|
||||
it('should reset selectedSignatureId to a different signature', () => {
|
||||
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId]
|
||||
SignatureStore._onRemoveSignature(toRemove)
|
||||
expect(SignatureStore.selectedSignatureId).toNotEqual('1')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,74 +1,188 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.container-signatures {
|
||||
@blurred-primary-color: mix(@background-primary, #ffbb00, 96%);
|
||||
// Styles for Preferences Signatures
|
||||
.preferences-signatures-container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
|
||||
.empty-list {
|
||||
height: 300px;
|
||||
width: inherit;
|
||||
background-color: @background-primary;
|
||||
border: 1px solid @border-color-divider;
|
||||
text-align: center;
|
||||
padding: 60px;
|
||||
|
||||
.icon-signature {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: @text-color-very-subtle;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
.signature-list {
|
||||
position: relative;
|
||||
height: inherit;
|
||||
width: inherit;
|
||||
|
||||
.items-wrapper {
|
||||
min-width:200px;
|
||||
height: 262px;
|
||||
}
|
||||
.item-rule-disabled {
|
||||
color: @color-error;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid @border-color-divider;
|
||||
}
|
||||
.selected .item-rule-disabled {
|
||||
color: @component-active-bg;
|
||||
}
|
||||
|
||||
.editable-item {
|
||||
padding: 8px 10px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.btn-editable-list {
|
||||
height: 37px;
|
||||
width: 37px;
|
||||
line-height: 37px;
|
||||
}
|
||||
}
|
||||
|
||||
.signature-wrap {
|
||||
position: relative;
|
||||
border: 1px solid @input-border-color;
|
||||
background-color: @white;
|
||||
padding: 10px;
|
||||
margin-top: 20px;
|
||||
min-height: 200px;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
|
||||
textarea {
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
.section-body {
|
||||
padding: 10px 0 0 0;
|
||||
|
||||
.menu {
|
||||
border: solid thin #CCC;
|
||||
margin-right: 5px;
|
||||
min-height: 200px;
|
||||
.menu-items {
|
||||
margin:0;
|
||||
padding:0;
|
||||
list-style: none;
|
||||
|
||||
li { padding: 6px; }
|
||||
}
|
||||
.contenteditable {
|
||||
padding: 10px;
|
||||
}
|
||||
.menu-horizontal {
|
||||
height: 100%;
|
||||
.menu-items {
|
||||
height:100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
text-align:center;
|
||||
width:40px;
|
||||
display:inline-block;
|
||||
padding:8px 16px 8px 16px;
|
||||
border-right: solid thin #CCC;
|
||||
|
||||
.editable-toolbar {
|
||||
border-top: 1px solid @input-border-color;
|
||||
padding: 6px 10px;
|
||||
|
||||
.render-mode {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.account-picker {
|
||||
display: inline-block;
|
||||
color: @text-color-subtle;
|
||||
|
||||
.button-dropdown {
|
||||
.secondary-items {
|
||||
.menu {
|
||||
.item {
|
||||
padding: 5px 10px 5px 25px;
|
||||
}
|
||||
.item.checked {
|
||||
background-image: url(images/menu/osx-checkmark@2x.svg);
|
||||
background-position: left;
|
||||
background-position-x: 5%;
|
||||
background-size: 10px;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.signature-area {
|
||||
border: solid thin #CCC;
|
||||
min-height: 200px;
|
||||
}
|
||||
.menu-footer {
|
||||
border: solid thin #CCC;
|
||||
overflow: auto;
|
||||
}
|
||||
.signature-footer {
|
||||
border: solid thin #CCC;
|
||||
overflow: auto;
|
||||
|
||||
.edit-html-button {
|
||||
float: right;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Styles for account-contact-field signature selector
|
||||
.message-item-white-wrap.composer-outer-wrap{
|
||||
.composer-participant-field {
|
||||
|
||||
.dropdown-component {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
|
||||
.signature-button-dropdown {
|
||||
.only-item {
|
||||
background: @blurred-primary-color;
|
||||
box-shadow: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
img.content-mask {
|
||||
display: none;
|
||||
}
|
||||
img.signature-button{
|
||||
display: inline-block;
|
||||
background-color: #919191;
|
||||
}
|
||||
|
||||
|
||||
.button-dropdown {
|
||||
&.open {
|
||||
img.signature-button {
|
||||
background-color: @component-active-color;
|
||||
}
|
||||
}
|
||||
.secondary-items {
|
||||
right:0;
|
||||
left:auto;
|
||||
.menu {
|
||||
.header-container {
|
||||
display:inline-block;
|
||||
padding: 0;
|
||||
}
|
||||
.footer-container {
|
||||
display: inline-block;
|
||||
border-top: 1px solid #dddddd;
|
||||
}
|
||||
.item {
|
||||
padding: 5px 20px 5px 20px;
|
||||
}
|
||||
.item.checked {
|
||||
background-image: url(images/menu/osx-checkmark@2x.svg);
|
||||
background-position: left;
|
||||
background-position-x: 5%;
|
||||
background-size: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
&.focused {
|
||||
.composer-participant-field {
|
||||
.dropdown-component {
|
||||
.only-item {
|
||||
background: @background-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import {AccountStore} from 'nylas-exports';
|
||||
import {Menu, ButtonDropdown} from 'nylas-component-kit';
|
||||
import {
|
||||
AccountStore,
|
||||
} from 'nylas-exports';
|
||||
import {Menu, ButtonDropdown, InjectedComponentSet} from 'nylas-component-kit';
|
||||
|
||||
export default class AccountContactField extends React.Component {
|
||||
static displayName = 'AccountContactField';
|
||||
|
@ -10,11 +11,14 @@ export default class AccountContactField extends React.Component {
|
|||
static propTypes = {
|
||||
value: React.PropTypes.object,
|
||||
accounts: React.PropTypes.array.isRequired,
|
||||
session: React.PropTypes.object.isRequired,
|
||||
draft: React.PropTypes.object.isRequired,
|
||||
onChange: React.PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
_onChooseContact = (contact) => {
|
||||
this.props.onChange({from: [contact]});
|
||||
this.props.session.ensureCorrectAccount()
|
||||
this.refs.dropdown.toggleDropdown();
|
||||
}
|
||||
|
||||
|
@ -72,11 +76,29 @@ export default class AccountContactField extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
_renderFromFieldComponents = () => {
|
||||
const {draft, session, accounts} = this.props
|
||||
return (
|
||||
<InjectedComponentSet
|
||||
className="dropdown-component"
|
||||
matching={{role: "Composer:FromFieldComponents"}}
|
||||
exposedProps={{
|
||||
draft,
|
||||
session,
|
||||
accounts,
|
||||
currentAccount: draft.from[0],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="composer-participant-field">
|
||||
<div className="composer-field-label">From:</div>
|
||||
{this._renderAccountSelector()}
|
||||
{this._renderFromFieldComponents()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,7 +3,12 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
import AccountContactField from './account-contact-field';
|
||||
import {Utils, DraftHelpers, Actions, AccountStore} from 'nylas-exports';
|
||||
import {InjectedComponent, KeyCommandsRegion, ParticipantsTextField, ListensToFluxStore} from 'nylas-component-kit';
|
||||
import {
|
||||
InjectedComponent,
|
||||
KeyCommandsRegion,
|
||||
ParticipantsTextField,
|
||||
ListensToFluxStore,
|
||||
} from 'nylas-component-kit';
|
||||
|
||||
import CollapsedParticipants from './collapsed-participants';
|
||||
import ComposerHeaderActions from './composer-header-actions';
|
||||
|
@ -270,6 +275,7 @@ export default class ComposerHeader extends React.Component {
|
|||
key="from"
|
||||
ref={Fields.From}
|
||||
draft={this.props.draft}
|
||||
session={this.props.session}
|
||||
onChange={this._onChangeParticipants}
|
||||
value={from[0]}
|
||||
/>
|
||||
|
|
|
@ -27,7 +27,6 @@ class PreferencesTabItem extends React.Component {
|
|||
|
||||
render() {
|
||||
const {tabId, displayName} = this.props.tabItem;
|
||||
|
||||
const classes = classNames({
|
||||
item: true,
|
||||
active: tabId === this.props.selection.get('tabId'),
|
||||
|
|
|
@ -139,6 +139,42 @@ describe('EditableList', function editableList() {
|
|||
});
|
||||
});
|
||||
|
||||
describe('_onDeleteItem', () => {
|
||||
let onSelectItem;
|
||||
let onDeleteItem;
|
||||
beforeEach(() => {
|
||||
onSelectItem = jasmine.createSpy('onSelectItem');
|
||||
onDeleteItem = jasmine.createSpy('onDeleteItem');
|
||||
})
|
||||
it('deletes the item from the list', () => {
|
||||
const list = makeList(['1', '2'], {selected: '2', onDeleteItem, onSelectItem});
|
||||
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
|
||||
|
||||
Simulate.click(button);
|
||||
expect(onDeleteItem).toHaveBeenCalledWith('2', 1);
|
||||
})
|
||||
it('sets the selected item to the one above if it exists', () => {
|
||||
const list = makeList(['1', '2'], {selected: '2', onDeleteItem, onSelectItem});
|
||||
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
|
||||
|
||||
Simulate.click(button);
|
||||
expect(onSelectItem).toHaveBeenCalledWith('1', 0)
|
||||
})
|
||||
it('sets the selected item to the one below if it is at the top', () => {
|
||||
const list = makeList(['1', '2'], {selected: '1', onDeleteItem, onSelectItem});
|
||||
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
|
||||
|
||||
Simulate.click(button);
|
||||
expect(onSelectItem).toHaveBeenCalledWith('2', 1)
|
||||
})
|
||||
it('sets the selected item to nothing when you delete the last item', () => {
|
||||
const list = makeList(['1'], {selected: '1', onDeleteItem, onSelectItem});
|
||||
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
|
||||
|
||||
Simulate.click(button);
|
||||
expect(onSelectItem).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
describe('_renderItem', () => {
|
||||
const makeItem = (item, idx, state = {}, handlers = {}) => {
|
||||
const list = makeList([], {initialState: state});
|
||||
|
@ -231,18 +267,13 @@ describe('EditableList', function editableList() {
|
|||
});
|
||||
|
||||
it('renders delete button', () => {
|
||||
const onSelectItem = jasmine.createSpy('onSelectItem');
|
||||
const onDeleteItem = jasmine.createSpy('onDeleteItem');
|
||||
const list = makeList(['1', '2'], {selected: '2', onDeleteItem, onSelectItem});
|
||||
const list = makeList(['1', '2'], {selected: '2'});
|
||||
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
|
||||
|
||||
Simulate.click(button);
|
||||
|
||||
expect(findDOMNode(button).textContent).toEqual('—');
|
||||
expect(onDeleteItem).toHaveBeenCalledWith('2', 1);
|
||||
});
|
||||
|
||||
it('disables teh delete button when no item is selected', () => {
|
||||
it('disables the delete button when no item is selected', () => {
|
||||
const onSelectItem = jasmine.createSpy('onSelectItem');
|
||||
const onDeleteItem = jasmine.createSpy('onDeleteItem');
|
||||
const list = makeList(['1', '2'], {selected: null, onDeleteItem, onSelectItem});
|
||||
|
|
25
spec/components/multiselect-dropdown-spec.jsx
Normal file
25
spec/components/multiselect-dropdown-spec.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
scryRenderedDOMComponentsWithClass,
|
||||
Simulate,
|
||||
} from 'react-addons-test-utils';
|
||||
|
||||
import MultiselectDropdown from '../../src/components/multiselect-dropdown'
|
||||
import {renderIntoDocument} from '../nylas-test-utils'
|
||||
|
||||
const makeDropdown = (items = [], props = {}) => {
|
||||
return renderIntoDocument(<MultiselectDropdown {...props} items={items} />)
|
||||
}
|
||||
describe('MultiselectDropdown', function multiSelectedDropdown() {
|
||||
describe('_onItemClick', () => {
|
||||
it('calls onToggleItem function', () => {
|
||||
const onToggleItem = jasmine.createSpy('onToggleItem')
|
||||
const itemChecked = jasmine.createSpy('itemChecked')
|
||||
const dropdown = makeDropdown(["annie@nylas.com", "anniecook@ostby.com"], {onToggleItem, itemChecked})
|
||||
dropdown.setState({selectingItems: true})
|
||||
const item = scryRenderedDOMComponentsWithClass(dropdown, 'item')[0]
|
||||
Simulate.mouseDown(item)
|
||||
expect(onToggleItem).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -262,13 +262,10 @@ class EditableList extends Component {
|
|||
_onDeleteItem = () => {
|
||||
const selectedItem = this._getSelectedItem();
|
||||
const index = this.props.items.indexOf(selectedItem);
|
||||
|
||||
if (selectedItem) {
|
||||
// Move the selection 1 up after deleting
|
||||
const len = this.props.items.length;
|
||||
const newIndex = Math.min(len - 2, Math.max(0, index + 1));
|
||||
// Move the selection 1 up or down after deleting
|
||||
const newIndex = index === 0 ? index + 1 : index - 1
|
||||
this.props.onDeleteItem(selectedItem, index);
|
||||
|
||||
if (this.props.items[newIndex]) {
|
||||
this._selectItem(this.props.items[newIndex], newIndex);
|
||||
}
|
||||
|
|
81
src/components/multiselect-dropdown.jsx
Normal file
81
src/components/multiselect-dropdown.jsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import React, {Component, PropTypes} from 'react';
|
||||
import {ButtonDropdown, Menu} from 'nylas-component-kit'
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
/**
|
||||
Renders a drop down of items that can have multiple selected
|
||||
Item can be string or object
|
||||
|
||||
@param {object} props - props for MultiselectDropdown
|
||||
@param {string} props.className - css class applied to the component
|
||||
@param {array} props.items - items to be rendered in the dropdown
|
||||
@param {props.itemChecked} - props.itemChecked -- a function to determine if the item should be checked or not
|
||||
@param {props.onToggleItem} - props.onToggleItem -- function called when an item is clicked
|
||||
@param {props.itemKey} - props.itemKey -- function that indicates how to select the key for each MenuItem
|
||||
@param {props.buttonText} - props.buttonText -- string to be rendered in the button
|
||||
**/
|
||||
|
||||
class MultiselectDropdown extends Component {
|
||||
static displayName = 'MultiselectDropdown'
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
items: PropTypes.array.isRequired,
|
||||
itemChecked: PropTypes.func,
|
||||
onToggleItem: PropTypes.func,
|
||||
itemKey: PropTypes.func,
|
||||
buttonText: PropTypes.string,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
items: [],
|
||||
itemChecked: {},
|
||||
onToggleItem: () => {},
|
||||
itemKey: () => {},
|
||||
buttonText: '',
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (ReactDOM.findDOMNode(this.refs.select)) {
|
||||
ReactDOM.findDOMNode(this.refs.select).focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_onItemClick = (item) => {
|
||||
this.props.onToggleItem(item.id)
|
||||
}
|
||||
|
||||
_renderItem = (item) => {
|
||||
const MenuItem = Menu.Item
|
||||
return (
|
||||
<MenuItem onMouseDown={() => this._onItemClick(item)} checked={this.props.itemChecked(item)} key={this.props.itemKey(item)} content={item.label} />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
_renderMenu= (items) => {
|
||||
return (
|
||||
<Menu
|
||||
items={items}
|
||||
itemContent={this._renderItem}
|
||||
itemKey={item => item.id}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {items} = this.props
|
||||
const menu = this._renderMenu(items)
|
||||
return (
|
||||
<ButtonDropdown
|
||||
className={'btn-multiselect'}
|
||||
primaryItem={<span>{this.props.buttonText}</span>}
|
||||
menu={menu}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default MultiselectDropdown
|
|
@ -546,6 +546,15 @@ class Actions
|
|||
@nextSearchResult: ActionScopeWindow
|
||||
@previousSearchResult: ActionScopeWindow
|
||||
|
||||
|
||||
# Actions for the signature preferences and shared with the composer
|
||||
@addSignature: ActionScopeWindow
|
||||
@removeSignature: ActionScopeWindow
|
||||
@updateSignature: ActionScopeWindow
|
||||
@selectSignature: ActionScopeWindow
|
||||
@toggleAccount: ActionScopeWindow
|
||||
|
||||
|
||||
# Read the actions we declared on the dummy Actions object above
|
||||
# and translate them into Reflux Actions
|
||||
|
||||
|
|
118
src/flux/stores/signature-store.es6
Normal file
118
src/flux/stores/signature-store.es6
Normal file
|
@ -0,0 +1,118 @@
|
|||
import {Utils, Actions} from 'nylas-exports';
|
||||
import NylasStore from 'nylas-store'
|
||||
import _ from 'underscore'
|
||||
|
||||
const DefaultSignature = "Sent from <a href=\"https://nylas.com/n1?ref=n1\">Nylas N1</a>, the extensible, open source mail client.";
|
||||
|
||||
class SignatureStore extends NylasStore {
|
||||
|
||||
activate() {
|
||||
this.unsubscribers = [
|
||||
Actions.addSignature.listen(this._onAddSignature),
|
||||
Actions.removeSignature.listen(this._onRemoveSignature),
|
||||
Actions.updateSignature.listen(this._onEditSignature),
|
||||
Actions.selectSignature.listen(this._onSelectSignature),
|
||||
Actions.toggleAccount.listen(this._onToggleAccount),
|
||||
];
|
||||
|
||||
NylasEnv.config.onDidChange(`nylas.signatures`, () => {
|
||||
this.signatures = NylasEnv.config.get(`nylas.signatures`)
|
||||
this.trigger()
|
||||
})
|
||||
NylasEnv.config.onDidChange(`nylas.defaultSignatures`, () => {
|
||||
this.defaultSignatures = NylasEnv.config.get(`nylas.defaultSignatures`)
|
||||
this.trigger()
|
||||
})
|
||||
this.signatures = NylasEnv.config.get(`nylas.signatures`) || {}
|
||||
this.selectedSignatureId = this._setSelectedSignatureId()
|
||||
this.defaultSignatures = NylasEnv.config.get(`nylas.defaultSignatures`) || {}
|
||||
this.trigger()
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.unsubscribers.forEach(unsub => unsub());
|
||||
}
|
||||
|
||||
getSignatures() {
|
||||
return this.signatures;
|
||||
}
|
||||
|
||||
selectedSignature() {
|
||||
return this.signatures[this.selectedSignatureId]
|
||||
}
|
||||
|
||||
getDefaults() {
|
||||
return this.defaultSignatures
|
||||
}
|
||||
|
||||
signatureForAccountId = (accountId) => {
|
||||
return this.signatures[this.defaultSignatures[accountId]]
|
||||
}
|
||||
|
||||
_saveSignatures() {
|
||||
_.debounce(NylasEnv.config.set(`nylas.signatures`, this.signatures), 500)
|
||||
}
|
||||
|
||||
_saveDefaultSignatures() {
|
||||
_.debounce(NylasEnv.config.set(`nylas.defaultSignatures`, this.defaultSignatures), 500)
|
||||
}
|
||||
|
||||
|
||||
_onSelectSignature = (id) => {
|
||||
this.selectedSignatureId = id
|
||||
this.trigger()
|
||||
}
|
||||
|
||||
_removeByKey = (obj, keyToDelete) => {
|
||||
return Object.keys(obj)
|
||||
.filter(key => key !== keyToDelete)
|
||||
.reduce((result, current) => {
|
||||
result[current] = obj[current];
|
||||
return result;
|
||||
}, {})
|
||||
}
|
||||
|
||||
_setSelectedSignatureId() {
|
||||
const sigIds = Object.keys(this.signatures)
|
||||
if (sigIds.length) {
|
||||
return sigIds[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
_onRemoveSignature = (signatureToDelete) => {
|
||||
this.signatures = this._removeByKey(this.signatures, signatureToDelete.id)
|
||||
this.selectedSignatureId = this._setSelectedSignatureId()
|
||||
this.trigger()
|
||||
this._saveSignatures()
|
||||
}
|
||||
|
||||
_onAddSignature = (sigTitle = "Untitled") => {
|
||||
const newId = Utils.generateTempId()
|
||||
this.signatures[newId] = {id: newId, title: sigTitle, body: DefaultSignature}
|
||||
this.selectedSignatureId = newId
|
||||
this.trigger()
|
||||
this._saveSignatures()
|
||||
}
|
||||
|
||||
_onEditSignature = (editedSig, oldSigId) => {
|
||||
this.signatures[oldSigId].title = editedSig.title
|
||||
this.signatures[oldSigId].body = editedSig.body
|
||||
this.trigger()
|
||||
this._saveSignatures()
|
||||
}
|
||||
|
||||
_onToggleAccount = (accountId) => {
|
||||
if (this.defaultSignatures[accountId] === this.selectedSignatureId) {
|
||||
this.defaultSignatures[accountId] = null
|
||||
} else {
|
||||
this.defaultSignatures[accountId] = this.selectedSignatureId
|
||||
}
|
||||
|
||||
this.trigger()
|
||||
this._saveDefaultSignatures()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new SignatureStore();
|
|
@ -43,6 +43,7 @@ class NylasComponentKit
|
|||
@load "ButtonDropdown", 'button-dropdown'
|
||||
@load "Contenteditable", 'contenteditable/contenteditable'
|
||||
@load "MultiselectList", 'multiselect-list'
|
||||
@load "MultiselectDropdown", "multiselect-dropdown"
|
||||
@load "KeyCommandsRegion", 'key-commands-region'
|
||||
@load "TabGroupRegion", 'tab-group-region'
|
||||
@load "InjectedComponent", 'injected-component'
|
||||
|
|
|
@ -120,6 +120,7 @@ class NylasExports
|
|||
@lazyLoadAndRegisterStore "OutboxStore", 'outbox-store'
|
||||
@lazyLoadAndRegisterStore "PopoverStore", 'popover-store'
|
||||
@lazyLoadAndRegisterStore "AccountStore", 'account-store'
|
||||
@lazyLoadAndRegisterStore "SignatureStore", 'signature-store'
|
||||
@lazyLoadAndRegisterStore "MessageStore", 'message-store'
|
||||
@lazyLoadAndRegisterStore "ContactStore", 'contact-store'
|
||||
@lazyLoadAndRegisterStore "IdentityStore", 'identity-store'
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
position: relative;
|
||||
color: @text-color;
|
||||
font-size: @font-size;
|
||||
padding-bottom: 15px;
|
||||
|
||||
div[contenteditable], .contenteditable {
|
||||
flex: 1;
|
||||
|
|
|
@ -69,10 +69,6 @@
|
|||
padding-right: 10%;
|
||||
}
|
||||
|
||||
.item.selected.checked, .item.checked:hover {
|
||||
background-image:url(./images/menu/checked-selected@2x.png);
|
||||
}
|
||||
|
||||
.item.selected, .item:active {
|
||||
text-decoration: none;
|
||||
background-color: @accent-primary;
|
||||
|
|
15
static/components/multiselect-dropdown.less
Normal file
15
static/components/multiselect-dropdown.less
Normal file
|
@ -0,0 +1,15 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.nylas-multiselect-dropdown {
|
||||
display: inline-block;
|
||||
|
||||
.button-dropdown, .menu, .secondary-items{
|
||||
.content-container {
|
||||
background: @dropdown-default-bg-color;
|
||||
border-radius: 3px;
|
||||
.item {
|
||||
padding: 5px 10px 5px 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
BIN
static/images/composer/top-signature-dropdown@1x.png
Normal file
BIN
static/images/composer/top-signature-dropdown@1x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
static/images/composer/top-signature-dropdown@2x.png
Normal file
BIN
static/images/composer/top-signature-dropdown@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
11
static/images/menu/osx-checkmark@2x.svg
Normal file
11
static/images/menu/osx-checkmark@2x.svg
Normal file
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="17.91" height="19.594" viewBox="0 0 17.91 19.594">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #444;
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path id="osx-checkmark_2x.svg" data-name="osx-checkmark@2x.svg" class="cls-1" d="M1135.13,290.9a2.162,2.162,0,0,0,.6,2.1,20.22,20.22,0,0,1,4.29,5.009,18.224,18.224,0,0,0,1.62,2.844l0.5,0.126s1.7-4.595,3.72-9.165a32.745,32.745,0,0,1,6.35-9.237,2.354,2.354,0,0,0,.77-0.761s-2.06-.251-3.01-0.38a2.922,2.922,0,0,0-2.15.385c-1.12.472-3.4,4.39-3.88,6.008s-2.37,5.03-2.34,6.35-1.6-2.881-2.84-3.527S1135.13,290.9,1135.13,290.9Z" transform="translate(-1135.06 -281.375)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 687 B |
BIN
static/images/preferences/signatures/signatures-big@2x.png
Normal file
BIN
static/images/preferences/signatures/signatures-big@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
|
@ -39,3 +39,4 @@
|
|||
@import "components/time-picker";
|
||||
@import "components/table";
|
||||
@import "components/editable-table";
|
||||
@import "components/multiselect-dropdown";
|
||||
|
|
Loading…
Add table
Reference in a new issue