Mailspring/app/spec/components/tokenizing-text-field-spec.tsx
Ben Gotow c629967c3d Add a proper address book with CardDAV + Google People support
commit 423cf4f407
Author: Ben Gotow <ben@foundry376.com>
Date:   Fri Oct 11 22:33:53 2019 -0500

    Replace belcard with homegrown VCard parser because our needs are minimal and the linux binary has relocation issues

commit 4ae19c0ed5
Author: Ben Gotow <ben@foundry376.com>
Date:   Fri Oct 11 10:38:26 2019 -0500

    Skip building a few more belr components, still looking for relocation error cause

commit a7ec02a449
Author: Ben Gotow <ben@foundry376.com>
Date:   Thu Oct 10 22:16:15 2019 -0500

    Fix windows SRV record lookups for contact directory autodiscovery

commit 318a31d279
Author: Ben Gotow <ben@foundry376.com>
Date:   Thu Oct 10 20:50:06 2019 -0500

    Leave vcard_grammar unpacked so mailsync can find it at runtime

commit bf7e43e37d
Author: Ben Gotow <ben@foundry376.com>
Date:   Thu Oct 10 20:43:01 2019 -0500

    Fix bug in icon case sensitivity

commit c283513653
Author: Ben Gotow <ben@foundry376.com>
Date:   Thu Oct 10 17:29:24 2019 -0500

    Renew Windows Authenticode code signing cert (there goes $500…)

commit d13235f65b
Author: Ben Gotow <ben@foundry376.com>
Date:   Wed Oct 9 23:25:34 2019 -0500

    Bump mailsync to move belr dlls

commit 00ca6431df
Author: Ben Gotow <ben@foundry376.com>
Date:   Wed Oct 9 23:22:53 2019 -0500

    Bump to xcode9 to fix odd C++11 error

commit 47903c99c4
Author: Ben Gotow <ben@foundry376.com>
Date:   Wed Oct 9 23:01:32 2019 -0500

    Bump mailsync to build belr as a dll instead of a static lib

commit 21d645d4e7
Author: Ben Gotow <ben@foundry376.com>
Date:   Wed Oct 9 22:38:24 2019 -0500

    Bump mailsync

commit 3f943031cb
Author: Ben Gotow <ben@foundry376.com>
Date:   Wed Oct 9 21:57:39 2019 -0500

    Bump mailsync to fix windows libetpan failure

commit 8fb55ca0fc
Author: Ben Gotow <ben@foundry376.com>
Date:   Wed Oct 9 21:36:57 2019 -0500

    Bump mailsync, add grammar

commit b959c54e50
Author: Ben Gotow <ben@foundry376.com>
Date:   Wed Oct 9 21:20:13 2019 -0500

    Bump mailsync for linux / win32

commit ddb5229d67
Author: Ben Gotow <ben@foundry376.com>
Date:   Tue Oct 8 22:44:12 2019 -0500

    Bump mailsync

commit f80e1bc422
Author: Ben Gotow <ben@foundry376.com>
Date:   Tue Oct 8 11:11:39 2019 -0500

    Fix LESS linter failures

commit 66dc60a731
Author: Ben Gotow <ben@foundry376.com>
Date:   Tue Oct 8 11:00:37 2019 -0500

    Extend participant search to return / expand groups

commit 3bded91307
Author: Ben Gotow <ben@foundry376.com>
Date:   Tue Oct 8 03:18:11 2019 -0500

    Add comments, etc

commit 4ede5446de
Author: Ben Gotow <ben@foundry376.com>
Date:   Tue Oct 8 02:38:50 2019 -0500

    Google People API contacts CRUD alongside CardDav

commit 96c6a64e46
Author: Ben Gotow <ben@foundry376.com>
Date:   Mon Oct 7 14:00:34 2019 -0500

    Build ContactBook concept to track which accounts have sync running

commit 1f6aab1083
Author: Ben Gotow <ben@foundry376.com>
Date:   Mon Oct 7 11:38:03 2019 -0500

    Contact and contact group CRUD, address book menus

commit b877c58d48
Author: Ben Gotow <ben@foundry376.com>
Date:   Sun Oct 6 16:32:33 2019 -0500

    Editing contact names working

commit 761079304c
Author: Ben Gotow <ben@foundry376.com>
Date:   Mon Sep 30 15:42:59 2019 -0500

    Improved styling of YYYYMMDD field

commit 71a567276b
Author: Ben Gotow <ben@foundry376.com>
Date:   Mon Sep 30 15:06:28 2019 -0500

    UI for edit + move + delete contacts

commit f1967dd603
Author: Ben Gotow <ben@foundry376.com>
Date:   Thu Sep 26 13:50:44 2019 -0500

    Allow you to turn on / off the “Found in Mail” autocompletions

commit 0c2b0eb03b
Author: Ben Gotow <ben@foundry376.com>
Date:   Thu Sep 26 13:50:14 2019 -0500

    Improve contacts window launch perf by lazy loading composer support, scanning less of fs for themes

commit 07abd6cb71
Author: Ben Gotow <ben@foundry376.com>
Date:   Thu Sep 26 04:36:10 2019 -0500

    Support for CardDav contact display, better groups presentation

commit 0a9e166d79
Author: Ben Gotow <ben@foundry376.com>
Date:   Tue Sep 24 12:42:37 2019 -0500

    Add hidden attribute to the model in prep for deletion of auto-created contacts

commit e6ce3b2af9
Author: Ben Gotow <ben@foundry376.com>
Date:   Tue Sep 24 12:12:52 2019 -0500

    Initial pass at address book

commit 740d7e8655
Author: Ben Gotow <ben@foundry376.com>
Date:   Tue Sep 24 08:27:06 2019 -0500

    Make headers of Preferences > Accounts consistent with General
2019-10-12 12:40:57 -05:00

463 lines
18 KiB
TypeScript

import React from 'react';
const { mount } = require('enzyme');
import { Contact } from 'mailspring-exports';
import { KeyCommandsRegion, TokenizingTextField, Menu } from 'mailspring-component-kit';
class CustomToken extends React.Component {
render() {
return <span>{this.props.token.email}</span>;
}
}
class CustomSuggestion extends React.Component {
render() {
return <span>{this.props.item.email}</span>;
}
}
const participant1 = new Contact({
id: '1',
email: 'ben@mailspring.com',
});
const participant2 = new Contact({
id: '2',
email: 'burgers@mailspring.com',
name: 'Mailspring Burger Basket',
});
const participant3 = new Contact({
id: '3',
email: 'evan@mailspring.com',
name: 'Evan',
});
const participant4 = new Contact({
id: '4',
email: 'tester@elsewhere.com',
name: 'Tester',
});
const participant5 = new Contact({
id: '5',
email: 'michael@elsewhere.com',
name: 'Michael',
});
describe('TokenizingTextField', function() {
beforeEach(function() {
this.completions = [];
this.propAdd = jasmine.createSpy('add');
this.propEdit = jasmine.createSpy('edit');
this.propRemove = jasmine.createSpy('remove');
this.propEmptied = jasmine.createSpy('emptied');
this.propTokenKey = jasmine.createSpy('tokenKey').andCallFake(p => p.email);
this.propTokenIsValid = jasmine.createSpy('tokenIsValid').andReturn(true);
this.propTokenRenderer = CustomToken;
this.propOnTokenAction = jasmine.createSpy('tokenAction');
this.propCompletionNode = p => <CustomSuggestion item={p} />;
this.propCompletionsForInput = input => this.completions;
spyOn(this, 'propCompletionNode').andCallThrough();
spyOn(this, 'propCompletionsForInput').andCallThrough();
this.tokens = [participant1, participant2, participant3];
this.rebuildRenderedField = (tokens = this.tokens) => {
this.renderedField = mount(
<TokenizingTextField
tokens={this.tokens}
tokenKey={this.propTokenKey}
tokenRenderer={this.propTokenRenderer}
tokenIsValid={this.propTokenIsValid}
onRequestCompletions={this.propCompletionsForInput}
completionNode={this.propCompletionNode}
onAdd={this.propAdd}
onEdit={this.propEdit}
onRemove={this.propRemove}
onEmptied={this.propEmptied}
onTokenAction={this.propOnTokenAction}
tabIndex={this.tabIndex}
/>
);
this.renderedInput = this.renderedField.find('input');
return this.renderedField;
};
this.rebuildRenderedField();
});
it('renders into the document', function() {
expect(this.renderedField.find(TokenizingTextField).length).toBe(1);
});
it('should render an input field', function() {
expect(this.renderedInput).toBeDefined();
});
it('shows the tokens provided by the tokenRenderer', function() {
expect(this.renderedField.find(CustomToken).length).toBe(this.tokens.length);
});
it('shows the tokens in the correct order', function() {
this.renderedTokens = this.renderedField.find(CustomToken);
__range__(0, this.tokens.length - 1, true).map(i =>
expect(this.renderedTokens.at(i).props().token).toBe(this.tokens[i])
);
});
describe('prop: tokenIsValid', function() {
it("should be evaluated for each token when it's provided", function() {
this.propTokenIsValid = jasmine.createSpy('tokenIsValid').andCallFake(p => {
if (p === participant2) {
return true;
} else {
return false;
}
});
this.rebuildRenderedField();
this.tokens = this.renderedField.find(TokenizingTextField.Token);
expect(this.tokens.at(0).props().valid).toBe(false);
expect(this.tokens.at(1).props().valid).toBe(true);
expect(this.tokens.at(2).props().valid).toBe(false);
});
it('should default to true when not provided', function() {
this.propTokenIsValid = null;
this.rebuildRenderedField();
this.tokens = this.renderedField.find(TokenizingTextField.Token);
expect(this.tokens.at(0).props().valid).toBe(true);
expect(this.tokens.at(1).props().valid).toBe(true);
expect(this.tokens.at(2).props().valid).toBe(true);
});
});
describe('when the user drags and drops a token between two fields', () =>
it('should work properly', function() {
const tokensA = [participant1, participant2, participant3];
const fieldA = this.rebuildRenderedField(tokensA);
const tokensB = [];
const fieldB = this.rebuildRenderedField(tokensB);
const tokenIndexToDrag = 1;
const token = fieldA.find('.token').at(tokenIndexToDrag);
const dragStartEventData = {};
const dragStartEvent = {
dataTransfer: {
setData(type, val) {
dragStartEventData[type] = val;
},
},
};
token.simulate('dragStart', dragStartEvent);
expect(dragStartEventData).toEqual({
'mailspring-token-items':
'[{"id":"2","name":"Mailspring Burger Basket","email":"burgers@mailspring.com","__cls":"Contact"}]',
'text/plain': 'Mailspring Burger Basket <burgers@mailspring.com>',
});
const dropEvent = {
dataTransfer: {
types: Object.keys(dragStartEventData),
getData(type) {
return dragStartEventData[type];
},
},
};
fieldB.find(KeyCommandsRegion).simulate('drop', dropEvent);
expect(this.propAdd).toHaveBeenCalledWith([tokensA[tokenIndexToDrag]]);
}));
describe('When the user selects a token', function() {
beforeEach(function() {
const token = this.renderedField.find('.token').first();
token.simulate('click');
});
it('should set the selectedKeys state', function() {
expect(this.renderedField.state().selectedKeys).toEqual([participant1.email]);
});
it('should return the appropriate token object', function() {
expect(this.propTokenKey).toHaveBeenCalledWith(participant1);
expect(this.renderedField.find('.token.selected').length).toEqual(1);
});
});
describe('when focused', () =>
it('should receive the `focused` class', function() {
expect(this.renderedField.find('.focused').length).toBe(0);
this.renderedInput.simulate('focus');
expect(this.renderedField.find('.focused').length).not.toBe(0);
}));
describe('when the user types in the input', function() {
it('should fetch completions for the text', function() {
this.renderedInput.simulate('change', { target: { value: 'abc' } });
advanceClock(1000);
expect(this.propCompletionsForInput.calls[0].args[0]).toBe('abc');
});
it('should fetch completions on focus', function() {
this.renderedField.setState({ inputValue: 'abc' });
this.renderedInput.simulate('focus');
advanceClock(1000);
expect(this.propCompletionsForInput.calls[0].args[0]).toBe('abc');
});
it('should display the completions', function() {
this.completions = [participant4, participant5];
this.renderedInput.simulate('change', { target: { value: 'abc' } });
const components = this.renderedField.find(CustomSuggestion);
expect(components.length).toBe(2);
expect(components.at(0).props().item).toBe(participant4);
expect(components.at(1).props().item).toBe(participant5);
});
it('should not display items with keys matching items already in the token field', function() {
this.completions = [participant2, participant4, participant1];
this.renderedInput.simulate('change', { target: { value: 'abc' } });
const components = this.renderedField.find(CustomSuggestion);
expect(components.length).toBe(1);
expect(components.at(0).props().item).toBe(participant4);
});
it('should highlight the first completion', function() {
this.completions = [participant4, participant5];
this.renderedInput.simulate('change', { target: { value: 'abc' } });
const components = this.renderedField.find(Menu.Item);
const menuItem = components.first();
expect(menuItem.props().selected).toBe(true);
});
it('select the clicked element', function() {
this.completions = [participant4, participant5];
this.renderedInput.simulate('change', { target: { value: 'abc' } });
const components = this.renderedField.find(Menu.Item);
const menuItem = components.first();
menuItem.simulate('mouseDown');
expect(this.propAdd).toHaveBeenCalledWith([participant4]);
});
it("doesn't sumbmit if it looks like an email but has no space at the end", function() {
this.renderedInput.simulate('change', { target: { value: 'abc@foo.com' } });
advanceClock(10);
expect(this.propCompletionsForInput.calls[0].args[0]).toBe('abc@foo.com');
expect(this.propAdd).not.toHaveBeenCalled();
});
it("allows spaces if what's currently being entered doesn't look like an email", function() {
this.renderedInput.simulate('change', { target: { value: 'ab' } });
advanceClock(10);
this.renderedInput.simulate('change', { target: { value: 'ab ' } });
advanceClock(10);
this.renderedInput.simulate('change', { target: { value: 'ab c' } });
advanceClock(10);
expect(this.propCompletionsForInput.calls[2].args[0]).toBe('ab c');
expect(this.propAdd).not.toHaveBeenCalled();
});
});
[{ key: 'Enter', keyCode: 13 }, { key: ',', keyCode: 188 }].forEach(({ key, keyCode }) =>
describe(`when the user presses ${key}`, function() {
describe('and there is an completion available', () =>
it('should call add with the first completion', function() {
this.completions = [participant4];
this.renderedInput.simulate('change', { target: { value: 'abc' } });
this.renderedInput.simulate('keyDown', { key, keyCode });
expect(this.propAdd).toHaveBeenCalledWith([participant4]);
}));
describe('and there is NO completion available', () =>
it('should call add, allowing the parent to (optionally) turn the text into a token', function() {
this.completions = [];
this.renderedInput.simulate('change', { target: { value: 'abc' } });
this.renderedInput.simulate('keyDown', { key, keyCode });
expect(this.propAdd).toHaveBeenCalledWith('abc', {});
}));
})
);
describe('when the user presses tab', function() {
beforeEach(function() {
this.tabDownEvent = {
key: 'Tab',
keyCode: 9,
preventDefault: jasmine.createSpy('preventDefault'),
stopPropagation: jasmine.createSpy('stopPropagation'),
};
});
describe('and there is an completion available', () =>
it('should call add with the first completion', function() {
this.completions = [participant4];
this.renderedInput.simulate('change', { target: { value: 'abc' } });
this.renderedInput.simulate('keyDown', this.tabDownEvent);
expect(this.propAdd).toHaveBeenCalledWith([participant4]);
expect(this.tabDownEvent.preventDefault).toHaveBeenCalled();
expect(this.tabDownEvent.stopPropagation).toHaveBeenCalled();
}));
it("shouldn't handle the event in the input is empty", function() {
// We ignore on empty input values
this.renderedInput.simulate('change', { target: { value: ' ' } });
this.renderedInput.simulate('keyDown', this.tabDownEvent);
expect(this.propAdd).not.toHaveBeenCalled();
});
it('should NOT stop the propagation if the input is empty.', function() {
// This is to allow tabs to propagate up to controls that might want
// to change the focus later.
this.renderedInput.simulate('change', { target: { value: ' ' } });
this.renderedInput.simulate('keyDown', this.tabDownEvent);
expect(this.propAdd).not.toHaveBeenCalled();
expect(this.tabDownEvent.stopPropagation).not.toHaveBeenCalled();
});
it('should add the raw input value if there are no completions', function() {
this.completions = [];
this.renderedInput.simulate('change', { target: { value: 'abc' } });
this.renderedInput.simulate('keyDown', this.tabDownEvent);
expect(this.propAdd).toHaveBeenCalledWith('abc', {});
expect(this.tabDownEvent.preventDefault).toHaveBeenCalled();
expect(this.tabDownEvent.stopPropagation).toHaveBeenCalled();
});
});
describe('when blurred', function() {
it('should do nothing if the relatedTarget is null meaning the app has been blurred', function() {
this.renderedInput.simulate('focus');
this.renderedInput.simulate('change', { target: { value: 'text' } });
this.renderedInput.simulate('blur', { relatedTarget: null });
expect(this.propAdd).not.toHaveBeenCalled();
expect(this.renderedField.find('.focused').length).not.toBe(0);
});
it('should call add, allowing the parent component to (optionally) turn the entered text into a token', function() {
this.renderedInput.simulate('focus');
this.renderedInput.simulate('change', { target: { value: 'text' } });
this.renderedInput.simulate('blur', { relatedTarget: document.body });
expect(this.propAdd).toHaveBeenCalledWith('text', {});
});
it('should clear the entered text', function() {
this.renderedInput.simulate('focus');
this.renderedInput.simulate('change', { target: { value: 'text' } });
this.renderedInput.simulate('blur', { relatedTarget: document.body });
expect(this.renderedInput.props().value).toBe('');
});
it('should no longer have the `focused` class', function() {
this.renderedInput.simulate('focus');
expect(this.renderedField.find('.focused').length).not.toBe(0);
this.renderedInput.simulate('blur', { relatedTarget: document.body });
expect(this.renderedField.find('.focused').length).toBe(0);
});
});
describe('cut', () =>
it('removes the selected tokens', function() {
this.renderedField.setState({ selectedKeys: [participant1.email] });
this.renderedInput.simulate('cut');
expect(this.propRemove).toHaveBeenCalledWith([participant1]);
expect(this.renderedField.find('.token.selected').length).toEqual(0);
expect(this.propEmptied).not.toHaveBeenCalled();
}));
describe('backspace', function() {
describe('when no token is selected', () =>
it("selects the last token first and doesn't remove", function() {
this.renderedInput.simulate('keyDown', { key: 'Backspace', keyCode: 8 });
expect(this.renderedField.find('.token.selected').length).toEqual(1);
expect(this.propRemove).not.toHaveBeenCalled();
expect(this.propEmptied).not.toHaveBeenCalled();
}));
describe('when a token is selected', () =>
it('removes that token and deselects', function() {
this.renderedField.setState({ selectedKeys: [participant1.email] });
expect(this.renderedField.find('.token.selected').length).toEqual(1);
this.renderedInput.simulate('keyDown', { key: 'Backspace', keyCode: 8 });
expect(this.propRemove).toHaveBeenCalledWith([participant1]);
expect(this.renderedField.find('.token.selected').length).toEqual(0);
expect(this.propEmptied).not.toHaveBeenCalled();
}));
describe('when there are no tokens left', () =>
it('fires onEmptied', function() {
this.renderedField.setProps({ tokens: [] });
expect(this.renderedField.find('.token').length).toEqual(0);
this.renderedInput.simulate('keyDown', { key: 'Backspace', keyCode: 8 });
expect(this.propEmptied).toHaveBeenCalled();
}));
});
});
describe('TokenizingTextField.Token', function() {
describe('when an onEdit prop has been provided', function() {
beforeEach(function() {
this.propEdit = jasmine.createSpy('onEdit');
this.propClick = jasmine.createSpy('onClick');
this.token = mount(
<TokenizingTextField.Token
selected={false}
valid={true}
item={participant1}
onClick={this.propClick}
onEdited={this.propEdit}
onDragStart={jasmine.createSpy('onDragStart')}
onAction={jasmine.createSpy('onAction')}
/>
);
});
it('should enter editing mode', function() {
expect(this.token.state().editing).toBe(false);
this.token.simulate('doubleClick', {});
expect(this.token.state().editing).toBe(true);
});
it('should call onEdit to commit the new token value when the edit field is blurred', function() {
expect(this.token.state().editing).toBe(false);
this.token.simulate('doubleClick', {});
expect(this.token.state().editing).toBe(true);
const tokenEditInput = this.token.find('input');
tokenEditInput.getDOMNode().value = 'new tag content';
tokenEditInput.simulate('change');
tokenEditInput.simulate('blur');
expect(this.propEdit).toHaveBeenCalledWith(participant1, 'new tag content');
});
});
describe('when no onEdit prop has been provided', () =>
it('should not enter editing mode', function() {
this.token = mount(
React.createElement(TokenizingTextField.Token, {
selected: false,
valid: true,
item: participant1,
onClick: jasmine.createSpy('onClick'),
onDragStart: jasmine.createSpy('onDragStart'),
onEdited: null,
})
);
expect(this.token.state().editing).toBe(false);
this.token.simulate('doubleClick', {});
expect(this.token.state().editing).toBe(false);
}));
});
function __range__(left, right, inclusive) {
let range = [];
let ascending = left < right;
let end = !inclusive ? right : ascending ? right + 1 : right - 1;
for (let i = left; ascending ? i < end : i > end; ascending ? i++ : i--) {
range.push(i);
}
return range;
}