Unverified Commit a965d91e authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1964 from hypothesis/preact-thread-component

Replace `annotation-thread` with preact `Thread` component
parents 4db9f2a3 a47da4ae
...@@ -264,7 +264,7 @@ const defaultOpts = { ...@@ -264,7 +264,7 @@ const defaultOpts = {
/** /**
* Project, filter and sort a list of annotations into a thread structure for * Project, filter and sort a list of annotations into a thread structure for
* display by the <annotation-thread> directive. * display by the <Thread> component.
* *
* buildThread() takes as inputs a flat list of annotations, * buildThread() takes as inputs a flat list of annotations,
* the current visibility filters and sort function and returns * the current visibility filters and sort function and returns
......
import { countVisible, countHidden } from '../util/thread';
function showAllChildren(thread, showFn) {
thread.children.forEach(child => {
showFn(child);
showAllChildren(child, showFn);
});
}
function showAllParents(thread, showFn) {
while (thread.parent && thread.parent.annotation) {
showFn(thread.parent);
thread = thread.parent;
}
}
// @ngInject
function AnnotationThreadController(features, store) {
// Flag that tracks whether the content of the annotation is hovered,
// excluding any replies.
this.annotationHovered = false;
this.toggleCollapsed = function() {
this.onChangeCollapsed({
id: this.thread.id,
collapsed: !this.thread.collapsed,
});
};
this.threadClasses = function() {
return {
'annotation-thread': true,
'annotation-thread--reply': this.thread.depth > 0,
'annotation-thread--top-reply': this.thread.depth === 1,
};
};
this.threadToggleClasses = function() {
return {
'annotation-thread__collapse-toggle': true,
'is-open': !this.thread.collapsed,
'is-hovered': this.annotationHovered,
};
};
this.annotationClasses = function() {
return {
annotation: true,
'annotation--reply': this.thread.depth > 0,
'is-collapsed': this.thread.collapsed,
'is-highlighted': this.thread.highlightState === 'highlight',
'is-dimmed': this.thread.highlightState === 'dim',
};
};
/**
* Show this thread and any of its children. This is available if filtering
* is applied that hides items in the thread.
*/
this.showThreadAndReplies = function() {
showAllParents(this.thread, this.onForceVisible);
this.onForceVisible(this.thread);
showAllChildren(this.thread, this.onForceVisible);
};
this.isTopLevelThread = function() {
return !this.thread.parent;
};
/**
* Return the total number of annotations in the current
* thread which have been hidden because they do not match the current
* search filter.
*/
this.hiddenCount = function() {
return countHidden(this.thread);
};
this.shouldShowReply = function(child) {
return countVisible(child) > 0;
};
this.onForceVisible = function(thread) {
store.setForceVisible(thread.id, true);
if (thread.parent) {
store.setCollapsed(thread.parent.id, false);
}
};
}
export default {
controllerAs: 'vm',
controller: AnnotationThreadController,
bindings: {
/** The annotation thread to render. */
thread: '<',
/**
* Specify whether document information should be shown
* on annotation cards.
*/
showDocumentInfo: '<',
/** Called when the user clicks on the expand/collapse replies toggle. */
onChangeCollapsed: '&',
},
template: require('../templates/annotation-thread.html'),
};
...@@ -117,15 +117,15 @@ function Annotation({ ...@@ -117,15 +117,15 @@ function Annotation({
{isEditing && <TagEditor onEditTags={onEditTags} tagList={tags} />} {isEditing && <TagEditor onEditTags={onEditTags} tagList={tags} />}
{!isEditing && <TagList annotation={annotation} tags={tags} />} {!isEditing && <TagList annotation={annotation} tags={tags} />}
<footer className="annotation__footer"> <footer className="annotation__footer">
<div className="annotation__form-actions">
{isEditing && ( {isEditing && (
<div className="annotation__form-actions">
<AnnotationPublishControl <AnnotationPublishControl
annotation={annotation} annotation={annotation}
isDisabled={isEmpty} isDisabled={isEmpty}
onSave={onSave} onSave={onSave}
/> />
)}
</div> </div>
)}
{shouldShowLicense && <AnnotationLicense />} {shouldShowLicense && <AnnotationLicense />}
<div className="annotation__controls"> <div className="annotation__controls">
{shouldShowReplyToggle && ( {shouldShowReplyToggle && (
......
import angular from 'angular';
import * as util from './angular-util';
import * as fixtures from '../../test/annotation-fixtures';
import annotationThread from '../annotation-thread';
import moderationBanner from '../moderation-banner';
function PageObject(element) {
this.annotations = function() {
return Array.from(element[0].querySelectorAll('annotation'));
};
this.visibleReplies = function() {
return Array.from(
element[0].querySelectorAll(
'.annotation-thread__content > ul > li:not(.ng-hide)'
)
);
};
this.replyList = function() {
return element[0].querySelector('.annotation-thread__content > ul');
};
this.isHidden = function(element) {
return element.classList.contains('ng-hide');
};
}
describe('annotationThread', function() {
before(function() {
angular
.module('app', [])
.component('annotationThread', annotationThread)
.component('moderationBanner', {
bindings: moderationBanner.bindings,
});
});
let fakeFeatures;
let fakeStore;
beforeEach(function() {
fakeFeatures = {
flagEnabled: sinon.stub().returns(false),
};
fakeStore = {
setForceVisible: sinon.stub(),
setCollapsed: sinon.stub(),
getState: sinon.stub(),
};
angular.mock.module('app', { features: fakeFeatures, store: fakeStore });
});
it('renders the tree structure of parent and child annotations', function() {
const element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: { id: '1', text: 'text' },
children: [
{
id: '2',
annotation: { id: '2', text: 'areply' },
children: [],
visible: true,
},
],
visible: true,
},
});
const pageObject = new PageObject(element);
assert.equal(pageObject.annotations().length, 2);
assert.equal(pageObject.visibleReplies().length, 1);
});
it('does not render hidden threads', function() {
const element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: { id: '1' },
visible: false,
children: [],
},
});
const pageObject = new PageObject(element);
assert.equal(pageObject.annotations().length, 0);
});
describe('onForceVisible', () => {
it('shows the thread', () => {
const thread = {
id: '1',
children: [],
};
const element = util.createDirective(document, 'annotationThread', {
thread: thread,
});
element.ctrl.onForceVisible(thread);
assert.calledWith(fakeStore.setForceVisible, thread.id, true);
});
it('uncollapses the parent', () => {
const thread = {
id: '2',
children: [],
parent: { id: '3' },
};
const element = util.createDirective(document, 'annotationThread', {
thread: thread,
});
element.ctrl.onForceVisible(thread);
assert.calledWith(fakeStore.setCollapsed, thread.parent.id, false);
});
});
it('shows replies if not collapsed', function() {
const element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: { id: '1' },
visible: true,
children: [
{
id: '2',
annotation: { id: '2' },
children: [],
visible: true,
},
],
collapsed: false,
},
});
const pageObject = new PageObject(element);
assert.isFalse(pageObject.isHidden(pageObject.replyList()));
});
it('does not show replies if collapsed', function() {
const element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: { id: '1' },
visible: true,
children: [
{
id: '2',
annotation: { id: '2' },
children: [],
visible: true,
},
],
collapsed: true,
},
});
const pageObject = new PageObject(element);
assert.isTrue(pageObject.isHidden(pageObject.replyList()));
});
it('only shows replies that match the search filter', function() {
const element = util.createDirective(document, 'annotationThread', {
thread: {
id: '1',
annotation: { id: '1' },
visible: true,
children: [
{
id: '2',
annotation: { id: '2' },
children: [],
visible: false,
},
{
id: '3',
annotation: { id: '3' },
children: [],
visible: true,
},
],
collapsed: false,
},
});
const pageObject = new PageObject(element);
assert.equal(pageObject.visibleReplies().length, 1);
});
describe('#toggleCollapsed', function() {
it('toggles replies', function() {
const onChangeCollapsed = sinon.stub();
const element = util.createDirective(document, 'annotationThread', {
thread: {
id: '123',
annotation: { id: '123' },
children: [],
collapsed: true,
},
onChangeCollapsed: {
args: ['id', 'collapsed'],
callback: onChangeCollapsed,
},
});
element.ctrl.toggleCollapsed();
assert.calledWith(onChangeCollapsed, '123', false);
});
});
describe('#showThreadAndReplies', function() {
it('reveals all parents and replies', function() {
const thread = {
id: '123',
annotation: { id: '123' },
children: [
{
id: 'child-id',
annotation: { id: 'child-id' },
children: [],
},
],
parent: {
id: 'parent-id',
annotation: { id: 'parent-id' },
},
};
const element = util.createDirective(document, 'annotationThread', {
thread: thread,
});
element.ctrl.showThreadAndReplies();
assert.calledWith(fakeStore.setForceVisible, thread.parent.id, true);
assert.calledWith(fakeStore.setForceVisible, thread.id, true);
assert.calledWith(fakeStore.setForceVisible, thread.children[0].id, true);
assert.calledWith(fakeStore.setCollapsed, thread.parent.id, false);
});
});
it('renders the moderation banner', function() {
const ann = fixtures.moderatedAnnotation({ flagCount: 1 });
const thread = {
annotation: ann,
id: '123',
parent: null,
children: [],
};
const element = util.createDirective(document, 'annotationThread', {
thread: thread,
});
assert.ok(element[0].querySelector('moderation-banner'));
});
it('does not render the annotation or moderation banner if there is no annotation', function() {
const thread = {
annotation: null,
id: '123',
parent: null,
children: [],
};
const element = util.createDirective(document, 'annotationThread', {
thread: thread,
});
assert.notOk(element[0].querySelector('moderation-banner'));
assert.notOk(element[0].querySelector('annotation'));
});
});
...@@ -160,7 +160,7 @@ describe('threadList', function() { ...@@ -160,7 +160,7 @@ describe('threadList', function() {
element.scope.$digest(); element.scope.$digest();
assert.equal( assert.equal(
element[0].querySelectorAll('.thread-list__card--theme-clean').length, element[0].querySelectorAll('.thread-list__card--theme-clean').length,
element[0].querySelectorAll('annotation-thread').length element[0].querySelectorAll('thread').length
); );
}); });
...@@ -168,7 +168,7 @@ describe('threadList', function() { ...@@ -168,7 +168,7 @@ describe('threadList', function() {
const element = createThreadList(); const element = createThreadList();
fakeVirtualThread.notify(); fakeVirtualThread.notify();
element.scope.$digest(); element.scope.$digest();
const children = element[0].querySelectorAll('annotation-thread'); const children = element[0].querySelectorAll('thread');
assert.equal(children.length, 2); assert.equal(children.length, 2);
}); });
......
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,12 +120,12 @@ import SelectionTabs from './components/selection-tabs'; ...@@ -120,12 +120,12 @@ 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';
// Remaining UI components that are still built with Angular. // Remaining UI components that are still built with Angular.
import annotationThread from './components/annotation-thread';
import annotationViewerContent from './components/annotation-viewer-content'; import annotationViewerContent from './components/annotation-viewer-content';
import hypothesisApp from './components/hypothesis-app'; import hypothesisApp from './components/hypothesis-app';
import sidebarContent from './components/sidebar-content'; import sidebarContent from './components/sidebar-content';
...@@ -158,6 +158,7 @@ import sessionService from './services/session'; ...@@ -158,6 +158,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 +203,7 @@ function startAngularApp(config) { ...@@ -202,6 +203,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)
...@@ -236,7 +238,6 @@ function startAngularApp(config) { ...@@ -236,7 +238,6 @@ function startAngularApp(config) {
// UI components // UI components
.component('annotation', wrapComponent(Annotation)) .component('annotation', wrapComponent(Annotation))
.component('annotationThread', annotationThread)
.component('annotationViewerContent', annotationViewerContent) .component('annotationViewerContent', annotationViewerContent)
.component('helpPanel', wrapComponent(HelpPanel)) .component('helpPanel', wrapComponent(HelpPanel))
.component('loginPromptPanel', wrapComponent(LoginPromptPanel)) .component('loginPromptPanel', wrapComponent(LoginPromptPanel))
...@@ -250,6 +251,7 @@ function startAngularApp(config) { ...@@ -250,6 +251,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 +280,7 @@ function startAngularApp(config) { ...@@ -278,6 +280,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,
};
}
<div ng-class="vm.threadClasses()">
<div class="annotation-thread__thread-edge" ng-if="!vm.isTopLevelThread()">
<a href=""
ng-class="vm.threadToggleClasses()"
title="{{vm.thread.collapsed && 'Expand' || 'Collapse'}}"
ng-click="vm.toggleCollapsed()">
<svg-icon name="'caret-right'" ng-if="vm.thread.collapsed"></svg-icon>
<svg-icon name="'expand-menu'" ng-if="!vm.thread.collapsed"></svg-icon>
</a>
<div class="annotation-thread__thread-line"></div>
</div>
<div class="annotation-thread__content">
<moderation-banner
annotation="vm.thread.annotation"
ng-if="vm.thread.annotation">
</moderation-banner>
<annotation ng-if="vm.thread.annotation && vm.thread.visible"
annotation="vm.thread.annotation"
reply-count="vm.thread.replyCount"
on-reply-count-click="vm.toggleCollapsed()"
show-document-info="vm.showDocumentInfo"
thread-is-collapsed="vm.thread.collapsed">
</annotation>
<div ng-if="!vm.thread.annotation" class="thread-deleted">
<p><em>Message not available.</em></p>
</div>
<div ng-if="vm.hiddenCount() > 0">
<a class="small"
href=""
ng-click="vm.showThreadAndReplies()"
ng-pluralize
count="vm.hiddenCount()"
when="{'0': '',
one: 'View one more in conversation',
other: 'View {} more in conversation'}"
></a>
</div>
<!-- Replies -->
<ul ng-show="!vm.thread.collapsed">
<li ng-repeat="child in vm.thread.children track by child.id"
ng-show="vm.shouldShowReply(child)">
<annotation-thread
show-document-info="false"
thread="child"
on-change-collapsed="vm.onChangeCollapsed({id:id, collapsed:collapsed})"
on-force-visible="vm.onForceVisible(thread)">
</annotation-thread>
</li>
</ul>
</div>
</div>
...@@ -8,11 +8,7 @@ ...@@ -8,11 +8,7 @@
ng-class="{'thread-list__card--theme-clean' : vm.isThemeClean }" ng-class="{'thread-list__card--theme-clean' : vm.isThemeClean }"
ng-click="vm.onSelect({annotation: child.annotation})" ng-click="vm.onSelect({annotation: child.annotation})"
ng-mouseleave="vm.onFocus({annotation: null})"> ng-mouseleave="vm.onFocus({annotation: null})">
<annotation-thread <thread thread="child" show-document-info="vm.showDocumentInfo"></thread>
thread="child"
show-document-info="vm.showDocumentInfo"
on-change-collapsed="vm.onChangeCollapsed({id: id, collapsed: collapsed})">
</annotation-thread>
</div> </div>
<hr ng-if="vm.isThemeClean" <hr ng-if="vm.isThemeClean"
class="thread-list__separator--theme-clean" /> class="thread-list__separator--theme-clean" />
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
&__row { &__row {
display: flex; display: flex;
flex-wrap: wrap-reverse;
align-items: baseline; align-items: baseline;
} }
......
@use "../../variables" as var;
.annotation-thread {
display: flex;
flex-direction: row;
}
// Direct or nested reply to an annotation
.annotation-thread--reply {
// Left margin is set so that left edge of collapse toggle arrow
// for the reply is aligned with the left edge of the parent annotation's
// content.
margin-left: -5px;
}
// Top-level reply to an annotation
.annotation-thread--top-reply {
padding-top: 5px;
padding-bottom: 5px;
}
li:first-child .annotation-thread--top-reply {
// Gap between baseline of 'Hide/Show Replies' for annotation and top
// of first reply should be ~15px
margin-top: 5px;
}
// Container for the toggle arrow and dashed line at the left edge of replies.
.annotation-thread__thread-edge {
display: flex;
flex-direction: column;
width: 8px;
margin-right: 13px;
}
// The dashed line at the left edge of replies
.annotation-thread__thread-line {
border-right: 1px dashed var.$grey-3;
flex-grow: 1;
}
.annotation-thread__content {
flex-grow: 1;
// Prevent annotation content from overflowing the container
max-width: 100%;
}
// Darken expand/collapse toggle when an annotation is hovered. This is only
// when the annotation itself is hovered, not the replies.
.annotation-thread__collapse-toggle:hover,
.annotation-thread__collapse-toggle.is-hovered {
color: var.$grey-7;
}
// Toggle arrow which expands and collapses threads.
// This is aligned so that it appears above a dashed line which appears
// to the left of the threads.
.annotation-thread__collapse-toggle {
width: 10px;
color: var.$grey-4;
display: block;
text-align: center;
margin-left: 3px;
font-size: 15px;
line-height: 22px;
height: 100%;
&.is-open {
// When the thread is expanded, the top of the dashed line is should be
// aligned with the top of the privacy indicator ("Only me") if present
height: 24px;
}
}
@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%;
}
}
...@@ -34,7 +34,6 @@ ...@@ -34,7 +34,6 @@
@use './components/annotation-quote'; @use './components/annotation-quote';
@use './components/annotation-share-control'; @use './components/annotation-share-control';
@use './components/annotation-share-info'; @use './components/annotation-share-info';
@use './components/annotation-thread';
@use './components/annotation-user'; @use './components/annotation-user';
@use './components/autocomplete-list'; @use './components/autocomplete-list';
@use './components/button'; @use './components/button';
...@@ -62,6 +61,7 @@ ...@@ -62,6 +61,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