Commit 1bf6e8f9 authored by Robert Knight's avatar Robert Knight

Compute thread counts for each tab as part of building root thread

Previously the thread counts shown on tabs in the sidebar were computed from the
full set of annotations in the store, not taking into account any filters that
are active. This was acceptable because the tabs were hidden if a filter was
active. However we are planning to change this and thus we need to compute tab
counts that include the effects of active filters.

 - Change `threadAnnotations` to compute thread counts per tab, as well as the
   thread itself. This is done by not filtering top-level threads in
   `buildThread` but instead doing a filtering pass later, which also computes
   the thread count for each tab.

 - Change `SelectionTabs` to take the tab counts as a prop, instead of
   getting the count from the store.
parent 099152fb
......@@ -23,7 +23,7 @@ function AnnotationView({
}: AnnotationViewProps) {
const store = useSidebarStore();
const annotationId = store.routeParams().id ?? '';
const rootThread = useRootThread();
const { rootThread } = useRootThread();
const userid = store.profile().userid;
const [fetchError, setFetchError] = useState(false);
......
......@@ -32,7 +32,7 @@ function NotebookResultCount({
isLoading,
resultCount,
}: NotebookResultCountProps) {
const rootThread = useRootThread();
const { rootThread } = useRootThread();
const visibleCount = isLoading ? resultCount : countVisible(rootThread);
const hasResults = rootThread.children.length > 0;
......
......@@ -33,7 +33,7 @@ function NotebookView({ loadAnnotationsService, streamer }: NotebookViewProps) {
const isLoading = store.isLoading();
const resultCount = store.annotationResultCount();
const rootThread = useRootThread();
const { rootThread } = useRootThread();
const groupName = focusedGroup?.name ?? '…';
......
......@@ -86,6 +86,13 @@ export type SelectionTabProps = {
/** Are we waiting on any annotations from the server? */
isLoading: boolean;
/** Counts of threads in each tab, to be displayed next to the tab title. */
tabCounts: {
annotation: number;
note: number;
orphan: number;
};
// injected
settings: SidebarSettings;
annotationsService: AnnotationsService;
......@@ -98,12 +105,13 @@ function SelectionTabs({
annotationsService,
isLoading,
settings,
tabCounts,
}: SelectionTabProps) {
const store = useSidebarStore();
const selectedTab = store.selectedTab();
const noteCount = store.noteCount();
const annotationCount = store.annotationCount();
const orphanCount = store.orphanCount();
const noteCount = tabCounts.note;
const annotationCount = tabCounts.annotation;
const orphanCount = tabCounts.orphan;
const isWaitingToAnchorAnnotations = store.isWaitingToAnchorAnnotations();
const selectTab = (tabId: TabName) => {
......
......@@ -35,7 +35,7 @@ function SidebarView({
loadAnnotationsService,
streamer,
}: SidebarViewProps) {
const rootThread = useRootThread();
const { rootThread, tabCounts } = useRootThread();
// Store state values
const store = useSidebarStore();
......@@ -146,7 +146,9 @@ function SidebarView({
{hasDirectLinkedGroupError && (
<SidebarContentError errorType="group" onLoginRequest={onLogin} />
)}
{showTabs && <SelectionTabs isLoading={isLoading} />}
{showTabs && (
<SelectionTabs isLoading={isLoading} tabCounts={tabCounts} />
)}
<ThreadList threads={rootThread.children} />
{showLoggedOutMessage && <LoggedOutMessage onLogin={onLogin} />}
</div>
......
......@@ -58,7 +58,7 @@ function StreamView({ api, toastMessenger }: StreamViewProps) {
});
}, [currentQuery, loadAnnotations, store, toastMessenger]);
const rootThread = useRootThread();
const { rootThread } = useRootThread();
return <ThreadList threads={rootThread.children} />;
}
......
......@@ -11,11 +11,15 @@ describe('sidebar/components/hooks/use-root-thread', () => {
fakeStore = {
allAnnotations: sinon.stub().returns(['1', '2']),
filterQuery: sinon.stub().returns('itchy'),
route: sinon.stub().returns('66'),
route: sinon.stub().returns('sidebar'),
selectionState: sinon.stub().returns({ hi: 'there' }),
getFilterValues: sinon.stub().returns({ user: 'hotspur' }),
};
fakeThreadAnnotations = sinon.stub().returns('fakeThreadAnnotations');
fakeThreadAnnotations = sinon.stub().returns({
rootThread: {
children: [],
},
});
$imports.$mock({
'../../store': { useSidebarStore: () => fakeStore },
......@@ -23,28 +27,38 @@ describe('sidebar/components/hooks/use-root-thread', () => {
threadAnnotations: fakeThreadAnnotations,
},
});
// Mount a dummy component to be able to use the `useRootThread` hook
// Do things that cause `useRootThread` to recalculate in the store and
// test them (hint: use `act`)
function DummyComponent() {
lastRootThread = useRootThread();
}
mount(<DummyComponent />);
});
afterEach(() => {
$imports.$restore();
});
function DummyComponent() {
lastRootThread = useRootThread();
}
it('should return results of `threadAnnotations` with current thread state', () => {
const threadState = fakeThreadAnnotations.getCall(0).args[0];
mount(<DummyComponent />);
const threadState = fakeThreadAnnotations.getCall(0).args[0];
assert.deepEqual(threadState.annotations, ['1', '2']);
assert.equal(threadState.selection.filterQuery, 'itchy');
assert.equal(threadState.route, '66');
assert.equal(threadState.showTabs, true);
assert.equal(threadState.selection.filters.user, 'hotspur');
assert.equal(lastRootThread, fakeThreadAnnotations());
});
assert.equal(lastRootThread, 'fakeThreadAnnotations');
[
{ route: 'sidebar', showTabs: true },
{ route: 'notebook', showTabs: false },
].forEach(({ route, showTabs }) => {
it('shows tabs in the sidebar only', () => {
fakeStore.route.returns(route);
mount(<DummyComponent />);
const threadState = fakeThreadAnnotations.getCall(0).args[0];
assert.equal(threadState.showTabs, showTabs);
});
});
});
import { useMemo } from 'preact/hooks';
import type { Thread } from '../../helpers/build-thread';
import { threadAnnotations } from '../../helpers/thread-annotations';
import type { ThreadState } from '../../helpers/thread-annotations';
import type {
ThreadAnnotationsResult,
ThreadState,
} from '../../helpers/thread-annotations';
import { useSidebarStore } from '../../store';
/**
* Gather together state relevant to building a root thread of annotations and
* replies and return an updated root thread when changes occur.
*/
export function useRootThread(): Thread {
export function useRootThread(): ThreadAnnotationsResult {
const store = useSidebarStore();
const annotations = store.allAnnotations();
const query = store.filterQuery();
......@@ -20,8 +22,8 @@ export function useRootThread(): Thread {
const threadState = useMemo((): ThreadState => {
return {
annotations,
route,
selection: { ...selectionState, filterQuery: query, filters },
showTabs: route === 'sidebar',
};
}, [annotations, query, route, selectionState, filters]);
......
......@@ -136,7 +136,7 @@ function FilterStatusMessage({
*/
export default function FilterStatus() {
const store = useSidebarStore();
const rootThread = useRootThread();
const { rootThread } = useRootThread();
const annotationCount = store.annotationCount();
const directLinkedId = store.directLinkedAnnotationId();
......
......@@ -36,7 +36,9 @@ describe('FilterStatus', () => {
toggleFocusMode: sinon.stub(),
};
fakeUseRootThread = sinon.stub().returns({});
fakeUseRootThread = sinon.stub().returns({
rootThread: { children: [] },
});
$imports.$mock(mockImportedComponents());
$imports.$mock({
......@@ -46,6 +48,10 @@ describe('FilterStatus', () => {
});
});
afterEach(() => {
$imports.$restore();
});
function assertFilterText(wrapper, text) {
const filterText = wrapper.find('[role="status"]').text();
assert.equal(filterText, text);
......@@ -155,23 +161,25 @@ describe('FilterStatus', () => {
// the selectedCount from the count of all visible top-level threads
// (children/replies are ignored in this count)
fakeUseRootThread.returns({
id: '__default__',
children: [
{ id: '1', annotation: { $tag: '1' }, visible: true, children: [] },
{
id: '2',
annotation: { $tag: '2' },
visible: true,
children: [
{
id: '2a',
annotation: { $tag: '2a' },
visible: true,
children: [],
},
],
},
],
rootThread: {
id: '__default__',
children: [
{ id: '1', annotation: { $tag: '1' }, visible: true, children: [] },
{
id: '2',
annotation: { $tag: '2' },
visible: true,
children: [
{
id: '2a',
annotation: { $tag: '2a' },
visible: true,
children: [],
},
],
},
],
},
});
assertFilterText(createComponent(), 'Showing 1 annotation (and 1 more)');
});
......
......@@ -133,7 +133,7 @@ function FilterStatusMessage({
*/
export default function FilterAnnotationsStatus() {
const store = useSidebarStore();
const rootThread = useRootThread();
const { rootThread } = useRootThread();
const annotationCount = store.annotationCount();
const directLinkedId = store.directLinkedAnnotationId();
......
......@@ -7,7 +7,7 @@ import { useRootThread } from '../hooks/use-root-thread';
export default function SearchStatus() {
const store = useSidebarStore();
const rootThread = useRootThread();
const { rootThread } = useRootThread();
const filterQuery = store.filterQuery();
const forcedVisibleCount = store.forcedVisibleThreads().length;
......
......@@ -35,7 +35,7 @@ describe('FilterAnnotationsStatus', () => {
toggleFocusMode: sinon.stub(),
};
fakeUseRootThread = sinon.stub().returns({});
fakeUseRootThread = sinon.stub().returns({ rootThread: { children: [] } });
$imports.$mock(mockImportedComponents());
$imports.$mock({
......@@ -45,6 +45,10 @@ describe('FilterAnnotationsStatus', () => {
});
});
afterEach(() => {
$imports.$restore();
});
function assertFilterText(wrapper, text) {
const filterText = wrapper.find('[role="status"]').text();
assert.equal(filterText, text);
......@@ -101,23 +105,25 @@ describe('FilterAnnotationsStatus', () => {
// the selectedCount from the count of all visible top-level threads
// (children/replies are ignored in this count)
fakeUseRootThread.returns({
id: '__default__',
children: [
{ id: '1', annotation: { $tag: '1' }, visible: true, children: [] },
{
id: '2',
annotation: { $tag: '2' },
visible: true,
children: [
{
id: '2a',
annotation: { $tag: '2a' },
visible: true,
children: [],
},
],
},
],
rootThread: {
id: '__default__',
children: [
{ id: '1', annotation: { $tag: '1' }, visible: true, children: [] },
{
id: '2',
annotation: { $tag: '2' },
visible: true,
children: [
{
id: '2a',
annotation: { $tag: '2a' },
visible: true,
children: [],
},
],
},
],
},
});
assertFilterText(createComponent(), 'Showing 1 annotation (and 1 more)');
});
......
......@@ -25,7 +25,9 @@ describe('AnnotationView', () => {
fakeOnLogin = sinon.stub();
fakeUseRootThread = sinon.stub().returns({
children: [],
rootThread: {
children: [],
},
});
$imports.$mock(mockImportedComponents());
......
......@@ -21,7 +21,7 @@ describe('NotebookResultCount', () => {
beforeEach(() => {
fakeCountVisible = sinon.stub().returns(0);
fakeUseRootThread = sinon.stub().returns({ children: [] });
fakeUseRootThread = sinon.stub().returns({ rootThread: { children: [] } });
$imports.$mock({
'./hooks/use-root-thread': { useRootThread: fakeUseRootThread },
......@@ -35,16 +35,12 @@ describe('NotebookResultCount', () => {
context('when there are no results', () => {
it('should show "No Results" if no filters are applied', () => {
fakeUseRootThread.returns({ children: [] });
const wrapper = createComponent({ isFiltered: false });
assert.equal(wrapper.text(), 'No results');
});
it('should show "No Results" if filters are applied', () => {
fakeUseRootThread.returns({ children: [] });
const wrapper = createComponent({ isFiltered: true });
assert.equal(wrapper.text(), 'No results');
......@@ -71,7 +67,7 @@ describe('NotebookResultCount', () => {
].forEach(test => {
it('should render a count of threads and annotations', () => {
fakeCountVisible.returns(test.visibleCount);
fakeUseRootThread.returns(test.thread);
fakeUseRootThread.returns({ rootThread: test.thread });
const wrapper = createComponent();
......@@ -102,7 +98,7 @@ describe('NotebookResultCount', () => {
},
].forEach(test => {
it('should render a count of results', () => {
fakeUseRootThread.returns(test.thread);
fakeUseRootThread.returns({ rootThread: test.thread });
fakeCountVisible.returns(test.visibleCount);
const wrapper = createComponent({
......@@ -122,7 +118,7 @@ describe('NotebookResultCount', () => {
});
it('shows annotation count if there are any matching annotations being fetched', () => {
fakeUseRootThread.returns({ children: [1, 2] });
fakeUseRootThread.returns({ rootThread: { children: [1, 2] } });
// Setting countVisible to something different to demonstrate that
// resultCount is used while loading
fakeCountVisible.returns(5);
......@@ -144,7 +140,7 @@ describe('NotebookResultCount', () => {
name: 'with results',
content: () => {
fakeCountVisible.returns(2);
fakeUseRootThread.returns({ children: [1, 2] });
fakeUseRootThread.returns({ rootThread: { children: [1, 2] } });
return createComponent();
},
},
......@@ -152,7 +148,7 @@ describe('NotebookResultCount', () => {
name: 'with results and filters applied',
content: () => {
fakeCountVisible.returns(3);
fakeUseRootThread.returns({ children: [1] });
fakeUseRootThread.returns({ rootThread: { children: [1] } });
return createComponent({ forcedVisibleCount: 1, isFiltered: true });
},
},
......
......@@ -20,7 +20,9 @@ describe('NotebookView', () => {
load: sinon.stub(),
};
fakeUseRootThread = sinon.stub().returns({});
fakeUseRootThread = sinon.stub().returns({
rootThread: { children: [] },
});
fakeScrollIntoView = sinon.stub();
......
......@@ -12,9 +12,13 @@ describe('SelectionTabs', () => {
let fakeSettings;
let fakeStore;
// default props
const defaultProps = {
isLoading: false,
tabCounts: {
annotation: 123,
note: 456,
orphan: 0,
},
};
function createComponent(props) {
......@@ -38,9 +42,6 @@ describe('SelectionTabs', () => {
fakeStore = {
clearSelection: sinon.stub(),
selectTab: sinon.stub(),
annotationCount: sinon.stub().returns(123),
noteCount: sinon.stub().returns(456),
orphanCount: sinon.stub().returns(0),
isWaitingToAnchorAnnotations: sinon.stub().returns(false),
selectedTab: sinon.stub().returns('annotation'),
};
......@@ -155,9 +156,12 @@ describe('SelectionTabs', () => {
describe('orphans tab', () => {
it('should display orphans tab if there is 1 or more orphans', () => {
fakeStore.orphanCount.returns(1);
const wrapper = createComponent();
const wrapper = createComponent({
tabCounts: {
...defaultProps.tabCounts,
orphan: 1,
},
});
const orphanTab = wrapper.find('Tab[label="Orphans"]');
assert.isTrue(orphanTab.exists());
......@@ -165,17 +169,14 @@ describe('SelectionTabs', () => {
it('should display orphans tab as selected when it is active', () => {
fakeStore.selectedTab.returns('orphan');
fakeStore.orphanCount.returns(1);
const wrapper = createComponent();
const wrapper = createComponent({ tabCounts: { orphan: 1 } });
const orphanTab = wrapper.find('Tab[label="Orphans"]');
assert.isTrue(orphanTab.find('LinkButton').prop('pressed'));
});
it('should not display orphans tab if there are 0 orphans', () => {
fakeStore.orphanCount.returns(0);
const wrapper = createComponent();
const orphanTab = wrapper.find('Tab[label="Orphans"]');
......@@ -186,9 +187,12 @@ describe('SelectionTabs', () => {
describe('tab display and counts', () => {
it('should not render count if there are no page notes', () => {
fakeStore.noteCount.returns(0);
const wrapper = createComponent({});
const wrapper = createComponent({
tabCounts: {
...defaultProps.tabCounts,
note: 0,
},
});
const noteTab = wrapper.find('Tab[label="Page notes"]');
......@@ -196,9 +200,12 @@ describe('SelectionTabs', () => {
});
it('should not display a message when its loading annotation count is 0', () => {
fakeStore.annotationCount.returns(0);
const wrapper = createComponent({
isLoading: true,
tabCounts: {
...defaultProps.tabCounts,
annotation: 0,
},
});
assert.isFalse(
wrapper.exists('[data-testid="annotations-unavailable-message"]'),
......@@ -207,9 +214,12 @@ describe('SelectionTabs', () => {
it('should not display a message when its loading notes count is 0', () => {
fakeStore.selectedTab.returns('note');
fakeStore.noteCount.returns(0);
const wrapper = createComponent({
isLoading: true,
tabCounts: {
...defaultProps.tabCounts,
note: 0,
},
});
assert.isFalse(
wrapper.exists('[data-testid="notes-unavailable-message"]'),
......@@ -217,10 +227,13 @@ describe('SelectionTabs', () => {
});
it('should not display the longer version of the no annotations message when there are no annotations and isWaitingToAnchorAnnotations is true', () => {
fakeStore.annotationCount.returns(0);
fakeStore.isWaitingToAnchorAnnotations.returns(true);
const wrapper = createComponent({
isLoading: false,
tabCounts: {
...defaultProps.tabCounts,
annotationn: 0,
},
});
assert.isFalse(
wrapper.exists('[data-testid="annotations-unavailable-message"]'),
......@@ -229,8 +242,12 @@ describe('SelectionTabs', () => {
it('should display the longer version of the no notes message when there are no notes', () => {
fakeStore.selectedTab.returns('note');
fakeStore.noteCount.returns(0);
const wrapper = createComponent({});
const wrapper = createComponent({
tabCounts: {
...defaultProps.tabCounts,
note: 0,
},
});
assert.include(
wrapper.find('Card[data-testid="notes-unavailable-message"]').text(),
......@@ -239,8 +256,12 @@ describe('SelectionTabs', () => {
});
it('should display the longer version of the no annotations message when there are no annotations', () => {
fakeStore.annotationCount.returns(0);
const wrapper = createComponent({});
const wrapper = createComponent({
tabCounts: {
...defaultProps.tabCounts,
annotation: 0,
},
});
assert.include(
wrapper
.find('Card[data-testid="annotations-unavailable-message"]')
......@@ -264,9 +285,13 @@ describe('SelectionTabs', () => {
// Pre-select a different tab than the one we are about to click.
fakeStore.selectedTab.returns('other-tab');
// Make the "Orphans" tab appear.
fakeStore.orphanCount.returns(1);
const wrapper = createComponent({});
const wrapper = createComponent({
// Make the "Orphans" tab appear.
tabCounts: {
...defaultProps.tabCounts,
orphan: 1,
},
});
findButton(wrapper, label).simulate('click');
......@@ -289,10 +314,13 @@ describe('SelectionTabs', () => {
'should pass a11y checks',
checkAccessibility({
content: () => {
fakeStore.annotationCount.returns(1);
fakeStore.noteCount.returns(2);
fakeStore.orphanCount.returns(3);
return createComponent({});
return createComponent({
tabCounts: {
annotation: 1,
note: 2,
orphan: 3,
},
});
},
}),
);
......
......@@ -35,7 +35,14 @@ describe('SidebarView', () => {
load: sinon.stub(),
};
fakeUseRootThread = sinon.stub().returns({
children: [],
rootThread: {
children: [],
},
tabCounts: {
annotation: 1,
note: 2,
orphan: 0,
},
});
fakeStreamer = {
connect: sinon.stub(),
......
......@@ -16,7 +16,9 @@ describe('StreamView', () => {
};
fakeUseRootThread = sinon.stub().returns({
children: [],
rootThread: {
children: [],
},
});
fakeQueryParser = {
......
......@@ -24,7 +24,7 @@ describe('sidebar/helpers/thread-annotations', () => {
beforeEach(() => {
fakeThreadState = {
annotations: [],
route: 'sidebar',
showTabs: true,
selection: {
expanded: {},
forcedVisible: [],
......@@ -36,7 +36,9 @@ describe('sidebar/helpers/thread-annotations', () => {
},
};
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
fakeBuildThread = sinon
.stub()
.callsFake(() => structuredClone(fixtures.emptyThread));
fakeFilterAnnotations = sinon.stub();
fakeQueryParser = {
parseFilterQuery: sinon.stub(),
......@@ -55,12 +57,15 @@ describe('sidebar/helpers/thread-annotations', () => {
describe('threadAnnotations', () => {
it('returns the result of buildThread', () => {
assert.equal(threadAnnotations(fakeThreadState), fixtures.emptyThread);
assert.deepEqual(
threadAnnotations(fakeThreadState).rootThread,
fixtures.emptyThread,
);
});
it('memoizes on `threadState`', () => {
fakeBuildThread.onCall(0).returns({ brisket: 'fingers' });
fakeBuildThread.onCall(1).returns({ brisket: 'bananas' });
fakeBuildThread.onCall(0).returns({ children: [] });
fakeBuildThread.onCall(1).returns({ children: [] });
const thread1 = threadAnnotations(fakeThreadState);
const thread2 = threadAnnotations(fakeThreadState);
......@@ -113,39 +118,115 @@ describe('sidebar/helpers/thread-annotations', () => {
});
describe('annotation and thread filtering', () => {
context('sidebar route', () => {
context('when `showTabs` is true', () => {
function annotationForTab(tab) {
switch (tab) {
case 'annotation':
return {
...annotationFixtures.defaultAnnotation(),
$orphan: false,
};
case 'note':
return annotationFixtures.oldPageNote();
case 'orphan':
return {
...annotationFixtures.defaultAnnotation(),
$orphan: true,
};
default:
throw new Error('Invalid tab');
}
}
[
// Tabs enabled, annotations in each tab.
{
annotations: [
annotationForTab('annotation'),
annotationForTab('annotation'),
annotationForTab('annotation'),
annotationForTab('note'),
annotationForTab('note'),
annotationForTab('orphan'),
],
showTabs: true,
expectedCounts: {
annotation: 3,
note: 2,
orphan: 1,
},
},
// Tabs enabled, no annotations
{
annotations: [],
showTabs: true,
expectedCounts: {
annotation: 0,
note: 0,
orphan: 0,
},
},
// Tabs disabled
{
annotations: [
annotationForTab('annotation'),
annotationForTab('note'),
annotationForTab('orphan'),
],
showTabs: false,
expectedCounts: {
annotation: 0,
note: 0,
orphan: 0,
},
},
].forEach(({ annotations, showTabs, expectedCounts }) => {
it('returns thread count for each tab', () => {
fakeThreadState.annotations = annotations;
fakeBuildThread.returns({
children: fakeThreadState.annotations.map(annotation => ({
annotation,
})),
});
fakeThreadState.showTabs = showTabs;
fakeThreadState.selection.selectedTab = 'annotation';
const { tabCounts } = threadAnnotations(fakeThreadState);
assert.deepEqual(tabCounts, expectedCounts);
});
});
['note', 'annotation', 'orphan'].forEach(selectedTab => {
it(`should filter the thread for the tab '${selectedTab}'`, () => {
const annotations = {
['annotation']: {
fakeThreadState.annotations = [
{
...annotationFixtures.defaultAnnotation(),
$orphan: false,
},
['note']: annotationFixtures.oldPageNote(),
['orphan']: {
annotationFixtures.oldPageNote(),
{
...annotationFixtures.defaultAnnotation(),
$orphan: true,
},
};
const fakeThreads = [
{},
{ annotation: annotations.annotation },
{ annotation: annotations.note },
{ annotation: annotations.orphan },
];
fakeBuildThread.returns({
children: fakeThreadState.annotations.map(annotation => ({
annotation,
})),
});
fakeThreadState.showTabs = true;
fakeThreadState.selection.selectedTab = selectedTab;
threadAnnotations(fakeThreadState);
const threadFilterFn = fakeBuildThread.args[0][1].threadFilterFn;
const filteredThreads = fakeThreads.filter(thread =>
threadFilterFn(thread),
);
const { rootThread } = threadAnnotations(fakeThreadState);
const filteredThreads = rootThread.children;
assert.lengthOf(filteredThreads, 1);
assert.equal(
filteredThreads[0].annotation,
annotations[selectedTab],
fakeThreadState.annotations.indexOf(
filteredThreads[0].annotation,
),
['annotation', 'note', 'orphan'].indexOf(selectedTab),
);
});
});
......
import type { Annotation } from '../../types/api';
import { memoize } from '../util/memoize';
import { isWaitingToAnchor } from './annotation-metadata';
import { buildThread } from './build-thread';
import type { Thread, BuildThreadOptions } from './build-thread';
import { filterAnnotations } from './filter-annotations';
import { parseFilterQuery } from './query-parser';
import type { FilterField } from './query-parser';
import { shouldShowInTab } from './tabs';
import { tabForAnnotation } from './tabs';
import { sorters } from './thread-sorters';
export type ThreadState = {
annotations: Annotation[];
route: string | null;
showTabs: boolean;
selection: {
expanded: Record<string, boolean>;
filterQuery: string | null;
......@@ -22,11 +23,32 @@ export type ThreadState = {
};
};
export type ThreadAnnotationsResult = {
/**
* Count of annotations for each tab.
*
* These are only computed if {@link ThreadState.showTabs} is true.
*/
tabCounts: {
annotation: number;
note: number;
orphan: number;
};
/**
* Root thread containing all annotation threads that match the current
* filters and selected tab.
*/
rootThread: Thread;
};
/**
* Cobble together the right set of options and filters based on current
* `threadState` to build the root thread.
*/
function buildRootThread(threadState: ThreadState): Thread {
function threadAnnotationsImpl(
threadState: ThreadState,
): ThreadAnnotationsResult {
const selection = threadState.selection;
const options: BuildThreadOptions = {
expanded: selection.expanded,
......@@ -48,21 +70,30 @@ function buildRootThread(threadState: ThreadState): Thread {
options.filterFn = ann => filterAnnotations([ann], filters).length > 0;
}
// If annotations aren't filtered, should we filter out tab-irrelevant
// annotations (e.g. we should only show notes in the `Notes` tab)
// in the sidebar?
const threadFiltered =
!annotationsFiltered && threadState.route === 'sidebar';
const rootThread = buildThread(threadState.annotations, options);
if (threadFiltered) {
options.threadFilterFn = thread => {
if (!thread.annotation) {
const tabCounts = {
annotation: 0,
note: 0,
orphan: 0,
};
if (threadState.showTabs) {
rootThread.children = rootThread.children.filter(thread => {
if (thread.annotation && isWaitingToAnchor(thread.annotation)) {
// Until this annotation anchors or fails to anchor, we do not know which
// tab it should be displayed in.
return false;
}
return shouldShowInTab(thread.annotation, selection.selectedTab);
};
const tab = thread.annotation
? tabForAnnotation(thread.annotation)
: 'annotation';
tabCounts[tab] += 1;
return tab === selection.selectedTab;
});
}
return buildThread(threadState.annotations, options);
return { tabCounts, rootThread };
}
export const threadAnnotations = memoize(buildRootThread);
export const threadAnnotations = memoize(threadAnnotationsImpl);
......@@ -62,7 +62,7 @@ describe('integration: annotation threading', () => {
// Do things that cause `useRootThread` to recalculate in the store and
// test them (hint: use `act`)
function DummyComponent() {
lastRootThread = useRootThread();
lastRootThread = useRootThread().rootThread;
[, forceUpdate] = useReducer(x => x + 1, 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