diff --git a/src/components/edit/RecentChangeHistory.tsx b/src/components/edit/RecentChangeHistory.tsx index 364076098..576116303 100644 --- a/src/components/edit/RecentChangeHistory.tsx +++ b/src/components/edit/RecentChangeHistory.tsx @@ -148,7 +148,7 @@ const UpdatedFields = ({ fields, doc }: UpdatedFieldsProps): JSX.Element | null // double access - doc[parent][child] if (field.includes('.')) { - var [parent, child] = field.split('.') + let [parent, child] = field.split('.') if (parent === 'content' && doc.__typename === DocumentTypeName.Area) { parent = 'areaContent' // I had to alias this in the query bc of the overlap with ClimbType } diff --git a/src/components/ui/Spinner.tsx b/src/components/ui/Spinner.tsx new file mode 100644 index 000000000..d137dafcd --- /dev/null +++ b/src/components/ui/Spinner.tsx @@ -0,0 +1,11 @@ +const Spinner: React.FC = (): JSX.Element => ( +
+ + Loading... +
+) + +export default Spinner diff --git a/src/components/ui/micro/AlertDialogue.tsx b/src/components/ui/micro/AlertDialogue.tsx index f2346aa4b..57cc33772 100644 --- a/src/components/ui/micro/AlertDialogue.tsx +++ b/src/components/ui/micro/AlertDialogue.tsx @@ -142,6 +142,8 @@ interface LeanAlertProps { description?: ReactNode children?: ReactNode className?: string + stackChildren?: boolean + onEscapeKeyDown?: () => void } /** * A reusable popup alert @@ -150,12 +152,18 @@ interface LeanAlertProps { * @param cancelAction A button of type `AlertDialogPrimitive.Action` that closes the alert on click. You can register an `onClick()` to perform some action. * @param noncancelAction Any kind of React component/button that doesn't close the alert on click. Use this if you want to perform an action on click and keep the alert open. */ -export const LeanAlert = ({ icon = null, title = null, description = null, children = DefaultOkButton, closeOnEsc = true, className = '' }: LeanAlertProps): JSX.Element => { +export const LeanAlert = ({ icon = null, title = null, description = null, children = DefaultOkButton, closeOnEsc = true, onEscapeKeyDown = () => {}, className = '', stackChildren = false }: LeanAlertProps): JSX.Element => { return ( !closeOnEsc && e.preventDefault()} + onEscapeKeyDown={e => { + if (!closeOnEsc) { + e.preventDefault() + } else { + onEscapeKeyDown() + } + }} className='z-50 fixed h-screen inset-0 mx-auto flex items-center justify-center px-2 lg:px-0 text-center overflow-y-auto max-w-xs md:max-w-md lg:max-w-lg' >
@@ -164,7 +172,7 @@ export const LeanAlert = ({ icon = null, title = null, description = null, child {title} {description} -
+
{children}
diff --git a/src/components/users/ImportFromMtnProj.tsx b/src/components/users/ImportFromMtnProj.tsx index 99a83c988..9f0141330 100644 --- a/src/components/users/ImportFromMtnProj.tsx +++ b/src/components/users/ImportFromMtnProj.tsx @@ -1,7 +1,7 @@ -import { Fragment, useEffect, useState } from 'react' +import { useState } from 'react' import { useRouter } from 'next/router' -import { Transition } from '@headlessui/react' -import { FolderArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import { FolderArrowDownIcon } from '@heroicons/react/24/outline' import { useMutation } from '@apollo/client' import { signIn, useSession } from 'next-auth/react' import { toast } from 'react-toastify' @@ -10,9 +10,10 @@ import clx from 'classnames' import { graphqlClient } from '../../js/graphql/Client' import { MUTATION_IMPORT_TICKS } from '../../js/graphql/gql/fragments' import { INPUT_DEFAULT_CSS } from '../ui/form/TextArea' +import Spinner from '../ui/Spinner' +import { LeanAlert } from '../ui/micro/AlertDialogue' interface Props { - isButton: boolean username: string } // regex pattern to validate mountain project input @@ -20,13 +21,11 @@ const pattern = /^https:\/\/www.mountainproject.com\/user\/\d{9}\/[a-zA-Z-]*/ /** * - * @prop isButton -- a true or false value + * @prop username -- the openbeta username of the user * - * if the isButton prop is true, the component will be rendered as a button - * if the isButton prop is false, the component will be rendered as a modal * @returns JSX element */ -export function ImportFromMtnProj ({ isButton, username }: Props): JSX.Element { +export function ImportFromMtnProj ({ username }: Props): JSX.Element { const router = useRouter() const [mpUID, setMPUID] = useState('') const session = useSession() @@ -40,19 +39,35 @@ export function ImportFromMtnProj ({ isButton, username }: Props): JSX.Element { errorPolicy: 'none' }) - // this function updates the users metadata - async function dontShowAgain (): Promise { - setLoading(true) - const res = await fetch('/api/user/ticks', { - method: 'PUT', - body: '' - }) - if (res.status === 200) { - setShow(false) - } else { - setErrors(['Sorry, something went wrong. Please try again later']) + async function fetchMPData (url: string, method: 'GET' | 'POST' | 'PUT' = 'GET', body?: string): Promise { + try { + const headers = { + 'Content-Type': 'application/json' + } + const config: RequestInit = { + method, + headers + } + + if (body !== null && body !== undefined && body !== '') { + config.body = JSON.stringify(body) + } + + const response = await fetch(url, config) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.statusText) + } + + return await response.json() + } catch (error) { + if (error instanceof Error) { + console.error('Fetch error:', error.message) + throw error + } + throw new Error('An unexpected error occurred') } - setLoading(false) } // this function is for when the component is rendered as a button and sends the user straight to the input form @@ -67,26 +82,41 @@ export function ImportFromMtnProj ({ isButton, username }: Props): JSX.Element { async function getTicks (): Promise { // get the ticks and add it to the database + setErrors([]) if (pattern.test(mpUID)) { setLoading(true) - const res = await fetch('/api/user/ticks', { - method: 'POST', - body: JSON.stringify(mpUID) - }) - if (res.status === 200) { - setShow(false) - const { ticks } = await res.json() - await addTicks({ - variables: { - input: ticks - } - }) - - const ticksCount: number = ticks?.length ?? 0 - toast.info(`${ticksCount} ticks have been imported!`) - await router.replace(`/u2/${username}`) - } else { - setErrors(['Sorry, something went wrong. Please try again later']) + + try { + const response = await fetchMPData('/api/user/ticks', 'POST', JSON.stringify(mpUID)) + + if (response.ticks[0] !== undefined) { + await addTicks({ + variables: { + input: response.ticks + } + }) + // Add a delay before rerouting to the new page + const ticksCount: number = response.ticks?.length ?? 0 + toast.info( + <> + {ticksCount} ticks have been imported! 🎉
+ Redirecting in a few seconds...` + + ) + + setTimeout(() => { + void router.replace(`/u2/${username}`) + }, 2000) + setShow(false) + } else { + setErrors(['Sorry, no ticks were found for that user. Please check your Mountain Project ID and try again.']) + toast.error('Sorry, no ticks were found for that user. Please check your Mountain Project ID and try again.') + } + } catch (error) { + toast.error('Sorry, something went wrong. Please check your network and try again.') + setErrors(['Sorry, something went wrong. Please check your network and try again.']) + } finally { + setLoading(false) } } else { // handle errors @@ -95,120 +125,69 @@ export function ImportFromMtnProj ({ isButton, username }: Props): JSX.Element { setLoading(false) } - useEffect(() => { - // if we aren't rendering this component as a button - // and the user is authenticated we want to show the import your ticks modal - // then we check to see if they have a ticks imported flag set - // if it is, set show to the opposite of whatever it is - // otherwise don't show the modal - if (!isButton) { - fetch('/api/user/profile') - .then(async res => await res.json()) - .then((profile) => { - if (profile?.ticksImported !== null) { - setShow(profile.ticksImported !== true) - } else if (session.status === 'authenticated') { - setShow(true) - } else { - setShow(false) - } - }).catch(console.error) - } - }, [session]) - - // if the isButton prop is passed to this component as true, the component will be rendered as a button, otherwise it will be a modal return ( <> - {isButton && } -
-
- -
-
-
-
-
-
- {(errors != null) && errors.length > 0 && errors.map((err, i) =>

{err}

)} -

{showInput ? 'Input your Mountain Project profile link' : 'Import your ticks from Mountain Project'}

- {!showInput && -

- Don't lose your progress, bring it over to Open Beta. -

} - {showInput && -
-
- setMPUID(e.target.value)} - className={clx(INPUT_DEFAULT_CSS, 'w-full')} - placeholder='https://www.mountainproject.com/user/123456789/username' - /> -
-
} -
- {!showInput && - } - {showInput && - } - {!isButton && - } -
-
-
- -
-
-
+ + + {show && ( +
-
+ )} + +
+ {!showInput && ( + + )} + + {showInput && ( + + )} + + { + setShow(false) + setErrors([]) + }} + > + + +
+ + )} ) } diff --git a/src/components/users/PublicProfile.tsx b/src/components/users/PublicProfile.tsx index 3da5f40a9..99b901fc1 100644 --- a/src/components/users/PublicProfile.tsx +++ b/src/components/users/PublicProfile.tsx @@ -51,7 +51,7 @@ export default function PublicProfile ({ userProfile }: PublicProfileProps): JSX
View ticks
} - {username != null && isAuthorized && } + {username != null && isAuthorized && } {userProfile != null && } {userProfile != null && }
diff --git a/src/components/users/__tests__/ImportFromMtnProj.test.tsx b/src/components/users/__tests__/ImportFromMtnProj.test.tsx new file mode 100644 index 000000000..1c7d6f268 --- /dev/null +++ b/src/components/users/__tests__/ImportFromMtnProj.test.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import { render, fireEvent, waitFor, screen, act } from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing' +import ImportFromMtnProj from '../ImportFromMtnProj' +import '@testing-library/jest-dom/extend-expect' + +jest.mock('next-auth/react', () => ({ + useSession: jest.fn(() => ({ status: 'authenticated' })) +})) + +jest.mock('next/router', () => ({ + useRouter: jest.fn(() => ({ replace: jest.fn() })) +})) + +jest.mock('../../../js/graphql/Client', () => ({ + + graphqlClient: jest.fn() +})) + +jest.mock('react-toastify', () => ({ + toast: { + info: jest.fn(), + error: jest.fn() + } +})) + +describe('', () => { + it('renders without crashing', () => { + render( + + + + ) + }) + + it('renders modal on button click', async () => { + render( + + + + ) + + const button = screen.getByText('Import ticks') + await waitFor(() => { + act(() => { + fireEvent.click(button) + }) + }) + + await waitFor(() => { + const modalText = screen.getByText('Input your Mountain Project profile link') + expect(modalText).toBeInTheDocument() + }) + }) + + it('accepts input for the Mountain Project profile link', async () => { + render( + ) + + // Simulate a click to open the modal. + const openModalButton = screen.getByText('Import ticks') + await waitFor(() => { + act(() => { + fireEvent.click(openModalButton) + }) + }) + + // Use findBy to wait for the input field to appear. + const inputField = await screen.findByPlaceholderText('https://www.mountainproject.com/user/123456789/username') + + if (!(inputField instanceof HTMLInputElement)) { + throw new Error('Expected an input field') + } + + // Simulate entering a Mountain Project URL. + + await waitFor(() => { + act(() => { + fireEvent.change(inputField, { target: { value: 'https://www.mountainproject.com/user/123456789/sampleuser' } }) + }) + }) + + expect(inputField.value).toBe('https://www.mountainproject.com/user/123456789/sampleuser') + }) +}) diff --git a/src/pages/u2/[...slug].tsx b/src/pages/u2/[...slug].tsx index 65b543178..5ccbb7919 100644 --- a/src/pages/u2/[...slug].tsx +++ b/src/pages/u2/[...slug].tsx @@ -38,7 +38,7 @@ const Index: NextPage = ({ username, ticks }) => {

{username}