refactor(ui): extract useMutation (#1010)

r0.7
Meng Zhang 2023-12-10 21:47:30 +08:00 committed by GitHub
parent 6305744356
commit 7361e6c987
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 104 additions and 100 deletions

View File

@ -4,7 +4,7 @@ import { graphql } from '@/lib/gql/generates'
import { WorkerKind } from '@/lib/gql/generates/graphql' import { WorkerKind } from '@/lib/gql/generates/graphql'
import { useHealth } from '@/lib/hooks/use-health' import { useHealth } from '@/lib/hooks/use-health'
import { useWorkers } from '@/lib/hooks/use-workers' import { useWorkers } from '@/lib/hooks/use-workers'
import { useAuthenticatedGraphQLQuery, useGraphQLForm } from '@/lib/tabby/gql' import { useAuthenticatedGraphQLQuery, useMutation } from '@/lib/tabby/gql'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { IconRotate } from '@/components/ui/icons' import { IconRotate } from '@/components/ui/icons'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@ -36,12 +36,11 @@ export default function Workers() {
getRegistrationTokenDocument getRegistrationTokenDocument
) )
const { onSubmit: resetRegistrationToken } = useGraphQLForm( const resetRegistrationToken = useMutation(resetRegistrationTokenDocument, {
resetRegistrationTokenDocument, onCompleted() {
{ mutate()
onSuccess: () => mutate()
} }
) })
if (!healthInfo) return if (!healthInfo) return

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { graphql } from '@/lib/gql/generates' import { graphql } from '@/lib/gql/generates'
import { useHealth } from '@/lib/hooks/use-health' import { useHealth } from '@/lib/hooks/use-health'
import { useAuthenticatedGraphQLQuery, useGraphQLForm } from '@/lib/tabby/gql' import { useAuthenticatedGraphQLQuery, useMutation } from '@/lib/tabby/gql'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
CardContent, CardContent,
@ -49,12 +49,9 @@ function MainPanel() {
setOrigin(new URL(window.location.href).origin) setOrigin(new URL(window.location.href).origin)
}, []) }, [])
const { onSubmit: resetUserAuthToken } = useGraphQLForm( const resetUserAuthToken = useMutation(resetUserAuthTokenDocument, {
resetUserAuthTokenDocument, onCompleted: () => mutate()
{ })
onSuccess: () => mutate()
}
)
if (!healthInfo || !data) return if (!healthInfo || !data) return

View File

@ -6,7 +6,7 @@ import { useForm } from 'react-hook-form'
import * as z from 'zod' import * as z from 'zod'
import { graphql } from '@/lib/gql/generates' import { graphql } from '@/lib/gql/generates'
import { useGraphQLForm } from '@/lib/tabby/gql' import { useMutation } from '@/lib/tabby/gql'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Form, Form,
@ -17,7 +17,7 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
const createInvitation = graphql(/* GraphQL */ ` const createInvitationMutation = graphql(/* GraphQL */ `
mutation CreateInvitation($email: String!) { mutation CreateInvitation($email: String!) {
createInvitation(email: $email) createInvitation(email: $email)
} }
@ -37,12 +37,12 @@ export default function CreateInvitationForm({
}) })
const { isSubmitting } = form.formState const { isSubmitting } = form.formState
const { onSubmit } = useGraphQLForm(createInvitation, { const createInvitation = useMutation(createInvitationMutation, {
onSuccess: () => { onCompleted() {
form.reset({ email: '' }) form.reset({ email: '' })
onCreated() onCreated()
}, },
onError: (path, message) => form.setError(path as any, { message }) form
}) })
return ( return (
@ -50,7 +50,7 @@ export default function CreateInvitationForm({
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col items-start gap-2">
<form <form
className="flex w-full items-center gap-2" className="flex w-full items-center gap-2"
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(createInvitation)}
> >
<FormField <FormField
control={form.control} control={form.control}

View File

@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react'
import moment from 'moment' import moment from 'moment'
import { graphql } from '@/lib/gql/generates' import { graphql } from '@/lib/gql/generates'
import { useAuthenticatedGraphQLQuery, useGraphQLForm } from '@/lib/tabby/gql' import { useAuthenticatedGraphQLQuery, useMutation } from '@/lib/tabby/gql'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { IconTrash } from '@/components/ui/icons' import { IconTrash } from '@/components/ui/icons'
import { import {
@ -44,12 +44,11 @@ export default function InvitationTable() {
setOrigin(new URL(window.location.href).origin) setOrigin(new URL(window.location.href).origin)
}, []) }, [])
const { onSubmit: deleteInvitation } = useGraphQLForm( const deleteInvitation = useMutation(deleteInvitationMutation, {
deleteInvitationMutation, onCompleted() {
{ mutate()
onSuccess: () => mutate()
} }
) })
return ( return (
invitations && ( invitations && (
@ -75,7 +74,7 @@ export default function InvitationTable() {
<Button <Button
size="icon" size="icon"
variant="hover-destructive" variant="hover-destructive"
onClick={() => deleteInvitation({ id: x.id })} onClick={() => deleteInvitation(x)}
> >
<IconTrash /> <IconTrash />
</Button> </Button>

View File

@ -8,7 +8,7 @@ import * as z from 'zod'
import { graphql } from '@/lib/gql/generates' import { graphql } from '@/lib/gql/generates'
import { useSignIn } from '@/lib/tabby/auth' import { useSignIn } from '@/lib/tabby/auth'
import { useGraphQLForm } from '@/lib/tabby/gql' import { useMutation } from '@/lib/tabby/gql'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -52,13 +52,13 @@ export default function UserSignInForm({
const router = useRouter() const router = useRouter()
const signIn = useSignIn() const signIn = useSignIn()
const { isSubmitting } = form.formState const { isSubmitting } = form.formState
const { onSubmit } = useGraphQLForm(tokenAuth, { const onSubmit = useMutation(tokenAuth, {
onSuccess: async values => { async onCompleted(values) {
if (await signIn(values.tokenAuth)) { if (await signIn(values.tokenAuth)) {
router.replace('/') router.replace('/')
} }
}, },
onError: (path, message) => form.setError(path as any, { message }) form
}) })
return ( return (

View File

@ -8,7 +8,7 @@ import * as z from 'zod'
import { graphql } from '@/lib/gql/generates' import { graphql } from '@/lib/gql/generates'
import { useSignIn } from '@/lib/tabby/auth' import { useSignIn } from '@/lib/tabby/auth'
import { useGraphQLForm } from '@/lib/tabby/gql' import { useMutation } from '@/lib/tabby/gql'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@ -67,13 +67,13 @@ export function UserAuthForm({
const router = useRouter() const router = useRouter()
const signIn = useSignIn() const signIn = useSignIn()
const { isSubmitting } = form.formState const { isSubmitting } = form.formState
const { onSubmit } = useGraphQLForm(registerUser, { const onSubmit = useMutation(registerUser, {
onSuccess: async values => { async onCompleted(values) {
if (await signIn(values.register)) { if (await signIn(values.register)) {
router.replace('/') router.replace('/')
} }
}, },
onError: (path, message) => form.setError(path as any, { message }) form
}) })
return ( return (

View File

@ -4,7 +4,7 @@ import { jwtDecode, JwtPayload } from 'jwt-decode'
import { graphql } from '@/lib/gql/generates' import { graphql } from '@/lib/gql/generates'
import useInterval from '@/lib/hooks/use-interval' import useInterval from '@/lib/hooks/use-interval'
import { gqlClient, useGraphQLQuery } from '@/lib/tabby/gql' import { useGraphQLQuery, useMutation } from '@/lib/tabby/gql'
interface AuthData { interface AuthData {
accessToken: string accessToken: string
@ -115,35 +115,36 @@ const refreshTokenMutation = graphql(/* GraphQL */ `
} }
`) `)
async function doRefresh(token: string, dispatch: React.Dispatch<AuthActions>) {
let action: AuthActions
try {
action = {
type: AuthActionType.Refresh,
data: (
await gqlClient.request(refreshTokenMutation, { refreshToken: token })
).refreshToken
}
} catch (err) {
console.error('Failed to refresh token', err)
action = {
type: AuthActionType.SignOut
}
}
dispatch(action)
}
const AuthProvider: React.FunctionComponent<AuthProviderProps> = ({ const AuthProvider: React.FunctionComponent<AuthProviderProps> = ({
children children
}) => { }) => {
const storage = new TokenStorage()
const [authState, dispatch] = React.useReducer(authReducer, { const [authState, dispatch] = React.useReducer(authReducer, {
status: 'loading', status: 'loading',
data: null data: null
}) })
return (
<AuthContext.Provider value={{ authState, dispatch }}>
<RefreshAuth />
{children}
</AuthContext.Provider>
)
}
function RefreshAuth() {
const { authState, dispatch } = useAuthStore()
const storage = new TokenStorage()
const refreshToken = useMutation(refreshTokenMutation, {
onCompleted({ refreshToken: data }) {
dispatch({ type: AuthActionType.Refresh, data })
},
onError() {
dispatch({
type: AuthActionType.SignOut
})
}
})
const initialized = React.useRef(false) const initialized = React.useRef(false)
React.useEffect(() => { React.useEffect(() => {
if (initialized.current) return if (initialized.current) return
@ -151,38 +152,38 @@ const AuthProvider: React.FunctionComponent<AuthProviderProps> = ({
initialized.current = true initialized.current = true
const data = storage.initialState() const data = storage.initialState()
if (data?.refreshToken) { if (data?.refreshToken) {
doRefresh(data.refreshToken, dispatch) refreshToken(data)
} else { } else {
dispatch({ type: AuthActionType.Init, data: null }) dispatch({ type: AuthActionType.Init, data: null })
} }
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
authState.data && storage.persist(authState.data) authState?.data && storage.persist(authState.data)
}, [authState]) }, [authState])
useInterval(async () => { useInterval(async () => {
if (authState.status !== 'authenticated') { if (authState?.status !== 'authenticated') {
return return
} }
await doRefresh(authState.data.refreshToken, dispatch) await refreshToken(authState.data)
}, 5) }, 5)
return ( return <></>
<AuthContext.Provider value={{ authState, dispatch }}> }
{children}
</AuthContext.Provider> class AuthProviderIsMissing extends Error {
) constructor() {
super('AuthProvider is missing. Please add the AuthProvider at root level')
}
} }
function useAuthStore(): AuthStore { function useAuthStore(): AuthStore {
const context = React.useContext(AuthContext) const context = React.useContext(AuthContext)
if (!context) { if (!context) {
throw new Error( throw new AuthProviderIsMissing()
'AuthProvider is missing. Please add the AuthProvider at root level'
)
} }
return context return context

View File

@ -1,11 +1,12 @@
import { TypedDocumentNode } from '@graphql-typed-document-node/core' import { TypedDocumentNode } from '@graphql-typed-document-node/core'
import { GraphQLClient, Variables } from 'graphql-request' import { GraphQLClient, Variables } from 'graphql-request'
import { GraphQLResponse } from 'graphql-request/build/esm/types' import { GraphQLResponse } from 'graphql-request/build/esm/types'
import { FieldValues, UseFormReturn } from 'react-hook-form'
import useSWR, { SWRConfiguration, SWRResponse } from 'swr' import useSWR, { SWRConfiguration, SWRResponse } from 'swr'
import { useSession } from './auth' import { useSession } from './auth'
export const gqlClient = new GraphQLClient( const gqlClient = new GraphQLClient(
`${process.env.NEXT_PUBLIC_TABBY_SERVER_URL ?? ''}/graphql` `${process.env.NEXT_PUBLIC_TABBY_SERVER_URL ?? ''}/graphql`
) )
@ -18,52 +19,59 @@ export interface ValidationErrors {
errors: Array<ValidationError> errors: Array<ValidationError>
} }
export function useGraphQLForm< export function useMutation<TResult, TVariables extends Variables | undefined>(
TResult,
TVariables extends Variables | undefined
>(
document: TypedDocumentNode<TResult, TVariables>, document: TypedDocumentNode<TResult, TVariables>,
options?: { options?: {
onSuccess?: (values: TResult) => void onCompleted?: (data: TResult) => void
onError?: (path: string, message: string) => void onError?: (err: any) => any
form?: any
} }
) { ) {
const { data } = useSession() const { data: session } = useSession()
const accessToken = data?.accessToken const onFormError = options?.form
const onSubmit = async (variables?: TVariables) => { ? makeFormErrorHandler(options.form)
let res : undefined
const fn = async (variables?: TVariables) => {
let res: TResult | undefined
try { try {
res = await gqlClient.request({ res = await gqlClient.request({
document, document,
variables, variables: variables,
requestHeaders: accessToken requestHeaders: session
? { ? {
authorization: `Bearer ${accessToken}` authorization: `Bearer ${session.accessToken}`
} }
: undefined : undefined
}) })
} catch (err) { } catch (err) {
console.error('err', err) onFormError && onFormError(err)
const { errors = [] } = (err as any).response as GraphQLResponse options?.onError && options.onError(err)
for (const error of errors) { return
if (error.extensions && error.extensions['validation-errors']) {
const validationErrors = error.extensions[
'validation-errors'
] as ValidationErrors
for (const error of validationErrors.errors) {
options?.onError && options?.onError(error.path, error.message)
}
} else {
options?.onError && options?.onError('root', error.message)
}
}
return res
} }
options?.onSuccess && options.onSuccess(res) options?.onCompleted && options.onCompleted(res)
}
return fn
}
function makeFormErrorHandler<T extends FieldValues>(form: UseFormReturn<T>) {
return (err: any) => {
const { errors = [] } = err.response as GraphQLResponse
for (const error of errors) {
if (error.extensions && error.extensions['validation-errors']) {
const validationErrors = error.extensions[
'validation-errors'
] as ValidationErrors
for (const error of validationErrors.errors) {
form.setError(error.path as any, error)
}
} else {
form.setError('root', error)
}
}
} }
return { onSubmit }
} }
export function useGraphQLQuery< export function useGraphQLQuery<