Commit 67a1e52b authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Remove editing logic from `AnnotationBody`, `Annotation`

Consolidate annotation-editing logic and sub-components within
`AnnotationEditor`. `AnnotationBody` is for rendering content, and
`Annotation` is relieved of managing editing-related callbacks.

These changes also add a new feature: When saving an annotation,
`AnnotationEditor` will check the contents of `TagEditor`'s input
element. If any text remains there that has not yet bet "committed"
as a tag, that text will be added as a tag. This is to prevent tags
accidentally getting "lost" on save.

Fixes https://github.com/hypothesis/product-backlog/issues/1122
parent 4d8ca3af
...@@ -8,9 +8,7 @@ import { applyTheme } from '../util/theme'; ...@@ -8,9 +8,7 @@ import { applyTheme } from '../util/theme';
import Button from './button'; import Button from './button';
import Excerpt from './excerpt'; import Excerpt from './excerpt';
import MarkdownEditor from './markdown-editor';
import MarkdownView from './markdown-view'; import MarkdownView from './markdown-view';
import TagEditor from './tag-editor';
import TagList from './tag-list'; import TagList from './tag-list';
/** /**
...@@ -21,13 +19,6 @@ import TagList from './tag-list'; ...@@ -21,13 +19,6 @@ import TagList from './tag-list';
/** /**
* @typedef AnnotationBodyProps * @typedef AnnotationBodyProps
* @prop {Annotation} annotation - The annotation in question * @prop {Annotation} annotation - The annotation in question
* @prop {boolean} [isEditing] - Whether to display the body in edit mode (if true) or view mode.
* @prop {(a: Object<'tags', string[]>) => void} [onEditTags] - Callback invoked when the user edits tags.
* @prop {(a?: Object<'text', string>) => void} [onEditText] - Callback invoked when the user edits the content of the annotation body.
* @prop {string[]} tags
* @prop {string} text -
* The markdown annotation body, which is either rendered as HTML (if `isEditing`
* is false) or displayed in a text area otherwise.
* @prop {MergedConfig} settings * @prop {MergedConfig} settings
*/ */
...@@ -36,15 +27,7 @@ import TagList from './tag-list'; ...@@ -36,15 +27,7 @@ import TagList from './tag-list';
* *
* @param {AnnotationBodyProps} props * @param {AnnotationBodyProps} props
*/ */
function AnnotationBody({ function AnnotationBody({ annotation, settings }) {
annotation,
isEditing,
onEditTags,
onEditText,
tags,
text,
settings,
}) {
// Should the text content of `Excerpt` be rendered in a collapsed state, // Should the text content of `Excerpt` be rendered in a collapsed state,
// assuming it is collapsible (exceeds allotted collapsed space)? // assuming it is collapsible (exceeds allotted collapsed space)?
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
...@@ -54,8 +37,10 @@ function AnnotationBody({ ...@@ -54,8 +37,10 @@ function AnnotationBody({
const [isCollapsible, setIsCollapsible] = useState(false); const [isCollapsible, setIsCollapsible] = useState(false);
const toggleText = isCollapsed ? 'More' : 'Less'; const toggleText = isCollapsed ? 'More' : 'Less';
const showExcerpt = !isEditing && text.length > 0; const tags = annotation.tags;
const showTagList = !isEditing && tags.length > 0; const text = annotation.text;
const showExcerpt = text.length > 0;
const showTagList = tags.length > 0;
const textStyle = applyTheme(['annotationFontFamily'], settings); const textStyle = applyTheme(['annotationFontFamily'], settings);
...@@ -80,15 +65,7 @@ function AnnotationBody({ ...@@ -80,15 +65,7 @@ function AnnotationBody({
/> />
</Excerpt> </Excerpt>
)} )}
{isEditing && ( {isCollapsible && (
<MarkdownEditor
textStyle={textStyle}
label="Annotation body"
text={text}
onEditText={onEditText}
/>
)}
{isCollapsible && !isEditing && (
<div className="annotation-body__collapse-toggle"> <div className="annotation-body__collapse-toggle">
{/* @ts-ignore - TODO: Button props need to be fixed */} {/* @ts-ignore - TODO: Button props need to be fixed */}
<Button <Button
...@@ -104,18 +81,12 @@ function AnnotationBody({ ...@@ -104,18 +81,12 @@ function AnnotationBody({
</div> </div>
)} )}
{showTagList && <TagList annotation={annotation} tags={tags} />} {showTagList && <TagList annotation={annotation} tags={tags} />}
{isEditing && <TagEditor onEditTags={onEditTags} tagList={tags} />}
</div> </div>
); );
} }
AnnotationBody.propTypes = { AnnotationBody.propTypes = {
annotation: propTypes.object.isRequired, annotation: propTypes.object.isRequired,
isEditing: propTypes.bool,
onEditTags: propTypes.func,
onEditText: propTypes.func,
tags: propTypes.array.isRequired,
text: propTypes.string,
settings: propTypes.object, settings: propTypes.object,
}; };
......
...@@ -2,17 +2,14 @@ import classnames from 'classnames'; ...@@ -2,17 +2,14 @@ import classnames from 'classnames';
import { createElement } from 'preact'; import { createElement } from 'preact';
import propTypes from 'prop-types'; import propTypes from 'prop-types';
import { normalizeKeyName } from '../../shared/browser-compatibility-utils';
import useStore from '../store/use-store'; import useStore from '../store/use-store';
import { isReply, quote } from '../util/annotation-metadata'; import { isReply, quote } from '../util/annotation-metadata';
import { isShared } from '../util/permissions';
import { withServices } from '../util/service-context'; import { withServices } from '../util/service-context';
import AnnotationActionBar from './annotation-action-bar'; import AnnotationActionBar from './annotation-action-bar';
import AnnotationBody from './annotation-body'; import AnnotationBody from './annotation-body';
import AnnotationEditor from './annotation-editor';
import AnnotationHeader from './annotation-header'; import AnnotationHeader from './annotation-header';
import AnnotationLicense from './annotation-license';
import AnnotationPublishControl from './annotation-publish-control';
import AnnotationQuote from './annotation-quote'; import AnnotationQuote from './annotation-quote';
import Button from './button'; import Button from './button';
...@@ -28,7 +25,6 @@ import Button from './button'; ...@@ -28,7 +25,6 @@ import Button from './button';
* @prop {boolean} showDocumentInfo - Should extended document info be rendered (e.g. in non-sidebar contexts)? * @prop {boolean} showDocumentInfo - Should extended document info be rendered (e.g. in non-sidebar contexts)?
* @prop {boolean} threadIsCollapsed - Is the thread to which this annotation belongs currently collapsed? * @prop {boolean} threadIsCollapsed - Is the thread to which this annotation belongs currently collapsed?
* @prop {Object} annotationsService - Injected service * @prop {Object} annotationsService - Injected service
* @prop {Object} toastMessenger - Injected service
*/ */
/** /**
...@@ -42,9 +38,7 @@ function Annotation({ ...@@ -42,9 +38,7 @@ function Annotation({
replyCount, replyCount,
showDocumentInfo, showDocumentInfo,
threadIsCollapsed, threadIsCollapsed,
toastMessenger,
}) { }) {
const createDraft = useStore(store => store.createDraft);
const isFocused = useStore(store => const isFocused = useStore(store =>
store.isAnnotationFocused(annotation.$tag) store.isAnnotationFocused(annotation.$tag)
); );
...@@ -52,17 +46,12 @@ function Annotation({ ...@@ -52,17 +46,12 @@ function Annotation({
// An annotation will have a draft if it is being edited // An annotation will have a draft if it is being edited
const draft = useStore(store => store.getDraft(annotation)); const draft = useStore(store => store.getDraft(annotation));
const group = useStore(store => store.getGroup(annotation.group));
const userid = useStore(store => store.profile().userid); const userid = useStore(store => store.profile().userid);
const isSaving = useStore(store => store.isSavingAnnotation(annotation)); const isSaving = useStore(store => store.isSavingAnnotation(annotation));
const isCollapsedReply = isReply(annotation) && threadIsCollapsed; const isCollapsedReply = isReply(annotation) && threadIsCollapsed;
const isPrivate = draft ? draft.isPrivate : !isShared(annotation.permissions);
const tags = draft ? draft.tags : annotation.tags;
const text = draft ? draft.text : annotation.text;
const hasQuote = !!quote(annotation); const hasQuote = !!quote(annotation);
const isEmpty = !text && !tags.length;
const isEditing = !!draft && !isSaving; const isEditing = !!draft && !isSaving;
...@@ -70,55 +59,22 @@ function Annotation({ ...@@ -70,55 +59,22 @@ function Annotation({
const toggleText = `${toggleAction} (${replyCount})`; const toggleText = `${toggleAction} (${replyCount})`;
const shouldShowActions = !isSaving && !isEditing; const shouldShowActions = !isSaving && !isEditing;
const shouldShowLicense =
isEditing && !isPrivate && group && group.type !== 'private';
const shouldShowReplyToggle = replyCount > 0 && !isReply(annotation); const shouldShowReplyToggle = replyCount > 0 && !isReply(annotation);
const onEditTags = ({ tags }) => {
createDraft(annotation, { ...draft, tags });
};
const onEditText = ({ text }) => {
createDraft(annotation, { ...draft, text });
};
const onReply = () => annotationsService.reply(annotation, userid); const onReply = () => annotationsService.reply(annotation, userid);
const onSave = async () => {
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 || !isEditing) {
return;
}
if ((event.metaKey || event.ctrlKey) && key === 'Enter') {
event.stopPropagation();
event.preventDefault();
onSave();
}
};
const onToggleReplies = () => const onToggleReplies = () =>
// nb. We assume the annotation has an ID here because it is not possible // nb. We assume the annotation has an ID here because it is not possible
// to create replies until the annotation has been saved. // to create replies until the annotation has been saved.
setExpanded(/** @type {string} */ (annotation.id), !!threadIsCollapsed); setExpanded(/** @type {string} */ (annotation.id), !!threadIsCollapsed);
return ( return (
/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */
<article <article
className={classnames('annotation', { className={classnames('annotation', {
'annotation--reply': isReply(annotation), 'annotation--reply': isReply(annotation),
'is-collapsed': threadIsCollapsed, 'is-collapsed': threadIsCollapsed,
'is-focused': isFocused, 'is-focused': isFocused,
})} })}
onKeyDown={onKeyDown}
> >
<AnnotationHeader <AnnotationHeader
annotation={annotation} annotation={annotation}
...@@ -132,27 +88,11 @@ function Annotation({ ...@@ -132,27 +88,11 @@ function Annotation({
<AnnotationQuote annotation={annotation} isFocused={isFocused} /> <AnnotationQuote annotation={annotation} isFocused={isFocused} />
)} )}
{!isCollapsedReply && ( {!isCollapsedReply && !isEditing && (
<AnnotationBody <AnnotationBody annotation={annotation} />
annotation={annotation}
isEditing={isEditing}
onEditTags={onEditTags}
onEditText={onEditText}
tags={tags}
text={text}
/>
)} )}
{isEditing && ( {isEditing && <AnnotationEditor annotation={annotation} />}
<div className="annotation__form-actions u-layout-row">
<AnnotationPublishControl
annotation={annotation}
isDisabled={isEmpty}
onSave={onSave}
/>
</div>
)}
{shouldShowLicense && <AnnotationLicense />}
{!isCollapsedReply && ( {!isCollapsedReply && (
<footer className="annotation__footer"> <footer className="annotation__footer">
...@@ -186,9 +126,8 @@ Annotation.propTypes = { ...@@ -186,9 +126,8 @@ Annotation.propTypes = {
showDocumentInfo: propTypes.bool.isRequired, showDocumentInfo: propTypes.bool.isRequired,
threadIsCollapsed: propTypes.bool.isRequired, threadIsCollapsed: propTypes.bool.isRequired,
annotationsService: propTypes.object.isRequired, annotationsService: propTypes.object.isRequired,
toastMessenger: propTypes.object.isRequired,
}; };
Annotation.injectedProps = ['annotationsService', 'toastMessenger']; Annotation.injectedProps = ['annotationsService'];
export default withServices(Annotation); export default withServices(Annotation);
...@@ -447,7 +447,7 @@ export default function MarkdownEditor({ ...@@ -447,7 +447,7 @@ export default function MarkdownEditor({
}; };
return ( return (
<div> <div className="markdown-editor">
<Toolbar <Toolbar
onCommand={handleCommand} onCommand={handleCommand}
isPreviewing={preview} isPreviewing={preview}
......
...@@ -17,9 +17,11 @@ let tagEditorIdCounter = 0; ...@@ -17,9 +17,11 @@ let tagEditorIdCounter = 0;
/** /**
* @typedef TagEditorProps * @typedef TagEditorProps
* @prop {(a: Object<'tags', string[]>) => any} onEditTags - Callback that saves the tag list. * @prop {(tag: string) => boolean} onAddTag - Callback to add a tag to the annotation
* @prop {string[]} tagList - The list of editable tags as strings. * @prop {(tag: string) => boolean} onRemoveTag - Callback to remove a tag from the annotation
* @prop {Object} tags - Services * @prop {(tag: string) => any} onTagInput - Callback when inputted tag text changes
* @prop {string[]} tagList - The list of tags for the annotation under edit
* @prop {Object} tags - Injected service
*/ */
/** /**
...@@ -30,7 +32,13 @@ let tagEditorIdCounter = 0; ...@@ -30,7 +32,13 @@ let tagEditorIdCounter = 0;
* *
* @param {TagEditorProps} props * @param {TagEditorProps} props
*/ */
function TagEditor({ onEditTags, tags: tagsService, tagList }) { function TagEditor({
onAddTag,
onRemoveTag,
onTagInput,
tagList,
tags: tagsService,
}) {
const inputEl = useRef(/** @type {HTMLInputElement|null} */ (null)); const inputEl = useRef(/** @type {HTMLInputElement|null} */ (null));
const [suggestions, setSuggestions] = useState(/** @type {string[]} */ ([])); const [suggestions, setSuggestions] = useState(/** @type {string[]} */ ([]));
const [activeItem, setActiveItem] = useState(-1); // -1 is unselected const [activeItem, setActiveItem] = useState(-1); // -1 is unselected
...@@ -49,6 +57,13 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -49,6 +57,13 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
const ie11 = useMemo(() => isIE11(), []); const ie11 = useMemo(() => isIE11(), []);
/**
* Retrieve the current trimmed text value of the tag <input>
*/
const pendingTag = () => inputEl.current.value.trim();
const hasPendingTag = () => pendingTag() && pendingTag().length > 0;
const clearPendingTag = () => (inputEl.current.value = '');
/** /**
* Helper function that returns a list of suggestions less any * Helper function that returns a list of suggestions less any
* results also found from the duplicates list. * results also found from the duplicates list.
...@@ -72,13 +87,12 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -72,13 +87,12 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
* reset the activeItem and open the AutocompleteList * reset the activeItem and open the AutocompleteList
*/ */
const updateSuggestions = () => { const updateSuggestions = () => {
const value = inputEl.current.value.trim(); if (!hasPendingTag()) {
if (value === '') {
// If there is no input, just hide the suggestions // If there is no input, just hide the suggestions
setSuggestionsListOpen(false); setSuggestionsListOpen(false);
} else { } else {
// Call filter() with a query value to return all matching suggestions. // Call filter() with a query value to return all matching suggestions.
const suggestions = tagsService.filter(value); const suggestions = tagsService.filter(pendingTag());
// Remove any repeated suggestions that are already tags // Remove any repeated suggestions that are already tags
// and set those to state. // and set those to state.
setSuggestions(removeDuplicates(suggestions, tagList)); setSuggestions(removeDuplicates(suggestions, tagList));
...@@ -88,51 +102,19 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -88,51 +102,19 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
}; };
/** /**
* Handle changes to this annotation's tags * Invokes callback to add tag. If the tag was added, close the suggestions
* * list, clear the field content and maintain focus.
* @param {string[]} tagList
*/
const updateTags = tagList => {
// update suggested tags list via service
tagsService.store(tagList.map(tag => ({ text: tag })));
onEditTags({ tags: tagList });
};
/**
* Remove a tag from this annotation.
*
* @param {string} tag
*/
const removeTag = tag => {
const newTagList = [...tagList]; // make a copy
const index = newTagList.indexOf(tag);
newTagList.splice(index, 1);
updateTags(newTagList);
};
/**
* Adds a tag to the annotation equal to the value of the input field
* and then clears out the suggestions list and the input field.
* *
* @param {string} newTag * @param {string} newTag
*/ */
const addTag = newTag => { const addTag = newTag => {
const value = newTag.trim(); if (onAddTag(newTag)) {
if (value.length === 0) { setSuggestionsListOpen(false);
// don't add an empty tag setActiveItem(-1);
return;
}
if (tagList.indexOf(value) >= 0) {
// don't add duplicate tag
return;
}
updateTags([...tagList, value]);
setSuggestionsListOpen(false);
setActiveItem(-1);
const input = inputEl.current; clearPendingTag();
input.value = ''; inputEl.current.focus();
input.focus(); }
}; };
/** /**
...@@ -141,6 +123,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -141,6 +123,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
* @param {import("preact").JSX.TargetedEvent<HTMLInputElement, InputEvent>} e * @param {import("preact").JSX.TargetedEvent<HTMLInputElement, InputEvent>} e
*/ */
const handleOnInput = e => { const handleOnInput = e => {
onTagInput?.(pendingTag());
if ( if (
e.inputType === 'insertText' || e.inputType === 'insertText' ||
e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentBackward' ||
...@@ -166,7 +149,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -166,7 +149,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
* Opens the AutocompleteList on focus if there is a value in the input * Opens the AutocompleteList on focus if there is a value in the input
*/ */
const handleFocus = () => { const handleFocus = () => {
if (inputEl.current.value.trim().length) { if (hasPendingTag()) {
setSuggestionsListOpen(true); setSuggestionsListOpen(true);
} }
}; };
...@@ -212,7 +195,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -212,7 +195,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
break; break;
case 'Escape': case 'Escape':
// Clear any entered text, but retain focus // Clear any entered text, but retain focus
inputEl.current.value = ''; clearPendingTag();
e.preventDefault(); e.preventDefault();
break; break;
case 'Enter': case 'Enter':
...@@ -220,7 +203,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -220,7 +203,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
// Commit a tag // Commit a tag
if (activeItem === -1) { if (activeItem === -1) {
// nothing selected, just add the typed text // nothing selected, just add the typed text
addTag(/** @type {HTMLInputElement} */ (inputEl.current).value); addTag(pendingTag());
} else { } else {
// Add the selected tag // Add the selected tag
addTag(suggestions[activeItem]); addTag(suggestions[activeItem]);
...@@ -230,7 +213,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -230,7 +213,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
case 'Tab': case 'Tab':
// Commit a tag, or tab out of the field if it is empty (default browser // Commit a tag, or tab out of the field if it is empty (default browser
// behavior) // behavior)
if (inputEl.current.value.trim() === '') { if (!hasPendingTag()) {
// If the tag field is empty, allow `Tab` to have its default // If the tag field is empty, allow `Tab` to have its default
// behavior: continue to the next element in tab order // behavior: continue to the next element in tab order
break; break;
...@@ -245,7 +228,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -245,7 +228,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
addTag(suggestions[0]); addTag(suggestions[0]);
} else { } else {
// Commit the tag as typed in the field // Commit the tag as typed in the field
addTag(/** @type {HTMLInputElement} */ (inputEl.current).value); addTag(pendingTag());
} }
// Retain focus // Retain focus
e.preventDefault(); e.preventDefault();
...@@ -262,7 +245,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -262,7 +245,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
* @return {JSXElement} - Formatted tag for use in list * @return {JSXElement} - Formatted tag for use in list
*/ */
const formatSuggestItem = item => { const formatSuggestItem = item => {
const curVal = inputEl.current.value.trim(); const curVal = pendingTag();
const prefix = item.slice(0, item.indexOf(curVal)); const prefix = item.slice(0, item.indexOf(curVal));
const suffix = item.slice(item.indexOf(curVal) + curVal.length); const suffix = item.slice(item.indexOf(curVal) + curVal.length);
...@@ -283,7 +266,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -283,7 +266,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
: ''; : '';
return ( return (
<section className="tag-editor"> <div className="tag-editor">
<ul <ul
className="tag-editor__tags" className="tag-editor__tags"
aria-label="Suggested tags for annotation" aria-label="Suggested tags for annotation"
...@@ -300,7 +283,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -300,7 +283,7 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
</span> </span>
<button <button
onClick={() => { onClick={() => {
removeTag(tag); onRemoveTag(tag);
}} }}
aria-label={`Remove Tag: ${tag}`} aria-label={`Remove Tag: ${tag}`}
title={`Remove Tag: ${tag}`} title={`Remove Tag: ${tag}`}
...@@ -348,12 +331,14 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) { ...@@ -348,12 +331,14 @@ function TagEditor({ onEditTags, tags: tagsService, tagList }) {
activeItem={activeItem} activeItem={activeItem}
/> />
</span> </span>
</section> </div>
); );
} }
TagEditor.propTypes = { TagEditor.propTypes = {
onEditTags: propTypes.func.isRequired, onAddTag: propTypes.func.isRequired,
onRemoveTag: propTypes.func.isRequired,
onTagInput: propTypes.func,
tagList: propTypes.array.isRequired, tagList: propTypes.array.isRequired,
tags: propTypes.object.isRequired, tags: propTypes.object.isRequired,
}; };
......
...@@ -11,17 +11,14 @@ import { checkAccessibility } from '../../../test-util/accessibility'; ...@@ -11,17 +11,14 @@ import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components'; import mockImportedComponents from '../../../test-util/mock-imported-components';
describe('AnnotationBody', () => { describe('AnnotationBody', () => {
let fakeAnnotation;
let fakeApplyTheme; let fakeApplyTheme;
let fakeSettings; let fakeSettings;
function createBody(props = {}) { function createBody(props = {}) {
return mount( return mount(
<AnnotationBody <AnnotationBody
annotation={fixtures.defaultAnnotation()} annotation={fakeAnnotation}
isEditing={false}
onEditTags={() => null}
tags={[]}
text="test comment"
settings={fakeSettings} settings={fakeSettings}
{...props} {...props}
/> />
...@@ -29,6 +26,9 @@ describe('AnnotationBody', () => { ...@@ -29,6 +26,9 @@ describe('AnnotationBody', () => {
} }
beforeEach(() => { beforeEach(() => {
fakeAnnotation = fixtures.defaultAnnotation();
fakeAnnotation.text = 'some text here';
fakeAnnotation.tags = ['eenie', 'minie'];
fakeApplyTheme = sinon.stub(); fakeApplyTheme = sinon.stub();
fakeSettings = {}; fakeSettings = {};
...@@ -42,24 +42,12 @@ describe('AnnotationBody', () => { ...@@ -42,24 +42,12 @@ describe('AnnotationBody', () => {
$imports.$restore(); $imports.$restore();
}); });
it('displays the body if `isEditing` is false', () => {
const wrapper = createBody({ isEditing: false });
assert.isFalse(wrapper.exists('MarkdownEditor'));
assert.isTrue(wrapper.exists('MarkdownView'));
});
it('displays an editor if `isEditing` is true', () => {
const wrapper = createBody({ isEditing: true });
assert.isTrue(wrapper.exists('MarkdownEditor'));
assert.isFalse(wrapper.exists('MarkdownView'));
});
it('does not render controls to expand/collapse the excerpt if it is not collapsible', () => { it('does not render controls to expand/collapse the excerpt if it is not collapsible', () => {
const wrapper = createBody(); const wrapper = createBody();
// By default, `isCollapsible` is `false` until changed by `Excerpt`, // By default, `isCollapsible` is `false` until changed by `Excerpt`,
// so the expand/collapse button will not render // so the expand/collapse button will not render
assert.notOk(wrapper.find('Button').exists()); assert.isFalse(wrapper.find('Button').exists());
}); });
it('renders controls to expand/collapse the excerpt if it is collapsible', () => { it('renders controls to expand/collapse the excerpt if it is collapsible', () => {
...@@ -109,42 +97,32 @@ describe('AnnotationBody', () => { ...@@ -109,42 +97,32 @@ describe('AnnotationBody', () => {
}); });
describe('tag list and editor', () => { describe('tag list and editor', () => {
it('renders a list of tags if not editing and annotation has tags', () => { it('renders a list of tags if annotation has tags', () => {
const wrapper = createBody({ isEditing: false, tags: ['foo', 'bar'] }); const wrapper = createBody();
assert.isTrue(wrapper.find('TagList').exists()); assert.isTrue(wrapper.find('TagList').exists());
}); });
it('does not render a tag list if annotation has no tags', () => { it('does not render a tag list if annotation has no tags', () => {
const wrapper = createBody({ isEditing: false, tags: [] }); const wrapper = createBody({ annotation: fixtures.defaultAnnotation() });
assert.isFalse(wrapper.find('TagList').exists()); assert.isFalse(wrapper.find('TagList').exists());
}); });
it('renders a tag editor if annotation is being edited', () => { it('applies theme', () => {
const wrapper = createBody({ isEditing: true, tags: ['foo', 'bar'] }); const textStyle = { fontFamily: 'serif' };
fakeApplyTheme
assert.isTrue(wrapper.find('TagEditor').exists()); .withArgs(['annotationFontFamily'], fakeSettings)
assert.isFalse(wrapper.find('TagList').exists()); .returns(textStyle);
const wrapper = createBody();
assert.deepEqual(
wrapper.find('MarkdownView').prop('textStyle'),
textStyle
);
}); });
}); });
it('applies theme', () => {
const textStyle = { fontFamily: 'serif' };
fakeApplyTheme
.withArgs(['annotationFontFamily'], fakeSettings)
.returns(textStyle);
const wrapper = createBody();
assert.deepEqual(wrapper.find('MarkdownView').prop('textStyle'), textStyle);
wrapper.setProps({ isEditing: true });
assert.deepEqual(
wrapper.find('MarkdownEditor').prop('textStyle'),
textStyle
);
});
it( it(
'should pass a11y checks', 'should pass a11y checks',
checkAccessibility([ checkAccessibility([
...@@ -153,14 +131,14 @@ describe('AnnotationBody', () => { ...@@ -153,14 +131,14 @@ describe('AnnotationBody', () => {
}, },
{ {
name: 'when annotation has tags (tag list)', name: 'when annotation has tags (tag list)',
content: () => createBody({ isEditing: false, tags: ['foo', 'bar'] }), content: () => {
}, const annotation = fixtures.defaultAnnotation();
{ annotation.tags = ['foo', 'bar'];
name: 'when annotation is being edited and has tags', return createBody({ annotation });
content: () => createBody({ isEditing: true, tags: ['foo', 'bar'] }), },
}, },
{ {
name: 'when expandable and not editing', name: 'when expandable',
content: () => { content: () => {
const wrapper = createBody(); const wrapper = createBody();
act(() => { act(() => {
......
...@@ -6,7 +6,6 @@ import * as fixtures from '../../test/annotation-fixtures'; ...@@ -6,7 +6,6 @@ import * as fixtures from '../../test/annotation-fixtures';
import { checkAccessibility } from '../../../test-util/accessibility'; import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components'; import mockImportedComponents from '../../../test-util/mock-imported-components';
import { waitFor } from '../../../test-util/wait';
import Annotation from '../annotation'; import Annotation from '../annotation';
import { $imports } from '../annotation'; import { $imports } from '../annotation';
...@@ -14,11 +13,9 @@ import { $imports } from '../annotation'; ...@@ -14,11 +13,9 @@ import { $imports } from '../annotation';
describe('Annotation', () => { describe('Annotation', () => {
// Dependency Mocks // Dependency Mocks
let fakeMetadata; let fakeMetadata;
let fakePermissions;
// Injected dependency mocks // Injected dependency mocks
let fakeAnnotationsService; let fakeAnnotationsService;
let fakeToastMessenger;
let fakeStore; let fakeStore;
const setEditingMode = (isEditing = true) => { const setEditingMode = (isEditing = true) => {
...@@ -35,7 +32,6 @@ describe('Annotation', () => { ...@@ -35,7 +32,6 @@ describe('Annotation', () => {
<Annotation <Annotation
annotation={fixtures.defaultAnnotation()} annotation={fixtures.defaultAnnotation()}
annotationsService={fakeAnnotationsService} annotationsService={fakeAnnotationsService}
toastMessenger={fakeToastMessenger}
replyCount={0} replyCount={0}
showDocumentInfo={false} showDocumentInfo={false}
threadIsCollapsed={true} threadIsCollapsed={true}
...@@ -50,25 +46,13 @@ describe('Annotation', () => { ...@@ -50,25 +46,13 @@ describe('Annotation', () => {
save: sinon.stub().resolves(), save: sinon.stub().resolves(),
}; };
fakeToastMessenger = {
error: sinon.stub(),
};
fakeMetadata = { fakeMetadata = {
isReply: sinon.stub(), isReply: sinon.stub(),
quote: sinon.stub(), quote: sinon.stub(),
}; };
fakePermissions = {
isShared: sinon.stub().returns(true),
};
fakeStore = { fakeStore = {
createDraft: sinon.stub(),
getDraft: sinon.stub().returns(null), getDraft: sinon.stub().returns(null),
getGroup: sinon.stub().returns({
type: 'private',
}),
isAnnotationFocused: sinon.stub().returns(false), isAnnotationFocused: sinon.stub().returns(false),
isSavingAnnotation: sinon.stub().returns(false), isSavingAnnotation: sinon.stub().returns(false),
profile: sinon.stub().returns({ userid: 'acct:foo@bar.com' }), profile: sinon.stub().returns({ userid: 'acct:foo@bar.com' }),
...@@ -78,7 +62,6 @@ describe('Annotation', () => { ...@@ -78,7 +62,6 @@ describe('Annotation', () => {
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
'../util/annotation-metadata': fakeMetadata, '../util/annotation-metadata': fakeMetadata,
'../util/permissions': fakePermissions,
'../store/use-store': callback => callback(fakeStore), '../store/use-store': callback => callback(fakeStore),
}); });
}); });
...@@ -141,211 +124,12 @@ describe('Annotation', () => { ...@@ -141,211 +124,12 @@ describe('Annotation', () => {
}); });
}); });
describe('annotation body and excerpt', () => { it('should show a "Saving" message when annotation is saving', () => {
it('updates annotation draft when text edited', () => { fakeStore.isSavingAnnotation.returns(true);
const wrapper = createComponent();
const body = wrapper.find('AnnotationBody');
act(() => {
body.props().onEditText({ text: 'updated text' });
});
const call = fakeStore.createDraft.getCall(0); const wrapper = createComponent();
assert.calledOnce(fakeStore.createDraft);
assert.equal(call.args[1].text, 'updated text');
});
});
describe('publish control', () => {
it('should show the publish control if in edit mode', () => {
setEditingMode(true);
const wrapper = createComponent();
assert.isTrue(wrapper.find('AnnotationPublishControl').exists());
});
it('should not show the publish control if not in edit mode', () => {
setEditingMode(false);
const wrapper = createComponent();
assert.isFalse(wrapper.find('AnnotationPublishControl').exists()); assert.include(wrapper.find('.annotation__actions').text(), 'Saving...');
});
it('should enable the publish control if the annotation is not empty', () => {
const draft = fixtures.defaultDraft();
draft.text = 'bananas';
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
assert.isFalse(
wrapper.find('AnnotationPublishControl').props().isDisabled
);
});
it('should set the publish control to disabled if annotation is empty', () => {
const draft = fixtures.defaultDraft();
draft.tags = [];
draft.text = '';
fakeStore.getDraft.returns(draft);
const wrapper = createComponent();
assert.isTrue(
wrapper.find('AnnotationPublishControl').props().isDisabled
);
});
context('saving an annotation', () => {
it('should save the annotation when the publish control invokes the `onSave` callback', () => {
setEditingMode(true);
const wrapper = createComponent();
wrapper.find('AnnotationPublishControl').props().onSave();
assert.calledWith(
fakeAnnotationsService.save,
wrapper.props().annotation
);
});
it('should show a "Saving" message when annotation is saving', () => {
setEditingMode(true);
fakeStore.isSavingAnnotation.returns(true);
const wrapper = createComponent();
assert.include(
wrapper.find('.annotation__actions').text(),
'Saving...'
);
});
it('should show an error message on failure', async () => {
setEditingMode(true);
fakeAnnotationsService.save.rejects();
const wrapper = createComponent();
wrapper.find('AnnotationPublishControl').props().onSave();
await waitFor(() => fakeToastMessenger.error.called);
});
describe('saving using shortcut-key combo', () => {
context('in editing mode with text or tag content populated', () => {
beforeEach(() => {
// Put into editing mode by presence of draft, and add some `text`
// so that the annotation is not seen as "empty"
const draft = fixtures.defaultDraft();
draft.text = 'bananas';
fakeStore.getDraft.returns(draft);
});
it('should save annotation if `CTRL+Enter` is typed', () => {
const wrapper = createComponent();
wrapper
.find('.annotation')
.simulate('keydown', { key: 'Enter', ctrlKey: true });
assert.calledWith(
fakeAnnotationsService.save,
wrapper.props().annotation
);
});
it('should save annotation if `META+Enter` is typed', () => {
const wrapper = createComponent();
wrapper
.find('.annotation')
.simulate('keydown', { key: 'Enter', metaKey: true });
assert.calledWith(
fakeAnnotationsService.save,
wrapper.props().annotation
);
});
it('should not save annotation if `META+g` is typed', () => {
// i.e. don't save on non-"Enter" keys
const wrapper = createComponent();
wrapper
.find('.annotation')
.simulate('keydown', { key: 'g', metaKey: true });
assert.notCalled(fakeAnnotationsService.save);
});
});
context('empty or not in editing mode', () => {
it('should not save annotation if not in editing mode', () => {
fakeStore.getDraft.returns(null);
const wrapper = createComponent();
wrapper
.find('.annotation')
.simulate('keydown', { key: 'Enter', metaKey: true });
assert.notCalled(fakeAnnotationsService.save);
});
it('should not save annotation if content is empty', () => {
fakeStore.getDraft.returns(fixtures.defaultDraft());
const wrapper = createComponent();
wrapper
.find('.annotation')
.simulate('keydown', { key: 'Enter', ctrlKey: true });
assert.notCalled(fakeAnnotationsService.save);
});
});
});
});
});
describe('license information', () => {
it('should show license information when editing shared annotations in public groups', () => {
fakeStore.getGroup.returns({ type: 'open' });
setEditingMode(true);
const wrapper = createComponent();
assert.isTrue(wrapper.find('AnnotationLicense').exists());
});
it('should not show license information when not editing', () => {
fakeStore.getGroup.returns({ type: 'open' });
setEditingMode(false);
const wrapper = createComponent();
assert.isFalse(wrapper.find('AnnotationLicense').exists());
});
it('should not show license information for annotations in private groups', () => {
fakeStore.getGroup.returns({ type: 'private' });
setEditingMode(true);
const wrapper = createComponent();
assert.isFalse(wrapper.find('AnnotationLicense').exists());
});
it('should not show license information for private annotations', () => {
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());
});
}); });
describe('reply thread toggle button', () => { describe('reply thread toggle button', () => {
......
...@@ -4,6 +4,13 @@ ...@@ -4,6 +4,13 @@
@use "../../mixins/utils"; @use "../../mixins/utils";
@use "../../variables" as var; @use "../../variables" as var;
.markdown-editor {
/* Reset line-height to avoid having extra gaps/vertical spacing in the
element's container
*/
line-height: 1;
}
.markdown-editor__toolbar { .markdown-editor__toolbar {
@include layout.row; @include layout.row;
// Toolbar buttons wrap on non-touch devices if they don't fit. We don't use // Toolbar buttons wrap on non-touch devices if they don't fit. We don't use
......
...@@ -5,8 +5,7 @@ ...@@ -5,8 +5,7 @@
@use "../../variables" as var; @use "../../variables" as var;
.tag-editor { .tag-editor {
margin: var.$layout-space--xsmall 0; @include layout.vertical-rhythm(var.$layout-space--xsmall);
&__input { &__input {
@include forms.form-input; @include forms.form-input;
width: 100%; width: 100%;
...@@ -15,7 +14,6 @@ ...@@ -15,7 +14,6 @@
&__tags { &__tags {
@include layout.row; @include layout.row;
flex-wrap: wrap; flex-wrap: wrap;
margin: var.$layout-space--xsmall 0;
} }
&__item { &__item {
......
...@@ -5,7 +5,6 @@ ...@@ -5,7 +5,6 @@
.tag-list { .tag-list {
@include layout.row; @include layout.row;
flex-wrap: wrap; flex-wrap: wrap;
margin: 1em 0;
&__item { &__item {
@include utils.border; @include utils.border;
......
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