From 796169977ef28f8114e245fb24d99490747c2279 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 9 Jan 2025 16:12:29 -0800 Subject: [PATCH 1/3] Add form validation with zod --- package.json | 3 +- src/components/ProfileModal.tsx | 77 ++++++++++++++++++++++++++++++--- src/utils/gold-star.ts | 36 ++++++++++----- yarn.lock | 2 +- 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 0a1d795bd5..f8fa0a7a8b 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "tailwind-scrollbar-hide": "1.1.7", "tailwindcss": "^3.3.5", "unist-util-visit": "^5.0.0", - "webpack-merge": "5.8.0" + "webpack-merge": "5.8.0", + "zod": "^3.24.1" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.6.3", diff --git a/src/components/ProfileModal.tsx b/src/components/ProfileModal.tsx index 8838dd43b5..bfaba208a7 100644 --- a/src/components/ProfileModal.tsx +++ b/src/components/ProfileModal.tsx @@ -14,6 +14,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTrophy } from '@fortawesome/free-solid-svg-icons'; import { useChallenges } from '../hooks/use-challenges'; import * as fcl from '@onflow/fcl'; +import { z } from 'zod'; interface ProfileModalProps { isOpen: boolean; @@ -29,6 +30,13 @@ const flowSources = [ { name: 'Other', description: 'Another way not listed above.' }, ]; +const ProfileSettingsSchema = z.object({ + handle: z.string().nonempty(), + socials: z.record(z.string().nonempty()), + referralSource: z.string().nonempty().optional(), + deployedContracts: z.record(z.string().nonempty()), +}); + const ProfileModal: React.FC = ({ isOpen, onClose }) => { const { challenges } = useChallenges(); const { user } = useCurrentUser(); @@ -38,16 +46,46 @@ const ProfileModal: React.FC = ({ isOpen, onClose }) => { error, mutate: mutateProfile, } = useProfile(user.addr); + const [loaded, setLoaded] = useState(false); + const [txStatus, setTxStatus] = useState(null); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(''); + const [settings, setSettings] = useState({ handle: '', socials: {}, referralSource: '', deployedContracts: {}, }); - const [loaded, setLoaded] = useState(false); - const [txStatus, setTxStatus] = useState(null); - const [tags, setTags] = useState([]); - const [tagInput, setTagInput] = useState(''); + const [errors, setErrors] = useState<{ + handle?: string; + socials?: string; + referralSource?: string; + deployedContracts?: string; + }>({}); + const [touched, setTouched] = useState<{ + handle?: boolean; + socials?: boolean; + referralSource?: boolean; + deployedContracts?: boolean; + }>({}); + + const validate = () => { + const result = ProfileSettingsSchema.safeParse(settings); + console.log(result); + if (!result.success) { + setErrors( + result.error.errors.reduce((acc, error) => { + const field = error.path[0]; + acc[field] = error.message; + return acc; + }, {}), + ); + console.log(errors); + } else { + setErrors({}); + } + }; const completedChallenges = Object.keys(profile?.submissions ?? {}).reduce( (acc, key) => { @@ -72,6 +110,10 @@ const ProfileModal: React.FC = ({ isOpen, onClose }) => { } }, [profile, settings, loaded, isLoading, error]); + useEffect(() => { + validate(); + }, [settings]); + const handleAddTag = () => { if (tagInput.trim() && !tags.includes(tagInput.trim())) { setTags([...tags, tagInput.trim()]); @@ -86,6 +128,15 @@ const ProfileModal: React.FC = ({ isOpen, onClose }) => { async function handleSave() { if (!settings) return; + // Set touched for all fields & validate + setTouched({ + handle: true, + socials: true, + referralSource: true, + deployedContracts: true, + }); + validate(); + setTxStatus('Pending Approval...'); try { let txId: string | null = null; @@ -145,21 +196,32 @@ const ProfileModal: React.FC = ({ isOpen, onClose }) => { onChange={(e) => setSettings({ ...settings, handle: e.target.value }) } + onBlur={() => setTouched({ ...touched, handle: true })} /> + {errors.handle && touched.handle && ( +
{errors.handle}
+ )} setSettings({ ...settings, - socials: { [SocialType.GITHUB]: e.target.value }, + socials: { + ...settings.socials, + [SocialType.GITHUB]: e.target.value, + }, }) } + onBlur={() => setTouched({ ...touched, socials: true })} /> + {errors.socials && touched.socials && ( +
{errors.socials}
+ )} = ({ isOpen, onClose }) => { ?.description || '' } /> + {errors.referralSource && touched.referralSource && ( +
{errors.referralSource}
+ )} {completedChallenges.length > 0 && ( diff --git a/src/utils/gold-star.ts b/src/utils/gold-star.ts index 21f0892b10..b1ca964495 100644 --- a/src/utils/gold-star.ts +++ b/src/utils/gold-star.ts @@ -57,22 +57,28 @@ export const createProfile = async (profile: ProfileSettings) => { arg(profile.handle, t.String), arg(profile.referralSource, t.Optional(t.String)), arg( - profile.socials - ? Object.entries(profile.socials).map(([key, value]) => ({ + profile.deployedContracts + ? Object.entries(profile.deployedContracts).map(([key, value]) => ({ key, value, })) : [], - t.Dictionary(t.Address, t.String), + t.Dictionary({ + key: t.Address, + value: t.Array(t.String), + }), ), arg( - profile.deployedContracts - ? Object.entries(profile.deployedContracts).map(([key, value]) => ({ + profile.socials + ? Object.entries(profile.socials).map(([key, value]) => ({ key, value, })) : [], - t.Dictionary(t.Address, t.Array(t.String)), + fcl.t.Dictionary({ + key: fcl.t.String, + value: fcl.t.String, + }), ), ], }); @@ -90,22 +96,28 @@ export const setProfile = async (profile: ProfileSettings) => { arg(profile.handle, t.String), arg(profile.referralSource, t.Optional(t.String)), arg( - profile.socials - ? Object.entries(profile.socials).map(([key, value]) => ({ + profile.deployedContracts + ? Object.entries(profile.deployedContracts).map(([key, value]) => ({ key, value, })) : [], - t.Dictionary(t.Address, t.String), + t.Dictionary({ + key: t.Address, + value: t.Array(t.String), + }), ), arg( - profile.deployedContracts - ? Object.entries(profile.deployedContracts).map(([key, value]) => ({ + profile.socials + ? Object.entries(profile.socials).map(([key, value]) => ({ key, value, })) : [], - t.Dictionary(t.Address, t.Array(t.String)), + t.Dictionary({ + key: t.String, + value: t.String, + }), ), ], }); diff --git a/yarn.lock b/yarn.lock index a0c5689016..ea33dcdae9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17817,7 +17817,7 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110" integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g== -zod@^3.23.8: +zod@^3.23.8, zod@^3.24.1: version "3.24.1" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== From c18eb08c81a199eefc5ab7a27e1d48d34693445f Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 9 Jan 2025 16:20:07 -0800 Subject: [PATCH 2/3] fix validation --- src/components/ProfileModal.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/ProfileModal.tsx b/src/components/ProfileModal.tsx index bfaba208a7..640aaef920 100644 --- a/src/components/ProfileModal.tsx +++ b/src/components/ProfileModal.tsx @@ -72,7 +72,6 @@ const ProfileModal: React.FC = ({ isOpen, onClose }) => { const validate = () => { const result = ProfileSettingsSchema.safeParse(settings); - console.log(result); if (!result.success) { setErrors( result.error.errors.reduce((acc, error) => { @@ -81,7 +80,6 @@ const ProfileModal: React.FC = ({ isOpen, onClose }) => { return acc; }, {}), ); - console.log(errors); } else { setErrors({}); } @@ -207,15 +205,19 @@ const ProfileModal: React.FC = ({ isOpen, onClose }) => { name="github_handle" placeholder="joedoecodes" value={settings?.socials?.[SocialType.GITHUB] || ''} - onChange={(e) => + onChange={(e) => { + const socials: Record = { + ...settings.socials, + [SocialType.GITHUB]: e.target.value, + }; + if (!socials[SocialType.GITHUB]) { + delete socials[SocialType.GITHUB]; + } setSettings({ ...settings, - socials: { - ...settings.socials, - [SocialType.GITHUB]: e.target.value, - }, - }) - } + socials, + }); + }} onBlur={() => setTouched({ ...touched, socials: true })} />
From 0fac8f42d2c79c49f85bbb08a493be901f942ac1 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 9 Jan 2025 16:20:44 -0800 Subject: [PATCH 3/3] fix error message --- src/components/ProfileModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ProfileModal.tsx b/src/components/ProfileModal.tsx index 640aaef920..c2a54e4d78 100644 --- a/src/components/ProfileModal.tsx +++ b/src/components/ProfileModal.tsx @@ -31,7 +31,7 @@ const flowSources = [ ]; const ProfileSettingsSchema = z.object({ - handle: z.string().nonempty(), + handle: z.string().nonempty('Username is required'), socials: z.record(z.string().nonempty()), referralSource: z.string().nonempty().optional(), deployedContracts: z.record(z.string().nonempty()),