Open jupiter007 opened 3 weeks ago
Forms
For any forms, there should be both frontend and backend validation of fields.
Apollo Server can return an errors array in the response that looks like:
const errorResponse = [
{"error": "username error message", "path": ["username"]},
{"error": "password error message", "path": ["password"]}
];
These errors can then be displayed below the form fields accordingly.
Here is an example of frontend code where field validations are done, and field errors in the response from the backend are also handled:
"use client"
import React, { useState } from "react";
import { useAddContributorRoleMutation } from "@/generated/graphql";
type FieldError = {
error: string;
path: string;
};
type ContributorRoleFormState = {
url: string;
label: string;
displayOrder: number;
description: string;
};
type FieldErrors = {
[key in keyof ContributorRoleFormState]?: string;
};
/**
* This is a test page to demo and test the use of graphql hooks on the client side.
* Client-side graphql requests uses the apollo-wrapper.tsx file
* @returns
*/
const AddContributorRoleForm: React.FC = () => {
// Form state
const [formState, setFormState] = useState<ContributorRoleFormState>({
url: '',
label: '',
displayOrder: 0,
description: '',
});
const [errors, setErrors] = useState({
url: '',
label: '',
displayOrder: '',
description: '',
})
// State for field-level errors
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
// Use the generated mutation hook
const [addContributorRoleMutation, { data, loading, error }] = useAddContributorRoleMutation();
// Handle input change
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormState({
...formState,
[name]: name === 'displayOrder' ? parseInt(value) : value,
});
};
const validateForm = () => {
const newErrors = { url: '', label: '', displayOrder: '', description: '' };
if (!formState.url) newErrors.url = 'Url is required';
if (!formState.label) newErrors.label = 'Label is required';
if (!formState.displayOrder) newErrors.displayOrder = 'DisplayOrder is required';
if (!formState.description) newErrors.description = 'Description is required';
return newErrors;
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const validationErrors = validateForm();
setFieldErrors(validationErrors);
if (!fieldErrors) {
try {
const response = await addContributorRoleMutation({
variables: formState,
});
if (response?.data.addContributorRole?.success) {
console.log('Contributor role added successfully:', response.data.addContributorRole.contributorRole);
setFieldErrors({})
} else {
console.error('Failed to add contributor role:', response.data.addContributorRole.message);
const errors = response?.data?.addContributorRoles?.errors || [];
const errorMap: FieldErrors = {};
errors.forEach(err => {
errorMap[err.path as keyof ContributorRoleFormState] = err.error;
});
setFieldErrors(errorMap);
}
} catch (err) {
console.error('Error executing mutation', err);
}
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
name="url"
placeholder="URL"
value={formState.url}
onChange={handleChange}
/>
{fieldErrors.url && <div style={{ color: 'red' }}>{fieldErrors.url}</div>}
</div>
<div>
<input
type="text"
name="label"
placeholder="Label"
value={formState.label}
onChange={handleChange}
/>
{fieldErrors.label && <div style={{ color: 'red' }}>{fieldErrors.label}</div>}
</div>
<div>
<input
type="number"
name="displayOrder"
placeholder="Display Order"
value={formState.displayOrder}
onChange={handleChange}
/>
{fieldErrors.displayOrder && <div style={{ color: 'red' }}>{fieldErrors.displayOrder}</div>}
</div>
<div>
<input
type="text"
placeholder="Description"
name="description"
value={formState.description}
onChange={handleChange}
/>
{fieldErrors.description && <div style={{ color: 'red' }}>{fieldErrors.description}</div>}
</div>
<button type="submit" disabled={loading}>
{loading ? 'Submitting...' : 'Add Contributor Role'}
</button>
{error && <p>Error: {error.message}</p>}
</form>
);
}
export default AddContributorRoleForm;
GraphQL
When the client component function making the GraphQL request catches an error, we should use a shared errorHandler
function to check for graphQLErrors
and networkError
and handle accordingly. GraphQL errors will be caught in the catch part of the try-catch
.
I'm picturing the error handler will look something like this:
'use client'
import { ApolloError } from '@apollo/client';
import { useRouter } from 'next/navigation';
import logECS from '@/utils/clientLogger';
export const handleError = (error: any, setFlashMessage: (message: { type: 'success' | 'error' | 'info', message: string }) => void) => {
let message = 'An unexpected error occurred';
const router = useRouter();
// Handle Apollo Client GraphQL errors
if (error instanceof ApolloError) {
if (error.graphQLErrors.length > 0) {
error.graphQLErrors.forEach(graphQLError => {
switch (graphQLError.extensions?.code) {
case 'UNAUTHORIZED':
setFlashMessage({ type: 'error', message: 'You are not authenticated. Please log in.' })
logECS('error', `[GraphQL Error]: UNAUTHORIZED - ${graphQLError.message}`, {
error: graphQLError
});
router.push('/login');
break;
case 'FORBIDDEN':
setFlashMessage({ type: 'error', message: 'You do not have permission to access this resource.' })
logECS('error', `[GraphQL Error]: FORBIDDEN - ${graphQLError.message}`, {
error: graphQLError
});
router.push('/login');
break;
case 'BAD_USER_INPUT':
message = 'There was a problem with your input.'
break;
default:
message = graphQLError.message;
}
});
} else if (error.networkError) {
message = 'Network error occurred.'
}
} else if (error.response) {
// Handle HTTP status codes
switch (error.response.status) {
case 404:
setFlashMessage({ type: 'error', message: 'Something went wrong.' })
logECS('error', `404 Error - ${error.message}`, {
error: error
});
router.push('/404-error');
break;
case 500:
setFlashMessage({ type: 'error', message: 'Something went wrong.' })
logECS('error', `500 Error - ${error.message}`, {
error: error
});
router.push('/500-error');
break;
default:
message = `Error: ${error.response.statusText}`;
logECS('error', message, {
error: error
});
}
} else if (error.message) {
// Handle other types of errors (e.g., client-side errors)
setFlashMessage({ type: 'error', message: 'Something went wrong.' })
router.push('/500-error');
return;
//message = error.message;
}
return message;
}
Also, the Apollo Client instance will include an errorLink
which will allow us to handle specific types of graphQLErrors
:
import { ApolloClient, InMemoryCache, createHttpLink, from } from "@apollo/client";
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { useRouter } from 'next/router';
import { createAuthLink } from '@/utils/authLink';
import logECS from '@/utils/clientLogger';
const httpLink = createHttpLink({
uri: `${process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT}/graphql`
});
const authLink = createAuthLink();
const retryLink = new RetryLink({
attempts: {
max: 3, // Maximum number of retry attempts
retryIf: (error, _operation) => {
// Retry on network errors
return !!error.networkError;
}
},
delay: {
initial: 1000, // Initial delay in milliseconds
max: 5000, // Maximum delay in milliseconds
jitter: true // Add random jitter to the delay to help spread out retry attempts and avoid potential overloading of backend system
}
});
interface CustomError extends Error {
customInfo?: { errorMessage: string }
}
const router = useRouter();
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, extensions }) => {
//Check for specific error codes
switch (extensions?.code) {
case 'UNAUTHORIZED':
logECS('error', `[GraphQL Error]: - ${message}`, {
errorCode: 'UNAUTHORIZED'
});
router.push('/login');
break;
case 'FORBIDDEN':
logECS('error', `[GraphQL Error]: FORBIDDEN - ${message}`, {
errorCode: 'FORBIDDEN'
});
// Rediret to dashboard
router.push('/');
break;
case 'INTERNAL_SERVER_ERROR':
logECS('error', `[GraphQL Error]: INTERNAL_SERVER_ERROR - ${message}`, {
errorCode: 'INTERNAL_SERVER_ERROR'
});
router.push('/500-error');
break;
default:
logECS('error', `[GraphQL Error]: ${message}`, {
errorCode: 'GRAPHQL'
});
break;
}
})
}
if (networkError) {
logECS('error', `[GraphQL Error Network Error]: ${networkError.message}`, {
errorCode: 'NETWORK_ERROR'
});
const customNetworkError = networkError as CustomError;
customNetworkError.customInfo = { errorMessage: 'There was a problem ' };
operation.setContext({ networkError: customNetworkError });
}
});
export const createApolloClient = () => {
return new ApolloClient({
link: from([errorLink, authLink, retryLink, httpLink]),
cache: new InMemoryCache(),
});
};
Flash Messages
We should probably implement Flash Messages in the frontend app. We can set flash messages in the Apollo Client before redirecting the user on errors like UNAUTHORIZED
, FORBIDDEN
, and INTERNAL_SERVER_ERROR
. Flash Messages could be implemented using a FlashMessageContext.
The FlashMessageContext would display until the user navigated away from the page. The code would look something like this:
'use client'
import React, { createContext, useState, useContext, ReactNode, useEffect } from 'react';
type FlashMessageType = 'success' | 'error' | 'info';
interface FlashMessage {
type: FlashMessageType;
message: string;
}
interface FlashMessageContextType {
flashMessage: FlashMessage | null;
setFlashMessage: (message: FlashMessage) => void;
clearFlashMessage: () => void;
}
const FlashMessageContext = createContext<FlashMessageContextType | undefined>(undefined);
export const FlashMessageProvider = ({ children }: { children: ReactNode }) => {
const [flashMessage, setFlashMessage] = useState<FlashMessage | null>(null);
useEffect(() => {
// Load flash message from localStorage on initial render
const storedMessage = localStorage.getItem('flashMessage');
if (storedMessage) {
setFlashMessage(JSON.parse(storedMessage));
}
}, []);
const setFlashMessageWithStorage = (message: FlashMessage) => {
setFlashMessage(message);
localStorage.setItem('flashMessage', JSON.stringify(message));
};
const clearFlashMessage = () => {
setFlashMessage(null);
localStorage.removeItem('flashMessage');
};
return (
<FlashMessageContext.Provider value={{ flashMessage, setFlashMessage: setFlashMessageWithStorage, clearFlashMessage }}>
{children}
</FlashMessageContext.Provider>
);
};
export const useFlashMessage = () => {
const context = useContext(FlashMessageContext);
if (!context) {
throw new Error('useFlashMessage must be used within a FlashMessageProvider');
}
return context;
};
I've added documentation on form field validations, adding a shared error handler for handling different error types and using Flash Messages, especially for redirects.
I will close out this ticket for now, and the above can be implemented once the backend is able to complete this ticket: https://github.com/CDLUC3/dmsp_backend_prototype/issues/68
1) Define ways in which errors from graphql will be caught and processed 2) Determine how to handle different types of errors (higher level errors, page- or field-level errors 3) Establish conventions for error messages and user feedback
We can establish what we need the backend server to return. For example, other than the status code, what do we need in the returned error object. For field-level errors, should the backend return an array of objects with each object containing the field name and error message?