diff --git a/package-lock.json b/package-lock.json index 17ca0bf09..580ae1237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "autoprefixer": "^10.4.4", "chart.js": "^3.8.2", "classnames": "^2.3.2", + "clipboard-copy": "^4.0.1", "core-js": "^3.21.1", "decentraland-gatsby": "^5.67.2", "decentraland-ui": "^3.102.0", diff --git a/package.json b/package.json index 309d0dc47..0c45ac6b7 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "autoprefixer": "^10.4.4", "chart.js": "^3.8.2", "classnames": "^2.3.2", + "clipboard-copy": "^4.0.1", "core-js": "^3.21.1", "decentraland-gatsby": "^5.67.2", "decentraland-ui": "^3.102.0", 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/Link.css b/src/components/Common/Link.css deleted file mode 100644 index 353dd84e7..000000000 --- a/src/components/Common/Link.css +++ /dev/null @@ -1,19 +0,0 @@ -.Link { - font-size: inherit; - line-height: inherit; - color: var(--dcl-primary); -} - -.Link.Link--pointer { - cursor: pointer; -} - -.Link .icon { - font-size: 19px; - vertical-align: middle; -} - -.Link i.icon { - font-size: 1em; - vertical-align: text-top; -} diff --git a/src/components/Common/Link.tsx b/src/components/Common/Link.tsx deleted file mode 100644 index 3a03d4cda..000000000 --- a/src/components/Common/Link.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' - -import classNames from 'classnames' - -import { navigate } from '../../utils/locations' - -import './Link.css' - -type Props = React.AnchorHTMLAttributes - -function isLocalLink(href?: string | null) { - return ( - typeof href === 'string' && !href.startsWith('https://') && !href.startsWith('http://') && !href.startsWith('//') - ) -} - -export default function Link({ target, rel, href, onClick, className, ...props }: Props) { - const isLocal = isLocalLink(href) - const linkTarget = !target && !isLocal ? '_blank' : target || undefined - const linkRel = !isLocal ? classNames(rel, 'noopener', 'noreferrer') : rel - const handleClick = (e: React.MouseEvent) => { - if (onClick) { - onClick(e) - } - if (isLocal && href) { - e.preventDefault() - navigate(href) - } - } - - return ( - - ) -} diff --git a/src/components/Common/Typography/Link.css b/src/components/Common/Typography/Link.css index db2475ea8..7452d5b6b 100644 --- a/src/components/Common/Typography/Link.css +++ b/src/components/Common/Typography/Link.css @@ -1,5 +1,5 @@ .Link { - color: var(--primary); + color: var(--dcl-primary); } .Link--pointer { diff --git a/src/components/Common/Typography/Text.css b/src/components/Common/Typography/Text.css index a9007e671..f6c7f2ac4 100644 --- a/src/components/Common/Typography/Text.css +++ b/src/components/Common/Typography/Text.css @@ -52,6 +52,10 @@ 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/Typography/Text.tsx b/src/components/Common/Typography/Text.tsx index b4723fd8c..ec62315b1 100644 --- a/src/components/Common/Typography/Text.tsx +++ b/src/components/Common/Typography/Text.tsx @@ -10,7 +10,7 @@ const DEFAULT_FONT_SIZE: FontSize = 'md' const DEFAULT_FONT_STYLE: FontStyle = 'normal' export type FontSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' export 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 5bb431c9a..632b323a2 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/Typography/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/Error/ErrorMessage.tsx b/src/components/Error/ErrorMessage.tsx index e55c19d55..79424b2fe 100644 --- a/src/components/Error/ErrorMessage.tsx +++ b/src/components/Error/ErrorMessage.tsx @@ -1,9 +1,9 @@ -import React, { useCallback, useState } from 'react' +import React, { useState } from 'react' import classNames from 'classnames' -import useClipboardCopy from 'decentraland-gatsby/dist/hooks/useClipboardCopy' import { Button } from 'decentraland-ui/dist/components/Button/Button' +import useClipboardCopy from '../../hooks/useClipboardCopy' import useFormatMessage from '../../hooks/useFormatMessage' import Time from '../../utils/date/Time' import Link from '../Common/Typography/Link' @@ -19,17 +19,13 @@ interface Props { export default function ErrorMessage({ label, errorMessage }: Props) { const t = useFormatMessage() - const [copied, state] = useClipboardCopy(Time.Second) + const { copiedValue, handleCopy } = useClipboardCopy(Time.Second) const [open, setOpen] = useState(false) const toggleHandler = () => { setOpen(!open) } - const handleCopy = useCallback(() => { - state.copy(errorMessage) - }, [errorMessage, state]) - return (
@@ -42,8 +38,13 @@ export default function ErrorMessage({ label, errorMessage }: Props) {
{errorMessage}
-
- 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')} - /> - - - -