From 785456c8f47a4415e0922d7ca6426b94bc0154da Mon Sep 17 00:00:00 2001 From: Jeff Sieu Date: Mon, 7 Nov 2022 01:42:55 +0800 Subject: [PATCH] [questions][refactor] refactor useVote hooks --- .../components/questions/card/AnswerCard.tsx | 2 +- .../card/question/BaseQuestionCard.tsx | 2 +- .../comments/AnswerCommentListItem.tsx | 2 +- .../comments/QuestionCommentListItem.tsx | 2 +- apps/portal/src/utils/questions/useVote.ts | 567 ------------------ .../questions/vote/useAnswerCommentVote.ts | 68 +++ .../src/utils/questions/vote/useAnswerVote.ts | 98 +++ .../questions/vote/useQuestionCommentVote.ts | 69 +++ .../utils/questions/vote/useQuestionVote.ts | 98 +++ .../src/utils/questions/vote/useVote.ts | 254 ++++++++ 10 files changed, 591 insertions(+), 571 deletions(-) delete mode 100644 apps/portal/src/utils/questions/useVote.ts create mode 100644 apps/portal/src/utils/questions/vote/useAnswerCommentVote.ts create mode 100644 apps/portal/src/utils/questions/vote/useAnswerVote.ts create mode 100644 apps/portal/src/utils/questions/vote/useQuestionCommentVote.ts create mode 100644 apps/portal/src/utils/questions/vote/useQuestionVote.ts create mode 100644 apps/portal/src/utils/questions/vote/useVote.ts diff --git a/apps/portal/src/components/questions/card/AnswerCard.tsx b/apps/portal/src/components/questions/card/AnswerCard.tsx index 5407e95e..68e94d17 100644 --- a/apps/portal/src/components/questions/card/AnswerCard.tsx +++ b/apps/portal/src/components/questions/card/AnswerCard.tsx @@ -1,7 +1,7 @@ import { format } from 'date-fns'; import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline'; -import { useAnswerVote } from '~/utils/questions/useVote'; +import useAnswerVote from '~/utils/questions/vote/useAnswerVote'; import type { VotingButtonsProps } from '../VotingButtons'; import VotingButtons from '../VotingButtons'; diff --git a/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx b/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx index a917bc87..77b7f5c9 100644 --- a/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx +++ b/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx @@ -10,7 +10,7 @@ import type { QuestionsQuestionType } from '@prisma/client'; import { Button } from '@tih/ui'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; -import { useQuestionVote } from '~/utils/questions/useVote'; +import { useQuestionVote } from '~/utils/questions/vote/useQuestionVote'; import AddToListDropdown from '../../AddToListDropdown'; import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm'; diff --git a/apps/portal/src/components/questions/comments/AnswerCommentListItem.tsx b/apps/portal/src/components/questions/comments/AnswerCommentListItem.tsx index 9fa59b71..0a4f76b6 100644 --- a/apps/portal/src/components/questions/comments/AnswerCommentListItem.tsx +++ b/apps/portal/src/components/questions/comments/AnswerCommentListItem.tsx @@ -1,4 +1,4 @@ -import { useAnswerCommentVote } from '~/utils/questions/useVote'; +import useAnswerCommentVote from '~/utils/questions/vote/useAnswerCommentVote'; import type { CommentListItemProps } from './CommentListItem'; import CommentListItem from './CommentListItem'; diff --git a/apps/portal/src/components/questions/comments/QuestionCommentListItem.tsx b/apps/portal/src/components/questions/comments/QuestionCommentListItem.tsx index be8ecf70..0a8cabd6 100644 --- a/apps/portal/src/components/questions/comments/QuestionCommentListItem.tsx +++ b/apps/portal/src/components/questions/comments/QuestionCommentListItem.tsx @@ -1,4 +1,4 @@ -import { useQuestionCommentVote } from '~/utils/questions/useVote'; +import useQuestionCommentVote from '~/utils/questions/vote/useQuestionCommentVote'; import type { CommentListItemProps } from './CommentListItem'; import CommentListItem from './CommentListItem'; diff --git a/apps/portal/src/utils/questions/useVote.ts b/apps/portal/src/utils/questions/useVote.ts deleted file mode 100644 index 0f3f3a95..00000000 --- a/apps/portal/src/utils/questions/useVote.ts +++ /dev/null @@ -1,567 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useCallback } from 'react'; -import type { InfiniteData } from 'react-query'; -import { Vote } from '@prisma/client'; - -import { trpc } from '../trpc'; - -import type { - Answer, - AnswerComment, - Question, - QuestionComment, -} from '~/types/questions'; - -type UseVoteOptions = { - setDownVote: () => void; - setNoVote: () => void; - setUpVote: () => void; -}; - -type BackendVote = { - id: string; - vote: Vote; -}; - -const createVoteCallbacks = ( - vote: BackendVote | null, - opts: UseVoteOptions, -) => { - const { setDownVote, setNoVote, setUpVote } = opts; - - const handleUpvote = () => { - // Either upvote or remove upvote - if (vote && vote.vote === 'UPVOTE') { - setNoVote(); - } else { - setUpVote(); - } - }; - - const handleDownvote = () => { - // Either downvote or remove downvote - if (vote && vote.vote === 'DOWNVOTE') { - setNoVote(); - } else { - setDownVote(); - } - }; - - return { handleDownvote, handleUpvote }; -}; - -type MutationKey = Parameters[0]; -type QueryKey = Parameters[0][0]; - -const getVoteValue = (vote: Vote | null) => { - if (vote === Vote.UPVOTE) { - return 1; - } - if (vote === Vote.DOWNVOTE) { - return -1; - } - return 0; -}; - -export const useQuestionVote = (id: string) => { - const utils = trpc.useContext(); - - return useVote(id, { - idKey: 'questionId', - invalidateKeys: [ - // 'questions.questions.getQuestionById', - // 'questions.questions.getQuestionsByFilterAndContent', - ], - onMutate: async (voteValueChange) => { - // Update question list - const questionQueries = utils.queryClient.getQueriesData([ - 'questions.questions.getQuestionsByFilterAndContent', - ]); - - const revertFunctions: Array<() => void> = []; - - if (questionQueries !== undefined) { - for (const [key, query] of questionQueries) { - if (query === undefined) { - continue; - } - - const { pages, ...restQuery } = query as InfiniteData<{ - data: Array; - }>; - - const newQuery = { - pages: pages.map(({ data, ...restPage }) => ({ - data: data.map((question) => { - if (question.id === id) { - const { numVotes, ...restQuestion } = question; - return { - numVotes: numVotes + voteValueChange, - ...restQuestion, - }; - } - return question; - }), - ...restPage, - })), - ...restQuery, - }; - - utils.queryClient.setQueryData(key, newQuery); - - revertFunctions.push(() => { - utils.queryClient.setQueryData(key, query); - }); - } - } - - const prevQuestion = utils.queryClient.getQueryData([ - 'questions.questions.getQuestionById', - { - id, - }, - ]) as Question | undefined; - - if (prevQuestion !== undefined) { - const newQuestion = { - ...prevQuestion, - numVotes: prevQuestion.numVotes + voteValueChange, - }; - - utils.queryClient.setQueryData( - ['questions.questions.getQuestionById', { id }], - newQuestion, - ); - - revertFunctions.push(() => { - utils.queryClient.setQueryData( - ['questions.questions.getQuestionById', { id }], - prevQuestion, - ); - }); - } - - return () => { - for (const revertFunction of revertFunctions) { - revertFunction(); - } - }; - }, - query: 'questions.questions.user.getVote', - setDownVoteKey: 'questions.questions.user.setDownVote', - setNoVoteKey: 'questions.questions.user.setNoVote', - setUpVoteKey: 'questions.questions.user.setUpVote', - }); -}; - -export const useAnswerVote = (id: string) => { - const utils = trpc.useContext(); - - return useVote(id, { - idKey: 'answerId', - invalidateKeys: [ - // 'questions.answers.getAnswerById', - // 'questions.answers.getAnswers', - ], - onMutate: async (voteValueChange) => { - // Update question answer list - const answerQueries = utils.queryClient.getQueriesData([ - 'questions.answers.getAnswers', - ]); - - const revertFunctions: Array<() => void> = []; - - if (answerQueries !== undefined) { - for (const [key, query] of answerQueries) { - if (query === undefined) { - continue; - } - - const { pages, ...restQuery } = query as InfiniteData<{ - data: Array; - }>; - - const newQuery = { - pages: pages.map(({ data, ...restPage }) => ({ - data: data.map((answer) => { - if (answer.id === id) { - const { numVotes, ...restAnswer } = answer; - return { - numVotes: numVotes + voteValueChange, - ...restAnswer, - }; - } - return answer; - }), - ...restPage, - })), - ...restQuery, - }; - - utils.queryClient.setQueryData(key, newQuery); - - revertFunctions.push(() => { - utils.queryClient.setQueryData(key, query); - }); - } - } - - const prevAnswer = utils.queryClient.getQueryData([ - 'questions.answers.getAnswerById', - { - answerId: id, - }, - ]) as Answer | undefined; - - if (prevAnswer !== undefined) { - const newAnswer = { - ...prevAnswer, - numVotes: prevAnswer.numVotes + voteValueChange, - }; - - utils.queryClient.setQueryData( - ['questions.answers.getAnswerById', { answerId: id }], - newAnswer, - ); - - revertFunctions.push(() => { - utils.queryClient.setQueryData( - ['questions.answers.getAnswerById', { answerId: id }], - prevAnswer, - ); - }); - } - - return () => { - for (const revertFunction of revertFunctions) { - revertFunction(); - } - }; - }, - query: 'questions.answers.user.getVote', - setDownVoteKey: 'questions.answers.user.setDownVote', - setNoVoteKey: 'questions.answers.user.setNoVote', - setUpVoteKey: 'questions.answers.user.setUpVote', - }); -}; - -export const useQuestionCommentVote = (id: string) => { - const utils = trpc.useContext(); - - return useVote(id, { - idKey: 'questionCommentId', - invalidateKeys: [], - onMutate: async (voteValueChange) => { - // Update question comment list - const questionCommentQueries = utils.queryClient.getQueriesData([ - 'questions.questions.comments.getQuestionComments', - ]); - - const revertFunctions: Array<() => void> = []; - - if (questionCommentQueries !== undefined) { - for (const [key, query] of questionCommentQueries) { - if (query === undefined) { - continue; - } - - const { pages, ...restQuery } = query as InfiniteData<{ - data: Array; - }>; - - const newQuery = { - pages: pages.map(({ data, ...restPage }) => ({ - data: data.map((questionComment) => { - if (questionComment.id === id) { - const { numVotes, ...restQuestionComment } = questionComment; - return { - numVotes: numVotes + voteValueChange, - ...restQuestionComment, - }; - } - return questionComment; - }), - ...restPage, - })), - ...restQuery, - }; - - utils.queryClient.setQueryData(key, newQuery); - - revertFunctions.push(() => { - utils.queryClient.setQueryData(key, query); - }); - } - } - - return () => { - for (const revertFunction of revertFunctions) { - revertFunction(); - } - }; - }, - query: 'questions.questions.comments.user.getVote', - setDownVoteKey: 'questions.questions.comments.user.setDownVote', - setNoVoteKey: 'questions.questions.comments.user.setNoVote', - setUpVoteKey: 'questions.questions.comments.user.setUpVote', - }); -}; - -export const useAnswerCommentVote = (id: string) => { - const utils = trpc.useContext(); - - return useVote(id, { - idKey: 'answerCommentId', - invalidateKeys: [], - onMutate: async (voteValueChange) => { - // Update answer comment list - const answerCommentQueries = utils.queryClient.getQueriesData([ - 'questions.answers.comments.getAnswerComments', - ]); - - const revertFunctions: Array<() => void> = []; - - if (answerCommentQueries !== undefined) { - for (const [key, query] of answerCommentQueries) { - if (query === undefined) { - continue; - } - - const { pages, ...restQuery } = query as InfiniteData<{ - data: Array; - }>; - - const newQuery = { - pages: pages.map(({ data, ...restPage }) => ({ - data: data.map((answerComment) => { - if (answerComment.id === id) { - const { numVotes, ...restAnswerComment } = answerComment; - return { - numVotes: numVotes + voteValueChange, - ...restAnswerComment, - }; - } - return answerComment; - }), - ...restPage, - })), - ...restQuery, - }; - - utils.queryClient.setQueryData(key, newQuery); - - revertFunctions.push(() => { - utils.queryClient.setQueryData(key, query); - }); - } - } - return () => { - for (const revertFunction of revertFunctions) { - revertFunction(); - } - }; - }, - query: 'questions.answers.comments.user.getVote', - setDownVoteKey: 'questions.answers.comments.user.setDownVote', - setNoVoteKey: 'questions.answers.comments.user.setNoVote', - setUpVoteKey: 'questions.answers.comments.user.setUpVote', - }); -}; - -type RevertFunction = () => void; - -type InvalidateFunction = (voteValueChange: number) => Promise; - -type VoteProps = { - idKey: string; - invalidateKeys: Array; - onMutate?: InvalidateFunction; - query: VoteQueryKey; - setDownVoteKey: MutationKey; - setNoVoteKey: MutationKey; - setUpVoteKey: MutationKey; -}; - -type UseVoteMutationContext = { - currentData: any; - previousData: any; - revert: RevertFunction | undefined; -}; - -export const useVote = ( - id: string, - opts: VoteProps, -) => { - const { - idKey, - invalidateKeys, - onMutate, - query, - setDownVoteKey, - setNoVoteKey, - setUpVoteKey, - } = opts; - const utils = trpc.useContext(); - - const onVoteUpdateSettled = useCallback(() => { - // TODO: Optimise query invalidation - // utils.invalidateQueries([query, { [idKey]: id } as any]); - for (const invalidateKey of invalidateKeys) { - utils.invalidateQueries(invalidateKey); - // If (invalidateFunction === null) { - // utils.invalidateQueries([invalidateKey as QueryKey]); - // } else { - // invalidateFunction(utils, previousVote, currentVote); - // } - } - }, [utils, invalidateKeys]); - - const { data } = trpc.useQuery([ - query, - { - [idKey]: id, - }, - ] as any); - - const backendVote = data as BackendVote; - - const { mutate: setUpVote } = trpc.useMutation( - setUpVoteKey, - { - onError: (_error, _variables, context) => { - if (context !== undefined) { - utils.setQueryData([query], context.previousData); - context.revert?.(); - } - }, - onMutate: async (vote) => { - await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); - const previousData = utils.queryClient.getQueryData( - [query, { [idKey]: id } as any], - ); - - const currentData = { - ...(vote as any), - vote: Vote.UPVOTE, - } as BackendVote; - - utils.setQueryData( - [ - query, - { - [idKey]: id, - } as any, - ], - currentData as any, - ); - - const voteValueChange = - getVoteValue(currentData?.vote ?? null) - - getVoteValue(previousData?.vote ?? null); - - const revert = await onMutate?.(voteValueChange); - return { currentData, previousData, revert }; - }, - onSettled: onVoteUpdateSettled, - }, - ); - const { mutate: setDownVote } = trpc.useMutation( - setDownVoteKey, - { - onError: (_error, _variables, context) => { - if (context !== undefined) { - utils.setQueryData([query], context.previousData); - context.revert?.(); - } - }, - onMutate: async (vote) => { - await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); - const previousData = utils.queryClient.getQueryData( - [query, { [idKey]: id } as any], - ); - - const currentData = { - ...vote, - vote: Vote.DOWNVOTE, - } as BackendVote; - - utils.setQueryData( - [ - query, - { - [idKey]: id, - } as any, - ], - currentData as any, - ); - - const voteValueChange = - getVoteValue(currentData?.vote ?? null) - - getVoteValue(previousData?.vote ?? null); - - const revert = await onMutate?.(voteValueChange); - return { currentData, previousData, revert }; - }, - onSettled: onVoteUpdateSettled, - }, - ); - - const { mutate: setNoVote } = trpc.useMutation( - setNoVoteKey, - { - onError: (_error, _variables, context) => { - if (context !== undefined) { - utils.setQueryData([query], context.previousData); - context.revert?.(); - } - }, - onMutate: async () => { - await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); - const previousData = utils.queryClient.getQueryData( - [query, { [idKey]: id } as any], - ); - const currentData: BackendVote | null = null; - - utils.queryClient.setQueryData( - [ - query, - { - [idKey]: id, - } as any, - ], - currentData, - ); - - const voteValueChange = - getVoteValue(null) - getVoteValue(previousData?.vote ?? null); - - const revert = await onMutate?.(voteValueChange); - return { currentData, previousData, revert }; - }, - onSettled: onVoteUpdateSettled, - }, - ); - - const { handleDownvote, handleUpvote } = createVoteCallbacks( - backendVote ?? null, - { - setDownVote: () => { - setDownVote({ - [idKey]: id, - }); - }, - setNoVote: () => { - setNoVote({ - [idKey]: id, - }); - }, - setUpVote: () => { - setUpVote({ - [idKey]: id, - }); - }, - }, - ); - - return { handleDownvote, handleUpvote, vote: backendVote ?? null }; -}; diff --git a/apps/portal/src/utils/questions/vote/useAnswerCommentVote.ts b/apps/portal/src/utils/questions/vote/useAnswerCommentVote.ts new file mode 100644 index 00000000..2a7849d4 --- /dev/null +++ b/apps/portal/src/utils/questions/vote/useAnswerCommentVote.ts @@ -0,0 +1,68 @@ +import type { InfiniteData } from 'react-query'; + +import { trpc } from '~/utils/trpc'; + +import useVote from './useVote'; + +import type { AnswerComment } from '~/types/questions'; + +export default function useAnswerCommentVote(id: string) { + const utils = trpc.useContext(); + + return useVote(id, { + idKey: 'answerCommentId', + invalidateKeys: [], + onMutate: async (voteValueChange) => { + // Update answer comment list + const answerCommentQueries = utils.queryClient.getQueriesData([ + 'questions.answers.comments.getAnswerComments', + ]); + + const revertFunctions: Array<() => void> = []; + + if (answerCommentQueries !== undefined) { + for (const [key, query] of answerCommentQueries) { + if (query === undefined) { + continue; + } + + const { pages, ...restQuery } = query as InfiniteData<{ + data: Array; + }>; + + const newQuery = { + pages: pages.map(({ data, ...restPage }) => ({ + data: data.map((answerComment) => { + if (answerComment.id === id) { + const { numVotes, ...restAnswerComment } = answerComment; + return { + numVotes: numVotes + voteValueChange, + ...restAnswerComment, + }; + } + return answerComment; + }), + ...restPage, + })), + ...restQuery, + }; + + utils.queryClient.setQueryData(key, newQuery); + + revertFunctions.push(() => { + utils.queryClient.setQueryData(key, query); + }); + } + } + return () => { + for (const revertFunction of revertFunctions) { + revertFunction(); + } + }; + }, + query: 'questions.answers.comments.user.getVote', + setDownVoteKey: 'questions.answers.comments.user.setDownVote', + setNoVoteKey: 'questions.answers.comments.user.setNoVote', + setUpVoteKey: 'questions.answers.comments.user.setUpVote', + }); +} diff --git a/apps/portal/src/utils/questions/vote/useAnswerVote.ts b/apps/portal/src/utils/questions/vote/useAnswerVote.ts new file mode 100644 index 00000000..1c9ea578 --- /dev/null +++ b/apps/portal/src/utils/questions/vote/useAnswerVote.ts @@ -0,0 +1,98 @@ +import type { InfiniteData } from 'react-query'; + +import { trpc } from '~/utils/trpc'; + +import useVote from './useVote'; + +import type { Answer } from '~/types/questions'; + +export default function useAnswerVote(id: string) { + const utils = trpc.useContext(); + + return useVote(id, { + idKey: 'answerId', + invalidateKeys: [ + // 'questions.answers.getAnswerById', + // 'questions.answers.getAnswers', + ], + onMutate: async (voteValueChange) => { + // Update question answer list + const answerQueries = utils.queryClient.getQueriesData([ + 'questions.answers.getAnswers', + ]); + + const revertFunctions: Array<() => void> = []; + + if (answerQueries !== undefined) { + for (const [key, query] of answerQueries) { + if (query === undefined) { + continue; + } + + const { pages, ...restQuery } = query as InfiniteData<{ + data: Array; + }>; + + const newQuery = { + pages: pages.map(({ data, ...restPage }) => ({ + data: data.map((answer) => { + if (answer.id === id) { + const { numVotes, ...restAnswer } = answer; + return { + numVotes: numVotes + voteValueChange, + ...restAnswer, + }; + } + return answer; + }), + ...restPage, + })), + ...restQuery, + }; + + utils.queryClient.setQueryData(key, newQuery); + + revertFunctions.push(() => { + utils.queryClient.setQueryData(key, query); + }); + } + } + + const prevAnswer = utils.queryClient.getQueryData([ + 'questions.answers.getAnswerById', + { + answerId: id, + }, + ]) as Answer | undefined; + + if (prevAnswer !== undefined) { + const newAnswer = { + ...prevAnswer, + numVotes: prevAnswer.numVotes + voteValueChange, + }; + + utils.queryClient.setQueryData( + ['questions.answers.getAnswerById', { answerId: id }], + newAnswer, + ); + + revertFunctions.push(() => { + utils.queryClient.setQueryData( + ['questions.answers.getAnswerById', { answerId: id }], + prevAnswer, + ); + }); + } + + return () => { + for (const revertFunction of revertFunctions) { + revertFunction(); + } + }; + }, + query: 'questions.answers.user.getVote', + setDownVoteKey: 'questions.answers.user.setDownVote', + setNoVoteKey: 'questions.answers.user.setNoVote', + setUpVoteKey: 'questions.answers.user.setUpVote', + }); +} diff --git a/apps/portal/src/utils/questions/vote/useQuestionCommentVote.ts b/apps/portal/src/utils/questions/vote/useQuestionCommentVote.ts new file mode 100644 index 00000000..e2369f4e --- /dev/null +++ b/apps/portal/src/utils/questions/vote/useQuestionCommentVote.ts @@ -0,0 +1,69 @@ +import type { InfiniteData } from 'react-query'; + +import { trpc } from '~/utils/trpc'; + +import useVote from './useVote'; + +import type { QuestionComment } from '~/types/questions'; + +export default function useQuestionCommentVote(id: string) { + const utils = trpc.useContext(); + + return useVote(id, { + idKey: 'questionCommentId', + invalidateKeys: [], + onMutate: async (voteValueChange) => { + // Update question comment list + const questionCommentQueries = utils.queryClient.getQueriesData([ + 'questions.questions.comments.getQuestionComments', + ]); + + const revertFunctions: Array<() => void> = []; + + if (questionCommentQueries !== undefined) { + for (const [key, query] of questionCommentQueries) { + if (query === undefined) { + continue; + } + + const { pages, ...restQuery } = query as InfiniteData<{ + data: Array; + }>; + + const newQuery = { + pages: pages.map(({ data, ...restPage }) => ({ + data: data.map((questionComment) => { + if (questionComment.id === id) { + const { numVotes, ...restQuestionComment } = questionComment; + return { + numVotes: numVotes + voteValueChange, + ...restQuestionComment, + }; + } + return questionComment; + }), + ...restPage, + })), + ...restQuery, + }; + + utils.queryClient.setQueryData(key, newQuery); + + revertFunctions.push(() => { + utils.queryClient.setQueryData(key, query); + }); + } + } + + return () => { + for (const revertFunction of revertFunctions) { + revertFunction(); + } + }; + }, + query: 'questions.questions.comments.user.getVote', + setDownVoteKey: 'questions.questions.comments.user.setDownVote', + setNoVoteKey: 'questions.questions.comments.user.setNoVote', + setUpVoteKey: 'questions.questions.comments.user.setUpVote', + }); +} diff --git a/apps/portal/src/utils/questions/vote/useQuestionVote.ts b/apps/portal/src/utils/questions/vote/useQuestionVote.ts new file mode 100644 index 00000000..6e2686c8 --- /dev/null +++ b/apps/portal/src/utils/questions/vote/useQuestionVote.ts @@ -0,0 +1,98 @@ +import type { InfiniteData } from 'react-query'; + +import { trpc } from '~/utils/trpc'; + +import useVote from './useVote'; + +import type { Question } from '~/types/questions'; + +export const useQuestionVote = (id: string) => { + const utils = trpc.useContext(); + + return useVote(id, { + idKey: 'questionId', + invalidateKeys: [ + // 'questions.questions.getQuestionById', + // 'questions.questions.getQuestionsByFilterAndContent', + ], + onMutate: async (voteValueChange) => { + // Update question list + const questionQueries = utils.queryClient.getQueriesData([ + 'questions.questions.getQuestionsByFilterAndContent', + ]); + + const revertFunctions: Array<() => void> = []; + + if (questionQueries !== undefined) { + for (const [key, query] of questionQueries) { + if (query === undefined) { + continue; + } + + const { pages, ...restQuery } = query as InfiniteData<{ + data: Array; + }>; + + const newQuery = { + pages: pages.map(({ data, ...restPage }) => ({ + data: data.map((question) => { + if (question.id === id) { + const { numVotes, ...restQuestion } = question; + return { + numVotes: numVotes + voteValueChange, + ...restQuestion, + }; + } + return question; + }), + ...restPage, + })), + ...restQuery, + }; + + utils.queryClient.setQueryData(key, newQuery); + + revertFunctions.push(() => { + utils.queryClient.setQueryData(key, query); + }); + } + } + + const prevQuestion = utils.queryClient.getQueryData([ + 'questions.questions.getQuestionById', + { + id, + }, + ]) as Question | undefined; + + if (prevQuestion !== undefined) { + const newQuestion = { + ...prevQuestion, + numVotes: prevQuestion.numVotes + voteValueChange, + }; + + utils.queryClient.setQueryData( + ['questions.questions.getQuestionById', { id }], + newQuestion, + ); + + revertFunctions.push(() => { + utils.queryClient.setQueryData( + ['questions.questions.getQuestionById', { id }], + prevQuestion, + ); + }); + } + + return () => { + for (const revertFunction of revertFunctions) { + revertFunction(); + } + }; + }, + query: 'questions.questions.user.getVote', + setDownVoteKey: 'questions.questions.user.setDownVote', + setNoVoteKey: 'questions.questions.user.setNoVote', + setUpVoteKey: 'questions.questions.user.setUpVote', + }); +}; diff --git a/apps/portal/src/utils/questions/vote/useVote.ts b/apps/portal/src/utils/questions/vote/useVote.ts new file mode 100644 index 00000000..7e3355bc --- /dev/null +++ b/apps/portal/src/utils/questions/vote/useVote.ts @@ -0,0 +1,254 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useCallback } from 'react'; +import { Vote } from '@prisma/client'; + +import { trpc } from '../../trpc'; + +type UseVoteOptions = { + setDownVote: () => void; + setNoVote: () => void; + setUpVote: () => void; +}; + +type BackendVote = { + id: string; + vote: Vote; +}; + +const createVoteCallbacks = ( + vote: BackendVote | null, + opts: UseVoteOptions, +) => { + const { setDownVote, setNoVote, setUpVote } = opts; + + const handleUpvote = () => { + // Either upvote or remove upvote + if (vote && vote.vote === 'UPVOTE') { + setNoVote(); + } else { + setUpVote(); + } + }; + + const handleDownvote = () => { + // Either downvote or remove downvote + if (vote && vote.vote === 'DOWNVOTE') { + setNoVote(); + } else { + setDownVote(); + } + }; + + return { handleDownvote, handleUpvote }; +}; + +type MutationKey = Parameters[0]; +type QueryKey = Parameters[0][0]; + +const getVoteValue = (vote: Vote | null) => { + if (vote === Vote.UPVOTE) { + return 1; + } + if (vote === Vote.DOWNVOTE) { + return -1; + } + return 0; +}; + +type RevertFunction = () => void; + +type InvalidateFunction = (voteValueChange: number) => Promise; + +type VoteProps = { + idKey: string; + invalidateKeys: Array; + onMutate?: InvalidateFunction; + query: VoteQueryKey; + setDownVoteKey: MutationKey; + setNoVoteKey: MutationKey; + setUpVoteKey: MutationKey; +}; + +type UseVoteMutationContext = { + currentData: any; + previousData: any; + revert: RevertFunction | undefined; +}; + +export default function useVote( + id: string, + opts: VoteProps, +) { + const { + idKey, + invalidateKeys, + onMutate, + query, + setDownVoteKey, + setNoVoteKey, + setUpVoteKey, + } = opts; + const utils = trpc.useContext(); + + const onVoteUpdateSettled = useCallback(() => { + // TODO: Optimise query invalidation + // utils.invalidateQueries([query, { [idKey]: id } as any]); + for (const invalidateKey of invalidateKeys) { + utils.invalidateQueries(invalidateKey); + // If (invalidateFunction === null) { + // utils.invalidateQueries([invalidateKey as QueryKey]); + // } else { + // invalidateFunction(utils, previousVote, currentVote); + // } + } + }, [utils, invalidateKeys]); + + const { data } = trpc.useQuery([ + query, + { + [idKey]: id, + }, + ] as any); + + const backendVote = data as BackendVote; + + const { mutate: setUpVote } = trpc.useMutation( + setUpVoteKey, + { + onError: (_error, _variables, context) => { + if (context !== undefined) { + utils.setQueryData([query], context.previousData); + context.revert?.(); + } + }, + onMutate: async (vote) => { + await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); + const previousData = utils.queryClient.getQueryData( + [query, { [idKey]: id } as any], + ); + + const currentData = { + ...(vote as any), + vote: Vote.UPVOTE, + } as BackendVote; + + utils.setQueryData( + [ + query, + { + [idKey]: id, + } as any, + ], + currentData as any, + ); + + const voteValueChange = + getVoteValue(currentData?.vote ?? null) - + getVoteValue(previousData?.vote ?? null); + + const revert = await onMutate?.(voteValueChange); + return { currentData, previousData, revert }; + }, + onSettled: onVoteUpdateSettled, + }, + ); + const { mutate: setDownVote } = trpc.useMutation( + setDownVoteKey, + { + onError: (_error, _variables, context) => { + if (context !== undefined) { + utils.setQueryData([query], context.previousData); + context.revert?.(); + } + }, + onMutate: async (vote) => { + await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); + const previousData = utils.queryClient.getQueryData( + [query, { [idKey]: id } as any], + ); + + const currentData = { + ...vote, + vote: Vote.DOWNVOTE, + } as BackendVote; + + utils.setQueryData( + [ + query, + { + [idKey]: id, + } as any, + ], + currentData as any, + ); + + const voteValueChange = + getVoteValue(currentData?.vote ?? null) - + getVoteValue(previousData?.vote ?? null); + + const revert = await onMutate?.(voteValueChange); + return { currentData, previousData, revert }; + }, + onSettled: onVoteUpdateSettled, + }, + ); + + const { mutate: setNoVote } = trpc.useMutation( + setNoVoteKey, + { + onError: (_error, _variables, context) => { + if (context !== undefined) { + utils.setQueryData([query], context.previousData); + context.revert?.(); + } + }, + onMutate: async () => { + await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); + const previousData = utils.queryClient.getQueryData( + [query, { [idKey]: id } as any], + ); + const currentData: BackendVote | null = null; + + utils.queryClient.setQueryData( + [ + query, + { + [idKey]: id, + } as any, + ], + currentData, + ); + + const voteValueChange = + getVoteValue(null) - getVoteValue(previousData?.vote ?? null); + + const revert = await onMutate?.(voteValueChange); + return { currentData, previousData, revert }; + }, + onSettled: onVoteUpdateSettled, + }, + ); + + const { handleDownvote, handleUpvote } = createVoteCallbacks( + backendVote ?? null, + { + setDownVote: () => { + setDownVote({ + [idKey]: id, + }); + }, + setNoVote: () => { + setNoVote({ + [idKey]: id, + }); + }, + setUpVote: () => { + setUpVote({ + [idKey]: id, + }); + }, + }, + ); + + return { handleDownvote, handleUpvote, vote: backendVote ?? null }; +}