Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ async function createFeedbackTicket(formData: {
body: string;
projectId: string;
title: string;
recaptchaToken?: string;
}) {
// Replace undefined with null so that deleted values are unset instead of ignored.
const res = await apiClient.post(`/help/feedback/`, {
Expand All @@ -16,6 +17,7 @@ async function createFeedbackTicket(formData: {
subject: `Project Feedback for ${formData.projectId}`,
projectId: formData.projectId,
title: formData.title,
recaptchaToken: formData.recaptchaToken,
});
return res.data;
}
Expand All @@ -31,6 +33,7 @@ export function useCreateFeedbackTicket(projectId: string, title: string) {
body: string;
projectId: string;
title: string;
recaptchaToken?: string;
};
}) => createFeedbackTicket(formData),
});
Expand Down
1 change: 1 addition & 0 deletions client/modules/datafiles/src/projects/forms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DateInput } from './_fields';
1 change: 1 addition & 0 deletions client/modules/datafiles/src/projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export {
DownloadCitation,
} from './ProjectCitation/ProjectCitation';
export * from './modals';
export * from './forms';
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
import React, { useState } from 'react';
import { Button, Form, Input, Modal } from 'antd';
import { useAuthenticatedUser, useCreateFeedbackTicket } from '@client/hooks';
import ReCAPTCHA from 'react-google-recaptcha';
import { notification } from 'antd';

export const SubmitFeedbackModal: React.FC<{
projectId: string;
title: string;
}> = ({ projectId, title }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [form] = Form.useForm();
const recaptchaSiteKey =
(window as any).__RECAPTCHA_ENTERPRISE_SITE_KEY || '';

const showModal = () => setIsModalOpen(true);
const handleClose = () => {
form.resetFields();
setIsModalOpen(false);
};

const { mutate } = useCreateFeedbackTicket(projectId, title);
const [notifApi, contextHolder] = notification.useNotification();

const { user } = useAuthenticatedUser();
const isAuthenticated = !!user;
const submitFeedback = (formData: {
name: string;
email: string;
body: string;
recaptchaResponse?: string;
}) => {
mutate(
{ formData: { ...formData, projectId, title } },
{
formData: {
...formData,
projectId,
title,
...(formData.recaptchaResponse && {
recaptchaToken: formData.recaptchaResponse,
}),
},
},
{
onSuccess: () => {
handleClose();
Expand Down Expand Up @@ -60,6 +76,7 @@ export const SubmitFeedbackModal: React.FC<{
>
<div style={{ display: 'flex', gap: '1rem' }}>
<Form
form={form}
layout="vertical"
style={{ flex: 1 }}
onFinish={(formData) => submitFeedback(formData)}
Expand Down Expand Up @@ -102,6 +119,29 @@ export const SubmitFeedbackModal: React.FC<{
>
<Input.TextArea autoSize={{ minRows: 4 }} />
</Form.Item>
{!isAuthenticated && (
<Form.Item
name="recaptchaResponse"
required
rules={[
{ required: true, message: 'Please complete the reCAPTCHA' },
]}
>
{recaptchaSiteKey ? (
<ReCAPTCHA
sitekey={recaptchaSiteKey}
onChange={(value) =>
form.setFieldValue('recaptchaResponse', value || '')
}
onExpired={() =>
form.setFieldValue('recaptchaResponse', '')
}
/>
) : (
<div style={{ color: 'red' }}>RECAPTCHA site key not set</div>
)}
</Form.Item>
)}
<Form.Item>
<Button
type="primary"
Expand Down
276 changes: 276 additions & 0 deletions client/modules/reconportal/src/ReconSidePanel/ContributeDataModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import React, { useState } from 'react';
import { Button, Form, Input, Modal, Typography, notification } from 'antd';
import { z } from 'zod';
import ReCAPTCHA from 'react-google-recaptcha';
import { useCreateFeedbackTicket, useAuthenticatedUser } from '@client/hooks';
import { DateInput } from '@client/datafiles';

const formSchema = z.object({
name: z.string().min(1, 'Required'),
email: z.string().email('Invalid Email').min(1, 'Required'),
dateOfHazard: z.string().min(1, 'Required'),
eventTitle: z.string().min(1, 'Required'),
url: z.string().url('Invalid URL').min(1, 'Required'),
latitude: z.coerce
.number({
required_error: 'Required',
invalid_type_error: 'Latitude must be a number',
})
.min(-90, 'Latitude must be between -90 and 90')
.max(90, 'Latitude must be between -90 and 90'),
longitude: z.coerce
.number({
required_error: 'Required',
invalid_type_error: 'Longitude must be a number',
})
.min(-180, 'Longitude must be between -180 and 180')
.max(180, 'Longitude must be between -180 and 180'),
body: z.string().min(10, 'Description must be at least 10 characters'),
recaptchaResponse: z
.string()
.min(1, 'Please complete the reCAPTCHA')
.optional(),
});

type FormValues = z.infer<typeof formSchema>;

export const ContributeDataModal: React.FC = () => {
const { user } = useAuthenticatedUser();
const [isModalOpen, setIsModalOpen] = useState(false);
const [form] = Form.useForm<FormValues>();
const { Link } = Typography;
const recaptchaSiteKey =
(window as any).__RECAPTCHA_ENTERPRISE_SITE_KEY || '';
const isAuthenticated = !!user;

const showModal = () => setIsModalOpen(true);
const handleClose = () => {
form.resetFields();
setIsModalOpen(false);
};

const { mutate } = useCreateFeedbackTicket(
'RECON-PORTAL',
'Data Contribution for DesignSafe Recon Portal'
);
const [notifApi, contextHolder] = notification.useNotification();

const handleSubmit = (values: FormValues) => {
// Putting all extra fields in body so they're included in ticket, hook doesn't handle these extra fields
const formattedBody = `
${values.body}

--- Additional Information ---
Date of Hazard Event: ${values.dateOfHazard || ''}
Event Title: ${values.eventTitle}
URL to Data: ${values.url}
Latitude: ${values.latitude}
Longitude: ${values.longitude}
`.trim();

mutate(
{
formData: {
name: values.name,
email: values.email,
body: formattedBody,
projectId: 'RECON-PORTAL',
title: 'Data Contribution',
...(values.recaptchaResponse && {
recaptchaToken: values.recaptchaResponse,
}), // Only include recaptcha if present
},
},
{
onSuccess: () => {
form.resetFields();
handleClose();
notifApi.open({
type: 'success',
message: '',
description:
'Your data contribution was successfully submitted. Our team will contact you shortly to help load your data.',
placement: 'bottomLeft',
});
},
onError: () => {
notifApi.open({
type: 'error',
message: 'Error',
description: 'Submission failed, please try again.',
placement: 'bottomLeft',
});
},
}
);
};

const validateField = (fieldName: keyof FormValues) => {
return async (_: any, value: any) => {
// Handle reCAPTCHA separately
if (fieldName === 'recaptchaResponse') {
if (isAuthenticated) {
return Promise.resolve(); // Skip for logged-in users
}
// Require for unauthenticated users
if (!value || value.trim() === '') {
return Promise.reject('Please complete the reCAPTCHA');
}
return Promise.resolve();
}

const fieldSchema = formSchema.shape[fieldName];
try {
await fieldSchema.parseAsync(value);
} catch (error) {
if (error instanceof z.ZodError) {
return Promise.reject(error.errors[0]?.message);
}
return Promise.reject('Validation failed');
}
};
};

return (
<>
{contextHolder}
<Link onClick={showModal}>Email us to Contribute your Data</Link>
<Modal
destroyOnHidden
open={isModalOpen}
onCancel={handleClose}
width={900}
title={<h2>Contribute Your Data</h2>}
footer={null}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}
>
<Form.Item
label="Full Name"
name="name"
rules={[{ validator: validateField('name') }]}
initialValue={
user ? `${user?.firstName} ${user?.lastName}` : undefined
}
required
>
<Input disabled={!!user} />
</Form.Item>

<Form.Item
label="Email"
name="email"
rules={[{ validator: validateField('email') }]}
initialValue={user ? user.email : undefined}
required
>
<Input type="email" disabled={!!user} />
</Form.Item>
<Form.Item
label="Date of Hazard Event"
name="dateOfHazard"
rules={[{ validator: validateField('dateOfHazard') }]}
required
>
<DateInput />
</Form.Item>

<Form.Item
label="Event Title"
name="eventTitle"
rules={[{ validator: validateField('eventTitle') }]}
required
>
<Input />
</Form.Item>

<Form.Item
label="URL to Data"
name="url"
rules={[{ validator: validateField('url') }]}
required
>
<Input />
</Form.Item>

<Form.Item
label="Latitude"
name="latitude"
rules={[{ validator: validateField('latitude') }]}
required
>
<Input
type="number"
onChange={(e) => {
const num = Number(e.target.value);
form.setFieldValue(
'latitude',
e.target.value === '' || isNaN(num) ? undefined : num
);
}}
/>
</Form.Item>

<Form.Item
label="Longitude"
name="longitude"
rules={[{ validator: validateField('longitude') }]}
required
>
<Input
type="number"
onChange={(e) => {
const num = Number(e.target.value);
form.setFieldValue(
'longitude',
e.target.value === '' || isNaN(num) ? undefined : num
);
}}
/>
</Form.Item>

<Form.Item
label="Brief Description"
name="body"
rules={[{ validator: validateField('body') }]}
required
>
<Input.TextArea autoSize={{ minRows: 4 }} />
</Form.Item>

{!isAuthenticated && (
<Form.Item
name="recaptchaResponse"
rules={[{ validator: validateField('recaptchaResponse') }]}
required
>
{recaptchaSiteKey ? (
<ReCAPTCHA
sitekey={recaptchaSiteKey}
onChange={(value) =>
form.setFieldValue('recaptchaResponse', value || '')
}
onExpired={() => form.setFieldValue('recaptchaResponse', '')}
/>
) : (
<div style={{ color: 'red' }}>
RECAPTCHA site key not set yet
</div>
)}
</Form.Item>
)}

<Form.Item>
<Button type="primary" style={{ float: 'right' }} htmlType="submit">
Submit
</Button>
</Form.Item>
</Form>
</Modal>
</>
);
};
Loading