Skip to content

Commit

Permalink
Tick import error (#969)
Browse files Browse the repository at this point in the history
* feat: add progress and error indicator for importing mountainproject ticks #913
* feat: update importfrommtnproj component and add some basic tests #913
* feat: center spinner, better error handling on fetches #913
* feat: refactor component, remove unused isButton prop and logic from component and references in apps #913
* feat: disable button when loading #913
  • Loading branch information
clintonlunn authored Aug 31, 2023
1 parent 5261a6a commit 9ea5e9c
Show file tree
Hide file tree
Showing 7 changed files with 238 additions and 155 deletions.
2 changes: 1 addition & 1 deletion src/components/edit/RecentChangeHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions src/components/ui/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const Spinner: React.FC = (): JSX.Element => (
<div role='status'>
<svg aria-hidden='true' className='w-6 h-6 m-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600' viewBox='0 0 100 101' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path d='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z' fill='currentColor' />
<path d='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z' fill='currentFill' />
</svg>
<span className='sr-only'>Loading...</span>
</div>
)

export default Spinner
14 changes: 11 additions & 3 deletions src/components/ui/micro/AlertDialogue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ interface LeanAlertProps {
description?: ReactNode
children?: ReactNode
className?: string
stackChildren?: boolean
onEscapeKeyDown?: () => void
}
/**
* A reusable popup alert
Expand All @@ -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 (
<AlertDialogPrimitive.Root defaultOpen>
<AlertDialogPrimitive.Overlay className='fixed inset-0 bg-black/60 z-50' />
<AlertDialogPrimitive.Content
onEscapeKeyDown={e => !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'
>
<div className={`p-4 rounded-box bg-base-100 w-full ${className}`}>
Expand All @@ -164,7 +172,7 @@ export const LeanAlert = ({ icon = null, title = null, description = null, child
{title}
</AlertDialogPrimitive.Title>
<AlertDialogPrimitive.Description className='my-8 text-inherit'>{description}</AlertDialogPrimitive.Description>
<div className='flex items-center justify-center gap-x-6'>
<div className={stackChildren ? 'flex-col items-center justify-center gap-x-6' : 'flex items-center justify-center gap-x-6'}>
{children}
</div>
</div>
Expand Down
277 changes: 128 additions & 149 deletions src/components/users/ImportFromMtnProj.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -10,23 +10,22 @@ 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
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()
Expand All @@ -40,19 +39,35 @@ export function ImportFromMtnProj ({ isButton, username }: Props): JSX.Element {
errorPolicy: 'none'
})

// this function updates the users metadata
async function dontShowAgain (): Promise<void> {
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<any> {
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
Expand All @@ -67,26 +82,41 @@ export function ImportFromMtnProj ({ isButton, username }: Props): JSX.Element {

async function getTicks (): Promise<void> {
// 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! 🎉 <br />
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
Expand All @@ -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 && <button onClick={straightToInput} className='btn btn-xs md:btn-sm btn-primary'>Import ticks</button>}
<div
aria-live='assertive'
className='fixed inset-0 z-10 flex items-end px-4 py-6 mt-24 pointer-events-none sm:p-6 sm:items-start'
>
<div className='w-full flex flex-col items-center space-y-4 sm:items-end'>
<Transition.Root
show={show}
as={Fragment}
enter='transform ease-out duration-300 transition'
enterFrom='translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2'
enterTo='translate-y-0 opacity-100 sm:translate-x-0'
leave='transition ease-in duration-100'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<div className='max-w-xl w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden'>
<div className='p-4'>
<div className='flex items-start'>
<div className='flex-shrink-0'>
<FolderArrowDownIcon className='h-6 w-6 text-gray-400' aria-hidden='true' />
</div>
<div className='ml-3 w-0 flex-1 pt-0.5'>
{(errors != null) && errors.length > 0 && errors.map((err, i) => <p className='mt-2 text-ob-primary' key={i}>{err}</p>)}
<p className='text-sm font-medium text-gray-900'>{showInput ? 'Input your Mountain Project profile link' : 'Import your ticks from Mountain Project'}</p>
{!showInput &&
<p className='mt-1 text-sm text-gray-500'>
Don't lose your progress, bring it over to Open Beta.
</p>}
{showInput &&
<div>
<div className='mt-1 relative rounded-md shadow-sm'>
<input
type='text'
name='website'
id='website'
value={mpUID}
onChange={(e) => setMPUID(e.target.value)}
className={clx(INPUT_DEFAULT_CSS, 'w-full')}
placeholder='https://www.mountainproject.com/user/123456789/username'
/>
</div>
</div>}
<div className='mt-3 flex space-x-7'>
{!showInput &&
<button
type='button'
onClick={() => setShowInput(true)}
className='text-center p-2 border-2 rounded-xl border-ob-primary transition
text-ob-primary hover:bg-ob-primary hover:ring hover:ring-ob-primary ring-offset-2
hover:text-white w-32 font-bold'
>
{loading ? 'Working...' : 'Show me how'}
</button>}
{showInput &&
<button
type='button'
onClick={getTicks}
className='btn btn-primary'
>
Get my ticks!
</button>}
{!isButton &&
<button
type='button'
onClick={dontShowAgain}
className='bg-white rounded-md text-sm font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
>
{loading ? 'Working...' : "Don't show again"}
</button>}
</div>
</div>
<div className='ml-4 flex-shrink-0 flex'>
<button
type='button'
className='bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
onClick={() => {
setShow(false)
}}
>
<span className='sr-only'>Close</span>
<XMarkIcon className='h-5 w-5' aria-hidden='true' />
</button>
</div>
</div>
</div>
<button onClick={straightToInput} className='btn btn-xs md:btn-sm btn-primary'>Import ticks</button>

{show && (
<LeanAlert
icon={<FolderArrowDownIcon className='h-6 w-6 text-gray-400' aria-hidden='true' />}
title={showInput ? 'Input your Mountain Project profile link' : 'Import your ticks from Mountain Project'}
description={!showInput ? "Don't lose your progress, bring it over to Open Beta." : null}
closeOnEsc
stackChildren
>
{(errors != null) && errors.length > 0 && errors.map((err, i) => <p className='mt-2 text-ob-primary' key={i}>{err}</p>)}

{showInput && (
<div className='mt-1 relative rounded-md shadow-sm'>
<input
type='text'
name='website'
id='website'
value={mpUID}
onChange={(e) => setMPUID(e.target.value)}
className={clx(INPUT_DEFAULT_CSS, 'w-full')}
placeholder='https://www.mountainproject.com/user/123456789/username'
/>
</div>
</Transition.Root>
</div>
</div>
)}

<div className='mt-3 flex space-x-7 justify-center'>
{!showInput && (
<button
type='button'
onClick={() => setShowInput(true)}
className='text-center p-2 border-2 rounded-xl border-ob-primary transition
text-ob-primary hover:bg-ob-primary hover:ring hover:ring-ob-primary ring-offset-2
hover:text-white w-32 font-bold'
>
{loading ? 'Working...' : 'Show me how'}
</button>
)}

{showInput && (
<button
type='button'
onClick={getTicks}
className='btn btn-primary'
disabled={loading}
>
{loading ? <Spinner /> : 'Get my ticks!'}
</button>
)}

<AlertDialogPrimitive.Cancel
asChild onClick={() => {
setShow(false)
setErrors([])
}}
>
<button className='Button mauve'>Cancel</button>
</AlertDialogPrimitive.Cancel>
</div>
</LeanAlert>
)}
</>
)
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/users/PublicProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function PublicProfile ({ userProfile }: PublicProfileProps): JSX
<div className='btn btn-outline btn-xs md:btn-sm'> View ticks</div>
</a>
</Link>}
{username != null && isAuthorized && <ImportFromMtnProj isButton username={username} />}
{username != null && isAuthorized && <ImportFromMtnProj username={username} />}
{userProfile != null && <EditProfileButton userUuid={userProfile?.userUuid} />}
{userProfile != null && <APIKeyCopy userUuid={userProfile.userUuid} />}
</div>
Expand Down
Loading

1 comment on commit 9ea5e9c

@vercel
Copy link

@vercel vercel bot commented on 9ea5e9c Aug 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.