From 092a004968dab6f57c846bb6fb94ab186a5a6e44 Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Thu, 16 Mar 2023 09:51:59 +0000 Subject: [PATCH 1/2] change website field to be a file uploader --- .../forms/AccountAbstractionGrantsForm.tsx | 54 ++----- src/components/forms/api.ts | 36 +++-- src/components/forms/fields/UploadFile.tsx | 139 ++++++++++++++++++ src/components/forms/fields/index.tsx | 1 + src/pages/api/account-abstraction-grants.ts | 133 ++++++++++++----- src/types.ts | 4 +- 6 files changed, 280 insertions(+), 87 deletions(-) create mode 100644 src/components/forms/fields/UploadFile.tsx diff --git a/src/components/forms/AccountAbstractionGrantsForm.tsx b/src/components/forms/AccountAbstractionGrantsForm.tsx index 82226ff6..75daab5c 100644 --- a/src/components/forms/AccountAbstractionGrantsForm.tsx +++ b/src/components/forms/AccountAbstractionGrantsForm.tsx @@ -38,6 +38,7 @@ import { import { ACCOUNT_ABSTRACTION_GRANTS_THANK_YOU_PAGE_URL, TOAST_OPTIONS } from '../../constants'; import { AccountAbstractionGrantsFormData, ApplyingAs, GrantsReferralSource } from '../../types'; +import { UploadFile } from './fields'; export const AccountAbstractionGrantsForm: FC = () => { const router = useRouter(); @@ -92,6 +93,14 @@ export const AccountAbstractionGrantsForm: FC = () => { setGrantsReferralSource(source); }; + const handleDrop = () => { + toast({ + ...TOAST_OPTIONS, + title: 'Proposal uploaded!', + status: 'success' + }); + }; + return ( { )} - - + + - Grant Proposal URL + Grant Proposal - Please provide a link to your grant proposal for review.{' '} + Please upload a file with your grant proposal for review.{' '} { - - - https:// - - - - - {errors?.website?.type === 'maxLength' && ( - - - The URL cannot exceed 255 characters. - - - )} + - {errors?.website?.type === 'required' && ( + {errors?.proposal?.type === 'required' && ( - A URL is required. + A proposal file is required. )} diff --git a/src/components/forms/api.ts b/src/components/forms/api.ts index cbcc43c9..77b7149b 100644 --- a/src/components/forms/api.ts +++ b/src/components/forms/api.ts @@ -159,21 +159,29 @@ export const api = { }, accountAbstractionGrants: { submit: (data: AccountAbstractionGrantsFormData) => { + const curatedData: { [key: string]: any } = { + ...data, + applyingAs: data.applyingAs.value, + // Company is a required field in SF, we're using the Name as default value if no company provided + company: data.company === 'N/A' ? `${data.firstName} ${data.lastName}` : data.company, + country: data.country.value, + timezone: data.timezone.value, + projectCategory: data.projectCategory.value, + howDidYouHearAboutGrantsWave: data.howDidYouHearAboutGrantsWave.value, + wouldYouShareYourResearch: data.wouldYouShareYourResearch.value, + repeatApplicant: data.repeatApplicant === 'Yes', + canTheEFReachOut: data.canTheEFReachOut === 'Yes' + }; + + const formData = new FormData(); + + for (const name in data) { + formData.append(name, curatedData[name]); + } + const accountAbstractionRequestOptions: RequestInit = { - ...methodOptions, - body: JSON.stringify({ - ...data, - applyingAs: data.applyingAs.value, - // Company is a required field in SF, we're using the Name as default value if no company provided - company: data.company === 'N/A' ? `${data.firstName} ${data.lastName}` : data.company, - country: data.country.value, - timezone: data.timezone.value, - projectCategory: data.projectCategory.value, - howDidYouHearAboutGrantsWave: data.howDidYouHearAboutGrantsWave.value, - wouldYouShareYourResearch: data.wouldYouShareYourResearch.value, - repeatApplicant: data.repeatApplicant === 'Yes', - canTheEFReachOut: data.canTheEFReachOut === 'Yes' - }) + method: 'POST', + body: formData }; return fetch(API_ACCOUNT_ABSTRACTION_GRANTS, accountAbstractionRequestOptions); diff --git a/src/components/forms/fields/UploadFile.tsx b/src/components/forms/fields/UploadFile.tsx new file mode 100644 index 00000000..5a529bf7 --- /dev/null +++ b/src/components/forms/fields/UploadFile.tsx @@ -0,0 +1,139 @@ +import { FC, MouseEvent, useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { useDropzone } from 'react-dropzone'; +import { + Box, + ChakraProps, + Flex, + FormControl, + FormLabel, + Grid, + GridItem, + Input, + InputGroup, + Stack +} from '@chakra-ui/react'; +import Image from 'next/image'; + +import { PageText } from '../../UI'; +import { RemoveIcon } from '../../UI/icons'; +import { MAX_PROPOSAL_FILE_SIZE } from '../../../constants'; + +import uploadSVG from '../../../../public/images/upload.svg'; + +interface UploadFileProps extends ChakraProps { + name?: string; + title: string; + onDrop: (acceptedFiles: File[]) => void; +} + +export const UploadFile: FC = ({ name = 'upload', title, onDrop, ...rest }) => { + const { control, formState, setValue, getValues } = useFormContext(); + + const handleDrop = (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + setValue(name, file, { shouldValidate: true }); + + onDrop(acceptedFiles); + }; + + const handleRemoveFile = (e: MouseEvent) => { + e.stopPropagation(); + + setValue(name, null, { shouldValidate: true }); + }; + + const { getRootProps, getInputProps } = useDropzone({ onDrop: handleDrop }); + const selectedFile = getValues(name); + + const { errors } = formState; + + return ( + (file ? file.size < MAX_PROPOSAL_FILE_SIZE : true) + }} + render={({ field: { onChange } }) => ( + + + + + + + + Upload file + + + + + + + {title} + + + + + Click here or drag file to this box. + + + + {selectedFile && errors[name] && ( + + + File size cannot exceed 4mb. + + + )} + + + {selectedFile && ( + + {selectedFile.name} + + + + + )} + + + + + + )} + /> + ); +}; diff --git a/src/components/forms/fields/index.tsx b/src/components/forms/fields/index.tsx index 898b4a3b..3216d009 100644 --- a/src/components/forms/fields/index.tsx +++ b/src/components/forms/fields/index.tsx @@ -1 +1,2 @@ export * from './Captcha'; +export * from './UploadFile'; diff --git a/src/pages/api/account-abstraction-grants.ts b/src/pages/api/account-abstraction-grants.ts index 6943cd30..adb93188 100644 --- a/src/pages/api/account-abstraction-grants.ts +++ b/src/pages/api/account-abstraction-grants.ts @@ -1,18 +1,21 @@ import jsforce from 'jsforce'; -import { NextApiResponse } from 'next'; +import fs from 'fs'; +import type { NextApiResponse } from 'next'; +import type { File } from 'formidable'; import addRowToSpreadsheet from '../../utils/addRowToSpreadsheet'; -import { sanitizeFields, verifyCaptcha } from '../../middlewares'; +import { multipartyParse, sanitizeFields, verifyCaptcha } from '../../middlewares'; import { AccountAbstractionGrantsNextApiRequest } from '../../types'; +import { MAX_PROPOSAL_FILE_SIZE } from '../../constants'; async function handler( req: AccountAbstractionGrantsNextApiRequest, res: NextApiResponse ): Promise { return new Promise(resolve => { - const { body } = req; + const { fields = {}, files = {} } = req; const { firstName: FirstName, lastName: LastName, @@ -36,9 +39,9 @@ async function handler( telegram: Alternative_Contact__c, repeatApplicant: Repeat_Applicant__c, canTheEFReachOut: Can_the_EF_reach_out__c, - additionalInfo: Additional_Information__c, - website: Website - } = body; + additionalInfo: Additional_Information__c + } = fields; + const { SF_PROD_LOGIN_URL, SF_PROD_USERNAME, SF_PROD_PASSWORD, SF_PROD_SECURITY_TOKEN } = process.env; @@ -54,30 +57,29 @@ async function handler( } const application = { - FirstName: FirstName.trim(), - LastName: LastName.trim(), - Email: Email.trim(), - Title: Title.trim(), - Applying_as_a__c: Applying_as_a__c.trim(), - Applying_as_Other__c: Applying_as_Other__c.trim(), - Company: Company.trim(), - npsp__CompanyCountry__c: npsp__CompanyCountry__c.trim(), - Countries_of_Team__c: Countries_of_Team__c.trim(), - Time_Zone__c: Time_Zone__c.trim(), - Project_Name__c: Project_Name__c.trim(), - Project_Description__c: Project_Description__c.trim(), - Website: Website.trim(), - Category__c: Category__c.trim(), - Requested_Amount__c: Requested_Amount__c.trim(), - Referral_Source__c: Referral_Source__c.trim(), - Referral_Source_if_Other__c: Referral_Source_if_Other__c.trim(), - Would_you_share_your_research__c: Would_you_share_your_research__c.trim(), - Twitter__c: Twitter__c.trim(), - Github_Username__c: Github_Username__c.trim(), - Alternative_Contact__c: Alternative_Contact__c.trim(), - Repeat_Applicant__c, // this is a boolean value, no trim applied - Can_the_EF_reach_out__c, // this is a boolean value, no trim applied - Additional_Information__c: Additional_Information__c.trim(), + FirstName, + LastName, + Email, + Title, + Applying_as_a__c, + Applying_as_Other__c, + Company, + npsp__CompanyCountry__c, + Countries_of_Team__c, + Time_Zone__c, + Project_Name__c, + Project_Description__c, + Category__c, + Requested_Amount__c, + Referral_Source__c, + Referral_Source_if_Other__c, + Would_you_share_your_research__c, + Twitter__c, + Github_Username__c, + Alternative_Contact__c, + Repeat_Applicant__c, + Can_the_EF_reach_out__c, + Additional_Information__c: Additional_Information__c, Proactive_Community_Grants_Round__c: 'Account Abstraction 2023', // this value is hardwired, depending on the type of grant round RecordTypeId: process.env.SF_RECORD_TYPE_GRANTS_ROUND! // Proactive Grant Round }; @@ -97,6 +99,7 @@ async function handler( id: '1MUH1hUdeHpTRXEYLzpokHEV1tQYHmy83RDpl7ny-wxQ', sheetName: 'Applications' }, + // @ts-ignore application ); } catch (err) { @@ -106,11 +109,75 @@ async function handler( console.log(`Account Abstraction Grants 2023 Lead with ID: ${ret.id} has been created!`); - res.status(200).json({ status: 'ok' }); - return resolve(); + const createdLeadID = ret.id; + console.log({ createdLeadID }); + + const uploadProposal = files.proposal as File; + console.log({ uploadProposal }); + + if (!uploadProposal) { + res.status(200).json({ status: 'ok' }); + return resolve(); + } + + let uploadProposalContent; + try { + // turn file into base64 encoding + uploadProposalContent = fs.readFileSync(uploadProposal.filepath, { + encoding: 'base64' + }); + } catch (error) { + console.error(error); + res.status(500).json({ status: 'fail' }); + return resolve(); + } + + // Document upload + conn.sobject('ContentVersion').create( + { + Title: `[PROPOSAL] ${application.Project_Name__c} - ${createdLeadID}`, + PathOnClient: uploadProposal.originalFilename, + VersionData: uploadProposalContent // base64 encoded file content + }, + async (err, uploadedFile) => { + if (err || !uploadedFile.success) { + console.error(err); + + res.status(400).json({ status: 'fail' }); + return resolve(); + } else { + console.log({ uploadedFile }); + console.log(`Document has been uploaded successfully!`); + + const contentDocument = await conn + .sobject<{ + Id: string; + ContentDocumentId: string; + }>('ContentVersion') + .retrieve(uploadedFile.id); + + await conn.sobject('ContentDocumentLink').create({ + ContentDocumentId: contentDocument.ContentDocumentId, + LinkedEntityId: createdLeadID, + ShareType: 'V' + }); + + res.status(200).json({ status: 'ok' }); + return resolve(); + } + } + ); }); }); }); } -export default sanitizeFields(verifyCaptcha(handler)); +export const config = { + api: { + bodyParser: false + } +}; + +export default multipartyParse(sanitizeFields(verifyCaptcha(handler)), { + maxFileSize: MAX_PROPOSAL_FILE_SIZE +}); diff --git a/src/types.ts b/src/types.ts index b221220e..5c913e89 100644 --- a/src/types.ts +++ b/src/types.ts @@ -229,7 +229,7 @@ export interface AccountAbstractionGrantsFormData extends CaptchaForm { timezone: Timezone; // SF API: Time_Zone__c projectName: string; // SF API: Project_Name__c projectDescription: string; // SF API: Project_Description__c - website: string; // SF API: Website + proposal: File; projectCategory: AcademicGrantsProjectCategory; // SF API: Category__c requestedAmount: string; // SF API: Requested_Amount__c wouldYouShareYourResearch: WouldYouShareYourResearch; // SF API: Would_you_share_your_research__c @@ -447,7 +447,7 @@ export interface AccountAbstractionGrantsNextApiRequest extends NextApiRequest { timezone: string; projectName: string; projectDescription: string; - website: string; + proposal: File; projectCategory: string; requestedAmount: string; wouldYouShareYourResearch: string; From 1711be4224a529e11e8532350e7fc6b0e6dbc87b Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Wed, 22 Mar 2023 11:45:03 -0300 Subject: [PATCH 2/2] remove await from sending data to spreadsheet --- src/pages/api/account-abstraction-grants.ts | 22 ++++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/pages/api/account-abstraction-grants.ts b/src/pages/api/account-abstraction-grants.ts index adb93188..82c0ac67 100644 --- a/src/pages/api/account-abstraction-grants.ts +++ b/src/pages/api/account-abstraction-grants.ts @@ -93,19 +93,17 @@ async function handler( } // send submission data to a google spreadsheet - try { - await addRowToSpreadsheet( - { - id: '1MUH1hUdeHpTRXEYLzpokHEV1tQYHmy83RDpl7ny-wxQ', - sheetName: 'Applications' - }, - // @ts-ignore - application - ); - } catch (err) { + addRowToSpreadsheet( + { + id: '1MUH1hUdeHpTRXEYLzpokHEV1tQYHmy83RDpl7ny-wxQ', + sheetName: 'Applications' + }, + // @ts-ignore + application + ).catch(err => { // as this is something internal we don't want to show this error to the user - console.log(err); - } + console.error(err); + }); console.log(`Account Abstraction Grants 2023 Lead with ID: ${ret.id} has been created!`);