Commit 453e0a52 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Accommodate sorting of threads with no annotation at root level

Refactor sorting functions to operate on threads instead of annotations.
Establish logic for retrieving and comparing "root" annotations, which
aren't always at the top level of a thread for "headless threads".
parent aa88f7dc
...@@ -170,44 +170,16 @@ function mapThread(thread, mapFn) { ...@@ -170,44 +170,16 @@ function mapThread(thread, mapFn) {
}); });
} }
/**
* Return a sorted copy of an array of threads.
*
* @param {Thread[]} threads - The list of threads to sort
* @param {(a: Annotation, b: Annotation) => boolean} compareFn
* @return {Thread[]} Sorted list of threads
*/
function sort(threads, compareFn) {
return threads.slice().sort((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 new `Thread` object with all (recursive) `children` arrays sorted. * Return a new `Thread` object with all (recursive) `children` arrays sorted.
* Sort the children of top-level threads using `compareFn` and all other * Sort the children of top-level threads using `compareFn` and all other
* children using `replyCompareFn`. * children using `replyCompareFn`.
* *
* @param {Thread} thread * @param {Thread} thread
* @param {(a: Annotation, b: Annotation) => boolean} compareFn - Less-than * @param {(a: Thread, b: Thread) => number} compareFn - comparison function
* comparison function for sorting top-level annotations * for sorting top-level annotations
* @param {(a: Annotation, b: Annotation) => boolean} replyCompareFn - Less-than * @param {(a: Thread, b: Thread) => number} replyCompareFn - comparison
* comparison function for sorting replies * function for sorting replies
* @return {Thread} * @return {Thread}
*/ */
function sortThread(thread, compareFn, replyCompareFn) { function sortThread(thread, compareFn, replyCompareFn) {
...@@ -215,7 +187,9 @@ function sortThread(thread, compareFn, replyCompareFn) { ...@@ -215,7 +187,9 @@ function sortThread(thread, compareFn, replyCompareFn) {
sortThread(child, replyCompareFn, replyCompareFn) sortThread(child, replyCompareFn, replyCompareFn)
); );
return { ...thread, children: sort(children, compareFn) }; const sortedChildren = children.slice().sort(compareFn);
return { ...thread, children: sortedChildren };
} }
/** /**
...@@ -253,38 +227,38 @@ function hasVisibleChildren(thread) { ...@@ -253,38 +227,38 @@ function hasVisibleChildren(thread) {
} }
/** /**
* @typedef Options * @typedef BuildThreadOptions
* @prop {string[]} selected - List of currently-selected annotation ids, from * @prop {Object.<string, boolean>} expanded - Map of thread id => expansion state
* the data store
* @prop {string[]} forcedVisible - List of $tags of annotations that have * @prop {string[]} forcedVisible - List of $tags of annotations that have
* been explicitly expanded by the user, even if they don't * been explicitly expanded by the user, even if they don't
* match current filters * match current filters
* @prop {string[]} selected - List of currently-selected annotation ids, from
* the data store
* @prop {(a: Thread, b: Thread) => number} sortCompareFn - comparison
* function for sorting top-level annotations
* @prop {(a: Annotation) => boolean} [filterFn] - Predicate function that * @prop {(a: Annotation) => boolean} [filterFn] - Predicate function that
* returns `true` if annotation should be visible * returns `true` if annotation should be visible
* @prop {(t: Thread) => boolean} [threadFilterFn] - Predicate function that * @prop {(t: Thread) => boolean} [threadFilterFn] - Predicate function that
* returns `true` if the annotation should be included in the thread tree * returns `true` if the annotation should be included in the thread tree
* @prop {Object.<string, boolean>} expanded - Map of thread id => expansion state
* @prop {(a: Annotation, b: Annotation) => boolean} sortCompareFn - Less-than
* comparison function for sorting top-level annotations
* @prop {(a: Annotation, b: Annotation) => boolean} replySortCompareFn - Less-than
* comparison function for sorting replies
*/ */
/** /**
* Default options for buildThread() * Sort by reply (Annotation) `created` date
* *
* @type {Options} * @param {Thread} a
* @param {Thread} b
* @return {number}
*/ */
const defaultOpts = { const replySortCompareFn = (a, b) => {
selected: [], if (!a.annotation || !b.annotation) {
expanded: {}, return 0;
forcedVisible: [], }
sortCompareFn: (a, b) => { if (a.annotation.created < b.annotation.created) {
return a.$tag < b.$tag; return -1;
}, } else if (a.annotation.created > b.annotation.created) {
replySortCompareFn: (a, b) => { return 1;
return a.created < b.created; }
}, return 0;
}; };
/** /**
...@@ -306,15 +280,13 @@ const defaultOpts = { ...@@ -306,15 +280,13 @@ const defaultOpts = {
* a user). * a user).
* *
* @param {Annotation[]} annotations - A list of annotations and replies * @param {Annotation[]} annotations - A list of annotations and replies
* @param {Partial<Options>} options * @param {BuildThreadOptions} options
* @return {Thread} - The root thread, whose children are the top-level * @return {Thread} - The root thread, whose children are the top-level
* annotations to display. * annotations to display.
*/ */
export default function buildThread(annotations, options) { export default function buildThread(annotations, options) {
const opts = { ...defaultOpts, ...options }; const hasSelection = options.selected.length > 0;
const hasForcedVisible = options.forcedVisible.length > 0;
const hasSelection = opts.selected.length > 0;
const hasForcedVisible = opts.forcedVisible.length > 0;
let thread = threadAnnotations(annotations); let thread = threadAnnotations(annotations);
...@@ -322,18 +294,18 @@ export default function buildThread(annotations, options) { ...@@ -322,18 +294,18 @@ export default function buildThread(annotations, options) {
// Remove threads (annotations) that are not selected or // Remove threads (annotations) that are not selected or
// are not forced-visible // are not forced-visible
thread.children = thread.children.filter(child => { thread.children = thread.children.filter(child => {
const isSelected = opts.selected.includes(child.id); const isSelected = options.selected.includes(child.id);
const isForcedVisible = const isForcedVisible =
hasForcedVisible && hasForcedVisible &&
child.annotation && child.annotation &&
opts.forcedVisible.includes(child.annotation.$tag); options.forcedVisible.includes(child.annotation.$tag);
return isSelected || isForcedVisible; return isSelected || isForcedVisible;
}); });
} }
if (opts.threadFilterFn) { if (options.threadFilterFn) {
// Remove threads not matching thread-level filters // Remove threads not matching thread-level filters
thread.children = thread.children.filter(opts.threadFilterFn); thread.children = thread.children.filter(options.threadFilterFn);
} }
// Set visibility for threads // Set visibility for threads
...@@ -342,17 +314,17 @@ export default function buildThread(annotations, options) { ...@@ -342,17 +314,17 @@ export default function buildThread(annotations, options) {
if (!thread.annotation) { if (!thread.annotation) {
threadIsVisible = false; // Nothing to show threadIsVisible = false; // Nothing to show
} else if (opts.filterFn) { } else if (options.filterFn) {
if ( if (
hasForcedVisible && hasForcedVisible &&
opts.forcedVisible.includes(thread.annotation.$tag) options.forcedVisible.includes(thread.annotation.$tag)
) { ) {
// This annotation may or may not match the filter, but we should // This annotation may or may not match the filter, but we should
// make sure it is visible because it has been forced visible by user // make sure it is visible because it has been forced visible by user
threadIsVisible = true; threadIsVisible = true;
} else { } else {
// Otherwise, visibility depends on whether it matches the filter // Otherwise, visibility depends on whether it matches the filter
threadIsVisible = !!opts.filterFn(thread.annotation); threadIsVisible = !!options.filterFn(thread.annotation);
} }
} }
return { ...thread, visible: threadIsVisible }; return { ...thread, visible: threadIsVisible };
...@@ -369,20 +341,22 @@ export default function buildThread(annotations, options) { ...@@ -369,20 +341,22 @@ export default function buildThread(annotations, options) {
collapsed: thread.collapsed, collapsed: thread.collapsed,
}; };
if (opts.expanded.hasOwnProperty(thread.id)) { if (options.expanded.hasOwnProperty(thread.id)) {
// This thread has been explicitly expanded/collapsed by user // This thread has been explicitly expanded/collapsed by user
threadStates.collapsed = !opts.expanded[thread.id]; threadStates.collapsed = !options.expanded[thread.id];
} else { } else {
// If annotations are filtered, and at least one child matches // If annotations are filtered, and at least one child matches
// those filters, make sure thread is not collapsed // those filters, make sure thread is not collapsed
const hasUnfilteredChildren = opts.filterFn && hasVisibleChildren(thread); const hasUnfilteredChildren =
options.filterFn && hasVisibleChildren(thread);
threadStates.collapsed = thread.collapsed && !hasUnfilteredChildren; threadStates.collapsed = thread.collapsed && !hasUnfilteredChildren;
} }
return { ...thread, ...threadStates }; return { ...thread, ...threadStates };
}); });
// Sort the root thread according to the current search criteria // Sort the root thread according to the current search criteria
thread = sortThread(thread, opts.sortCompareFn, opts.replySortCompareFn); //const compareFn = options.sortCompareFn ?? defaultSortCompareFn;
thread = sortThread(thread, options.sortCompareFn, replySortCompareFn);
// Update `replyCount` and `depth` properties // Update `replyCount` and `depth` properties
thread = countRepliesAndDepth(thread, -1); thread = countRepliesAndDepth(thread, -1);
......
...@@ -23,6 +23,13 @@ const SIMPLE_FIXTURE = [ ...@@ -23,6 +23,13 @@ const SIMPLE_FIXTURE = [
}, },
]; ];
const defaultBuildThreadOpts = {
expanded: {},
forcedVisible: [],
selected: [],
sortCompareFn: () => 0,
};
/** /**
* Filter a Thread, keeping only properties in `keys` for each thread. * Filter a Thread, keeping only properties in `keys` for each thread.
* *
...@@ -51,8 +58,8 @@ function filter(thread, keys) { ...@@ -51,8 +58,8 @@ function filter(thread, keys) {
* @param {Object?} opts - Options to pass to buildThread() * @param {Object?} opts - Options to pass to buildThread()
* @param {Array<string>?} keys - List of keys to keep in the output * @param {Array<string>?} keys - List of keys to keep in the output
*/ */
function createThread(fixture, opts, keys) { function createThread(fixture, options, keys) {
opts = opts || {}; const opts = { ...defaultBuildThreadOpts, ...options };
keys = keys || []; keys = keys || [];
const rootThread = filter( const rootThread = filter(
...@@ -62,9 +69,9 @@ function createThread(fixture, opts, keys) { ...@@ -62,9 +69,9 @@ function createThread(fixture, opts, keys) {
return rootThread.children; return rootThread.children;
} }
describe('sidebar/util/build-thread', function () { describe('sidebar/util/build-thread', () => {
describe('threading', function () { describe('threading', () => {
it('arranges parents and children as a thread', function () { it('arranges parents and children as a thread', () => {
const thread = createThread(SIMPLE_FIXTURE); const thread = createThread(SIMPLE_FIXTURE);
assert.deepEqual(thread, [ assert.deepEqual(thread, [
{ {
...@@ -83,7 +90,7 @@ describe('sidebar/util/build-thread', function () { ...@@ -83,7 +90,7 @@ describe('sidebar/util/build-thread', function () {
]); ]);
}); });
it('threads nested replies', function () { it('threads nested replies', () => {
const NESTED_FIXTURE = [ const NESTED_FIXTURE = [
{ {
id: '1', id: '1',
...@@ -118,7 +125,47 @@ describe('sidebar/util/build-thread', function () { ...@@ -118,7 +125,47 @@ describe('sidebar/util/build-thread', function () {
]); ]);
}); });
it('handles loops implied by the reply field', function () { it('sorts replies by their created dates', () => {
const time = 'T15:58:20.308658+00:00';
// This set of annotations has a missing implied annotation with id '3' at depth 1
const REPLY_FIXTURE = [
{
id: '1',
references: [],
created: `2020-08-06${time}`,
},
{
id: '2',
references: ['1'],
created: `2021-01-06${time}`,
},
{
id: '4',
references: ['1'],
created: `2021-01-01${time}`,
},
{
id: '6',
references: ['1', '3'],
created: `2021-01-10${time}`,
},
{
id: '5',
references: ['1'],
created: `2021-01-02${time}`,
},
];
const thread = buildThread(REPLY_FIXTURE, defaultBuildThreadOpts);
const rootThread = thread.children[0];
// We want to look at the ordering of the replies at the first reply level
const replyIds = rootThread.children.map(child => child.annotation?.id);
// This will be ordered by creation date, desc, except for the thread
// with the missing annotation (reply)
assert.deepEqual(replyIds, ['4', '5', '2', undefined]);
});
it('handles loops implied by the reply field', () => {
const LOOPED_FIXTURE = [ const LOOPED_FIXTURE = [
{ {
id: '1', id: '1',
...@@ -177,7 +224,7 @@ describe('sidebar/util/build-thread', function () { ...@@ -177,7 +224,7 @@ describe('sidebar/util/build-thread', function () {
]); ]);
}); });
it('handles missing parent annotations', function () { it('handles missing parent annotations', () => {
const fixture = [ const fixture = [
{ {
id: '1', id: '1',
...@@ -196,7 +243,7 @@ describe('sidebar/util/build-thread', function () { ...@@ -196,7 +243,7 @@ describe('sidebar/util/build-thread', function () {
]); ]);
}); });
it('handles missing replies', function () { it('handles missing replies', () => {
const fixture = [ const fixture = [
{ {
id: '1', id: '1',
...@@ -225,7 +272,7 @@ describe('sidebar/util/build-thread', function () { ...@@ -225,7 +272,7 @@ describe('sidebar/util/build-thread', function () {
]); ]);
}); });
it('threads new annotations which have tags but not IDs', function () { it('threads new annotations which have tags but not IDs', () => {
const fixture = [ const fixture = [
{ {
$tag: 't1', $tag: 't1',
...@@ -235,7 +282,7 @@ describe('sidebar/util/build-thread', function () { ...@@ -235,7 +282,7 @@ describe('sidebar/util/build-thread', function () {
assert.deepEqual(thread, [{ annotation: fixture[0], children: [] }]); assert.deepEqual(thread, [{ annotation: fixture[0], children: [] }]);
}); });
it('threads new replies which have tags but not IDs', function () { it('threads new replies which have tags but not IDs', () => {
const fixture = [ const fixture = [
{ {
id: '1', id: '1',
...@@ -263,54 +310,56 @@ describe('sidebar/util/build-thread', function () { ...@@ -263,54 +310,56 @@ describe('sidebar/util/build-thread', function () {
}); });
}); });
describe('collapsed state', function () { describe('collapsed state', () => {
it('collapses top-level annotations by default', function () { it('collapses top-level annotations by default', () => {
const thread = buildThread(SIMPLE_FIXTURE, {}); const thread = buildThread(SIMPLE_FIXTURE, defaultBuildThreadOpts);
assert.isTrue(thread.children[0].collapsed); assert.isTrue(thread.children[0].collapsed);
}); });
it('expands replies by default', function () { it('expands replies by default', () => {
const thread = buildThread(SIMPLE_FIXTURE, {}); const thread = buildThread(SIMPLE_FIXTURE, defaultBuildThreadOpts);
assert.isFalse(thread.children[0].children[0].collapsed); assert.isFalse(thread.children[0].children[0].collapsed);
}); });
it('expands threads which have been explicitly expanded', function () { it('expands threads which have been explicitly expanded', () => {
const thread = buildThread(SIMPLE_FIXTURE, { const opts = { ...defaultBuildThreadOpts, expanded: { 1: true } };
expanded: { 1: true },
}); const thread = buildThread(SIMPLE_FIXTURE, opts);
assert.isFalse(thread.children[0].collapsed); assert.isFalse(thread.children[0].collapsed);
}); });
it('collapses replies which have been explicitly collapsed', function () { it('collapses replies which have been explicitly collapsed', () => {
const thread = buildThread(SIMPLE_FIXTURE, { const opts = { ...defaultBuildThreadOpts, expanded: { 3: false } };
expanded: { 3: false },
}); const thread = buildThread(SIMPLE_FIXTURE, opts);
assert.isTrue(thread.children[0].children[0].collapsed); assert.isTrue(thread.children[0].children[0].collapsed);
}); });
it('expands threads with visible children', function () { it('expands threads with visible children', () => {
// Simulate performing a search which only matches the top-level // Simulate performing a search which only matches the top-level
// annotation, not its reply, and then clicking // annotation, not its reply, and then clicking
// 'View N more in conversation' to show the complete discussion thread // 'View N more in conversation' to show the complete discussion thread
const thread = buildThread(SIMPLE_FIXTURE, { const opts = {
filterFn: function (annot) { ...defaultBuildThreadOpts,
return annot.text.match(/first/); filterFn: annot => annot.text.match(/first/),
},
forcedVisible: ['3'], forcedVisible: ['3'],
}); };
const thread = buildThread(SIMPLE_FIXTURE, opts);
assert.isFalse(thread.children[0].collapsed); assert.isFalse(thread.children[0].collapsed);
}); });
}); });
describe('filtering', function () { describe('filtering', () => {
context('when there is an active filter', function () { context('when there is an active filter', () => {
it('shows only annotations that match the filter', function () { it('shows only annotations that match the filter', () => {
const threads = createThread( const threads = createThread(
SIMPLE_FIXTURE, SIMPLE_FIXTURE,
{ {
filterFn: function (annot) { filterFn: annot => annot.text.match(/first/),
return annot.text.match(/first/);
},
}, },
['visible'] ['visible']
); );
...@@ -329,13 +378,11 @@ describe('sidebar/util/build-thread', function () { ...@@ -329,13 +378,11 @@ describe('sidebar/util/build-thread', function () {
]); ]);
}); });
it('shows threads containing replies that match the filter', function () { it('shows threads containing replies that match the filter', () => {
const threads = createThread( const threads = createThread(
SIMPLE_FIXTURE, SIMPLE_FIXTURE,
{ {
filterFn: function (annot) { filterFn: annot => annot.text.match(/third/),
return annot.text.match(/third/);
},
}, },
['visible'] ['visible']
); );
...@@ -355,8 +402,8 @@ describe('sidebar/util/build-thread', function () { ...@@ -355,8 +402,8 @@ describe('sidebar/util/build-thread', function () {
}); });
}); });
context('when there is a selection', function () { context('when there is a selection', () => {
it('shows only selected annotations', function () { it('shows only selected annotations', () => {
const thread = createThread(SIMPLE_FIXTURE, { const thread = createThread(SIMPLE_FIXTURE, {
selected: ['1'], selected: ['1'],
}); });
...@@ -373,7 +420,7 @@ describe('sidebar/util/build-thread', function () { ...@@ -373,7 +420,7 @@ describe('sidebar/util/build-thread', function () {
]); ]);
}); });
it('shows forced-visible annotations, also', function () { it('shows forced-visible annotations, also', () => {
const thread = createThread(SIMPLE_FIXTURE, { const thread = createThread(SIMPLE_FIXTURE, {
selected: ['1'], selected: ['1'],
forcedVisible: ['2'], forcedVisible: ['2'],
...@@ -396,7 +443,7 @@ describe('sidebar/util/build-thread', function () { ...@@ -396,7 +443,7 @@ describe('sidebar/util/build-thread', function () {
}); });
}); });
describe('thread filtering', function () { describe('thread filtering', () => {
const fixture = [ const fixture = [
{ {
id: '1', id: '1',
...@@ -410,11 +457,9 @@ describe('sidebar/util/build-thread', function () { ...@@ -410,11 +457,9 @@ describe('sidebar/util/build-thread', function () {
}, },
]; ];
it('shows only annotations matching the thread filter', function () { it('shows only annotations matching the thread filter', () => {
const thread = createThread(fixture, { const thread = createThread(fixture, {
threadFilterFn: function (thread) { threadFilterFn: thread => metadata.isPageNote(thread.annotation),
return metadata.isPageNote(thread.annotation);
},
}); });
assert.deepEqual(thread, [ assert.deepEqual(thread, [
...@@ -427,36 +472,36 @@ describe('sidebar/util/build-thread', function () { ...@@ -427,36 +472,36 @@ describe('sidebar/util/build-thread', function () {
}); });
}); });
describe('sort order', function () { describe('sort order', () => {
const annots = function (threads) { const annots = threads => threads.map(thread => thread.annotation);
return threads.map(function (thread) {
return thread.annotation;
});
};
it('sorts top-level annotations using the comparison function', function () { it('sorts top-level annotations using the comparison function', () => {
const fixture = [ const fixture = [
{ {
id: '1', id: '1',
updated: 100, updated: '2021-01-01',
references: [], references: [],
}, },
{ {
id: '2', id: '2',
updated: 200, updated: '2021-01-02',
references: [], references: [],
}, },
]; ];
const thread = createThread(fixture, { const thread = createThread(fixture, {
sortCompareFn: function (a, b) { sortCompareFn: (a, b) => {
return a.updated > b.updated; if (a.annotation.updated < b.annotation.updated) {
return 1;
}
return -1;
}, },
}); });
assert.deepEqual(annots(thread), [fixture[1], fixture[0]]); assert.deepEqual(annots(thread), [fixture[1], fixture[0]]);
}); });
it('sorts replies by creation date', function () { it('sorts replies by creation date', () => {
const fixture = [ const fixture = [
{ {
id: '1', id: '1',
...@@ -475,16 +520,14 @@ describe('sidebar/util/build-thread', function () { ...@@ -475,16 +520,14 @@ describe('sidebar/util/build-thread', function () {
}, },
]; ];
const thread = createThread(fixture, { const thread = createThread(fixture, {
sortCompareFn: function (a, b) { sortCompareFn: (a, b) => b.annotation.id - a.annotation.id,
return a.id < b.id;
},
}); });
assert.deepEqual(annots(thread[0].children), [fixture[2], fixture[1]]); assert.deepEqual(annots(thread[0].children), [fixture[2], fixture[1]]);
}); });
}); });
describe('reply counts', function () { describe('reply counts', () => {
it('populates the reply count field', function () { it('populates the reply count field', () => {
assert.deepEqual(createThread(SIMPLE_FIXTURE, {}, ['replyCount']), [ assert.deepEqual(createThread(SIMPLE_FIXTURE, {}, ['replyCount']), [
{ {
annotation: SIMPLE_FIXTURE[0], annotation: SIMPLE_FIXTURE[0],
...@@ -506,13 +549,13 @@ describe('sidebar/util/build-thread', function () { ...@@ -506,13 +549,13 @@ describe('sidebar/util/build-thread', function () {
}); });
}); });
describe('depth', function () { describe('depth', () => {
it('is 0 for annotations', function () { it('is 0 for annotations', () => {
const thread = createThread(SIMPLE_FIXTURE, {}, ['depth']); const thread = createThread(SIMPLE_FIXTURE, {}, ['depth']);
assert.deepEqual(thread[0].depth, 0); assert.deepEqual(thread[0].depth, 0);
}); });
it('is 1 for top-level replies', function () { it('is 1 for top-level replies', () => {
const thread = createThread(SIMPLE_FIXTURE, {}, ['depth']); const thread = createThread(SIMPLE_FIXTURE, {}, ['depth']);
assert.deepEqual(thread[0].children[0].depth, 1); assert.deepEqual(thread[0].children[0].depth, 1);
}); });
......
import * as annotationFixtures from '../../test/annotation-fixtures'; import * as annotationFixtures from '../../test/annotation-fixtures';
import uiConstants from '../../ui-constants'; import uiConstants from '../../ui-constants';
import threadAnnotations from '../thread-annotations'; import threadAnnotations from '../thread-annotations';
import { sorters } from '../thread-sorters';
import { $imports } from '../thread-annotations'; import { $imports } from '../thread-annotations';
import immutable from '../immutable'; import immutable from '../immutable';
...@@ -100,71 +101,15 @@ describe('sidebar/utils/thread-annotations', () => { ...@@ -100,71 +101,15 @@ describe('sidebar/utils/thread-annotations', () => {
}); });
describe('when sort order changes', () => { describe('when sort order changes', () => {
function sortBy(annotations, sortCompareFn) { ['Location', 'Oldest', 'Newest'].forEach(testCase => {
return annotations.slice().sort((a, b) => { it(`uses the appropriate sorting function when sorting by ${testCase}`, () => {
if (sortCompareFn(a, b)) { fakeThreadState.selection.sortKey = testCase;
return -1;
}
return sortCompareFn(b, a) ? 1 : 0;
});
}
// Format TextPositionSelector for the given position `pos`
function targetWithPos(pos) {
return [
{
selector: [{ type: 'TextPositionSelector', start: pos }],
},
];
}
const annotations = [
{
target: targetWithPos(1),
updated: 20,
},
{
target: targetWithPos(100),
updated: 100,
},
{
target: targetWithPos(50),
updated: 50,
},
{
target: targetWithPos(20),
updated: 10,
},
];
[
{
order: 'Location',
expectedOrder: [0, 3, 2, 1],
},
{
order: 'Oldest',
expectedOrder: [3, 0, 2, 1],
},
{
order: 'Newest',
expectedOrder: [1, 2, 0, 3],
},
].forEach(testCase => {
it(`sorts correctly when sorting by ${testCase.order}`, () => {
fakeThreadState.selection.sortKey = testCase.order;
threadAnnotations(fakeThreadState); threadAnnotations(fakeThreadState);
// The sort compare fn passed to `buildThread` // The sort compare fn passed to `buildThread`
const sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn; const sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn;
assert.equal(sortCompareFn, sorters[testCase]);
// Sort the test annotations by the sort compare fn that would be
// used by `build-thread` and make sure it's as expected
const actualOrder = sortBy(annotations, sortCompareFn).map(annot =>
annotations.indexOf(annot)
);
assert.deepEqual(actualOrder, testCase.expectedOrder);
}); });
}); });
}); });
......
import { sorters, $imports } from '../thread-sorters';
describe('sidebar/util/thread-sorters', () => {
let fakeRootAnnotations;
let fakeLocation;
beforeEach(() => {
// The thread argument passed to `Newest` or `Oldest` sorting functions
// gets wrapped with an additional Array by `*RootAnnotationDate` before
// being passed on to `rootAnnotations`. This unwraps that extra array
// and returns the original first argument to `*RootAnnotationDate`
fakeRootAnnotations = sinon.stub().callsFake(threads => threads[0]);
fakeLocation = sinon.stub().callsFake(annotation => annotation.location);
$imports.$mock({
'./annotation-metadata': { location: fakeLocation },
'./thread': { rootAnnotations: fakeRootAnnotations },
});
});
afterEach(() => {
$imports.$restore();
});
describe('sorting by newest annotation thread first', () => {
[
{
a: [{ updated: 40 }, { updated: 5 }],
b: [{ updated: 20 }, { updated: 3 }],
expected: -1,
},
{
a: [{ updated: 20 }, { updated: 3 }],
b: [{ updated: 20 }, { updated: 3 }],
expected: 0,
},
{
a: [{ updated: 20 }, { updated: 3 }],
b: [{ updated: 40 }, { updated: 5 }],
expected: 1,
},
].forEach(testCase => {
it('sorts by newest updated root annotation', () => {
// Disable eslint: `sorters` properties start with capital letters
// to match their displayed sort option values
/* eslint-disable-next-line new-cap */
assert.equal(sorters.Newest(testCase.a, testCase.b), testCase.expected);
});
});
});
describe('sorting by oldest annotation thread first', () => {
[
{
a: [{ updated: 20 }, { updated: 5 }],
b: [{ updated: 40 }, { updated: 3 }],
expected: 1,
},
{
a: [{ updated: 20 }, { updated: 3 }],
b: [{ updated: 20 }, { updated: 3 }],
expected: 0,
},
{
a: [{ updated: 40 }, { updated: 3 }],
b: [{ updated: 20 }, { updated: 5 }],
expected: -1,
},
].forEach(testCase => {
it('sorts by oldest updated root annotation', () => {
// Disable eslint: `sorters` properties start with capital letters
// to match their displayed sort option values
/* eslint-disable-next-line new-cap */
assert.equal(sorters.Oldest(testCase.a, testCase.b), testCase.expected);
});
});
});
describe('sorting by document location', () => {
[
{
a: { annotation: { location: 5 } },
b: { annotation: { location: 10 } },
expected: -1,
},
{
a: { annotation: { location: 10 } },
b: { annotation: { location: 10 } },
expected: 0,
},
{
a: { annotation: { location: 10 } },
b: { annotation: { location: 5 } },
expected: 1,
},
{
a: {},
b: { annotation: { location: 5 } },
expected: -1,
},
{
a: {},
b: {},
expected: 0,
},
{
a: { annotation: { location: 10 } },
b: {},
expected: 1,
},
].forEach(testCase => {
it('sorts by annotation location', () => {
assert.equal(
// Disable eslint: `sorters` properties start with capital letters
// to match their displayed sort option values
/* eslint-disable-next-line new-cap */
sorters.Location(testCase.a, testCase.b),
testCase.expected
);
});
});
});
});
...@@ -51,4 +51,45 @@ describe('sidebar/util/thread', () => { ...@@ -51,4 +51,45 @@ describe('sidebar/util/thread', () => {
assert.equal(threadUtil.countHidden(thread), 3); assert.equal(threadUtil.countHidden(thread), 3);
}); });
}); });
describe('rootAnnotations', () => {
it("returns all of the annotations in the thread's child threads if there is at least one annotation present", () => {
const fixture = {
children: [
{ annotation: 1, children: [] },
{ children: [] },
{ annotation: 2, children: [] },
],
};
assert.deepEqual(threadUtil.rootAnnotations(fixture.children), [1, 2]);
});
it('returns all of the annotations at the first depth that has any annotations', () => {
const fixture = {
children: [
{
children: [
{ annotation: 1, children: [] },
{ children: [] },
{ annotation: 2, children: [] },
],
},
{ children: [{ children: [{ annotation: 3, children: [] }] }] },
{ children: [{ annotation: 4, children: [] }] },
],
};
assert.deepEqual(threadUtil.rootAnnotations(fixture.children), [1, 2, 4]);
});
it('throws an exception if fed a thread hierarchy with no annotations', () => {
const fixture = {
children: [{ children: [{ children: [] }] }],
};
assert.throws(() => {
threadUtil.rootAnnotations(fixture.children);
}, /Thread contains no annotations/);
});
});
}); });
import buildThread from './build-thread'; import buildThread from './build-thread';
import memoize from './memoize'; import memoize from './memoize';
import * as metadata from './annotation-metadata';
import { generateFacetedFilter } from './search-filter'; import { generateFacetedFilter } from './search-filter';
import filterAnnotations from './view-filter'; import filterAnnotations from './view-filter';
import { shouldShowInTab } from './tabs'; import { shouldShowInTab } from './tabs';
import { sorters } from './thread-sorters';
/** @typedef {import('../../types/api').Annotation} Annotation */ /** @typedef {import('../../types/api').Annotation} Annotation */
/** @typedef {import('./build-thread').Thread} Thread */ /** @typedef {import('./build-thread').Thread} Thread */
/** @typedef {import('./build-thread').Options} BuildThreadOptions */ /** @typedef {import('./build-thread').BuildThreadOptions} BuildThreadOptions */
/** /**
* @typedef ThreadState * @typedef ThreadState
...@@ -23,19 +23,6 @@ import { shouldShowInTab } from './tabs'; ...@@ -23,19 +23,6 @@ import { shouldShowInTab } from './tabs';
* @prop {string|null} route * @prop {string|null} route
*/ */
// Sort functions keyed on sort option
const sortFns = {
Newest: function (a, b) {
return a.updated > b.updated;
},
Oldest: function (a, b) {
return a.updated < b.updated;
},
Location: function (a, b) {
return metadata.location(a) < metadata.location(b);
},
};
/** /**
* Cobble together the right set of options and filters based on current * Cobble together the right set of options and filters based on current
* `threadState` to build the root thread. * `threadState` to build the root thread.
...@@ -46,12 +33,11 @@ const sortFns = { ...@@ -46,12 +33,11 @@ const sortFns = {
function buildRootThread(threadState) { function buildRootThread(threadState) {
const selection = threadState.selection; const selection = threadState.selection;
/** @type {Partial<BuildThreadOptions>} */
const options = { const options = {
expanded: selection.expanded, expanded: selection.expanded,
forcedVisible: selection.forcedVisible, forcedVisible: selection.forcedVisible,
selected: selection.selected, selected: selection.selected,
sortCompareFn: sortFns[selection.sortKey], sortCompareFn: sorters[selection.sortKey],
}; };
// Is there a filter query present, or an applied user (focus) filter? // Is there a filter query present, or an applied user (focus) filter?
......
import { location } from './annotation-metadata';
import { rootAnnotations } from './thread';
/** @typedef {import('./build-thread').Thread} Thread */
/**
* Sort comparison function when one or both threads being compared is lacking
* an annotation.
* Sort such that a thread without an annotation sorts to the top
*
* @param {Thread} a
* @param {Thread} b
* @return {number}
*/
function compareHeadlessThreads(a, b) {
if (!a.annotation && !b.annotation) {
return 0;
} else {
return !a.annotation ? -1 : 1;
}
}
/**
* Find the most recent updated date amongst a thread's root annotation set
*
* @param {Thread} thread
* @return {string}
*/
function newestRootAnnotationDate(thread) {
const annotations = rootAnnotations([thread]);
return annotations.reduce(
(newestDate, annotation) =>
annotation.updated > newestDate ? annotation.updated : newestDate,
''
);
}
/**
* Find the oldest updated date amongst a thread's root annotation set
*
* @param {Thread} thread
* @return {string}
*/
function oldestRootAnnotationDate(thread) {
const annotations = rootAnnotations([thread]);
return annotations.reduce((oldestDate, annotation) => {
if (!oldestDate) {
oldestDate = annotation.updated;
}
return annotation.updated < oldestDate ? annotation.updated : oldestDate;
}, '');
}
/**
* Sorting comparison functions for the three defined application options for
* sorting annotation (threads)
*/
export const sorters = {
Newest: function (a, b) {
const dateA = newestRootAnnotationDate(a);
const dateB = newestRootAnnotationDate(b);
if (dateA > dateB) {
return -1;
} else if (dateA < dateB) {
return 1;
}
return 0;
},
Oldest: function (a, b) {
const dateA = oldestRootAnnotationDate(a);
const dateB = oldestRootAnnotationDate(b);
if (dateA < dateB) {
return -1;
} else if (dateA > dateB) {
return 1;
}
return 0;
},
Location: function (a, b) {
if (!a.annotation || !b.annotation) {
return compareHeadlessThreads(a, b);
}
const aLocation = location(a.annotation);
const bLocation = location(b.annotation);
if (aLocation < bLocation) {
return -1;
} else if (aLocation > bLocation) {
return 1;
}
return 0;
},
};
import { notNull } from './typing';
/** @typedef {import('../../types/api').Annotation} Annotation */
/** @typedef {import('../util/build-thread').Thread} Thread */ /** @typedef {import('../util/build-thread').Thread} Thread */
/** /**
...@@ -31,3 +34,58 @@ export function countHidden(thread) { ...@@ -31,3 +34,58 @@ export function countHidden(thread) {
export function countVisible(thread) { export function countVisible(thread) {
return countByVisibility(thread, true); return countByVisibility(thread, true);
} }
/**
* Find the topmost annotations in a thread.
*
* For the (vast) majority of threads, this is the single annotation at the
* top level of the thread hierarchy.
*
* However, when the top-level thread lacks
* an annotation, as is the case if that annotation has been deleted but still
* has replies, find the first level of descendants that has at least one
* annotation (reply) and return the set of annotations (replies) at that level.
*
* For example, given the (overly-complex) thread-annotation structure of:
*
* [missing]
* - [missing]
* - reply 1
* - reply 2
* - reply 3
* - reply 4
* - [missing]
* - reply 5
* - [missing]
* - [missing]
* - reply 6
*
* Return [reply 1, reply 4, reply 5]
*
* @param {Thread[]} threads
* @return {Annotation[]}
*/
export function rootAnnotations(threads) {
// If there are any threads at this level with extant annotations, return
// those annotations
const threadAnnotations = threads
.filter(thread => !!thread.annotation)
.map(thread => notNull(thread.annotation));
if (threadAnnotations.length) {
return threadAnnotations;
}
// Else, search across all children at once (an entire hierarchical level)
const allChildren = [];
threads.forEach(thread => {
if (thread.children) {
allChildren.push(...thread.children);
}
});
if (allChildren.length) {
return rootAnnotations(allChildren);
}
throw new Error('Thread contains no annotations');
}
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