Commit 4d8ca3af authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Add new `AnnotationEditor` component

This component will take over the editing-related responsibilities from
`AnnotationBody` and `Annotation`
parent 69c50358
import { createElement } from 'preact';
import { useState } from 'preact/hooks';
import propTypes from 'prop-types';
import { normalizeKeyName } from '../../shared/browser-compatibility-utils';
import { withServices } from '../util/service-context';
import { applyTheme } from '../util/theme';
import useStore from '../store/use-store';
import AnnotationLicense from './annotation-license';
import AnnotationPublishControl from './annotation-publish-control';
import MarkdownEditor from './markdown-editor';
import TagEditor from './tag-editor';
/**
* @typedef {import("../../types/api").Annotation} Annotation
* @typedef {import("../../types/config").MergedConfig} MergedConfig
*/
/**
* @typedef AnnotationEditorProps
* @prop {Annotation} annotation - The annotation under edit
* @prop {Object} annotationsService - Injected service
* @prop {MergedConfig} settings - Injected service
* @prop {Object} toastMessenger - Injected service
* @prop {Object} tags - Injected service
*/
/**
* Display annotation content in an editable format.
*
* @param {AnnotationEditorProps} props
*/
function AnnotationEditor({
annotation,
annotationsService,
settings,
tags: tagsService,
toastMessenger,
}) {
// Track the currently-entered text in the tag editor's input
const [pendingTag, setPendingTag] = useState(
/** @type {string|null} */ (null)
);
const draft = useStore(store => store.getDraft(annotation));
const createDraft = useStore(store => store.createDraft);
const group = useStore(store => store.getGroup(annotation.group));
if (!draft) {
// If there's no draft, we can't be in editing mode
return null;
}
const shouldShowLicense =
!draft.isPrivate && group && group.type !== 'private';
const tags = draft.tags;
const text = draft.text;
const isEmpty = !text && !tags.length;
const onEditTags = ({ tags }) => {
createDraft(draft.annotation, { ...draft, tags });
};
/**
* Verify `newTag` has content and is not a duplicate; add the tag
*
* @param {string} newTag
* @return {boolean} - `true` if tag is added
*/
const onAddTag = newTag => {
if (!newTag || tags.indexOf(newTag) >= 0) {
// don't add empty or duplicate tags
return false;
}
const tagList = [...tags, newTag];
// Update the tag locally for the suggested-tag list
tagsService.store(tagList.map(tag => ({ text: tag })));
onEditTags({ tags: tagList });
return true;
};
/**
* Remove a tag from the annotation.
*
* @param {string} tag
* @return {boolean} - `true` if tag extant and removed
*/
const onRemoveTag = tag => {
const newTagList = [...tags]; // make a copy
const index = newTagList.indexOf(tag);
if (index >= 0) {
newTagList.splice(index, 1);
onEditTags({ tags: newTagList });
return true;
}
return false;
};
const onEditText = ({ text }) => {
createDraft(draft.annotation, { ...draft, text });
};
const onSave = async () => {
// If there is any content in the tag editor input field that has
// not been committed as a tag, go ahead and add it as a tag
// See https://github.com/hypothesis/product-backlog/issues/1122
if (pendingTag) {
onAddTag(pendingTag);
}
try {
await annotationsService.save(annotation);
} catch (err) {
toastMessenger.error('Saving annotation failed');
}
};
// Allow saving of annotation by pressing CMD/CTRL-Enter
const onKeyDown = event => {
const key = normalizeKeyName(event.key);
if (isEmpty) {
return;
}
if ((event.metaKey || event.ctrlKey) && key === 'Enter') {
event.stopPropagation();
event.preventDefault();
onSave();
}
};
const textStyle = applyTheme(['annotationFontFamily'], settings);
return (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
<div className="annotation-editor u-vertical-rhythm" onKeyDown={onKeyDown}>
<MarkdownEditor
textStyle={textStyle}
label="Annotation body"
text={text}
onEditText={onEditText}
/>
<TagEditor
onAddTag={onAddTag}
onRemoveTag={onRemoveTag}
onTagInput={setPendingTag}
tagList={tags}
/>
<div className="annotation__form-actions u-layout-row">
<AnnotationPublishControl
annotation={annotation}
isDisabled={isEmpty}
onSave={onSave}
/>
</div>
{shouldShowLicense && <AnnotationLicense />}
</div>
);
}
AnnotationEditor.propTypes = {
annotation: propTypes.object.isRequired,
annotationsService: propTypes.object,
settings: propTypes.object,
tags: propTypes.object.isRequired,
toastMessenger: propTypes.object,
};
AnnotationEditor.injectedProps = [
'annotationsService',
'settings',
'tags',
'toastMessenger',
];
export default withServices(AnnotationEditor);
import { mount } from 'enzyme';
import { createElement } from 'preact';
import { act } from 'preact/test-utils';
import * as fixtures from '../../test/annotation-fixtures';
import { waitFor } from '../../../test-util/wait';
import AnnotationEditor from '../annotation-editor';
import { $imports } from '../annotation-editor';
import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components';
describe('AnnotationEditor', () => {
let fakeApplyTheme;
let fakeAnnotationsService;
let fakeTagsService;
let fakeSettings;
let fakeToastMessenger;
let fakeStore;
function createComponent(props = {}) {
return mount(
<AnnotationEditor
annotation={fixtures.defaultAnnotation()}
annotationsService={fakeAnnotationsService}
settings={fakeSettings}
tags={fakeTagsService}
toastMessenger={fakeToastMessenger}
{...props}
/>
);
}
beforeEach(() => {
fakeApplyTheme = sinon.stub();
fakeAnnotationsService = {
save: sinon.stub(),
};
fakeSettings = {};
fakeTagsService = {
store: sinon.stub(),
};
fakeToastMessenger = {
error: sinon.stub(),
};
fakeStore = {
createDraft: sinon.stub(),
getDraft: sinon.stub().returns(fixtures.defaultDraft()),
getGroup: sinon.stub(),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
'../util/theme': { applyTheme: fakeApplyTheme },
});
});
afterEach(() => {
$imports.$restore();
});
it('is an empty component if there is no draft', () => {
fakeStore.getDraft.returns(null);
const wrapper = createComponent();
assert.isEmpty(wrapper);
});
describe('markdown content editor', () => {
it('applies theme', () => {
const textStyle = { fontFamily: 'serif' };
fakeApplyTheme
.withArgs(['annotationFontFamily'], fakeSettings)
.returns(textStyle);
const wrapper = createComponent();
assert.deepEqual(
wrapper.find('MarkdownEditor').prop('textStyle'),
textStyle
);
});
it('updates draft text on edit callback', () => {
const wrapper = createComponent();
const editor = wrapper.find('MarkdownEditor');
act(() => {
editor.props().onEditText({ text: 'updated text' });
});
const call = fakeStore.createDraft.getCall(0);
assert.calledOnce(fakeStore.createDraft);
assert.equal(call.args[1].text, 'updated text');
});
describe('tag editing', () => {
it('adds tag when add callback invoked', () => {
const wrapper = createComponent();
wrapper.find('TagEditor').props().onAddTag('newTag');
const storeCall = fakeTagsService.store.getCall(0);
const draftCall = fakeStore.createDraft.getCall(0);
assert.deepEqual(storeCall.args[0], [{ text: 'newTag' }]);
assert.deepEqual(draftCall.args[1].tags, ['newTag']);
});
it('does not add duplicate tags', () => {
const draft = fixtures.defaultDraft();
draft.tags = ['newTag'];
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
wrapper.find('TagEditor').props().onAddTag('newTag');
assert.equal(fakeTagsService.store.callCount, 0);
assert.equal(fakeStore.createDraft.callCount, 0);
});
it('removes tag when remove callback invoked', () => {
const draft = fixtures.defaultDraft();
draft.tags = ['newTag'];
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
wrapper.find('TagEditor').props().onRemoveTag('newTag');
const draftCall = fakeStore.createDraft.getCall(0);
assert.equal(fakeTagsService.store.callCount, 0);
assert.deepEqual(draftCall.args[1].tags, []);
});
it('does not remove non-existent tags', () => {
const draft = fixtures.defaultDraft();
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
wrapper.find('TagEditor').props().onRemoveTag('newTag');
assert.equal(fakeTagsService.store.callCount, 0);
assert.equal(fakeStore.createDraft.callCount, 0);
});
});
describe('saving the annotation', () => {
it('saves the annotation when save callback invoked', async () => {
const annotation = fixtures.defaultAnnotation();
const wrapper = createComponent({ annotation });
await wrapper.find('AnnotationPublishControl').props().onSave();
assert.calledOnce(fakeAnnotationsService.save);
assert.calledWith(fakeAnnotationsService.save, annotation);
});
it('checks for unsaved tags on save', async () => {
const wrapper = createComponent();
// Simulate "typing in" some tag text into the tag editor
wrapper.find('TagEditor').props().onTagInput('foobar');
wrapper.update();
await act(
async () =>
await wrapper.find('AnnotationPublishControl').props().onSave()
);
const draftCall = fakeStore.createDraft.getCall(0);
assert.equal(fakeTagsService.store.callCount, 1);
assert.equal(fakeStore.createDraft.callCount, 1);
assert.deepEqual(draftCall.args[1].tags, ['foobar']);
});
it('shows a toast message on error', async () => {
fakeAnnotationsService.save.throws();
const wrapper = createComponent();
fakeAnnotationsService.save.rejects();
wrapper.find('AnnotationPublishControl').props().onSave();
await waitFor(() => fakeToastMessenger.error.called);
});
it('should save annotation if `CTRL+Enter` is typed', () => {
const draft = fixtures.defaultDraft();
// Need some content so that it won't evaluate as "empty" and not save
draft.text = 'something is here';
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
wrapper
.find('.annotation-editor')
.simulate('keydown', { key: 'Enter', ctrlKey: true });
assert.calledOnce(fakeAnnotationsService.save);
assert.calledWith(
fakeAnnotationsService.save,
wrapper.props().annotation
);
});
it('should save annotation if `META+Enter` is typed', () => {
const draft = fixtures.defaultDraft();
// Need some content so that it won't evaluate as "empty" and not save
draft.text = 'something is here';
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
wrapper
.find('.annotation-editor')
.simulate('keydown', { key: 'Enter', metaKey: true });
assert.calledOnce(fakeAnnotationsService.save);
assert.calledWith(
fakeAnnotationsService.save,
wrapper.props().annotation
);
});
it('should not save annotation if `META+Enter` is typed but annotation empty', () => {
const wrapper = createComponent();
wrapper
.find('.annotation-editor')
.simulate('keydown', { key: 'Enter', metaKey: true });
assert.notCalled(fakeAnnotationsService.save);
});
});
it('sets the publish control to disabled if the annotation is empty', () => {
// default draft has no tags or text
const wrapper = createComponent();
assert.isTrue(
wrapper.find('AnnotationPublishControl').props().isDisabled
);
});
it('enables the publish control when annotation has content', () => {
const draft = fixtures.defaultDraft();
draft.text = 'something is here';
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
assert.isFalse(
wrapper.find('AnnotationPublishControl').props().isDisabled
);
});
it('shows license info if annotation is shared and in a public group', () => {
fakeStore.getGroup.returns({ type: 'open' });
const wrapper = createComponent();
assert.isTrue(wrapper.find('AnnotationLicense').exists());
});
it('does not show license info if annotation is only-me', () => {
const draft = fixtures.defaultDraft();
draft.isPrivate = true;
fakeStore.getGroup.returns({ type: 'open' });
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
assert.isFalse(wrapper.find('AnnotationLicense').exists());
});
it('does not show license if annotation is in a private group', () => {
fakeStore.getGroup.returns({ type: 'private' });
const wrapper = createComponent();
assert.isFalse(wrapper.find('AnnotationLicense').exists());
});
});
it(
'should pass a11y checks',
checkAccessibility([
{
// AnnotationEditor is primarily a container and state-managing component;
// a11y should be more deeply checked on the leaf components
content: () => createComponent(),
},
])
);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment