diff --git a/packages/playwright/tests/helpers.ts b/packages/playwright/tests/helpers.ts index 15b0265c42d..41233ad57d3 100644 --- a/packages/playwright/tests/helpers.ts +++ b/packages/playwright/tests/helpers.ts @@ -1,3 +1,5 @@ +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 => { @@ -11,3 +13,53 @@ export const extractRootDomain = (hostname: string): string => { } return parts.join('.'); }; + +export const getRequiredEnv = (name: 'USER_NAME' | 'PASSWORD'): string => { + const value = process.env[name]; + + if (!value) { + throw new Error(`${name} environment variable is required`); + } + + return value; +}; + +export 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); +}; + +export 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(); + + // 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' }), + }); + + await loginForm + .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(); + + await expect( + page + .getByRole('link', { name: /profile/i }) + .or(page.getByRole('button', { name: 'Profile settings' })), + ).toBeVisible({ timeout: 20000 }); +}; diff --git a/packages/playwright/tests/login.spec.ts b/packages/playwright/tests/login.spec.ts index 333c33adb58..cb2faf323b7 100644 --- a/packages/playwright/tests/login.spec.ts +++ b/packages/playwright/tests/login.spec.ts @@ -1,14 +1,5 @@ import { test, expect } 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; -}; +import { getRequiredEnv } from './helpers'; test.describe.skip('Daily.dev Homepage', () => { test('should load the homepage successfully', async ({ page }) => { 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..cc6fdeed5b1 --- /dev/null +++ b/packages/playwright/tests/profile-social-links.spec.ts @@ -0,0 +1,75 @@ +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 openProfileSettings(page); + + 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 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 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 }, + ); + await expect(savedLink).toBeVisible(); + + await page.reload(); + await expect(savedLink).toBeVisible(); + } finally { + await removeSocialLink(page, savedUrl); + } +}); 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/components/profile/SocialLinksInput.spec.tsx b/packages/shared/src/components/profile/SocialLinksInput.spec.tsx new file mode 100644 index 00000000000..c9c8714e532 --- /dev/null +++ b/packages/shared/src/components/profile/SocialLinksInput.spec.tsx @@ -0,0 +1,164 @@ +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 = ({ + defaultLinks = [], + isError = false, + isLoading = false, + onSubmit, +}: { + defaultLinks?: UserSocialLink[]; + isError?: boolean; + isLoading?: boolean; + onSubmit: (values: FormValues) => void; +}) => { + const methods = useForm({ + defaultValues: { + socialLinks: defaultLinks, + }, + }); + 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('does not commit 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(); + + 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 1d8302a9295..cb24daccd26 100644 --- a/packages/shared/src/components/profile/SocialLinksInput.tsx +++ b/packages/shared/src/components/profile/SocialLinksInput.tsx @@ -1,5 +1,11 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import type { ReactElement } from 'react'; +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + 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'; @@ -12,14 +18,23 @@ 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; } /** @@ -35,14 +50,19 @@ 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", + isLoading = false, + isError = false, + }: SocialLinksInputProps, + ref: ForwardedRef, +): ReactElement { + const { clearErrors, control, setError } = useFormContext(); const { - field: { value = [], onChange }, + field: { value = [], onBlur, onChange }, fieldState: { error }, } = useController({ name, @@ -61,55 +81,92 @@ export function SocialLinksInput({ ? PLATFORM_LABELS[detectedPlatform] : null; + const updateUrl = useCallback( + (nextUrl: string) => { + 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 = url.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 isDuplicate = links.some((link) => + isSameSocialLinkUrl(link.url, normalizedUrl), ); - // 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); - onChange([...links, newLink]); - setUrl(''); - } catch { - displayToast('Please enter a valid URL'); - } - }, [url, detectedPlatform, links, onChange, displayToast]); + onChange([ + ...links, + { + url: normalizedUrl, + platform: platform || 'other', + }, + ]); + updateUrl(''); + clearErrors(name); + + return true; + }, + [ + clearErrors, + displayToast, + links, + name, + onChange, + setError, + updateUrl, + url, + ], + ); + + useImperativeHandle( + ref, + () => ({ + flushPendingUrl: () => commitPendingUrl({ allowDuplicate: true }), + }), + [commitPendingUrl], + ); const handleRemove = useCallback( (index: number) => { const newLinks = [...links]; newLinks.splice(index, 1); onChange(newLinks); + clearErrors(name); }, - [links, onChange], + [clearErrors, links, name, onChange], ); const displayLinks = useMemo(() => links.map(getSocialLinkDisplay), [links]); @@ -131,18 +188,22 @@ export function SocialLinksInput({ {/* URL input */} { if (e.key === 'Enter') { e.preventDefault(); - handleAdd(); + commitPendingUrl(); } }} + valid={!error} fieldType="secondary" actionButton={ @@ -168,12 +229,30 @@ export function SocialLinksInput({ )} + {/* 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 */} @@ -219,3 +298,6 @@ export function SocialLinksInput({
); } + +export const SocialLinksInput = forwardRef(SocialLinksInputComponent); +SocialLinksInput.displayName = 'SocialLinksInput'; 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/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.spec.tsx b/packages/shared/src/hooks/useUserInfoForm.spec.tsx new file mode 100644 index 00000000000..28902940ef8 --- /dev/null +++ b/packages/shared/src/hooks/useUserInfoForm.spec.tsx @@ -0,0 +1,322 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +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, UserSocialLink } from '../lib/user'; +import { getProfile } from '../lib/user'; +import { mutateUserInfo } from '../graphql/users'; +import useUserInfoForm from './useUserInfoForm'; + +const mockDisplayToast = jest.fn(); +const mockLogEvent = jest.fn(); + +jest.mock('../lib/user', () => ({ + ...jest.requireActual('../lib/user'), + getProfile: jest.fn(), +})); + +jest.mock('../graphql/users', () => ({ + ...jest.requireActual('../graphql/users'), + mutateUserInfo: jest.fn(), +})); + +jest.mock('./useDirtyForm', () => ({ + useDirtyForm: jest.fn((_isDirty, options) => ({ + allowNavigation: jest.fn(), + hasPendingNavigation: () => false, + navigateToPending: jest.fn(), + save: options.onSave, + })), +})); + +jest.mock('./useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +jest.mock('../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +const mockGetProfile = getProfile as jest.MockedFunction; +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, + username: loggedUser.username, + bio: loggedUser.bio, + createdAt: loggedUser.createdAt, + image: loggedUser.image, + permalink: loggedUser.permalink, + premium: false, + reputation: 0, + socialLinks: serverLinks, +}; + +const renderUserInfoForm = (user: LoggedUser = loggedUser) => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + const updateUser = jest.fn().mockResolvedValue(undefined); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + + return { + updateUser, + ...renderHook(() => useUserInfoForm(), { wrapper }), + }; +}; + +const createDeferred = () => { + let resolve: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + + return { + promise, + resolve: resolve!, + }; +}; + +describe('useUserInfoForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetProfile.mockResolvedValue(profile); + mockMutateUserInfo.mockResolvedValue({ + ...loggedUser, + socialLinks: [], + }); + }); + + it('does not send empty socialLinks before the profile query resolves', async () => { + mockGetProfile.mockReturnValue(new Promise(() => undefined)); + mockMutateUserInfo.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderUserInfoForm({ + ...loggedUser, + socialLinks: undefined, + }); + + act(() => { + result.current.save(); + }); + + await waitFor(() => expect(mockMutateUserInfo).toHaveBeenCalled()); + expect(mockMutateUserInfo.mock.calls[0][0]).not.toHaveProperty( + 'socialLinks', + ); + }); + + it('merges a link added while the profile query is in flight with the server links', async () => { + const deferred = createDeferred(); + mockGetProfile.mockReturnValue(deferred.promise); + + const { result } = renderUserInfoForm({ + ...loggedUser, + socialLinks: undefined, + }); + const pendingLink = { + platform: 'github', + url: 'https://github.com/pending', + }; + + act(() => { + result.current.methods.setValue('socialLinks', [pendingLink], { + shouldDirty: true, + }); + }); + + await act(async () => { + deferred.resolve(profile); + await deferred.promise; + }); + + 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: { + errors: [{ message: 'value too long for type character varying(39)' }], + }, + }); + + const { result } = renderUserInfoForm(); + + act(() => { + result.current.save(); + }); + + await waitFor(() => + expect(mockDisplayToast).toHaveBeenCalledWith('Failed to update profile'), + ); + }); + + it('shows a toast for mutation errors keyed to fields absent from the page', async () => { + mockMutateUserInfo.mockRejectedValue({ + response: { + errors: [ + { message: JSON.stringify({ github: 'github already exists' }) }, + ], + }, + }); + + const { result } = renderUserInfoForm(); + + act(() => { + result.current.save(); + }); + + await waitFor(() => + expect(mockDisplayToast).toHaveBeenCalledWith('github already exists'), + ); + }); +}); diff --git a/packages/shared/src/hooks/useUserInfoForm.ts b/packages/shared/src/hooks/useUserInfoForm.ts index b5632d14d11..83e8c526fdc 100644 --- a/packages/shared/src/hooks/useUserInfoForm.ts +++ b/packages/shared/src/hooks/useUserInfoForm.ts @@ -1,24 +1,21 @@ -import { useContext, useEffect, useRef } from 'react'; +import { useCallback, useContext, useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; 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'; 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'; - -export interface ProfileFormHint { - username?: string; - name?: string; -} +import { isSameSocialLinkUrl } from '../lib/socialLink'; export type UpdateProfileParameters = Partial & { upload?: File; @@ -29,6 +26,8 @@ interface UseUserInfoForm { methods: UseFormReturn; save: () => void; isLoading: boolean; + isSocialLinksLoading: boolean; + isSocialLinksError: boolean; } const useUserInfoForm = (): UseUserInfoForm => { @@ -43,7 +42,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, @@ -51,6 +50,9 @@ const useUserInfoForm = (): UseUserInfoForm => { enabled: !!userId, }); + const hasInitializedSocialLinks = + !!fullProfile || Array.isArray(user?.socialLinks); + useEffect(() => { const searchParams = new URLSearchParams(window?.location?.search); const field = searchParams?.get('field'); @@ -73,19 +75,33 @@ const useUserInfoForm = (): UseUserInfoForm => { experienceLevel: user?.experienceLevel, hideExperience: user?.hideExperience, readme: user?.readme || '', - socialLinks: [], + socialLinks: user?.socialLinks || [], }, }); - // Update socialLinks when fullProfile loads (async fetch completes) - const hasUpdatedSocialLinks = useRef(false); useEffect(() => { - if (fullProfile && !hasUpdatedSocialLinks.current) { - hasUpdatedSocialLinks.current = true; - methods.setValue('socialLinks', fullProfile.socialLinks || [], { - shouldDirty: false, - }); + if (!fullProfile) { + return; } + + const serverLinks = fullProfile.socialLinks || []; + + if (!methods.getFieldState('socialLinks').isDirty) { + methods.resetField('socialLinks', { defaultValue: serverLinks }); + return; + } + + 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); @@ -119,27 +135,62 @@ 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) { + const isValidationError = + responseError?.extensions?.code === ApiError.GraphqlValidationFailed; + + displayToast( + isValidationError && errorMessage + ? errorMessage + : 'Failed to update profile', + ); + return; + } - if (errorMessage) { - const data: ProfileFormHint = JSON.parse(errorMessage); + const toastMessages: string[] = []; + const formFields = methods.getValues(); - Object.entries(data).forEach(([key, value]) => { + Object.entries(data).forEach(([key, value]) => { + if (!value) { + return; + } + + if (key in formFields) { methods.setError(key as keyof UserProfile, { type: 'manual', message: value, }); - }); - } else { + } else { + toastMessages.push(value); + } + }); + + if (toastMessages.length) { + displayToast(toastMessages[0]); + } else if (!Object.keys(data).length) { displayToast('Failed to update profile'); } }, }); + const getProfileUpdatePayload = useCallback((): UpdateProfileParameters => { + const formData = methods.getValues(); + + if (!hasInitializedSocialLinks) { + const { socialLinks, ...payload } = formData; + return payload; + } + + return formData; + }, [hasInitializedSocialLinks, methods]); + const dirtyForm = useDirtyForm(methods.formState.isDirty, { onSave: () => { - const formData = methods.getValues(); - updateUserProfile(formData); + updateUserProfile(getProfileUpdatePayload()); }, onDiscard: () => { methods.reset(); @@ -152,6 +203,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 aa5e48382ef..780d4e79214 100644 --- a/packages/webapp/components/layouts/SettingsLayout/Profile/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/Profile/index.tsx @@ -4,7 +4,7 @@ import { ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import type { ReactElement } from 'react'; -import React, { useContext } from 'react'; +import React, { useContext, useRef } from 'react'; import ControlledTextField from '@dailydotdev/shared/src/components/fields/ControlledTextField'; import ControlledTextarea from '@dailydotdev/shared/src/components/fields/ControlledTextarea'; import { @@ -28,6 +28,7 @@ import ControlledCoverUpload from '@dailydotdev/shared/src/components/profile/Co import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; import useUserInfoForm from '@dailydotdev/shared/src/hooks/useUserInfoForm'; import ControlledSwitch from '@dailydotdev/shared/src/components/fields/ControlledSwitch'; +import type { SocialLinksInputHandle } from '@dailydotdev/shared/src/components/profile/SocialLinksInput'; import { SocialLinksInput } from '@dailydotdev/shared/src/components/profile/SocialLinksInput'; import { MarkdownCommand } from '@dailydotdev/shared/src/hooks/input/useMarkdownInput'; import { AccountPageContainer } from '../AccountPageContainer'; @@ -36,9 +37,17 @@ 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(() => save()); + const handleSubmit = methods.handleSubmit(() => { + if (socialLinksRef.current && !socialLinksRef.current.flushPendingUrl()) { + return; + } + + save(); + }); return (
@@ -131,9 +140,12 @@ const ProfileIndex = (): ReactElement => {