From 070866d1beb2911098568ef8f5abb98bb06a8ea1 Mon Sep 17 00:00:00 2001 From: Alec Deitloff Date: Wed, 12 Jan 2022 16:55:48 -0800 Subject: [PATCH] Implement adding/removing counters from user collections --- src/client/hooks/useUserCollections.ts | 103 +++++++++---- src/client/interfaces/index.ts | 12 ++ src/client/ui/modules/explore/ExplorePage.tsx | 2 +- .../hooks/useAddCounterToCollection.ts | 141 ----------------- .../hooks/useRemoveCounterFromCollection.ts | 144 ------------------ .../pages/collection/UserCollectionView.tsx | 43 +----- .../pages/counter/ExploreCounterPage.tsx | 34 +---- .../edit-membership-dialog/CollectionRow.tsx | 17 +-- .../EditMembershipDialog.tsx | 51 ++----- .../ui/modules/explore/pages/counter/types.ts | 1 - .../explore/{hooks => }/useExploreRoutes.tsx | 0 11 files changed, 107 insertions(+), 441 deletions(-) delete mode 100644 src/client/ui/modules/explore/hooks/useAddCounterToCollection.ts delete mode 100644 src/client/ui/modules/explore/hooks/useRemoveCounterFromCollection.ts delete mode 100644 src/client/ui/modules/explore/pages/counter/types.ts rename src/client/ui/modules/explore/{hooks => }/useExploreRoutes.tsx (100%) diff --git a/src/client/hooks/useUserCollections.ts b/src/client/hooks/useUserCollections.ts index 6d27e65b..be371277 100644 --- a/src/client/hooks/useUserCollections.ts +++ b/src/client/hooks/useUserCollections.ts @@ -97,12 +97,62 @@ function useUserCollections(): HookResults { USER_COLLECTIONS_STORAGE.setValue(state.collections); }, [state.collections]); + // Build a utility function that generates a field mutator for UserCounterCollections + // (the general case for functions). + const mutateCollectionField = useCallback( + ( + collectionId: string, + generate: (current: SerializedUserCollection) => SerializedUserCollection + ): Promise => { + return new Promise((resolve, reject) => { + setState( + (current): InternalState => { + const index = current.collections.findIndex( + ({ id }) => id === collectionId + ); + if (index < 0) { + return { + callbacks: [ + ...current.callbacks, + () => reject(new Error("This collection no longer exists")), + ], + collections: current.collections, + }; + } + + // Create a new container array and a new instance of the + // collection object + const nextCollections = [...current.collections]; + nextCollections[index] = generate(nextCollections[index]); + + return { + callbacks: [...current.callbacks, resolve], + collections: nextCollections, + }; + } + ); + }); + }, + [] + ); + // Convert our serialized data structures into our client types const userCollections = useMemo( (): readonly UserCounterCollection[] => state.collections.map( (collection): UserCounterCollection => ({ ...collection, + addCounter: (counterId) => + mutateCollectionField(collection.id, (current) => { + if (current.counterIds.includes(counterId)) { + return current; + } + + return { + ...current, + counterIds: [...current.counterIds, counterId], + }; + }), delete: () => new Promise((resolve, reject) => { setState( @@ -130,42 +180,29 @@ function useUserCollections(): HookResults { } ); }), - rename: (name) => - new Promise((resolve, reject) => { - setState( - (current): InternalState => { - const index = current.collections.findIndex( - ({ id }) => id === collection.id - ); - if (index < 0) { - return { - callbacks: [ - ...current.callbacks, - () => - reject(new Error("This collection no longer exists")), - ], - collections: current.collections, - }; - } - - // Create a new container array and a new instance of the - // collection object - const nextCollections = [...current.collections]; - nextCollections[index] = { - ...nextCollections[index], - name, - }; - - return { - callbacks: [...current.callbacks, resolve], - collections: nextCollections, - }; - } - ); + removeCounter: (counterId) => + mutateCollectionField(collection.id, (current) => { + const index = current.counterIds.indexOf(counterId); + if (index < 0) { + return current; + } + + const nextCounters = [...current.counterIds]; + nextCounters.splice(index, 1); + + return { + ...current, + counterIds: nextCounters, + }; }), + rename: (name) => + mutateCollectionField(collection.id, (current) => ({ + ...current, + name, + })), }) ), - [state.collections] + [state.collections, mutateCollectionField] ); // Create a memoized function to create new user collections diff --git a/src/client/interfaces/index.ts b/src/client/interfaces/index.ts index b6ee4f70..1017beae 100644 --- a/src/client/interfaces/index.ts +++ b/src/client/interfaces/index.ts @@ -213,6 +213,18 @@ export interface UserCounterCollection extends CounterCollection { * Deletes this collection, resolving after the change has taken place. */ delete: () => Promise; + + /** + * Adds a counter to this collection, resolving after the change has + * taken place. + */ + addCounter: (counterId: string) => Promise; + + /** + * Removes a counter from this collection, resolving after the change has + * taken place. + */ + removeCounter: (counterId: string) => Promise; } /** diff --git a/src/client/ui/modules/explore/ExplorePage.tsx b/src/client/ui/modules/explore/ExplorePage.tsx index 930c476d..9738d172 100644 --- a/src/client/ui/modules/explore/ExplorePage.tsx +++ b/src/client/ui/modules/explore/ExplorePage.tsx @@ -3,8 +3,8 @@ import { Route, Switch } from "react-router-dom"; import { PageComponentProps } from "@jyosuushi/ui/types"; -import useExploreRoutes from "./hooks/useExploreRoutes"; import LandingView from "./pages/landing/LandingView"; +import useExploreRoutes from "./useExploreRoutes"; function ExplorePage({ createUserCollection, diff --git a/src/client/ui/modules/explore/hooks/useAddCounterToCollection.ts b/src/client/ui/modules/explore/hooks/useAddCounterToCollection.ts deleted file mode 100644 index 8318b88f..00000000 --- a/src/client/ui/modules/explore/hooks/useAddCounterToCollection.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Reference } from "@apollo/client"; -import gql from "graphql-tag"; -import { useCallback, useState } from "react"; - -import { - AddCounterToCollectionResult, - useAddCounterToCollectionMutation, -} from "@jyosuushi/graphql/types.generated"; - -import { RedirectLocation } from "@jyosuushi/ui/modules/explore/pages/counter/types"; - -type AddToCollectionFn = ( - counterId: string, - collectionId: string -) => Promise; - -interface HookResults { - /** - * The callback that can be invoked to add the specified counter to the - * given collection. - */ - callback: AddToCollectionFn; - - /** - * The specified location that the modal should redirect to, if one or more - * invocations of the callback triggered an error that should be handled with - * redirection. - */ - redirectRequest: RedirectLocation | null; -} - -const COLLECTION_COUNTERS_FRAGMENT = gql` - fragment CollectionCounters on UserCounterCollection { - counterIds - dateLastUpdated - } -`; - -interface CollectionCountersFragment { - counterIds: readonly string[]; - dateLastUpdated: Date; -} - -function useCounterAddToCollection(): HookResults { - // Define hook state - const [ - redirectRequest, - setRedirectRequest, - ] = useState(null); - - // Connect with GraphQL - const [addCounterToCollectionMutation] = useAddCounterToCollectionMutation({ - update: (cache, { data }): void => { - if ( - !data || - data.addCounterToCollection.result !== - AddCounterToCollectionResult.Success - ) { - return; - } - - cache.modify({ - fields: { - userCounterCollections: ( - currentRefs: readonly Reference[] = [] - ): readonly Reference[] => { - const cacheId = `UserCounterCollection:${data.addCounterToCollection.collectionId}`; - const collectionFrag = cache.readFragment( - { - fragment: COLLECTION_COUNTERS_FRAGMENT, - id: cacheId, - } - ); - if (!collectionFrag) { - return currentRefs; - } - - const updatedCollection: CollectionCountersFragment = { - counterIds: [ - ...collectionFrag.counterIds, - data.addCounterToCollection.counterId, - ], - dateLastUpdated: new Date(), - }; - - cache.writeFragment({ - data: updatedCollection, - fragment: COLLECTION_COUNTERS_FRAGMENT, - id: cacheId, - }); - - return currentRefs; - }, - }, - }); - }, - }); - - // Create a wrapper function to act as the callback - const callback = useCallback( - async (counterId: string, collectionId: string): Promise => { - const result = await addCounterToCollectionMutation({ - variables: { - collectionId, - counterId, - }, - }); - - if (!result.data) { - return; - } - - switch (result.data.addCounterToCollection.result) { - case AddCounterToCollectionResult.Success: - case AddCounterToCollectionResult.ErrorCollectionDoesNotExist: - case AddCounterToCollectionResult.ErrorRateLimited: - case AddCounterToCollectionResult.ErrorAlreadyInCollection: - case AddCounterToCollectionResult.ErrorCouldNotAdd: { - return; - } - case AddCounterToCollectionResult.ErrorCounterDoesNotExist: { - setRedirectRequest("explore-landing-page"); - return; - } - case AddCounterToCollectionResult.ErrorNotAuthenticated: { - setRedirectRequest("profile"); - return; - } - } - }, - [addCounterToCollectionMutation] - ); - - // Return the public API - return { - callback, - redirectRequest, - }; -} - -export default useCounterAddToCollection; diff --git a/src/client/ui/modules/explore/hooks/useRemoveCounterFromCollection.ts b/src/client/ui/modules/explore/hooks/useRemoveCounterFromCollection.ts deleted file mode 100644 index e6722e5d..00000000 --- a/src/client/ui/modules/explore/hooks/useRemoveCounterFromCollection.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Reference } from "@apollo/client"; -import gql from "graphql-tag"; -import { useCallback, useState } from "react"; - -import { - RemoveCounterFromCollectionResult, - useRemoveCounterFromCollectionMutation, -} from "@jyosuushi/graphql/types.generated"; - -import { RedirectLocation } from "@jyosuushi/ui/modules/explore/pages/counter/types"; - -type RemoveFromCollectionFn = ( - counterId: string, - collectionId: string -) => Promise; - -interface HookResults { - /** - * The callback that can be invoked to remove the specified counter from the - * given collection. - */ - callback: RemoveFromCollectionFn; - - /** - * The specified location that the modal should redirect to, if one or more - * invocations of the callback triggered an error that should be handled with - * redirection. - */ - redirectRequest: RedirectLocation | null; -} - -const COLLECTION_COUNTERS_FRAGMENT = gql` - fragment CollectionCounters on UserCounterCollection { - counterIds - dateLastUpdated - } -`; - -interface CollectionCountersFragment { - counterIds: readonly string[]; - dateLastUpdated: Date; -} - -function useRemoveCounterFromCollection(): HookResults { - // Define hook state - const [ - redirectRequest, - setRedirectRequest, - ] = useState(null); - - // Connect with GraphQL - const [ - removeCounterFromCollectionMutation, - ] = useRemoveCounterFromCollectionMutation({ - update: (cache, { data }): void => { - if ( - !data || - data.removeCounterFromCollection.result !== - RemoveCounterFromCollectionResult.Success - ) { - return; - } - - cache.modify({ - fields: { - userCounterCollections: ( - currentRefs: readonly Reference[] = [] - ): readonly Reference[] => { - const cacheId = `UserCounterCollection:${data.removeCounterFromCollection.collectionId}`; - const collectionFrag = cache.readFragment( - { - fragment: COLLECTION_COUNTERS_FRAGMENT, - id: cacheId, - } - ); - if (!collectionFrag) { - return currentRefs; - } - - const updatedCollection: CollectionCountersFragment = { - counterIds: collectionFrag.counterIds.filter( - (collectionCounterId): boolean => - collectionCounterId !== - data.removeCounterFromCollection.counterId - ), - dateLastUpdated: new Date(), - }; - - cache.writeFragment({ - data: updatedCollection, - fragment: COLLECTION_COUNTERS_FRAGMENT, - id: cacheId, - }); - - return currentRefs; - }, - }, - }); - }, - }); - - // Create a wrapper function to act as the callback - const callback = useCallback( - async (counterId: string, collectionId: string): Promise => { - const result = await removeCounterFromCollectionMutation({ - variables: { - collectionId, - counterId, - }, - }); - - if (!result.data) { - return; - } - - switch (result.data.removeCounterFromCollection.result) { - case RemoveCounterFromCollectionResult.Success: - case RemoveCounterFromCollectionResult.ErrorCollectionDoesNotExist: - case RemoveCounterFromCollectionResult.ErrorRateLimited: - case RemoveCounterFromCollectionResult.ErrorNotInCollection: - case RemoveCounterFromCollectionResult.ErrorCouldNotRemove: { - return; - } - case RemoveCounterFromCollectionResult.ErrorCounterDoesNotExist: { - setRedirectRequest("explore-landing-page"); - return; - } - case RemoveCounterFromCollectionResult.ErrorNotAuthenticated: { - setRedirectRequest("profile"); - return; - } - } - }, - [removeCounterFromCollectionMutation] - ); - - // Return the public API - return { - callback, - redirectRequest, - }; -} - -export default useRemoveCounterFromCollection; diff --git a/src/client/ui/modules/explore/pages/collection/UserCollectionView.tsx b/src/client/ui/modules/explore/pages/collection/UserCollectionView.tsx index e4faea87..3e966e23 100644 --- a/src/client/ui/modules/explore/pages/collection/UserCollectionView.tsx +++ b/src/client/ui/modules/explore/pages/collection/UserCollectionView.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from "react"; import { defineMessages } from "react-intl"; -import { Redirect, useHistory } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import { UserCounterCollection } from "@jyosuushi/interfaces"; @@ -9,8 +9,6 @@ import ActionBar, { } from "@jyosuushi/ui/modules/explore/components/action-bar/ActionBar"; import PencilIcon from "@jyosuushi/ui/modules/explore/pencil.svg"; -import useAddCounterToCollection from "@jyosuushi/ui/modules/explore/hooks/useAddCounterToCollection"; -import useRemoveCounterFromCollection from "@jyosuushi/ui/modules/explore/hooks/useRemoveCounterFromCollection"; import { EXPLORE_PAGE_PATH } from "@jyosuushi/ui/modules/explore/pathing"; import BaseCounterCollectionView from "./BaseCounterCollectionView"; @@ -53,16 +51,6 @@ function UserCollectionView({ const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - // Connect with the backend - const { - callback: addCounterToCollection, - redirectRequest: addCounterRedirectRequest, - } = useAddCounterToCollection(); - const { - callback: removeCounterFromCollection, - redirectRequest: removeCounterRedirectRequest, - } = useRemoveCounterFromCollection(); - // Prepare the action bar const actionBarItems = useMemo( (): readonly ActionBarItemDefinition[] => [ @@ -109,36 +97,15 @@ function UserCollectionView({ }, [collection, history]); const handleAddCounter = useCallback( - (counterId: string) => addCounterToCollection(counterId, collection.id), - [addCounterToCollection, collection.id] + (counterId: string) => collection.addCounter(counterId), + [collection] ); const handleRemoveCounter = useCallback( - (counterId: string) => - removeCounterFromCollection(counterId, collection.id), - [removeCounterFromCollection, collection.id] + (counterId: string) => collection.removeCounter(counterId), + [collection] ); - // Handle redirecting to a different location, if one of our API requests - // indicates we should redirect - const redirectRequest = - addCounterRedirectRequest || removeCounterRedirectRequest; - if (redirectRequest) { - let to: string; - switch (redirectRequest) { - case "explore-landing-page": { - to = "/explore"; - break; - } - case "profile": { - to = "/profile"; - break; - } - } - - return ; - } - // Render the component return ( diff --git a/src/client/ui/modules/explore/pages/counter/ExploreCounterPage.tsx b/src/client/ui/modules/explore/pages/counter/ExploreCounterPage.tsx index 8fc40cf6..6302a45b 100644 --- a/src/client/ui/modules/explore/pages/counter/ExploreCounterPage.tsx +++ b/src/client/ui/modules/explore/pages/counter/ExploreCounterPage.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from "react"; import { defineMessages } from "react-intl"; -import { Redirect, useLocation } from "react-router-dom"; +import { useLocation } from "react-router-dom"; import { UserCounterCollection } from "@jyosuushi/interfaces"; import useLocale from "@jyosuushi/i18n/useLocale"; @@ -28,7 +28,6 @@ import FootnotesSection from "./FootnotesSection"; import InfoSection, { hasInfoSectionContents } from "./InfoSection"; import ItemsSection, { hasItemsSectionContents } from "./ItemsSection"; import SectionContainer, { SectionActionDefinition } from "./SectionContainer"; -import { RedirectLocation } from "./types"; import PencilIcon from "@jyosuushi/ui/modules/explore/pencil.svg"; @@ -80,12 +79,6 @@ function ExploreCounterPage({ const locale = useLocale(); const location = useLocation(); - // Define component state - const [ - redirectRequest, - setRedirectRequest, - ] = useState(null); - // Handle editing collection membership const [isEditMembershipDialogOpen, setIsEditMembershipDialogOpen] = useState< boolean @@ -102,13 +95,9 @@ function ExploreCounterPage({ [] ); - const handleRequestEditMembershipDialogClose = useCallback( - (redirectTo: RedirectLocation | null): void => { - setIsEditMembershipDialogOpen(false); - setRedirectRequest(redirectTo); - }, - [] - ); + const handleRequestEditMembershipDialogClose = useCallback((): void => { + setIsEditMembershipDialogOpen(false); + }, []); // Determine the links that should apear in the breadcrumb bar const breadcrumbLinks = useMemo((): readonly BreadcrumbBarLinkDefinition[] => { @@ -138,21 +127,6 @@ function ExploreCounterPage({ return links; }, [counter, locale, location.state]); - // If we should be redirecting to another location, do so here. - if (redirectRequest) { - switch (redirectRequest) { - case "explore-landing-page": { - return ; - } - case "profile": { - return ; - } - default: { - return redirectRequest; - } - } - } - // Render the component return (
diff --git a/src/client/ui/modules/explore/pages/counter/collection-membership/edit-membership-dialog/CollectionRow.tsx b/src/client/ui/modules/explore/pages/counter/collection-membership/edit-membership-dialog/CollectionRow.tsx index 47e5320e..6fa68ea3 100644 --- a/src/client/ui/modules/explore/pages/counter/collection-membership/edit-membership-dialog/CollectionRow.tsx +++ b/src/client/ui/modules/explore/pages/counter/collection-membership/edit-membership-dialog/CollectionRow.tsx @@ -23,19 +23,14 @@ interface ComponentProps { /** * A callback which will be invoked if the user has indicated that they * wish to add the current counter to this collection. - * - * In general, this won't be called when */ - onAddToCollection: (collectionId: string) => Promise; + onAddToCollection: (collection: UserCounterCollection) => Promise; /** * A callback which will be invoked if the user has indicated that they * wish to remove the current counter from this collection. - * - * In general, this won't be called when - * {@link ComponentProps.isCounterInCollection} is false. */ - onRemoveFromCollection: (collectionId: string) => Promise; + onRemoveFromCollection: (collection: UserCounterCollection) => Promise; } function CollectionRow({ @@ -51,20 +46,20 @@ function CollectionRow({ const handleAdd = useCallback(async (): Promise => { try { setIsPerforming(true); - await onAddToCollection(collection.id); + await onAddToCollection(collection); } finally { setIsPerforming(false); } - }, [collection.id, onAddToCollection]); + }, [collection, onAddToCollection]); const handleRemove = useCallback(async (): Promise => { try { setIsPerforming(true); - await onRemoveFromCollection(collection.id); + await onRemoveFromCollection(collection); } finally { setIsPerforming(false); } - }, [collection.id, onRemoveFromCollection]); + }, [collection, onRemoveFromCollection]); // Determine the current state to display let checkState: CheckState; diff --git a/src/client/ui/modules/explore/pages/counter/collection-membership/edit-membership-dialog/EditMembershipDialog.tsx b/src/client/ui/modules/explore/pages/counter/collection-membership/edit-membership-dialog/EditMembershipDialog.tsx index 7cfad1a8..8a4ad4e7 100644 --- a/src/client/ui/modules/explore/pages/counter/collection-membership/edit-membership-dialog/EditMembershipDialog.tsx +++ b/src/client/ui/modules/explore/pages/counter/collection-membership/edit-membership-dialog/EditMembershipDialog.tsx @@ -1,14 +1,10 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback } from "react"; import { defineMessages } from "react-intl"; import { UserCounterCollection } from "@jyosuushi/interfaces"; import BaseDialog from "@jyosuushi/ui/components/popups/BaseDialog"; -import useAddCounterToCollection from "@jyosuushi/ui/modules/explore/hooks/useAddCounterToCollection"; -import useRemoveCounterFromCollection from "@jyosuushi/ui/modules/explore/hooks/useRemoveCounterFromCollection"; -import { RedirectLocation } from "@jyosuushi/ui/modules/explore/pages/counter/types"; - import CollectionRow from "./CollectionRow"; import styles from "./EditMembershipDialog.scss"; @@ -34,12 +30,8 @@ interface ComponentProps { /** * A callback that can be invoked to request that the dialog be closed. This * will not be called if {@link ComponentProps.isOpen} is false. - * - * If this modal is closing with the recommendation to redirect elsewhere, - * the requested redirect will be included as the first parameter. If this - * modal is closing with normal behavior, the first parameter will be null. */ - onRequestClose: (redirectLocation: RedirectLocation | null) => void; + onRequestClose: () => void; /** * The array of custom collections that the currently authenticated user has @@ -54,42 +46,17 @@ function EditMembershipDialog({ onRequestClose, userCollections, }: ComponentProps): React.ReactElement { - // Connect with the server - const { - callback: addCounterToCollection, - redirectRequest: addRedirectRequest, - } = useAddCounterToCollection(); - const { - callback: removeCounterFromCollection, - redirectRequest: removeRedirectRequest, - } = useRemoveCounterFromCollection(); - - // Current the normal `onRequestClose` callback - const handleRequestClose = useCallback((): void => onRequestClose(null), [ - onRequestClose, - ]); - - // If one of the callbacks has requested a redirect, bubble that up - const redirectRequest = addRedirectRequest || removeRedirectRequest; - useEffect((): void => { - if (!redirectRequest) { - return; - } - - onRequestClose(redirectRequest); - }, [onRequestClose, redirectRequest]); - // Handle events const handleAddToCollection = useCallback( - (collectionId: string): Promise => - addCounterToCollection(counterId, collectionId), - [addCounterToCollection, counterId] + (collection: UserCounterCollection): Promise => + collection.addCounter(counterId), + [counterId] ); const handleRemoveFromCollection = useCallback( - (collectionId: string): Promise => - removeCounterFromCollection(counterId, collectionId), - [removeCounterFromCollection, counterId] + (collection: UserCounterCollection): Promise => + collection.removeCounter(counterId), + [counterId] ); // Render the component @@ -101,7 +68,7 @@ function EditMembershipDialog({ contentClassName={styles.content} header={INTL_MESSAGES.dialogHeader} isOpen={isOpen} - onRequestClose={handleRequestClose} + onRequestClose={onRequestClose} >
{userCollections.map( diff --git a/src/client/ui/modules/explore/pages/counter/types.ts b/src/client/ui/modules/explore/pages/counter/types.ts deleted file mode 100644 index f60ac027..00000000 --- a/src/client/ui/modules/explore/pages/counter/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type RedirectLocation = "profile" | "explore-landing-page"; diff --git a/src/client/ui/modules/explore/hooks/useExploreRoutes.tsx b/src/client/ui/modules/explore/useExploreRoutes.tsx similarity index 100% rename from src/client/ui/modules/explore/hooks/useExploreRoutes.tsx rename to src/client/ui/modules/explore/useExploreRoutes.tsx