Skip to content

Commit 3951bc6

Browse files
authored
✨ feat: webform support for react-router (#152)
* ✨ feat: update GraphQL schema paths in TypeScript configuration * ✨ feat: adding contact form support
1 parent af16309 commit 3951bc6

File tree

10 files changed

+391
-178
lines changed

10 files changed

+391
-178
lines changed

starters/react-router/app/graphql/generated/schema.graphql

Lines changed: 168 additions & 168 deletions
Large diffs are not rendered by default.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { getFormProps, useForm } from '@conform-to/react'
2+
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
3+
import { CheckCircle2, CircleAlert } from 'lucide-react'
4+
import { Input, Textarea } from '~/components/form'
5+
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
6+
import { Button } from '~/components/ui/button'
7+
import { Label } from '~/components/ui/label'
8+
import { contactFormSchema } from '~/integration/forms/ContactForm/schema'
9+
import { useFetcher } from 'react-router'
10+
import { action } from '~/routes/contact_form'
11+
12+
export const ContactForm = () => {
13+
const fetcher = useFetcher<typeof action>()
14+
15+
const [form, fields] = useForm({
16+
id: 'contact-form',
17+
lastResult: fetcher.data?.reply,
18+
constraint: getZodConstraint(contactFormSchema),
19+
onValidate({ formData }) {
20+
return parseWithZod(formData, { schema: contactFormSchema })
21+
},
22+
shouldValidate: 'onBlur',
23+
})
24+
25+
return (
26+
<div className="flex items-center justify-center">
27+
<div className="container mx-auto max-w-xl">
28+
{fetcher.data?.reply.status === 'success' ? (
29+
<div className="space-y-4">
30+
<Alert>
31+
<CheckCircle2 className="stroke-green-500" />
32+
<AlertTitle
33+
className="text-lg"
34+
dangerouslySetInnerHTML={{
35+
__html: fetcher.data.data?.confirmation_title || '',
36+
}}
37+
></AlertTitle>
38+
<AlertDescription
39+
dangerouslySetInnerHTML={{
40+
__html: fetcher.data.data?.confirmation_message || '',
41+
}}
42+
></AlertDescription>
43+
</Alert>
44+
</div>
45+
) : (
46+
<fetcher.Form
47+
{...getFormProps(form)}
48+
method="post"
49+
action="/contact_form"
50+
className="space-y-4"
51+
>
52+
{form.errors && (
53+
<div className="space-y-4">
54+
<Alert>
55+
<CircleAlert className="stroke-red-500" />
56+
<AlertTitle className="text-lg">Form Error!</AlertTitle>
57+
<AlertDescription>{form.errors}</AlertDescription>
58+
</Alert>
59+
</div>
60+
)}
61+
<div className="space-y-2">
62+
<Label htmlFor={fields.name.id}>Name</Label>
63+
<Input
64+
meta={fields.name}
65+
type="text"
66+
placeholder="Enter your name"
67+
/>
68+
{fields.name.errors && (
69+
<p className="text-sm text-red-500">{fields.name.errors}</p>
70+
)}
71+
</div>
72+
73+
<div className="space-y-2">
74+
<Label htmlFor={fields.email.id}>Email</Label>
75+
<Input
76+
meta={fields.email}
77+
type="email"
78+
placeholder="Enter your email"
79+
/>
80+
{fields.email.errors && (
81+
<p className="text-sm text-red-500">{fields.email.errors}</p>
82+
)}
83+
</div>
84+
85+
<div className="space-y-2">
86+
<Label htmlFor={fields.message.id}>Message</Label>
87+
<Textarea
88+
meta={fields.message}
89+
placeholder="Enter your message"
90+
className="min-h-[120px]"
91+
/>
92+
{fields.message.errors && (
93+
<p className="text-sm text-red-500">{fields.message.errors}</p>
94+
)}
95+
</div>
96+
<Button type="submit" className="w-full">
97+
Submit
98+
</Button>
99+
</fetcher.Form>
100+
)}
101+
</div>
102+
</div>
103+
)
104+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { graphql } from '~/graphql/gql.tada'
2+
import { getClient } from '~/utils/client.server'
3+
import { ContactFormSchema } from './schema'
4+
import { composable } from 'composable-functions'
5+
6+
const contactMutation = graphql(`
7+
mutation SubmitContactForm($input: [KeyValueInput]) {
8+
submitWebform(id: "contact_form", data: $input) {
9+
confirmation {
10+
confirmation_title
11+
confirmation_message
12+
}
13+
}
14+
}
15+
`)
16+
17+
async function submitContactForm(
18+
input: ContactFormSchema,
19+
) {
20+
const client = await getClient({
21+
url: process.env.DRUPAL_GRAPHQL_URI as string,
22+
auth: {
23+
uri: process.env.DRUPAL_AUTH_URI as string,
24+
clientId: process.env.DRUPAL_CLIENT_ID as string,
25+
clientSecret: process.env.DRUPAL_CLIENT_SECRET as string,
26+
},
27+
})
28+
29+
const inputArray = Object.entries(input).map(([key, value]) => ({
30+
key,
31+
value,
32+
}))
33+
const result = await client.mutation(contactMutation, { input: inputArray })
34+
35+
if (!result.data?.submitWebform?.confirmation) {
36+
throw new Error('Error submitting contact form')
37+
}
38+
39+
40+
return result.data.submitWebform.confirmation
41+
42+
}
43+
44+
export const submitContactFormFunction = composable(submitContactForm)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { z } from 'zod'
2+
3+
export const contactFormSchema = z.object({
4+
name: z.string().min(1, 'Name is required'),
5+
email: z.string().email('Invalid email address').min(1, 'Email is required'),
6+
message: z.string().optional(),
7+
})
8+
9+
export type ContactFormSchema = z.infer<typeof contactFormSchema>

starters/react-router/app/integration/resolvers/ParagraphWebformResolver.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FragmentOf, readFragment } from 'gql.tada'
2+
import { ContactForm } from '~/integration/forms/ContactForm/ContactForm'
23

34
import { WebformFragment } from '~/graphql/fragments/webform'
45
import { graphql } from '~/graphql/gql.tada'
@@ -28,18 +29,30 @@ export const ParagraphWebformFragment = graphql(
2829
export const ParagraphWebformResolver = ({
2930
paragraph,
3031
}: ParagraphWebformProps) => {
31-
const { id, heading, subheadingOptional, descriptionOptional, form } =
32+
const { heading, subheadingOptional, descriptionOptional, form } =
3233
readFragment(ParagraphWebformFragment, paragraph)
3334

3435
return (
35-
<div className="container mx-auto grid items-center gap-8 pt-8 pb-8 lg:grid-cols-2">
36-
<pre>
37-
{JSON.stringify(
38-
{ id, descriptionOptional, heading, subheadingOptional, form },
39-
null,
40-
2
36+
<div className="container mx-auto py-8 md:py-16 lg:py-24">
37+
<div>
38+
<h2 className="mb-5 text-3xl font-bold sm:text-4xl md:text-5xl">
39+
{heading}
40+
</h2>
41+
{subheadingOptional && (
42+
<h3 className="mb-3 text-xl">{subheadingOptional}</h3>
4143
)}
42-
</pre>
44+
{descriptionOptional && (
45+
<p
46+
className="text-muted-foreground mb-5 text-lg"
47+
dangerouslySetInnerHTML={{ __html: descriptionOptional }}
48+
/>
49+
)}
50+
{form && (
51+
<div className="py-8 md:py-16 lg:py-24">
52+
<ContactForm />
53+
</div>
54+
)}
55+
</div>
4356
</div>
4457
)
4558
}

starters/react-router/app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes'
33
export default [
44
index('routes/$.tsx', { id: 'index' }),
55
route('*', 'routes/$.tsx'),
6+
route('contact_form', 'routes/contact_form.ts')
67
] satisfies RouteConfig
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ActionFunctionArgs } from 'react-router'
2+
import { parseWithZod } from '@conform-to/zod'
3+
import { contactFormSchema } from '~/integration/forms/ContactForm/schema'
4+
import { submitContactFormFunction } from '~/integration/forms/ContactForm/function.server'
5+
6+
export async function action({ request }: ActionFunctionArgs) {
7+
const formData = await request.formData()
8+
9+
const submission = parseWithZod(formData, { schema: contactFormSchema })
10+
11+
if (submission.status !== 'success') {
12+
return {
13+
reply: submission.reply(),
14+
data: null,
15+
}
16+
}
17+
18+
const result = await submitContactFormFunction(submission.value)
19+
20+
if (!result.success) {
21+
return {
22+
reply: submission.reply({
23+
formErrors: [
24+
'There was an error submitting the form. Please try again later.',
25+
],
26+
}),
27+
data: null,
28+
}
29+
}
30+
31+
return {
32+
reply: submission.reply(),
33+
data: result.data,
34+
}
35+
}

starters/react-router/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@urql/core": "^5.1.1",
2828
"class-variance-authority": "^0.7.1",
2929
"clsx": "^2.1.1",
30+
"composable-functions": "^5.0.0",
3031
"drupal-auth-client": "^0.4",
3132
"drupal-decoupled": "^0.2.2",
3233
"gql.tada": "^1.8.10",

starters/react-router/tsconfig.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525
"plugins": [
2626
{
2727
"name": "gql.tada/ts-plugin",
28-
"schema": "./schema.graphql",
29-
"tadaOutputLocation": "./src/graphql-env.d.ts",
28+
"schema": "./app/graphql/generated/schema.graphql",
29+
"tadaOutputLocation": "./app/graphql/generated/gql.tada.instrospection.ts",
30+
"tadaTurboLocation": "./app/graphql/generated/gql.tada.cache.ts",
3031
"trackFieldUsage": false,
3132
"shouldCheckForColocatedFragments": false
3233
}

starters/react-router/yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2171,6 +2171,11 @@ color-name@~1.1.4:
21712171
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
21722172
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
21732173

2174+
composable-functions@^5.0.0:
2175+
version "5.0.0"
2176+
resolved "https://registry.yarnpkg.com/composable-functions/-/composable-functions-5.0.0.tgz#1dbe89a20779ec24fc7d8e2bdcea0b9644948b72"
2177+
integrity sha512-CV5r9eXpnombeKTHFP895n++aYIJkmpHrlDXqloF0OUYdrXr7CU008R8tcBjaDBcZHrSwB03W1Lv8T8Ly8C37A==
2178+
21742179
compressible@~2.0.18:
21752180
version "2.0.18"
21762181
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"

0 commit comments

Comments
 (0)