Commit e0f3f7d3 authored by Robert Knight's avatar Robert Knight

Split `useStore` hook into general and app-specific hooks

Split the `useStoreProxy` hook into a generic/base `useStore` hook that handles
wrapping a store created with `createStore`, and a `useSidebarStore` hook that
handles looking up the sidebar's main store via `useService('store')` and
passing it to the base hook.

This follows the existing separation of responsibilities between `createStore`
vs `createSidebarStore`, and would potentially allow us to split the generic
store infrastructure code into a separate package for use in other projects in
future.
parent 089a2702
import { Actions, Spinner } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import { isOrphan, isSaved, quote } from '../../helpers/annotation-metadata';
import { withServices } from '../../service-context';
......@@ -72,7 +72,7 @@ function Annotation({
}) {
const isCollapsedReply = isReply && threadIsCollapsed;
const store = useStoreProxy();
const store = useSidebarStore();
const draft = annotation && store.getDraft(annotation);
......
......@@ -8,7 +8,7 @@ import {
} from '../../helpers/annotation-sharing';
import { isPrivate, permits } from '../../helpers/permissions';
import { withServices } from '../../service-context';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import AnnotationShareControl from './AnnotationShareControl';
......@@ -48,7 +48,7 @@ function AnnotationActionBar({
settings,
toastMessenger,
}) {
const store = useStoreProxy();
const store = useSidebarStore();
const userProfile = store.profile();
const annotationGroup = store.getGroup(annotation.group);
const isLoggedIn = store.isLoggedIn();
......
......@@ -2,7 +2,7 @@ import { Icon, LabeledButton } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useMemo, useState } from 'preact/hooks';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import { isThirdPartyUser } from '../../helpers/account-id';
import { isHidden } from '../../helpers/annotation-metadata';
import { withServices } from '../../service-context';
......@@ -71,7 +71,7 @@ function AnnotationBody({ annotation, settings }) {
// collapsing/expanding is relevant?
const [collapsible, setCollapsible] = useState(false);
const store = useStoreProxy();
const store = useSidebarStore();
const defaultAuthority = store.defaultAuthority();
const draft = store.getDraft(annotation);
......
......@@ -4,7 +4,7 @@ import { useCallback, useState } from 'preact/hooks';
import { withServices } from '../../service-context';
import { isReply, isSaved } from '../../helpers/annotation-metadata';
import { applyTheme } from '../../helpers/theme';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import MarkdownEditor from '../MarkdownEditor';
import TagEditor from '../TagEditor';
......@@ -46,7 +46,7 @@ function AnnotationEditor({
/** @type {string|null} */ (null)
);
const store = useStoreProxy();
const store = useSidebarStore();
const group = store.getGroup(annotation.group);
const shouldShowLicense =
......
......@@ -2,7 +2,7 @@ import { Icon, LinkButton } from '@hypothesis/frontend-shared';
import { useMemo } from 'preact/hooks';
import { withServices } from '../../service-context';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import { isThirdPartyUser, username } from '../../helpers/account-id';
import {
domainAndTitle,
......@@ -56,7 +56,7 @@ function AnnotationHeader({
threadIsCollapsed,
settings,
}) {
const store = useStoreProxy();
const store = useSidebarStore();
const defaultAuthority = store.defaultAuthority();
const displayNamesEnabled = store.isFeatureEnabled('client_display_names');
......
......@@ -64,7 +64,7 @@ describe('Annotation', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../helpers/annotation-metadata': fakeMetadata,
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -95,7 +95,7 @@ describe('AnnotationActionBar', () => {
annotationSharingLink: fakeAnnotationSharingLink,
},
'../../helpers/permissions': { permits: fakePermits },
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
'../../../shared/prompts': { confirm: fakeConfirm },
});
});
......
......@@ -60,7 +60,7 @@ describe('AnnotationBody', () => {
$imports.$mock({
'../../helpers/account-id': { isThirdPartyUser: fakeIsThirdPartyUser },
'../../helpers/theme': { applyTheme: fakeApplyTheme },
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -64,7 +64,7 @@ describe('AnnotationEditor', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
'../../helpers/theme': { applyTheme: fakeApplyTheme },
});
// `AnnotationLicense` is a presentation-only component and is only used
......
......@@ -66,7 +66,7 @@ describe('AnnotationHeader', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
'../../helpers/account-id': fakeAccountId,
'../../helpers/annotation-metadata': {
domainAndTitle: fakeDomainAndTitle,
......
import { useEffect, useState } from 'preact/hooks';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { withServices } from '../service-context';
import { useRootThread } from './hooks/use-root-thread';
......@@ -19,7 +19,7 @@ import SidebarContentError from './SidebarContentError';
* @param {AnnotationViewProps} props
*/
function AnnotationView({ loadAnnotationsService, onLogin }) {
const store = useStoreProxy();
const store = useSidebarStore();
const annotationId = store.routeParams().id;
const rootThread = useRootThread();
const userid = store.profile().userid;
......
......@@ -4,7 +4,7 @@ import classNames from 'classnames';
import { useMemo } from 'preact/hooks';
import { countVisible } from '../helpers/thread';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { useRootThread } from './hooks/use-root-thread';
......@@ -69,7 +69,7 @@ function FilterStatusPanel({
focusDisplayName,
resultCount,
}) {
const store = useStoreProxy();
const store = useSidebarStore();
return (
<Card classes="mb-3 p-3">
<div className="flex items-center justify-center space-x-1">
......@@ -133,7 +133,7 @@ function FilterStatusPanel({
* @param {FilterModeProps} props
*/
function SelectionFilterStatus({ filterState, rootThread }) {
const store = useStoreProxy();
const store = useSidebarStore();
const directLinkedId = store.directLinkedAnnotationId();
// The total number of top-level annotations (visible or not)
const totalCount = store.annotationCount();
......@@ -188,7 +188,7 @@ function SelectionFilterStatus({ filterState, rootThread }) {
* @param {FilterModeProps} props
*/
function QueryFilterStatus({ filterState, rootThread }) {
const store = useStoreProxy();
const store = useSidebarStore();
const visibleCount = countVisible(rootThread);
const resultCount = visibleCount - filterState.forcedVisibleCount;
......@@ -233,7 +233,7 @@ function QueryFilterStatus({ filterState, rootThread }) {
* @param {FilterModeProps} props
*/
function FocusFilterStatus({ filterState, rootThread }) {
const store = useStoreProxy();
const store = useSidebarStore();
const visibleCount = countVisible(rootThread);
const resultCount = visibleCount - filterState.forcedVisibleCount;
......@@ -280,7 +280,7 @@ function FocusFilterStatus({ filterState, rootThread }) {
export default function FilterStatus() {
const rootThread = useRootThread();
const store = useStoreProxy();
const store = useSidebarStore();
const focusState = store.focusState();
const forcedVisibleCount = store.forcedVisibleThreads().length;
const filterQuery = store.filterQuery();
......
......@@ -6,7 +6,7 @@ import { orgName } from '../../helpers/group-list-item-common';
import { groupsByOrganization } from '../../helpers/group-organizations';
import { isThirdPartyService } from '../../helpers/is-third-party-service';
import { withServices } from '../../service-context';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import Menu from '../Menu';
import MenuItem from '../MenuItem';
......@@ -41,7 +41,7 @@ function publisherProvidedIcon(settings) {
* @param {GroupListProps} props
*/
function GroupList({ settings }) {
const store = useStoreProxy();
const store = useSidebarStore();
const currentGroups = store.getCurrentlyViewingGroups();
const featuredGroups = store.getFeaturedGroups();
const myGroups = store.getMyGroups();
......
import { orgName } from '../../helpers/group-list-item-common';
import { withServices } from '../../service-context';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import { copyText } from '../../util/copy-to-clipboard';
import { confirm } from '../../../shared/prompts';
......@@ -40,7 +40,7 @@ function GroupListItem({
const isSelectable =
(group.scopes && !group.scopes.enforced) || group.isScopedToUri;
const store = useStoreProxy();
const store = useSidebarStore();
const focusedGroupId = store.focusedGroupId();
const isSelected = group.id === focusedGroupId;
......
......@@ -57,7 +57,7 @@ describe('GroupList', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
'../../config/service-config': { serviceConfig: fakeServiceConfig },
});
});
......
......@@ -67,7 +67,7 @@ describe('GroupListItem', () => {
copyText: fakeCopyText,
},
'../../helpers/group-list-item-common': fakeGroupListItemCommon,
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
'../../../shared/prompts': { confirm: fakeConfirm },
});
});
......
import { Icon, Link, LinkButton } from '@hypothesis/frontend-shared';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { withServices } from '../service-context';
import { VersionData } from '../helpers/version-data';
......@@ -63,7 +63,7 @@ function HelpPanelTab({ linkText, url }) {
* @param {HelpPanelProps} props
*/
function HelpPanel({ auth, session }) {
const store = useStoreProxy();
const store = useSidebarStore();
const frames = store.frames();
const mainFrame = store.mainFrame();
......
......@@ -7,7 +7,7 @@ import { parseAccountID } from '../helpers/account-id';
import { shouldAutoDisplayTutorial } from '../helpers/session';
import { applyTheme } from '../helpers/theme';
import { withServices } from '../service-context';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import AnnotationView from './AnnotationView';
import SidebarView from './SidebarView';
......@@ -68,7 +68,7 @@ function authStateFromProfile(profile) {
* @param {HypothesisAppProps} props
*/
function HypothesisApp({ auth, frameSync, settings, session, toastMessenger }) {
const store = useStoreProxy();
const store = useSidebarStore();
const hasFetchedProfile = store.hasFetchedProfile();
const profile = store.profile();
const route = store.route();
......
import { Link, LinkButton, Icon } from '@hypothesis/frontend-shared';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
/**
* @typedef LoggedOutMessageProps
......@@ -15,7 +15,7 @@ import { useStoreProxy } from '../store/use-store';
* @param {LoggedOutMessageProps} props
*/
function LoggedOutMessage({ onLogin }) {
const store = useStoreProxy();
const store = useSidebarStore();
return (
<div className="flex flex-col items-center m-6 space-y-6">
......
import { Actions, LabeledButton } from '@hypothesis/frontend-shared';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import SidebarPanel from './SidebarPanel';
......@@ -16,7 +16,7 @@ import SidebarPanel from './SidebarPanel';
* @param {LoginPromptPanelProps} props
*/
export default function LoginPromptPanel({ onLogin, onSignUp }) {
const store = useStoreProxy();
const store = useSidebarStore();
const isLoggedIn = store.isLoggedIn();
if (isLoggedIn) {
return null;
......
import { Icon, LabeledButton } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import * as annotationMetadata from '../helpers/annotation-metadata';
import { withServices } from '../service-context';
......@@ -25,7 +25,7 @@ import { withServices } from '../service-context';
* @param {ModerationBannerProps} props
*/
function ModerationBanner({ annotation, api, toastMessenger }) {
const store = useStoreProxy();
const store = useSidebarStore();
const flagCount = annotationMetadata.flagCount(annotation);
const isHiddenOrFlagged =
......
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { useUserFilterOptions } from './hooks/use-filter-options';
import FilterSelect from './FilterSelect';
......@@ -11,7 +11,7 @@ import FilterSelect from './FilterSelect';
* Filters for the Notebook
*/
function NotebookFilters() {
const store = useStoreProxy();
const store = useSidebarStore();
const userFilter = store.getFilter('user');
const userFilterOptions = useUserFilterOptions();
......
......@@ -4,7 +4,7 @@ import scrollIntoView from 'scroll-into-view';
import { ResultSizeError } from '../search-client';
import { withServices } from '../service-context';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import NotebookFilters from './NotebookFilters';
import NotebookResultCount from './NotebookResultCount';
......@@ -23,7 +23,7 @@ import { useRootThread } from './hooks/use-root-thread';
* @param {NotebookViewProps} props
*/
function NotebookView({ loadAnnotationsService, streamer }) {
const store = useStoreProxy();
const store = useSidebarStore();
const filters = store.getFilterValues();
const focusedGroup = store.focusedGroup();
......
......@@ -2,7 +2,7 @@ import { IconButton, Spinner } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useRef, useState } from 'preact/hooks';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
/**
* @typedef SearchInputProps
......@@ -26,7 +26,7 @@ import { useStoreProxy } from '../store/use-store';
* @param {SearchInputProps} props
*/
export default function SearchInput({ alwaysExpanded, query, onSearch }) {
const store = useStoreProxy();
const store = useSidebarStore();
const isLoading = store.isLoading();
const input = /** @type {{ current: HTMLInputElement }} */ (useRef());
......
......@@ -7,8 +7,8 @@ import {
import classnames from 'classnames';
import { applyTheme } from '../helpers/theme';
import { useStoreProxy } from '../store/use-store';
import { withServices } from '../service-context';
import { useSidebarStore } from '../store';
/**
* @typedef {import('../../types/config').SidebarSettings} SidebarSettings
......@@ -91,7 +91,7 @@ function Tab({
* @param {SelectionTabsProps} props
*/
function SelectionTabs({ annotationsService, isLoading, settings }) {
const store = useStoreProxy();
const store = useSidebarStore();
const selectedTab = store.selectedTab();
const noteCount = store.noteCount();
const annotationCount = store.annotationCount();
......
......@@ -6,7 +6,7 @@ import {
TextInputWithButton,
} from '@hypothesis/frontend-shared';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { pageSharingLink } from '../helpers/annotation-sharing';
import { copyText } from '../util/copy-to-clipboard';
import { withServices } from '../service-context';
......@@ -30,7 +30,7 @@ import SidebarPanel from './SidebarPanel';
* @param {ShareAnnotationsPanelProps} props
*/
function ShareAnnotationsPanel({ toastMessenger }) {
const store = useStoreProxy();
const store = useSidebarStore();
const mainFrame = store.mainFrame();
const focusedGroup = store.focusedGroup();
const groupName = (focusedGroup && focusedGroup.name) || '...';
......
import { LabeledButton, Panel } from '@hypothesis/frontend-shared';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
/**
* @typedef SidebarContentErrorProps
......@@ -20,7 +20,7 @@ export default function SidebarContentError({
onLoginRequest,
showClearSelection = false,
}) {
const store = useStoreProxy();
const store = useSidebarStore();
const isLoggedIn = store.isLoggedIn();
const errorTitle =
......
......@@ -2,7 +2,7 @@ import { Panel } from '@hypothesis/frontend-shared';
import { useCallback, useEffect, useRef } from 'preact/hooks';
import scrollIntoView from 'scroll-into-view';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import Slider from './Slider';
......@@ -35,7 +35,7 @@ export default function SidebarPanel({
title,
onActiveChanged,
}) {
const store = useStoreProxy();
const store = useSidebarStore();
const panelIsActive = store.isSidebarPanelOpen(panelName);
const panelElement = useRef(/** @type {HTMLDivElement|null}*/ (null));
......
......@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'preact/hooks';
import { useRootThread } from './hooks/use-root-thread';
import { withServices } from '../service-context';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { tabForAnnotation } from '../helpers/tabs';
import FilterStatus from './FilterStatus';
......@@ -36,7 +36,7 @@ function SidebarView({
const rootThread = useRootThread();
// Store state values
const store = useStoreProxy();
const store = useSidebarStore();
const focusedGroupId = store.focusedGroupId();
const hasAppliedFilter =
store.hasAppliedFilter() || store.hasSelectedAnnotations();
......
import { Icon } from '@hypothesis/frontend-shared';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import Menu from './Menu';
import MenuItem from './MenuItem';
......@@ -9,7 +9,7 @@ import MenuItem from './MenuItem';
* A drop-down menu of sorting options for a collection of annotations.
*/
export default function SortMenu() {
const store = useStoreProxy();
const store = useSidebarStore();
// The currently-applied sort order
const sortKey = store.sortKey();
// All available sorting options. These change depending on current
......
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { withServices } from '../service-context';
import SearchInput from './SearchInput';
......@@ -16,7 +16,7 @@ import SearchInput from './SearchInput';
* @param {StreamSearchInputProps} props
*/
function StreamSearchInput({ router }) {
const store = useStoreProxy();
const store = useSidebarStore();
const query = store.routeParams().q;
/** @param {string} query */
const setQuery = query => {
......
......@@ -3,7 +3,7 @@ import { useCallback, useEffect } from 'preact/hooks';
import * as searchFilter from '../util/search-filter';
import { withServices } from '../service-context';
import { useRootThread } from './hooks/use-root-thread';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import ThreadList from './ThreadList';
......@@ -19,7 +19,7 @@ import ThreadList from './ThreadList';
* @param {StreamViewProps} props
*/
function StreamView({ api, toastMessenger }) {
const store = useStoreProxy();
const store = useSidebarStore();
const currentQuery = store.routeParams().q;
/**
......
......@@ -2,7 +2,7 @@ import { IconButton, LabeledButton } from '@hypothesis/frontend-shared';
import classnames from 'classnames';
import { useCallback, useMemo } from 'preact/hooks';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { withServices } from '../service-context';
import { countHidden, countVisible } from '../helpers/thread';
......@@ -26,7 +26,7 @@ import ModerationBanner from './ModerationBanner';
* @param {boolean} props.threadIsCollapsed
*/
function HiddenThreadCardHeader({ annotation, ...restProps }) {
const store = useStoreProxy();
const store = useSidebarStore();
// These two lines are copied from the AnnotationHeader component to mimic the
// exact same behaviour.
......@@ -129,7 +129,7 @@ function Thread({ thread, threadsService }) {
child => countVisible(child) > 0
);
const store = useStoreProxy();
const store = useSidebarStore();
const hasAppliedFilter = store.hasAppliedFilter();
const onToggleReplies = useCallback(
() => store.setExpanded(thread.id, !!thread.collapsed),
......
......@@ -3,7 +3,7 @@ import classnames from 'classnames';
import debounce from 'lodash.debounce';
import { useCallback, useMemo } from 'preact/hooks';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { withServices } from '../service-context';
import Thread from './Thread';
......@@ -25,7 +25,7 @@ import Thread from './Thread';
* @param {ThreadCardProps} props
*/
function ThreadCard({ frameSync, thread }) {
const store = useStoreProxy();
const store = useSidebarStore();
const threadTag = thread.annotation?.$tag ?? null;
const isFocused = threadTag && store.isAnnotationFocused(threadTag);
const focusThreadAnnotation = useMemo(
......
......@@ -7,7 +7,7 @@ import {
calculateVisibleThreads,
THREAD_DIMENSION_DEFAULTS,
} from '../helpers/visible-threads';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { getElementHeightWithMargins } from '../util/dom';
import ThreadCard from './ThreadCard';
......@@ -109,7 +109,7 @@ function ThreadList({ threads }) {
[topLevelThreads, threadHeights, scrollPosition, scrollContainerHeight]
);
const store = useStoreProxy();
const store = useSidebarStore();
// Get the `$tag` of the most recently created unsaved annotation.
const newAnnotationTag = (() => {
......
import classnames from 'classnames';
import { Icon } from '@hypothesis/frontend-shared';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import { withServices } from '../service-context';
/**
......@@ -86,7 +86,7 @@ function ToastMessage({ message, onDismiss }) {
* @param {ToastMessagesProps} props
*/
function ToastMessages({ toastMessenger }) {
const store = useStoreProxy();
const store = useSidebarStore();
const messages = store.getToastMessages();
return (
<div>
......
......@@ -5,7 +5,7 @@ import { serviceConfig } from '../config/service-config';
import { isThirdPartyService } from '../helpers/is-third-party-service';
import { applyTheme } from '../helpers/theme';
import { withServices } from '../service-context';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import GroupList from './GroupList';
import SearchInput from './SearchInput';
......@@ -53,7 +53,7 @@ function TopBar({
const showSharePageButton = !isThirdPartyService(settings);
const loginLinkStyle = applyTheme(['accentColor'], settings);
const store = useStoreProxy();
const store = useSidebarStore();
const filterQuery = store.filterQuery();
const pendingUpdateCount = store.pendingUpdateCount();
......
......@@ -4,7 +4,7 @@ import { useState } from 'preact/hooks';
import { serviceConfig } from '../config/service-config';
import { isThirdPartyUser } from '../helpers/account-id';
import { withServices } from '../service-context';
import { useStoreProxy } from '../store/use-store';
import { useSidebarStore } from '../store';
import Menu from './Menu';
import MenuItem from './MenuItem';
......@@ -40,7 +40,7 @@ import MenuSection from './MenuSection';
* @param {UserMenuProps} props
*/
function UserMenu({ auth, frameSync, onLogout, settings }) {
const store = useStoreProxy();
const store = useSidebarStore();
const defaultAuthority = store.defaultAuthority();
const isThirdParty = isThirdPartyUser(auth.userid, defaultAuthority);
......
......@@ -60,7 +60,7 @@ describe('sidebar/components/hooks/use-user-filter-options', () => {
$imports.$mock({
'../../helpers/account-id': fakeAccountId,
'../../helpers/annotation-user': fakeAnnotationUser,
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -18,7 +18,7 @@ describe('sidebar/components/hooks/use-root-thread', () => {
fakeThreadAnnotations = sinon.stub().returns('fakeThreadAnnotations');
$imports.$mock({
'../../store/use-store': { useStoreProxy: () => fakeStore },
'../../store': { useSidebarStore: () => fakeStore },
'../../helpers/thread-annotations': {
threadAnnotations: fakeThreadAnnotations,
},
......
import { useMemo } from 'preact/hooks';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import { isThirdPartyUser, username } from '../../helpers/account-id';
import { annotationDisplayName } from '../../helpers/annotation-user';
......@@ -13,7 +13,7 @@ import { annotationDisplayName } from '../../helpers/annotation-user';
* @return {FilterOption[]}
*/
export function useUserFilterOptions() {
const store = useStoreProxy();
const store = useSidebarStore();
const annotations = store.allAnnotations();
const defaultAuthority = store.defaultAuthority();
const displayNamesEnabled = store.isFeatureEnabled('client_display_names');
......
import { useMemo } from 'preact/hooks';
import { useStoreProxy } from '../../store/use-store';
import { useSidebarStore } from '../../store';
import { threadAnnotations } from '../../helpers/thread-annotations';
/** @typedef {import('../../helpers/build-thread').Thread} Thread */
......@@ -12,7 +12,7 @@ import { threadAnnotations } from '../../helpers/thread-annotations';
* @return {Thread}
*/
export function useRootThread() {
const store = useStoreProxy();
const store = useSidebarStore();
const annotations = store.allAnnotations();
const query = store.filterQuery();
const route = store.route();
......
......@@ -32,7 +32,7 @@ describe('AnnotationView', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'./hooks/use-root-thread': { useRootThread: fakeUseRootThread },
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -53,7 +53,7 @@ describe('FilterStatus', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'./hooks/use-root-thread': { useRootThread: fakeUseRootThread },
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../helpers/thread': fakeThreadUtil,
});
});
......
......@@ -39,7 +39,7 @@ describe('HelpPanel', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../helpers/version-data': { VersionData: FakeVersionData },
});
});
......
......@@ -79,7 +79,7 @@ describe('HypothesisApp', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../config/service-config': { serviceConfig: fakeServiceConfig },
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../helpers/session': {
shouldAutoDisplayTutorial: fakeShouldAutoDisplayTutorial,
},
......
......@@ -19,7 +19,7 @@ describe('LoggedOutMessage', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -31,7 +31,7 @@ describe('LoginPromptPanel', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -41,7 +41,7 @@ describe('ModerationBanner', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -26,7 +26,7 @@ describe('NotebookFilters', () => {
'./hooks/use-filter-options': {
useUserFilterOptions: fakeUseUserFilterOptions,
},
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -43,7 +43,7 @@ describe('NotebookView', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'./hooks/use-root-thread': { useRootThread: fakeUseRootThread },
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'scroll-into-view': fakeScrollIntoView,
});
});
......
......@@ -24,7 +24,7 @@ describe('SearchInput', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -46,7 +46,7 @@ describe('SelectionTabs', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -45,7 +45,7 @@ describe('ShareAnnotationsPanel', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../helpers/annotation-sharing': {
pageSharingLink: fakePageSharingLink,
},
......
......@@ -25,7 +25,7 @@ describe('SidebarContentError', () => {
isLoggedIn: sinon.stub().returns(true),
};
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
$imports.$mock(mockImportedComponents());
});
......
......@@ -22,7 +22,7 @@ describe('SidebarPanel', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'scroll-into-view': fakeScrollIntoView,
});
});
......
......@@ -69,7 +69,7 @@ describe('SidebarView', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'./hooks/use-root-thread': { useRootThread: fakeUseRootThread },
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../helpers/tabs': fakeTabsUtil,
});
});
......
......@@ -21,7 +21,7 @@ describe('SortMenu', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -18,7 +18,7 @@ describe('StreamSearchInput', () => {
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -41,7 +41,7 @@ describe('StreamView', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'./hooks/use-root-thread': { useRootThread: fakeUseRootThread },
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../util/search-filter': fakeSearchFilter,
});
});
......
......@@ -98,7 +98,7 @@ describe('Thread', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../helpers/thread': fakeThreadUtil,
});
});
......
......@@ -38,7 +38,7 @@ describe('ThreadCard', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'lodash.debounce': fakeDebounce,
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -66,7 +66,7 @@ describe('ThreadList', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../util/dom': fakeDomUtil,
'../helpers/visible-threads': fakeVisibleThreadsUtil,
});
......
......@@ -55,7 +55,7 @@ describe('ToastMessages', () => {
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -40,7 +40,7 @@ describe('TopBar', () => {
'./SidebarContent': true,
});
$imports.$mock({
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
'../helpers/is-third-party-service': {
isThirdPartyService: fakeIsThirdPartyService,
},
......
......@@ -54,7 +54,7 @@ describe('UserMenu', () => {
isThirdPartyUser: fakeIsThirdPartyUser,
},
'../config/service-config': { serviceConfig: fakeServiceConfig },
'../store/use-store': { useStoreProxy: () => fakeStore },
'../store': { useSidebarStore: () => fakeStore },
});
});
......
......@@ -187,9 +187,9 @@ function assignOnce(target, source) {
* selector and action methods rather than `getState` or `dispatch`. This
* makes it easier to refactor the internal state structure.
*
* Preact UI components access stores via the `useStoreProxy` hook defined in
* `use-store.js`. This returns a proxy which enables UI components to observe
* what store state a component depends upon and re-render when it changes.
* Preact UI components access stores via the `useStore` hook. This returns a
* proxy which enables UI components to observe what store state a component
* depends upon and re-render when it changes.
*
* @template {readonly Module<any,any,any,any>[]} Modules
* @param {Modules} modules
......
import { useService } from '../service-context';
import { createStore } from './create-store';
import { debugMiddleware } from './debug-middleware';
import { activityModule } from './modules/activity';
......@@ -16,6 +17,7 @@ import { sessionModule } from './modules/session';
import { sidebarPanelsModule } from './modules/sidebar-panels';
import { toastMessagesModule } from './modules/toast-messages';
import { viewerModule } from './modules/viewer';
import { useStore } from './use-store';
/** @typedef {ReturnType<createSidebarStore>} SidebarStore */
......@@ -56,3 +58,15 @@ export function createSidebarStore(settings) {
]);
return createStore(modules, [settings], middleware);
}
/**
* Hook for accessing the sidebar's store in UI components.
*
* Returns a wrapper around the store which tracks its usage by the component
* and re-renders the component when relevant data in the store changes. See
* {@link useStore}.
*/
export function useSidebarStore() {
const store = /** @type {SidebarStore} */ (useService('store'));
return useStore(store);
}
import { render } from 'preact';
import { act } from 'preact/test-utils';
import * as annotationFixtures from '../../test/annotation-fixtures';
import { createSidebarStore } from '../index';
import { createSidebarStore, useSidebarStore } from '../index';
import { immutable } from '../../util/immutable';
import { ServiceContext } from '../../service-context';
const defaultAnnotation = annotationFixtures.defaultAnnotation;
const newAnnotation = annotationFixtures.newAnnotation;
......@@ -148,3 +152,45 @@ describe('createSidebarStore', () => {
});
});
});
describe('useSidebarStore', () => {
function AnnotationCard({ id }) {
const store = useSidebarStore();
const ann = store.findAnnotationByID(id);
return <div>{ann.text}</div>;
}
// `useSidebarStore` is a trivial wrapper, so rather than mock its dependencies,
// this is a more useful integration test that covers interaction of the store
// and UI components.
it('returns wrapper for components to interact with store', () => {
const store = createSidebarStore({});
const annot = { ...defaultAnnotation(), text: 'Initial text' };
store.addAnnotations([annot]);
const services = {
get(service) {
return service === 'store' ? store : null;
},
};
const el = document.createElement('div');
act(() => {
render(
<ServiceContext.Provider value={services}>
<AnnotationCard id={annot.id} />
</ServiceContext.Provider>,
el
);
});
assert.equal(el.innerHTML, '<div>Initial text</div>');
act(() => {
const updatedAnnot = { ...annot, text: 'Updated text' };
store.addAnnotations([updatedAnnot]);
});
assert.equal(el.innerHTML, '<div>Updated text</div>');
render(null, el); // Force unmount and cleanup of subscribers
});
});
......@@ -2,7 +2,7 @@ import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { createStore, createStoreModule } from '../create-store';
import { useStoreProxy, $imports } from '../use-store';
import { useStore } from '../use-store';
// Store module for use with `createStore` in tests.
const initialState = () => ({ things: [] });
......@@ -36,34 +36,27 @@ const thingsModule = createStoreModule(initialState, {
});
describe('sidebar/store/use-store', () => {
afterEach(() => {
$imports.$restore();
});
describe('useStoreProxy', () => {
describe('useStore', () => {
let store;
let renderCount;
beforeEach(() => {
renderCount = 0;
store = createStore([thingsModule]);
store.addThing('foo');
store.addThing('bar');
$imports.$mock({
'../service-context': {
useService: name => (name === 'store' ? store : null),
},
});
});
function useTestStore() {
return useStore(store);
}
function renderTestComponent() {
let proxy;
const TestComponent = () => {
++renderCount;
proxy = useStoreProxy();
proxy = useTestStore();
return <div>{proxy.thingCount()}</div>;
};
......
import { useEffect, useRef, useReducer } from 'preact/hooks';
import { useService } from '../service-context';
/** @typedef {import("redux").Store} Store */
/** @typedef {import("./index").SidebarStore} SidebarStore */
/**
* Result of a cached store selector method call.
*/
......@@ -35,18 +29,26 @@ class CacheEntry {
}
/**
* Return a wrapper around the `store` service that UI components can use to
* extract data from the store and call actions on it.
* Return a wrapper around a store that UI components can use to read from and
* modify data in it.
*
* Unlike using the `store` service directly, the wrapper tracks what data from
* the store the current component uses, via selector methods, and re-renders the
* component when that data changes.
* Unlike using the store directly, the wrapper tracks what data from
* the store the current component uses, by recording calls to selector methods,
* and re-renders the components when the results of those calls change.
*
* The returned wrapper has the same API as the store itself.
*
* @example
* // A hook which encapsulates looking up the specific store instance,
* // eg. via `useContext`.
* function useAppStore() {
* // Get the store from somewhere, eg. a prop or context.
* const appStore = ...;
* return useStore(store);
* }
*
* function MyComponent() {
* const store = useStoreProxy();
* const store = useAppStore();
* const currentUser = store.currentUser();
*
* return (
......@@ -57,11 +59,11 @@ class CacheEntry {
* );
* }
*
* @return {SidebarStore}
* @template {import('./create-store').Store<unknown, unknown, unknown>} Store
* @param {Store} store - The store to wrap
* @return {Store} - A proxy with the same API as `store`
*/
export function useStoreProxy() {
const store = /** @type {SidebarStore} */ (useService('store'));
export function useStore(store) {
// Hack to trigger a component re-render.
const [, forceUpdate] = useReducer(x => x + 1, 0);
......@@ -75,7 +77,7 @@ export function useStoreProxy() {
const cache = cacheRef.current;
// Create the wrapper around the store.
const proxy = useRef(/** @type {SidebarStore|null} */ (null));
const proxy = useRef(/** @type {Store|null} */ (null));
if (!proxy.current) {
// Cached method wrappers.
/** @type {Map<string, Function>} */
......@@ -147,5 +149,5 @@ export function useStoreProxy() {
return cleanup;
}, [cache, store]);
return /** @type {SidebarStore} */ (proxy.current);
return /** @type {Store} */ (proxy.current);
}
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