Skip to content

Commit

Permalink
feat: speakeasy integration (#165)
Browse files Browse the repository at this point in the history
* feat: speakeasy integration
  • Loading branch information
dmortal authored Mar 5, 2025
1 parent 7c99d45 commit 21d72e3
Show file tree
Hide file tree
Showing 8 changed files with 1,094 additions and 966 deletions.
1,933 changes: 996 additions & 937 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"typescript": "^5.6.3"
},
"dependencies": {
"@gusto/embedded-api": "^0.4.1",
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^3.9.0",
"@internationalized/date": "^3.5.6",
Expand Down
84 changes: 62 additions & 22 deletions src/components/Base/Base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import {
} from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next'
import { Alert, InternalError, Loading, useAsyncError } from '@/components/Common'
import { componentEvents, type EventType } from '@/shared/constants'
import { APIError } from '@gusto/embedded-api/models/errors/apierror'
import { SDKValidationError } from '@gusto/embedded-api/models/errors/sdkvalidationerror.js'
import { UnprocessableEntityErrorObject } from '@gusto/embedded-api/models/errors/unprocessableentityerrorobject.js'
import { EntityErrorObject } from '@gusto/embedded-api/models/components/entityerrorobject.js'
import { ApiError, ApiErrorMessage } from '@/api/queries/helpers'
import { componentEvents, type EventType } from '@/shared/constants'
import { Alert, InternalError, Loading, useAsyncError } from '@/components/Common'

// Define types
export type OnEventType<K, T> = (type: K, data?: T) => void
Expand All @@ -31,12 +35,13 @@ export interface BaseComponentInterface {
children?: ReactNode
}

type KnownErrors = ApiError | APIError | SDKValidationError | UnprocessableEntityErrorObject

type FieldError = {
key: string
message: string
}
interface BaseContextProps {
error: ApiError | null
fieldErrors: FieldError[] | null
setError: (err: ApiError) => void
onEvent: OnEventType<EventType, unknown>
Expand All @@ -57,13 +62,11 @@ export const useBase = () => {
return context
}

/**Recuresively traverses errorList and finds items with message propertys */
const renderErrorList = (errorList: ApiErrorMessage[]): React.ReactNode => {
/**Traverses errorList and finds items with message properties */
const renderErrorList = (errorList: FieldError[]): React.ReactNode => {
return errorList.map(errorFromList => {
if (errorFromList.message) {
return <li key={errorFromList.error_key}>{errorFromList.message}</li>
} else if (errorFromList.errors) {
return renderErrorList(errorFromList.errors)
return <li key={errorFromList.key}>{errorFromList.message}</li>
}
return null
})
Expand All @@ -72,25 +75,34 @@ const renderErrorList = (errorList: ApiErrorMessage[]): React.ReactNode => {
* metadata.state is a special case for state taxes validation errors
*/
const getFieldErrors = (
error: ApiErrorMessage,
error: ApiErrorMessage | EntityErrorObject,
parentKey?: string,
): { key: string; message: string }[] => {
//TODO: remove ApiErrorMessage and cammel case safety once transitioned to speakeasy
const keyPrefix = parentKey ? parentKey + '.' : ''
if (error.category === 'invalid_attribute_value') {
return [
{
key: keyPrefix + error.error_key,
key: keyPrefix + ('error_key' in error ? error.error_key : (error.errorKey ?? '')),
message: error.message ?? '',
},
]
}
if (error.category === 'nested_errors' && error.errors !== undefined) {
return error.errors.flatMap(err =>
getFieldErrors(
err,
keyPrefix + ((error.metadata?.key || error.metadata?.state) ?? error.error_key),
),
)
//TODO: clean this up once Metadata type is fixed in openapi spec
const keySuffix =
//@ts-expect-error: Metadata in speakeasy is incorrectly typed
error.metadata?.key && typeof error.metadata.key === 'string'
? //@ts-expect-error: Metadata in speakeasy is incorrectly typed
(error.metadata.key as string)
: //@ts-expect-error: Metadata in speakeasy is incorrectly typed
error.metadata?.state && typeof error.metadata.state === 'string'
? //@ts-expect-error: Metadata in speakeasy is incorrectly typed
(error.metadata.state as string)
: 'error_key' in error
? error.error_key
: ''
return error.errors.flatMap(err => getFieldErrors(err, keyPrefix + keySuffix))
}
return []
}
Expand All @@ -101,18 +113,47 @@ export const BaseComponent: FC<BaseComponentInterface> = ({
LoaderComponent = Loading,
onEvent,
}) => {
const [error, setError] = useState<ApiError | null>(null)
const [error, setError] = useState<KnownErrors | null>(null)
const [fieldErrors, setFieldErrors] = useState<FieldError[] | null>(null)
const throwError = useAsyncError()
const { t } = useTranslation()

const processError = (error: KnownErrors) => {
//Legacy React SDK error class:
//TODO: remove once switched to speakeasy
if (error instanceof ApiError) {
setFieldErrors(error.errorList ? error.errorList.flatMap(err => getFieldErrors(err)) : null)
}

//Speakeasy response handling
// The server response does not match the expected SDK schema
if (error instanceof SDKValidationError) {
setError(error)
}
//422 application/json - content relaited error
if (error instanceof UnprocessableEntityErrorObject) {
setError(error)
setFieldErrors(error.errors ? error.errors.flatMap(err => getFieldErrors(err)) : null)
}
//Speakeasy embedded api error class 4XX, 5XX */*
if (error instanceof APIError) {
setError(error)
}
}

const baseSubmitHandler = useCallback(
async <T,>(data: T, componentHandler: SubmitHandler<T>) => {
setError(null)
try {
await componentHandler(data)
} catch (err) {
if (err instanceof ApiError) {
setError(err)
if (
err instanceof ApiError ||
err instanceof APIError ||
err instanceof SDKValidationError ||
err instanceof UnprocessableEntityErrorObject
) {
processError(err)
} else throwError(err)
}
},
Expand All @@ -122,8 +163,7 @@ export const BaseComponent: FC<BaseComponentInterface> = ({
return (
<BaseContext.Provider
value={{
error,
fieldErrors: error?.errorList ? error.errorList.flatMap(err => getFieldErrors(err)) : null,
fieldErrors,
setError,
onEvent,
throwError,
Expand All @@ -139,7 +179,7 @@ export const BaseComponent: FC<BaseComponentInterface> = ({
>
{error && (
<Alert label={t('status.errorEncountered')} variant="error">
{error.errorList?.length && <ul>{renderErrorList(error.errorList)}</ul>}
{fieldErrors && <ul>{renderErrorList(fieldErrors)}</ul>}
</Alert>
)}
{children}
Expand Down
21 changes: 17 additions & 4 deletions src/components/Common/InternalError/InternalError.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { FallbackProps } from 'react-error-boundary'
import { Trans, useTranslation } from 'react-i18next'
import { Button } from '../Button/Button'

export const InternalError = ({ error }: FallbackProps) => {
const errorMessage =
typeof error === 'string' ? error : error instanceof Error ? error.message : 'unknown error'
export const InternalError = ({ error, resetErrorBoundary }: FallbackProps) => {
//TODO: Need to integrate useQueryErrorResetBoundary from tanstac to reset query cach on "try again" - GWS-3926
const { t } = useTranslation('common')
const errorMessage =
typeof error === 'string'
? error
: error instanceof Error
? error.message
: t('errors.unknownError')

return (
<section role="alert">
<Trans t={t} i18nKey="errors.globalReactError" values={{ error: errorMessage }} />
<Trans
t={t}
i18nKey="errors.globalReactError"
values={{ error: errorMessage }}
shouldUnescape={true}
/>
<Button onPress={resetErrorBoundary}>{t('errors.resetGlobalError')}</Button>
</section>
)
}
10 changes: 8 additions & 2 deletions src/contexts/GustoApiProvider/GustoApiProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useEffect, useMemo } from 'react'
import { QueryClient } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
import { I18nextProvider } from 'react-i18next'
import { ReactSDKProvider } from '@gusto/embedded-api/ReactSDKProvider'
import { SDKI18next } from './SDKI18next'
import { InternalError } from '@/components/Common'
import { LocaleProvider } from '@/contexts/LocaleProvider'
Expand All @@ -11,6 +12,7 @@ import { GTheme } from '@/types/GTheme'
import { APIConfig, GustoClient } from '@/api/client'
import { GustoApiContextProvider } from '@/api/context'
import { DeepPartial } from '@/types/Helpers'

type Resources = CustomTypeOptions['resources']

export type Dictionary = Record<
Expand All @@ -19,7 +21,7 @@ export type Dictionary = Record<
>

export interface GustoApiProps {
config?: APIConfig
config: APIConfig
dictionary?: Dictionary
lng?: string
locale?: string
Expand Down Expand Up @@ -60,13 +62,17 @@ const GustoApiProvider: React.FC<GustoApiProps> = ({
await SDKI18next.changeLanguage(lng)
})()
}, [lng])

return (
<ErrorBoundary FallbackComponent={InternalError}>
<LocaleProvider locale={locale} currency={currency}>
<ThemeProvider theme={theme}>
<I18nextProvider i18n={SDKI18next} key={lng}>
<GustoApiContextProvider context={context} queryClient={queryClient}>
{children}
{/* TODO: remove localhost - speakeasy client expects full url */}
<ReactSDKProvider url={`http://localhost:7777/${config.baseUrl}`}>
{children}
</ReactSDKProvider>
</GustoApiContextProvider>
</I18nextProvider>
</ThemeProvider>
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"optionalLabel": " (optional)",
"errors": {
"globalReactError": "Error while rendering SDK component: {{error}}",
"resetGlobalError": "Try again",
"unknownError": "Unknown Error",
"missingParamsOrContext": "{{component}} is missing {{param}} parameter or is used outside {{provider}}",
"unhandledEvent": "Unhandled event type: {{event}}",
"unknownEventType": "Unprocessed event type"
Expand Down
7 changes: 6 additions & 1 deletion src/test/GustoTestApiProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { QueryClient } from '@tanstack/react-query'
import { GustoApiProvider } from '@/contexts'
import { API_BASE_URL } from '@/api/constants'

interface GustoTestApiProviderProps {
children: React.ReactNode
Expand All @@ -22,5 +23,9 @@ export const GustoTestApiProvider = ({
})
}

return <GustoApiProvider queryClient={queryClient}>{children}</GustoApiProvider>
return (
<GustoApiProvider queryClient={queryClient} config={{ baseUrl: API_BASE_URL }}>
{children}
</GustoApiProvider>
)
}
2 changes: 2 additions & 0 deletions src/types/i18next.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@ export interface common{
"optionalLabel":string;
"errors":{
"globalReactError":string;
"resetGlobalError":string;
"unknownError":string;
"missingParamsOrContext":string;
"unhandledEvent":string;
"unknownEventType":string;
Expand Down

0 comments on commit 21d72e3

Please sign in to comment.