Skip to content

Commit

Permalink
add optimistic update for edit contributions and profile (#1438)
Browse files Browse the repository at this point in the history
* refactor: redirect to error and fetch per guild

* feat: add optimistic update to contributions

* feat: finish update contribution and polish

* feat: add optimistic update to profile

* chore: rename revalidateContribution and fix type error

* feat: memoize profile guard
  • Loading branch information
dominik-stumpf authored Aug 9, 2024
1 parent b950348 commit 71b4ad0
Show file tree
Hide file tree
Showing 12 changed files with 115 additions and 74 deletions.
4 changes: 2 additions & 2 deletions src/app/(marketing)/profile/_components/EditContributions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { AvatarFallback } from "@radix-ui/react-avatar"
import { DialogDescription } from "@radix-ui/react-dialog"
import { useState } from "react"
import useSWRImmutable from "swr/immutable"
import { useContribution } from "../_hooks/useContribution"
import { useContributions } from "../_hooks/useContributions"
import { useCreateContribution } from "../_hooks/useCreateContribution"
import { useDeleteContribution } from "../_hooks/useDeleteContribution"
import { useMemberships } from "../_hooks/useMemberships"
Expand Down Expand Up @@ -88,7 +88,7 @@ const EditContributionCard = ({
}

export const EditContributions = () => {
const contributions = useContribution()
const contributions = useContributions()
const memberships = useMemberships()
const [guildId, setGuildId] = useState("")
const [roleId, setRoleId] = useState("")
Expand Down
4 changes: 2 additions & 2 deletions src/app/(marketing)/profile/_components/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import { EditProfile } from "../_components/EditProfile"
import { OperatedGuildCard } from "../_components/OperatedGuildCard"
import { ProfileOwnerGuard } from "../_components/ProfileOwnerGuard"
import { RecentActivity } from "../_components/RecentActivity"
import { useContribution } from "../_hooks/useContribution"
import { useContributions } from "../_hooks/useContributions"
import { useProfile } from "../_hooks/useProfile"
import { ProfileSkeleton } from "./ProfileSkeleton"

export const Profile = () => {
const { data: profile } = useProfile()
const { data: contributions } = useContribution()
const { data: contributions } = useContributions()

if (!profile || !contributions) return <ProfileSkeleton />

Expand Down
8 changes: 5 additions & 3 deletions src/app/(marketing)/profile/_components/ProfileOwnerGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"use client"

import { useUserPublic } from "@/hooks/useUserPublic"
import { PropsWithChildren } from "react"
import { PropsWithChildren, useMemo } from "react"
import { useProfile } from "../_hooks/useProfile"

export const ProfileOwnerGuard = ({ children }: PropsWithChildren) => {
const { data: profile } = useProfile()
const { id: publicUserId } = useUserPublic()
const isProfileOwner = !!profile?.userId && publicUserId === profile.userId

const isProfileOwner = useMemo(
() => !!profile?.userId && publicUserId === profile.userId,
[publicUserId]
)
if (!isProfileOwner) return
return children
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Schemas } from "@guildxyz/types"
import useSWRImmutable from "swr/immutable"
import { useProfile } from "./useProfile"

export const useContribution = () => {
export const useContributions = () => {
const { data: profileData } = useProfile()
return useSWRImmutable<Schemas["Contribution"][]>(
profileData ? `/v2/profiles/${profileData.username}/contributions` : null
Expand Down
51 changes: 32 additions & 19 deletions src/app/(marketing)/profile/_hooks/useCreateContribution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@ import { useToast } from "@/components/ui/hooks/useToast"
import { Schemas } from "@guildxyz/types"
import { SignedValidation, useSubmitWithSign } from "hooks/useSubmit"
import fetcher from "utils/fetcher"
import { revalidateContribution } from "../_server_actions/revalidateContribution"
import { useContribution } from "./useContribution"
import { revalidateContributions } from "../_server_actions/revalidateContributions"
import { useContributions } from "./useContributions"
import { useProfile } from "./useProfile"

export type EditProfilePayload = Schemas["ContributionUpdate"]

export const useCreateContribution = () => {
const { toast } = useToast()
const { data: profile } = useProfile()
const contribution = useContribution()
const contributions = useContributions()

const update = async (signedValidation: SignedValidation) => {
return fetcher(
Expand All @@ -24,21 +22,36 @@ export const useCreateContribution = () => {
}

const submitWithSign = useSubmitWithSign<Schemas["Contribution"]>(update, {
onSuccess: (response) => {
contribution.mutate(
(prev) => {
// WARNING: should we validate here?
if (!prev) return
prev.push(response)
return prev
onOptimistic: (response, payload) => {
if (!profile?.userId) return
contributions.mutate(
async () => {
if (!contributions.data) return
const contribution = await response
contributions.data[
contributions.data.findLastIndex(({ id }) => id === -1)
] = contribution
return contributions.data.filter(({ id }) => id !== -1)
},
{ revalidate: false }
{
revalidate: false,
rollbackOnError: true,
optimisticData: () => {
// @ts-expect-error: incorrect types coming from lib
const fakeContribution: Schemas["Contribution"] = {
...(payload as Schemas["ContributionUpdate"]),
id: -1,
profileId: profile.userId,
}
if (!contributions.data) return [fakeContribution]
contributions.data.push(fakeContribution)
return contributions.data
},
}
)
revalidateContribution()
toast({
variant: "success",
title: "Successfully created contribution",
})
},
onSuccess: () => {
revalidateContributions()
},
onError: (response) => {
toast({
Expand All @@ -50,7 +63,7 @@ export const useCreateContribution = () => {
})
return {
...submitWithSign,
onSubmit: (payload: EditProfilePayload) =>
onSubmit: (payload: Schemas["ContributionUpdate"]) =>
profile && submitWithSign.onSubmit(payload),
}
}
34 changes: 19 additions & 15 deletions src/app/(marketing)/profile/_hooks/useDeleteContribution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { useToast } from "@/components/ui/hooks/useToast"
import { Schemas } from "@guildxyz/types"
import { SignedValidation, useSubmitWithSign } from "hooks/useSubmit"
import fetcher from "utils/fetcher"
import { revalidateContribution } from "../_server_actions/revalidateContribution"
import { useContribution } from "./useContribution"
import { revalidateContributions } from "../_server_actions/revalidateContributions"
import { useContributions } from "./useContributions"
import { useProfile } from "./useProfile"

export const useDeleteContribution = ({
contributionId,
}: { contributionId: Schemas["Contribution"]["id"] }) => {
const { toast } = useToast()
const { data: profile } = useProfile()
const contribution = useContribution()
const contributions = useContributions()

const update = async (signedValidation: SignedValidation) => {
return fetcher(
Expand All @@ -24,20 +24,24 @@ export const useDeleteContribution = ({
}

const submitWithSign = useSubmitWithSign<object>(update, {
onSuccess: () => {
contribution.mutate(
(prev) => {
if (!prev || !contribution.data) return
// WARNING: should we validate here?
return prev.filter((p) => p.id !== contributionId)
onOptimistic: (response) => {
contributions.mutate(
async () => {
await response
return contributions.data?.filter((p) => p.id !== contributionId)
},
{ revalidate: false }
{
revalidate: false,
rollbackOnError: true,
optimisticData: () => {
if (!contributions.data) return []
return contributions.data.filter((p) => p.id !== contributionId)
},
}
)
revalidateContribution()
toast({
variant: "success",
title: "Successfully deleted contribution",
})
},
onSuccess: () => {
revalidateContributions()
},
onError: (response) => {
toast({
Expand Down
44 changes: 26 additions & 18 deletions src/app/(marketing)/profile/_hooks/useUpdateContribution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { useToast } from "@/components/ui/hooks/useToast"
import { Schemas } from "@guildxyz/types"
import { SignedValidation, useSubmitWithSign } from "hooks/useSubmit"
import fetcher from "utils/fetcher"
import { revalidateContribution } from "../_server_actions/revalidateContribution"
import { useContribution } from "./useContribution"
import { revalidateContributions } from "../_server_actions/revalidateContributions"
import { useContributions } from "./useContributions"
import { useProfile } from "./useProfile"

export const useUpdateContribution = ({
contributionId,
}: { contributionId: Schemas["Contribution"]["id"] }) => {
const { toast } = useToast()
const { data: profile } = useProfile()
const contribution = useContribution()
const contributions = useContributions()

const update = async (signedValidation: SignedValidation) => {
return fetcher(
Expand All @@ -24,24 +24,32 @@ export const useUpdateContribution = ({
}

const submitWithSign = useSubmitWithSign<Schemas["Contribution"]>(update, {
onSuccess: (response) => {
contribution.mutate(
(prev) => {
if (!prev || !contribution.data) return
// WARNING: should we validate here?
return prev.map((p) =>
p.id === (contribution.data as unknown as Schemas["Contribution"]).id
? response
: p
onOptimistic: (response, payload) => {
if (!profile?.userId) return
contributions.mutate(
async () => {
if (!contributions.data) return
const contribution = await response
return contributions.data.map((data) =>
data.id === contributionId ? contribution : data
)
},
{ revalidate: false }
{
revalidate: false,
rollbackOnError: true,
optimisticData: () => {
if (!contributions.data) return []
return contributions.data.map((data) =>
data.id === contributionId
? { ...data, ...(payload as Schemas["Contribution"]) }
: data
)
},
}
)
revalidateContribution()
toast({
variant: "success",
title: "Successfully updated contribution",
})
},
onSuccess: () => {
revalidateContributions()
},
onError: (response) => {
toast({
Expand Down
24 changes: 14 additions & 10 deletions src/app/(marketing)/profile/_hooks/useUpdateProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { Schemas } from "@guildxyz/types"
import { SignedValidation, useSubmitWithSign } from "hooks/useSubmit"
import { useParams, useRouter } from "next/navigation"
import fetcher from "utils/fetcher"
import { revalidateContributions } from "../_server_actions/revalidateContributions"
import { revalidateProfile } from "../_server_actions/revalidateProfile"
import { useProfile } from "./useProfile"

export const useUpdateProfile = () => {
const { toast } = useToast()
const router = useRouter()
const params = useParams<{ username: string }>()
const { mutate } = useProfile()
const { mutate, data: profile } = useProfile()

const updateProfile = async (signedValidation: SignedValidation) => {
return fetcher(`/v2/profiles/${params?.username}`, {
Expand All @@ -20,18 +21,21 @@ export const useUpdateProfile = () => {
}

const submitWithSign = useSubmitWithSign<Schemas["Profile"]>(updateProfile, {
onSuccess: (response) => {
console.log("onSuccess", response)
router.replace(`/profile/${response.username}`)
mutate(() => response, { revalidate: false })
revalidateProfile()
toast({
variant: "success",
title: "Successfully updated profile",
onOptimistic: (response, payload) => {
mutate(() => response, {
revalidate: false,
rollbackOnError: true,
optimisticData: () => payload,
})
},
onSuccess: async (response) => {
await revalidateProfile()
if (profile?.username !== response.username) {
await revalidateContributions()
router.replace(`/profile/${response.username}`)
}
},
onError: (response) => {
console.log("onError", response)
toast({
variant: "error",
title: "Failed to update profile",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

import { revalidateTag } from "next/cache"

export async function revalidateContribution() {
export async function revalidateContributions() {
revalidateTag("contributions")
}
2 changes: 2 additions & 0 deletions src/hooks/useSetKeyPair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const useSetKeyPair = (submitOptions?: UseSubmitOptions) => {
const recaptcha = useAtomValue(recaptchaAtom)

const setSubmitResponse = useSubmit(
// @ts-ignore
async ({
signProps,
}: {
Expand Down Expand Up @@ -175,6 +176,7 @@ const useSetKeyPair = (submitOptions?: UseSubmitOptions) => {
return { keyPair: generatedKeys, user: userProfile }
},

// @ts-ignore
{
...submitOptions,
onError: (error) => {
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useSubmit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type MessageParams = {
export type UseSubmitOptions<ResponseType = void> = {
onSuccess?: (response: ResponseType) => void
onError?: (error: any) => void
onOptimistic?: (response: Promise<ResponseType>, payload: any) => void

// Use catefully! If this is set to true, a .onSubmit() call can reject!
allowThrow?: boolean
Expand Down
13 changes: 10 additions & 3 deletions src/hooks/useSubmit/useSubmit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ type FetcherFunction<ResponseType> = ({

const useSubmit = <DataType, ResponseType>(
fetch: (data?: DataType) => Promise<ResponseType>,
{ onSuccess, onError, allowThrow }: UseSubmitOptions<ResponseType> = {}
{
onSuccess,
onError,
allowThrow,
onOptimistic,
}: UseSubmitOptions<ResponseType> = {}
) => {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<any>(undefined)
Expand All @@ -31,7 +36,9 @@ const useSubmit = <DataType, ResponseType>(
(data?: DataType): Promise<ResponseType> => {
setIsLoading(true)
setError(undefined)
return fetch(data)
const response = fetch(data)
onOptimistic?.(response, data)
return response
.then((d) => {
onSuccess?.(d)
setResponse(d)
Expand All @@ -47,7 +54,7 @@ const useSubmit = <DataType, ResponseType>(
})
.finally(() => setIsLoading(false))
},
[allowThrow, fetch, onError, onSuccess]
[allowThrow, fetch, onError, onSuccess, onOptimistic]
)

return {
Expand Down

0 comments on commit 71b4ad0

Please sign in to comment.