refactor(signatures): Removed old signature imgs and made static empty signatures page

Summary:
Refactored signature preferences page to allow more signatures than the previous 1-1 mapping for signatures and accounts. Created a multi select dropdown of the accounts for which a certain signature is set as default for. Added a button into the draft header From field to toggle between saved signatures.

refactor(signatures): Add basic add/remove capabilities to static

refactor(signatures): Hooked up signature actions and signature store for basic functionality

fix(signatures): Cleaned up signature store and started on multiselectdropdown

fix(signatures): Add multi signature toggle select to multiselect dropdown

build(signatures): Built framework for multiselect dropdown

build(signatures): Toggle button functionality for dropdown

build(signatures): Build multi select from components and add debounce

refactor(signatures): Move signature actions and signature store into flux

fix(signatures): Styled composer signatures button/dropdown and fixed preferences checkmarks

build(signatures): Finish main functionality, about to refactor composer signature button into injected component

fix(signatures): Changed position styles

fix(signatures): Fixed background color for dropdown button when blurred

build(signatures): Began to write tests for signatures store, preferences and dropdown

Test Plan: Wrote tests for preferences signatures, signature store, signature composer dropdown and refactored tests for signature composer extension. For signature composer extension I removed applyTransformsToDraft and unapplyTransformsToDraft and tested by sending emails with signatures to different providers to make sure the <signature> tag caused problems.

Reviewers: bengotow, juan

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D3073
This commit is contained in:
Annie 2016-07-11 12:28:37 -07:00
parent b03a352ee6
commit bd361c8abb
33 changed files with 1047 additions and 293 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -1,7 +1,7 @@
import {PreferencesUIStore, ExtensionRegistry} from 'nylas-exports';
import {PreferencesUIStore, ExtensionRegistry, SignatureStore, ComponentRegistry} from 'nylas-exports';
import SignatureComposerExtension from './signature-composer-extension';
import SignatureStore from './signature-store';
import SignatureComposerDropdown from './signature-composer-dropdown';
import PreferencesSignatures from "./preferences-signatures";
export function activate() {
@ -14,12 +14,18 @@ export function activate() {
ExtensionRegistry.Composer.register(SignatureComposerExtension);
PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
SignatureStore.activate();
ComponentRegistry.register(SignatureComposerDropdown, {
role: 'Composer:FromFieldComponents',
});
}
export function deactivate() {
ExtensionRegistry.Composer.unregister(SignatureComposerExtension);
PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.sectionId);
SignatureStore.deactivate();
ComponentRegistry.unregister(SignatureComposerDropdown);
}
export function serialize() {

View file

@ -1,121 +1,210 @@
import React from 'react';
import {Contenteditable} from 'nylas-component-kit';
import {AccountStore} from 'nylas-exports';
import SignatureStore from './signature-store';
import SignatureActions from './signature-actions';
import _ from 'underscore';
import {
Flexbox,
RetinaImg,
EditableList,
Contenteditable,
MultiselectDropdown,
} from 'nylas-component-kit';
import {AccountStore, SignatureStore, Actions} from 'nylas-exports';
export default class PreferencesSignatures extends React.Component {
static displayName = 'PreferencesSignatures';
constructor(props) {
super(props);
this.state = this._getStateFromStores();
constructor() {
super()
this.state = this._getStateFromStores()
}
componentDidMount() {
this.usub = AccountStore.listen(this._onChange);
this.unsubscribers = [
SignatureStore.listen(this._onChange),
]
}
componentWillUnmount() {
this.usub();
this.unsubscribers.forEach(unsubscribe => unsubscribe());
}
_onChange = () => {
this.setState(this._getStateFromStores());
this.setState(this._getStateFromStores())
}
_getStateFromStores() {
const accounts = AccountStore.accounts();
const state = this.state || {};
let {currentAccountId} = state;
if (!accounts.find(acct => acct.id === currentAccountId)) {
currentAccountId = accounts[0].id;
}
const signatures = SignatureStore.getSignatures()
const accounts = AccountStore.accounts()
const selected = SignatureStore.selectedSignature()
const defaults = SignatureStore.getDefaults()
return {
accounts,
currentAccountId,
currentSignature: SignatureStore.signatureForAccountId(currentAccountId),
editAsHTML: state.editAsHTML,
};
signatures: signatures,
selectedSignature: selected,
defaults: defaults,
accounts: accounts,
editAsHTML: this.state ? this.state.editAsHTML : false,
}
}
_onCreateButtonClick = () => {
this._onAddSignature()
}
_onAddSignature = () => {
Actions.addSignature()
}
_onDeleteSignature = (signature) => {
Actions.removeSignature(signature)
}
_onEditSignature = (edit) => {
let editedSig;
if (typeof edit === "object") {
editedSig = {
title: this.state.selectedSignature.title,
body: edit.target.value,
}
} else {
editedSig = {
title: edit,
body: this.state.selectedSignature.body,
}
}
Actions.updateSignature(editedSig, this.state.selectedSignature.id)
}
_onSelectSignature = (sig) => {
Actions.selectSignature(sig.id)
}
_onToggleAccount = (accountId) => {
Actions.toggleAccount(accountId)
}
_onToggleEditAsHTML = () => {
const toggled = !this.state.editAsHTML
this.setState({editAsHTML: toggled})
}
_renderListItemContent = (sig) => {
return sig.title
}
_renderSignatureToolbar() {
return (
<div className="editable-toolbar">
<div className="account-picker">
Default for: {this._renderAccountPicker()}
</div>
<div className="render-mode">
<input type="checkbox" id="render-mode" checked={this.state.editAsHTML} onClick={this._onToggleEditAsHTML} />
<label>Edit raw HTML</label>
</div>
</div>
)
}
_selectItemKey = (account) => {
return account.accountId
}
_isChecked = (account) => {
if (this.state.defaults[account.accountId] === this.state.selectedSignature.id) return true
return false
}
_numSelected() {
const sel = _.filter(this.state.accounts, (account) => {
return this._isChecked(account)
})
const numSelected = sel.length
return numSelected.toString() + (numSelected === 1 ? " Account" : " Accounts")
}
_renderAccountPicker() {
const options = this.state.accounts.map(account =>
<option value={account.id} key={account.id}>{account.label}</option>
);
const buttonText = this._numSelected()
return (
<select value={this.state.currentAccountId} onChange={this._onSelectAccount} style={{minWidth: 200}}>
{options}
</select>
);
<MultiselectDropdown
className="account-dropdown"
items={this.state.accounts}
itemChecked={this._isChecked}
onToggleItem={this._onToggleAccount}
itemKey={this._selectItemKey}
current={this.selectedSignature}
buttonText={buttonText}
/>
)
}
_renderEditableSignature() {
const selectedBody = this.state.selectedSignature ? this.state.selectedSignature.body : ""
return (
<Contenteditable
tabIndex={-1}
ref="signatureInput"
value={this.state.currentSignature}
onChange={this._onEditSignature}
value={selectedBody}
spellcheck={false}
onChange={this._onEditSignature}
/>
);
)
}
_renderHTMLSignature() {
return (
<textarea
ref="signatureHTMLInput"
value={this.state.currentSignature}
value={this.state.selectedSignature.body}
onChange={this._onEditSignature}
/>
);
}
_onEditSignature = (event) => {
const html = event.target.value;
this.setState({currentSignature: html});
SignatureActions.setSignatureForAccountId({
accountId: this.state.currentAccountId,
signature: html,
});
}
_onSelectAccount = (event) => {
const accountId = event.target.value;
this.setState({
currentSignature: SignatureStore.signatureForAccountId(accountId),
currentAccountId: accountId,
});
}
_renderModeToggle() {
const label = this.state.editAsHTML ? "Edit live preview" : "Edit raw HTML";
const action = () => {
this.setState({editAsHTML: !this.state.editAsHTML});
return;
};
_renderSignatures() {
const sigArr = _.values(this.state.signatures)
if (sigArr.length === 0) {
return (
<div className="empty-list">
<RetinaImg
className="icon-signature"
name="signatures-big.png"
mode={RetinaImg.Mode.ContentDark}
/>
<h2>No signatures</h2>
<button className="btn btn-small btn-create-signature" onMouseDown={this._onCreateButtonClick}>
Create a new signature
</button>
</div>
);
}
return (
<a onClick={action}>{label}</a>
);
<Flexbox>
<EditableList
showEditIcon
className="signature-list"
items={sigArr}
itemContent={this._renderListItemContent}
onCreateItem={this._onAddSignature}
onDeleteItem={this._onDeleteSignature}
onItemEdited={this._onEditSignature}
onSelectItem={this._onSelectSignature}
selected={this.state.selectedSignature}
/>
<div className="signature-wrap">
{this.state.editAsHTML ? this._renderHTMLSignature() : this._renderEditableSignature()}
{this._renderSignatureToolbar()}
</div>
</Flexbox>
)
}
render() {
const rawText = this.state.editAsHTML ? "Raw HTML " : "";
return (
<section className="container-signatures">
<div className="section-title">
{rawText}Signature for: {this._renderAccountPicker()}
</div>
<div className="signature-wrap">
{this.state.editAsHTML ? this._renderHTMLSignature() : this._renderEditableSignature()}
</div>
<div className="toggle-mode" style={{marginTop: "1em"}}>{this._renderModeToggle()}</div>
</section>
<div className="preferences-signatures-container">
<section>
{this._renderSignatures()}
</section>
</div>
)
}
}

View file

@ -1,12 +0,0 @@
import Reflux from 'reflux';
const ActionNames = [
'setSignatureForAccountId',
];
const Actions = Reflux.createActions(ActionNames);
ActionNames.forEach((name) => {
Actions[name].sync = true;
});
export default Actions;

View file

@ -0,0 +1,145 @@
import {
React,
Actions,
SignatureStore,
} from 'nylas-exports'
import {
Menu,
RetinaImg,
ButtonDropdown,
} from 'nylas-component-kit'
import SignatureUtils from './signature-utils'
import _ from 'underscore'
export default class SignatureComposerDropdown extends React.Component {
static displayName = 'SignatureComposerDropdown'
static containerRequired = false
static propTypes = {
draft: React.PropTypes.object.isRequired,
session: React.PropTypes.object.isRequired,
currentAccount: React.PropTypes.object,
accounts: React.PropTypes.array,
}
constructor() {
super()
this.state = this._getStateFromStores()
}
componentDidMount = () => {
this.unsubscribers = [
SignatureStore.listen(this._onChange),
]
}
componentDidUpdate(previousProps) {
if (previousProps.currentAccount.accountId !== this.props.currentAccount.accountId) {
const newAccountDefaultSignature = SignatureStore.signatureForAccountId(this.props.currentAccount.accountId)
this._changeSignature(newAccountDefaultSignature)
}
}
componentWillUnmount() {
this.unsubscribers.forEach(unsubscribe => unsubscribe())
}
_onChange = () => {
this.setState(this._getStateFromStores())
}
_getStateFromStores() {
const signatures = SignatureStore.getSignatures()
return {
signatures: signatures,
}
}
_renderSigItem = (sigItem) => {
return (
<span className={`signature-title-${sigItem.title}`}>{sigItem.title}</span>
)
}
_changeSignature = (sig) => {
let body;
if (sig) {
body = SignatureUtils.applySignature(this.props.draft.body, sig.body)
} else {
body = SignatureUtils.applySignature(this.props.draft.body, '')
}
this.props.session.changes.add({body})
}
_isSelected = (sigObj) => {
// http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
const escapeRegExp = (str) => {
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
}
const signatureRegex = new RegExp(escapeRegExp(`<signature>${sigObj.body}</signature>`))
const signatureLocation = signatureRegex.exec(this.props.draft.body)
if (signatureLocation) return true
return false
}
_onClickNoSignature = () => {
this._changeSignature({body: ''})
}
_onClickEditSignatures() {
Actions.switchPreferencesTab('Signatures')
Actions.openPreferences()
}
_renderSignatures() {
const header = [<div className="item item-none" key="none" onMouseDown={this._onClickNoSignature}><span>No signature</span></div>]
const footer = [<div className="item item-edit" key="edit" onMouseDown={this._onClickEditSignatures}><span>Edit Signatures...</span></div>]
const sigItems = _.values(this.state.signatures)
return (
<Menu
headerComponents={header}
footerComponents={footer}
items={sigItems}
itemKey={sigItem => sigItem.id}
itemContent={this._renderSigItem}
onSelect={this._changeSignature}
itemChecked={this._isSelected}
/>
)
}
_renderSignatureIcon() {
return (
<RetinaImg
className="signature-button"
name="top-signature-dropdown.png"
mode={RetinaImg.Mode.ContentIsMask}
/>
)
}
render() {
const sigs = this.state.signatures;
const icon = this._renderSignatureIcon()
if (!_.isEmpty(sigs)) {
return (
<div className="signature-button-dropdown">
<ButtonDropdown
primaryItem={icon}
menu={this._renderSignatures()}
bordered={false}
/>
</div>
)
}
return null
}
}

View file

@ -1,30 +1,13 @@
import {ComposerExtension} from 'nylas-exports';
import {ComposerExtension, SignatureStore} from 'nylas-exports';
import SignatureUtils from './signature-utils';
import SignatureStore from './signature-store';
export default class SignatureComposerExtension extends ComposerExtension {
static prepareNewDraft = ({draft}) => {
const accountId = draft.accountId;
const signature = SignatureStore.signatureForAccountId(accountId);
if (!signature) {
const signatureObj = SignatureStore.signatureForAccountId(accountId);
if (!signatureObj) {
return;
}
draft.body = SignatureUtils.applySignature(draft.body, signature);
}
static applyTransformsToDraft = ({draft}) => {
const nextDraft = draft.clone();
nextDraft.body = nextDraft.body.replace(/<\/?signature[^>]*>/g, (match) =>
`<!-- ${match} -->`
);
return nextDraft;
}
static unapplyTransformsToDraft = ({draft}) => {
const nextDraft = draft.clone();
nextDraft.body = nextDraft.body.replace(/<!-- (<\/?signature[^>]*>) -->/g, (match, node) =>
node
);
return nextDraft;
draft.body = SignatureUtils.applySignature(draft.body, signatureObj.body);
}
}

View file

@ -1,57 +0,0 @@
import {DraftStore, AccountStore, Actions} from 'nylas-exports';
import SignatureUtils from './signature-utils';
import SignatureActions from './signature-actions';
class SignatureStore {
DefaultSignature = "Sent from <a href=\"https://nylas.com/n1?ref=n1\">Nylas N1</a>, the extensible, open source mail client.";
constructor() {
this.unsubscribes = [];
}
activate() {
this.unsubscribes.push(
SignatureActions.setSignatureForAccountId.listen(this._onSetSignatureForAccountId)
);
this.unsubscribes.push(
Actions.draftParticipantsChanged.listen(this._onParticipantsChanged)
);
}
deactivate() {
this.unsubscribes.forEach(unsub => unsub());
}
signatureForAccountId(accountId) {
if (!accountId) {
return this.DefaultSignature;
}
const saved = NylasEnv.config.get(`nylas.account-${accountId}.signature`);
if (saved === undefined) {
return this.DefaultSignature;
}
return saved;
}
_onParticipantsChanged = (draftClientId, changes) => {
if (!changes.from) { return; }
DraftStore.sessionForClientId(draftClientId).then((session) => {
const draft = session.draft();
const {accountId} = AccountStore.accountForEmail(changes.from[0].email);
const signature = this.signatureForAccountId(accountId);
const body = SignatureUtils.applySignature(draft.body, signature);
session.changes.add({body});
});
}
_onSetSignatureForAccountId = ({signature, accountId}) => {
// NylasEnv.config.set is internally debounced 100ms
NylasEnv.config.set(`nylas.account-${accountId}.signature`, signature)
}
}
export default new SignatureStore();

View file

@ -7,10 +7,10 @@ export default {
let newBody = body;
let paddingBefore = '';
let paddingAfter = '';
// Remove any existing signature in the body
newBody = newBody.replace(signatureRegex, "");
const signatureInPrevious = newBody !== body
// http://www.regexpal.com/?fam=94390
// prefer to put the signature one <br> before the beginning of the quote,
@ -18,13 +18,11 @@ export default {
let insertionPoint = newBody.search(RegExpUtils.n1QuoteStartRegex());
if (insertionPoint === -1) {
insertionPoint = newBody.length;
paddingBefore = '<br/><br/>';
} else {
paddingAfter = '<br/>';
if (!signatureInPrevious) paddingBefore = '<br><br>'
}
const contentBefore = newBody.slice(0, insertionPoint);
const contentAfter = newBody.slice(insertionPoint);
return `${contentBefore}${paddingBefore}<signature>${signature}${paddingAfter}</signature>${contentAfter}`;
return `${contentBefore}${paddingBefore}<signature>${signature}</signature>${contentAfter}`;
},
};

View file

@ -0,0 +1,90 @@
/* eslint quote-props: 0 */
import PreferencesSignatures from '../lib/preferences-signatures.jsx';
import ReactTestUtils from 'react-addons-test-utils';
import React from 'react';
import {SignatureStore, Actions} from 'nylas-exports';
const SIGNATURES = {
'1': {
id: '1',
title: 'one',
body: 'first test signature!',
},
'2': {
id: '2',
title: 'two',
body: 'Here is my second sig!',
},
}
const DEFAULTS = {
11: '1',
22: '2',
}
const makeComponent = (props = {}) => {
return ReactTestUtils.renderIntoDocument(<PreferencesSignatures {...props} />)
}
describe('PreferencesSignatures', function preferencesSignatures() {
this.component = null
describe('when there are no signatures', () => {
it('should add a signature when you click the button', () => {
spyOn(SignatureStore, 'getSignatures').andReturn({})
spyOn(SignatureStore, 'selectedSignature')
spyOn(SignatureStore, 'getDefaults').andReturn({})
this.component = makeComponent()
spyOn(Actions, 'addSignature')
this.button = ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'btn-create-signature')
ReactTestUtils.Simulate.mouseDown(this.button)
expect(Actions.addSignature).toHaveBeenCalled()
})
})
describe('when there are signatures', () => {
beforeEach(() => {
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES)
spyOn(SignatureStore, 'selectedSignature').andReturn(SIGNATURES['1'])
spyOn(SignatureStore, 'getDefaults').andReturn(DEFAULTS)
this.component = makeComponent()
})
it('should add a signature when you click the plus button', () => {
spyOn(Actions, 'addSignature')
this.plus = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'btn-editable-list')[0]
ReactTestUtils.Simulate.click(this.plus)
expect(Actions.addSignature).toHaveBeenCalled()
})
it('should delete a signature when you click the minus button', () => {
spyOn(Actions, 'removeSignature')
this.minus = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'btn-editable-list')[1]
ReactTestUtils.Simulate.click(this.minus)
expect(Actions.removeSignature).toHaveBeenCalledWith(SIGNATURES['1'])
})
it('should toggle default status when you click an email on the dropdown', () => {
spyOn(Actions, 'toggleAccount')
this.account = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'item')[0]
ReactTestUtils.Simulate.mouseDown(this.account)
expect(Actions.toggleAccount).toHaveBeenCalledWith('test-account-server-id')
})
it('should set the selected signature when you click on one that is not currently selected', () => {
spyOn(Actions, 'selectSignature')
this.item = ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'list-item')[1]
ReactTestUtils.Simulate.click(this.item)
expect(Actions.selectSignature).toHaveBeenCalledWith('2')
})
it('should modify the signature body when edited', () => {
spyOn(Actions, 'updateSignature')
const newText = 'Changed <strong>NEW 1 HTML</strong><br>'
this.component._onEditSignature({target: {value: newText}});
expect(Actions.updateSignature).toHaveBeenCalled()
})
it('should modify the signature title when edited', () => {
spyOn(Actions, 'updateSignature')
const newTitle = 'Changed'
this.component._onEditSignature(newTitle)
expect(Actions.updateSignature).toHaveBeenCalled()
})
})
})

View file

@ -0,0 +1,58 @@
/* eslint quote-props: 0 */
import React from 'react';
import SignatureComposerDropdown from '../lib/signature-composer-dropdown'
import {renderIntoDocument} from '../../../spec/nylas-test-utils'
import ReactTestUtils from 'react-addons-test-utils'
import {SignatureStore} from 'nylas-exports';
const SIGNATURES = {
'1': {
id: '1',
title: 'one',
body: 'first test signature!',
},
'2': {
id: '2',
title: 'two',
body: 'Here is my second sig!',
},
}
describe('SignatureComposerDropdown', function signatureComposerDropdown() {
beforeEach(() => {
spyOn(SignatureStore, 'getSignatures').andReturn(SIGNATURES)
spyOn(SignatureStore, 'selectedSignature')
this.session = {
changes: {
add: jasmine.createSpy('add'),
},
}
this.draft = {
body: "draft body",
}
this.button = renderIntoDocument(<SignatureComposerDropdown draft={this.draft} session={this.session} />)
})
describe('the button dropdown', () => {
it('calls add signature with the correct signature', () => {
const sigToAdd = SIGNATURES['2']
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
this.signature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, `signature-title-${sigToAdd.title}`)
ReactTestUtils.Simulate.mouseDown(this.signature)
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature>${sigToAdd.body}</signature>`})
})
it('calls add signature with nothing when no signature is clicked and there is no current signature', () => {
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
this.noSignature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'item-none')
ReactTestUtils.Simulate.mouseDown(this.noSignature)
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature></signature>`})
})
it('finds and removes the signature when no signature is clicked and there is a current signature', () => {
this.draft = 'draft body<signature>Remove me</signature>'
ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'only-item'))
this.noSignature = ReactTestUtils.findRenderedDOMComponentWithClass(this.button, 'item-none')
ReactTestUtils.Simulate.mouseDown(this.noSignature)
expect(this.button.props.session.changes.add).toHaveBeenCalledWith({body: `${this.button.props.draft.body}<br><br><signature></signature>`})
})
})
})

View file

@ -1,26 +1,23 @@
import {Message} from 'nylas-exports';
import {Message, SignatureStore} from 'nylas-exports';
import SignatureComposerExtension from '../lib/signature-composer-extension';
import SignatureStore from '../lib/signature-store';
const TEST_SIGNATURE = '<div class="something">This is my signature.</div>';
const TEST_ID = 1
const TEST_SIGNATURE = {
id: TEST_ID,
title: 'test-sig',
body: '<div class="something">This is my signature.</div>',
}
const TEST_SIGNATURES = {}
TEST_SIGNATURES[TEST_ID] = TEST_SIGNATURE
describe('SignatureComposerExtension', function signatureComposerExtension() {
describe("applyTransformsToDraft", () => {
it("should unwrap the signature and remove the custom DOM element", () => {
const a = new Message({
draft: true,
accountId: TEST_ACCOUNT_ID,
body: `This is a test! <signature>${TEST_SIGNATURE}<br/></signature><div class="gmail_quote">Hello world</div>`,
});
const out = SignatureComposerExtension.applyTransformsToDraft({draft: a});
expect(out.body).toEqual(`This is a test! <!-- <signature> -->${TEST_SIGNATURE}<br/><!-- </signature> --><div class="gmail_quote">Hello world</div>`);
});
});
describe("prepareNewDraft", () => {
describe("when a signature is defined", () => {
beforeEach(() => {
spyOn(NylasEnv.config, 'get').andCallFake(() => TEST_SIGNATURE);
spyOn(NylasEnv.config, 'get').andCallFake(() => TEST_SIGNATURES);
spyOn(SignatureStore, 'signatureForAccountId').andReturn(TEST_SIGNATURE)
SignatureStore.activate()
});
it("should insert the signature at the end of the message or before the first quoted text block and have a newline", () => {
@ -36,31 +33,31 @@ describe('SignatureComposerExtension', function signatureComposerExtension() {
});
SignatureComposerExtension.prepareNewDraft({draft: a});
expect(a.body).toEqual(`This is a test! <signature>${TEST_SIGNATURE}<br/></signature><div class="gmail_quote">Hello world</div>`);
expect(a.body).toEqual(`This is a test! <signature>${TEST_SIGNATURE.body}</signature><div class="gmail_quote">Hello world</div>`);
SignatureComposerExtension.prepareNewDraft({draft: b});
expect(b.body).toEqual(`This is a another test.<br/><br/><signature>${TEST_SIGNATURE}</signature>`);
expect(b.body).toEqual(`This is a another test.<br><br><signature>${TEST_SIGNATURE.body}</signature>`);
});
const scenarios = [
{
name: 'With blockquote',
body: `This is a test! <signature><div>SIG</div></signature><div class="gmail_quote">Hello world</div>`,
expected: `This is a test! <signature>${TEST_SIGNATURE}<br/></signature><div class="gmail_quote">Hello world</div>`,
expected: `This is a test! <signature>${TEST_SIGNATURE.body}</signature><div class="gmail_quote">Hello world</div>`,
},
{
name: 'Populated signature div',
body: `This is a test! <signature><br/><br/><div>SIG</div></signature>`,
expected: `This is a test! <br/><br/><signature>${TEST_SIGNATURE}</signature>`,
body: `This is a test! <signature><div>SIG</div></signature>`,
expected: `This is a test! <signature>${TEST_SIGNATURE.body}</signature>`,
},
{
name: 'Empty signature div',
body: 'This is a test! <signature></signature>',
expected: `This is a test! <br/><br/><signature>${TEST_SIGNATURE}</signature>`,
expected: `This is a test! <signature>${TEST_SIGNATURE.body}</signature>`,
},
{
name: 'With newlines',
body: 'This is a test!<br/> <signature>\n<br>\n<div>SIG</div>\n</signature>',
expected: `This is a test!<br/> <br/><br/><signature>${TEST_SIGNATURE}</signature>`,
expected: `This is a test!<br/> <signature>${TEST_SIGNATURE.body}</signature>`,
},
]
@ -76,38 +73,5 @@ describe('SignatureComposerExtension', function signatureComposerExtension() {
});
});
});
describe("when no signature is present in the config file", () => {
beforeEach(() => {
spyOn(NylasEnv.config, 'get').andCallFake(() => undefined);
});
it("should insert the default signature", () => {
const a = new Message({
draft: true,
accountId: TEST_ACCOUNT_ID,
body: 'This is a test! <div class="gmail_quote">Hello world</div>',
});
SignatureComposerExtension.prepareNewDraft({draft: a});
expect(a.body).toEqual(`This is a test! <signature>${SignatureStore.DefaultSignature}<br/></signature><div class="gmail_quote">Hello world</div>`);
});
});
describe("when a blank signature is present in the config file", () => {
beforeEach(() => {
spyOn(NylasEnv.config, 'get').andCallFake(() => "");
});
it("should insert nothing", () => {
const a = new Message({
draft: true,
accountId: TEST_ACCOUNT_ID,
body: 'This is a test! <div class="gmail_quote">Hello world</div>',
});
SignatureComposerExtension.prepareNewDraft({draft: a});
expect(a.body).toEqual(`This is a test! <div class="gmail_quote">Hello world</div>`);
});
});
});
});

View file

@ -0,0 +1,62 @@
/* eslint quote-props: 0 */
import {SignatureStore} from 'nylas-exports'
let SIGNATURES = {
'1': {
id: '1',
title: 'one',
body: 'first test signature!',
},
'2': {
id: '2',
title: 'two',
body: 'Here is my second sig!',
},
}
const DEFAULTS = {
11: '2',
22: '2',
33: null,
}
describe('SignatureStore', function signatureStore() {
beforeEach(() => {
spyOn(NylasEnv.config, 'get').andCallFake(() => SIGNATURES)
spyOn(SignatureStore, '_saveSignatures').andCallFake(() => {
NylasEnv.config.set(`nylas.signatures`, SignatureStore.signatures)
})
spyOn(SignatureStore, 'signatureForAccountId').andCallFake((accountId) => SIGNATURES[DEFAULTS[accountId]])
spyOn(SignatureStore, 'selectedSignature').andCallFake(() => SIGNATURES['1'])
SignatureStore.activate()
})
describe('signatureForAccountId', () => {
it('should return the default signature for that account', () => {
const titleForAccount11 = SignatureStore.signatureForAccountId(11).title
expect(titleForAccount11).toEqual(SIGNATURES['2'].title)
const account22Def = SignatureStore.signatureForAccountId(33)
expect(account22Def).toEqual(undefined)
})
})
describe('removeSignature', () => {
beforeEach(() => {
spyOn(NylasEnv.config, 'set').andCallFake((notImportant, newObject) => {
SIGNATURES = newObject
})
})
it('should remove the signature from our list of signatures', () => {
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId]
SignatureStore._onRemoveSignature(toRemove)
expect(SIGNATURES['1']).toEqual(undefined)
})
it('should reset selectedSignatureId to a different signature', () => {
const toRemove = SIGNATURES[SignatureStore.selectedSignatureId]
SignatureStore._onRemoveSignature(toRemove)
expect(SignatureStore.selectedSignatureId).toNotEqual('1')
})
})
})

View file

@ -1,74 +1,188 @@
@import "ui-variables";
.container-signatures {
@blurred-primary-color: mix(@background-primary, #ffbb00, 96%);
// Styles for Preferences Signatures
.preferences-signatures-container {
max-width: 640px;
margin: 0 auto;
.empty-list {
height: 300px;
width: inherit;
background-color: @background-primary;
border: 1px solid @border-color-divider;
text-align: center;
padding: 60px;
.icon-signature {
height: 120px;
}
h2 {
color: @text-color-very-subtle;
}
.btn {
margin-top: 10px;
}
}
.signature-list {
position: relative;
height: inherit;
width: inherit;
.items-wrapper {
min-width:200px;
height: 262px;
}
.item-rule-disabled {
color: @color-error;
padding: 4px 10px;
border-bottom: 1px solid @border-color-divider;
}
.selected .item-rule-disabled {
color: @component-active-bg;
}
.editable-item {
padding: 8px 10px;
border-bottom: none;
}
.btn-editable-list {
height: 37px;
width: 37px;
line-height: 37px;
}
}
.signature-wrap {
position: relative;
border: 1px solid @input-border-color;
background-color: @white;
padding: 10px;
margin-top: 20px;
min-height: 200px;
height: 300px;
display: flex;
flex-direction: column;
width: 100%;
border-left: none;
textarea {
padding: 10px;
border: 0;
padding: 0;
flex: 1;
font-family: monospace;
font-size: 0.9em;
}
}
.section-body {
padding: 10px 0 0 0;
.menu {
border: solid thin #CCC;
margin-right: 5px;
min-height: 200px;
.menu-items {
margin:0;
padding:0;
list-style: none;
li { padding: 6px; }
}
.contenteditable {
padding: 10px;
}
.menu-horizontal {
height: 100%;
.menu-items {
height:100%;
margin: 0;
padding: 0;
list-style: none;
li {
text-align:center;
width:40px;
display:inline-block;
padding:8px 16px 8px 16px;
border-right: solid thin #CCC;
.editable-toolbar {
border-top: 1px solid @input-border-color;
padding: 6px 10px;
.render-mode {
float: right;
}
.account-picker {
display: inline-block;
color: @text-color-subtle;
.button-dropdown {
.secondary-items {
.menu {
.item {
padding: 5px 10px 5px 25px;
}
.item.checked {
background-image: url(images/menu/osx-checkmark@2x.svg);
background-position: left;
background-position-x: 5%;
background-size: 10px;
padding-right: 0;
}
}
}
}
}
}
.signature-area {
border: solid thin #CCC;
min-height: 200px;
}
.menu-footer {
border: solid thin #CCC;
overflow: auto;
}
.signature-footer {
border: solid thin #CCC;
overflow: auto;
.edit-html-button {
float: right;
margin: 6px;
}
}
// Styles for account-contact-field signature selector
.message-item-white-wrap.composer-outer-wrap{
.composer-participant-field {
.dropdown-component {
display: inline-block;
float: right;
.signature-button-dropdown {
.only-item {
background: @blurred-primary-color;
box-shadow: none;
padding-right: 0;
}
img.content-mask {
display: none;
}
img.signature-button{
display: inline-block;
background-color: #919191;
}
.button-dropdown {
&.open {
img.signature-button {
background-color: @component-active-color;
}
}
.secondary-items {
right:0;
left:auto;
.menu {
.header-container {
display:inline-block;
padding: 0;
}
.footer-container {
display: inline-block;
border-top: 1px solid #dddddd;
}
.item {
padding: 5px 20px 5px 20px;
}
.item.checked {
background-image: url(images/menu/osx-checkmark@2x.svg);
background-position: left;
background-position-x: 5%;
background-size: 10px;
}
}
}
}
}
}
}
&.focused {
.composer-participant-field {
.dropdown-component {
.only-item {
background: @background-primary;
}
}
}
}

View file

@ -1,8 +1,9 @@
import React from 'react';
import classnames from 'classnames';
import {AccountStore} from 'nylas-exports';
import {Menu, ButtonDropdown} from 'nylas-component-kit';
import {
AccountStore,
} from 'nylas-exports';
import {Menu, ButtonDropdown, InjectedComponentSet} from 'nylas-component-kit';
export default class AccountContactField extends React.Component {
static displayName = 'AccountContactField';
@ -10,11 +11,14 @@ export default class AccountContactField extends React.Component {
static propTypes = {
value: React.PropTypes.object,
accounts: React.PropTypes.array.isRequired,
session: React.PropTypes.object.isRequired,
draft: React.PropTypes.object.isRequired,
onChange: React.PropTypes.func.isRequired,
};
_onChooseContact = (contact) => {
this.props.onChange({from: [contact]});
this.props.session.ensureCorrectAccount()
this.refs.dropdown.toggleDropdown();
}
@ -72,11 +76,29 @@ export default class AccountContactField extends React.Component {
);
}
_renderFromFieldComponents = () => {
const {draft, session, accounts} = this.props
return (
<InjectedComponentSet
className="dropdown-component"
matching={{role: "Composer:FromFieldComponents"}}
exposedProps={{
draft,
session,
accounts,
currentAccount: draft.from[0],
}}
/>
)
}
render() {
return (
<div className="composer-participant-field">
<div className="composer-field-label">From:</div>
{this._renderAccountSelector()}
{this._renderFromFieldComponents()}
</div>
);
}

View file

@ -3,7 +3,12 @@ import React from 'react';
import ReactDOM from 'react-dom';
import AccountContactField from './account-contact-field';
import {Utils, DraftHelpers, Actions, AccountStore} from 'nylas-exports';
import {InjectedComponent, KeyCommandsRegion, ParticipantsTextField, ListensToFluxStore} from 'nylas-component-kit';
import {
InjectedComponent,
KeyCommandsRegion,
ParticipantsTextField,
ListensToFluxStore,
} from 'nylas-component-kit';
import CollapsedParticipants from './collapsed-participants';
import ComposerHeaderActions from './composer-header-actions';
@ -270,6 +275,7 @@ export default class ComposerHeader extends React.Component {
key="from"
ref={Fields.From}
draft={this.props.draft}
session={this.props.session}
onChange={this._onChangeParticipants}
value={from[0]}
/>

View file

@ -27,7 +27,6 @@ class PreferencesTabItem extends React.Component {
render() {
const {tabId, displayName} = this.props.tabItem;
const classes = classNames({
item: true,
active: tabId === this.props.selection.get('tabId'),

View file

@ -139,6 +139,42 @@ describe('EditableList', function editableList() {
});
});
describe('_onDeleteItem', () => {
let onSelectItem;
let onDeleteItem;
beforeEach(() => {
onSelectItem = jasmine.createSpy('onSelectItem');
onDeleteItem = jasmine.createSpy('onDeleteItem');
})
it('deletes the item from the list', () => {
const list = makeList(['1', '2'], {selected: '2', onDeleteItem, onSelectItem});
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
Simulate.click(button);
expect(onDeleteItem).toHaveBeenCalledWith('2', 1);
})
it('sets the selected item to the one above if it exists', () => {
const list = makeList(['1', '2'], {selected: '2', onDeleteItem, onSelectItem});
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
Simulate.click(button);
expect(onSelectItem).toHaveBeenCalledWith('1', 0)
})
it('sets the selected item to the one below if it is at the top', () => {
const list = makeList(['1', '2'], {selected: '1', onDeleteItem, onSelectItem});
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
Simulate.click(button);
expect(onSelectItem).toHaveBeenCalledWith('2', 1)
})
it('sets the selected item to nothing when you delete the last item', () => {
const list = makeList(['1'], {selected: '1', onDeleteItem, onSelectItem});
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
Simulate.click(button);
expect(onSelectItem).not.toHaveBeenCalled()
})
})
describe('_renderItem', () => {
const makeItem = (item, idx, state = {}, handlers = {}) => {
const list = makeList([], {initialState: state});
@ -231,18 +267,13 @@ describe('EditableList', function editableList() {
});
it('renders delete button', () => {
const onSelectItem = jasmine.createSpy('onSelectItem');
const onDeleteItem = jasmine.createSpy('onDeleteItem');
const list = makeList(['1', '2'], {selected: '2', onDeleteItem, onSelectItem});
const list = makeList(['1', '2'], {selected: '2'});
const button = scryRenderedDOMComponentsWithClass(list, 'btn-editable-list')[1];
Simulate.click(button);
expect(findDOMNode(button).textContent).toEqual('—');
expect(onDeleteItem).toHaveBeenCalledWith('2', 1);
});
it('disables teh delete button when no item is selected', () => {
it('disables the delete button when no item is selected', () => {
const onSelectItem = jasmine.createSpy('onSelectItem');
const onDeleteItem = jasmine.createSpy('onDeleteItem');
const list = makeList(['1', '2'], {selected: null, onDeleteItem, onSelectItem});

View file

@ -0,0 +1,25 @@
import React from 'react'
import {
scryRenderedDOMComponentsWithClass,
Simulate,
} from 'react-addons-test-utils';
import MultiselectDropdown from '../../src/components/multiselect-dropdown'
import {renderIntoDocument} from '../nylas-test-utils'
const makeDropdown = (items = [], props = {}) => {
return renderIntoDocument(<MultiselectDropdown {...props} items={items} />)
}
describe('MultiselectDropdown', function multiSelectedDropdown() {
describe('_onItemClick', () => {
it('calls onToggleItem function', () => {
const onToggleItem = jasmine.createSpy('onToggleItem')
const itemChecked = jasmine.createSpy('itemChecked')
const dropdown = makeDropdown(["annie@nylas.com", "anniecook@ostby.com"], {onToggleItem, itemChecked})
dropdown.setState({selectingItems: true})
const item = scryRenderedDOMComponentsWithClass(dropdown, 'item')[0]
Simulate.mouseDown(item)
expect(onToggleItem).toHaveBeenCalled()
})
})
})

View file

@ -262,13 +262,10 @@ class EditableList extends Component {
_onDeleteItem = () => {
const selectedItem = this._getSelectedItem();
const index = this.props.items.indexOf(selectedItem);
if (selectedItem) {
// Move the selection 1 up after deleting
const len = this.props.items.length;
const newIndex = Math.min(len - 2, Math.max(0, index + 1));
// Move the selection 1 up or down after deleting
const newIndex = index === 0 ? index + 1 : index - 1
this.props.onDeleteItem(selectedItem, index);
if (this.props.items[newIndex]) {
this._selectItem(this.props.items[newIndex], newIndex);
}

View file

@ -0,0 +1,81 @@
import React, {Component, PropTypes} from 'react';
import {ButtonDropdown, Menu} from 'nylas-component-kit'
import ReactDOM from 'react-dom';
/**
Renders a drop down of items that can have multiple selected
Item can be string or object
@param {object} props - props for MultiselectDropdown
@param {string} props.className - css class applied to the component
@param {array} props.items - items to be rendered in the dropdown
@param {props.itemChecked} - props.itemChecked -- a function to determine if the item should be checked or not
@param {props.onToggleItem} - props.onToggleItem -- function called when an item is clicked
@param {props.itemKey} - props.itemKey -- function that indicates how to select the key for each MenuItem
@param {props.buttonText} - props.buttonText -- string to be rendered in the button
**/
class MultiselectDropdown extends Component {
static displayName = 'MultiselectDropdown'
static propTypes = {
className: PropTypes.string,
items: PropTypes.array.isRequired,
itemChecked: PropTypes.func,
onToggleItem: PropTypes.func,
itemKey: PropTypes.func,
buttonText: PropTypes.string,
}
static defaultProps = {
className: '',
items: [],
itemChecked: {},
onToggleItem: () => {},
itemKey: () => {},
buttonText: '',
}
componentDidUpdate() {
if (ReactDOM.findDOMNode(this.refs.select)) {
ReactDOM.findDOMNode(this.refs.select).focus()
}
}
_onItemClick = (item) => {
this.props.onToggleItem(item.id)
}
_renderItem = (item) => {
const MenuItem = Menu.Item
return (
<MenuItem onMouseDown={() => this._onItemClick(item)} checked={this.props.itemChecked(item)} key={this.props.itemKey(item)} content={item.label} />
)
}
_renderMenu= (items) => {
return (
<Menu
items={items}
itemContent={this._renderItem}
itemKey={item => item.id}
onSelect={() => {}}
/>
)
}
render() {
const {items} = this.props
const menu = this._renderMenu(items)
return (
<ButtonDropdown
className={'btn-multiselect'}
primaryItem={<span>{this.props.buttonText}</span>}
menu={menu}
/>
)
}
}
export default MultiselectDropdown

View file

@ -546,6 +546,15 @@ class Actions
@nextSearchResult: ActionScopeWindow
@previousSearchResult: ActionScopeWindow
# Actions for the signature preferences and shared with the composer
@addSignature: ActionScopeWindow
@removeSignature: ActionScopeWindow
@updateSignature: ActionScopeWindow
@selectSignature: ActionScopeWindow
@toggleAccount: ActionScopeWindow
# Read the actions we declared on the dummy Actions object above
# and translate them into Reflux Actions

View file

@ -0,0 +1,118 @@
import {Utils, Actions} from 'nylas-exports';
import NylasStore from 'nylas-store'
import _ from 'underscore'
const DefaultSignature = "Sent from <a href=\"https://nylas.com/n1?ref=n1\">Nylas N1</a>, the extensible, open source mail client.";
class SignatureStore extends NylasStore {
activate() {
this.unsubscribers = [
Actions.addSignature.listen(this._onAddSignature),
Actions.removeSignature.listen(this._onRemoveSignature),
Actions.updateSignature.listen(this._onEditSignature),
Actions.selectSignature.listen(this._onSelectSignature),
Actions.toggleAccount.listen(this._onToggleAccount),
];
NylasEnv.config.onDidChange(`nylas.signatures`, () => {
this.signatures = NylasEnv.config.get(`nylas.signatures`)
this.trigger()
})
NylasEnv.config.onDidChange(`nylas.defaultSignatures`, () => {
this.defaultSignatures = NylasEnv.config.get(`nylas.defaultSignatures`)
this.trigger()
})
this.signatures = NylasEnv.config.get(`nylas.signatures`) || {}
this.selectedSignatureId = this._setSelectedSignatureId()
this.defaultSignatures = NylasEnv.config.get(`nylas.defaultSignatures`) || {}
this.trigger()
}
deactivate() {
this.unsubscribers.forEach(unsub => unsub());
}
getSignatures() {
return this.signatures;
}
selectedSignature() {
return this.signatures[this.selectedSignatureId]
}
getDefaults() {
return this.defaultSignatures
}
signatureForAccountId = (accountId) => {
return this.signatures[this.defaultSignatures[accountId]]
}
_saveSignatures() {
_.debounce(NylasEnv.config.set(`nylas.signatures`, this.signatures), 500)
}
_saveDefaultSignatures() {
_.debounce(NylasEnv.config.set(`nylas.defaultSignatures`, this.defaultSignatures), 500)
}
_onSelectSignature = (id) => {
this.selectedSignatureId = id
this.trigger()
}
_removeByKey = (obj, keyToDelete) => {
return Object.keys(obj)
.filter(key => key !== keyToDelete)
.reduce((result, current) => {
result[current] = obj[current];
return result;
}, {})
}
_setSelectedSignatureId() {
const sigIds = Object.keys(this.signatures)
if (sigIds.length) {
return sigIds[0]
}
return null
}
_onRemoveSignature = (signatureToDelete) => {
this.signatures = this._removeByKey(this.signatures, signatureToDelete.id)
this.selectedSignatureId = this._setSelectedSignatureId()
this.trigger()
this._saveSignatures()
}
_onAddSignature = (sigTitle = "Untitled") => {
const newId = Utils.generateTempId()
this.signatures[newId] = {id: newId, title: sigTitle, body: DefaultSignature}
this.selectedSignatureId = newId
this.trigger()
this._saveSignatures()
}
_onEditSignature = (editedSig, oldSigId) => {
this.signatures[oldSigId].title = editedSig.title
this.signatures[oldSigId].body = editedSig.body
this.trigger()
this._saveSignatures()
}
_onToggleAccount = (accountId) => {
if (this.defaultSignatures[accountId] === this.selectedSignatureId) {
this.defaultSignatures[accountId] = null
} else {
this.defaultSignatures[accountId] = this.selectedSignatureId
}
this.trigger()
this._saveDefaultSignatures()
}
}
export default new SignatureStore();

View file

@ -43,6 +43,7 @@ class NylasComponentKit
@load "ButtonDropdown", 'button-dropdown'
@load "Contenteditable", 'contenteditable/contenteditable'
@load "MultiselectList", 'multiselect-list'
@load "MultiselectDropdown", "multiselect-dropdown"
@load "KeyCommandsRegion", 'key-commands-region'
@load "TabGroupRegion", 'tab-group-region'
@load "InjectedComponent", 'injected-component'

View file

@ -120,6 +120,7 @@ class NylasExports
@lazyLoadAndRegisterStore "OutboxStore", 'outbox-store'
@lazyLoadAndRegisterStore "PopoverStore", 'popover-store'
@lazyLoadAndRegisterStore "AccountStore", 'account-store'
@lazyLoadAndRegisterStore "SignatureStore", 'signature-store'
@lazyLoadAndRegisterStore "MessageStore", 'message-store'
@lazyLoadAndRegisterStore "ContactStore", 'contact-store'
@lazyLoadAndRegisterStore "IdentityStore", 'identity-store'

View file

@ -7,6 +7,7 @@
position: relative;
color: @text-color;
font-size: @font-size;
padding-bottom: 15px;
div[contenteditable], .contenteditable {
flex: 1;

View file

@ -69,10 +69,6 @@
padding-right: 10%;
}
.item.selected.checked, .item.checked:hover {
background-image:url(./images/menu/checked-selected@2x.png);
}
.item.selected, .item:active {
text-decoration: none;
background-color: @accent-primary;

View file

@ -0,0 +1,15 @@
@import "ui-variables";
.nylas-multiselect-dropdown {
display: inline-block;
.button-dropdown, .menu, .secondary-items{
.content-container {
background: @dropdown-default-bg-color;
border-radius: 3px;
.item {
padding: 5px 10px 5px 20px;
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="17.91" height="19.594" viewBox="0 0 17.91 19.594">
<defs>
<style>
.cls-1 {
fill: #444;
fill-rule: evenodd;
}
</style>
</defs>
<path id="osx-checkmark_2x.svg" data-name="osx-checkmark@2x.svg" class="cls-1" d="M1135.13,290.9a2.162,2.162,0,0,0,.6,2.1,20.22,20.22,0,0,1,4.29,5.009,18.224,18.224,0,0,0,1.62,2.844l0.5,0.126s1.7-4.595,3.72-9.165a32.745,32.745,0,0,1,6.35-9.237,2.354,2.354,0,0,0,.77-0.761s-2.06-.251-3.01-0.38a2.922,2.922,0,0,0-2.15.385c-1.12.472-3.4,4.39-3.88,6.008s-2.37,5.03-2.34,6.35-1.6-2.881-2.84-3.527S1135.13,290.9,1135.13,290.9Z" transform="translate(-1135.06 -281.375)"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -39,3 +39,4 @@
@import "components/time-picker";
@import "components/table";
@import "components/editable-table";
@import "components/multiselect-dropdown";