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) {
});
}
/**
* 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.
* Sort the children of top-level threads using `compareFn` and all other
* children using `replyCompareFn`.
*
* @param {Thread} thread
* @param {(a: Annotation, b: Annotation) => boolean} compareFn - Less-than
* comparison function for sorting top-level annotations
* @param {(a: Annotation, b: Annotation) => boolean} replyCompareFn - Less-than
* comparison function for sorting replies
* @param {(a: Thread, b: Thread) => number} compareFn - comparison function
* for sorting top-level annotations
* @param {(a: Thread, b: Thread) => number} replyCompareFn - comparison
* function for sorting replies
* @return {Thread}
*/
function sortThread(thread, compareFn, replyCompareFn) {
......@@ -215,7 +187,9 @@ function sortThread(thread, compareFn, 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) {
}
/**
* @typedef Options
* @prop {string[]} selected - List of currently-selected annotation ids, from
* the data store
* @typedef BuildThreadOptions
* @prop {Object.<string, boolean>} expanded - Map of thread id => expansion state
* @prop {string[]} forcedVisible - List of $tags of annotations that have
* been explicitly expanded by the user, even if they don't
* 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
* returns `true` if annotation should be visible
* @prop {(t: Thread) => boolean} [threadFilterFn] - Predicate function that
* 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 = {
selected: [],
expanded: {},
forcedVisible: [],
sortCompareFn: (a, b) => {
return a.$tag < b.$tag;
},
replySortCompareFn: (a, b) => {
return a.created < b.created;
},
const replySortCompareFn = (a, b) => {
if (!a.annotation || !b.annotation) {
return 0;
}
if (a.annotation.created < b.annotation.created) {
return -1;
} else if (a.annotation.created > b.annotation.created) {
return 1;
}
return 0;
};
/**
......@@ -306,15 +280,13 @@ const defaultOpts = {
* a user).
*
* @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
* annotations to display.
*/
export default function buildThread(annotations, options) {
const opts = { ...defaultOpts, ...options };
const hasSelection = opts.selected.length > 0;
const hasForcedVisible = opts.forcedVisible.length > 0;
const hasSelection = options.selected.length > 0;
const hasForcedVisible = options.forcedVisible.length > 0;
let thread = threadAnnotations(annotations);
......@@ -322,18 +294,18 @@ export default function buildThread(annotations, options) {
// Remove threads (annotations) that are not selected or
// are not forced-visible
thread.children = thread.children.filter(child => {
const isSelected = opts.selected.includes(child.id);
const isSelected = options.selected.includes(child.id);
const isForcedVisible =
hasForcedVisible &&
child.annotation &&
opts.forcedVisible.includes(child.annotation.$tag);
options.forcedVisible.includes(child.annotation.$tag);
return isSelected || isForcedVisible;
});
}
if (opts.threadFilterFn) {
if (options.threadFilterFn) {
// 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
......@@ -342,17 +314,17 @@ export default function buildThread(annotations, options) {
if (!thread.annotation) {
threadIsVisible = false; // Nothing to show
} else if (opts.filterFn) {
} else if (options.filterFn) {
if (
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
// make sure it is visible because it has been forced visible by user
threadIsVisible = true;
} else {
// Otherwise, visibility depends on whether it matches the filter
threadIsVisible = !!opts.filterFn(thread.annotation);
threadIsVisible = !!options.filterFn(thread.annotation);
}
}
return { ...thread, visible: threadIsVisible };
......@@ -369,20 +341,22 @@ export default function buildThread(annotations, options) {
collapsed: thread.collapsed,
};
if (opts.expanded.hasOwnProperty(thread.id)) {
if (options.expanded.hasOwnProperty(thread.id)) {
// This thread has been explicitly expanded/collapsed by user
threadStates.collapsed = !opts.expanded[thread.id];
threadStates.collapsed = !options.expanded[thread.id];
} else {
// If annotations are filtered, and at least one child matches
// 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;
}
return { ...thread, ...threadStates };
});
// 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
thread = countRepliesAndDepth(thread, -1);
......
......@@ -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.
*
......@@ -51,8 +58,8 @@ function filter(thread, keys) {
* @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 || {};
function createThread(fixture, options, keys) {
const opts = { ...defaultBuildThreadOpts, ...options };
keys = keys || [];
const rootThread = filter(
......@@ -62,9 +69,9 @@ function createThread(fixture, opts, keys) {
return rootThread.children;
}
describe('sidebar/util/build-thread', function () {
describe('threading', function () {
it('arranges parents and children as a thread', function () {
describe('sidebar/util/build-thread', () => {
describe('threading', () => {
it('arranges parents and children as a thread', () => {
const thread = createThread(SIMPLE_FIXTURE);
assert.deepEqual(thread, [
{
......@@ -83,7 +90,7 @@ describe('sidebar/util/build-thread', function () {
]);
});
it('threads nested replies', function () {
it('threads nested replies', () => {
const NESTED_FIXTURE = [
{
id: '1',
......@@ -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 = [
{
id: '1',
......@@ -177,7 +224,7 @@ describe('sidebar/util/build-thread', function () {
]);
});
it('handles missing parent annotations', function () {
it('handles missing parent annotations', () => {
const fixture = [
{
id: '1',
......@@ -196,7 +243,7 @@ describe('sidebar/util/build-thread', function () {
]);
});
it('handles missing replies', function () {
it('handles missing replies', () => {
const fixture = [
{
id: '1',
......@@ -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 = [
{
$tag: 't1',
......@@ -235,7 +282,7 @@ describe('sidebar/util/build-thread', function () {
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 = [
{
id: '1',
......@@ -263,54 +310,56 @@ describe('sidebar/util/build-thread', function () {
});
});
describe('collapsed state', function () {
it('collapses top-level annotations by default', function () {
const thread = buildThread(SIMPLE_FIXTURE, {});
describe('collapsed state', () => {
it('collapses top-level annotations by default', () => {
const thread = buildThread(SIMPLE_FIXTURE, defaultBuildThreadOpts);
assert.isTrue(thread.children[0].collapsed);
});
it('expands replies by default', function () {
const thread = buildThread(SIMPLE_FIXTURE, {});
it('expands replies by default', () => {
const thread = buildThread(SIMPLE_FIXTURE, defaultBuildThreadOpts);
assert.isFalse(thread.children[0].children[0].collapsed);
});
it('expands threads which have been explicitly expanded', function () {
const thread = buildThread(SIMPLE_FIXTURE, {
expanded: { 1: true },
});
it('expands threads which have been explicitly expanded', () => {
const opts = { ...defaultBuildThreadOpts, expanded: { 1: true } };
const thread = buildThread(SIMPLE_FIXTURE, opts);
assert.isFalse(thread.children[0].collapsed);
});
it('collapses replies which have been explicitly collapsed', function () {
const thread = buildThread(SIMPLE_FIXTURE, {
expanded: { 3: false },
});
it('collapses replies which have been explicitly collapsed', () => {
const opts = { ...defaultBuildThreadOpts, expanded: { 3: false } };
const thread = buildThread(SIMPLE_FIXTURE, opts);
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
// annotation, not its reply, and then clicking
// 'View N more in conversation' to show the complete discussion thread
const thread = buildThread(SIMPLE_FIXTURE, {
filterFn: function (annot) {
return annot.text.match(/first/);
},
const opts = {
...defaultBuildThreadOpts,
filterFn: annot => annot.text.match(/first/),
forcedVisible: ['3'],
});
};
const thread = buildThread(SIMPLE_FIXTURE, opts);
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 () {
describe('filtering', () => {
context('when there is an active filter', () => {
it('shows only annotations that match the filter', () => {
const threads = createThread(
SIMPLE_FIXTURE,
{
filterFn: function (annot) {
return annot.text.match(/first/);
},
filterFn: annot => annot.text.match(/first/),
},
['visible']
);
......@@ -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(
SIMPLE_FIXTURE,
{
filterFn: function (annot) {
return annot.text.match(/third/);
},
filterFn: annot => annot.text.match(/third/),
},
['visible']
);
......@@ -355,8 +402,8 @@ describe('sidebar/util/build-thread', function () {
});
});
context('when there is a selection', function () {
it('shows only selected annotations', function () {
context('when there is a selection', () => {
it('shows only selected annotations', () => {
const thread = createThread(SIMPLE_FIXTURE, {
selected: ['1'],
});
......@@ -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, {
selected: ['1'],
forcedVisible: ['2'],
......@@ -396,7 +443,7 @@ describe('sidebar/util/build-thread', function () {
});
});
describe('thread filtering', function () {
describe('thread filtering', () => {
const fixture = [
{
id: '1',
......@@ -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, {
threadFilterFn: function (thread) {
return metadata.isPageNote(thread.annotation);
},
threadFilterFn: thread => metadata.isPageNote(thread.annotation),
});
assert.deepEqual(thread, [
......@@ -427,36 +472,36 @@ describe('sidebar/util/build-thread', function () {
});
});
describe('sort order', function () {
const annots = function (threads) {
return threads.map(function (thread) {
return thread.annotation;
});
};
describe('sort order', () => {
const annots = threads => threads.map(thread => thread.annotation);
it('sorts top-level annotations using the comparison function', function () {
it('sorts top-level annotations using the comparison function', () => {
const fixture = [
{
id: '1',
updated: 100,
updated: '2021-01-01',
references: [],
},
{
id: '2',
updated: 200,
updated: '2021-01-02',
references: [],
},
];
const thread = createThread(fixture, {
sortCompareFn: function (a, b) {
return a.updated > b.updated;
sortCompareFn: (a, b) => {
if (a.annotation.updated < b.annotation.updated) {
return 1;
}
return -1;
},
});
assert.deepEqual(annots(thread), [fixture[1], fixture[0]]);
});
it('sorts replies by creation date', function () {
it('sorts replies by creation date', () => {
const fixture = [
{
id: '1',
......@@ -475,16 +520,14 @@ describe('sidebar/util/build-thread', function () {
},
];
const thread = createThread(fixture, {
sortCompareFn: function (a, b) {
return a.id < b.id;
},
sortCompareFn: (a, b) => b.annotation.id - a.annotation.id,
});
assert.deepEqual(annots(thread[0].children), [fixture[2], fixture[1]]);
});
});
describe('reply counts', function () {
it('populates the reply count field', function () {
describe('reply counts', () => {
it('populates the reply count field', () => {
assert.deepEqual(createThread(SIMPLE_FIXTURE, {}, ['replyCount']), [
{
annotation: SIMPLE_FIXTURE[0],
......@@ -506,13 +549,13 @@ describe('sidebar/util/build-thread', function () {
});
});
describe('depth', function () {
it('is 0 for annotations', function () {
describe('depth', () => {
it('is 0 for annotations', () => {
const thread = createThread(SIMPLE_FIXTURE, {}, ['depth']);
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']);
assert.deepEqual(thread[0].children[0].depth, 1);
});
......
import * as annotationFixtures from '../../test/annotation-fixtures';
import uiConstants from '../../ui-constants';
import threadAnnotations from '../thread-annotations';
import { sorters } from '../thread-sorters';
import { $imports } from '../thread-annotations';
import immutable from '../immutable';
......@@ -100,71 +101,15 @@ describe('sidebar/utils/thread-annotations', () => {
});
describe('when sort order changes', () => {
function sortBy(annotations, sortCompareFn) {
return annotations.slice().sort((a, b) => {
if (sortCompareFn(a, b)) {
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;
['Location', 'Oldest', 'Newest'].forEach(testCase => {
it(`uses the appropriate sorting function when sorting by ${testCase}`, () => {
fakeThreadState.selection.sortKey = testCase;
threadAnnotations(fakeThreadState);
// The sort compare fn passed to `buildThread`
const sortCompareFn = fakeBuildThread.args[0][1].sortCompareFn;
// 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);
assert.equal(sortCompareFn, sorters[testCase]);
});
});
});
......
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', () => {
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 memoize from './memoize';
import * as metadata from './annotation-metadata';
import { generateFacetedFilter } from './search-filter';
import filterAnnotations from './view-filter';
import { shouldShowInTab } from './tabs';
import { sorters } from './thread-sorters';
/** @typedef {import('../../types/api').Annotation} Annotation */
/** @typedef {import('./build-thread').Thread} Thread */
/** @typedef {import('./build-thread').Options} BuildThreadOptions */
/** @typedef {import('./build-thread').BuildThreadOptions} BuildThreadOptions */
/**
* @typedef ThreadState
......@@ -23,19 +23,6 @@ import { shouldShowInTab } from './tabs';
* @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
* `threadState` to build the root thread.
......@@ -46,12 +33,11 @@ const sortFns = {
function buildRootThread(threadState) {
const selection = threadState.selection;
/** @type {Partial<BuildThreadOptions>} */
const options = {
expanded: selection.expanded,
forcedVisible: selection.forcedVisible,
selected: selection.selected,
sortCompareFn: sortFns[selection.sortKey],
sortCompareFn: sorters[selection.sortKey],
};
// 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 */
/**
......@@ -31,3 +34,58 @@ export function countHidden(thread) {
export function countVisible(thread) {
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