Commit 2332e117 authored by Robert Knight's avatar Robert Knight Committed by Nick Stenning

Add conversation thread construction module (#3244)

This is the first part of the new threading UI implementation
whose design is outlined at https://github.com/hypothesis/h/pull/3176/

build-thread implements the logic for constructing an immutable view
model for a list of conversations (a 'Thread') from a list of
annotations and replies plus an object describing the current UI state.

This view model is then rendered by an <annotation-thread> component.

Once the other parts of the new implementation are ready, this will
replace 'threading', 'thread-filter' and the JWZ script.
parent d4418cc5
'use strict';
/** Default state for new threads, before applying filters etc. */
var DEFAULT_THREAD_STATE = {
/**
* The ID of this thread. This will be the same as the annotation ID for
* created annotations or the `$$tag` property for new annotations.
*/
id: undefined,
/**
* The Annotation which is displayed by this thread.
*
* This may be null if the existence of an annotation is implied by the
* `references` field in an annotation but the referenced parent annotation
* does not exist.
*/
annotation: undefined,
/** The parent thread ID */
parent: undefined,
/** True if this thread is collapsed, hiding replies to this annotation. */
collapsed: false,
/** True if this annotation matches the current filters. */
visible: true,
/** Replies to this annotation. */
children: [],
/**
* The total number of children of this annotation,
* including any which have been hidden by filters.
*/
totalChildren: 0,
};
/**
* Returns a persistent identifier for an Annotation.
* If the Annotation has been created on the server, it will have
* an ID assigned, otherwise we fall back to the local-only '$$tag'
* property.
*/
function id(annotation) {
return annotation.id || annotation.$$tag;
}
/**
* Link the annotation with ID `id` to its parent thread.
*
* @param {string} id
* @param {Array<string>} parents - IDs of parent annotations, from the
* annotation's `references` field.
*/
function setParentID(threads, id, parents) {
if (threads[id].parent || !parents.length) {
// Parent already assigned, do not try to change it.
return;
}
var parentID = parents[parents.length-1];
if (!threads[parentID]) {
// Parent does not exist. This may be a reply to an annotation which has
// been deleted. Create a placeholder Thread with no annotation to
// represent the missing annotation.
threads[parentID] = Object.assign({}, DEFAULT_THREAD_STATE, {
id: parentID,
children: [],
});
setParentID(threads, parentID, parents.slice(0,-1));
}
var grandParentID = threads[parentID].parent;
while (grandParentID) {
if (grandParentID === id) {
// There is a loop in the `references` field, abort.
return;
} else {
grandParentID = threads[grandParentID].parent;
}
}
threads[id].parent = parentID;
threads[parentID].children.push(threads[id]);
}
/**
* Creates a thread of annotations from a list of annotations.
*
* Given a flat list of annotations and replies, this generates a hierarchical
* thread, using the `references` field of an annotation to link together
* annotations and their replies. The `references` field is a possibly
* incomplete ordered list of the parents of an annotation, from furthest to
* nearest ancestor.
*
* @param {Array<Annotation>} annotations - The input annotations to thread.
* @return {Thread} - The input annotations threaded into a tree structure.
*/
function threadAnnotations(annotations) {
// Map of annotation ID -> container
var threads = {};
// Build mapping of annotation ID -> thread
annotations.forEach(function (annotation) {
threads[id(annotation)] = Object.assign({}, DEFAULT_THREAD_STATE, {
id: id(annotation),
annotation: annotation,
children: [],
});
});
// Set each thread's parent based on the references field
annotations.forEach(function (annotation) {
if (!annotation.references) {
return;
}
setParentID(threads, id(annotation), annotation.references);
});
// Collect the set of threads which have no parent as
// children of the thread root
var roots = [];
Object.keys(threads).map(function (id) {
if (!threads[id].parent) {
// Top-level threads are collapsed by default
threads[id].collapsed = true;
roots.push(threads[id]);
}
});
var root = {
annotation: undefined,
children: roots,
visible: true,
collapsed: false,
totalChildren: roots.length,
};
return root;
}
/**
* Returns a copy of `thread` with the thread
* and each of its children transformed by mapFn(thread).
*
* @param {Thread} thread
* @param {(Thread) => Thread} mapFn
*/
function mapThread(thread, mapFn) {
return Object.assign({}, mapFn(thread), {
children: thread.children.map(function (child) {
return mapThread(child, mapFn);
}),
});
}
/**
* Return a sorted copy of an array of threads.
*
* @param {Array<Thread>} threads - The list of threads to sort
* @param {(Annotation,Annotation) => boolean} compareFn
* @return {Array<Thread>} Sorted list of threads
*/
function sort(threads, compareFn) {
return threads.slice().sort(function (a, b) {
// Threads with no annotation always sort to the top
if (!a.annotation || !b.annotation) {
if (!a.annotation && !b.annotation) {
return 0;
} else {
return !a.annotation ? -1 : 1;
}
}
if (compareFn(a.annotation, b.annotation)) {
return -1;
} else if (compareFn(b.annotation, a.annotation)) {
return 1;
} else {
return 0;
}
});
}
/**
* Return a copy of `thread` with siblings of the top-level thread sorted according
* to `compareFn` and replies sorted by `replyCompareFn`.
*/
function sortThread(thread, compareFn, replyCompareFn) {
var children = thread.children.map(function (child) {
return sortThread(child, replyCompareFn, replyCompareFn);
});
return Object.assign({}, thread, {
children: sort(children, compareFn),
});
}
/**
* Return a copy of @p thread with the replyCount property updated.
*/
function countReplies(thread) {
var children = thread.children.map(countReplies);
return Object.assign({}, thread, {
children: children,
replyCount: children.reduce(function (total, child) {
return total + 1 + child.replyCount;
}, 0),
});
}
/** Return true if a thread has any visible children. */
function hasVisibleChildren(thread) {
return thread.children.some(function (child) {
return child.visible || hasVisibleChildren(child);
});
}
function hasSelectedChildren(thread, selected) {
return thread.children.some(function (child) {
return selected.indexOf(child.id) !== -1 ||
hasSelectedChildren(child, selected);
});
}
/**
* Default options for buildThread()
*/
var defaultOpts = {
/** List of currently selected annotation IDs */
selected: [],
/**
* List of IDs of annotations that should be shown even if they
* do not match the current filter.
*/
forceVisible: undefined,
/**
* Predicate function that returns true if an annotation should be
* displayed.
*/
filterFn: undefined,
/**
* Mapping of annotation IDs to expansion states.
*/
expanded: {},
/**
* Less-than comparison function used to compare annotations in order to sort
* the top-level thread.
*/
sortCompareFn: function (a, b) {
return a.id < b.id;
},
/**
* Less-than comparison function used to compare annotations in order to sort
* replies.
*/
replySortCompareFn: function (a, b) {
return a.created < b.created;
},
};
/**
* Project, filter and sort a list of annotations into a thread structure for
* display by the <annotation-thread> directive.
*
* buildThread() takes as inputs a flat list of annotations,
* the current visibility filters and sort function and returns
* the thread structure that should be rendered.
*
* @param {Array<Annotation>} annotations - A list of annotations and replies
* @param {Options} opts
* @return {Thread} - The root thread, whose children are the top-level
* annotations to display.
*/
function buildThread(annotations, opts) {
opts = Object.assign({}, defaultOpts, opts);
var thread = threadAnnotations(annotations);
// Mark annotations as visible or hidden depending on whether
// they are being edited and whether they match the current filter
// criteria
var shouldShowThread = function (annotation) {
if (opts.forceVisible && opts.forceVisible.indexOf(id(annotation)) !== -1) {
return true;
}
if (opts.selected.length > 0 &&
opts.selected.indexOf(id(annotation)) === -1) {
return false;
}
if (opts.filterFn && !opts.filterFn(annotation)) {
return false;
}
return true;
};
// Set the visibility of threads based on whether they match
// the current search filter
thread = mapThread(thread, function (thread) {
return Object.assign({}, thread, {
visible: thread.visible &&
thread.annotation &&
shouldShowThread(thread.annotation),
});
});
// Expand any threads which:
// 1) Have been explicitly expanded OR
// 2) Have children matching the filter OR
// 3) Contain children which have been selected
thread = mapThread(thread, function (thread) {
var id = thread.id;
// If the thread was explicitly expanded or collapsed, respect that option
if (opts.expanded.hasOwnProperty(id)) {
return Object.assign({}, thread, {collapsed: !opts.expanded[id]});
}
var hasUnfilteredChildren = opts.filterFn && hasVisibleChildren(thread);
return Object.assign({}, thread, {
collapsed: thread.collapsed &&
!hasUnfilteredChildren &&
!hasSelectedChildren(thread, opts.selected)
});
});
// Remove top-level threads which contain no visible annotations
thread.children = thread.children.filter(function (child) {
return child.visible || hasVisibleChildren(child);
});
// Sort the root thread according to the current search criteria
thread = sortThread(thread, opts.sortCompareFn, opts.replySortCompareFn);
// Update reply counts
thread = countReplies(thread);
return thread;
}
module.exports = buildThread;
'use strict';
var buildThread = require('../build-thread');
// Fixture with two top level annotations and one reply
var SIMPLE_FIXTURE = [{
id: '1',
text: 'first annotation',
references: [],
},{
id: '2',
text: 'second annotation',
references: [],
},{
id: '3',
text: 'third annotation',
references: [1],
}];
/**
* Filter a Thread, keeping only properties in `keys` for each thread.
*
* @param {Thread} thread - Annotation thread generated by buildThread()
* @param {Array<string>} keys - The keys to retain
*/
function filter(thread, keys) {
var result = {};
keys.forEach(function (key) {
if (key === 'children') {
result[key] = thread[key].map(function (child) {
return filter(child, keys);
});
} else {
result[key] = thread[key];
}
});
return result;
}
/**
* Threads a list of annotations and removes keys from the resulting Object
* which do not match `keys`.
*
* @param {Array<Annotation>} fixture - List of annotations to thread
* @param {Object?} opts - Options to pass to buildThread()
* @param {Array<string>?} keys - List of keys to keep in the output
*/
function createThread(fixture, opts, keys) {
opts = opts || {};
keys = keys || [];
var rootThread = filter(buildThread(fixture, opts),
keys.concat(['annotation', 'children']));
return rootThread.children;
}
describe('build-thread', function () {
describe('threading', function () {
it('arranges parents and children as a thread', function () {
var thread = createThread(SIMPLE_FIXTURE);
assert.deepEqual(thread, [{
annotation: SIMPLE_FIXTURE[0],
children: [{
annotation: SIMPLE_FIXTURE[2],
children: [],
}],
},{
annotation: SIMPLE_FIXTURE[1],
children: [],
}]);
});
it('threads nested replies', function () {
var NESTED_FIXTURE = [{
id: '1',
references: [],
},{
id: '2',
references: ['1'],
},{
id: '3',
references: ['1','2']
}];
var thread = createThread(NESTED_FIXTURE);
assert.deepEqual(thread, [{
annotation: NESTED_FIXTURE[0],
children: [{
annotation: NESTED_FIXTURE[1],
children: [{
annotation: NESTED_FIXTURE[2],
children: [],
}]
}]
}]);
});
it('handles loops implied by the reply field', function () {
var LOOPED_FIXTURE = [{
id: '1',
references: ['2'],
},{
id: '2',
references: ['1'],
}];
var thread = createThread(LOOPED_FIXTURE);
assert.deepEqual(thread, [{
annotation: LOOPED_FIXTURE[1],
children: [{
annotation: LOOPED_FIXTURE[0],
children: [],
}],
}]);
});
it('handles missing parent annotations', function () {
var fixture = [{
id: '1',
references: ['3'],
}];
var thread = createThread(fixture);
assert.deepEqual(thread, [{
annotation: undefined,
children: [{annotation: fixture[0], children: []}],
}]);
});
it('handles missing replies', function () {
var fixture = [{
id: '1',
references: ['3','2'],
},{
id: '3',
}];
var thread = createThread(fixture);
assert.deepEqual(thread, [{
annotation: fixture[1],
children: [{
annotation: undefined,
children: [{
annotation: fixture[0],
children: [],
}],
}],
}]);
});
it('threads new annotations which have tags but not IDs', function () {
var fixture = [{
$$tag: 't1',
}];
var thread = createThread(fixture);
assert.deepEqual(thread, [{annotation: fixture[0], children: []}]);
});
it('threads new replies which have tags but not IDs', function () {
var fixture = [{
id: '1',
$$tag: 't1',
},{
$$tag: 't2',
references: ['1'],
}];
var thread = createThread(fixture, {}, ['parent']);
assert.deepEqual(thread, [{
annotation: fixture[0],
children: [{
annotation: fixture[1],
children: [],
parent: '1',
}],
parent: undefined,
}]);
});
});
describe('collapsed state', function () {
it('collapses top-level annotations by default', function () {
var thread = buildThread(SIMPLE_FIXTURE, {});
assert.isTrue(thread.children[0].collapsed);
});
it('expands replies by default', function () {
var thread = buildThread(SIMPLE_FIXTURE, {});
assert.isFalse(thread.children[0].children[0].collapsed);
});
it('expands threads which have been explicitly expanded', function () {
var thread = buildThread(SIMPLE_FIXTURE, {
expanded: {'1': true},
});
assert.isFalse(thread.children[0].collapsed);
});
it('collapses replies which have been explicitly collapsed', function () {
var thread = buildThread(SIMPLE_FIXTURE, {
expanded: {'3': false},
});
assert.isTrue(thread.children[0].children[0].collapsed);
});
it('expands threads with selected children', function () {
var thread = buildThread(SIMPLE_FIXTURE, {selected: ['3']});
assert.isFalse(thread.children[0].collapsed);
});
it('expands threads with visible children', function () {
// Simulate performing a search which only matches the top-level
// annotation, not its reply, and then clicking
// 'View N more in conversation' to show the complete discussion thread
var thread = buildThread(SIMPLE_FIXTURE, {
filterFn: function (annot) {
return annot.text.match(/first/);
},
forceVisible: ['3']
});
assert.isFalse(thread.children[0].collapsed);
});
});
describe('filtering', function () {
context('when there is an active filter', function () {
it('shows only annotations that match the filter', function () {
var threads = createThread(SIMPLE_FIXTURE, {
filterFn: function (annot) { return annot.text.match(/first/); },
}, ['visible']);
assert.deepEqual(threads, [{
annotation: SIMPLE_FIXTURE[0],
children: [{
annotation: SIMPLE_FIXTURE[2],
children: [],
visible: false,
}],
visible: true,
}]);
});
it('shows threads containing replies that match the filter', function () {
var threads = createThread(SIMPLE_FIXTURE, {
filterFn: function (annot) { return annot.text.match(/third/); },
}, ['visible']);
assert.deepEqual(threads, [{
annotation: SIMPLE_FIXTURE[0],
children: [{
annotation: SIMPLE_FIXTURE[2],
children: [],
visible: true,
}],
visible: false,
}]);
});
});
context('when there is a selection', function () {
it('shows only selected annotations', function () {
var thread = createThread(SIMPLE_FIXTURE, {
selected: ['1']
});
assert.deepEqual(thread, [{
annotation: SIMPLE_FIXTURE[0],
children: [{
annotation: SIMPLE_FIXTURE[2],
children: [],
}]
}]);
});
});
});
describe('sort order', function () {
var annots = function (threads) {
return threads.map(function (thread) { return thread.annotation; });
};
it('sorts top-level annotations using the comparison function', function () {
var fixture = [{
id: '1',
updated: 100,
references: [],
},{
id: '2',
updated: 200,
references: [],
}];
var thread = createThread(fixture, {
sortCompareFn: function (a, b) {
return a.updated > b.updated;
},
});
assert.deepEqual(annots(thread), [fixture[1], fixture[0]]);
});
it('sorts replies by creation date', function () {
var fixture = [{
id: '1',
references: [],
updated: 0,
},{
id: '3',
references: ['1'],
created: 100
},{
id: '2',
references: ['1'],
created: 50,
}];
var thread = createThread(fixture, {
sortCompareFn: function (a, b) { return a.id < b.id; }
});
assert.deepEqual(annots(thread[0].children),
[fixture[2], fixture[1]]);
});
});
describe('reply counts', function () {
it('populates the reply count field', function () {
assert.deepEqual(createThread(SIMPLE_FIXTURE, {}, ['replyCount']), [{
annotation: SIMPLE_FIXTURE[0],
children: [{
annotation: SIMPLE_FIXTURE[2],
children: [],
replyCount: 0,
}],
replyCount: 1,
},{
annotation: SIMPLE_FIXTURE[1],
children: [],
replyCount: 0
}]);
});
});
});
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