From 861f8686cbbdad0d378035c7a8228dcdaaa8679e Mon Sep 17 00:00:00 2001 From: Andy Espagnolo Date: Thu, 29 Jun 2023 13:02:53 -0300 Subject: [PATCH] refactor: use react-hook-form in all forms (#1049) * refactor: use react-hook-form in update proposal status modal * refactor: use react-hook-form in grant category section * refactor: use react-hook-form in http status test form * refactor: use react-hook-form in team and budget modals * refactor: use react-hook-form in grant general info submit * refactor: remove assert function from linked-wearables * fix: mobile styles * remove console logs * add function to get all field props in core unit * add function to get all field props in other categories --- src/components/Common/Form/TextArea.tsx | 33 ++ src/components/Common/Text/Text.css | 3 + src/components/Common/Text/Text.tsx | 2 +- src/components/Debug/HttpStatus.tsx | 111 +++---- .../GrantRequest/AddBudgetBreakdownModal.tsx | 305 +++++++++--------- src/components/GrantRequest/AddModal.tsx | 36 ++- .../GrantRequest/AddTeamMemberModal.tsx | 176 +++++----- .../CategorySection/AcceleratorSection.tsx | 165 ++++------ .../CategorySection/CoreUnitSection.tsx | 126 +++----- .../CategorySection/DocumentationSection.tsx | 100 +++--- .../CategorySection/InWorldContentSection.tsx | 121 ++++--- .../CategorySection/PlatformSection.tsx | 78 ++--- .../SocialMediaContentSection.tsx | 195 +++++------ .../CategorySection/SponsorshipSection.tsx | 277 ++++++++-------- .../GrantRequestCategorySection.tsx | 42 ++- .../GrantRequestGeneralInfoSection.tsx | 252 +++++++-------- .../GrantRequest/GrantRequestSection.css | 23 -- .../GrantRequest/GrantRequestSectionCard.css | 1 + .../GrantRequest/MultipleChoiceField.css | 3 + .../GrantRequest/MultipleChoiceField.tsx | 20 +- src/components/Home/OpenProposal.css | 4 + src/components/Home/OpenProposal.tsx | 2 +- .../UpdateProposalStatusModal.tsx | 207 ++++++------ .../Profile/ProposalCreatedItem.css | 20 +- .../Profile/ProposalCreatedItem.tsx | 18 +- src/hooks/useGrantCategoryEditor.ts | 22 -- src/pages/proposal.tsx | 2 +- src/pages/submit/grant.tsx | 14 +- src/pages/submit/linked-wearables.tsx | 20 +- src/ui-overrides.css | 26 ++ 30 files changed, 1160 insertions(+), 1244 deletions(-) create mode 100644 src/components/Common/Form/TextArea.tsx create mode 100644 src/components/GrantRequest/MultipleChoiceField.css delete mode 100644 src/hooks/useGrantCategoryEditor.ts diff --git a/src/components/Common/Form/TextArea.tsx b/src/components/Common/Form/TextArea.tsx new file mode 100644 index 000000000..1251d5f0b --- /dev/null +++ b/src/components/Common/Form/TextArea.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { Control, Controller, FieldValues, Path, PathValue } from 'react-hook-form' + +import { + TextAreaField as DCLTextArea, + TextAreaFieldProps, +} from 'decentraland-ui/dist/components/TextAreaField/TextAreaField' + +interface Props extends TextAreaFieldProps { + control: Control + name: Path + defaultValue?: PathValue> | undefined + rules?: any +} + +export default function TextArea({ + control, + name, + defaultValue, + rules, + ...fieldProps +}: Props) { + return ( + } + /> + ) +} diff --git a/src/components/Common/Text/Text.css b/src/components/Common/Text/Text.css index 57d0dd402..787556f16 100644 --- a/src/components/Common/Text/Text.css +++ b/src/components/Common/Text/Text.css @@ -43,6 +43,9 @@ .Text.Text--color-secondary { color: var(--secondary-text); } +.Text.Text--color-error { + color: var(--red-800); +} .Text.Text--style-normal { font-style: normal; diff --git a/src/components/Common/Text/Text.tsx b/src/components/Common/Text/Text.tsx index 400af369c..002904c10 100644 --- a/src/components/Common/Text/Text.tsx +++ b/src/components/Common/Text/Text.tsx @@ -10,7 +10,7 @@ const DEFAULT_FONT_SIZE: FontSize = 'md' const DEFAULT_FONT_STYLE: FontStyle = 'normal' type FontSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' type FontWeight = 'bold' | 'semi-bold' | 'normal' -type TextColor = 'default' | 'primary' | 'secondary' +type TextColor = 'default' | 'primary' | 'secondary' | 'error' type FontStyle = 'normal' | 'italic' interface Props { diff --git a/src/components/Debug/HttpStatus.tsx b/src/components/Debug/HttpStatus.tsx index ea59d14c6..63c9fdb40 100644 --- a/src/components/Debug/HttpStatus.tsx +++ b/src/components/Debug/HttpStatus.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' -import useEditor, { assert, createValidator } from 'decentraland-gatsby/dist/hooks/useEditor' import { Button } from 'decentraland-ui/dist/components/Button/Button' -import { Field } from 'decentraland-ui/dist/components/Field/Field' import { HttpStat } from '../../clients/HttpStat' import useFormatMessage from '../../hooks/useFormatMessage' +import Field from '../Common/Form/Field' import Label from '../Common/Label' import ErrorMessage from '../Error/ErrorMessage' import { ContentSection } from '../Layout/ContentLayout' @@ -20,96 +20,83 @@ const initialState: TestState = { sleepTime: 0, } -const edit = (state: TestState, props: Partial) => { - return { - ...state, - ...props, - } -} - const MAX_SLEEP_TIME = 300000 // 5 minutes -const validate = createValidator({ - httpStatus: (state) => ({ - httpStatus: assert(state.httpStatus.length === 3, 'error.debug.invalid_http_status'), - }), - sleepTime: (state) => ({ - sleepTime: assert(state.sleepTime >= 0 && state.sleepTime <= MAX_SLEEP_TIME, 'error.debug.invalid_sleep_time'), - }), - '*': (state) => ({ - httpStatus: assert(state.httpStatus.length === 3, 'error.debug.invalid_http_status'), - sleepTime: assert(state.sleepTime >= 0 && state.sleepTime <= MAX_SLEEP_TIME, 'error.debug.invalid_sleep_time'), - }), -}) - interface Props { className?: string } export default function HttpStatus({ className }: Props) { const t = useFormatMessage() - const [state, editor] = useEditor(edit, validate, initialState) const [formDisabled, setFormDisabled] = useState(false) + const { + handleSubmit, + formState: { isSubmitting, errors }, + control, + } = useForm({ defaultValues: initialState, mode: 'onTouched' }) + const [error, setError] = useState('') - useEffect(() => { - if (state.validated) { - setFormDisabled(true) - Promise.resolve() - .then(async () => { - return HttpStat.get().fetchResponse(state.value.httpStatus, state.value.sleepTime) - }) - .then((result) => { - console.log('result', result) - editor.error({ '*': '' }) - setFormDisabled(false) - }) - .catch((err) => { - console.error(err, { ...err }) - editor.error({ '*': err.body?.error || err.message }) - setFormDisabled(false) - }) + const onSubmit: SubmitHandler = async (data) => { + try { + const result = await HttpStat.get().fetchResponse(data.httpStatus, data.sleepTime) + console.log('result', result) + setFormDisabled(false) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + console.error(err, { ...err }) + setError(err.body?.error || err.message) + setFormDisabled(false) } - }, [editor, state.validated, state.value]) + } return ( -
+
editor.set({ httpStatus: value })} - onBlur={() => editor.set({ httpStatus: state.value.httpStatus.trim() })} - error={!!state.error.httpStatus} + name="httpStatus" + control={control} + error={!!errors.httpStatus} + message={errors.httpStatus?.message} disabled={formDisabled} - message={t(state.error.httpStatus)} + rules={{ + required: { value: true, message: t('error.draft.title_empty') }, + validate: (value: string) => { + if (value.length !== 3) { + return t('error.debug.invalid_http_status') + } + }, + }} /> editor.set({ sleepTime: value ? Number(value) : undefined })} - onBlur={() => editor.set({ sleepTime: state.value.sleepTime })} - error={!!state.error.sleepTime} - message={t(state.error.sleepTime)} + name="sleepTime" + control={control} + error={!!errors.sleepTime} + message={errors.sleepTime?.message} disabled={formDisabled} + rules={{ + required: { value: true, message: t('error.draft.title_empty') }, + validate: (value: string) => { + if (Number(value) >= 0 && Number(value) <= MAX_SLEEP_TIME) { + return t('error.debug.invalid_sleep_time') + } + }, + }} /> - - {state.error['*'] && ( + {error && ( - + )} -
+ ) } diff --git a/src/components/GrantRequest/AddBudgetBreakdownModal.tsx b/src/components/GrantRequest/AddBudgetBreakdownModal.tsx index 8fbc6dfe2..a145d0396 100644 --- a/src/components/GrantRequest/AddBudgetBreakdownModal.tsx +++ b/src/components/GrantRequest/AddBudgetBreakdownModal.tsx @@ -1,13 +1,12 @@ -import React, { useEffect, useMemo } from 'react' - -import Textarea from 'decentraland-gatsby/dist/components/Form/Textarea' -import useEditor, { assert, createValidator } from 'decentraland-gatsby/dist/hooks/useEditor' -import { Field } from 'decentraland-ui/dist/components/Field/Field' +import React, { useEffect } from 'react' +import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { BudgetBreakdownConcept, BudgetBreakdownConceptSchema } from '../../entities/Grant/types' import { asNumber } from '../../entities/Proposal/utils' import { isHttpsURL } from '../../helpers' import useFormatMessage from '../../hooks/useFormatMessage' +import Field from '../Common/Form/Field' +import TextArea from '../Common/Form/TextArea' import Label from '../Common/Label' import { ContentSection } from '../Layout/ContentLayout' @@ -16,7 +15,7 @@ import './AddModal.css' import BudgetInput from './BudgetInput' import NumberSelector from './NumberSelector' -export const INITIAL_BUDGET_BREAKDOWN_CONCEPT: BudgetBreakdownConcept = { +const INITIAL_BUDGET_BREAKDOWN_CONCEPT: BudgetBreakdownConcept = { concept: '', duration: 1, estimatedBudget: '', @@ -25,52 +24,6 @@ export const INITIAL_BUDGET_BREAKDOWN_CONCEPT: BudgetBreakdownConcept = { } const schema = BudgetBreakdownConceptSchema -const validate = (fundingLeftToDisclose: number) => - createValidator({ - concept: (state) => ({ - concept: - assert(state.concept.length <= schema.concept.maxLength, 'error.grant.due_diligence.concept_too_large') || - assert(state.concept.length > 0, 'error.grant.due_diligence.concept_empty') || - assert(state.concept.length >= schema.concept.minLength, 'error.grant.due_diligence.concept_too_short') || - undefined, - }), - estimatedBudget: (state) => ({ - estimatedBudget: - assert( - Number.isInteger(asNumber(state.estimatedBudget)), - 'error.grant.due_diligence.estimated_budget_invalid' - ) || - assert( - !state.estimatedBudget || asNumber(state.estimatedBudget) >= schema.estimatedBudget.minimum, - 'error.grant.due_diligence.estimated_budget_too_low' - ) || - assert( - !state.estimatedBudget || asNumber(state.estimatedBudget) <= fundingLeftToDisclose, - 'error.grant.due_diligence.estimated_budget_too_big' - ) || - undefined, - }), - aboutThis: (state) => ({ - aboutThis: - assert( - state.aboutThis.length <= schema.aboutThis.maxLength, - 'error.grant.due_diligence.about_this_too_large' - ) || - assert(state.aboutThis.length > 0, 'error.grant.due_diligence.about_this_empty') || - assert( - state.aboutThis.length >= schema.aboutThis.minLength, - 'error.grant.due_diligence.about_this_too_short' - ) || - undefined, - }), - }) - -const edit = (state: BudgetBreakdownConcept, props: Partial) => { - return { - ...state, - ...props, - } -} interface Props { isOpen: boolean @@ -82,7 +35,7 @@ interface Props { selectedConcept: BudgetBreakdownConcept | null } -const AddBudgetBreakdownModal = ({ +export default function AddBudgetBreakdownModal({ isOpen, onClose, onSubmit, @@ -90,130 +43,170 @@ const AddBudgetBreakdownModal = ({ fundingLeftToDisclose, selectedConcept, projectDuration, -}: Props) => { +}: Props) { const t = useFormatMessage() const leftToDisclose = selectedConcept ? fundingLeftToDisclose + Number(selectedConcept.estimatedBudget) : fundingLeftToDisclose - const validator = useMemo(() => validate(leftToDisclose), [leftToDisclose]) - const [state, editor] = useEditor(edit, validator, INITIAL_BUDGET_BREAKDOWN_CONCEPT) + + const { + formState: { errors }, + control, + reset, + watch, + setValue, + handleSubmit, + register, + } = useForm({ + defaultValues: INITIAL_BUDGET_BREAKDOWN_CONCEPT, + mode: 'onTouched', + }) + + const values = useWatch({ control }) const hasInvalidUrl = - state.value.relevantLink !== '' && - !!state.value.relevantLink && - (!isHttpsURL(state.value.relevantLink) || state.value.relevantLink?.length >= schema.relevantLink.maxLength) + values.relevantLink !== '' && + !!values.relevantLink && + (!isHttpsURL(values.relevantLink) || values.relevantLink?.length >= schema.relevantLink.maxLength) - useEffect(() => { - if (state.validated) { - onSubmit(state.value) - onClose() - editor.set(INITIAL_BUDGET_BREAKDOWN_CONCEPT) + const onSubmitForm: SubmitHandler = (data) => { + if (hasInvalidUrl) { + return } - }, [editor, onClose, onSubmit, state.validated, state.value]) + + onSubmit(data) + onClose() + reset() + } useEffect(() => { if (selectedConcept) { - editor.set({ ...selectedConcept }) + const { concept, aboutThis, duration, relevantLink } = selectedConcept + setValue('concept', concept) + setValue('aboutThis', aboutThis) + setValue('duration', duration) + setValue('relevantLink', relevantLink) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedConcept]) + }, [selectedConcept, setValue]) return ( !hasInvalidUrl && editor.validate()} + onPrimaryClick={handleSubmit(onSubmitForm)} onSecondaryClick={selectedConcept ? onDelete : undefined} > -
- - - editor.set({ concept: value })} - error={!!state.error.concept} - message={ - t(state.error.concept) + - ' ' + - t('page.submit.character_counter', { - current: state.value.concept.length, - limit: schema.concept.maxLength, - }) - } - /> - - - - editor.set({ - estimatedBudget: currentTarget.value !== '' ? Number(currentTarget.value) : currentTarget.value, - }) - } - subtitle={t('page.submit_grant.due_diligence.budget_breakdown_modal.estimated_budget_left_to_disclose', { + + + + + + - editor.set({ duration: Number(value) })} - label={t('page.submit_grant.due_diligence.budget_breakdown_modal.duration_label')} - unitLabel={t('page.submit_grant.due_diligence.budget_breakdown_modal.duration_unit_label')} - subtitle={t('page.submit_grant.due_diligence.budget_breakdown_modal.duration_subtitle')} - /> - - - -