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';
/**
* @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() {
......@@ -42,20 +51,6 @@ function SavingMessage() {
</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.
*
......@@ -63,40 +58,34 @@ function SavingMessage() {
*/
function Annotation({
annotation,
hasAppliedFilter,
isReply,
onToggleReplies,
replyCount,
threadIsCollapsed,
annotationsService,
}) {
const isCollapsedReply = isReply && threadIsCollapsed;
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 = annotation && store.isAnnotationFocused(annotation.$tag);
const isSaving = annotation && store.isSavingAnnotation(annotation);
const isEditing = annotation && !!draft && !isSaving;
const isFocused = store.isAnnotationFocused(annotation.$tag);
const isSaving = store.isSavingAnnotation(annotation);
const userid = store.profile().userid;
const showActions =
annotation && !isSaving && !isEditing && isSaved(annotation);
const showReplyToggle =
!isReply && !isEditing && !hasAppliedFilter && replyCount > 0;
const isEditing = !!draft && !isSaving;
const isCollapsedReply = isReply && threadIsCollapsed;
const showActions = !isSaving && !isEditing && isSaved(annotation);
const onReply = () => {
if (annotation && isSaved(annotation) && userid) {
if (isSaved(annotation) && userid) {
annotationsService.reply(annotation, userid);
}
};
return (
<article className="space-y-4">
{annotation && (
<>
<AnnotationHeader
annotation={annotation}
isEditing={isEditing}
......@@ -116,21 +105,11 @@ function Annotation({
<AnnotationBody annotation={annotation} />
)}
{isEditing && (
<AnnotationEditor annotation={annotation} draft={draft} />
)}
</>
)}
{!annotation && !isCollapsedReply && (
<div>
<em>Message not available.</em>
</div>
)}
{isEditing && <AnnotationEditor annotation={annotation} draft={draft} />}
{!isCollapsedReply && (
<footer className="flex items-center">
{showReplyToggle && (
{onToggleReplies && (
<AnnotationReplyToggle
onToggleReplies={onToggleReplies}
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
import Annotation, { $imports } from '../Annotation';
describe('Annotation', () => {
let fakeOnToggleReplies;
// Dependency Mocks
let fakeMetadata;
......@@ -31,9 +29,7 @@ describe('Annotation', () => {
<Annotation
annotation={fixtures.defaultAnnotation()}
annotationsService={fakeAnnotationsService}
hasAppliedFilter={false}
isReply={false}
onToggleReplies={fakeOnToggleReplies}
replyCount={0}
threadIsCollapsed={true}
{...props}
......@@ -42,8 +38,6 @@ describe('Annotation', () => {
};
beforeEach(() => {
fakeOnToggleReplies = sinon.stub();
fakeAnnotationsService = {
reply: sinon.stub(),
save: sinon.stub().resolves(),
......@@ -111,8 +105,10 @@ describe('Annotation', () => {
});
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({
onToggleReplies: fakeOnToggleReplies,
replyCount: 5,
threadIsCollapsed: true,
});
......@@ -125,30 +121,8 @@ describe('Annotation', () => {
assert.equal(toggle.props().threadIsCollapsed, true);
});
it('should not render a reply toggle if the annotation has no replies', () => {
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', () => {
it('should not render a reply toggle if no toggle callback provided', () => {
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({
isReply: true,
replyCount: 5,
threadIsCollapsed: true,
});
......@@ -222,52 +196,6 @@ describe('Annotation', () => {
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(
......
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';
import Annotation from './Annotation';
import AnnotationHeader from './Annotation/AnnotationHeader';
import EmptyAnnotation from './Annotation/EmptyAnnotation';
import ModerationBanner from './ModerationBanner';
/** @typedef {import('../helpers/build-thread').Thread} Thread */
......@@ -107,29 +108,40 @@ function Thread({ thread, threadsService }) {
[store, thread.id, thread.collapsed]
);
const showReplyToggle =
!isReply && !isEditing && !hasAppliedFilter && thread.replyCount > 0;
// Memoize annotation content to avoid re-rendering an annotation when content
// in other annotations/threads change.
const annotationContent = useMemo(
() =>
thread.visible && (
<>
{thread.annotation && (
{thread.annotation ? (
<>
<ModerationBanner annotation={thread.annotation} />
)}
<Annotation
annotation={thread.annotation}
hasAppliedFilter={hasAppliedFilter}
isReply={isReply}
onToggleReplies={onToggleReplies}
onToggleReplies={showReplyToggle ? onToggleReplies : undefined}
replyCount={thread.replyCount}
threadIsCollapsed={thread.collapsed}
/>
</>
) : (
<EmptyAnnotation
isReply={isReply}
onToggleReplies={showReplyToggle ? onToggleReplies : undefined}
replyCount={thread.replyCount}
threadIsCollapsed={thread.collapsed}
/>
)}
</>
),
[
hasAppliedFilter,
isReply,
onToggleReplies,
showReplyToggle,
thread.annotation,
thread.replyCount,
thread.collapsed,
......
......@@ -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', () => {
let collapsedThread;
......@@ -193,7 +232,7 @@ describe('Thread', () => {
it('renders an annotation component', () => {
const wrapper = createComponent({ thread: noAnnotationThread });
const annotation = wrapper.find('Annotation');
const annotation = wrapper.find('EmptyAnnotation');
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