Commit 0374e506 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Add interim `ThreadListOmega` component

parent 37e9b424
import { mount } from 'enzyme';
import { createElement } from 'preact';
import events from '../../events';
import { act } from 'preact/test-utils';
import ThreadList from '../thread-list-omega';
import { $imports } from '../thread-list-omega';
import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components';
describe('ThreadListOmega', () => {
let fakeDebounce;
let fakeDomUtil;
let fakeMetadata;
let fakeTopThread;
let fakeRootScope;
let fakeScrollContainer;
let fakeStore;
let fakeVisibleThreadsUtil;
function createComponent(props) {
return mount(
<ThreadList
thread={fakeTopThread}
$rootScope={fakeRootScope}
{...props}
/>,
{ attachTo: fakeScrollContainer }
);
}
beforeEach(() => {
fakeDebounce = sinon.stub().returns(() => null);
fakeDomUtil = {
getElementHeightWithMargins: sinon.stub().returns(0),
};
fakeMetadata = {
isHighlight: sinon.stub().returns(false),
isReply: sinon.stub().returns(false),
};
fakeRootScope = {
eventCallbacks: {},
$apply: function (callback) {
callback();
},
$on: function (event, callback) {
if (event === events.BEFORE_ANNOTATION_CREATED) {
this.eventCallbacks[event] = callback;
}
},
$broadcast: sinon.stub(),
};
fakeScrollContainer = document.createElement('div');
fakeScrollContainer.className = 'js-thread-list-scroll-root';
fakeScrollContainer.style.height = '2000px';
document.body.appendChild(fakeScrollContainer);
fakeStore = {
clearSelection: sinon.stub(),
};
fakeTopThread = {
id: 't0',
annotation: { $tag: 'myTag0' },
children: [
{ id: 't1', children: [], annotation: { $tag: 't1' } },
{ id: 't2', children: [], annotation: { $tag: 't2' } },
{ id: 't3', children: [], annotation: { $tag: 't3' } },
{ id: 't4', children: [], annotation: { $tag: 't4' } },
],
};
fakeVisibleThreadsUtil = {
calculateVisibleThreads: sinon.stub().returns({
visibleThreads: fakeTopThread.children,
offscreenUpperHeight: 400,
offscreenLowerHeight: 600,
}),
THREAD_DIMENSION_DEFAULTS: {
defaultHeight: 200,
},
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'lodash.debounce': fakeDebounce,
'../store/use-store': callback => callback(fakeStore),
'../util/annotation-metadata': fakeMetadata,
'../util/dom': fakeDomUtil,
'../util/visible-threads': fakeVisibleThreadsUtil,
});
});
afterEach(() => {
$imports.$restore();
fakeScrollContainer.remove();
});
it('works', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.find('section').exists());
});
it('calculates visible threads', () => {
createComponent();
assert.calledWith(
fakeVisibleThreadsUtil.calculateVisibleThreads,
fakeTopThread.children,
sinon.match({}),
0,
sinon.match.number
);
});
context('new annotation created in application', () => {
it('attaches a listener for the BEFORE_ANNOTATION_CREATED event', () => {
fakeRootScope.$on = sinon.stub();
createComponent();
assert.calledWith(
fakeRootScope.$on,
events.BEFORE_ANNOTATION_CREATED,
sinon.match.func
);
});
it('clears the current selection in the store', () => {
createComponent();
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED]({}, {});
assert.calledOnce(fakeStore.clearSelection);
});
it('does not clear the selection in the store if new annotation is a highlight', () => {
fakeMetadata.isHighlight.returns(true);
createComponent();
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED]({}, {});
assert.notCalled(fakeStore.clearSelection);
});
it('does not clear the selection in the store if new annotation is a reply', () => {
fakeMetadata.isReply.returns(true);
createComponent();
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED]({}, {});
assert.notCalled(fakeStore.clearSelection);
});
});
context('active scroll to an annotation thread', () => {
let stubbedDocument;
let stubbedFakeScrollContainer;
let fakeScrollTop;
beforeEach(() => {
fakeScrollTop = sinon.stub();
stubbedFakeScrollContainer = sinon
.stub(fakeScrollContainer, 'scrollTop')
.set(fakeScrollTop);
stubbedDocument = sinon
.stub(document, 'querySelector')
.returns(fakeScrollContainer);
});
afterEach(() => {
stubbedFakeScrollContainer.restore();
stubbedDocument.restore();
});
it('should do nothing if there is no active annotation thread to scroll to', () => {
createComponent();
assert.notCalled(fakeScrollTop);
});
it('should do nothing if the annotation thread to scroll to is not in DOM', () => {
createComponent();
act(() => {
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED](
{},
{ $tag: 'nonexistent' }
);
});
assert.notCalled(fakeScrollTop);
});
it('should set the scroll container `scrollTop` to derived position of thread', () => {
createComponent();
act(() => {
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED](
{},
fakeTopThread.children[3].annotation
);
});
// The third thread in a collection of threads at default height (200)
// should be at 600px. This setting of `scrollTop` is the only externally-
// observable thing that happens here...
assert.calledWith(fakeScrollTop, 600);
});
});
describe('scroll and resize event handling', () => {
let debouncedFn;
beforeEach(() => {
debouncedFn = sinon.stub();
fakeDebounce.returns(debouncedFn);
});
it('updates scroll position and window height for recalculation on container scroll', () => {
createComponent();
document
.querySelector('.js-thread-list-scroll-root')
.dispatchEvent(new Event('scroll'));
assert.calledOnce(debouncedFn);
});
it('updates scroll position and window height for recalculation on window resize', () => {
createComponent();
window.dispatchEvent(new Event('resize'));
assert.calledOnce(debouncedFn);
});
});
context('when top-level threads change', () => {
it('recalculates thread heights', () => {
const wrapper = createComponent();
// Initial render and effect hooks will trigger calculation twice
fakeDomUtil.getElementHeightWithMargins.resetHistory();
// Let's see it respond to the top-level threads changing
wrapper.setProps({ thread: fakeTopThread });
// It should check the element height for each top-level thread (assuming
// they are all onscreen, which these test threads "are")
assert.equal(
fakeDomUtil.getElementHeightWithMargins.callCount,
fakeTopThread.children.length
);
});
});
it('renders dimensional elements above and below visible threads', () => {
const wrapper = createComponent();
const upperDiv = wrapper.find('div').first();
const lowerDiv = wrapper.find('div').last();
assert.equal(upperDiv.getDOMNode().style.height, '400px');
assert.equal(lowerDiv.getDOMNode().style.height, '600px');
});
it('renders a `ThreadCard` for each visible thread', () => {
const wrapper = createComponent();
const cards = wrapper.find('ThreadCard');
assert.equal(cards.length, fakeTopThread.children.length);
});
it(
'should pass a11y checks',
checkAccessibility({
content: () => {
const wrapper = createComponent();
return wrapper;
},
})
);
});
import { createElement } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import propTypes from 'prop-types';
import debounce from 'lodash.debounce';
import shallowEqual from 'shallowequal';
import events from '../events';
import useStore from '../store/use-store';
import { isHighlight, isReply } from '../util/annotation-metadata';
import { getElementHeightWithMargins } from '../util/dom';
import { withServices } from '../util/service-context';
import {
calculateVisibleThreads,
THREAD_DIMENSION_DEFAULTS,
} from '../util/visible-threads';
import ThreadCard from './thread-card';
function getScrollContainer() {
return document.querySelector('.js-thread-list-scroll-root');
}
/**
* Render a list of threads, but only render those that are in or near the
* current browser viewport.
*/
function ThreadListOmega({ thread, $rootScope }) {
const clearSelection = useStore(store => store.clearSelection);
// Height of the scrollable container. For the moment, this is the same as
// the window height.
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
// Scroll offset of scroll container. This is updated after the scroll
// container is scrolled, with debouncing to limit update frequency.
const [scrollPosition, setScrollPosition] = useState(
getScrollContainer().scrollTop
);
// Map of thread ID to measured height of thread.
const [threadHeights, setThreadHeights] = useState({});
// ID of thread to scroll to after the next render. If the thread is not
// present, the value persists until it can be "consumed".
const [scrollToId, setScrollToId] = useState(null);
const topLevelThreads = thread.children;
const topLevelThreadIds = topLevelThreads.map(t => t.id);
const {
offscreenLowerHeight,
offscreenUpperHeight,
visibleThreads,
} = calculateVisibleThreads(
topLevelThreads,
threadHeights,
scrollPosition,
windowHeight
);
// Listen for when a new annotation is created in the application, and trigger
// a scroll to that annotation's thread card (unless highlight or reply)
useEffect(() => {
const removeListener = $rootScope.$on(
events.BEFORE_ANNOTATION_CREATED,
(event, annotation) => {
if (!isHighlight(annotation) && !isReply(annotation)) {
clearSelection();
setScrollToId(annotation.$tag);
}
}
);
return removeListener;
}, [$rootScope, clearSelection]);
// Effect to scroll a particular thread into view. This is mainly used to
// scroll a newly created annotation into view (as triggered by the
// listener for `BEFORE_ANNOTATION_CREATED`)
useEffect(() => {
if (!scrollToId) {
return;
}
const threadIndex = topLevelThreads.findIndex(t => t.id === scrollToId);
if (threadIndex === -1) {
// Thread is not currently present.
//
// When `scrollToId` is set as a result of a `BEFORE_ANNOTATION_CREATED`
// event, the annotation is not always present in the _next_ render after
// that event is received, but in another render after that. Therefore
// we wait until the annotation appears before "consuming" the scroll-to-id.
return;
}
// Clear `scrollToId` so we don't scroll again after the next render.
setScrollToId(null);
const getThreadHeight = thread =>
threadHeights[thread.id] || THREAD_DIMENSION_DEFAULTS.defaultHeight;
const yOffset = topLevelThreads
.slice(0, threadIndex)
.reduce((total, thread) => total + getThreadHeight(thread), 0);
const scrollContainer = getScrollContainer();
scrollContainer.scrollTop = yOffset;
}, [scrollToId, topLevelThreads, threadHeights]);
// Attach listeners such that whenever the scroll container is scrolled or the
// window resized, a recalculation of visible threads is triggered
useEffect(() => {
const scrollContainer = getScrollContainer();
const updateScrollPosition = debounce(
() => {
setWindowHeight(window.innerHeight);
setScrollPosition(scrollContainer.scrollTop);
},
10,
{ maxWait: 100 }
);
scrollContainer.addEventListener('scroll', updateScrollPosition);
window.addEventListener('resize', updateScrollPosition);
return () => {
scrollContainer.removeEventListener('scroll', updateScrollPosition);
window.removeEventListener('resize', updateScrollPosition);
};
}, []);
// When the set of top-level threads changes, recalculate the real rendered
// heights of thread cards and update `threadHeights` state if there are changes.
useEffect(() => {
let newHeights = {};
for (let id of topLevelThreadIds) {
const threadElement = document.getElementById(id);
if (!threadElement) {
// Thread is currently off screen.
continue;
}
const height = getElementHeightWithMargins(threadElement);
if (height !== null) {
newHeights[id] = height;
}
}
// Functional update of `threadHeights` state: `heights` is previous state
setThreadHeights(heights => {
// Merge existing and new heights.
newHeights = Object.assign({}, heights, newHeights);
// Skip update if no heights actually changed.
if (shallowEqual(heights, newHeights)) {
return heights;
}
return newHeights;
});
}, [topLevelThreadIds]);
return (
<section>
<div style={{ height: offscreenUpperHeight }} />
{visibleThreads.map(child => (
<div id={child.id} key={child.id}>
<ThreadCard thread={child} />
</div>
))}
<div style={{ height: offscreenLowerHeight }} />
</section>
);
}
ThreadListOmega.propTypes = {
/** Should annotations render extra document metadata? */
thread: propTypes.object.isRequired,
/** injected */
$rootScope: propTypes.object.isRequired,
};
ThreadListOmega.injectedProps = ['$rootScope'];
export default withServices(ThreadListOmega);
......@@ -136,6 +136,7 @@ import hypothesisApp from './components/hypothesis-app';
import sidebarContent from './components/sidebar-content';
import streamContent from './components/stream-content';
import threadList from './components/thread-list';
import threadListOmega from './components/thread-list-omega';
// Services.
......@@ -268,6 +269,7 @@ function startAngularApp(config) {
.component('svgIcon', wrapComponent(SvgIcon))
.component('thread', wrapComponent(Thread))
.component('threadList', threadList)
.component('threadListOmega', wrapComponent(threadListOmega))
.component('toastMessages', wrapComponent(ToastMessages))
.component('topBar', wrapComponent(TopBar))
......
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