Skip to content

Commit

Permalink
feat(SLB-218): contact form and mutation example in FE
Browse files Browse the repository at this point in the history
  • Loading branch information
chindris committed Mar 5, 2024
1 parent e5e1224 commit e41a7b0
Show file tree
Hide file tree
Showing 10 changed files with 288 additions and 35 deletions.
8 changes: 8 additions & 0 deletions apps/website/gatsby-node.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ export const createPages = async ({ actions }) => {
});
});

// Create a contact page in each language.
Object.values(Locale).forEach((locale) => {
actions.createPage({
path: `/${locale}/contact`,
component: resolve(`./src/templates/contact.tsx`),
});
});

// Broken Gatsby links will attempt to load page-data.json files, which don't exist
// and also should not be piped into the strangler function. Thats why they
// are caught right here.
Expand Down
6 changes: 6 additions & 0 deletions apps/website/src/templates/contact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Contact } from '@custom/ui/routes/Contact';
import React from 'react';

export default function ContentHubPage() {
return <Contact />
}
63 changes: 46 additions & 17 deletions apps/website/src/utils/drupal-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,52 @@ export function drupalExecutor(endpoint: string, forward: boolean = true) {
variables?: OperationVariables<OperationId>,
) {
const url = new URL(endpoint, window.location.origin);
url.searchParams.set('queryId', id);
url.searchParams.set('variables', JSON.stringify(variables));
const { data, errors } = await (
await fetch(url, {
credentials: 'include',
headers: forward
? {
'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1),
'SLB-Forwarded-Host': window.location.hostname,
'SLB-Forwarded-Port': window.location.port,
}
: {},
})
).json();
if (errors) {
throw errors;
if (variables && variables.graphqlOperationType === 'mutation') {
const { data, errors } = await (
await fetch(url, {
method: 'POST',
credentials: 'include',
body: JSON.stringify({
'queryId': id,
'variables': variables.variables,
}),
headers: forward
? {
'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1),
'SLB-Forwarded-Host': window.location.hostname,
'SLB-Forwarded-Port': window.location.port,
"Content-Type": "application/json",
}
: {
"Content-Type": "application/json",
},
})
).json();
if (errors) {
throw errors;
}
return data;
} else {
url.searchParams.set('queryId', id);
if (variables?.variables) {
url.searchParams.set('variables', JSON.stringify(variables.variables));
}
const { data, errors } = await (
await fetch(url, {
credentials: 'include',
headers: forward
? {
'SLB-Forwarded-Proto': window.location.protocol.slice(0, -1),
'SLB-Forwarded-Host': window.location.hostname,
'SLB-Forwarded-Port': window.location.port,
}
: {},
})
).json();
if (errors) {
throw errors;
}
return data;
}
return data;
};
}
17 changes: 17 additions & 0 deletions packages/schema/src/operations/ContactRequest.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
mutation ContactRequest (
$contact: ContactInput
){
createContact(contact: $contact) {
errors {
key
field
message
}
contact {
name
email
message
subject
}
}
}
9 changes: 9 additions & 0 deletions packages/ui/src/components/Molecules/ContactForm.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Meta, StoryObj } from '@storybook/react';

import { ContactForm as Component } from './ContactForm';

export default {
component: Component,
} satisfies Meta<typeof Component>;

export const Empty = {} satisfies StoryObj<typeof Component>;
118 changes: 118 additions & 0 deletions packages/ui/src/components/Molecules/ContactForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { ContactRequestMutation } from '@custom/schema';
import React from 'react';
import { useForm } from 'react-hook-form';
import { useIntl } from 'react-intl';
import { z } from 'zod';

import { useMutation } from '../../utils/operation';
import { Messages } from './Messages';

const formValueSchema = z.object({
name: z.string(),
email: z.string(),
subject: z.string().optional(),
message: z.string(),
});

export function ContactForm() {
const intl = useIntl();
type FormValue = z.infer<typeof formValueSchema>;
const { register, handleSubmit } = useForm<FormValue>();

const { data, trigger, isMutating } = useMutation(ContactRequestMutation);
const errorMessages = (!isMutating && data && data.createContact?.errors && data.createContact.errors.length > 0 ) ?
data.createContact.errors.map(error => {
return error?.message || '';
})
:
null;
return (
<div>
{ errorMessages ? <Messages messages={errorMessages} /> : null}
<form
className="mt-5 sm:items-center"
onSubmit={handleSubmit((values) => {
// @todo: fix this
// @ts-ignore
trigger({
contact: values,
});
})}
>
<div className="w-full sm:max-w-sm">
<label htmlFor="name" className="sr-only">
{intl.formatMessage({
defaultMessage: 'Name',
id: 'HAlOn1',
})}
</label>
<input
{...register('name', { required: true })}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder={intl.formatMessage({
defaultMessage: 'Name',
id: 'HAlOn1',
})}
/>
</div>
<div className="w-full sm:max-w-sm pt-2">
<label htmlFor="email" className="sr-only">
{intl.formatMessage({
defaultMessage: 'Email',
id: 'sy+pv5',
})}
</label>
<input
{...register('email', { required: true })}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder={intl.formatMessage({
defaultMessage: 'Email',
id: 'sy+pv5',
})}
/>
</div>
<div className="w-full sm:max-w-sm pt-2">
<label htmlFor="subject" className="sr-only">
{intl.formatMessage({
defaultMessage: 'Subject',
id: 'LLtKhp',
})}
</label>
<input
{...register('subject')}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder={intl.formatMessage({
defaultMessage: 'Subject',
id: 'LLtKhp',
})}
/>
</div>
<div className="w-full sm:max-w-sm pt-2">
<label htmlFor="message" className="sr-only">
{intl.formatMessage({
defaultMessage: 'Message',
id: 'T7Ry38',
})}
</label>
<textarea
{...register('message', { required: true })}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder={intl.formatMessage({
defaultMessage: 'Message',
id: 'T7Ry38',
})}
/>
</div>
<div className="w-full pt-2">
<button
type="submit"
disabled={isMutating}
className="mt-3 inline-flex w-full items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:ml-3 sm:mt-0 sm:w-auto"
>
{isMutating ? intl.formatMessage({ defaultMessage: 'Sending...', id: '82Y7Sa' }) : intl.formatMessage({ defaultMessage: 'Submit', id: 'wSZR47' })}
</button>
</div>
</form>
</div>
);
}
11 changes: 11 additions & 0 deletions packages/ui/src/components/Organisms/Contact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';

import { ContactForm } from '../Molecules/ContactForm';

export function Contact() {
return (
<div className="mx-auto max-w-6xl">
<ContactForm />
</div>
);
}
20 changes: 20 additions & 0 deletions packages/ui/src/components/Routes/Contact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Locale } from '@custom/schema';
import React from 'react';

import { useTranslations } from '../../utils/translations';
import { PageTransition } from '../Molecules/PageTransition';
import { Contact as ContactOrganism } from '../Organisms/Contact';

export function Contact() {
// Initialize the contact page in each language.
useTranslations(
Object.fromEntries(
Object.values(Locale).map((locale) => [locale, `/${locale}/contact`]),
),
);
return (
<PageTransition>
<ContactOrganism />
</PageTransition>
);
}
69 changes: 52 additions & 17 deletions packages/ui/src/utils/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,65 @@ import {
OperationVariables,
} from '@custom/schema';
import useSwr, { SWRResponse } from 'swr';
import useSWRMutation, { SWRMutationResponse } from 'swr/mutation';

function swrFetcher<TOperation extends AnyOperationId>(
operationMetadata: {
operation: TOperation,
variables?: OperationVariables<TOperation>,
},
) {
const executor = createExecutor(operationMetadata.operation, {
variables: operationMetadata.variables,
});
if (typeof executor === 'function') {
// @todo: fix this.
// @ts-ignore
return executor();
}
// If the executor is not a function, then just return it. This means the
// executor is already the data we want.
return executor;
}

function swrMutator<TOperation extends AnyOperationId>(
operationMetadata: {
operation: TOperation,
},
args?: OperationVariables<TOperation>,
) {
const executor = createExecutor(operationMetadata.operation, {
graphqlOperationType: 'mutation',
variables: args?.arg,
});
if (typeof executor === 'function') {
// @todo: fix this.
// @ts-ignore
return executor();
}
return executor;
}

export function useOperation<TOperation extends AnyOperationId>(
operation: TOperation,
variables?: OperationVariables<TOperation>,
): Omit<SWRResponse<OperationResult<TOperation>>, 'mutate'> {
const executor = createExecutor(operation, variables);
// If the executor is a function, use SWR to manage it.
const result = useSwr<OperationResult<TOperation>>(
[operation, variables],
// If the executor is not a function, pass null to SWR,
// so it does not try to fetch.
typeof executor === 'function' ? executor : null,
): SWRResponse<OperationResult<TOperation>> {
return useSwr<OperationResult<TOperation>>(
{operation, variables},
swrFetcher,
{
suspense: false,
},
);
}

return typeof executor === 'function'
? result
: // If the executor is not a function, return a mock SWR response.
{
data: executor,
error: undefined,
isValidating: false,
isLoading: false,
};
export function useMutation<TOperation extends AnyOperationId>(
operation: TOperation,
): SWRMutationResponse<OperationResult<TOperation>> {
return useSWRMutation<OperationResult<TOperation>>(
{operation},
// @todo: fix this.
// @ts-ignore
swrMutator,
);
}
2 changes: 1 addition & 1 deletion tests/schema/specs/contact.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('create contact', () => {
{
"field": "email",
"key": "invalid_field_email",
"message": "The email address <em class=\"placeholder\">invalid_email</em> is not valid. Use the format [email protected].",
"message": "The email address <em class="placeholder">invalid_email</em> is not valid. Use the format [email protected].",
},
],
},
Expand Down

0 comments on commit e41a7b0

Please sign in to comment.