tests(calendar): adding calendar and scheduler tests

Summary: Adding tests

Test Plan: Tests

Reviewers: juan, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2892
This commit is contained in:
Evan Morikawa 2016-04-19 11:54:15 -07:00
parent 36ab9d593b
commit b29d5ac75b
28 changed files with 1521 additions and 71 deletions

View file

@ -1,4 +1,5 @@
import React from 'react'
import classnames from 'classnames'
import SchedulerActions from '../scheduler-actions'
import {CALENDAR_ID} from '../scheduler-constants'
@ -30,9 +31,14 @@ export default class ProposedTimeEvent extends React.Component {
}
render() {
const className = classnames({
"rm-time": true,
proposal: this.props.event.proposalType === "proposal",
availability: this.props.event.proposalType === "availability",
});
if (this.props.event.calendarId === CALENDAR_ID) {
return (
<div className="rm-time"
<div className={className}
data-end={this.props.event.end}
data-start={this.props.event.start}
onMouseDown={this._onMouseDown}

View file

@ -67,7 +67,7 @@ export default class ProposedTimePicker extends React.Component {
<button key="clear"
style={{order: -99, marginLeft: 20}}
onClick={this._onClearProposals}
className="btn"
className="btn clear-proposed-times"
>
Clear Times
</button>
@ -86,7 +86,11 @@ export default class ProposedTimePicker extends React.Component {
const durationPicker = (
<div key="dp" className="duration-picker" style={{order: -100}}>
<label style={{paddingRight: 10}}>Event Duration:</label>
<select value={this.state.duration.join("|")} onChange={this._onChangeDuration}>
<select
className="duration-picker-select"
value={this.state.duration.join("|")}
onChange={this._onChangeDuration}
>
{optComponents}
</select>
</div>

View file

@ -94,6 +94,7 @@ export default class NewEventCardContainer extends Component {
if (this._session && this.state.event) {
card = (
<NewEventCard event={this.state.event}
ref="newEventCard"
draft={this._session.draft()}
onRemove={this._removeEvent}
onChange={this._updateEvent}

View file

@ -223,6 +223,7 @@ export default class NewEventCard extends React.Component {
{this._renderIcon("ic-eventcard-description@2x.png")}
<input type="text"
name="title"
className="event-title"
placeholder="Add an event title"
value={this.props.event.title || ""}
onChange={e => this.props.onChange({title: e.target.value}) }

View file

@ -0,0 +1,47 @@
import moment from 'moment'
import {PLUGIN_ID} from '../scheduler-constants'
import {
Event,
Calendar,
DatabaseStore,
} from 'nylas-exports'
export default class NewEventHelper {
// Extra level of indirection for testing
static now() {
return moment()
}
static addEventToSession(session) {
if (!session) { return }
const draft = session.draft()
DatabaseStore.findAll(Calendar, {accountId: draft.accountId})
.then((allCalendars) => {
if (allCalendars.length === 0) {
throw new Error(`Can't create an event. The Account \
${draft.accountId} has no calendars.`);
}
const cals = allCalendars.filter(c => !c.readOnly);
if (cals.length === 0) {
NylasEnv.showErrorDialog(`This account has no editable \
calendars. We can't create an event for you. Please make sure you have an \
editable calendar with your account provider.`);
return;
}
const start = NewEventHelper.now().ceil(30, 'minutes');
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
metadata.uid = draft.clientId;
metadata.pendingEvent = new Event({
calendarId: cals[0].id,
start: start.unix(),
end: moment(start).add(1, 'hour').unix(),
}).toJSON();
session.changes.addPluginMetadata(PLUGIN_ID, metadata);
})
}
}

View file

@ -40,7 +40,7 @@ export default class NewEventPreview extends React.Component {
paddingLeft: "40px",
}
return (
<div>
<div className="new-event-preview">
<h2 style={styles}>
{this._renderB64Img("description", {verticalAlign: "middle"})}
{this.props.event.title}

View file

@ -199,7 +199,15 @@ export default class ProposedTimeList extends React.Component {
)
}
return <table style={this._sProposalTable()}><tbody>{trs}</tbody></table>
return (
<table style={this._sProposalTable()}
className="proposed-time-table"
>
<tbody>
{trs}
</tbody>
</table>
)
}
_renderProposalTimeText(proposal) {

View file

@ -1,17 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom'
import {
Event,
Actions,
Calendar,
APIError,
NylasAPI,
DraftStore,
DatabaseStore,
} from 'nylas-exports'
import {Menu, RetinaImg} from 'nylas-component-kit'
import {PLUGIN_ID, PLUGIN_NAME} from '../scheduler-constants'
import NewEventHelper from './new-event-helper'
import moment from 'moment'
// moment-round upon require patches `moment` with new functions.
require('moment-round')
@ -70,7 +69,7 @@ export default class SchedulerComposerButton extends React.Component {
// Helper method that will render the contents of our popover.
_renderPopover() {
const headerComponents = [
<span>I'd like to:</span>,
<span key="header">I'd like to:</span>,
];
const items = [
MEETING_REQUEST,
@ -91,7 +90,7 @@ export default class SchedulerComposerButton extends React.Component {
}
_onSelectItem = (item) => {
this._onCreateEventCard();
NewEventHelper.addEventToSession(this._session)
const draft = this._session.draft()
if (item === PROPOSAL) {
NylasEnv.newWindow({
@ -131,42 +130,15 @@ Please try again later.\n\nError: ${error}`
)
}
_onCreateEventCard = () => {
if (!this._session) { return }
const draft = this._session.draft()
DatabaseStore.findAll(Calendar, {accountId: draft.accountId})
.then((allCalendars) => {
if (allCalendars.length === 0) {
throw new Error(`Can't create an event. The Account \
${draft.accountId} has no calendars.`);
}
const cals = allCalendars.filter(c => !c.readOnly);
if (cals.length === 0) {
NylasEnv.showErrorDialog(`This account has no editable \
calendars. We can't create an event for you. Please make sure you have an \
editable calendar with your account provider.`);
return;
}
const start = moment().ceil(30, 'minutes');
const metadata = draft.metadataForPluginId(PLUGIN_ID) || {};
metadata.uid = draft.clientId;
metadata.pendingEvent = new Event({
calendarId: cals[0].id,
start: start.unix(),
end: moment(start).add(1, 'hour').unix(),
}).toJSON();
Actions.setMetadata(draft, PLUGIN_ID, metadata);
})
_now() {
return moment()
}
render() {
return (
<button className={`btn btn-toolbar ${this.state.enabled ? "btn-enabled" : ""}`}
onClick={this._onClick}
title="Add an event…"
title="Schedule an event…"
>
<RetinaImg url="nylas://composer-scheduler/assets/ic-composer-scheduler@2x.png"
mode={RetinaImg.Mode.ContentIsMask}

View file

@ -1,4 +1,5 @@
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import {PLUGIN_ID} from '../scheduler-constants'
import NewEventPreview from './new-event-preview'
import ProposedTimeList from './proposed-time-list'
@ -91,7 +92,7 @@ export default class SchedulerComposerExtension extends ComposerExtension {
inEmail: true,
proposals: metadata.proposals,
});
const markup = React.renderToStaticMarkup(el);
const markup = ReactDOMServer.renderToStaticMarkup(el);
const nextBody = SchedulerComposerExtension._insertInBody(nextDraft.body, markup)
nextDraft.body = nextBody;
} else {
@ -99,7 +100,7 @@ export default class SchedulerComposerExtension extends ComposerExtension {
{
event: metadata.pendingEvent,
});
const markup = React.renderToStaticMarkup(el);
const markup = ReactDOMServer.renderToStaticMarkup(el);
const nextBody = SchedulerComposerExtension._insertInBody(nextDraft.body, markup)
nextDraft.body = nextBody;
}

View file

@ -45,10 +45,10 @@ export function serialize() {
export function deactivate() {
if (NylasEnv.getWindowType() === 'calendar') {
ProposedTimeCalendarStore.deactivate()
ProposedTimeMainWindowStore.deactivate()
ComponentRegistry.unregister(ProposedTimeEvent);
ComponentRegistry.unregister(ProposedTimePicker);
} else {
ProposedTimeMainWindowStore.deactivate()
ComponentRegistry.unregister(NewEventCardContainer);
ComponentRegistry.unregister(SchedulerComposerButton);
ExtensionRegistry.Composer.unregister(SchedulerComposerExtension);

View file

@ -56,8 +56,8 @@ class ProposedTimeCalendarStore extends NylasStore {
if (!this._dragBuffer.anchor) {
return []
}
const {start, end} = this._dragBuffer
return [new Event().fromJSON({
const {start, end} = this._dragBuffer;
const event = new Event().fromJSON({
title: "Availability Block",
calendar_id: CALENDAR_ID,
when: {
@ -65,12 +65,14 @@ class ProposedTimeCalendarStore extends NylasStore {
start_time: start,
end_time: end,
},
})];
})
event.proposalType = "availability"
return [event];
}
proposalsAsEvents() {
return _.map(this._proposals, (p) =>
new Event().fromJSON({
return _.map(this._proposals, (p) => {
const event = new Event().fromJSON({
title: "Proposed Time",
calendar_id: CALENDAR_ID,
when: {
@ -79,7 +81,9 @@ class ProposedTimeCalendarStore extends NylasStore {
end_time: p.end,
},
})
).concat(this._dragBufferAsEvent());
event.proposalType = "proposal";
return event
}).concat(this._dragBufferAsEvent());
}
_convertBufferToProposedTimes() {
@ -90,16 +94,19 @@ class ProposedTimeCalendarStore extends NylasStore {
const maxMoment = moment.unix(bounds.end);
maxMoment.ceil(30, 'minutes');
if (maxMoment.isSameOrBefore(minMoment)) { return }
if (maxMoment.isSame(minMoment)) {
maxMoment.add(30, 'minutes')
}
const overlapBoundsTest = {start: bounds.start, end: bounds.end - 1}
this._proposals = _.reject(this._proposals, (p) =>
Utils.overlapsBounds(bounds, p)
Utils.overlapsBounds(overlapBoundsTest, p)
)
const blockSize = this._duration.slice(0, 2)
blockSize[0] = blockSize[0] / 1; // moment requires a number
const isMinBlockSize = (bounds.end - bounds.start) >= moment.duration.apply(moment, blockSize).as('seconds');
while (minMoment.isSameOrBefore(maxMoment)) {
while (minMoment.isBefore(maxMoment)) {
const start = minMoment.unix();
minMoment.add(blockSize[0], blockSize[1]);
const end = minMoment.unix() - 1;

View file

@ -16,10 +16,12 @@ for (const key in SchedulerActions) {
}
}
NylasEnv.actionBridge.registerGlobalAction({
scope: "SchedulerActions",
name: "confirmChoices",
actionFn: SchedulerActions.confirmChoices,
});
if (!NylasEnv.inSpecMode()) {
NylasEnv.actionBridge.registerGlobalAction({
scope: "SchedulerActions",
name: "confirmChoices",
actionFn: SchedulerActions.confirmChoices,
});
}
export default SchedulerActions

View file

@ -1,6 +1,14 @@
import plugin from '../package.json'
export const PLUGIN_ID = plugin.appId[NylasEnv.config.get("env")];
export const PLUGIN_URL = plugin.serverUrl[NylasEnv.config.get("env")];
let pluginId = plugin.appId[NylasEnv.config.get("env")];
let pluginUrl = plugin.serverUrl[NylasEnv.config.get("env")];
if (NylasEnv.inSpecMode()) {
pluginId = "TEST_SCHEDULER_PLUGIN_ID"
pluginUrl = "https://edgehill-test.nylas.com"
}
export const PLUGIN_ID = pluginId;
export const PLUGIN_URL = pluginUrl;
export const PLUGIN_NAME = "Quick Schedule"
export const CALENDAR_ID = "QUICK SCHEDULE"

View file

@ -0,0 +1,62 @@
import {
DatabaseStore,
DraftStore,
Message,
Actions,
Calendar,
Contact,
} from 'nylas-exports'
import {activate, deactivate} from '../lib/main'
import {PLUGIN_ID} from '../lib/scheduler-constants'
export const DRAFT_CLIENT_ID = "draft-client-id"
// Must be a `function` so `this` can be overridden by caller's `apply`
export const prepareDraft = function prepareDraft() {
spyOn(NylasEnv, "isMainWindow").andReturn(true);
spyOn(NylasEnv, "getWindowType").andReturn("root");
spyOn(Actions, "setMetadata").andCallFake((draft, pluginId, metadata) => {
if (!this.session) {
throw new Error("Setup test session first")
}
this.session.changes.addPluginMetadata(PLUGIN_ID, metadata);
})
activate();
const draft = new Message({
clientId: DRAFT_CLIENT_ID,
draft: true,
body: "",
accountId: window.TEST_ACCOUNT_ID,
from: [new Contact({email: window.TEST_ACCOUNT_EMAIL})],
})
spyOn(DatabaseStore, 'run').andReturn(Promise.resolve(draft));
this.session = null;
runs(() => {
DraftStore.sessionForClientId(DRAFT_CLIENT_ID).then((session) => {
this.session = session
});
})
waitsFor(() => this.session);
}
export const cleanupDraft = function cleanupDraft() {
DraftStore._cleanupAllSessions()
deactivate()
}
export const setupCalendars = function setupCalendars() {
const aid = window.TEST_ACCOUNT_ID
spyOn(DatabaseStore, "findAll").andCallFake((klass, {accountId}) => {
expect(klass).toBe(Calendar);
expect(accountId).toBe(aid);
const cals = [
new Calendar({accountId: aid, readOnly: false, name: 'a'}),
new Calendar({accountId: aid, readOnly: true, name: 'b'}),
]
return Promise.resolve(cals);
})
}

View file

@ -0,0 +1,232 @@
import _ from 'underscore'
import React from 'react'
import ReactDOM from 'react-dom'
import {PLUGIN_ID} from '../lib/scheduler-constants'
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 {
Calendar,
Event,
DatabaseStore,
} from 'nylas-exports'
import {
DRAFT_CLIENT_ID,
prepareDraft,
cleanupDraft,
} from './composer-scheduler-spec-helper'
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)
});
afterEach(() => {
cleanupDraft()
})
const testCalendars = () => [new Calendar({
clientId: "client-1",
servierId: "server-1",
name: "Test Calendar",
})]
const stubCalendars = (calendars = []) => {
jasmine.unspy(DatabaseStore, "run")
spyOn(DatabaseStore, "run").andCallFake((query) => {
if (query.objectClass() === Calendar.name) {
return Promise.resolve(calendars)
}
return Promise.resolve()
})
}
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({
calendarId: "TEST_CALENDAR_ID",
title: "",
start: now().unix(),
end: now().add(1, 'hour').unix(),
}).toJSON();
this.session.changes.addPluginMetadata(PLUGIN_ID, metadata);
})
waitsFor(() => this.eventCardContainer.state.event);
runs(callback)
}
it("creates a new event card", () => {
const el = ReactTestUtils.findRenderedComponentWithType(this.eventCardContainer,
NewEventCardContainer);
expect(el instanceof NewEventCardContainer).toBe(true)
});
it("doesn't render if there's no event on metadata", () => {
expect(this.eventCardContainer.refs.newEventCard).not.toBeDefined();
});
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);
})
});
it("loads the calendars for email", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => { })
waitsFor(() =>
this.eventCardContainer.refs.newEventCard.state.calendars.length > 0
)
runs(() => {
const newEventCard = this.eventCardContainer.refs.newEventCard;
expect(newEventCard.state.calendars).toEqual(testCalendars());
});
});
it("removes the event and clears metadata", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => {
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass,
this.eventCardContainer);
const rmBtn = ReactDOM.findDOMNode($("remove-button")[0]);
// 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()
ReactTestUtils.Simulate.click(rmBtn);
// 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()
})
});
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]);
// The event has the old title
expect(this.eventCardContainer.state.event.title).toBe("")
expect(getPendingEvent().title).toBe("")
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")
})
});
it("updates the day", () => {
stubCalendars(testCalendars())
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 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())
})
});
it("updates the time properly", () => {
stubCalendars(testCalendars())
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 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)
})
});
it("adjusts the times to prevent invalid times", () => {
stubCalendars(testCalendars())
setNewTestEvent(() => {
const eventCard = this.eventCardContainer.refs.newEventCard;
let event = this.eventCardContainer.state.event;
const start0 = now();
const end0 = now().add(1, 'hour');
const start1 = now().add(2, 'hours');
const expectedEnd1 = now().add(3, '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())
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())
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())
})
});
});

View file

@ -0,0 +1,342 @@
import _ from 'underscore'
import React from 'react'
import ReactDOM from 'react-dom'
import ReactTestUtils from 'react-addons-test-utils'
import ProposedTimePicker from '../lib/calendar/proposed-time-picker'
import TestProposalDataSource from './test-proposal-data-source'
import WeekView from '../../../src/components/nylas-calendar/week-view'
import ProposedTimeCalendarStore from '../lib/proposed-time-calendar-store'
import {NylasCalendar} from 'nylas-component-kit'
import {activate, deactivate} from '../lib/main'
const now = window.testNowMoment
/**
* This tests the ProposedTimePicker as an integration test of the picker,
* associated calendar object, the ProposedTimeCalendarStore, and stubbed
* ProposedTimeCalendarDataSource
*
*/
describe("ProposedTimePicker", () => {
beforeEach(() => {
spyOn(NylasEnv, "getWindowType").andReturn("calendar");
spyOn(WeekView.prototype, "_now").andReturn(now());
spyOn(NylasCalendar.prototype, "_now").andReturn(now());
activate()
this.testSrc = new TestProposalDataSource()
spyOn(ProposedTimePicker.prototype, "_dataSource").andReturn(this.testSrc)
this.picker = ReactTestUtils.renderIntoDocument(
<ProposedTimePicker />
)
this.weekView = ReactTestUtils.findRenderedComponentWithType(this.picker, WeekView);
});
afterEach(() => {
deactivate()
})
it("renders a proposed time picker in week view", () => {
const picker = ReactTestUtils.findRenderedComponentWithType(this.picker, ProposedTimePicker);
const weekView = ReactTestUtils.findRenderedComponentWithType(this.picker, WeekView);
expect(picker instanceof ProposedTimePicker).toBe(true);
expect(weekView instanceof WeekView).toBe(true);
});
// NOTE: We manually fire the SchedulerActions since we've tested the
// mouse click to time conversion in the nylas-calendar
it("creates a proposal on click", () => {
this.picker._onCalendarMouseDown({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseUp({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.picker);
expect(ProposedTimeCalendarStore.proposals().length).toBe(1)
expect(ProposedTimeCalendarStore.proposalsAsEvents().length).toBe(1)
const proposals = $("proposal");
const events = $("calendar-event");
expect(events.length).toBe(1);
expect(proposals.length).toBe(1);
// It's not an availability block but a full blown proposal
expect($("availability").length).toBe(0);
});
it("creates the time picker for the correct timespan", () => {
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.picker);
const title = $("title");
expect(ReactDOM.findDOMNode(title[0]).innerHTML).toBe("March 13 - March 19 2016");
});
it("creates a block of proposals on drag down", () => {
this.picker._onCalendarMouseDown({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseMove({
time: now().add(30, 'minutes'),
mouseIsDown: true,
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseMove({
time: now().add(60, 'minutes'),
mouseIsDown: true,
currentView: NylasCalendar.WEEK_VIEW,
})
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.picker);
// Ensure that we don't see any proposals
expect(ProposedTimeCalendarStore.proposals().length).toBe(0)
let proposalEls = $("proposal");
expect(proposalEls.length).toBe(0);
// But we DO see the drag block event
expect(ProposedTimeCalendarStore.proposalsAsEvents().length).toBe(1)
let events = $("calendar-event");
expect($("availability").length).toBe(1);
expect(events.length).toBe(1);
this.picker._onCalendarMouseUp({
time: now().add(90, 'minutes'),
currentView: NylasCalendar.WEEK_VIEW,
})
// Now that we've moused up, this should convert them into proposals
const proposals = ProposedTimeCalendarStore.proposals()
expect(proposals.length).toBe(3)
expect(ProposedTimeCalendarStore.proposalsAsEvents().length).toBe(3)
proposalEls = $("proposal");
events = $("calendar-event");
expect(events.length).toBe(3);
expect(proposalEls.length).toBe(3);
const times = proposals.map((p) =>
[p.start, p.end]
);
expect(times).toEqual([
[now().unix(), now().add(30, 'minutes').unix() - 1],
[now().add(30, 'minutes').unix(),
now().add(60, 'minutes').unix() - 1],
[now().add(60, 'minutes').unix(),
now().add(90, 'minutes').unix() - 1],
]);
});
it("creates a block of proposals on drag up", () => {
this.picker._onCalendarMouseDown({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseMove({
time: now().subtract(30, 'minutes'),
mouseIsDown: true,
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseMove({
time: now().subtract(60, 'minutes'),
mouseIsDown: true,
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseUp({
time: now().subtract(90, 'minutes'),
currentView: NylasCalendar.WEEK_VIEW,
})
const proposals = ProposedTimeCalendarStore.proposals()
const times = proposals.map((p) =>
[p.start, p.end]
);
expect(times).toEqual([
[now().subtract(90, 'minutes').unix(),
now().subtract(60, 'minutes').unix() - 1],
[now().subtract(60, 'minutes').unix(),
now().subtract(30, 'minutes').unix() - 1],
[now().subtract(30, 'minutes').unix(),
now().unix() - 1],
]);
});
it("removes proposals when clicked on", () => {
// This created a proposal
this.picker._onCalendarMouseDown({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseUp({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
// See the proposal is there
expect(ProposedTimeCalendarStore.proposals().length).toBe(1)
// Now let's find and click it.
// This also tests to make sure it actually rendered
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.picker);
const removeBtn = $("rm-time proposal");
expect(removeBtn.length).toBe(1)
ReactTestUtils.Simulate.mouseDown(ReactDOM.findDOMNode(removeBtn[0]))
// Now see that it's gone!
expect(ProposedTimeCalendarStore.proposals().length).toBe(0)
// And gone from the DOM too.
expect($("proposal").length).toBe(0);
// And didn't turn into an availability block or something dumb
expect($("availability").length).toBe(0);
});
it("can clear all of the proposals", () => {
// This created a proposal
this.picker._onCalendarMouseDown({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseUp({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
// See the proposal is there
expect(ProposedTimeCalendarStore.proposals().length).toBe(1)
// Find the clear button
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.picker);
const clearBtns = $("clear-proposed-times");
expect(clearBtns.length).toBe(1);
// Click it
ReactTestUtils.Simulate.click(ReactDOM.findDOMNode(clearBtns[0]))
// Ensure no more proposals
expect(ProposedTimeCalendarStore.proposals().length).toBe(0)
// And nothing still rendered
expect($("proposal").length).toBe(0);
expect($("availability").length).toBe(0);
});
it("can change the duration", () => {
// Find the duration picker.
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.picker);
const pickerEls = $("duration-picker-select");
expect(pickerEls.length).toBe(1);
// Starts with default duration
const d30Min = ProposedTimeCalendarStore.DURATIONS[0]
expect(ProposedTimeCalendarStore._duration).toEqual(d30Min)
const pickerEl = ReactDOM.findDOMNode(pickerEls[0]);
pickerEl.value = "1.5|hours|1½ hr"
ReactTestUtils.Simulate.change(pickerEl)
const dHrHalf = ProposedTimeCalendarStore.DURATIONS[2]
dHrHalf[0] = `${dHrHalf[0]}` // convert to string
expect(ProposedTimeCalendarStore._duration).toEqual(dHrHalf)
});
it("creates a block of proposals with a longer duration", () => {
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.picker);
// Create a single proposal with the default 30 min duration.
this.picker._onCalendarMouseDown({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseUp({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
// It's 30 min long
const proposals = ProposedTimeCalendarStore.proposals()
const times = proposals.map((p) => [p.start, p.end]);
expect(times).toEqual([
[now().unix(),
now().add(30, 'minutes').unix() - 1],
]);
// Change duration to 2.5 hours
const pickerEl = ReactDOM.findDOMNode($("duration-picker-select")[0]);
pickerEl.value = "2.5|hours|2½ hr"
ReactTestUtils.Simulate.change(pickerEl)
// Click a new event
this.picker._onCalendarMouseDown({
time: now().add(2, 'hours'),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseUp({
time: now().add(2, 'hours'),
currentView: NylasCalendar.WEEK_VIEW,
})
// It should have added a 2.5 hour long event and left the original
// event alone
const p2 = ProposedTimeCalendarStore.proposals()
const t2 = p2.map((p) => [p.start, p.end]);
expect(t2).toEqual([
[now().unix(),
now().add(30, 'minutes').unix() - 1],
[now().add(2, 'hours').unix(),
now().add(4.5, 'hours').unix() - 1],
]);
});
it("overrides events so they don't overlap", () => {
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.picker);
this.picker._onCalendarMouseDown({
time: now(),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseUp({
time: now().add(1, 'hour'),
currentView: NylasCalendar.WEEK_VIEW,
})
// Creates two proposals.
const proposals = ProposedTimeCalendarStore.proposals()
const times = proposals.map((p) => [p.start, p.end]);
expect(times).toEqual([
[now().unix(),
now().add(30, 'minutes').unix() - 1],
[now().add(30, 'minutes').unix(),
now().add(60, 'minutes').unix() - 1],
]);
// Change the duration to 2 hours
const pickerEl = ReactDOM.findDOMNode($("duration-picker-select")[0]);
pickerEl.value = "2|hours|2 hr"
ReactTestUtils.Simulate.change(pickerEl)
// Click and drag overlapping the first of the original events.
this.picker._onCalendarMouseDown({
time: now().subtract(1.5, 'hours'),
currentView: NylasCalendar.WEEK_VIEW,
})
this.picker._onCalendarMouseUp({
time: now().add(20, 'minutes'),
currentView: NylasCalendar.WEEK_VIEW,
})
// See that there's only 1 new event with the correct time and it
// exhchanged it with the old one.
//
// It left the non overlapping one alone.
const p2 = ProposedTimeCalendarStore.proposals()
const t2 = p2.map((p) => [p.start, p.end]);
expect(t2).toEqual([
[now().add(30, 'minutes').unix(),
now().add(60, 'minutes').unix() - 1],
[now().subtract(1.5, 'hours').unix(),
now().add(30, 'minutes').unix() - 1],
]);
});
});

View file

@ -0,0 +1,184 @@
import React from 'react'
import ReactDOM from 'react-dom'
import ReactTestUtils from 'react-addons-test-utils'
import {DatabaseStore, Calendar, APIError, Actions, NylasAPI} from 'nylas-exports'
import {PLUGIN_ID, PLUGIN_NAME} from '../lib/scheduler-constants'
import SchedulerComposerButton from '../lib/composer/scheduler-composer-button'
import NewEventHelper from '../lib/composer/new-event-helper'
import {
prepareDraft,
cleanupDraft,
setupCalendars,
DRAFT_CLIENT_ID,
} from './composer-scheduler-spec-helper'
const now = window.testNowMoment;
describe("SchedulerComposerButton", () => {
beforeEach(() => {
this.session = null
spyOn(Actions, "openPopover").andCallThrough();
spyOn(Actions, "closePopover").andCallThrough();
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)
});
afterEach(() => {
cleanupDraft()
})
const spyAuthSuccess = () => {
spyOn(NylasAPI, "authPlugin").andCallFake((pluginId, pluginName, accountId) => {
expect(pluginId).toBe(PLUGIN_ID);
expect(pluginName).toBe(PLUGIN_NAME);
expect(accountId).toBe(window.TEST_ACCOUNT_ID);
return Promise.resolve();
})
}
it("loads the draft and renders the button", () => {
const el = ReactTestUtils.findRenderedComponentWithType(this.schedulerBtn,
SchedulerComposerButton);
expect(el instanceof SchedulerComposerButton).toBe(true)
});
const testForError = () => {
runs(() => {
ReactTestUtils.Simulate.click(ReactDOM.findDOMNode(this.schedulerBtn));
})
waitsFor(() =>
NylasEnv.showErrorDialog.calls.length > 0
);
runs(() => {
const picker = document.querySelector(".scheduler-picker")
expect(Actions.openPopover).toHaveBeenCalled();
expect(Actions.closePopover).toHaveBeenCalled();
expect(picker).toBe(null);
})
}
it("errors on 400 error and reports", () => {
const err = new APIError({statusCode: 400});
spyOn(NylasAPI, "authPlugin").andReturn(Promise.reject(err));
testForError(err);
runs(() => {
expect(NylasEnv.reportError).toHaveBeenCalledWith(err);
})
});
it("errors on unexpected errors and reports", () => {
const err = new Error("OH NO");
spyOn(NylasAPI, "authPlugin").andReturn(Promise.reject(err));
testForError(err);
runs(() => {
expect(NylasEnv.reportError).toHaveBeenCalledWith(err);
})
});
it("errors on offline, but doesn't report", () => {
const err = new APIError({statusCode: 0});
spyOn(NylasAPI, "authPlugin").andReturn(Promise.reject(err));
testForError(err);
runs(() => {
expect(NylasEnv.reportError).not.toHaveBeenCalled();
})
});
describe("auth success", () => {
beforeEach(() => {
spyAuthSuccess();
ReactTestUtils.Simulate.click(ReactDOM.findDOMNode(this.schedulerBtn));
const items = document.querySelectorAll(".scheduler-picker .item");
this.meetingRequestBtn = items[0];
this.proposalBtn = items[1];
});
it("renders the popover on click", () => {
// The popover renders outside the scope of the component.
const picker = document.querySelector(".scheduler-picker")
expect(Actions.openPopover).toHaveBeenCalled();
expect(picker).toBeDefined();
});
it("auths the plugin on click", () => {
expect(NylasAPI.authPlugin).toHaveBeenCalled()
expect(NylasAPI.authPlugin.calls.length).toBe(1)
});
it("creates a new event on the metadata", () => {
setupCalendars();
spyOn(this.session.changes, "addPluginMetadata").andCallThrough()
runs(() => {
ReactTestUtils.Simulate.mouseDown(this.meetingRequestBtn);
})
waitsFor(() =>
this.session.changes.addPluginMetadata.calls.length > 0
);
runs(() => {
expect(Actions.closePopover).toHaveBeenCalled();
const metadata = this.session.draft().metadataForPluginId(PLUGIN_ID);
expect(metadata.pendingEvent._start).toBe(now().unix())
expect(metadata.pendingEvent._end).toBe(now().add(1, 'hour').unix())
expect(NylasEnv.showErrorDialog).not.toHaveBeenCalled()
})
});
// NOTE: The backend requires a `uid` key on the metadata in order to
// properly look up the pending event. This must be present in order
// for the service to work.
it("IMPORTANT: puts the draft client ID on the `uid` key", () => {
setupCalendars();
spyOn(this.session.changes, "addPluginMetadata").andCallThrough()
runs(() => {
ReactTestUtils.Simulate.mouseDown(this.meetingRequestBtn);
})
waitsFor(() =>
this.session.changes.addPluginMetadata.calls.length > 0
);
runs(() => {
const metadata = this.session.draft().metadataForPluginId(PLUGIN_ID);
expect(metadata.uid).toBe(DRAFT_CLIENT_ID)
})
});
it("throws an error if there aren't any calendars", () => {
// Only a read-only calendar
spyOn(DatabaseStore, "findAll").andCallFake((klass, {accountId}) => {
const cals = [
new Calendar({accountId, readOnly: true, name: 'b'}),
]
return Promise.resolve(cals);
})
runs(() => {
ReactTestUtils.Simulate.mouseDown(this.meetingRequestBtn);
})
waitsFor(() =>
NylasEnv.showErrorDialog.calls.length > 0
);
runs(() => {
expect(Actions.closePopover).toHaveBeenCalled();
const metadata = this.session.draft().metadataForPluginId(PLUGIN_ID);
expect(metadata).toBe(null);
expect(NylasEnv.showErrorDialog.calls.length).toBe(1)
})
});
});
});

View file

@ -0,0 +1,148 @@
import {PLUGIN_ID} from '../lib/scheduler-constants'
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 Proposal from '../lib/proposal'
import SchedulerActions from '../lib/scheduler-actions'
const now = window.testNowMoment;
describe("SchedulerComposerExtension", () => {
beforeEach(() => {
this.session = null
// Will eventually fill this.session
prepareDraft.call(this);
setupCalendars.call(this);
spyOn(NewEventHelper, "now").andReturn(now())
// Note: Needs to be in a `runs` block so it happens after the async
// activities of `prepareDraft`
runs(() => {
NewEventHelper.addEventToSession(this.session)
})
waitsFor(() =>
this.session.draft().metadataForPluginId(PLUGIN_ID)
)
});
afterEach(() => {
cleanupDraft()
})
describe("Inserting a new event", () => {
beforeEach(() => {
this.nextDraft = SchedulerComposerExtension.applyTransformsToDraft({
draft: this.session.draft(),
});
});
it("Inserts the proposted-time-list", () => {
expect(this.nextDraft.body).toMatch(/new-event-preview/);
});
it("Has the correct start and end times in the body", () => {
expect(this.nextDraft.body).toMatch(/Tuesday, March 15, 2016 <br\/>12:00 PM 1:00 PM/);
});
it("Doesn't include proposed times", () => {
expect(this.nextDraft.body).not.toMatch(/proposed-time-table/);
});
});
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(metadata.proposals).toBe(this.proposals);
});
it("inserts the proposals into the draft body", () => {
const nextDraft = SchedulerComposerExtension.applyTransformsToDraft({
draft: this.session.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/);
});
});
// The backend will use whatever is stored in the `pendingEvent` field
// to POST to the /events API endpoint. This means the data must be
// a valid event. Verify that it meets Nylas API specs
describe("When setting the event JSON to match server requirements", () => {
beforeEach(() => {
SchedulerComposerExtension.applyTransformsToDraft({
draft: this.session.draft(),
});
const metadata = this.session.draft().metadataForPluginId(PLUGIN_ID);
this.pendingEvent = metadata.pendingEvent
});
it("doesn't have a clientId", () => {
expect(this.pendingEvent.client_id).not.toBeDefined();
expect(this.pendingEvent.clientId).not.toBeDefined();
});
it("doesn't have an id", () => {
expect(this.pendingEvent.id).not.toBeDefined();
expect(this.pendingEvent.serverId).not.toBeDefined();
expect(this.pendingEvent.server_id).not.toBeDefined();
});
it("has the correct `when` block", () => {
expect(this.pendingEvent.when).toEqual({
start_time: now().unix(),
end_time: now().add(1, 'hour').unix(),
})
expect(this.pendingEvent.when.object).not.toBeDefined();
});
it("doesn't have _start or _end blocks", () => {
expect(this.pendingEvent._start).not.toBeDefined();
expect(this.pendingEvent._end).not.toBeDefined();
});
it("has the correct participants", () => {
const from = this.session.draft().from[0]
expect(this.pendingEvent.participants.length).toBe(1);
expect(this.pendingEvent.participants[0].name).toBe(from.name);
expect(this.pendingEvent.participants[0].email).toBe(from.email);
expect(this.pendingEvent.participants[0].status).toBe("noreply");
});
it("only has appropriate keys", () => {
expect(Object.keys(this.pendingEvent)).toEqual([
"calendar_id",
"title",
"participants",
"when",
])
});
});
});

View file

@ -0,0 +1,24 @@
import ProposedTimeCalendarStore from '../lib/proposed-time-calendar-store'
import {CalendarDataSource} from 'nylas-exports'
export default class TestProposalDataSource extends CalendarDataSource {
buildObservable({startTime, endTime}) {
this.endTime = endTime
this.startTime = startTime
this._usub = ProposedTimeCalendarStore.listen(this.manuallyTrigger)
return this
}
manuallyTrigger = () => {
this.onNext({events: ProposedTimeCalendarStore.proposalsAsEvents()})
}
subscribe(onNext) {
this.onNext = onNext
this.manuallyTrigger()
const dispose = () => {
this._usub()
}
return {dispose}
}
}

View file

@ -0,0 +1,132 @@
import moment from 'moment-timezone'
import {Event} from 'nylas-exports'
import {TZ, TEST_CALENDAR} from '../test-utils'
// All day
// All day overlap
//
// Simple single event
// Event that spans a day
// Overlapping events
let gen = 0
const genEvent = ({start, end, object = "timespan"}) => {
gen += 1;
let when = {}
if (object === "timespan") {
when = {
object: "timespan",
end_time: moment.tz(end, TZ).unix(),
start_time: moment.tz(start, TZ).unix(),
}
}
if (object === "datespan") {
when = {
object: "datespan",
end_date: end,
start_date: start,
}
}
return new Event().fromJSON({
id: `server-${gen}`,
calendar_id: TEST_CALENDAR,
account_id: window.TEST_ACCOUNT_ID,
description: `description ${gen}`,
location: `location ${gen}`,
owner: `${window._TEST_ACCOUNT_NAME} <${window.TEST_ACCOUNT_EMAIL}>`,
participants: [{
email: window.TEST_ACCOUNT_EMAIL,
name: window.TEST_ACCOUNT_NAME,
status: "yes",
}],
read_only: "false",
title: `Title ${gen}`,
busy: true,
when,
status: "confirmed",
})
}
// NOTE:
// DST Started 2016-03-13 01:59 and immediately jumps to 03:00.
// DST Ended 2016-11-06 01:59 and immediately jumps to 01:00 again!
//
// See: http://momentjs.com/timezone/docs/#/using-timezones/parsing-ambiguous-inputs/
// All times are in "America/Los_Angeles"
export const numAllDayEvents = 6
export const numStandardEvents = 9
export const numByDay = {
1457769600: 2,
1457856000: 7,
}
export const eventOverlapForSunday = {
"server-2": {
concurrentEvents: 2,
order: 1,
},
"server-3": {
concurrentEvents: 2,
order: 2,
},
"server-6": {
concurrentEvents: 1,
order: 1,
},
"server-7": {
concurrentEvents: 1,
order: 1,
},
"server-8": {
concurrentEvents: 2,
order: 1,
},
"server-9": {
concurrentEvents: 2,
order: 2,
},
"server-10": {
concurrentEvents: 2,
order: 1,
},
}
export const events = [
// Single event
genEvent({start: "2016-03-12 12:00", end: "2016-03-12 13:00"}),
// DST start spanning event. 6 hours when it should be 7!
genEvent({start: "2016-03-12 23:00", end: "2016-03-13 06:00"}),
// DST start invalid event. Does not exist!
genEvent({start: "2016-03-13 02:15", end: "2016-03-13 02:45"}),
// DST end spanning event. 8 hours when it shoudl be 7!
genEvent({start: "2016-11-05 23:00", end: "2016-11-06 06:00"}),
// DST end ambiguous event. This timespan happens twice!
genEvent({start: "2016-11-06 01:15", end: "2016-11-06 01:45"}),
// Adjacent events
genEvent({start: "2016-03-13 12:00", end: "2016-03-13 13:00"}),
genEvent({start: "2016-03-13 13:00", end: "2016-03-13 14:00"}),
// Overlapping events
genEvent({start: "2016-03-13 14:30", end: "2016-03-13 15:30"}),
genEvent({start: "2016-03-13 15:00", end: "2016-03-13 16:00"}),
genEvent({start: "2016-03-13 15:30", end: "2016-03-13 16:30"}),
// All day timespan event
genEvent({start: "2016-03-15 00:00", end: "2016-03-16 00:00"}),
// All day datespan
genEvent({start: "2016-03-17", end: "2016-03-18", object: "datespan"}),
// Overlapping all day
genEvent({start: "2016-03-19", end: "2016-03-20", object: "datespan"}),
genEvent({start: "2016-03-19 00:00", end: "2016-03-20 00:00"}),
genEvent({start: "2016-03-19 12:00", end: "2016-03-20 12:00"}),
genEvent({start: "2016-03-20 00:00", end: "2016-03-21 00:00"}),
]

View file

@ -0,0 +1,17 @@
// import Rx from 'rx-lite-testing'
import {events} from './fixtures/events'
import {CalendarDataSource} from 'nylas-exports'
export default class TestDataSource extends CalendarDataSource {
buildObservable({startTime, endTime}) {
this.endTime = endTime;
this.startTime = startTime;
return this
}
subscribe(onNext) {
onNext({events})
this.unsubscribe = jasmine.createSpy("unusbscribe");
return {dispose: this.unsubscribe}
}
}

View file

@ -0,0 +1,14 @@
import moment from 'moment-timezone'
export const TZ = window.TEST_TIME_ZONE;
export const TEST_CALENDAR = "TEST_CALENDAR";
export const now = () => window.testNowMoment();
export const NOW_WEEK_START = moment.tz("2016-03-13 00:00", TZ);
export const NOW_BUFFER_START = moment.tz("2016-03-06 00:00", TZ);
export const NOW_BUFFER_END = moment.tz("2016-03-26 23:59:59", TZ);
// Makes test failure output easier to read.
export const u2h = (unixTime) => moment.unix(unixTime).format("LLL z")
export const m2h = (m) => m.format("LLL z")

View file

@ -0,0 +1,5 @@
import {events} from './fixtures/events'
import {NylasCalendar} from 'nylas-component-kit'
describe("Extended Nylas Calendar Week View", () => {
});

View file

@ -0,0 +1,188 @@
import _ from 'underscore'
import moment from 'moment'
import React from 'react'
import ReactTestUtils from 'react-addons-test-utils'
import {
now,
NOW_WEEK_START,
NOW_BUFFER_START,
NOW_BUFFER_END,
} from './test-utils'
import TestDataSource from './test-data-source'
import {NylasCalendar} from 'nylas-component-kit'
import {
numByDay,
numAllDayEvents,
numStandardEvents,
eventOverlapForSunday,
} from './fixtures/events'
import WeekView from '../../../src/components/nylas-calendar/week-view'
describe("Nylas Calendar Week View", () => {
beforeEach(() => {
spyOn(WeekView.prototype, "_now").andReturn(now());
this.onCalendarMouseDown = jasmine.createSpy("onCalendarMouseDown")
this.dataSource = new TestDataSource();
this.calendar = ReactTestUtils.renderIntoDocument(
<NylasCalendar
currentMoment={now()}
onCalendarMouseDown={this.onCalendarMouseDown}
dataSource={this.dataSource}
/>
);
this.weekView = ReactTestUtils.findRenderedComponentWithType(this.calendar, WeekView);
});
it("renders a calendar", () => {
const cal = ReactTestUtils.findRenderedComponentWithType(this.calendar, NylasCalendar)
expect(cal instanceof NylasCalendar).toBe(true)
});
it("sets the correct moment", () => {
expect(this.calendar.state.currentMoment.valueOf()).toBe(now().valueOf())
});
it("defaulted to WeekView", () => {
expect(this.calendar.state.currentView).toBe("week");
expect(this.weekView instanceof WeekView).toBe(true);
});
it("initializes the component", () => {
expect(this.weekView.todayYear).toBe(now().year());
expect(this.weekView.todayDayOfYear).toBe(now().dayOfYear());
});
it("initializes the data source & state with the correct times", () => {
expect(this.dataSource.startTime).toBe(NOW_BUFFER_START.unix());
expect(this.dataSource.endTime).toBe(NOW_BUFFER_END.unix());
expect(this.weekView.state.startMoment.unix()).toBe(NOW_BUFFER_START.unix());
expect(this.weekView.state.endMoment.unix()).toBe(NOW_BUFFER_END.unix());
expect(this.weekView._scrollTime).toBe(NOW_WEEK_START.unix())
});
it("has the correct days in buffer", () => {
const days = this.weekView._daysInView();
expect(days.length).toBe(21);
expect(days[0].dayOfYear()).toBe(66)
expect(days[days.length - 1].dayOfYear()).toBe(86)
});
it("shows the correct current week", () => {
expect(this.weekView._currentWeekText()).toBe("March 13 - March 19 2016")
});
it("goes to next week on click", () => {
const nextBtn = this.weekView.refs.headerControls.refs.onNextAction
expect(this.weekView.state.startMoment.unix()).toBe(NOW_BUFFER_START.unix());
expect(this.weekView._scrollTime).toBe(NOW_WEEK_START.unix())
ReactTestUtils.Simulate.click(nextBtn);
expect((this.weekView.state.startMoment).unix())
.toBe(moment(NOW_BUFFER_START).add(1, 'week').unix());
expect(this.weekView._scrollTime)
.toBe(moment(NOW_WEEK_START).add(1, 'week').unix());
});
it("goes to the previous week on click", () => {
const prevBtn = this.weekView.refs.headerControls.refs.onPreviousAction
expect(this.weekView.state.startMoment.unix()).toBe(NOW_BUFFER_START.unix());
expect(this.weekView._scrollTime).toBe(NOW_WEEK_START.unix())
ReactTestUtils.Simulate.click(prevBtn);
expect((this.weekView.state.startMoment).unix())
.toBe(moment(NOW_BUFFER_START).subtract(1, 'week').unix());
expect(this.weekView._scrollTime)
.toBe(moment(NOW_WEEK_START).subtract(1, 'week').unix());
});
it("goes to 'today' when the 'today' btn is pressed", () => {
const todayBtn = this.weekView.refs.todayBtn;
const nextBtn = this.weekView.refs.headerControls.refs.onNextAction
ReactTestUtils.Simulate.click(nextBtn);
ReactTestUtils.Simulate.click(todayBtn)
expect(this.weekView.state.startMoment.unix()).toBe(NOW_BUFFER_START.unix());
expect(this.weekView._scrollTime).toBe(NOW_WEEK_START.unix())
});
it("sets the interval height properly", () => {
expect(this.weekView.state.intervalHeight).toBe(21)
});
it("properly segments the events by day", () => {
const days = this.weekView._daysInView();
const eventsByDay = this.weekView._eventsByDay(days);
// See fixtures/events
expect(eventsByDay.allDay.length).toBe(numAllDayEvents);
for (const day in numByDay) {
if (numByDay.hasOwnProperty(day)) {
expect(eventsByDay[day].length).toBe(numByDay[day])
}
}
});
it("correctly stacks all day events", () => {
const height = this.weekView.refs.weekViewAllDayEvents.props.height;
// This means it's 3-high
expect(height).toBe(64);
});
it("correctly sets up the event overlap for a day", () => {
const days = this.weekView._daysInView();
const eventsByDay = this.weekView._eventsByDay(days);
const eventOverlap = this.weekView._eventOverlap(eventsByDay['1457856000']);
expect(eventOverlap).toEqual(eventOverlapForSunday)
});
it("renders the events onto the grid", () => {
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.weekView);
const events = $("calendar-event");
const standardEvents = $("calendar-event vertical");
const allDayEvents = $("calendar-event horizontal");
expect(events.length).toBe(numStandardEvents + numAllDayEvents)
expect(standardEvents.length).toBe(numStandardEvents)
expect(allDayEvents.length).toBe(numAllDayEvents)
});
it("finds the correct data from mouse events", () => {
const $ = _.partial(ReactTestUtils.scryRenderedDOMComponentsWithClass, this.weekView);
const eventContainer = this.weekView.refs.calendarEventContainer;
// Unfortunately, _dataFromMouseEvent requires the component to both
// be mounted and have size. To truly test this we'd have to load the
// integratino test environment. For now, we test that the event makes
// its way back to passed in callback handlers
const mouseData = {
x: 100,
y: 100,
width: 100,
height: 100,
time: now(),
}
spyOn(eventContainer, "_dataFromMouseEvent").andReturn(mouseData)
const eventEl = $("calendar-event vertical")[0];
ReactTestUtils.Simulate.mouseDown(eventEl, {x: 100, y: 100});
const mouseEvent = eventContainer._dataFromMouseEvent.calls[0].args[0];
expect(mouseEvent.x).toBe(100)
expect(mouseEvent.y).toBe(100)
const mouseDataOut = this.onCalendarMouseDown.calls[0].args[0]
expect(mouseDataOut.x).toEqual(mouseData.x)
expect(mouseDataOut.y).toEqual(mouseData.y)
expect(mouseDataOut.width).toEqual(mouseData.width)
expect(mouseDataOut.height).toEqual(mouseData.height)
expect(mouseDataOut.time.unix()).toEqual(mouseData.time.unix())
});
});

View file

@ -114,6 +114,12 @@ window.TEST_ACCOUNT_NAME = "Nylas Test"
window.TEST_PLUGIN_ID = "test-plugin-id-123"
window.TEST_ACCOUNT_ALIAS_EMAIL = "tester+alternative@nylas.com"
window.TEST_TIME_ZONE = "America/Los_Angeles"
moment = require('moment-timezone')
# This date was chosen because it's close to a DST boundary
window.testNowMoment = ->
moment.tz("2016-03-15 12:00", TEST_TIME_ZONE)
beforeEach ->
NylasEnv.testOrganizationUnit = null
Grim.clearDeprecations() if isCoreSpec

View file

@ -24,7 +24,10 @@ export default class HeaderControls extends React.Component {
_renderNextAction() {
if (!this.props.nextAction) { return false; }
return (
<button className="btn btn-icon next" onClick={this.props.nextAction}>
<button className="btn btn-icon next"
ref="onNextAction"
onClick={this.props.nextAction}
>
<RetinaImg name="ic-calendar-right-arrow.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
@ -35,7 +38,10 @@ export default class HeaderControls extends React.Component {
_renderPrevAction() {
if (!this.props.prevAction) { return false; }
return (
<button className="btn btn-icon prev" onClick={this.props.prevAction}>
<button className="btn btn-icon prev"
ref="onPreviousAction"
onClick={this.props.prevAction}
>
<RetinaImg name="ic-calendar-left-arrow.png"
mode={RetinaImg.Mode.ContentIsMask}
/>

View file

@ -17,6 +17,8 @@ export default class NylasCalendar extends React.Component {
*/
dataSource: React.PropTypes.instanceOf(CalendarDataSource).isRequired,
currentMoment: React.PropTypes.instanceOf(moment),
/**
* Any extra info you want to display on the top banner of calendar
* components
@ -72,7 +74,7 @@ export default class NylasCalendar extends React.Component {
super(props);
this.state = {
currentView: WEEK_VIEW,
currentMoment: moment(),
currentMoment: props.currentMoment || this._now(),
};
}
@ -80,6 +82,10 @@ export default class NylasCalendar extends React.Component {
height: "100%",
}
_now() {
return moment()
}
_getCurrentViewComponent() {
const components = {}
components[WEEK_VIEW] = WeekView

View file

@ -1,7 +1,7 @@
import _ from 'underscore'
import React from 'react'
import ReactDOM from 'react-dom'
import moment from 'moment'
import moment from 'moment-timezone'
import classnames from 'classnames'
import {Utils} from 'nylas-exports'
@ -60,6 +60,7 @@ export default class WeekView extends React.Component {
}
componentDidMount() {
this._mounted = true;
this._centerScrollRegion()
this._setIntervalHeight()
const weekStart = moment(this.state.startMoment).add(BUFFER_DAYS, 'days').unix()
@ -78,13 +79,19 @@ export default class WeekView extends React.Component {
}
componentWillUnmount() {
this._mounted = false;
this._sub.dispose();
window.removeEventListener('resize', this._setIntervalHeight)
}
// Indirection for testing purposes
_now() {
return moment()
}
_initializeComponent(props) {
this.todayYear = moment().year()
this.todayDayOfYear = moment().dayOfYear()
this.todayYear = this._now().year()
this.todayDayOfYear = this._now().dayOfYear()
if (this._sub) { this._sub.dispose() }
const startMoment = this._calculateStartMoment(props)
const endMoment = this._calculateEndMoment(props)
@ -96,9 +103,21 @@ export default class WeekView extends React.Component {
}
_calculateStartMoment(props) {
const start = moment([props.currentMoment.year()])
.weekday(0)
.week(props.currentMoment.week())
let start;
// NOTE: Since we initialize a new time from one of the properties of
// the props.currentMomet, we need to check for the timezone!
//
// Other relative operations (like adding or subtracting time) are
// independent of a timezone.
const tz = props.currentMoment.tz()
if (tz) {
start = moment.tz([props.currentMoment.year()], tz)
} else {
start = moment([props.currentMoment.year()])
}
start = start.weekday(0).week(props.currentMoment.week())
.subtract(BUFFER_DAYS, 'days')
return start
}
@ -233,7 +252,10 @@ export default class WeekView extends React.Component {
_headerComponents() {
const left = (
<button key="today" className="btn" onClick={this._onClickToday} style={{position: 'absolute', left: 10}}>
<button key="today" className="btn" ref="todayBtn"
onClick={this._onClickToday}
style={{position: 'absolute', left: 10}}
>
Today
</button>
);
@ -242,7 +264,7 @@ export default class WeekView extends React.Component {
}
_onClickToday = () => {
this._onChangeCurrentMoment(moment())
this._onChangeCurrentMoment(this._now())
}
_onClickNextWeek = () => {
@ -303,6 +325,7 @@ export default class WeekView extends React.Component {
}
_setIntervalHeight = () => {
if (!this._mounted) { return } // Resize unmounting is delayed in tests
const wrap = ReactDOM.findDOMNode(this.refs.eventGridWrap);
const wrapHeight = wrap.getBoundingClientRect().height;
if (this._lastWrapHeight === wrapHeight) {
@ -399,10 +422,12 @@ export default class WeekView extends React.Component {
const days = this._daysInView();
const eventsByDay = this._eventsByDay(days)
const allDayOverlap = this._eventOverlap(eventsByDay.allDay);
const tickGen = this._tickGenerator.bind(this)
const tickGen = this._tickGenerator.bind(this);
return (
<div className="calendar-view week-view">
<CalendarEventContainer
ref="calendarEventContainer"
onCalendarMouseUp={this.props.onCalendarMouseUp}
onCalendarMouseDown={this.props.onCalendarMouseDown}
onCalendarMouseMove={this.props.onCalendarMouseMove}
@ -410,6 +435,7 @@ export default class WeekView extends React.Component {
<TopBanner bannerComponents={this.props.bannerComponents} />
<HeaderControls title={this._currentWeekText()}
ref="headerControls"
headerComponents={this._headerComponents()}
nextAction={this._onClickNextWeek}
prevAction={this._onClickPrevWeek}
@ -437,6 +463,7 @@ export default class WeekView extends React.Component {
</div>
<WeekViewAllDayEvents
ref="weekViewAllDayEvents"
minorDim={MIN_INTERVAL_HEIGHT}
end={this.state.endMoment.unix()}
height={this._allDayEventHeight(allDayOverlap)}