Commit 80c9e2e3 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Add preact `Thread` component and new `threadsService`

parent f98d0ebb
import { mount } from 'enzyme';
import { createElement } from 'preact';
import { act } from 'preact/test-utils';
import Thread from '../thread';
import { $imports } from '../thread';
import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components';
// Utility functions to build nested threads
let lastThreadId = 0;
const createThread = () => {
lastThreadId++;
return {
id: lastThreadId.toString(),
annotation: {},
children: [],
parent: undefined,
collapsed: false,
visible: true,
depth: 0,
replyCount: 0,
};
};
const addChildThread = parent => {
const childThread = createThread();
childThread.parent = parent.id;
parent.children.push(childThread);
return childThread;
};
// NB: This logic lifted from `build-thread.js`
function countRepliesAndDepth(thread, depth) {
const children = thread.children.map(child => {
return countRepliesAndDepth(child, depth + 1);
});
return {
...thread,
children,
depth,
replyCount: children.reduce((total, child) => {
return total + 1 + child.replyCount;
}, 0),
};
}
/**
* Utility function: construct a thread with several children
*/
const buildThreadWithChildren = () => {
let thread = createThread();
addChildThread(thread);
addChildThread(thread);
addChildThread(thread.children[0]);
addChildThread(thread.children[0].children[0]);
addChildThread(thread.children[1]);
// `depth` and `replyCount` are computed properties...
thread = countRepliesAndDepth(thread, 0);
return thread;
};
describe('Thread', () => {
let fakeStore;
let fakeThreadsService;
let fakeThreadUtil;
// Because this is a recursive component, for most tests, we'll want single,
// flat `thread` object (so we are not misled by rendered children)
const createComponent = props => {
return mount(
<Thread
showDocumentInfo={false}
thread={createThread()}
threadsService={fakeThreadsService}
{...props}
/>
);
};
beforeEach(() => {
fakeStore = {
setCollapsed: sinon.stub(),
};
fakeThreadsService = {
forceVisible: sinon.stub(),
};
fakeThreadUtil = {
countHidden: sinon.stub(),
countVisible: sinon.stub(),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
'../util/thread': fakeThreadUtil,
});
});
afterEach(() => {
$imports.$restore();
});
context('thread not at top level (depth > 0)', () => {
// "Reply" here means that the thread has a `depth` of > 0, not that it is
// _strictly_ a reply—true annotation replies (per `util.annotation_metadata`)
// have `references`
let replyThread;
// Retrieve the (caret) button for showing and hiding replies
const getToggleButton = wrapper => {
return wrapper.find('Button').filter('.thread__collapse-button');
};
beforeEach(() => {
replyThread = createThread();
replyThread.depth = 1;
replyThread.parent = '1';
});
it('shows the reply toggle controls', () => {
const wrapper = createComponent({ thread: replyThread });
assert.lengthOf(getToggleButton(wrapper), 1);
});
it('collapses the thread when reply toggle clicked on expanded thread', () => {
replyThread.collapsed = false;
const wrapper = createComponent({ thread: replyThread });
act(() => {
getToggleButton(wrapper)
.props()
.onClick();
});
assert.calledOnce(fakeStore.setCollapsed);
assert.calledWith(fakeStore.setCollapsed, replyThread.id, true);
});
it('assigns an appropriate CSS class to the element', () => {
const wrapper = createComponent({ thread: replyThread });
assert.isTrue(wrapper.find('.thread').hasClass('thread--reply'));
});
});
context('visible thread with annotation', () => {
it('renders the annotation moderation banner', () => {
// NB: In the default `thread` provided, `visible` is `true` and there
// is an `annotation` object
const wrapper = createComponent();
assert.isTrue(wrapper.exists('ModerationBanner'));
});
it('renders the annotation', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.exists('Annotation'));
});
});
context('collapsed thread with annotation and children', () => {
let collapsedThread;
beforeEach(() => {
collapsedThread = buildThreadWithChildren();
collapsedThread.collapsed = true;
});
it('assigns an appropriate CSS class to the element', () => {
const wrapper = createComponent({ thread: collapsedThread });
assert.isTrue(wrapper.find('.thread').hasClass('is-collapsed'));
assert.isFalse(wrapper.find('.thread__collapse-button').exists());
});
it('renders reply toggle controls when thread has a parent', () => {
collapsedThread.parent = '1';
const wrapper = createComponent({ thread: collapsedThread });
assert.isTrue(wrapper.find('.thread__collapse-button').exists());
});
it('does not render child threads', () => {
const wrapper = createComponent({ thread: collapsedThread });
assert.isFalse(wrapper.find('.thread__children').exists());
});
});
context('thread annotation has been deleted', () => {
let noAnnotationThread;
beforeEach(() => {
noAnnotationThread = createThread();
noAnnotationThread.annotation = undefined;
});
it('does not render an annotation or a moderation banner', () => {
const wrapper = createComponent({ thread: noAnnotationThread });
assert.isFalse(wrapper.find('Annotation').exists());
assert.isFalse(wrapper.find('ModerationBanner').exists());
});
it('renders an unavailable message', () => {
const wrapper = createComponent({ thread: noAnnotationThread });
assert.isTrue(wrapper.find('.thread__unavailable-message').exists());
});
});
context('one or more threads hidden by applied search filter', () => {
beforeEach(() => {
fakeThreadUtil.countHidden.returns(1);
});
it('forces the hidden threads visible when show-hidden button clicked', () => {
const thread = createThread();
const wrapper = createComponent({ thread });
act(() => {
wrapper
.find('Button')
.filter({ buttonText: 'Show 1 more in conversation' })
.props()
.onClick();
});
assert.calledOnce(fakeThreadsService.forceVisible);
assert.calledWith(fakeThreadsService.forceVisible, thread);
});
});
context('thread with child threads', () => {
let threadWithChildren;
beforeEach(() => {
// A child must have at least one visible item to be rendered
fakeThreadUtil.countVisible.returns(1);
threadWithChildren = buildThreadWithChildren();
});
it('renders child threads', () => {
const wrapper = createComponent({ thread: threadWithChildren });
assert.equal(
wrapper.find('.thread__children').find('Thread').length,
threadWithChildren.replyCount
);
});
it('renders only those children with at least one visible item', () => {
// This has the effect of making the thread's first child _and_ all of
// that child threads descendents not render.
fakeThreadUtil.countVisible.onFirstCall().returns(0);
const wrapper = createComponent({ thread: threadWithChildren });
// The number of children that end up getting rendered is equal to
// all of the second child's replies plus the second child itself.
assert.equal(
wrapper.find('.thread__children').find('Thread').length,
threadWithChildren.children[1].replyCount + 1
);
});
});
describe('a11y', () => {
let threadWithChildren;
beforeEach(() => {
threadWithChildren = buildThreadWithChildren();
});
it(
'should pass a11y checks',
checkAccessibility({
content: () => createComponent({ thread: threadWithChildren }),
})
);
});
});
import classnames from 'classnames';
import { createElement, Fragment } from 'preact';
import propTypes from 'prop-types';
import useStore from '../store/use-store';
import { withServices } from '../util/service-context';
import { countHidden, countVisible } from '../util/thread';
import Annotation from './annotation';
import Button from './button';
import ModerationBanner from './moderation-banner';
/**
* A thread, which contains a single annotation at its top level, and its
* recursively-rendered children (i.e. replies). A thread may have a parent,
* and at any given time it may be `collapsed`.
*/
function Thread({ showDocumentInfo = false, thread, threadsService }) {
const setCollapsed = useStore(store => store.setCollapsed);
// Only render this thread's annotation if it exists and the thread is `visible`
const showAnnotation = thread.annotation && thread.visible;
// Render this thread's replies only if the thread is expanded
const showChildren = !thread.collapsed;
// Applied search filters will "hide" non-matching threads. If there are
// hidden items within this thread, provide a control to un-hide them.
const showHiddenToggle = countHidden(thread) > 0;
// Render a control to expand/collapse the current thread if this thread has
// a parent (i.e. is a reply thread)
const showThreadToggle = !!thread.parent;
const toggleIcon = thread.collapsed ? 'caret-right' : 'expand-menu';
const toggleTitle = thread.collapsed ? 'Expand replies' : 'Collapse replies';
// If rendering child threads, only render those that have at least one
// visible item within them—i.e. don't render empty/totally-hidden threads.
const visibleChildren = thread.children.filter(
child => countVisible(child) > 0
);
const onToggleReplies = () => setCollapsed(thread.id, !thread.collapsed);
return (
<div
className={classnames('thread', {
'thread--reply': thread.depth > 0,
'is-collapsed': thread.collapsed,
})}
>
{showThreadToggle && (
<div className="thread__collapse">
<Button
className="thread__collapse-button"
icon={toggleIcon}
title={toggleTitle}
onClick={onToggleReplies}
/>
</div>
)}
<div className="thread__content">
{showAnnotation && (
<Fragment>
<ModerationBanner annotation={thread.annotation} />
<Annotation
annotation={thread.annotation}
replyCount={thread.replyCount}
onReplyCountClick={onToggleReplies}
showDocumentInfo={showDocumentInfo}
threadIsCollapsed={thread.collapsed}
/>
</Fragment>
)}
{!thread.annotation && (
<div className="thread__unavailable-message">
<em>Message not available.</em>
</div>
)}
{showHiddenToggle && (
<Button
buttonText={`Show ${countHidden(thread)} more in conversation`}
onClick={() => threadsService.forceVisible(thread)}
/>
)}
{showChildren && (
<ul className="thread__children">
{visibleChildren.map(child => (
<li key={child.id}>
<Thread thread={child} threadsService={threadsService} />
</li>
))}
</ul>
)}
</div>
</div>
);
}
Thread.propTypes = {
showDocumentInfo: propTypes.bool,
thread: propTypes.object.isRequired,
// Injected
threadsService: propTypes.object.isRequired,
};
Thread.injectedProps = ['threadsService'];
export default withServices(Thread);
...@@ -120,6 +120,7 @@ import SelectionTabs from './components/selection-tabs'; ...@@ -120,6 +120,7 @@ import SelectionTabs from './components/selection-tabs';
import ShareAnnotationsPanel from './components/share-annotations-panel'; import ShareAnnotationsPanel from './components/share-annotations-panel';
import SidebarContentError from './components/sidebar-content-error'; import SidebarContentError from './components/sidebar-content-error';
import SvgIcon from './components/svg-icon'; import SvgIcon from './components/svg-icon';
import Thread from './components/thread';
import ToastMessages from './components/toast-messages'; import ToastMessages from './components/toast-messages';
import TopBar from './components/top-bar'; import TopBar from './components/top-bar';
...@@ -158,6 +159,7 @@ import sessionService from './services/session'; ...@@ -158,6 +159,7 @@ import sessionService from './services/session';
import streamFilterService from './services/stream-filter'; import streamFilterService from './services/stream-filter';
import streamerService from './services/streamer'; import streamerService from './services/streamer';
import tagsService from './services/tags'; import tagsService from './services/tags';
import threadsService from './services/threads';
import toastMessenger from './services/toast-messenger'; import toastMessenger from './services/toast-messenger';
import unicodeService from './services/unicode'; import unicodeService from './services/unicode';
import viewFilterService from './services/view-filter'; import viewFilterService from './services/view-filter';
...@@ -202,6 +204,7 @@ function startAngularApp(config) { ...@@ -202,6 +204,7 @@ function startAngularApp(config) {
.register('streamer', streamerService) .register('streamer', streamerService)
.register('streamFilter', streamFilterService) .register('streamFilter', streamFilterService)
.register('tags', tagsService) .register('tags', tagsService)
.register('threadsService', threadsService)
.register('toastMessenger', toastMessenger) .register('toastMessenger', toastMessenger)
.register('unicode', unicodeService) .register('unicode', unicodeService)
.register('viewFilter', viewFilterService) .register('viewFilter', viewFilterService)
...@@ -250,6 +253,7 @@ function startAngularApp(config) { ...@@ -250,6 +253,7 @@ function startAngularApp(config) {
.component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel)) .component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel))
.component('streamContent', streamContent) .component('streamContent', streamContent)
.component('svgIcon', wrapComponent(SvgIcon)) .component('svgIcon', wrapComponent(SvgIcon))
.component('thread', wrapComponent(Thread))
.component('threadList', threadList) .component('threadList', threadList)
.component('toastMessages', wrapComponent(ToastMessages)) .component('toastMessages', wrapComponent(ToastMessages))
.component('topBar', wrapComponent(TopBar)) .component('topBar', wrapComponent(TopBar))
...@@ -278,6 +282,7 @@ function startAngularApp(config) { ...@@ -278,6 +282,7 @@ function startAngularApp(config) {
.service('session', () => container.get('session')) .service('session', () => container.get('session'))
.service('streamer', () => container.get('streamer')) .service('streamer', () => container.get('streamer'))
.service('streamFilter', () => container.get('streamFilter')) .service('streamFilter', () => container.get('streamFilter'))
.service('threadsService', () => container.get('threadsService'))
.service('toastMessenger', () => container.get('toastMessenger')) .service('toastMessenger', () => container.get('toastMessenger'))
// Redux store // Redux store
......
import threadsService from '../threads';
const NESTED_THREADS = {
id: 'top',
children: [
{
id: '1',
children: [
{ id: '1a', children: [{ id: '1ai', children: [] }] },
{ id: '1b', children: [] },
{
id: '1c',
children: [{ id: '1ci', children: [] }],
},
],
},
{
id: '2',
children: [
{ id: '2a', children: [] },
{
id: '2b',
children: [
{ id: '2bi', children: [] },
{ id: '2bii', children: [] },
],
},
],
},
{
id: '3',
children: [],
},
],
};
describe('threadsService', function() {
let fakeStore;
let service;
beforeEach(() => {
fakeStore = {
setForceVisible: sinon.stub(),
};
service = threadsService(fakeStore);
});
describe('#forceVisible', () => {
it('should set the thread and its children force-visible in the store', () => {
service.forceVisible(NESTED_THREADS);
[
'top',
'1',
'2',
'3',
'1a',
'1b',
'1c',
'2a',
'2b',
'1ai',
'1ci',
'2bi',
'2bii',
].forEach(threadId =>
assert.calledWith(fakeStore.setForceVisible, threadId)
);
});
it('should not set the visibility on thread ancestors', () => {
// This starts at the level with `id` of '1'
service.forceVisible(NESTED_THREADS.children[0]);
const calledWithThreadIds = [];
for (let i = 0; i < fakeStore.setForceVisible.callCount; i++) {
calledWithThreadIds.push(fakeStore.setForceVisible.getCall(i).args[0]);
}
assert.deepEqual(calledWithThreadIds, [
'1ai',
'1a',
'1b',
'1ci',
'1c',
'1',
]);
});
});
});
// @ngInject
export default function threadsService(store) {
/**
* Make this thread and all of its children "visible". This has the effect of
* "unhiding" a thread which is currently hidden by an applied search filter
* (as well as its child threads).
*/
function forceVisible(thread) {
thread.children.forEach(child => {
forceVisible(child);
});
store.setForceVisible(thread.id, true);
}
return {
forceVisible,
};
}
@use "../../variables" as var;
.thread {
display: flex;
&--reply {
margin-top: 0.5em;
padding-top: 0.5em;
}
&__collapse {
margin: 0.25em;
margin-top: 0;
cursor: auto;
border-left: 1px dashed var.$grey-3;
&:hover {
border-left: 1px dashed var.$grey-4;
}
.is-collapsed & {
border-left: none;
}
}
// TODO These styles should be consolidated with other `Button` styles
&__collapse-button {
margin-left: -1.25em;
padding: 0.25em 0.75em 1em 0.75em;
// Need a non-transparent background so that the dashed border line
// does not show through the button
background-color: var.$white;
.button__icon {
width: 12px;
height: 12px;
color: var.$grey-4;
}
&:hover {
.button__icon {
color: var.$grey-6;
}
}
}
&__content {
flex-grow: 1;
// Prevent annotation content from overflowing the container
max-width: 100%;
}
}
...@@ -62,6 +62,7 @@ ...@@ -62,6 +62,7 @@
@use './components/spinner'; @use './components/spinner';
@use './components/tag-editor'; @use './components/tag-editor';
@use './components/tag-list'; @use './components/tag-list';
@use './components/thread';
@use './components/thread-list'; @use './components/thread-list';
@use './components/toast-messages'; @use './components/toast-messages';
@use './components/tooltip'; @use './components/tooltip';
......
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