Skip to content

Commit

Permalink
feat: new form for changing climb left-right order
Browse files Browse the repository at this point in the history
  • Loading branch information
viet nguyen committed Dec 28, 2023
1 parent 673ea55 commit 657166f
Show file tree
Hide file tree
Showing 12 changed files with 289 additions and 44 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"@algolia/autocomplete-js": "1.7.1",
"@algolia/autocomplete-theme-classic": "1.7.1",
"@apollo/client": "^3.7.16",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@google-cloud/storage": "^6.11.0",
"@headlessui/react": "^1.7.15",
"@heroicons/react": "2.0.13",
Expand Down
16 changes: 8 additions & 8 deletions src/app/editArea/[slug]/components/SingleEntryForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import clx from 'classnames'

export interface SingleEntryFormProps<T> {
children: ReactNode
initialValues: DefaultValues<T>
initialValues?: DefaultValues<T>
validationMode?: keyof ValidationMode
ignoreIsValid?: boolean
alwaysEnableSubmit?: boolean
submitHandler: (formData: T) => Promise<void> | void
title: string
helperText?: string
Expand All @@ -25,15 +25,15 @@ export function SingleEntryForm<T extends FieldValues> ({
initialValues,
submitHandler,
validationMode = 'onBlur',
ignoreIsValid = false,
alwaysEnableSubmit = false,
helperText,
title,
keepValuesAfterReset = true,
className = ''
}: SingleEntryFormProps<T>): ReactNode {
const form = useForm<T>({
mode: validationMode,
defaultValues: { ...initialValues }
...initialValues != null && { defaultValues: { ...initialValues } }
})

const { handleSubmit, reset, formState: { isValid, isSubmitting, isDirty } } = form
Expand All @@ -48,8 +48,7 @@ export function SingleEntryForm<T extends FieldValues> ({
} else {
reset()
}
}
)}
})}
>
<div className={clx('card card-bordered border-base-300/40 overflow-hidden w-full bg-base-100', className)}>
<div className='card-body'>
Expand All @@ -61,8 +60,9 @@ export function SingleEntryForm<T extends FieldValues> ({
<div className='px-8 py-2 w-full flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 bg-base-200 border-t'>
<span className='text-base-content/50'>{helperText}</span>
<SubmitButton
isDirty={isDirty} isSubmitting={isSubmitting}
isValid={ignoreIsValid ? true : isValid}
isSubmitting={isSubmitting}
isDirty={alwaysEnableSubmit ? true : isDirty}
isValid={alwaysEnableSubmit ? true : isValid}
/>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/app/editArea/[slug]/general/components/AddAreaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const AddAreaForm: React.FC<{ area: AreaType }> = ({ area }) => {
initialValues={{ areaName: '' }}
keepValuesAfterReset={false}
validationMode='onSubmit'
ignoreIsValid
alwaysEnableSubmit
title='Add new area'
helperText='TIP: Pick &ldquo;AREA&rdquo; type if not sure. You can change it later.'
submitHandler={async ({ areaName, areaType }) => {
Expand Down
12 changes: 2 additions & 10 deletions src/app/editArea/[slug]/general/components/climb/ClimbListForm.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import clx from 'classnames'
import { AreaMetadataType, ClimbDisciplineRecord, ClimbType } from '@/js/types'
import { disciplineTypeToDisplay } from '@/js/grades/util'
import { removeTypenameFromDisciplines } from '@/js/utils'
import { removeTypenameFromDisciplines, climbLeftRightIndexComparator } from '@/js/utils'
import Grade, { GradeContexts } from '@/js/grades/Grade'
import { ClimbListMiniToolbar } from '../../../manageClimbs/components/ClimbListMiniToolbar'

const leftRightIndexComparator = (a: ClimbType, b: ClimbType): number => {
const aIndex = a.metadata.leftRightIndex
const bIndex = b.metadata.leftRightIndex
if (aIndex < bIndex) return -1
else if (aIndex > bIndex) return 1
return 0
}

export const ClimbList: React.FC<{ gradeContext: GradeContexts, climbs: ClimbType[], areaMetadata: AreaMetadataType, editMode: boolean }> = ({ gradeContext, climbs, areaMetadata, editMode }) => {
const sortedClimbs = climbs.sort(leftRightIndexComparator)
const sortedClimbs = [...climbs].sort(climbLeftRightIndexComparator)
return (
<div>
{climbs.length === 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const AddClimbsForm: React.FC<{ parentAreaName: string, parentAreaUuid: s
title={`Add climbs to ${parentAreaName} area`}
initialValues={{ climbList: [{ name: '', disciplines: defaultDisciplines() }] }}
validationMode='onSubmit'
ignoreIsValid
alwaysEnableSubmit
keepValuesAfterReset={false}
submitHandler={async (data) => {
const { climbList } = data
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
'use client'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import {
DndContext,
closestCenter,
KeyboardSensor,
useSensor,
useSensors,
DragEndEvent,
MouseSensor as LibMouseSensor
} from '@dnd-kit/core'
import { SortableContext, rectSortingStrategy, sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'
import clx from 'classnames'

import { SingleEntryForm } from 'app/editArea/[slug]/components/SingleEntryForm'
import { ClimbType } from '@/js/types'
import { climbLeftRightIndexComparator } from '@/js/utils'
import { SortableClimbItem } from './SortableClimbItem'
import { disciplinesToCodes, gradesToString } from '@/js/grades/util'
import useUpdateClimbsCmd from '@/js/hooks/useUpdateClimbsCmd'
import { IndividualClimbChangeInput } from '@/js/graphql/gql/contribs'
import { GradeContexts } from '@/js/grades/Grade'

/**
* Sort climbs form
*/
export const SortClimbsForm: React.FC<{ parentAreaId: string, climbs: ClimbType[], gradeContext: GradeContexts }> = ({ parentAreaId, climbs, gradeContext }) => {
const router = useRouter()
const session = useSession({ required: true })
const { updateClimbCmd } = useUpdateClimbsCmd({
parentId: parentAreaId,
accessToken: session?.data?.accessToken as string
})

const [enabled, setEnabled] = useState(false)
const [climbHashmap, setClimbHashmap] = useState<Map<string, ClimbType>>(new Map())
const [sortedList, setSortedList] = useState<string[]>([])

useEffect(() => {
reset()
}, [enabled, climbs])

const reset = (): void => {
const climbMap = new Map([...climbs].sort(climbLeftRightIndexComparator).map(climb => [climb.id, climb]))
setClimbHashmap(climbMap)
setSortedList(Array.from(climbMap.keys()))
}

const sensors = useSensors(
useSensor(LibMouseSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
)

function handleDragEnd (event: DragEndEvent): void {
const { active, over } = event
if (active.id != null && over?.id != null && active.id !== over.id) {
const oldIndex = sortedList.indexOf(active.id as string)
const newIndex = sortedList.indexOf(over.id as string)
const newList = arrayMove(sortedList, oldIndex, newIndex)
setSortedList(newList)
}
}

const submitHandler = async (): Promise<void> => {
const updatedList = sortedList.reduce<IndividualClimbChangeInput[]>((acc, curr, index) => {
const climb = climbHashmap.get(curr)
if (climb == null) return acc
const { id, metadata: { leftRightIndex } } = climb
if (leftRightIndex !== index) {
acc.push({ id, leftRightIndex: index })
}
return acc
}, [])
await updateClimbCmd({ parentId: parentAreaId, changes: updatedList })
router.refresh()
}

return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedList}
strategy={rectSortingStrategy}
>
<SingleEntryForm<{ dummyField: string }>
title='Sort climbs'
validationMode='onSubmit'
alwaysEnableSubmit
submitHandler={submitHandler}
helperText='Drag and drop climbs to set their left-to-right order.'
>
<div>
<button
type='button'
className='btn btn-primary btn-outline'
onClick={() => {
setEnabled(curr => !curr)
}}
>
{enabled ? 'Cancel' : 'Sort climbs'}
</button>
</div>
{enabled &&
(
<div className='border-t'>
<p className='mt-4'>Climbs are ordered from left to right. Drag and drop climbs to change their position.</p>

<table className='mt-4 table-auto border-separate border-spacing-y-2'>

<thead>
<tr>
<th className='border-b border-base-300 py-2'>Position</th>
<th className='border-b border-base-300 py-2'>Name</th>
</tr>
</thead>

<tbody>
{sortedList.map((climbId, index) => {
const climb = climbHashmap.get(climbId)
if (climb == null) return null
const { id, name, type, grades, metadata: { leftRightIndex } } = climb
const gradeStr = gradesToString(grades, type, gradeContext)
const hasChanged = leftRightIndex !== index
return (
<SortableClimbItem
key={id}
id={id}
className=''
>
<>
<td className={clx('text-center px-2 border-l-4', hasChanged ? 'italic font-medium border-l-warning' : 'text-secondary border-transparent')}>
{index + 1}
</td>
<td className='max-w-sm flex gap-x-4 items-center nowrap overflow-hidden'>
<span className='pl-2 truncate max-w-xs overflow-hidden font-medium'>{name}</span>
<span className='text-secondary'>{gradeStr}&nbsp;{disciplinesToCodes(type)}</span>
</td>
</>
</SortableClimbItem>
)
})}
</tbody>

<tfoot>
<tr>
<td className='border-t border-base-300 py-2' />
<td className='border-t border-base-300 text-right py-2'>
<button className='btn btn-sm' onClick={reset} type='button'>Reset</button>
</td>
</tr>
</tfoot>
</table>

</div>
)}
</SingleEntryForm>
</SortableContext>
</DndContext>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { CSS } from '@dnd-kit/utilities'
import { useSortable } from '@dnd-kit/sortable'
import clx from 'classnames'

interface SortableItemProps {
id: string
children: JSX.Element
className: string
}

export const SortableClimbItem: React.FC<SortableItemProps> = ({ id, className, children }: SortableItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })

const style = {
transform: CSS.Transform.toString(transform),
transition
}

return (
<tr
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={
clx(className,
isDragging ? 'text-neutral-content bg-neutral cursor-move' : 'hover:bg-base-200')
}
>
{children}
</tr>
)
}
4 changes: 4 additions & 0 deletions src/app/editArea/[slug]/manageClimbs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DashboardPageProps, getPageDataForEdit } from '../general/page'
import { PageContainer, SectionContainer } from '../components/EditAreaContainers'
import { AddClimbsForm } from './components/AddClimbsForm'
import { ClimbListSection } from '@/app/area/[[...slug]]/sections/ClimbListSection'
import { SortClimbsForm } from './components/sorting/SortClimbsForm'

// Opt out of caching for all data requests in the route segment
export const dynamic = 'force-dynamic'
Expand All @@ -22,6 +23,9 @@ export default async function AddClimbsPage ({ params: { slug } }: DashboardPage
const { leaf, isBoulder } = metadata
return (
<PageContainer>
<SectionContainer id='leftToRight'>
<SortClimbsForm parentAreaId={area.uuid} climbs={area.climbs} gradeContext={gradeContext} />
</SectionContainer>
<SectionContainer id='climbList'>
<ClimbListSection area={area} editMode />
</SectionContainer>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/form/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface InputProps {
helper?: string | JSX.Element
disabled?: boolean
readOnly?: boolean
type?: 'text' | 'number' | 'email'
type?: 'text' | 'number' | 'email' | 'hidden'
spellCheck?: boolean
}

Expand Down
32 changes: 31 additions & 1 deletion src/js/grades/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ClimbDisciplineRecord } from '../types'
import { getScale } from '@openbeta/sandbag'

import { ClimbDisciplineRecord, GradeValuesType } from '../types'
import { GradeContexts, gradeContextToGradeScales } from './Grade'

export const disciplineTypeToDisplay = (type: Partial<ClimbDisciplineRecord>): string[] => {
const ret: string[] = []
Expand Down Expand Up @@ -75,3 +78,30 @@ export const defaultDisciplines = (): ClimbDisciplineRecord => ({
tr: false,
snow: false
})

/**
* Convert grades object to human-readable string. The next version of @openbeta/sandbag should support this.
* @param grades
* @param discplines
* @param context
*/
export const gradesToString = (grades: GradeValuesType, discplines: ClimbDisciplineRecord, context: GradeContexts): string => {
if (grades == null) return ''
const scale = gradeContextToGradeScales[context]
if (scale == null) return ''

const ret: string[] = []
const entries = Object.entries(discplines)
for (const [key, value] of entries) {
if (key === '__typename') continue
// @ts-expect-error
const scaleName = scale[key]
if (value && scaleName != null) {
const k = getScale(scaleName)
if (k == null) continue
const v = grades[k.name]
if (v != null) ret.push(v)
}
}
return ret.join(' ')
}
12 changes: 12 additions & 0 deletions src/js/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,3 +296,15 @@ export const legacyInvalidateClimbPageCache = async (uuid: string): Promise<void
console.log('Invalidating climb page cache', e)
}
}

/**
* Comparator for sorting climbs by leftRightIndex.
* If leftRightIndex is not defined, use 'name' as the tiebreaker.
*/
export const climbLeftRightIndexComparator = (a: ClimbType, b: ClimbType): number => {
const aIndex = a.metadata.leftRightIndex ?? a.name
const bIndex = b.metadata.leftRightIndex ?? b.name
if (aIndex < bIndex) return -1
else if (aIndex > bIndex) return 1
return 0
}
Loading

0 comments on commit 657166f

Please sign in to comment.