Commit 109c4ca0 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Extract `EmptyAnnotation` component

parent 6de3558a
...@@ -14,8 +14,17 @@ import AnnotationReplyToggle from './AnnotationReplyToggle'; ...@@ -14,8 +14,17 @@ import AnnotationReplyToggle from './AnnotationReplyToggle';
/** /**
* @typedef {import("../../../types/api").Annotation} Annotation * @typedef {import("../../../types/api").Annotation} Annotation
* @typedef {import("../../../types/api").SavedAnnotation} SavedAnnotation */
* @typedef {import('../../../types/api').Group} Group
/**
* @typedef AnnotationProps
* @prop {Annotation} annotation
* @prop {boolean} isReply
* @prop {VoidFunction} [onToggleReplies] - Callback to expand/collapse reply
* threads. The presence of a function indicates a toggle should be rendered.
* @prop {number} replyCount - Number of replies to this annotation's thread
* @prop {boolean} threadIsCollapsed - Is the thread to which this annotation belongs currently collapsed?
* @prop {import('../../services/annotations').AnnotationsService} annotationsService
*/ */
function SavingMessage() { function SavingMessage() {
...@@ -42,20 +51,6 @@ function SavingMessage() { ...@@ -42,20 +51,6 @@ function SavingMessage() {
</div> </div>
); );
} }
/**
* @typedef AnnotationProps
* @prop {Annotation} [annotation] - The annotation to render. If undefined,
* this Annotation will render as a "missing annotation" and will stand in
* as an Annotation for threads that lack an annotation.
* @prop {boolean} hasAppliedFilter - Is any filter applied currently?
* @prop {boolean} isReply
* @prop {VoidFunction} onToggleReplies - Callback to expand/collapse reply threads
* @prop {number} replyCount - Number of replies to this annotation's thread
* @prop {boolean} threadIsCollapsed - Is the thread to which this annotation belongs currently collapsed?
* @prop {import('../../services/annotations').AnnotationsService} annotationsService
*/
/** /**
* A single annotation. * A single annotation.
* *
...@@ -63,74 +58,58 @@ function SavingMessage() { ...@@ -63,74 +58,58 @@ function SavingMessage() {
*/ */
function Annotation({ function Annotation({
annotation, annotation,
hasAppliedFilter,
isReply, isReply,
onToggleReplies, onToggleReplies,
replyCount, replyCount,
threadIsCollapsed, threadIsCollapsed,
annotationsService, annotationsService,
}) { }) {
const isCollapsedReply = isReply && threadIsCollapsed;
const store = useSidebarStore(); const store = useSidebarStore();
const draft = annotation && store.getDraft(annotation); const annotationQuote = quote(annotation);
const draft = store.getDraft(annotation);
const userid = store.profile().userid;
const annotationQuote = annotation ? quote(annotation) : null; const isFocused = store.isAnnotationFocused(annotation.$tag);
const isFocused = annotation && store.isAnnotationFocused(annotation.$tag); const isSaving = store.isSavingAnnotation(annotation);
const isSaving = annotation && store.isSavingAnnotation(annotation);
const isEditing = annotation && !!draft && !isSaving;
const userid = store.profile().userid; const isEditing = !!draft && !isSaving;
const showActions = const isCollapsedReply = isReply && threadIsCollapsed;
annotation && !isSaving && !isEditing && isSaved(annotation);
const showReplyToggle = const showActions = !isSaving && !isEditing && isSaved(annotation);
!isReply && !isEditing && !hasAppliedFilter && replyCount > 0;
const onReply = () => { const onReply = () => {
if (annotation && isSaved(annotation) && userid) { if (isSaved(annotation) && userid) {
annotationsService.reply(annotation, userid); annotationsService.reply(annotation, userid);
} }
}; };
return ( return (
<article className="space-y-4"> <article className="space-y-4">
{annotation && ( <AnnotationHeader
<> annotation={annotation}
<AnnotationHeader isEditing={isEditing}
annotation={annotation} replyCount={replyCount}
isEditing={isEditing} threadIsCollapsed={threadIsCollapsed}
replyCount={replyCount} />
threadIsCollapsed={threadIsCollapsed}
/>
{annotationQuote && (
<AnnotationQuote
quote={annotationQuote}
isFocused={isFocused}
isOrphan={isOrphan(annotation)}
/>
)}
{!isCollapsedReply && !isEditing && (
<AnnotationBody annotation={annotation} />
)}
{isEditing && ( {annotationQuote && (
<AnnotationEditor annotation={annotation} draft={draft} /> <AnnotationQuote
)} quote={annotationQuote}
</> isFocused={isFocused}
isOrphan={isOrphan(annotation)}
/>
)} )}
{!annotation && !isCollapsedReply && ( {!isCollapsedReply && !isEditing && (
<div> <AnnotationBody annotation={annotation} />
<em>Message not available.</em>
</div>
)} )}
{isEditing && <AnnotationEditor annotation={annotation} draft={draft} />}
{!isCollapsedReply && ( {!isCollapsedReply && (
<footer className="flex items-center"> <footer className="flex items-center">
{showReplyToggle && ( {onToggleReplies && (
<AnnotationReplyToggle <AnnotationReplyToggle
onToggleReplies={onToggleReplies} onToggleReplies={onToggleReplies}
replyCount={replyCount} replyCount={replyCount}
......
import AnnotationReplyToggle from './AnnotationReplyToggle';
/**
* @typedef {import('./Annotation').AnnotationProps} AnnotationProps
* @typedef {Omit<AnnotationProps, 'annotation' | 'annotationsService'>} EmptyAnnotationProps
*/
/**
* Render an "annotation" when the annotation itself is missing. This can
* happen when an annotation is deleted by its author but there are still
* replies that pertain to it.
*
* @param {EmptyAnnotationProps} props
*/
export default function EmptyAnnotation({
isReply,
replyCount,
threadIsCollapsed,
onToggleReplies,
}) {
const isCollapsedReply = isReply && threadIsCollapsed;
return (
<article
className="space-y-4"
aria-label={`${
isReply ? 'Reply' : 'Annotation'
} with unavailable content`}
>
{!isCollapsedReply && (
<div>
<em>Message not available.</em>
</div>
)}
{onToggleReplies && (
<footer className="flex items-center">
<AnnotationReplyToggle
onToggleReplies={onToggleReplies}
replyCount={replyCount}
threadIsCollapsed={threadIsCollapsed}
/>
</footer>
)}
</article>
);
}
...@@ -8,8 +8,6 @@ import { mockImportedComponents } from '../../../../test-util/mock-imported-comp ...@@ -8,8 +8,6 @@ import { mockImportedComponents } from '../../../../test-util/mock-imported-comp
import Annotation, { $imports } from '../Annotation'; import Annotation, { $imports } from '../Annotation';
describe('Annotation', () => { describe('Annotation', () => {
let fakeOnToggleReplies;
// Dependency Mocks // Dependency Mocks
let fakeMetadata; let fakeMetadata;
...@@ -31,9 +29,7 @@ describe('Annotation', () => { ...@@ -31,9 +29,7 @@ describe('Annotation', () => {
<Annotation <Annotation
annotation={fixtures.defaultAnnotation()} annotation={fixtures.defaultAnnotation()}
annotationsService={fakeAnnotationsService} annotationsService={fakeAnnotationsService}
hasAppliedFilter={false}
isReply={false} isReply={false}
onToggleReplies={fakeOnToggleReplies}
replyCount={0} replyCount={0}
threadIsCollapsed={true} threadIsCollapsed={true}
{...props} {...props}
...@@ -42,8 +38,6 @@ describe('Annotation', () => { ...@@ -42,8 +38,6 @@ describe('Annotation', () => {
}; };
beforeEach(() => { beforeEach(() => {
fakeOnToggleReplies = sinon.stub();
fakeAnnotationsService = { fakeAnnotationsService = {
reply: sinon.stub(), reply: sinon.stub(),
save: sinon.stub().resolves(), save: sinon.stub().resolves(),
...@@ -111,8 +105,10 @@ describe('Annotation', () => { ...@@ -111,8 +105,10 @@ describe('Annotation', () => {
}); });
describe('reply thread toggle', () => { describe('reply thread toggle', () => {
it('should render a toggle button if the annotation has replies', () => { it('should render a toggle button if provided with a toggle callback', () => {
const fakeOnToggleReplies = sinon.stub();
const wrapper = createComponent({ const wrapper = createComponent({
onToggleReplies: fakeOnToggleReplies,
replyCount: 5, replyCount: 5,
threadIsCollapsed: true, threadIsCollapsed: true,
}); });
...@@ -125,30 +121,8 @@ describe('Annotation', () => { ...@@ -125,30 +121,8 @@ describe('Annotation', () => {
assert.equal(toggle.props().threadIsCollapsed, true); assert.equal(toggle.props().threadIsCollapsed, true);
}); });
it('should not render a reply toggle if the annotation has no replies', () => { it('should not render a reply toggle if no toggle callback provided', () => {
const wrapper = createComponent({
isReply: false,
replyCount: 0,
threadIsCollapsed: true,
});
assert.isFalse(wrapper.find('AnnotationReplyToggle').exists());
});
it('should not render a reply toggle if there are applied filters', () => {
const wrapper = createComponent({
hasAppliedFilter: true,
isReply: false,
replyCount: 5,
threadIsCollapsed: true,
});
assert.isFalse(wrapper.find('AnnotationReplyToggle').exists());
});
it('should not render a reply toggle if the annotation itself is a reply', () => {
const wrapper = createComponent({ const wrapper = createComponent({
isReply: true,
replyCount: 5, replyCount: 5,
threadIsCollapsed: true, threadIsCollapsed: true,
}); });
...@@ -222,52 +196,6 @@ describe('Annotation', () => { ...@@ -222,52 +196,6 @@ describe('Annotation', () => {
assert.isTrue(wrapper.find('footer').exists()); assert.isTrue(wrapper.find('footer').exists());
}); });
}); });
context('missing annotation', () => {
it('should render a message about annotation unavailability', () => {
const wrapper = createComponent({ annotation: undefined });
assert.equal(wrapper.text(), 'Message not available.');
});
it('should not render a message if collapsed reply', () => {
const wrapper = createComponent({
annotation: undefined,
isReply: true,
threadIsCollapsed: true,
});
assert.equal(wrapper.text(), '');
});
it('should render reply toggle controls if there are replies', () => {
const wrapper = createComponent({
annotation: undefined,
replyCount: 5,
threadIsCollapsed: true,
});
const toggle = wrapper.find('AnnotationReplyToggle');
assert.isTrue(toggle.exists());
assert.equal(toggle.props().onToggleReplies, fakeOnToggleReplies);
assert.equal(toggle.props().replyCount, 5);
assert.equal(toggle.props().threadIsCollapsed, true);
});
it('should not render reply toggle controls if collapsed reply', () => {
const wrapper = createComponent({
annotation: undefined,
isReply: true,
replyCount: 5,
threadIsCollapsed: true,
});
const toggle = wrapper.find('AnnotationReplyToggle');
assert.isFalse(toggle.exists());
});
});
}); });
it( it(
......
import { mount } from 'enzyme';
import { checkAccessibility } from '../../../../test-util/accessibility';
import { mockImportedComponents } from '../../../../test-util/mock-imported-components';
import EmptyAnnotation, { $imports } from '../EmptyAnnotation';
describe('EmptyAnnotation', () => {
const createComponent = props => {
return mount(
<EmptyAnnotation
isReply={false}
replyCount={0}
threadIsCollapsed={true}
{...props}
/>
);
};
beforeEach(() => {
$imports.$mock(mockImportedComponents());
});
afterEach(() => {
$imports.$restore();
});
describe('reply thread toggle', () => {
it('should render a toggle button if toggle callback provided', () => {
const fakeOnToggleReplies = sinon.stub();
const wrapper = createComponent({
onToggleReplies: fakeOnToggleReplies,
replyCount: 5,
threadIsCollapsed: true,
});
const toggle = wrapper.find('AnnotationReplyToggle');
assert.isTrue(toggle.exists());
assert.equal(toggle.props().onToggleReplies, fakeOnToggleReplies);
assert.equal(toggle.props().replyCount, 5);
assert.equal(toggle.props().threadIsCollapsed, true);
});
it('should not render a reply toggle if no callback provided', () => {
const wrapper = createComponent({
isReply: false,
replyCount: 5,
threadIsCollapsed: true,
});
assert.isFalse(wrapper.find('AnnotationReplyToggle').exists());
});
});
describe('labeling and description', () => {
it('should render a label and message for top-level missing annotations', () => {
const wrapper = createComponent();
assert.equal(
wrapper.find('article').props()['aria-label'],
'Annotation with unavailable content'
);
assert.equal(wrapper.text(), 'Message not available.');
});
it('should label the EmptyAnnotation as a reply if it is a reply', () => {
const wrapper = createComponent({
isReply: true,
});
assert.equal(
wrapper.find('article').props()['aria-label'],
'Reply with unavailable content'
);
});
it('should not render a message if collapsed reply', () => {
const wrapper = createComponent({
isReply: true,
threadIsCollapsed: true,
});
assert.equal(wrapper.text(), '');
});
});
it(
'should pass a11y checks',
checkAccessibility([
{
content: () => createComponent(),
},
{
name: 'when a collapsed top-level thread',
content: () => {
return createComponent({ isReply: false, threadIsCollapsed: true });
},
},
{
name: 'when a collapsed reply',
content: () => {
return createComponent({ isReply: true, threadIsCollapsed: true });
},
},
])
);
});
...@@ -8,6 +8,7 @@ import { countHidden, countVisible } from '../helpers/thread'; ...@@ -8,6 +8,7 @@ import { countHidden, countVisible } from '../helpers/thread';
import Annotation from './Annotation'; import Annotation from './Annotation';
import AnnotationHeader from './Annotation/AnnotationHeader'; import AnnotationHeader from './Annotation/AnnotationHeader';
import EmptyAnnotation from './Annotation/EmptyAnnotation';
import ModerationBanner from './ModerationBanner'; import ModerationBanner from './ModerationBanner';
/** @typedef {import('../helpers/build-thread').Thread} Thread */ /** @typedef {import('../helpers/build-thread').Thread} Thread */
...@@ -107,29 +108,40 @@ function Thread({ thread, threadsService }) { ...@@ -107,29 +108,40 @@ function Thread({ thread, threadsService }) {
[store, thread.id, thread.collapsed] [store, thread.id, thread.collapsed]
); );
const showReplyToggle =
!isReply && !isEditing && !hasAppliedFilter && thread.replyCount > 0;
// Memoize annotation content to avoid re-rendering an annotation when content // Memoize annotation content to avoid re-rendering an annotation when content
// in other annotations/threads change. // in other annotations/threads change.
const annotationContent = useMemo( const annotationContent = useMemo(
() => () =>
thread.visible && ( thread.visible && (
<> <>
{thread.annotation && ( {thread.annotation ? (
<ModerationBanner annotation={thread.annotation} /> <>
<ModerationBanner annotation={thread.annotation} />
<Annotation
annotation={thread.annotation}
isReply={isReply}
onToggleReplies={showReplyToggle ? onToggleReplies : undefined}
replyCount={thread.replyCount}
threadIsCollapsed={thread.collapsed}
/>
</>
) : (
<EmptyAnnotation
isReply={isReply}
onToggleReplies={showReplyToggle ? onToggleReplies : undefined}
replyCount={thread.replyCount}
threadIsCollapsed={thread.collapsed}
/>
)} )}
<Annotation
annotation={thread.annotation}
hasAppliedFilter={hasAppliedFilter}
isReply={isReply}
onToggleReplies={onToggleReplies}
replyCount={thread.replyCount}
threadIsCollapsed={thread.collapsed}
/>
</> </>
), ),
[ [
hasAppliedFilter,
isReply, isReply,
onToggleReplies, onToggleReplies,
showReplyToggle,
thread.annotation, thread.annotation,
thread.replyCount, thread.replyCount,
thread.collapsed, thread.collapsed,
......
...@@ -158,6 +158,45 @@ describe('Thread', () => { ...@@ -158,6 +158,45 @@ describe('Thread', () => {
}); });
}); });
describe('toggling replies for top-level threads', () => {
it('provides an `onToggleReplies` callback for top-level threads with replies', () => {
const threadWithChildren = buildThreadWithChildren();
const wrapper = createComponent({ thread: threadWithChildren });
assert.isFunction(wrapper.find('Annotation').props().onToggleReplies);
});
it('does not provide a toggle callback if thread is being edited', () => {
fakeStore.getDraft.returns({});
const threadWithChildren = buildThreadWithChildren();
const wrapper = createComponent({ thread: threadWithChildren });
assert.isUndefined(wrapper.find('Annotation').props().onToggleReplies);
});
it('does not provide a toggle callback if thread is a reply', () => {
const threadWithChildren = buildThreadWithChildren();
threadWithChildren.parent = 1;
const wrapper = createComponent({ thread: threadWithChildren });
assert.isUndefined(wrapper.find('Annotation').props().onToggleReplies);
});
it('does not provide a toggle callback if there is an applied filter', () => {
fakeStore.hasAppliedFilter.returns(true);
const threadWithChildren = buildThreadWithChildren();
const wrapper = createComponent({ thread: threadWithChildren });
assert.isUndefined(wrapper.find('Annotation').props().onToggleReplies);
});
it('does not provide a toggle callback if there are no replies', () => {
const thread = createThread();
const wrapper = createComponent({ thread });
assert.isUndefined(wrapper.find('Annotation').props().onToggleReplies);
});
});
context('collapsed thread with annotation and children', () => { context('collapsed thread with annotation and children', () => {
let collapsedThread; let collapsedThread;
...@@ -193,7 +232,7 @@ describe('Thread', () => { ...@@ -193,7 +232,7 @@ describe('Thread', () => {
it('renders an annotation component', () => { it('renders an annotation component', () => {
const wrapper = createComponent({ thread: noAnnotationThread }); const wrapper = createComponent({ thread: noAnnotationThread });
const annotation = wrapper.find('Annotation'); const annotation = wrapper.find('EmptyAnnotation');
assert.isTrue(annotation.exists()); assert.isTrue(annotation.exists());
}); });
......
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