From 2c5d3c03a2baa17e51e73d3f307bd1193bc75c5a Mon Sep 17 00:00:00 2001 From: rebelchris Date: Tue, 15 Sep 2026 14:11:10 +0000 Subject: [PATCH 1/3] fix(profile): persist pending social links on save --- .../tests/profile-social-links.spec.ts | 106 +++++++++ .../profile/SocialLinksInput.spec.tsx | 100 +++++++++ .../components/profile/SocialLinksInput.tsx | 181 ++++++++++++---- .../shared/src/hooks/useUserInfoForm.spec.tsx | 204 ++++++++++++++++++ packages/shared/src/hooks/useUserInfoForm.ts | 95 ++++++-- .../layouts/SettingsLayout/Profile/index.tsx | 13 +- 6 files changed, 636 insertions(+), 63 deletions(-) create mode 100644 packages/playwright/tests/profile-social-links.spec.ts create mode 100644 packages/shared/src/components/profile/SocialLinksInput.spec.tsx create mode 100644 packages/shared/src/hooks/useUserInfoForm.spec.tsx diff --git a/packages/playwright/tests/profile-social-links.spec.ts b/packages/playwright/tests/profile-social-links.spec.ts new file mode 100644 index 00000000000..5c6ee5bad2b --- /dev/null +++ b/packages/playwright/tests/profile-social-links.spec.ts @@ -0,0 +1,106 @@ +import { test, expect, type Page } from '@playwright/test'; + +const getRequiredEnv = (name: 'USER_NAME' | 'PASSWORD'): string => { + const value = process.env[name]; + + if (!value) { + throw new Error(`${name} environment variable is required`); + } + + return value; +}; + +const acceptCookieBanner = async (page: Page): Promise => { + await page + .getByRole('button', { name: 'Accept all' }) + .or(page.getByRole('button', { name: 'I understand' })) + .click({ timeout: 5000 }) + .catch(() => undefined); +}; + +const login = async (page: Page): Promise => { + await page.goto('/'); + await acceptCookieBanner(page); + + const loginButton = page.getByRole('button', { name: 'Log in' }); + + if (!(await loginButton.isVisible({ timeout: 5000 }).catch(() => false))) { + return; + } + + await loginButton.click(); + await page + .getByRole('textbox', { name: 'Email' }) + .fill(getRequiredEnv('USER_NAME')); + await page + .getByRole('textbox', { name: 'Password' }) + .fill(getRequiredEnv('PASSWORD')); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect( + page + .getByRole('link', { name: /profile/i }) + .or(page.getByRole('button', { name: 'Profile settings' })), + ).toBeVisible({ timeout: 20000 }); +}; + +const removeSocialLink = async (page: Page, url: string): Promise => { + await page.goto('/settings/profile'); + await expect(page.getByRole('textbox', { name: 'Add link' })).toBeVisible(); + + const linkRow = page.locator('div', { hasText: url }).filter({ + has: page.getByRole('button', { name: 'Remove link' }), + }); + + if ((await linkRow.count()) === 0) { + return; + } + + await linkRow.first().getByRole('button', { name: 'Remove link' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForURL( + (currentUrl) => !currentUrl.pathname.startsWith('/settings/profile'), + { + timeout: 20000, + }, + ); +}; + +test.skip( + !process.env.USER_NAME || !process.env.PASSWORD, + 'Credentials are required', +); +test.skip( + ({ browserName, isMobile }) => browserName !== 'chromium' || isMobile, + 'Run the mutating profile regression once', +); + +test('persists a pasted GitHub link when saving without clicking Add', async ({ + page, +}) => { + const handle = `dailydev-e2e-${Date.now()}`; + const typedUrl = `github.com/${handle}`; + const savedUrl = `https://github.com/${handle}`; + const savedLink = page.locator( + `[data-testid="social-link-github"][href="${savedUrl}"]`, + ); + + await login(page); + + try { + await page.goto('/settings/profile'); + await page.getByRole('textbox', { name: 'Add link' }).fill(typedUrl); + await page.getByRole('button', { name: 'Save' }).click(); + + await page.waitForURL( + (currentUrl) => !currentUrl.pathname.startsWith('/settings/profile'), + { timeout: 20000 }, + ); + await expect(savedLink).toBeVisible(); + + await page.reload(); + await expect(savedLink).toBeVisible(); + } finally { + await removeSocialLink(page, savedUrl); + } +}); diff --git a/packages/shared/src/components/profile/SocialLinksInput.spec.tsx b/packages/shared/src/components/profile/SocialLinksInput.spec.tsx new file mode 100644 index 00000000000..b325dd3559b --- /dev/null +++ b/packages/shared/src/components/profile/SocialLinksInput.spec.tsx @@ -0,0 +1,100 @@ +import React, { useRef } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm } from 'react-hook-form'; +import type { UserSocialLink } from '../../lib/user'; +import { + SocialLinksInput, + type SocialLinksInputHandle, +} from './SocialLinksInput'; + +const mockDisplayToast = jest.fn(); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +type FormValues = { + socialLinks: UserSocialLink[]; +}; + +const TestForm = ({ onSubmit }: { onSubmit: (values: FormValues) => void }) => { + const methods = useForm({ + defaultValues: { + socialLinks: [], + }, + }); + const socialLinksRef = useRef(null); + + const handleSubmit = methods.handleSubmit(() => { + if (socialLinksRef.current && !socialLinksRef.current.flushPendingUrl()) { + return; + } + + onSubmit(methods.getValues()); + }); + + return ( + +
+ + + +
+ ); +}; + +describe('SocialLinksInput', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('commits pending text before submitting', async () => { + const onSubmit = jest.fn(); + render(); + + await userEvent.type( + screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'), + 'github.com/testuser', + ); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + socialLinks: [ + { + platform: 'github', + url: 'https://github.com/testuser', + }, + ], + }), + ); + }); + + it('blocks submit and renders an inline error for invalid pending text', async () => { + const onSubmit = jest.fn(); + render(); + + await userEvent.type( + screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'), + '://', + ); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit).not.toHaveBeenCalled(); + await screen.findByText('Please enter a valid URL'); + }); + + it('commits pending text on blur', async () => { + const onSubmit = jest.fn(); + render(); + + const input = screen.getByPlaceholderText( + 'Paste a URL (e.g., github.com/username)', + ); + await userEvent.type(input, 'github.com/testuser'); + await userEvent.tab(); + + await screen.findByText('https://github.com/testuser'); + }); +}); diff --git a/packages/shared/src/components/profile/SocialLinksInput.tsx b/packages/shared/src/components/profile/SocialLinksInput.tsx index 1d8302a9295..2d633da1d64 100644 --- a/packages/shared/src/components/profile/SocialLinksInput.tsx +++ b/packages/shared/src/components/profile/SocialLinksInput.tsx @@ -1,5 +1,12 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import type { ReactElement } from 'react'; +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import type { ForwardedRef, ReactElement } from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { TextField } from '../fields/TextField'; import { Typography, TypographyType } from '../typography/Typography'; @@ -22,6 +29,28 @@ export interface SocialLinksInputProps { hint?: string; } +export interface SocialLinksInputHandle { + flushPendingUrl: () => boolean; +} + +export const normalizeSocialLinkUrl = (rawUrl: string): string | null => { + const trimmedUrl = rawUrl.trim(); + + if (!trimmedUrl) { + return null; + } + + try { + const parsedUrl = new URL( + /^https?:\/\//i.test(trimmedUrl) ? trimmedUrl : `https://${trimmedUrl}`, + ); + + return parsedUrl.href.replace(/\/$/, ''); + } catch { + return null; + } +}; + /** * Get display info for a social link */ @@ -35,14 +64,17 @@ const getSocialLinkDisplay = (link: UserSocialLink): SocialLinkDisplay => { }; }; -export function SocialLinksInput({ - name, - label = 'Links', - hint = "Paste any URL and we'll auto-detect the platform", -}: SocialLinksInputProps): ReactElement { - const { control } = useFormContext(); +function SocialLinksInputComponent( + { + name, + label = 'Links', + hint = "Paste any URL and we'll auto-detect the platform", + }: SocialLinksInputProps, + ref: ForwardedRef, +): ReactElement { + const { clearErrors, control, setError } = useFormContext(); const { - field: { value = [], onChange }, + field: { value = [], onBlur, onChange }, fieldState: { error }, } = useController({ name, @@ -51,9 +83,13 @@ export function SocialLinksInput({ }); const [url, setUrl] = useState(''); + const pendingUrlRef = useRef(''); + const linksRef = useRef([]); + const skipBlurCommitRef = useRef(false); const { displayToast } = useToastNotification(); const links: UserSocialLink[] = useMemo(() => value || [], [value]); + linksRef.current = links; // Detect platform as user types const detectedPlatform = detectUserPlatform(url); @@ -61,55 +97,90 @@ export function SocialLinksInput({ ? PLATFORM_LABELS[detectedPlatform] : null; + const updateUrl = useCallback( + (nextUrl: string) => { + pendingUrlRef.current = nextUrl; + setUrl(nextUrl); + clearErrors(name); + }, + [clearErrors, name], + ); + const handleUrlChange = (e: React.ChangeEvent) => { - setUrl(e.target.value); + updateUrl(e.target.value); }; - const handleAdd = useCallback(() => { - const trimmedUrl = url.trim(); - if (!trimmedUrl) { - return; - } + const commitPendingUrl = useCallback( + ({ allowDuplicate = false } = {}) => { + const trimmedUrl = pendingUrlRef.current.trim(); + + if (!trimmedUrl) { + clearErrors(name); + return true; + } + + const normalizedUrl = normalizeSocialLinkUrl(trimmedUrl); + if (!normalizedUrl) { + setError(name, { + type: 'manual', + message: 'Please enter a valid URL', + }); + return false; + } - // Basic URL validation - try { - const parsedUrl = new URL( - trimmedUrl.startsWith('http') ? trimmedUrl : `https://${trimmedUrl}`, + const currentLinks = linksRef.current; + const isDuplicate = currentLinks.some( + (link) => + link.url.toLowerCase().replace(/\/$/, '') === + normalizedUrl.toLowerCase(), ); - // Normalize URL by removing trailing slash for consistency - const normalizedUrl = parsedUrl.href.replace(/\/$/, ''); - - // Check if URL already exists - if ( - links.some( - (link) => - link.url.toLowerCase().replace(/\/$/, '') === - normalizedUrl.toLowerCase(), - ) - ) { + + if (isDuplicate) { displayToast('This link has already been added'); - return; + + if (allowDuplicate) { + updateUrl(''); + } + + return allowDuplicate; } - const newLink: UserSocialLink = { - url: normalizedUrl, - platform: detectedPlatform || 'other', - }; + const platform = detectUserPlatform(trimmedUrl); + const newLinks = [ + ...currentLinks, + { + url: normalizedUrl, + platform: platform || 'other', + }, + ]; - onChange([...links, newLink]); - setUrl(''); - } catch { - displayToast('Please enter a valid URL'); - } - }, [url, detectedPlatform, links, onChange, displayToast]); + linksRef.current = newLinks; + onChange(newLinks); + updateUrl(''); + clearErrors(name); + + return true; + }, + [clearErrors, displayToast, name, onChange, setError, updateUrl], + ); + + useImperativeHandle( + ref, + () => ({ + flushPendingUrl: () => commitPendingUrl({ allowDuplicate: true }), + }), + [commitPendingUrl], + ); const handleRemove = useCallback( (index: number) => { const newLinks = [...links]; newLinks.splice(index, 1); + linksRef.current = newLinks; onChange(newLinks); + clearErrors(name); }, - [links, onChange], + [clearErrors, links, name, onChange], ); const displayLinks = useMemo(() => links.map(getSocialLinkDisplay), [links]); @@ -137,12 +208,22 @@ export function SocialLinksInput({ placeholder="Paste a URL (e.g., github.com/username)" value={url} onChange={handleUrlChange} + onBlur={() => { + onBlur(); + + if (skipBlurCommitRef.current) { + return; + } + + commitPendingUrl(); + }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); - handleAdd(); + commitPendingUrl(); } }} + valid={!error} fieldType="secondary" actionButton={ @@ -85,7 +100,7 @@ describe('SocialLinksInput', () => { await screen.findByText('Please enter a valid URL'); }); - it('commits pending text on blur', async () => { + it('does not commit pending text on blur', async () => { const onSubmit = jest.fn(); render(); @@ -95,6 +110,55 @@ describe('SocialLinksInput', () => { await userEvent.type(input, 'github.com/testuser'); await userEvent.tab(); - await screen.findByText('https://github.com/testuser'); + expect( + screen.queryByText('https://github.com/testuser'), + ).not.toBeInTheDocument(); + expect(input).toHaveValue('github.com/testuser'); + }); + + it('toasts once when submitting a duplicate of an existing link', async () => { + const onSubmit = jest.fn(); + render( + , + ); + + await userEvent.type( + screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'), + 'github.com/testuser', + ); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(mockDisplayToast).toHaveBeenCalledTimes(1); + expect(mockDisplayToast).toHaveBeenCalledWith( + 'This link has already been added', + ); + }); + + it('disables adding links while the saved links are loading', () => { + render(); + + expect( + screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'), + ).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + }); + + it('explains why links are missing when they failed to load', () => { + render(); + + expect( + screen.getByText( + 'We could not load your links. Refresh the page to try again.', + ), + ).toBeVisible(); + expect( + screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'), + ).toBeDisabled(); }); }); diff --git a/packages/shared/src/components/profile/SocialLinksInput.tsx b/packages/shared/src/components/profile/SocialLinksInput.tsx index 2d633da1d64..cb24daccd26 100644 --- a/packages/shared/src/components/profile/SocialLinksInput.tsx +++ b/packages/shared/src/components/profile/SocialLinksInput.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useImperativeHandle, useMemo, - useRef, useState, } from 'react'; import type { ForwardedRef, ReactElement } from 'react'; @@ -19,38 +18,25 @@ import { detectUserPlatform, getPlatformIcon, getPlatformLabel, + isSameSocialLinkUrl, + normalizeSocialLinkUrl, PLATFORM_LABELS, } from '../../lib/socialLink'; import { useToastNotification } from '../../hooks/useToastNotification'; +import { ElementPlaceholder } from '../ElementPlaceholder'; export interface SocialLinksInputProps { name: string; label?: string; hint?: string; + isLoading?: boolean; + isError?: boolean; } export interface SocialLinksInputHandle { flushPendingUrl: () => boolean; } -export const normalizeSocialLinkUrl = (rawUrl: string): string | null => { - const trimmedUrl = rawUrl.trim(); - - if (!trimmedUrl) { - return null; - } - - try { - const parsedUrl = new URL( - /^https?:\/\//i.test(trimmedUrl) ? trimmedUrl : `https://${trimmedUrl}`, - ); - - return parsedUrl.href.replace(/\/$/, ''); - } catch { - return null; - } -}; - /** * Get display info for a social link */ @@ -69,6 +55,8 @@ function SocialLinksInputComponent( name, label = 'Links', hint = "Paste any URL and we'll auto-detect the platform", + isLoading = false, + isError = false, }: SocialLinksInputProps, ref: ForwardedRef, ): ReactElement { @@ -83,13 +71,9 @@ function SocialLinksInputComponent( }); const [url, setUrl] = useState(''); - const pendingUrlRef = useRef(''); - const linksRef = useRef([]); - const skipBlurCommitRef = useRef(false); const { displayToast } = useToastNotification(); const links: UserSocialLink[] = useMemo(() => value || [], [value]); - linksRef.current = links; // Detect platform as user types const detectedPlatform = detectUserPlatform(url); @@ -99,7 +83,6 @@ function SocialLinksInputComponent( const updateUrl = useCallback( (nextUrl: string) => { - pendingUrlRef.current = nextUrl; setUrl(nextUrl); clearErrors(name); }, @@ -112,7 +95,7 @@ function SocialLinksInputComponent( const commitPendingUrl = useCallback( ({ allowDuplicate = false } = {}) => { - const trimmedUrl = pendingUrlRef.current.trim(); + const trimmedUrl = url.trim(); if (!trimmedUrl) { clearErrors(name); @@ -128,11 +111,8 @@ function SocialLinksInputComponent( return false; } - const currentLinks = linksRef.current; - const isDuplicate = currentLinks.some( - (link) => - link.url.toLowerCase().replace(/\/$/, '') === - normalizedUrl.toLowerCase(), + const isDuplicate = links.some((link) => + isSameSocialLinkUrl(link.url, normalizedUrl), ); if (isDuplicate) { @@ -146,22 +126,29 @@ function SocialLinksInputComponent( } const platform = detectUserPlatform(trimmedUrl); - const newLinks = [ - ...currentLinks, + + onChange([ + ...links, { url: normalizedUrl, platform: platform || 'other', }, - ]; - - linksRef.current = newLinks; - onChange(newLinks); + ]); updateUrl(''); clearErrors(name); return true; }, - [clearErrors, displayToast, name, onChange, setError, updateUrl], + [ + clearErrors, + displayToast, + links, + name, + onChange, + setError, + updateUrl, + url, + ], ); useImperativeHandle( @@ -176,7 +163,6 @@ function SocialLinksInputComponent( (index: number) => { const newLinks = [...links]; newLinks.splice(index, 1); - linksRef.current = newLinks; onChange(newLinks); clearErrors(name); }, @@ -202,21 +188,15 @@ function SocialLinksInputComponent( {/* URL input */} { - onBlur(); - - if (skipBlurCommitRef.current) { - return; - } - - commitPendingUrl(); - }} + onBlur={onBlur} + disabled={isLoading || isError} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); @@ -231,17 +211,8 @@ function SocialLinksInputComponent( variant={ButtonVariant.Secondary} size={ButtonSize.XSmall} icon={} - onMouseDown={() => { - skipBlurCommitRef.current = true; - }} - onMouseUp={() => { - skipBlurCommitRef.current = false; - }} - onClick={() => { - skipBlurCommitRef.current = false; - commitPendingUrl(); - }} - disabled={!url.trim()} + onClick={() => commitPendingUrl()} + disabled={isLoading || isError || !url.trim()} > Add @@ -258,12 +229,30 @@ function SocialLinksInputComponent( )} + {/* Loading / failed to load */} + {isLoading && ( +
+ + +
+ )} + + {isError && ( + + We could not load your links. Refresh the page to try again. + + )} + {/* Link list */} - {displayLinks.length > 0 && ( + {!isLoading && !isError && displayLinks.length > 0 && (
{displayLinks.map((link, index) => (
{/* Platform icon */} diff --git a/packages/shared/src/graphql/common.ts b/packages/shared/src/graphql/common.ts index 648da6c6d13..312d2f07207 100644 --- a/packages/shared/src/graphql/common.ts +++ b/packages/shared/src/graphql/common.ts @@ -109,6 +109,7 @@ export const isQueryKeySame = (left: QueryKey, right: QueryKey): boolean => { export enum ApiError { Forbidden = 'FORBIDDEN', + GraphqlValidationFailed = 'GRAPHQL_VALIDATION_FAILED', NotFound = 'NOT_FOUND', RateLimited = 'RATE_LIMITED', BalanceTransactionError = 'BALANCE_TRANSACTION_ERROR', diff --git a/packages/shared/src/hooks/useUserInfoForm.spec.tsx b/packages/shared/src/hooks/useUserInfoForm.spec.tsx index 8649b56a264..28902940ef8 100644 --- a/packages/shared/src/hooks/useUserInfoForm.spec.tsx +++ b/packages/shared/src/hooks/useUserInfoForm.spec.tsx @@ -4,7 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import AuthContext from '../contexts/AuthContext'; import loggedUser from '../../__tests__/fixture/loggedUser'; -import type { LoggedUser, PublicProfile } from '../lib/user'; +import type { LoggedUser, PublicProfile, UserSocialLink } from '../lib/user'; import { getProfile } from '../lib/user'; import { mutateUserInfo } from '../graphql/users'; import useUserInfoForm from './useUserInfoForm'; @@ -44,6 +44,10 @@ const mockMutateUserInfo = mutateUserInfo as jest.MockedFunction< typeof mutateUserInfo >; +const serverLinks: UserSocialLink[] = [ + { platform: 'github', url: 'https://github.com/server' }, +]; + const profile: PublicProfile = { id: loggedUser.id, name: loggedUser.name, @@ -54,7 +58,7 @@ const profile: PublicProfile = { permalink: loggedUser.permalink, premium: false, reputation: 0, - socialLinks: [{ platform: 'github', url: 'https://github.com/server' }], + socialLinks: serverLinks, }; const renderUserInfoForm = (user: LoggedUser = loggedUser) => { @@ -133,7 +137,7 @@ describe('useUserInfoForm', () => { ); }); - it('keeps a social link added while the profile query is in flight', async () => { + it('merges a link added while the profile query is in flight with the server links', async () => { const deferred = createDeferred(); mockGetProfile.mockReturnValue(deferred.promise); @@ -159,11 +163,125 @@ describe('useUserInfoForm', () => { await waitFor(() => expect(result.current.methods.getValues('socialLinks')).toEqual([ + ...serverLinks, pendingLink, ]), ); }); + it('does not duplicate a link the server already had', async () => { + const deferred = createDeferred(); + mockGetProfile.mockReturnValue(deferred.promise); + + const { result } = renderUserInfoForm({ + ...loggedUser, + socialLinks: undefined, + }); + + act(() => { + result.current.methods.setValue( + 'socialLinks', + [{ platform: 'github', url: 'https://github.com/Server/' }], + { shouldDirty: true }, + ); + }); + + await act(async () => { + deferred.resolve(profile); + await deferred.promise; + }); + + await waitFor(() => + expect(result.current.methods.getValues('socialLinks')).toEqual( + serverLinks, + ), + ); + }); + + it('omits socialLinks when the profile query never resolves, even if edited', async () => { + mockGetProfile.mockReturnValue(new Promise(() => undefined)); + mockMutateUserInfo.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderUserInfoForm({ + ...loggedUser, + socialLinks: undefined, + }); + + act(() => { + result.current.methods.setValue( + 'socialLinks', + [{ platform: 'github', url: 'https://github.com/pending' }], + { shouldDirty: true }, + ); + }); + + act(() => { + result.current.save(); + }); + + await waitFor(() => expect(mockMutateUserInfo).toHaveBeenCalled()); + expect(mockMutateUserInfo.mock.calls[0][0]).not.toHaveProperty( + 'socialLinks', + ); + }); + + it('reports the links as loading until the profile query resolves', async () => { + const deferred = createDeferred(); + mockGetProfile.mockReturnValue(deferred.promise); + + const { result } = renderUserInfoForm({ + ...loggedUser, + socialLinks: undefined, + }); + + expect(result.current.isSocialLinksLoading).toBe(true); + expect(result.current.isSocialLinksError).toBe(false); + + await act(async () => { + deferred.resolve(profile); + await deferred.promise; + }); + + await waitFor(() => + expect(result.current.isSocialLinksLoading).toBe(false), + ); + }); + + it('reports an error when the profile query fails', async () => { + mockGetProfile.mockRejectedValue(new Error('offline')); + + const { result } = renderUserInfoForm({ + ...loggedUser, + socialLinks: undefined, + }); + + await waitFor(() => expect(result.current.isSocialLinksError).toBe(true)); + expect(result.current.isSocialLinksLoading).toBe(false); + }); + + it('surfaces a validation error message instead of the generic toast', async () => { + mockMutateUserInfo.mockRejectedValue({ + response: { + errors: [ + { + message: 'Invalid URL', + extensions: { code: 'GRAPHQL_VALIDATION_FAILED' }, + }, + ], + }, + }); + + const { result } = renderUserInfoForm(); + + act(() => { + result.current.save(); + }); + + await waitFor(() => + expect(mockDisplayToast).toHaveBeenCalledWith('Invalid URL'), + ); + }); + it('shows a fallback toast for non-JSON mutation errors', async () => { mockMutateUserInfo.mockRejectedValue({ response: { diff --git a/packages/shared/src/hooks/useUserInfoForm.ts b/packages/shared/src/hooks/useUserInfoForm.ts index cfab4818099..60e9693289f 100644 --- a/packages/shared/src/hooks/useUserInfoForm.ts +++ b/packages/shared/src/hooks/useUserInfoForm.ts @@ -9,11 +9,13 @@ import type { LoggedUser, PublicProfile, UserProfile } from '../lib/user'; import { getProfile } from '../lib/user'; import { useToastNotification } from './useToastNotification'; import type { ResponseError } from '../graphql/common'; +import { ApiError } from '../graphql/common'; import { useDirtyForm } from './useDirtyForm'; import { useLogContext } from '../contexts/LogContext'; import { LogEvent } from '../lib/log'; import { generateQueryKey, RequestKey, StaleTime } from '../lib/query'; import { disabledRefetch } from '../lib/func'; +import { isSameSocialLinkUrl } from '../lib/socialLink'; export interface ProfileFormHint { [key: string]: string; @@ -28,6 +30,8 @@ interface UseUserInfoForm { methods: UseFormReturn; save: () => void; isLoading: boolean; + isSocialLinksLoading: boolean; + isSocialLinksError: boolean; } const renderedProfileFields = new Set([ @@ -78,7 +82,7 @@ const useUserInfoForm = (): UseUserInfoForm => { const userQueryKey = generateQueryKey(RequestKey.Profile, user, { id: userId, }); - const { data: fullProfile } = useQuery({ + const { data: fullProfile, isError: isProfileError } = useQuery({ queryKey: userQueryKey, queryFn: () => getProfile(userId), ...disabledRefetch, @@ -86,6 +90,11 @@ const useUserInfoForm = (): UseUserInfoForm => { enabled: !!userId, }); + // Boot omits socialLinks, so until the profile query lands the form has no + // idea which links the server already holds. + const hasInitializedSocialLinks = + !!fullProfile || Array.isArray(user?.socialLinks); + useEffect(() => { const searchParams = new URLSearchParams(window?.location?.search); const field = searchParams?.get('field'); @@ -117,11 +126,26 @@ const useUserInfoForm = (): UseUserInfoForm => { return; } + const serverLinks = fullProfile.socialLinks || []; + if (!methods.getFieldState('socialLinks').isDirty) { - methods.resetField('socialLinks', { - defaultValue: fullProfile.socialLinks || [], - }); + methods.resetField('socialLinks', { defaultValue: serverLinks }); + return; } + + // Links edited before the query resolved only hold what was added locally, + // so saving them as-is would drop every link already on the server. + const localLinks = methods.getValues('socialLinks') || []; + const addedLinks = localLinks.filter( + (local) => + !serverLinks.some((server) => + isSameSocialLinkUrl(server.url, local.url), + ), + ); + + methods.setValue('socialLinks', [...serverLinks, ...addedLinks], { + shouldDirty: true, + }); }, [fullProfile, methods]); const dirtyFormRef = useRef | null>(null); @@ -155,11 +179,21 @@ const useUserInfoForm = (): UseUserInfoForm => { }, onError: (err) => { - const errorMessage = err?.response?.errors?.[0]?.message; + const [responseError] = err?.response?.errors || []; + const errorMessage = responseError?.message; const data = parseProfileFormHint(errorMessage); if (!data) { - displayToast('Failed to update profile'); + // Validation errors carry a message written for the user (a blocked + // social link URL, for one); anything else is internal noise. + const isValidationError = + responseError?.extensions?.code === ApiError.GraphqlValidationFailed; + + displayToast( + isValidationError && errorMessage + ? errorMessage + : 'Failed to update profile', + ); return; } @@ -186,17 +220,14 @@ const useUserInfoForm = (): UseUserInfoForm => { const getProfileUpdatePayload = useCallback((): UpdateProfileParameters => { const formData = methods.getValues(); - const socialLinksTouched = methods.getFieldState('socialLinks').isDirty; - const hasInitializedSocialLinks = - !!fullProfile || Array.isArray(user?.socialLinks); - if (!hasInitializedSocialLinks && !socialLinksTouched) { + if (!hasInitializedSocialLinks) { const { socialLinks, ...payload } = formData; return payload; } return formData; - }, [fullProfile, methods, user?.socialLinks]); + }, [hasInitializedSocialLinks, methods]); const dirtyForm = useDirtyForm(methods.formState.isDirty, { onSave: () => { @@ -213,6 +244,8 @@ const useUserInfoForm = (): UseUserInfoForm => { methods, save: dirtyForm.save, isLoading, + isSocialLinksLoading: !hasInitializedSocialLinks && !isProfileError, + isSocialLinksError: !hasInitializedSocialLinks && isProfileError, }; }; diff --git a/packages/shared/src/lib/socialLink.tsx b/packages/shared/src/lib/socialLink.tsx index b6394fb77de..9c208bcbf84 100644 --- a/packages/shared/src/lib/socialLink.tsx +++ b/packages/shared/src/lib/socialLink.tsx @@ -8,6 +8,7 @@ import { getPlatformIconElement, getPlatformLabel as getGenericPlatformLabel, } from './platforms'; +import { withHttps } from './links'; // Re-export types for backward compatibility export type { UserPlatformId as SocialPlatform } from './platforms'; @@ -76,3 +77,28 @@ export const getUserSocialLinks = ( ): SocialLinkDisplay[] => { return mapSocialLinksToDisplay(user.socialLinks || [], iconSize); }; + +/** + * Normalize a user-typed social link into a comparable, storable URL. + * Returns null when the text cannot be parsed as a URL. + */ +export const normalizeSocialLinkUrl = (rawUrl: string): string | null => { + const trimmedUrl = rawUrl.trim(); + + if (!trimmedUrl) { + return null; + } + + try { + return new URL(withHttps(trimmedUrl)).href.replace(/\/$/, ''); + } catch { + return null; + } +}; + +/** + * Compare two social link URLs ignoring case and a trailing slash, so the same + * link pasted twice (or added locally and returned by the server) matches. + */ +export const isSameSocialLinkUrl = (a: string, b: string): boolean => + a.toLowerCase().replace(/\/$/, '') === b.toLowerCase().replace(/\/$/, ''); diff --git a/packages/webapp/components/layouts/SettingsLayout/Profile/index.tsx b/packages/webapp/components/layouts/SettingsLayout/Profile/index.tsx index 1702130e13e..780d4e79214 100644 --- a/packages/webapp/components/layouts/SettingsLayout/Profile/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/Profile/index.tsx @@ -37,7 +37,8 @@ const Section = classed('section', 'flex flex-col gap-7'); const ProfileIndex = (): ReactElement => { const { user } = useContext(AuthContext); - const { methods, save, isLoading } = useUserInfoForm(); + const { methods, save, isLoading, isSocialLinksLoading, isSocialLinksError } = + useUserInfoForm(); const socialLinksRef = useRef(null); const handleSubmit = methods.handleSubmit(() => { @@ -143,6 +144,8 @@ const ProfileIndex = (): ReactElement => { name="socialLinks" label="Links" hint="Paste any URL and we'll auto-detect the platform" + isLoading={isSocialLinksLoading} + isError={isSocialLinksError} />
From 03fd14750af4a1519b6549e224dee5bc2ca1b17a Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 17 Sep 2026 12:21:05 +0200 Subject: [PATCH 3/3] refactor(profile): share the profile form hint type and parser Review follow-ups on conventions and duplication. ProfileFormHint existed twice with diverging shapes, and useProfileForm still ran JSON.parse on the raw error message, so the plain-string ValidationErrors handled in useUserInfoForm threw there instead. The type and a null-returning parser now live next to the mutations in graphql/users.ts, and both hooks plus SocialRegistrationForm consume them. renderedProfileFields hardcoded in a shared hook which fields the webapp settings page happens to render, so adding a field there silently moved its API error between inline and toast. The form already knows its own fields. Dropped the three why-comments this branch added to useUserInfoForm and trimmed the new ones in graphql/users.ts to the density around them; the reasoning is in the commit messages and the PR description. The e2e cleanup could not see a saved link while the links section was still loading, which would leak a dailydev-e2e row onto the shared account, so it waits for the section to settle first and the trade-off is written down. Also reverted the unrelated quote churn in playwright/tests/helpers.ts, from running Prettier without the package's config. Co-Authored-By: Claude Opus 5 (1M context) --- packages/playwright/tests/helpers.ts | 38 ++++++------- .../tests/profile-social-links.spec.ts | 56 ++++++++++++------- .../auth/SocialRegistrationForm.tsx | 2 +- packages/shared/src/graphql/users.spec.ts | 36 +++++++++++- packages/shared/src/graphql/users.ts | 32 +++++++++++ packages/shared/src/hooks/useProfileForm.ts | 18 +++--- packages/shared/src/hooks/useUserInfoForm.ts | 55 +++--------------- 7 files changed, 138 insertions(+), 99 deletions(-) diff --git a/packages/playwright/tests/helpers.ts b/packages/playwright/tests/helpers.ts index 63e3341e24e..41233ad57d3 100644 --- a/packages/playwright/tests/helpers.ts +++ b/packages/playwright/tests/helpers.ts @@ -1,20 +1,20 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Page } from '@playwright/test'; export const TRACKING_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10; export const extractRootDomain = (hostname: string): string => { - const host = hostname.split(":")[0]; - if (host === "127.0.0.1") { + const host = hostname.split(':')[0]; + if (host === '127.0.0.1') { return host; } - const parts = host.split("."); + const parts = host.split('.'); while (parts.length > 2) { parts.shift(); } - return parts.join("."); + return parts.join('.'); }; -export const getRequiredEnv = (name: "USER_NAME" | "PASSWORD"): string => { +export const getRequiredEnv = (name: 'USER_NAME' | 'PASSWORD'): string => { const value = process.env[name]; if (!value) { @@ -26,17 +26,17 @@ export const getRequiredEnv = (name: "USER_NAME" | "PASSWORD"): string => { export const acceptCookieBanner = async (page: Page): Promise => { await page - .getByRole("button", { name: "Accept all" }) - .or(page.getByRole("button", { name: "I understand" })) + .getByRole('button', { name: 'Accept all' }) + .or(page.getByRole('button', { name: 'I understand' })) .click({ timeout: 5000 }) .catch(() => undefined); }; export const login = async (page: Page): Promise => { - await page.goto("/"); + await page.goto('/'); await acceptCookieBanner(page); - const loginButton = page.getByRole("button", { name: "Log in" }); + const loginButton = page.getByRole('button', { name: 'Log in' }); if (!(await loginButton.isVisible({ timeout: 5000 }).catch(() => false))) { return; @@ -45,21 +45,21 @@ export const login = async (page: Page): Promise => { await loginButton.click(); // Scope the submit to the auth form, the page header keeps its own "Log in". - const loginForm = page.locator("form").filter({ - has: page.getByRole("textbox", { name: "Password" }), + const loginForm = page.locator('form').filter({ + has: page.getByRole('textbox', { name: 'Password' }), }); await loginForm - .getByRole("textbox", { name: "Email" }) - .fill(getRequiredEnv("USER_NAME")); + .getByRole('textbox', { name: 'Email' }) + .fill(getRequiredEnv('USER_NAME')); await loginForm - .getByRole("textbox", { name: "Password" }) - .fill(getRequiredEnv("PASSWORD")); - await loginForm.getByRole("button", { name: "Log in" }).click(); + .getByRole('textbox', { name: 'Password' }) + .fill(getRequiredEnv('PASSWORD')); + await loginForm.getByRole('button', { name: 'Log in' }).click(); await expect( page - .getByRole("link", { name: /profile/i }) - .or(page.getByRole("button", { name: "Profile settings" })) + .getByRole('link', { name: /profile/i }) + .or(page.getByRole('button', { name: 'Profile settings' })), ).toBeVisible({ timeout: 20000 }); }; diff --git a/packages/playwright/tests/profile-social-links.spec.ts b/packages/playwright/tests/profile-social-links.spec.ts index 933faebfc65..cc6fdeed5b1 100644 --- a/packages/playwright/tests/profile-social-links.spec.ts +++ b/packages/playwright/tests/profile-social-links.spec.ts @@ -1,55 +1,69 @@ -import { test, expect, type Page } from "@playwright/test"; -import { login } from "./helpers"; +import { test, expect, type Page } from '@playwright/test'; +import { login } from './helpers'; + +/** + * This is the only spec in the package that writes to the shared CI account, + * and it runs against live production. Cleanup is best effort: if Save itself + * fails the link was never stored, and if it stored but the redirect did not + * happen the row is still removed below. A hard failure mid-cleanup leaves one + * `dailydev-e2e-*` link behind for a maintainer to delete. + */ +const openProfileSettings = async (page: Page): Promise => { + await page.goto('/settings/profile'); + + // The links section stays disabled until the profile query settles, so the + // list is only trustworthy once the input is enabled. + await expect(page.getByRole('textbox', { name: 'Add link' })).toBeEnabled({ + timeout: 20000, + }); +}; const removeSocialLink = async (page: Page, url: string): Promise => { - await page.goto("/settings/profile"); - await expect(page.getByRole("textbox", { name: "Add link" })).toBeVisible(); + await openProfileSettings(page); - const linkRow = page.getByTestId("social-link-row").filter({ hasText: url }); + const linkRow = page.getByTestId('social-link-row').filter({ hasText: url }); if ((await linkRow.count()) === 0) { return; } - await linkRow.getByRole("button", { name: "Remove link" }).click(); - await page.getByRole("button", { name: "Save" }).click(); + await linkRow.getByRole('button', { name: 'Remove link' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); await page.waitForURL( - (currentUrl) => !currentUrl.pathname.startsWith("/settings/profile"), - { - timeout: 20000, - } + (currentUrl) => !currentUrl.pathname.startsWith('/settings/profile'), + { timeout: 20000 }, ); }; test.skip( !process.env.USER_NAME || !process.env.PASSWORD, - "Credentials are required" + 'Credentials are required', ); test.skip( - ({ browserName, isMobile }) => browserName !== "chromium" || isMobile, - "Run the mutating profile regression once" + ({ browserName, isMobile }) => browserName !== 'chromium' || isMobile, + 'Run the mutating profile regression once', ); -test("persists a pasted GitHub link when saving without clicking Add", async ({ +test('persists a pasted GitHub link when saving without clicking Add', async ({ page, }) => { const handle = `dailydev-e2e-${Date.now()}`; const typedUrl = `github.com/${handle}`; const savedUrl = `https://github.com/${handle}`; const savedLink = page.locator( - `[data-testid="social-link-github"][href="${savedUrl}"]` + `[data-testid="social-link-github"][href="${savedUrl}"]`, ); await login(page); try { - await page.goto("/settings/profile"); - await page.getByRole("textbox", { name: "Add link" }).fill(typedUrl); - await page.getByRole("button", { name: "Save" }).click(); + await openProfileSettings(page); + await page.getByRole('textbox', { name: 'Add link' }).fill(typedUrl); + await page.getByRole('button', { name: 'Save' }).click(); await page.waitForURL( - (currentUrl) => !currentUrl.pathname.startsWith("/settings/profile"), - { timeout: 20000 } + (currentUrl) => !currentUrl.pathname.startsWith('/settings/profile'), + { timeout: 20000 }, ); await expect(savedLink).toBeVisible(); diff --git a/packages/shared/src/components/auth/SocialRegistrationForm.tsx b/packages/shared/src/components/auth/SocialRegistrationForm.tsx index b4425bae994..1198373934d 100644 --- a/packages/shared/src/components/auth/SocialRegistrationForm.tsx +++ b/packages/shared/src/components/auth/SocialRegistrationForm.tsx @@ -12,7 +12,7 @@ import AuthHeader from './AuthHeader'; import type { AuthFormProps } from './common'; import { providerMap, SocialProvider } from './common'; import AuthContext from '../../contexts/AuthContext'; -import type { ProfileFormHint } from '../../hooks/useProfileForm'; +import type { ProfileFormHint } from '../../graphql/users'; import { Checkbox } from '../fields/Checkbox'; import { useLogContext } from '../../contexts/LogContext'; import AuthForm from './AuthForm'; diff --git a/packages/shared/src/graphql/users.spec.ts b/packages/shared/src/graphql/users.spec.ts index 2c2049591af..7eeca181d02 100644 --- a/packages/shared/src/graphql/users.spec.ts +++ b/packages/shared/src/graphql/users.spec.ts @@ -1,4 +1,8 @@ -import { TOP_READER_BADGE, TOP_READER_BADGE_BY_ID } from './users'; +import { + parseProfileFormHint, + TOP_READER_BADGE, + TOP_READER_BADGE_BY_ID, +} from './users'; describe('top reader badge queries', () => { it('includes the badge owner in the list query', () => { @@ -19,3 +23,33 @@ describe('top reader badge queries', () => { expect(TOP_READER_BADGE_BY_ID).toContain('image'); }); }); + +describe('parseProfileFormHint', () => { + it('parses a field-keyed hint', () => { + expect( + parseProfileFormHint(JSON.stringify({ github: 'github already exists' })), + ).toEqual({ github: 'github already exists' }); + }); + + it('returns null for a plain-string validation error', () => { + expect(parseProfileFormHint('Invalid URL')).toBeNull(); + }); + + it('returns null for a raw database error', () => { + expect( + parseProfileFormHint('value too long for type character varying(39)'), + ).toBeNull(); + }); + + it('returns null for a missing message, an array or a bare value', () => { + expect(parseProfileFormHint()).toBeNull(); + expect(parseProfileFormHint('["github"]')).toBeNull(); + expect(parseProfileFormHint('42')).toBeNull(); + }); + + it('drops non-string values instead of passing them through', () => { + expect(parseProfileFormHint('{"github":"taken","count":2}')).toEqual({ + github: 'taken', + }); + }); +}); diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index 570dd2ddd07..f54f155ce64 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -419,6 +419,38 @@ export const UPDATE_USER_INFO_MUTATION = gql` } `; +// Field-keyed hints the profile mutations return as a JSON-encoded error message +export interface ProfileFormHint extends Record { + username?: string; + name?: string; +} + +// Null unless the message parses into an object of strings; the same mutations +// also throw plain-string ValidationErrors +export const parseProfileFormHint = ( + message?: string, +): ProfileFormHint | null => { + if (!message) { + return null; + } + + try { + const parsed = JSON.parse(message); + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ); + } catch { + return null; + } +}; + export const mutateUserInfo = async ( data: Partial, upload?: File | null, diff --git a/packages/shared/src/hooks/useProfileForm.ts b/packages/shared/src/hooks/useProfileForm.ts index 25bf16b901c..d511d3a580c 100644 --- a/packages/shared/src/hooks/useProfileForm.ts +++ b/packages/shared/src/hooks/useProfileForm.ts @@ -2,16 +2,16 @@ import { useContext, useState } from 'react'; import type { UseMutateFunction } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query'; import AuthContext from '../contexts/AuthContext'; -import { handleRegex, UPDATE_USER_PROFILE_MUTATION } from '../graphql/users'; +import type { ProfileFormHint } from '../graphql/users'; +import { + handleRegex, + parseProfileFormHint, + UPDATE_USER_PROFILE_MUTATION, +} from '../graphql/users'; import type { LoggedUser, UserFlagsPublic, UserProfile } from '../lib/user'; import type { ResponseError } from '../graphql/common'; import { errorMessage, gqlClient } from '../graphql/common'; -export interface ProfileFormHint { - username?: string; - name?: string; -} - export interface UpdateProfileParameters extends Partial { upload?: File; coverUpload?: File; @@ -107,12 +107,12 @@ const useProfileForm = ({ return; } - const firstError = err.response.errors[0]; - if (!firstError?.message) { + const data = parseProfileFormHint(err.response.errors[0]?.message); + + if (!data) { return; } - const data: ProfileFormHint = JSON.parse(firstError.message); setHint(data); }, }); diff --git a/packages/shared/src/hooks/useUserInfoForm.ts b/packages/shared/src/hooks/useUserInfoForm.ts index 60e9693289f..83e8c526fdc 100644 --- a/packages/shared/src/hooks/useUserInfoForm.ts +++ b/packages/shared/src/hooks/useUserInfoForm.ts @@ -4,7 +4,7 @@ import type { UseFormReturn } from 'react-hook-form'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/router'; import AuthContext from '../contexts/AuthContext'; -import { mutateUserInfo } from '../graphql/users'; +import { mutateUserInfo, parseProfileFormHint } from '../graphql/users'; import type { LoggedUser, PublicProfile, UserProfile } from '../lib/user'; import { getProfile } from '../lib/user'; import { useToastNotification } from './useToastNotification'; @@ -17,10 +17,6 @@ import { generateQueryKey, RequestKey, StaleTime } from '../lib/query'; import { disabledRefetch } from '../lib/func'; import { isSameSocialLinkUrl } from '../lib/socialLink'; -export interface ProfileFormHint { - [key: string]: string; -} - export type UpdateProfileParameters = Partial & { upload?: File; coverUpload?: File; @@ -34,42 +30,6 @@ interface UseUserInfoForm { isSocialLinksError: boolean; } -const renderedProfileFields = new Set([ - 'bio', - 'experienceLevel', - 'externalLocationId', - 'hideExperience', - 'name', - 'readme', - 'socialLinks', - 'username', -]); - -const isRenderedProfileField = (key: string): key is keyof UserProfile => - renderedProfileFields.has(key as keyof UserProfile); - -const parseProfileFormHint = (message?: string): ProfileFormHint | null => { - if (!message) { - return null; - } - - try { - const parsed = JSON.parse(message); - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null; - } - - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string', - ), - ); - } catch { - return null; - } -}; - const useUserInfoForm = (): UseUserInfoForm => { const qc = useQueryClient(); const { user, updateUser } = useContext(AuthContext); @@ -90,8 +50,6 @@ const useUserInfoForm = (): UseUserInfoForm => { enabled: !!userId, }); - // Boot omits socialLinks, so until the profile query lands the form has no - // idea which links the server already holds. const hasInitializedSocialLinks = !!fullProfile || Array.isArray(user?.socialLinks); @@ -133,8 +91,6 @@ const useUserInfoForm = (): UseUserInfoForm => { return; } - // Links edited before the query resolved only hold what was added locally, - // so saving them as-is would drop every link already on the server. const localLinks = methods.getValues('socialLinks') || []; const addedLinks = localLinks.filter( (local) => @@ -184,8 +140,6 @@ const useUserInfoForm = (): UseUserInfoForm => { const data = parseProfileFormHint(errorMessage); if (!data) { - // Validation errors carry a message written for the user (a blocked - // social link URL, for one); anything else is internal noise. const isValidationError = responseError?.extensions?.code === ApiError.GraphqlValidationFailed; @@ -198,9 +152,14 @@ const useUserInfoForm = (): UseUserInfoForm => { } const toastMessages: string[] = []; + const formFields = methods.getValues(); Object.entries(data).forEach(([key, value]) => { - if (isRenderedProfileField(key)) { + if (!value) { + return; + } + + if (key in formFields) { methods.setError(key as keyof UserProfile, { type: 'manual', message: value,