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"