Commit f72292dc authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Add new notification displayed when there are pending updates

parent 3ad17ab6
...@@ -11,6 +11,7 @@ import NotebookFilters from './NotebookFilters'; ...@@ -11,6 +11,7 @@ import NotebookFilters from './NotebookFilters';
import NotebookResultCount from './NotebookResultCount'; import NotebookResultCount from './NotebookResultCount';
import PaginatedThreadList from './PaginatedThreadList'; import PaginatedThreadList from './PaginatedThreadList';
import PendingUpdatesButton from './PendingUpdatesButton'; import PendingUpdatesButton from './PendingUpdatesButton';
import PendingUpdatesNotification from './PendingUpdatesNotification';
import { useRootThread } from './hooks/use-root-thread'; import { useRootThread } from './hooks/use-root-thread';
export type NotebookViewProps = { export type NotebookViewProps = {
...@@ -32,6 +33,9 @@ function NotebookView({ loadAnnotationsService, streamer }: NotebookViewProps) { ...@@ -32,6 +33,9 @@ function NotebookView({ loadAnnotationsService, streamer }: NotebookViewProps) {
const hasAppliedFilter = store.hasAppliedFilter(); const hasAppliedFilter = store.hasAppliedFilter();
const isLoading = store.isLoading(); const isLoading = store.isLoading();
const resultCount = store.annotationResultCount(); const resultCount = store.annotationResultCount();
const pendingUpdatesNotification = store.isFeatureEnabled(
'pending_updates_notification',
);
const { rootThread } = useRootThread(); const { rootThread } = useRootThread();
...@@ -129,11 +133,16 @@ function NotebookView({ loadAnnotationsService, streamer }: NotebookViewProps) { ...@@ -129,11 +133,16 @@ function NotebookView({ loadAnnotationsService, streamer }: NotebookViewProps) {
{groupName} {groupName}
</h1> </h1>
</header> </header>
<div className="absolute w-full z-5 left-0 top-7">
<div className="container flex flex-row-reverse">
{pendingUpdatesNotification && <PendingUpdatesNotification />}
</div>
</div>
<div className="justify-self-start"> <div className="justify-self-start">
<NotebookFilters /> <NotebookFilters />
</div> </div>
<div className="flex items-center lg:justify-self-end text-md font-medium"> <div className="flex items-center lg:justify-self-end text-md font-medium">
<PendingUpdatesButton /> {!pendingUpdatesNotification && <PendingUpdatesButton />}
<NotebookResultCount <NotebookResultCount
forcedVisibleCount={forcedVisibleCount} forcedVisibleCount={forcedVisibleCount}
isFiltered={hasAppliedFilter} isFiltered={hasAppliedFilter}
......
import { Button, RefreshIcon } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useShortcut } from '../../shared/shortcut';
import { withServices } from '../service-context';
import type { StreamerService } from '../services/streamer';
import { useSidebarStore } from '../store';
export type PendingUpdatesNotificationProps = {
// Injected
streamer: StreamerService;
// Test seams
setTimeout_?: typeof setTimeout;
clearTimeout_?: typeof clearTimeout;
};
const collapseDelay = 5000;
function PendingUpdatesNotification({
streamer,
/* istanbul ignore next - test seam */
setTimeout_ = setTimeout,
/* istanbul ignore next - test seam */
clearTimeout_ = clearTimeout,
}: PendingUpdatesNotificationProps) {
const store = useSidebarStore();
const pendingUpdateCount = store.pendingUpdateCount();
const hasPendingUpdates = store.hasPendingUpdates();
const applyPendingUpdates = useCallback(
() => streamer.applyPendingUpdates(),
[streamer],
);
const [collapsed, setCollapsed] = useState(false);
const timeout = useRef<number | null>(null);
useShortcut('l', () => hasPendingUpdates && applyPendingUpdates());
useEffect(() => {
if (hasPendingUpdates) {
timeout.current = setTimeout_(() => {
setCollapsed(true);
timeout.current = null;
}, collapseDelay);
} else {
setCollapsed(false);
}
return () => timeout.current && clearTimeout_(timeout.current);
}, [clearTimeout_, hasPendingUpdates, setTimeout_]);
if (!hasPendingUpdates) {
return null;
}
return (
<div role="status" className="animate-fade-in">
<Button
onClick={applyPendingUpdates}
unstyled
classes={classnames(
'flex gap-1.5 items-center py-1 px-2',
'rounded shadow-lg bg-gray-900 text-white',
)}
onMouseEnter={() => setCollapsed(false)}
onFocus={() => setCollapsed(false)}
onMouseLeave={() => !timeout.current && setCollapsed(true)}
onBlur={() => !timeout.current && setCollapsed(true)}
>
<RefreshIcon />
{!collapsed && (
<span data-testid="full-notification">
Load <span className="font-bold">{pendingUpdateCount}</span> updates{' '}
<span className="sr-only">by pressing l</span>
</span>
)}
{collapsed && (
<span data-testid="collapsed-notification" className="font-bold">
{pendingUpdateCount}
</span>
)}
</Button>
</div>
);
}
export default withServices(PendingUpdatesNotification, ['streamer']);
...@@ -8,6 +8,7 @@ import type { StreamerService } from '../services/streamer'; ...@@ -8,6 +8,7 @@ import type { StreamerService } from '../services/streamer';
import { useSidebarStore } from '../store'; import { useSidebarStore } from '../store';
import LoggedOutMessage from './LoggedOutMessage'; import LoggedOutMessage from './LoggedOutMessage';
import LoginPromptPanel from './LoginPromptPanel'; import LoginPromptPanel from './LoginPromptPanel';
import PendingUpdatesNotification from './PendingUpdatesNotification';
import SelectionTabs from './SelectionTabs'; import SelectionTabs from './SelectionTabs';
import SidebarContentError from './SidebarContentError'; import SidebarContentError from './SidebarContentError';
import ThreadList from './ThreadList'; import ThreadList from './ThreadList';
...@@ -41,6 +42,9 @@ function SidebarView({ ...@@ -41,6 +42,9 @@ function SidebarView({
const focusedGroupId = store.focusedGroupId(); const focusedGroupId = store.focusedGroupId();
const isLoading = store.isLoading(); const isLoading = store.isLoading();
const isLoggedIn = store.isLoggedIn(); const isLoggedIn = store.isLoggedIn();
const pendingUpdatesNotification = store.isFeatureEnabled(
'pending_updates_notification',
);
const linkedAnnotationId = store.directLinkedAnnotationId(); const linkedAnnotationId = store.directLinkedAnnotationId();
const linkedAnnotation = linkedAnnotationId const linkedAnnotation = linkedAnnotationId
...@@ -132,7 +136,7 @@ function SidebarView({ ...@@ -132,7 +136,7 @@ function SidebarView({
}, [hasFetchedProfile, isLoggedIn, sidebarHasOpened, streamer]); }, [hasFetchedProfile, isLoggedIn, sidebarHasOpened, streamer]);
return ( return (
<div> <div className="relative">
<h2 className="sr-only">Annotations</h2> <h2 className="sr-only">Annotations</h2>
{showFilterControls && <FilterControls withCardContainer />} {showFilterControls && <FilterControls withCardContainer />}
<LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} /> <LoginPromptPanel onLogin={onLogin} onSignUp={onSignUp} />
...@@ -149,6 +153,11 @@ function SidebarView({ ...@@ -149,6 +153,11 @@ function SidebarView({
{showTabs && ( {showTabs && (
<SelectionTabs isLoading={isLoading} tabCounts={tabCounts} /> <SelectionTabs isLoading={isLoading} tabCounts={tabCounts} />
)} )}
{pendingUpdatesNotification && (
<div className="fixed z-1 right-2 top-12">
<PendingUpdatesNotification />
</div>
)}
<ThreadList threads={rootThread.children} /> <ThreadList threads={rootThread.children} />
{showLoggedOutMessage && <LoggedOutMessage onLogin={onLogin} />} {showLoggedOutMessage && <LoggedOutMessage onLogin={onLogin} />}
</div> </div>
......
...@@ -50,6 +50,9 @@ function TopBar({ ...@@ -50,6 +50,9 @@ function TopBar({
const store = useSidebarStore(); const store = useSidebarStore();
const isLoggedIn = store.isLoggedIn(); const isLoggedIn = store.isLoggedIn();
const hasFetchedProfile = store.hasFetchedProfile(); const hasFetchedProfile = store.hasFetchedProfile();
const pendingUpdatesNotification = store.isFeatureEnabled(
'pending_updates_notification',
);
const toggleSharePanel = () => { const toggleSharePanel = () => {
store.toggleSidebarPanel('shareGroupAnnotations'); store.toggleSidebarPanel('shareGroupAnnotations');
...@@ -93,7 +96,7 @@ function TopBar({ ...@@ -93,7 +96,7 @@ function TopBar({
<div className="grow flex items-center justify-end"> <div className="grow flex items-center justify-end">
{isSidebar && ( {isSidebar && (
<> <>
<PendingUpdatesButton /> {!pendingUpdatesNotification && <PendingUpdatesButton />}
<SearchIconButton /> <SearchIconButton />
<SortMenu /> <SortMenu />
<TopBarToggleButton <TopBarToggleButton
......
...@@ -36,6 +36,7 @@ describe('NotebookView', () => { ...@@ -36,6 +36,7 @@ describe('NotebookView', () => {
annotationResultCount: sinon.stub().returns(0), annotationResultCount: sinon.stub().returns(0),
setSortKey: sinon.stub(), setSortKey: sinon.stub(),
hasFetchedProfile: sinon.stub().returns(true), hasFetchedProfile: sinon.stub().returns(true),
isFeatureEnabled: sinon.stub().returns(false),
}; };
fakeStreamer = { fakeStreamer = {
...@@ -151,6 +152,22 @@ describe('NotebookView', () => { ...@@ -151,6 +152,22 @@ describe('NotebookView', () => {
assert.isTrue(wrapper.find('NotebookFilters').exists()); assert.isTrue(wrapper.find('NotebookFilters').exists());
}); });
[true, false].forEach(pendingUpdatesNotificationEnabled => {
it('shows expected pending updates component', () => {
fakeStore.isFeatureEnabled.returns(pendingUpdatesNotificationEnabled);
const wrapper = createComponent();
assert.equal(
wrapper.exists('PendingUpdatesNotification'),
pendingUpdatesNotificationEnabled,
);
assert.equal(
wrapper.exists('PendingUpdatesButton'),
!pendingUpdatesNotificationEnabled,
);
});
});
describe('pagination', () => { describe('pagination', () => {
it('passes the current pagination page to `PaginatedThreadList`', () => { it('passes the current pagination page to `PaginatedThreadList`', () => {
const wrapper = createComponent(); const wrapper = createComponent();
......
import { mount } from 'enzyme';
import sinon from 'sinon';
import { promiseWithResolvers } from '../../../shared/promise-with-resolvers';
import PendingUpdatesNotification, {
$imports,
} from '../PendingUpdatesNotification';
describe('PendingUpdatesNotification', () => {
let fakeSetTimeout;
let fakeClearTimeout;
let fakeStreamer;
let fakeStore;
beforeEach(() => {
fakeSetTimeout = sinon.stub();
fakeClearTimeout = sinon.stub();
fakeStreamer = {
applyPendingUpdates: sinon.stub(),
};
fakeStore = {
pendingUpdateCount: sinon.stub().returns(3),
hasPendingUpdates: sinon.stub().returns(true),
};
$imports.$mock({
'../store': {
useSidebarStore: () => fakeStore,
},
});
});
afterEach(() => {
$imports.$restore();
});
function createComponent(container) {
return mount(
<PendingUpdatesNotification
streamer={fakeStreamer}
setTimeout_={fakeSetTimeout}
clearTimeout_={fakeClearTimeout}
/>,
{ attachTo: container },
);
}
function notificationIsCollapsed(wrapper) {
return wrapper.exists('[data-testid="collapsed-notification"]');
}
/**
* To avoid delaying tests too much, this stubs fakeSetTimeout behavior so
* that it schedules a real 1ms timeout, and resolves a promise when finished.
* That keeps the async nature of the timeout without affecting test execution
* times.
*/
function timeoutAsPromise() {
const { resolve, promise } = promiseWithResolvers();
fakeSetTimeout.callsFake(callback =>
setTimeout(() => {
callback();
resolve();
}, 1),
);
return promise;
}
it('does not render anything while there are no pending updates', () => {
fakeStore.hasPendingUpdates.returns(false);
const wrapper = createComponent();
assert.isEmpty(wrapper);
});
it('loads full notification first, and collapses it after a timeout', async () => {
const promise = timeoutAsPromise();
const wrapper = createComponent();
// Initially, it shows full notification
assert.isTrue(wrapper.exists('[data-testid="full-notification"]'));
assert.isFalse(notificationIsCollapsed(wrapper));
assert.calledOnce(fakeSetTimeout);
await promise; // Wait for timeout callback to be invoked
wrapper.update();
// Once the timeout callback has been invoked, it collapses the notification
assert.isFalse(wrapper.exists('[data-testid="full-notification"]'));
assert.isTrue(notificationIsCollapsed(wrapper));
});
it('clears any in-progress timeout when unmounted', () => {
const timeoutId = 1;
fakeSetTimeout.returns(timeoutId);
const wrapper = createComponent();
assert.notCalled(fakeClearTimeout);
wrapper.unmount();
assert.calledWith(fakeClearTimeout, timeoutId);
});
it('applies updates when notification is clicked', () => {
const wrapper = createComponent();
assert.notCalled(fakeStreamer.applyPendingUpdates);
wrapper.find('button').simulate('click');
assert.called(fakeStreamer.applyPendingUpdates);
});
[true, false].forEach(hasPendingUpdates => {
it('applies updates when "l" is pressed', () => {
fakeStore.hasPendingUpdates.returns(hasPendingUpdates);
let wrapper;
const container = document.createElement('div');
document.body.append(container);
try {
wrapper = createComponent(container);
assert.notCalled(fakeStreamer.applyPendingUpdates);
document.documentElement.dispatchEvent(
new KeyboardEvent('keydown', { key: 'l' }),
);
assert.equal(
fakeStreamer.applyPendingUpdates.called,
hasPendingUpdates,
);
} finally {
wrapper?.unmount();
container.remove();
}
});
});
['onMouseLeave', 'onBlur'].forEach(handler => {
it('collapses notification when mouse or focus leaves button', () => {
const wrapper = createComponent();
assert.isFalse(notificationIsCollapsed(wrapper));
wrapper.find('Button').prop(handler)();
wrapper.update();
assert.isTrue(notificationIsCollapsed(wrapper));
});
it('does not collapse notification when mouse or focus leaves button if timeout is in progress', () => {
fakeSetTimeout.returns(1);
const wrapper = createComponent();
assert.isFalse(notificationIsCollapsed(wrapper));
wrapper.find('Button').prop(handler)();
wrapper.update();
assert.isFalse(notificationIsCollapsed(wrapper));
});
});
['onMouseEnter', 'onFocus'].forEach(handler => {
it('expands notification when button is hovered or focused', async () => {
const promise = timeoutAsPromise();
const wrapper = createComponent();
await promise; // Wait for timeout callback to be invoked
wrapper.update();
assert.isTrue(notificationIsCollapsed(wrapper));
wrapper.find('Button').prop(handler)();
wrapper.update();
assert.isFalse(notificationIsCollapsed(wrapper));
});
});
});
...@@ -70,6 +70,7 @@ describe('SidebarView', () => { ...@@ -70,6 +70,7 @@ describe('SidebarView', () => {
profile: sinon.stub().returns({ userid: null }), profile: sinon.stub().returns({ userid: null }),
searchUris: sinon.stub().returns([]), searchUris: sinon.stub().returns([]),
toggleFocusMode: sinon.stub(), toggleFocusMode: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(false),
}; };
fakeTabsUtil = { fakeTabsUtil = {
...@@ -288,6 +289,19 @@ describe('SidebarView', () => { ...@@ -288,6 +289,19 @@ describe('SidebarView', () => {
}); });
}); });
context('when pending_updates_notification is enabled', () => {
[true, false].forEach(pendingUpdatesNotificationEnabled => {
it('shows PendingUpdatesNotification', () => {
fakeStore.isFeatureEnabled.returns(pendingUpdatesNotificationEnabled);
const wrapper = createComponent();
assert.equal(
wrapper.exists('PendingUpdatesNotification'),
pendingUpdatesNotificationEnabled,
);
});
});
});
it( it(
'should pass a11y checks', 'should pass a11y checks',
checkAccessibility({ checkAccessibility({
......
...@@ -19,6 +19,7 @@ describe('TopBar', () => { ...@@ -19,6 +19,7 @@ describe('TopBar', () => {
isLoggedIn: sinon.stub().returns(false), isLoggedIn: sinon.stub().returns(false),
isSidebarPanelOpen: sinon.stub().returns(false), isSidebarPanelOpen: sinon.stub().returns(false),
toggleSidebarPanel: sinon.stub(), toggleSidebarPanel: sinon.stub(),
isFeatureEnabled: sinon.stub().returns(true),
}; };
fakeFrameSync = { fakeFrameSync = {
...@@ -193,6 +194,17 @@ describe('TopBar', () => { ...@@ -193,6 +194,17 @@ describe('TopBar', () => {
}); });
}); });
[true, false].forEach(pendingUpdatesNotificationEnabled => {
it('renders PendingUpdatesButton when pending_updates_notification feature is not enabled', () => {
fakeStore.isFeatureEnabled.returns(pendingUpdatesNotificationEnabled);
const wrapper = createTopBar();
assert.equal(
wrapper.exists('PendingUpdatesButton'),
!pendingUpdatesNotificationEnabled,
);
});
});
it( it(
'should pass a11y checks', 'should pass a11y checks',
checkAccessibility([ checkAccessibility([
......
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