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);
......
This diff is collapsed.
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