refactor(composer): Make session, draft available everywhere

Summary:
Up until now, we've been requiring that every plugin control in the composer take the draftClientId, retreive the session, listen to it, build state from the draft, etc. This is a huge pain and is hard to explain to newcomers becaus it frankly makes no sense.

In 0.3.45 we made it so that the ComposerView always has a non-null draft and session. (It isn't rendered until they're available). In this diff, I just pass those through to all the plugins and remove all the session retrieval cruft.

Almost none of the buttons have state of their own, which I think is appropriate.

They do render on every keystroke, but they were already running code (to recompute their state) on each keystroke and profiling suggests this has no impact.

Prepare for immutable

In preparation for Immutable models, make the draft store proxy returns a !== draft if any changes have been made. This means you can safely know that a draft has changed if `props.draft !== nextProps.draft`

Test Plan: Run tests

Reviewers: juan, evan

Reviewed By: juan, evan

Differential Revision: https://phab.nylas.com/D2902
This commit is contained in:
Ben Gotow 2016-04-19 16:05:15 -07:00
parent 7e3e3a6015
commit 585dab7cdf
24 changed files with 422 additions and 549 deletions

View file

@ -1,7 +1,8 @@
import React, {Component, PropTypes} from 'react';
import SchedulerActions from '../scheduler-actions'
import NewEventCard from './new-event-card'
import {PLUGIN_ID} from '../scheduler-constants'
import {Utils, Event, Actions, DraftStore} from 'nylas-exports';
import {Utils, Event} from 'nylas-exports';
/**
* When you're creating an event you can either be creating:
@ -18,84 +19,80 @@ export default class NewEventCardContainer extends Component {
static displayName = 'NewEventCardContainer';
static propTypes = {
draftClientId: PropTypes.string,
draft: PropTypes.object.isRequired,
session: PropTypes.object.isRequired,
}
constructor(props) {
super(props);
this.state = {event: null};
this._session = null;
this._mounted = false;
this._usub = () => {}
}
componentWillMount() {
this._mounted = true;
this._loadDraft(this.props.draftClientId);
}
componentWillReceiveProps(newProps) {
this._loadDraft(newProps.draftClientId);
componentDidMount() {
this._unlisten = SchedulerActions.confirmChoices.listen(this._onConfirmChoices);
}
componentWillUnmount() {
this._mounted = false;
this._usub()
if (this._unlisten) {
this._unlisten();
}
}
_loadDraft(draftClientId) {
DraftStore.sessionForClientId(draftClientId).then(session => {
// Only run if things are still relevant: component is mounted
// and draftClientIds still match
if (this._mounted) {
this._session = session;
this._usub()
this._usub = session.listen(this._onDraftChange);
this._onDraftChange();
}
});
}
_onConfirmChoices = ({proposals = [], draftClientId}) => {
const {draft} = this.props;
_onDraftChange = () => {
this.setState({event: this._getEvent()});
if (draft.clientId !== draftClientId) {
return;
}
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
if (proposals.length === 0) {
delete metadata.proposals;
} else {
metadata.proposals = proposals;
}
this.props.session.changes.addPluginMetadata(PLUGIN_ID, metadata);
}
_getEvent() {
const metadata = this._session.draft().metadataForPluginId(PLUGIN_ID);
const metadata = this.props.draft.metadataForPluginId(PLUGIN_ID);
if (metadata && metadata.pendingEvent) {
return new Event().fromJSON(metadata.pendingEvent || {})
return new Event().fromJSON(metadata.pendingEvent || {});
}
return null
}
_updateEvent = (newData) => {
const newEvent = Object.assign(this._getEvent().clone(), newData)
const {draft, session} = this.props;
const newEvent = Object.assign(this._getEvent().clone(), newData);
const newEventJSON = newEvent.toJSON();
const metadata = this._session.draft().metadataForPluginId(PLUGIN_ID);
const metadata = draft.metadataForPluginId(PLUGIN_ID);
if (!Utils.isEqual(metadata.pendingEvent, newEventJSON)) {
metadata.pendingEvent = newEventJSON;
this._session.changes.addPluginMetadata(PLUGIN_ID, metadata);
session.changes.addPluginMetadata(PLUGIN_ID, metadata);
}
}
_removeEvent = () => {
const draft = this._session.draft()
const {draft, session} = this.props;
const metadata = draft.metadataForPluginId(PLUGIN_ID);
if (metadata) {
delete metadata.pendingEvent
delete metadata.pendingEvent;
delete metadata.proposals
Actions.setMetadata(draft, PLUGIN_ID, metadata);
session.changes.addPluginMetadata(PLUGIN_ID, metadata);
}
}
render() {
const event = this._getEvent();
let card = false;
if (this._session && this.state.event) {
if (event) {
card = (
<NewEventCard event={this.state.event}
<NewEventCard event={event}
ref="newEventCard"
draft={this._session.draft()}
draft={this.props.draft}
onRemove={this._removeEvent}
onChange={this._updateEvent}
onParticipantsClick={() => {}}

View file

@ -173,9 +173,14 @@ export default class NewEventCard extends React.Component {
<div className="row time">
{this._renderIcon("ic-eventcard-time@2x.png")}
<span>
<TimePicker value={startVal} onChange={this._onChangeStartTime} />
<TimePicker
value={startVal}
onChange={this._onChangeStartTime}
/>
to
<TimePicker value={endVal} relativeTo={startVal}
<TimePicker
value={endVal}
relativeTo={startVal}
onChange={this._onChangeEndTime}
/>
<span className="timezone">

View file

@ -4,7 +4,6 @@ import {
Actions,
APIError,
NylasAPI,
DraftStore,
} from 'nylas-exports'
import {Menu, RetinaImg} from 'nylas-component-kit'
import {PLUGIN_ID, PLUGIN_NAME} from '../scheduler-constants'
@ -22,47 +21,23 @@ export default class SchedulerComposerButton extends React.Component {
static displayName = "SchedulerComposerButton";
static propTypes = {
draftClientId: React.PropTypes.string,
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = {enabled: false};
this._session = null;
this._mounted = false;
this._unsubscribes = [];
}
componentDidMount() {
this._mounted = true;
this.handleProps()
shouldComponentUpdate(nextProps, nextState) {
return (this.state !== nextState) ||
(this._hasPendingEvent(nextProps) !== this._hasPendingEvent(this.props));
}
componentWillReceiveProps(newProps) {
this.handleProps(newProps);
}
handleProps(newProps = null) {
const props = newProps || this.props;
DraftStore.sessionForClientId(props.draftClientId).then(session => {
// Only run if things are still relevant: component is mounted
// and draftClientIds still match
const idIsCurrent = newProps ? true : this.props.draftClientId === session.draftClientId;
if (this._mounted && idIsCurrent) {
this._session = session;
const unsub = session.listen(this._onDraftChange.bind(this));
this._unsubscribes.push(unsub);
this._onDraftChange();
}
});
}
_onDraftChange() {
this.setState({enabled: this._hasPendingEvent()});
}
_hasPendingEvent() {
const metadata = this._session.draft().metadataForPluginId(PLUGIN_ID);
_hasPendingEvent(props) {
const metadata = props.draft.metadataForPluginId(PLUGIN_ID);
return metadata && metadata.pendingEvent
}
@ -90,14 +65,14 @@ export default class SchedulerComposerButton extends React.Component {
}
_onSelectItem = (item) => {
NewEventHelper.addEventToSession(this._session)
const draft = this._session.draft()
NewEventHelper.addEventToSession(this.props.session)
if (item === PROPOSAL) {
NylasEnv.newWindow({
title: "Calendar",
windowType: "calendar",
windowProps: {
draftClientId: draft.clientId,
draftClientId: this.props.draft.clientId,
},
});
}
@ -105,10 +80,8 @@ export default class SchedulerComposerButton extends React.Component {
}
_onClick = () => {
if (!this._session) { return }
const draft = this._session.draft()
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
NylasAPI.authPlugin(PLUGIN_ID, PLUGIN_NAME, draft.accountId)
NylasAPI.authPlugin(PLUGIN_ID, PLUGIN_NAME, this.props.draft.accountId)
.catch((error) => {
let title = "Error"
let msg = `Unfortunately scheduling is not currently available. \
@ -135,8 +108,9 @@ Please try again later.\n\nError: ${error}`
}
render() {
const hasEvent = this._hasPendingEvent(this.props);
return (
<button className={`btn btn-toolbar ${this.state.enabled ? "btn-enabled" : ""}`}
<button className={`btn btn-toolbar ${hasEvent ? "btn-enabled" : ""}`}
onClick={this._onClick}
title="Schedule an event…"
>
@ -151,3 +125,5 @@ Please try again later.\n\nError: ${error}`
</button>)
}
}
SchedulerComposerButton.containerRequired = false;

View file

@ -3,7 +3,6 @@ import ProposedTimePicker from './calendar/proposed-time-picker'
import NewEventCardContainer from './composer/new-event-card-container'
import SchedulerComposerButton from './composer/scheduler-composer-button';
import ProposedTimeCalendarStore from './proposed-time-calendar-store'
import ProposedTimeMainWindowStore from './proposed-time-main-window-store'
import SchedulerComposerExtension from './composer/scheduler-composer-extension';
import {
@ -26,9 +25,6 @@ export function activate() {
ComponentRegistry.register(ProposedTimePicker,
{location: WorkspaceStore.Location.Center})
} else {
if (NylasEnv.isMainWindow()) {
ProposedTimeMainWindowStore.activate()
}
ComponentRegistry.register(NewEventCardContainer,
{role: 'Composer:Footer'});
@ -48,7 +44,6 @@ export function deactivate() {
ComponentRegistry.unregister(ProposedTimeEvent);
ComponentRegistry.unregister(ProposedTimePicker);
} else {
ProposedTimeMainWindowStore.deactivate()
ComponentRegistry.unregister(NewEventCardContainer);
ComponentRegistry.unregister(SchedulerComposerButton);
ExtensionRegistry.Composer.unregister(SchedulerComposerExtension);

View file

@ -1,48 +0,0 @@
import NylasStore from 'nylas-store'
import SchedulerActions from './scheduler-actions'
import {Message, Actions, DatabaseStore} from 'nylas-exports'
import {PLUGIN_ID} from './scheduler-constants'
// moment-round upon require patches `moment` with new functions.
require('moment-round')
/**
* Maintains the creation of "Proposed Times" when scheduling with people.
*
* The proposed times are displayed in various calendar views.
*
*/
class ProposedTimeMainWindowStore extends NylasStore {
activate() {
this.unsubscribers = [
SchedulerActions.confirmChoices.listen(this._onConfirmChoices),
]
}
deactivate() {
this.unsubscribers.forEach(unsub => unsub())
}
/**
* This will bundle up and attach the choices as metadata on the draft.
*
* Once we attach metadata to the draft, we need to make sure we clear
* the start and end times of the event.
*/
_onConfirmChoices = ({proposals = [], draftClientId}) => {
this._pendingSave = true;
this.trigger();
DatabaseStore.find(Message, draftClientId).then((draft) => {
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
if (proposals.length === 0) {
delete metadata.proposals
} else {
metadata.proposals = proposals;
}
Actions.setMetadata(draft, PLUGIN_ID, metadata);
})
}
}
export default new ProposedTimeMainWindowStore()

View file

@ -32,14 +32,8 @@ export const prepareDraft = function prepareDraft() {
})
spyOn(DatabaseStore, 'run').andReturn(Promise.resolve(draft));
this.session = null;
runs(() => {
DraftStore.sessionForClientId(DRAFT_CLIENT_ID).then((session) => {
this.session = session
});
})
waitsFor(() => this.session);
this.session = DraftStore._createSession(DRAFT_CLIENT_ID, draft);
advanceClock();
}
export const cleanupDraft = function cleanupDraft() {
@ -59,4 +53,3 @@ export const setupCalendars = function setupCalendars() {
return Promise.resolve(cals);
})
}

View file

@ -6,6 +6,9 @@ import NewEventCard from '../lib/composer/new-event-card'
import ReactTestUtils from 'react-addons-test-utils';
import NewEventCardContainer from '../lib/composer/new-event-card-container'
import Proposal from '../lib/proposal'
import SchedulerActions from '../lib/scheduler-actions'
import {
Calendar,
Event,
@ -23,16 +26,11 @@ const now = window.testNowMoment
describe("NewEventCard", () => {
beforeEach(() => {
this.session = null
// Will eventually fill this.session
prepareDraft.call(this)
runs(() => {
this.eventCardContainer = ReactTestUtils.renderIntoDocument(
<NewEventCardContainer draftClientId={DRAFT_CLIENT_ID} />
);
})
waitsFor(() => this.eventCardContainer._session)
this.eventCardContainer = ReactTestUtils.renderIntoDocument(
<NewEventCardContainer draft={this.session.draft()} session={this.session} />
);
});
afterEach(() => {
@ -55,27 +53,28 @@ describe("NewEventCard", () => {
})
}
const setNewTestEvent = (callback) => {
runs(() => {
if (!this.session) {
throw new Error("Setup test session first")
}
const metadata = {}
metadata.uid = DRAFT_CLIENT_ID;
metadata.pendingEvent = new Event({
const setNewTestEvent = () => {
if (!this.session) {
throw new Error("Setup test session first")
}
this.session.changes.addPluginMetadata(PLUGIN_ID, {
uid: DRAFT_CLIENT_ID,
pendingEvent: new Event({
calendarId: "TEST_CALENDAR_ID",
title: "",
start: now().unix(),
end: now().add(1, 'hour').unix(),
}).toJSON();
this.session.changes.addPluginMetadata(PLUGIN_ID, metadata);
})
}).toJSON(),
});
waitsFor(() => this.eventCardContainer.state.event);
runs(callback)
this.eventCardContainer = ReactTestUtils.renderIntoDocument(
<NewEventCardContainer draft={this.session.draft()} session={this.session} />
);
}
const getPendingEvent = () =>
this.session.draft().metadataForPluginId(PLUGIN_ID).pendingEvent
it("creates a new event card", () => {
const el = ReactTestUtils.findRenderedComponentWithType(this.eventCardContainer,
NewEventCardContainer);
@ -88,15 +87,14 @@ describe("NewEventCard", () => {
it("renders the event card when an event is created", () => {
stubCalendars()
setNewTestEvent(() => {
expect(this.eventCardContainer.refs.newEventCard).toBeDefined();
expect(this.eventCardContainer.refs.newEventCard instanceof NewEventCard).toBe(true);
})
setNewTestEvent()
expect(this.eventCardContainer.refs.newEventCard).toBeDefined();
expect(this.eventCardContainer.refs.newEventCard instanceof NewEventCard).toBe(true);
});
it("loads the calendars for email", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => { })
setNewTestEvent()
waitsFor(() =>
this.eventCardContainer.refs.newEventCard.state.calendars.length > 0
)
@ -108,125 +106,125 @@ describe("NewEventCard", () => {
it("removes the event and clears metadata", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => {
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass,
this.eventCardContainer);
const rmBtn = ReactDOM.findDOMNode($("remove-button")[0]);
setNewTestEvent()
// The event is there before clicking remove
expect(this.eventCardContainer.state.event).toBeDefined()
expect(this.eventCardContainer.refs.newEventCard).toBeDefined()
expect(this.session.draft().metadataForPluginId(PLUGIN_ID).pendingEvent).toBeDefined()
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass,
this.eventCardContainer);
const rmBtn = ReactDOM.findDOMNode($("remove-button")[0]);
ReactTestUtils.Simulate.click(rmBtn);
// The event is there before clicking remove
expect(this.eventCardContainer.refs.newEventCard).toBeDefined()
expect(this.session.draft().metadataForPluginId(PLUGIN_ID).pendingEvent).toBeDefined()
// The event has been removed from metadata and state
expect(this.eventCardContainer.state.event).toBe(null)
expect(this.eventCardContainer.refs.newEventCard).not.toBeDefined()
expect(this.session.draft().metadataForPluginId(PLUGIN_ID).pendingEvent).not.toBeDefined()
})
ReactTestUtils.Simulate.click(rmBtn);
// The event has been removed from metadata
expect(this.session.draft().metadataForPluginId(PLUGIN_ID).pendingEvent).not.toBeDefined()
});
const getPendingEvent = () =>
this.session.draft().metadataForPluginId(PLUGIN_ID).pendingEvent
it("properly updates the event", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => {
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass,
this.eventCardContainer);
const title = ReactDOM.findDOMNode($("event-title")[0]);
setNewTestEvent()
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass,
this.eventCardContainer);
const title = ReactDOM.findDOMNode($("event-title")[0]);
// The event has the old title
expect(this.eventCardContainer.state.event.title).toBe("")
expect(getPendingEvent().title).toBe("")
// The event has the old title
expect(getPendingEvent().title).toBe("")
title.value = "Test"
ReactTestUtils.Simulate.change(title);
title.value = "Test"
ReactTestUtils.Simulate.change(title);
// The event has the new title
expect(this.eventCardContainer.state.event.title).toBe("Test")
expect(getPendingEvent().title).toBe("Test")
})
// The event has the new title
expect(getPendingEvent().title).toBe("Test")
});
it("updates the day", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => {
const eventCard = this.eventCardContainer.refs.newEventCard;
setNewTestEvent()
const eventCard = this.eventCardContainer.refs.newEventCard;
// The event has the default day
const nowUnix = now().unix()
expect(this.eventCardContainer.state.event.start).toBe(nowUnix)
expect(getPendingEvent()._start).toBe(nowUnix)
// The event has the default day
const nowUnix = now().unix()
expect(getPendingEvent()._start).toBe(nowUnix)
// The event has the new day
const newDay = now().add(2, 'days');
eventCard._onChangeDay(newDay.valueOf());
// The event has the new day
const newDay = now().add(2, 'days');
eventCard._onChangeDay(newDay.valueOf());
expect(this.eventCardContainer.state.event.start).toBe(newDay.unix())
expect(getPendingEvent()._start).toBe(newDay.unix())
})
expect(getPendingEvent()._start).toBe(newDay.unix())
});
it("updates the time properly", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => {
const eventCard = this.eventCardContainer.refs.newEventCard;
setNewTestEvent()
const eventCard = this.eventCardContainer.refs.newEventCard;
const oldEnd = now().add(1, 'hour').unix()
expect(this.eventCardContainer.state.event.start).toBe(now().unix())
expect(getPendingEvent()._start).toBe(now().unix())
expect(getPendingEvent()._end).toBe(oldEnd)
const oldEnd = now().add(1, 'hour').unix()
expect(getPendingEvent()._start).toBe(now().unix())
expect(getPendingEvent()._end).toBe(oldEnd)
const newStart = now().subtract(1, 'hour');
eventCard._onChangeStartTime(newStart.valueOf());
const newStart = now().subtract(1, 'hour');
eventCard._onChangeStartTime(newStart.valueOf());
expect(this.eventCardContainer.state.event.start).toBe(newStart.unix())
expect(getPendingEvent()._start).toBe(newStart.unix())
expect(this.eventCardContainer.state.event.end).toBe(oldEnd)
expect(getPendingEvent()._end).toBe(oldEnd)
})
expect(getPendingEvent()._start).toBe(newStart.unix())
expect(getPendingEvent()._end).toBe(oldEnd)
});
it("adjusts the times to prevent invalid times", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => {
const eventCard = this.eventCardContainer.refs.newEventCard;
let event = this.eventCardContainer.state.event;
setNewTestEvent()
const eventCard = this.eventCardContainer.refs.newEventCard;
const start0 = now();
const end0 = now().add(1, 'hour');
const start0 = now();
const end0 = now().add(1, 'hour');
const start1 = now().add(2, 'hours');
const expectedEnd1 = now().add(3, 'hours');
const start1 = now().add(2, 'hours');
const expectedEnd1 = now().add(3, 'hours');
const expectedStart2 = now().subtract(3, 'hours');
const end2 = now().subtract(2, 'hours');
const expectedStart2 = now().subtract(3, 'hours');
const end2 = now().subtract(2, 'hours');
// The event has the start times
expect(event.start).toBe(start0.unix())
expect(event.end).toBe(end0.unix())
expect(getPendingEvent()._start).toBe(start0.unix())
expect(getPendingEvent()._end).toBe(end0.unix())
// The event has the start times
expect(getPendingEvent()._start).toBe(start0.unix())
expect(getPendingEvent()._end).toBe(end0.unix())
eventCard._onChangeStartTime(start1.valueOf());
eventCard._onChangeStartTime(start1.valueOf());
// The event the new start time and also moved the end to match
event = this.eventCardContainer.state.event;
expect(event.start).toBe(start1.unix())
expect(event.end).toBe(expectedEnd1.unix())
expect(getPendingEvent()._start).toBe(start1.unix())
expect(getPendingEvent()._end).toBe(expectedEnd1.unix())
// The event the new start time and also moved the end to match
expect(getPendingEvent()._start).toBe(start1.unix())
expect(getPendingEvent()._end).toBe(expectedEnd1.unix())
eventCard._onChangeEndTime(end2.valueOf());
eventCard._onChangeEndTime(end2.valueOf());
// The event the new end time and also moved the start to match
event = this.eventCardContainer.state.event;
expect(event.start).toBe(expectedStart2.unix())
expect(event.end).toBe(end2.unix())
expect(getPendingEvent()._start).toBe(expectedStart2.unix())
expect(getPendingEvent()._end).toBe(end2.unix())
})
// The event the new end time and also moved the start to match
expect(getPendingEvent()._start).toBe(expectedStart2.unix())
expect(getPendingEvent()._end).toBe(end2.unix())
});
describe("Inserting proposed times", () => {
beforeEach(() => {
const draft = this.session.draft()
spyOn(DatabaseStore, "find").andReturn(Promise.resolve(draft));
const start = now().add(1, 'hour').unix();
const end = now().add(2, 'hours').unix();
this.proposals = [new Proposal({start, end})]
runs(() => {
SchedulerActions.confirmChoices({
proposals: this.proposals,
draftClientId: DRAFT_CLIENT_ID,
});
})
waitsFor(() => {
const metadata = this.session.draft().metadataForPluginId(PLUGIN_ID);
return (metadata.proposals || []).length > 0;
})
});
it("inserts proposed times on metadata", () => {
const metadata = this.session.draft().metadataForPluginId(PLUGIN_ID);
expect(JSON.stringify(metadata.proposals)).toEqual(JSON.stringify(this.proposals));
});
});
});

View file

@ -26,18 +26,11 @@ describe("SchedulerComposerButton", () => {
spyOn(NylasEnv, "reportError")
spyOn(NylasEnv, "showErrorDialog")
spyOn(NewEventHelper, "now").andReturn(now())
// Will eventually fill this.session
prepareDraft.call(this)
// Note: Needs to be in a `runs` block so it happens after the async
// activities of `prepareDraft`
runs(() => {
this.schedulerBtn = ReactTestUtils.renderIntoDocument(
<SchedulerComposerButton draftClientId={DRAFT_CLIENT_ID} />
);
})
waitsFor(() => this.schedulerBtn._session)
this.schedulerBtn = ReactTestUtils.renderIntoDocument(
<SchedulerComposerButton draft={this.session.draft()} session={this.session} />
);
});
afterEach(() => {

View file

@ -3,15 +3,13 @@ import {
prepareDraft,
setupCalendars,
cleanupDraft,
DRAFT_CLIENT_ID,
} from './composer-scheduler-spec-helper'
import NewEventHelper from '../lib/composer/new-event-helper'
import SchedulerComposerExtension from '../lib/composer/scheduler-composer-extension'
import {DatabaseStore} from 'nylas-exports';
import {Message, Event} from 'nylas-exports';
import Proposal from '../lib/proposal'
import SchedulerActions from '../lib/scheduler-actions'
const now = window.testNowMoment;
@ -57,35 +55,18 @@ describe("SchedulerComposerExtension", () => {
});
});
describe("Inserting proposed times", () => {
beforeEach(() => {
const draft = this.session.draft()
spyOn(DatabaseStore, "find").andReturn(Promise.resolve(draft));
describe("When proposals are prsent", () => {
it("inserts the proposals into the draft body", () => {
const start = now().add(1, 'hour').unix();
const end = now().add(2, 'hours').unix();
this.proposals = [new Proposal({start, end})]
runs(() => {
SchedulerActions.confirmChoices({
proposals: this.proposals,
draftClientId: DRAFT_CLIENT_ID,
});
const draft = new Message({body: ''})
draft.applyPluginMetadata(PLUGIN_ID, {
pendingEvent: new Event(),
proposals: [new Proposal({start, end})],
})
waitsFor(() => {
const metadata = this.session.draft().metadataForPluginId(PLUGIN_ID);
return (metadata.proposals || []).length > 0;
})
});
it("inserts proposed times on metadata", () => {
const metadata = this.session.draft().metadataForPluginId(PLUGIN_ID);
expect(metadata.proposals).toBe(this.proposals);
});
it("inserts the proposals into the draft body", () => {
const nextDraft = SchedulerComposerExtension.applyTransformsToDraft({
draft: this.session.draft(),
});
const nextDraft = SchedulerComposerExtension.applyTransformsToDraft({draft});
expect(nextDraft.body).not.toMatch(/new-event-preview/);
expect(nextDraft.body).toMatch(/proposed-time-table/);
expect(nextDraft.body).toMatch(/1:00 PM — 2:00 PM/);

View file

@ -1,53 +1,26 @@
import {DraftStore, React} from 'nylas-exports';
import {React} from 'nylas-exports';
class TemplateStatusBar extends React.Component {
static displayName = 'TemplateStatusBar';
static propTypes = {
draftClientId: React.PropTypes.string,
draft: React.PropTypes.object.isRequired,
};
constructor() {
super();
this.state = { draft: null };
}
componentDidMount() {
DraftStore.sessionForClientId(this.props.draftClientId).then((_proxy)=> {
if (this._unmounted) {
return;
}
if (_proxy.draftClientId === this.props.draftClientId) {
this._proxy = _proxy;
this.unsubscribe = this._proxy.listen(this._onDraftChange.bind(this), this);
this._onDraftChange();
}
});
shouldComponentUpdate(nextProps) {
return (this._usingTemplate(nextProps) !== this._usingTemplate(this.props));
}
componentWillUnmount() {
this._unmounted = true;
if (this.unsubscribe) this.unsubscribe();
}
static containerStyles = {
textAlign: 'center',
width: 580,
margin: 'auto',
};
_onDraftChange() {
this.setState({draft: this._proxy.draft()});
}
_draftUsesTemplate() {
if (this.state.draft) {
return this.state.draft.body.search(/<code[^>]*class="var[^>]*>/i) > 0;
}
_usingTemplate({draft}) {
return draft && draft.body.search(/<code[^>]*class="var[^>]*>/i) > 0;
}
render() {
if (this._draftUsesTemplate()) {
if (this._usingTemplate(this.props)) {
return (
<div className="template-status-bar">
Press "tab" to quickly move between the blanks - highlighting will not be visible to recipients.
@ -59,4 +32,10 @@ class TemplateStatusBar extends React.Component {
}
TemplateStatusBar.containerStyles = {
textAlign: 'center',
width: 580,
margin: 'auto',
};
export default TemplateStatusBar;

View file

@ -11,7 +11,6 @@ import {
ReactDOM,
ComponentRegistry,
QuotedHTMLTransformer,
DraftStore,
Actions,
} from 'nylas-exports';
@ -44,9 +43,16 @@ class TranslateButton extends React.Component {
// we receive the local id of the current draft as a `prop` (a read-only
// property). Since our code depends on this prop, we mark it as a requirement.
static propTypes = {
draftClientId: React.PropTypes.string.isRequired,
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
};
shouldComponentUpdate(nextProps) {
// Our render method doesn't use the provided `draft`, and the draft changes
// constantly (on every keystroke!) `shouldComponentUpdate` helps keep N1 fast.
return nextProps.session !== this.props.session;
}
_onError(error) {
Actions.closePopover()
const dialog = require('electron').remote.dialog;
@ -59,37 +65,35 @@ class TranslateButton extends React.Component {
// Obtain the session for the current draft. The draft session provides us
// the draft object and also manages saving changes to the local cache and
// Nilas API as multiple parts of the application touch the draft.
DraftStore.sessionForClientId(this.props.draftClientId).then((session)=> {
const draftHtml = session.draft().body;
const text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml);
const draftHtml = this.props.draft.body;
const text = QuotedHTMLTransformer.removeQuotedHTML(draftHtml);
const query = {
key: YandexTranslationKey,
lang: YandexLanguages[lang],
text: text,
format: 'html',
};
const query = {
key: YandexTranslationKey,
lang: YandexLanguages[lang],
text: text,
format: 'html',
};
// Use Node's `request` library to perform the translation using the Yandex API.
request({url: YandexTranslationURL, qs: query}, (error, resp, data)=> {
if (resp.statusCode !== 200) {
this._onError(error);
return;
}
// Use Node's `request` library to perform the translation using the Yandex API.
request({url: YandexTranslationURL, qs: query}, (error, resp, data)=> {
if (resp.statusCode !== 200) {
this._onError(error);
return;
}
const json = JSON.parse(data);
let translated = json.text.join('');
const json = JSON.parse(data);
let translated = json.text.join('');
// The new text of the draft is our translated response, plus any quoted text
// that we didn't process.
translated = QuotedHTMLTransformer.appendQuotedHTML(translated, draftHtml);
// The new text of the draft is our translated response, plus any quoted text
// that we didn't process.
translated = QuotedHTMLTransformer.appendQuotedHTML(translated, draftHtml);
// To update the draft, we add the new body to it's session. The session object
// automatically marshalls changes to the database and ensures that others accessing
// the same draft are notified of changes.
session.changes.add({body: translated});
session.changes.commit();
});
// To update the draft, we add the new body to it's session. The session object
// automatically marshalls changes to the database and ensures that others accessing
// the same draft are notified of changes.
this.props.session.changes.add({body: translated});
this.props.session.changes.commit();
});
};

View file

@ -239,7 +239,12 @@ export default class ComposerView extends React.Component {
<div className="composer-footer-region">
<InjectedComponentSet
matching={{role: "Composer:Footer"}}
exposedProps={{draftClientId: this.props.draft.clientId, threadId: this.props.draft.threadId}}
exposedProps={{
draft: this.props.draft,
threadId: this.props.draft.threadId,
draftClientId: this.props.draft.clientId,
session: this.props.session,
}}
direction="column"/>
</div>
);
@ -311,7 +316,13 @@ export default class ComposerView extends React.Component {
<InjectedComponentSet
className="composer-action-bar-plugins"
matching={{role: "Composer:ActionButton"}}
exposedProps={{draftClientId: this.props.draft.clientId, threadId: this.props.draft.threadId}} />
exposedProps={{
draft: this.props.draft,
threadId: this.props.draft.threadId,
draftClientId: this.props.draft.clientId,
session: this.props.session,
}}
/>
<button
tabIndex={-1}

View file

@ -7,9 +7,14 @@ export default class LinkTrackingButton extends React.Component {
static displayName = 'LinkTrackingButton';
static propTypes = {
draftClientId: React.PropTypes.string.isRequired,
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
};
shouldComponentUpdate(nextProps) {
return (nextProps.draft.metadataForPluginId(PLUGIN_ID) !== this.props.draft.metadataForPluginId(PLUGIN_ID));
}
_title(enabled) {
const dir = enabled ? "Disable" : "Enable";
return `${dir} link tracking`
@ -32,7 +37,10 @@ export default class LinkTrackingButton extends React.Component {
metadataEnabledValue={{"tracked": true}}
stickyToggle
errorMessage={this._errorMessage}
draftClientId={this.props.draftClientId} />
draft={this.props.draft}
session={this.props.session} />
)
}
}
LinkTrackingButton.containerRequired = false;

View file

@ -1,6 +1,6 @@
import LinkTrackingComposerExtension from '../lib/link-tracking-composer-extension'
import {PLUGIN_ID, PLUGIN_URL} from '../lib/link-tracking-constants';
import {Message, QuotedHTMLTransformer} from 'nylas-exports';
import {Message, QuotedHTMLTransformer, Actions} from 'nylas-exports';
const testContent = `TEST_BODY<br>
<a href="www.replaced.com">test</a>
@ -44,21 +44,26 @@ describe("Link tracking composer extension", () => {
});
it("replaces links in the unquoted portion of the body", () => {
spyOn(Actions, 'setMetadata')
const out = LinkTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
const outUnquoted = QuotedHTMLTransformer.removeQuotedHTML(out.body);
expect(outUnquoted).toContain(replacedBody(this.draft.accountId, this.metadata.uid, true));
expect(out.body).toContain(replacedBody(this.draft.accountId, this.metadata.uid, false));
const metadata = Actions.setMetadata.mostRecentCall.args[2];
expect(outUnquoted).toContain(replacedBody(this.draft.accountId, metadata.uid, true));
expect(out.body).toContain(replacedBody(this.draft.accountId, metadata.uid, false));
});
it("sets a uid and list of links on the metadata", () => {
spyOn(Actions, 'setMetadata')
LinkTrackingComposerExtension.applyTransformsToDraft({draft: this.draft});
expect(this.metadata.uid).not.toBeUndefined();
expect(this.metadata.links).not.toBeUndefined();
expect(this.metadata.links.length).toEqual(2);
const metadata = Actions.setMetadata.mostRecentCall.args[2];
expect(metadata.uid).not.toBeUndefined();
expect(metadata.links).not.toBeUndefined();
expect(metadata.links.length).toEqual(2);
for (const link of this.metadata.links) {
for (const link of metadata.links) {
expect(link.click_count).toEqual(0);
}
});

View file

@ -7,9 +7,14 @@ export default class OpenTrackingButton extends React.Component {
static displayName = 'OpenTrackingButton';
static propTypes = {
draftClientId: React.PropTypes.string.isRequired,
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
};
shouldComponentUpdate(nextProps) {
return (nextProps.draft.metadataForPluginId(PLUGIN_ID) !== this.props.draft.metadataForPluginId(PLUGIN_ID));
}
_title(enabled) {
const dir = enabled ? "Disable" : "Enable";
return `${dir} read receipts`
@ -24,7 +29,7 @@ export default class OpenTrackingButton extends React.Component {
render() {
const enabledValue = {
uid: this.props.draftClientId,
uid: this.props.draft.clientId,
open_count: 0,
open_data: [],
};
@ -38,7 +43,10 @@ export default class OpenTrackingButton extends React.Component {
metadataEnabledValue={enabledValue}
stickyToggle
errorMessage={this._errorMessage}
draftClientId={this.props.draftClientId} />
draft={this.props.draft}
session={this.props.session} />
)
}
}
OpenTrackingButton.containerRequired = false;

View file

@ -1,52 +1,65 @@
/** @babel */
import Rx from 'rx-lite'
import React, {Component, PropTypes} from 'react'
import ReactDOM from 'react-dom'
import {Actions, DateUtils, Message, DatabaseStore} from 'nylas-exports'
import {Actions, DateUtils} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import SendLaterPopover from './send-later-popover'
import SendLaterActions from './send-later-actions'
import {PLUGIN_ID} from './send-later-constants'
class SendLaterButton extends Component {
export default class SendLaterButton extends Component {
static displayName = 'SendLaterButton';
static propTypes = {
draftClientId: PropTypes.string.isRequired,
draft: PropTypes.object.isRequired,
};
constructor() {
super()
super();
this.state = {
scheduledDate: null,
saving: false,
};
}
componentWillReceiveProps(nextProps) {
const isComposer = NylasEnv.isComposerWindow();
const next = this._sendLaterDateForDraft(nextProps.draft);
const isFinishedSelecting = ((this.state.saving) && (next !== null));
if (isFinishedSelecting) {
this.setState({saving: false});
if (isComposer) {
NylasEnv.close();
}
}
}
componentDidMount() {
this._subscription = Rx.Observable.fromQuery(
DatabaseStore.findBy(Message, {clientId: this.props.draftClientId})
).subscribe(this.onMessageChanged);
}
componentWillUnmount() {
this._subscription.dispose();
shouldComponentUpdate(nextProps, nextState) {
if (nextState !== this.state) {
return true;
}
if (this._sendLaterDateForDraft(nextProps.draft) !== this._sendLaterDateForDraft(this.props.draft)) {
return true;
}
return false;
}
onSendLater = (formattedDate, dateLabel) => {
SendLaterActions.sendLater(this.props.draftClientId, formattedDate, dateLabel);
this.setState({scheduledDate: 'saving'});
SendLaterActions.sendLater(this.props.draft.clientId, formattedDate, dateLabel);
this.setState({saving: true});
};
onCancelSendLater = () => {
SendLaterActions.cancelSendLater(this.props.draftClientId);
SendLaterActions.cancelSendLater(this.props.draft.clientId);
Actions.closePopover();
};
onClick = () => {
const buttonRect = ReactDOM.findDOMNode(this).getBoundingClientRect()
Actions.openPopover(
<SendLaterPopover
scheduledDate={this.state.scheduledDate}
sendLaterDate={this._sendLaterDateForDraft(this.props.draft)}
onSendLater={this.onSendLater}
onCancelSendLater={this.onCancelSendLater}
/>,
@ -54,29 +67,20 @@ class SendLaterButton extends Component {
)
};
onMessageChanged = (message) => {
if (!message) return;
const {scheduledDate} = this.state;
const messageMetadata = message.metadataForPluginId(PLUGIN_ID) || {}
const nextScheduledDate = messageMetadata.sendLaterDate
if (nextScheduledDate !== scheduledDate) {
const isComposer = NylasEnv.isComposerWindow()
const isFinishedSelecting = ((scheduledDate === 'saving') && (nextScheduledDate !== null));
if (isComposer && isFinishedSelecting) {
NylasEnv.close();
}
this.setState({scheduledDate: nextScheduledDate});
_sendLaterDateForDraft(draft) {
if (!draft) {
return null;
}
};
const messageMetadata = draft.metadataForPluginId(PLUGIN_ID) || {};
return messageMetadata.sendLaterDate;
}
render() {
const {scheduledDate} = this.state;
let className = 'btn btn-toolbar btn-send-later';
if (scheduledDate === 'saving') {
if (this.state.saving) {
return (
<button className={className} title="Saving send date..." tabIndex={-1}>
<button className={className} title="Saving send date..." tabIndex={-1} style={{order: -99}}>
<RetinaImg
name="inline-loading-spinner.gif"
mode={RetinaImg.Mode.ContentDark}
@ -86,18 +90,20 @@ class SendLaterButton extends Component {
);
}
let dateInterpretation;
if (scheduledDate) {
let sendLaterLabel = false;
const sendLaterDate = this._sendLaterDateForDraft(this.props.draft);
if (sendLaterDate) {
className += ' btn-enabled';
const momentDate = DateUtils.futureDateFromString(scheduledDate);
const momentDate = DateUtils.futureDateFromString(sendLaterDate);
if (momentDate) {
dateInterpretation = <span className="at">Sending in {momentDate.fromNow(true)}</span>;
sendLaterLabel = <span className="at">Sending in {momentDate.fromNow(true)}</span>;
}
}
return (
<button className={className} title="Send later…" onClick={this.onClick} tabIndex={-1}>
<button className={className} title="Send later…" onClick={this.onClick} tabIndex={-1} style={{order: -99}}>
<RetinaImg name="icon-composer-sendlater.png" mode={RetinaImg.Mode.ContentIsMask} />
{dateInterpretation}
{sendLaterLabel}
<span>&nbsp;</span>
<RetinaImg name="icon-composer-dropdown.png" mode={RetinaImg.Mode.ContentIsMask} />
</button>
@ -105,8 +111,4 @@ class SendLaterButton extends Component {
}
}
SendLaterButton.containerStyles = {
order: -99,
};
export default SendLaterButton;
SendLaterButton.containerRequired = false

View file

@ -20,7 +20,7 @@ class SendLaterPopover extends Component {
static displayName = 'SendLaterPopover';
static propTypes = {
scheduledDate: PropTypes.string,
sendLaterDate: PropTypes.string,
onSendLater: PropTypes.func.isRequired,
onCancelSendLater: PropTypes.func.isRequired,
};
@ -71,7 +71,7 @@ class SendLaterPopover extends Component {
onSubmitDate={this.onSelectCustomOption} />,
];
if (this.props.scheduledDate) {
if (this.props.sendLaterDate) {
footerComponents.push(<div key="divider-unschedule" className="divider" />)
footerComponents.push(
<div className="cancel-section" key="cancel-section">

View file

@ -1,98 +1,66 @@
import React from 'react';
import {findDOMNode} from 'react-dom';
import ReactDOM from 'react-dom';
import {findRenderedDOMComponentWithClass} from 'react-addons-test-utils';
import {Rx, DatabaseStore, DateUtils} from 'nylas-exports'
import {DateUtils} from 'nylas-exports'
import SendLaterButton from '../lib/send-later-button';
import SendLaterActions from '../lib/send-later-actions';
import {renderIntoDocument} from '../../../spec/nylas-test-utils'
const makeButton = (props = {})=> {
const button = renderIntoDocument(<SendLaterButton {...props} draftClientId="1" />);
if (props.initialState) {
button.setState(props.initialState)
const node = document.createElement('div');
const makeButton = (initialState, metadataValue)=> {
const message = {
metadataForPluginId: ()=> metadataValue,
}
const button = ReactDOM.render(<SendLaterButton draft={message} />, node);
if (initialState) {
button.setState(initialState)
}
return button
};
describe('SendLaterButton', ()=> {
beforeEach(()=> {
spyOn(DatabaseStore, 'findBy')
spyOn(Rx.Observable, 'fromQuery').andReturn(Rx.Observable.empty())
spyOn(DateUtils, 'format').andReturn('formatted')
spyOn(SendLaterActions, 'sendLater')
});
describe('onMessageChanged', ()=> {
it('sets scheduled date correctly', ()=> {
const button = makeButton({initialState: {scheduledDate: 'old'}})
const message = {
metadataForPluginId: ()=> ({sendLaterDate: 'date'}),
}
spyOn(button, 'setState')
spyOn(NylasEnv, 'isComposerWindow').andReturn(false)
button.onMessageChanged(message)
expect(button.setState).toHaveBeenCalledWith({scheduledDate: 'date'})
});
describe('componentWillReceiveProps', ()=> {
it('closes window if window is composer window and saving has finished', ()=> {
const button = makeButton({initialState: {scheduledDate: 'saving'}})
const message = {
metadataForPluginId: ()=> ({sendLaterDate: 'date'}),
}
spyOn(button, 'setState')
makeButton({saving: true}, null)
spyOn(NylasEnv, 'close')
spyOn(NylasEnv, 'isComposerWindow').andReturn(true)
button.onMessageChanged(message)
expect(button.setState).toHaveBeenCalledWith({scheduledDate: 'date'})
makeButton(null, {sendLaterDate: 'date'})
expect(NylasEnv.close).toHaveBeenCalled()
});
it('does nothing if new date is the same as current date', ()=> {
const button = makeButton({initialState: {scheduledDate: 'date'}})
const message = {
metadataForPluginId: ()=> ({sendLaterDate: 'date'}),
}
spyOn(button, 'setState')
button.onMessageChanged(message)
expect(button.setState).not.toHaveBeenCalled()
});
});
describe('onSendLater', ()=> {
it('sets scheduled date to "saving" and dispatches action', ()=> {
const button = makeButton()
const button = makeButton(null, {sendLaterDate: 'date'})
spyOn(button, 'setState')
button.onSendLater({utc: ()=> 'utc'})
expect(SendLaterActions.sendLater).toHaveBeenCalled()
expect(button.setState).toHaveBeenCalledWith({scheduledDate: 'saving'})
expect(button.setState).toHaveBeenCalledWith({saving: true})
});
});
describe('render', ()=> {
it('renders spinner if saving', ()=> {
const button = findDOMNode(
makeButton({initialState: {scheduledDate: 'saving'}})
)
const button = ReactDOM.findDOMNode(makeButton({saving: true}, null))
expect(button.title).toEqual('Saving send date...')
});
it('renders date if message is scheduled', ()=> {
spyOn(DateUtils, 'futureDateFromString').andReturn({fromNow: ()=> '5 minutes'})
const button = makeButton({initialState: {scheduledDate: 'date'}})
const span = findDOMNode(findRenderedDOMComponentWithClass(button, 'at'))
const button = makeButton(null, {sendLaterDate: 'date'})
const span = ReactDOM.findDOMNode(findRenderedDOMComponentWithClass(button, 'at'))
expect(span.textContent).toEqual('Sending in 5 minutes')
});
it('does not render date if message is not scheduled', ()=> {
const button = makeButton({initialState: {scheduledDate: null}})
const button = makeButton(null, null)
expect(()=> {
findRenderedDOMComponentWithClass(button, 'at')
}).toThrow()

View file

@ -9,7 +9,7 @@ import {renderIntoDocument} from '../../../spec/nylas-test-utils'
const makePopover = (props = {})=> {
return renderIntoDocument(
<SendLaterPopover
scheduledDate={null}
sendLaterDate={null}
onSendLater={()=>{}}
onCancelSendLater={()=>{}}
{...props} />
@ -50,7 +50,7 @@ describe('SendLaterPopover', ()=> {
describe('render', ()=> {
it('renders cancel button if scheduled', ()=> {
const onCancelSendLater = jasmine.createSpy('onCancelSendLater')
const popover = makePopover({onCancelSendLater, scheduledDate: 'date'})
const popover = makePopover({onCancelSendLater, sendLaterDate: 'date'})
const button = findDOMNode(
findRenderedDOMComponentWithClass(popover, 'btn-cancel')
)

View file

@ -118,7 +118,7 @@ describe "DraftStoreProxy", ->
it "prepare should resolve without querying for the draft", ->
waitsForPromise => @proxy.prepare().then =>
expect(@proxy.draft()).toEqual(@draft)
expect(@proxy.draft()).toBeDefined()
expect(DatabaseStore.run).not.toHaveBeenCalled()
describe "teardown", ->

View file

@ -1,6 +1,7 @@
import {DraftStore, React, Actions, NylasAPI, APIError, DatabaseStore, Message, Rx} from 'nylas-exports'
import {React, Actions, NylasAPI, APIError} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit'
import classnames from 'classnames'
import _ from 'underscore'
export default class MetadataComposerToggleButton extends React.Component {
@ -15,7 +16,9 @@ export default class MetadataComposerToggleButton extends React.Component {
metadataEnabledValue: React.PropTypes.object.isRequired,
stickyToggle: React.PropTypes.bool,
errorMessage: React.PropTypes.func.isRequired,
draftClientId: React.PropTypes.string.isRequired,
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
};
static defaultProps = {
@ -24,99 +27,85 @@ export default class MetadataComposerToggleButton extends React.Component {
constructor(props) {
super(props);
this.state = {
enabled: false,
isSetup: false,
pending: false,
};
}
componentDidMount() {
this._mounted = true;
const query = DatabaseStore.findBy(Message, {clientId: this.props.draftClientId});
this._subscription = Rx.Observable.fromQuery(query).subscribe(this._onDraftChange)
}
componentWillUnmount() {
this._mounted = false
this._subscription.dispose();
componentWillMount() {
if (this._isEnabledByDefault() && !this._isEnabled()) {
this._setEnabled(true);
}
}
_configKey() {
return `plugins.${this.props.pluginId}.defaultOn`
}
_isDefaultOn() {
_isEnabled() {
const {pluginId, draft, metadataEnabledValue} = this.props;
const value = draft.metadataForPluginId(pluginId);
return _.isEqual(value, metadataEnabledValue) || _.isMatch(value, metadataEnabledValue);
}
_isEnabledByDefault() {
return NylasEnv.config.get(this._configKey())
}
_onDraftChange = (draft)=> {
if (!this._mounted || !draft) { return; }
const metadata = draft.metadataForPluginId(this.props.pluginId);
if (!metadata) {
if (!this.state.isSetup) {
if (this._isDefaultOn()) {
this._setEnabled(true)
}
this.setState({isSetup: true})
}
} else {
this.setState({enabled: true, isSetup: true});
}
};
_setEnabled(enabled) {
const metadataValue = enabled ? this.props.metadataEnabledValue : null;
this.setState({enabled, pending: true});
const {pluginId, pluginName, draft, session, metadataEnabledValue} = this.props;
return DraftStore.sessionForClientId(this.props.draftClientId).then((session)=> {
const draft = session.draft();
const metadataValue = enabled ? metadataEnabledValue : null;
this.setState({pending: true});
return NylasAPI.authPlugin(this.props.pluginId, this.props.pluginName, draft.accountId)
.then(() => {
Actions.setMetadata(draft, this.props.pluginId, metadataValue);
})
.catch((error) => {
this.setState({enabled: false});
NylasAPI.authPlugin(pluginId, pluginName, draft.accountId)
.then(() => {
session.changes.addPluginMetadata(pluginId, metadataValue);
})
.catch((error) => {
const {stickyToggle, errorMessage} = this.props;
if (this.props.stickyToggle) {
NylasEnv.config.set(this._configKey(), false)
}
if (stickyToggle) {
NylasEnv.config.set(this._configKey(), false)
}
let title = "Error"
if (!(error instanceof APIError)) {
NylasEnv.reportError(error);
} else if (error.statusCode === 400) {
NylasEnv.reportError(error);
} else if (NylasAPI.TimeoutErrorCodes.includes(error.statusCode)) {
title = "Offline"
}
let title = "Error"
if (!(error instanceof APIError)) {
NylasEnv.reportError(error);
} else if (error.statusCode === 400) {
NylasEnv.reportError(error);
} else if (NylasAPI.TimeoutErrorCodes.includes(error.statusCode)) {
title = "Offline"
}
NylasEnv.showErrorDialog({title, message: this.props.errorMessage(error)});
})
NylasEnv.showErrorDialog({title, message: errorMessage(error)});
}).finally(() => {
this.setState({pending: false})
});
}
_onClick = () => {
// Toggle.
if (this.state.pending) { return; }
const dir = this.state.enabled ? "Disabled" : "Enabled"
const enabled = this._isEnabled();
const dir = enabled ? "Disabled" : "Enabled"
Actions.recordUserEvent(`${this.props.pluginName} ${dir}`)
if (this.props.stickyToggle) {
NylasEnv.config.set(this._configKey(), !this.state.enabled)
}
this._setEnabled(!this.state.enabled)
this._setEnabled(!enabled);
};
render() {
const title = this.props.title(this.state.enabled)
const enabled = this._isEnabled();
const title = this.props.title(enabled);
const className = classnames({
"btn": true,
"btn-toolbar": true,
"btn-pending": this.state.pending,
"btn-enabled": this.state.enabled,
"btn-enabled": enabled,
});
const attrs = {}

View file

@ -76,7 +76,7 @@ export default class ModelWithMetadata extends Model {
if (!metadata) {
return null;
}
return metadata.value;
return JSON.parse(JSON.stringify(metadata.value));
}
// Private helpers

View file

@ -26,7 +26,7 @@ DraftChangeSet associated with the store proxy. The DraftChangeSet does two thin
Section: Drafts
###
class DraftChangeSet
constructor: (@_onTrigger, @_onCommit) ->
constructor: (@_onAltered, @_onCommit) ->
@_commitChain = Promise.resolve()
@_pending = {}
@_saving = {}
@ -39,10 +39,10 @@ class DraftChangeSet
clearTimeout(@_timer)
@_timer = null
add: (changes, {silent} = {}) =>
add: (changes) =>
@_pending = _.extend(@_pending, changes)
@_pending['pristine'] = false
@_onTrigger() unless silent
@_onAltered()
clearTimeout(@_timer) if @_timer
@_timer = setTimeout(@commit, 10000)
@ -101,7 +101,7 @@ class DraftStoreProxy
@_draftPristineBody = null
@_destroyed = false
@changes = new DraftChangeSet(@_changeSetTrigger, @_changeSetCommit)
@changes = new DraftChangeSet(@_changeSetAltered, @_changeSetCommit)
if draft
@_draftPromise = @_setDraft(draft)
@ -113,7 +113,7 @@ class DraftStoreProxy
draft: ->
return null if not @_draft
@changes.applyToModel(@_draft)
@_draft
@_draft.clone()
# Public: Returns the initial body of the draft when it was pristine, or null if the
# draft was never pristine in this editing session. Useful for determining if the
@ -163,13 +163,15 @@ class DraftStoreProxy
# Is this change an update to our draft?
myDrafts = _.filter(change.objects, (obj) => obj.clientId is @_draft.clientId)
if myDrafts.length > 0
@_draft = _.extend @_draft, _.last(myDrafts)
@_draft = Object.assign(new Message(), @_draft, myDrafts.pop())
@trigger()
_changeSetTrigger: =>
_changeSetAltered: =>
return if @_destroyed
if !@_draft
throw new Error("DraftChangeSet was modified before the draft was prepared.")
@changes.applyToModel(@_draft)
@trigger()
_changeSetCommit: ({noSyncback}={}) =>

View file

@ -1,4 +1,4 @@
import {DraftStore, React} from 'nylas-exports';
import {React} from 'nylas-exports';
export default class MyComposerButton extends React.Component {
@ -10,26 +10,33 @@ export default class MyComposerButton extends React.Component {
// reference to the draft, and you can look it up to perform
// actions and retrieve data.
static propTypes = {
draftClientId: React.PropTypes.string.isRequired,
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
};
shouldComponentUpdate(nextProps) {
// Our render method doesn't use the provided `draft`, and the draft changes
// constantly (on every keystroke!) `shouldComponentUpdate` helps keep N1 fast.
return nextProps.session !== this.props.session;
}
_onClick = () => {
const {session, draft} = this.props;
// To retrieve information about the draft, we fetch the current editing
// session from the draft store. We can access attributes of the draft
// and add changes to the session which will be appear immediately.
DraftStore.sessionForClientId(this.props.draftClientId).then((session) => {
const newSubject = `${session.draft().subject} - It Worked!`;
const newSubject = `${draft.subject} - It Worked!`;
const dialog = this._getDialog();
dialog.showMessageBox({
title: 'Here we go...',
detail: `Adjusting the subject line To "${newSubject}"`,
buttons: ['OK'],
type: 'info',
});
session.changes.add({subject: newSubject});
const dialog = this._getDialog();
dialog.showMessageBox({
title: 'Here we go...',
detail: `Adjusting the subject line To "${newSubject}"`,
buttons: ['OK'],
type: 'info',
});
session.changes.add({subject: newSubject});
}
_getDialog() {