Skip to content

Commit

Permalink
feature: improve error handling for @formspree/core (#47)
Browse files Browse the repository at this point in the history
  • Loading branch information
bhongy authored Jul 26, 2023
1 parent 4c40e1b commit 49730d9
Show file tree
Hide file tree
Showing 29 changed files with 1,599 additions and 1,151 deletions.
11 changes: 11 additions & 0 deletions .changeset/orange-otters-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@formspree/core': major
'@formspree/react': minor
---

## Improve error handling

- `@formspree/core` `submitForm` function now will never rejects but always produces a type of `SubmissionResult`, different types of the result can be refined/narrowed down using the field `kind`.
- Provide `SubmissionErrorResult` which can be used to get an array of form errors and/or field errors (by field name)
- `Response` is no longer made available on the submission result
- Update `@formspree/react` for the changes introduced to `@formspree/core`
41 changes: 18 additions & 23 deletions examples/cra-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,12 @@
"name": "@formspree/cra-demo",
"version": "0.1.0",
"private": true,
"dependencies": {
"@formspree/react": "*",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0",
"react-google-recaptcha-v3": "^1.10.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"dev": "react-scripts start",
"start": "react-scripts start",
"build": "react-scripts build",
"clean": "rm -rf build && rm -rf node_modules",
"dev": "react-scripts start",
"eject": "react-scripts eject",
"clean": "rm -rf build && rm -rf node_modules"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"start": "react-scripts start"
},
"browserslist": {
"production": [
Expand All @@ -36,15 +21,25 @@
"last 1 safari version"
]
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"dependencies": {
"@formspree/react": "*",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0",
"react-google-recaptcha-v3": "^1.10.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@babel/core": "^7.22.1",
"@babel/plugin-syntax-flow": "^7.21.4",
"@babel/plugin-transform-react-jsx": "^7.22.0",
"@testing-library/dom": "^9.3.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.11.42",
"@types/react": "^18.0.14",
"@types/react-copy-to-clipboard": "^5.0.3",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^27.2.2",
"husky": "^8.0.0",
"isomorphic-fetch": "^3.0.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"lint-staged": "^13.2.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/formspree-core/.eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ root: true
extends:
- '../../.eslintrc.yml'
ignorePatterns:
- jest.config.js
- jest.setup.js
- dist/
parserOptions:
project: './tsconfig.json'
1 change: 1 addition & 0 deletions packages/formspree-core/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @type {import('jest').Config} */
const config = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom',
};

Expand Down
2 changes: 2 additions & 0 deletions packages/formspree-core/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Fix: ReferenceError: Response is not defined
import 'isomorphic-fetch';
214 changes: 126 additions & 88 deletions packages/formspree-core/src/core.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import type { Stripe } from '@stripe/stripe-js';
import type {
SubmissionData,
SubmissionOptions,
SubmissionBody,
SubmissionResponse,
} from './forms';
import { Session } from './session';
import {
SubmissionError,
SubmissionSuccess,
StripeSCAPending,
isServerErrorResponse,
isServerSuccessResponse,
isServerStripeSCAPendingResponse,
type FieldValues,
type SubmissionData,
type SubmissionOptions,
type SubmissionResult,
} from './submission';
import {
appendExtraData,
clientHeader,
encode64,
handleLegacyErrorPayload,
handleSCA,
isUnknownObject,
} from './utils';
import { Session } from './session';

export interface Config {
project?: string;
Expand All @@ -22,19 +27,13 @@ export interface Config {
export class Client {
project: string | undefined;
stripePromise: Stripe | undefined;
private session: Session | undefined;
private readonly session?: Session;

constructor(config: Config = {}) {
this.project = config.project;
this.stripePromise = config.stripePromise;
if (typeof window !== 'undefined') this.startBrowserSession();
}

/**
* Starts a browser session.
*/
startBrowserSession(): void {
if (!this.session) {
if (typeof window !== 'undefined') {
this.session = new Session();
}
}
Expand All @@ -46,22 +45,16 @@ export class Client {
* @param data - An object or FormData instance containing submission data.
* @param args - An object of form submission data.
*/
async submitForm(
async submitForm<T extends FieldValues>(
formKey: string,
data: SubmissionData,
data: SubmissionData<T>,
opts: SubmissionOptions = {}
): Promise<SubmissionResponse> {
): Promise<SubmissionResult<T>> {
const endpoint = opts.endpoint || 'https://formspree.io';
const fetchImpl = opts.fetchImpl || fetch;
const url = this.project
? `${endpoint}/p/${this.project}/f/${formKey}`
: `${endpoint}/f/${formKey}`;

const serializeBody = (data: SubmissionData): FormData | string => {
if (data instanceof FormData) return data;
return JSON.stringify(data);
};

const headers: { [key: string]: string } = {
Accept: 'application/json',
'Formspree-Client': clientHeader(opts.clientName),
Expand All @@ -75,78 +68,123 @@ export class Client {
headers['Content-Type'] = 'application/json';
}

const request = {
method: 'POST',
mode: 'cors' as const,
body: serializeBody(data),
headers,
};
async function makeFormspreeRequest(
data: SubmissionData<T>
): Promise<SubmissionResult<T> | StripeSCAPending> {
try {
const res = await fetch(url, {
method: 'POST',
mode: 'cors',
body: data instanceof FormData ? data : JSON.stringify(data),
headers,
});

const body = await res.json();

if (isUnknownObject(body)) {
if (isServerErrorResponse(body)) {
return Array.isArray(body.errors)
? new SubmissionError(...body.errors)
: new SubmissionError({ message: body.error });
}

if (isServerStripeSCAPendingResponse(body)) {
return new StripeSCAPending(
body.stripe.paymentIntentClientSecret,
body.resubmitKey
);
}

if (isServerSuccessResponse(body)) {
return new SubmissionSuccess({ next: body.next });
}
}

return new SubmissionError({
message: 'Unexpected response format',
});
} catch (err) {
const message =
err instanceof Error
? err.message
: `Unknown error while posting to Formspree: ${JSON.stringify(
err
)}`;
return new SubmissionError({ message: message });
}
}

// first check if we need to add the stripe paymentMethod
if (this.stripePromise && opts.createPaymentMethod) {
// Get Stripe payload
const payload = await opts.createPaymentMethod();

if (payload.error) {
// Return the error in case Stripe failed to create a payment method
return {
response: null,
body: {
errors: [
{
code: 'STRIPE_CLIENT_ERROR',
message: 'Error creating payment method',
field: 'paymentMethod',
},
],
},
};
const createPaymentMethodResult = await opts.createPaymentMethod();

if (createPaymentMethodResult.error) {
return new SubmissionError({
code: 'STRIPE_CLIENT_ERROR',
field: 'paymentMethod',
message: 'Error creating payment method',
});
}

// Add the paymentMethod to the data
appendExtraData(data, 'paymentMethod', payload.paymentMethod.id);
appendExtraData(
data,
'paymentMethod',
createPaymentMethodResult.paymentMethod.id
);

// Send a request to Formspree server to handle the payment method
const response = await fetchImpl(url, {
...request,
body: serializeBody(data),
});
const responseData = await response.json();

// Handle SCA
if (
responseData &&
responseData.stripe &&
responseData.stripe.requiresAction &&
responseData.resubmitKey
) {
return await handleSCA({
stripePromise: this.stripePromise,
responseData,
response,
payload,
data,
fetchImpl,
request,
url,
});
const result = await makeFormspreeRequest(data);

if (result.kind === 'error') {
return result;
}

return handleLegacyErrorPayload({
response,
body: responseData,
});
} else {
return fetchImpl(url, request)
.then((response) => {
return response
.json()
.then((body: SubmissionBody): SubmissionResponse => {
return handleLegacyErrorPayload({ body, response });
});
})
.catch();
if (result.kind === 'stripePluginPending') {
const stripeResult = await this.stripePromise.handleCardAction(
result.paymentIntentClientSecret
);

if (stripeResult.error) {
return new SubmissionError({
code: 'STRIPE_CLIENT_ERROR',
field: 'paymentMethod',
message: 'Stripe SCA error',
});
}

// `paymentMethod` must not be on the payload when resubmitting
// the form to handle Stripe SCA.
if (data instanceof FormData) {
data.delete('paymentMethod');
} else {
delete data.paymentMethod;
}

appendExtraData(data, 'paymentIntent', stripeResult.paymentIntent.id);
appendExtraData(data, 'resubmitKey', result.resubmitKey);

// Resubmit the form with the paymentIntent and resubmitKey
const resubmitResult = await makeFormspreeRequest(data);
assertSubmissionResult(resubmitResult);
return resubmitResult;
}

return result;
}

const result = await makeFormspreeRequest(data);
assertSubmissionResult(result);
return result;
}
}

// assertSubmissionResult ensures the result is SubmissionResult
function assertSubmissionResult<T extends FieldValues>(
result: SubmissionResult<T> | StripeSCAPending
): asserts result is SubmissionResult<T> {
const { kind } = result;
if (kind !== 'success' && kind !== 'error') {
throw new Error(`Unexpected submission result (kind: ${kind})`);
}
}

Expand Down
Loading

1 comment on commit 49730d9

@vercel
Copy link

@vercel vercel bot commented on 49730d9 Jul 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.