diff --git a/package.json b/package.json index 54eef29ab..6ec8f1113 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "reactstrap": "^9.1.5", "styled-jsx": "^3.3.2", "text-mask-addons": "^3.8.0", + "tributejs": "^5.1.3", "use-deep-compare-effect": "^1.8.1", "use-local-storage-state": "^4.0.0", "uuid": "^8.3.1" diff --git a/src/components/Note/EditableNoteMentions.spec.js b/src/components/Note/EditableNoteMentions.spec.js new file mode 100644 index 000000000..778a154af --- /dev/null +++ b/src/components/Note/EditableNoteMentions.spec.js @@ -0,0 +1,220 @@ +import assert from 'assert'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { mount } from 'enzyme'; +import React from 'react'; +import sinon from 'sinon'; +import FormLabelGroup from '../Form/FormLabelGroup'; +import Input from '../Input/Input'; +import EditableNoteMentions from './EditableNoteMentions'; + +describe('', () => { + const note = { + text: 'Hello World!', + }; + let component; + let props; + + beforeEach(() => { + const onCancel = sinon.spy(); + const onChange = sinon.spy(); + const onSave = sinon.spy(); + props = { note, onCancel, onChange, onSave }; + }); + + describe('rendering', () => { + it('should render a card', () => { + component = mount(); + const card = component.find('Card'); + + assert(card.exists); + assert.equal(1, card.length); + }); + + describe('in default mode', () => { + beforeEach(() => { + component = mount(); + }); + + describe('should render a text input', () => { + it('should render with text', () => { + const input = component.find(Input); + + assert(input.exists()); + assert.equal(1, input.length); + assert.equal(note.text, input.text()); + assert(!input.props().disabled); + }); + + it('should call onChange on text change', () => { + const event = { target: { value: 'Yikes!' } }; + component.find(Input).simulate('change', event); + assert(props.onChange.calledOnce); + assert(props.onChange.calledWith(sinon.match(event), note)); + }); + }); + + describe('with mentionable users', () => { + it('should show mentionable user dropdown on @ trigger', () => { + const mentionableUsers = [ + { + key: 'John Doe', + value: 'John.Doe', + email: 'john.doe@appfolio.com', + }, + { + key: 'Mike Smith', + value: 'Mike.Smith', + email: 'mike.smith@appfolio.com', + }, + ]; + props.mentionableUsers = mentionableUsers; + props.note = 'Hey '; + render(); + + const input = screen.getByRole('textbox'); + userEvent.type(input, '@'); + + expect(screen.getByText('John Doe')).toBeTruthy(); + expect(screen.getByText('Mike Smith')).toBeTruthy(); + }); + }); + + describe('should render a cancel button', () => { + it('should call onCancel on click', () => { + component.find('.js-editable-note_cancel').hostNodes().simulate('click'); + assert(props.onCancel.calledOnce); + assert(props.onCancel.calledWith(note)); + }); + }); + + describe('should render a save button', () => { + it('should render with text', () => { + const button = component.find('.js-editable-note_save').hostNodes(); + + assert(button); + assert(!button.props().disabled); + }); + + it('should render with custom text', () => { + const wrapper = mount(); + const button = wrapper.find('.js-editable-note_save').hostNodes(); + + assert.equal(button.text(), 'Send'); + }); + + it('should call onSave on click', () => { + component.find('.js-editable-note_save').hostNodes().simulate('click'); + assert(props.onSave.calledOnce); + assert(props.onSave.calledWith(note)); + }); + }); + }); + + describe('in default mode with children', () => { + const text = 'This is some additional text to be rendered'; + + beforeEach(() => { + component = mount( + + {text} + + ); + }); + + it('renders children', () => { + const children = component.find('.js-editable-note__child'); + assert.equal(1, children.length); + assert.equal(text, children.text()); + }); + + it('should still render input area', () => { + const input = component.find(Input); + + assert(input.exists); + assert.equal(1, input.length); + }); + + it('should still render cancel button', () => { + const button = component.find('.js-editable-note_cancel').hostNodes(); + + assert(button.exists); + }); + + it('should still render save button', () => { + const button = component.find('.js-editable-note_save'); + + assert(button.exists); + }); + }); + + describe('with errors', () => { + beforeEach(() => { + props.note.errors = 'oh snap!'; + component = mount(); + }); + + it('should render form group with errors', () => { + const group = component.find(FormLabelGroup); + + assert(group.exists); + assert.equal(1, group.length); + assert.equal('oh snap!', group.props().feedback); + }); + + it('should render input state as invalid', () => { + const input = component.find(Input); + + assert.equal(input.props().invalid, true); + }); + }); + + describe('in saving mode', () => { + beforeEach(() => { + component = mount(); + }); + + it('should render text input disabled', () => { + const input = component.find(Input); + + assert(input.exists); + assert(input.props().disabled); + }); + + it('should render cancel button disabled', () => { + const button = component.find('.js-editable-note_cancel').hostNodes(); + + assert(button.exists); + assert(button.props().disabled); + }); + + it('should render save button disabled with alternate text', () => { + const button = component.find('.js-editable-note_save').hostNodes(); + + assert(button.exists); + assert(button.props().disabled); + assert.equal('Saving...', button.text()); + }); + + it('should render save button disabled with custom text', () => { + const wrapper = mount(); + const button = wrapper.find('.js-editable-note_save').hostNodes(); + + assert(button.exists); + assert(button.props().disabled); + assert.equal('Sending...', button.text()); + }); + }); + + describe('if note has a date', () => { + it('should render a NoteHeader', () => { + props.note.date = new Date(); + component = mount(); + const header = component.find('NoteHeader'); + + assert.equal(1, header.length); + assert.equal(note, header.props().note); + }); + }); + }); +}); diff --git a/src/components/Note/EditableNoteMentions.tsx b/src/components/Note/EditableNoteMentions.tsx new file mode 100644 index 000000000..44bb96631 --- /dev/null +++ b/src/components/Note/EditableNoteMentions.tsx @@ -0,0 +1,175 @@ +import React, { FC, RefObject, useEffect, useRef } from 'react'; +import Tribute, { TributeItem } from 'tributejs'; +import Button from '../Button/Button'; +import ButtonToolbar from '../Button/ButtonToolbar'; +import Card from '../Card/Card'; +import CardBody from '../Card/CardBody'; +import FormLabelGroup from '../Form/FormLabelGroup'; +import Input from '../Input/Input'; +import { Note } from './Note.types'; +import NoteHeader from './NoteHeader'; + +type MentionableUser = { + key: string; + value: string; + email: string; +}; + +function injectTribute( + mentionableUsers: MentionableUser[], + ref: RefObject +) { + if (mentionableUsers.length > 0) { + if (ref.current) { + const tribute = new Tribute({ + values: mentionableUsers, + menuItemTemplate(item: TributeItem) { + return `${item.string}${item.original.email}`; + }, + noMatchTemplate: () => '', + selectClass: 'note__mention-highlight', + allowSpaces: true, + }); + tribute.attach(ref.current); + } + } +} + +type EditableNoteMentionsProps = { + children?: React.ReactNode; + className?: string; + dateFormat?: string; + showTimezone?: boolean; + mentionableUsers: MentionableUser[] | undefined; + note: Note; + onCancel: (note: Note) => void; + onChange: (ev: React.ChangeEvent, note: Note) => void; + onSave: (note: Note) => void; + rows?: number; + saving?: boolean; + saveLabel?: React.ReactNode; + savingLabel?: React.ReactNode; +}; + +export const EditableNoteMentionsDefaultProps = { + className: 'bg-white mb-3', + dateFormat: 'ddd, MMMM D, YYYY "at" h:mm A', + mentionableUsers: [], + rows: 4, + saving: false, + saveLabel: 'Save', + savingLabel: 'Saving...', + showTimezone: true, +}; + +const EditableNoteMentions: FC = ({ + className = EditableNoteMentionsDefaultProps.className, + dateFormat = EditableNoteMentionsDefaultProps.dateFormat, + mentionableUsers = EditableNoteMentionsDefaultProps.mentionableUsers, + rows = EditableNoteMentionsDefaultProps.rows, + saving = EditableNoteMentionsDefaultProps.saving, + saveLabel = EditableNoteMentionsDefaultProps.saveLabel, + savingLabel = EditableNoteMentionsDefaultProps.savingLabel, + showTimezone = EditableNoteMentionsDefaultProps.showTimezone, + ...props +}) => { + const { children, note, onCancel, onChange, onSave } = props; + const { errors, text } = note; + const ref = useRef(null); + + useEffect(() => { + injectTribute(mentionableUsers, ref); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const mentionStyles = () => ( + + ); + + return ( + + + + + onChange(event, note)} + /> + {mentionStyles()} + + {children} + + + + + + + ); +}; + +EditableNoteMentions.defaultProps = EditableNoteMentionsDefaultProps; +EditableNoteMentions.displayName = 'EditableNoteMentions'; +export default EditableNoteMentions; diff --git a/src/components/Note/Note.stories.js b/src/components/Note/Note.stories.js index 6612ec54a..619406514 100644 --- a/src/components/Note/Note.stories.js +++ b/src/components/Note/Note.stories.js @@ -1,7 +1,8 @@ import { action } from '@storybook/addon-actions'; import { boolean, number, text } from '@storybook/addon-knobs'; -import React from 'react'; +import React, { useState } from 'react'; import EditableNote from './EditableNote'; +import EditableNoteMentions from './EditableNoteMentions'; import Note from './Note'; const noteToEdit = { @@ -107,3 +108,49 @@ export const EditableNoteWithChildren = () => { ); }; + +export const EditableNoteMentionsExample = () => { + const [note, setNote] = useState({ + date: new Date(), + from: 'Tom Brady', + text: '', + }); + + const mentionableUsers = [ + { + key: 'Satoshi Nakamoto', + value: 'Satoshi.Nakamoto', + email: 'satoshi@appfolio.com', + }, + { + key: 'LeBron James', + value: 'LeBron.James', + email: 'lebron.james@appfolio.com', + }, + { + key: 'Barbra Streisand', + value: 'Barbra.Streisand', + email: 'barbra.streisand@appfolio.com', + }, + { + key: 'Barry Bonds', + value: 'Barry.Bonds', + email: 'barry.bonds@appfolio.com', + }, + ]; + + const onNoteChange = (e) => { + setNote({ ...note, text: e.target.value }); + }; + + return ( + + ); +}; diff --git a/yarn.lock b/yarn.lock index 554609650..64bad7c95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -152,6 +152,7 @@ __metadata: sinon: ^9.2.1 styled-jsx: ^3.3.2 text-mask-addons: ^3.8.0 + tributejs: ^5.1.3 ts-node: ^10.7.0 typescript: ^4.6.3 uncontrollable: ^4.1.0 @@ -17083,6 +17084,13 @@ __metadata: languageName: node linkType: hard +"tributejs@npm:^5.1.3": + version: 5.1.3 + resolution: "tributejs@npm:5.1.3" + checksum: 06110d74c69a8229ffcee73c180536b7c7f644397074fbe471da696b144a8a3583c6821b3c0f889a681cd475fcb83033a31a5bfa6cf95e675ec97ef99cc72288 + languageName: node + linkType: hard + "trim-newlines@npm:^1.0.0": version: 1.0.0 resolution: "trim-newlines@npm:1.0.0"