Skip to content

Commit

Permalink
Issue 662 area order and sorting (#696)
Browse files Browse the repository at this point in the history
* AreaCRUD calls new updateAreaSortingOrder endpoint
* Use dnd-kit
* Frontend sorts child areas by leftRightIndex
  • Loading branch information
tedgeving authored May 21, 2023
1 parent 5b9684a commit 6759f2c
Show file tree
Hide file tree
Showing 9 changed files with 207 additions and 37 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"@algolia/autocomplete-js": "1.7.1",
"@algolia/autocomplete-theme-classic": "1.7.1",
"@apollo/client": "^3.6.9",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@google-cloud/storage": "^6.9.5",
"@headlessui/react": "^1.6.4",
"@heroicons/react": "2.0.13",
Expand Down
10 changes: 5 additions & 5 deletions src/components/crag/cragSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { toast } from 'react-toastify'

import { AreaUpdatableFieldsType, AreaType, ClimbDisciplineRecord, ClimbDiscipline, ChangesetType } from '../../js/types'
import { IndividualClimbChangeInput, UpdateOneAreaInputType } from '../../js/graphql/gql/contribs'
import { getMapHref, sortClimbsByLeftRightIndex, removeTypenameFromDisciplines } from '../../js/utils'
import { getMapHref, sortByLeftRightIndex, removeTypenameFromDisciplines } from '../../js/utils'
import { AREA_NAME_FORM_VALIDATION_RULES, AREA_LATLNG_FORM_VALIDATION_RULES, AREA_DESCRIPTION_FORM_VALIDATION_RULES } from '../edit/EditAreaForm'
import { AreaDesignationRadioGroupProps, areaDesignationToDb, areaDesignationToForm } from '../edit/form/AreaDesignationRadioGroup'
import { ClimbListPreview, findDeletedCandidates } from './ClimbListPreview'
Expand Down Expand Up @@ -117,7 +117,7 @@ export default function CragSummary ({ area, history }: CragSummaryProps): JSX.E
* Initially set to the childAreas prop coming from Next build, the cache
* may be updated by the users in the AreaCRUD component.
*/
const [childAreasCache, setChildAreasCache] = useState(childAreas)
const [childAreasCache, setChildAreasCache] = useState(sortByLeftRightIndex(childAreas))

/**
* Hold the form base states aka default values. Since we use Next SSG,
Expand All @@ -130,7 +130,7 @@ export default function CragSummary ({ area, history }: CragSummaryProps): JSX.E
description: initDescription,
latlng: `${initLat.toString()},${initLng.toString()}`,
areaType: areaDesignationToForm(areaMeta),
climbList: sortClimbsByLeftRightIndex(climbs).map(
climbList: sortByLeftRightIndex(climbs).map(
({
id,
name,
Expand Down Expand Up @@ -304,7 +304,7 @@ export default function CragSummary ({ area, history }: CragSummaryProps): JSX.E
*/
useEffect(() => {
if (data?.area != null) {
setChildAreasCache(data.area.children)
setChildAreasCache(sortByLeftRightIndex(data.area.children))
const { uuid, areaName, metadata, content, climbs } = data.area
const { lat, lng } = metadata
setCache((current) => ({
Expand All @@ -314,7 +314,7 @@ export default function CragSummary ({ area, history }: CragSummaryProps): JSX.E
description: content.description,
areaType: areaDesignationToForm(metadata),
latlng: `${lat.toString()},${lng.toString()}`,
climbList: sortClimbsByLeftRightIndex(climbs).map(
climbList: sortByLeftRightIndex(climbs).map(
({
id,
name,
Expand Down
136 changes: 115 additions & 21 deletions src/components/edit/AreaCRUD.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import clx from 'classnames'
import { AreaType } from '../../js/types'
import { AreaSummaryType } from '../crag/cragSummary'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy
} from '@dnd-kit/sortable'
import { useSession } from 'next-auth/react'
import React, { useState } from 'react'

import { SortableItem } from './SortableItem'
import { DeleteAreaTrigger, AddAreaTrigger, AddAreaTriggerButtonMd, AddAreaTriggerButtonSm, DeleteAreaTriggerButtonSm } from './Triggers'
import { AreaSummaryType } from '../crag/cragSummary'
import { AreaEntityIcon } from '../EntityIcons'
import NetworkSquareIcon from '../../assets/icons/network-square-icon.svg'
import useUpdateAreasCmd from '../../js/hooks/useUpdateAreasCmd'
import { AreaType } from '../../js/types'

export type AreaCRUDProps = Pick<AreaType, 'uuid'|'areaName'> & {
childAreas: AreaSummaryType[]
export type AreaCRUDProps = Pick<AreaType, 'uuid' | 'areaName'> & {
childAreas: AreaType[]
editMode: boolean
onChange: () => void
}
Expand All @@ -15,8 +35,48 @@ export type AreaCRUDProps = Pick<AreaType, 'uuid'|'areaName'> & {
* Responsible for rendering child areas table (Read) and Create/Update/Delete operations.
* @param onChange notify parent of any changes
*/

export const AreaCRUD = ({ uuid: parentUuid, areaName: parentName, childAreas, editMode, onChange }: AreaCRUDProps): JSX.Element => {
const session = useSession()
const areaCount = childAreas.length
// Prepare {uuid: <AreaType>} mapping to avoid passing entire areas around.
const areaStore = new Map(childAreas.map(a => [a.uuid, a]))

const [areasSortedState, setAreasSortedState] = useState<string[]>(Array.from(areaStore.keys()))
const [draggedArea, setDraggedArea] = useState<string | null>(null)

const { updateAreasSortingOrderCmd } = useUpdateAreasCmd({
areaId: parentUuid,
accessToken: session?.data?.accessToken as string ?? ''
})

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

function handleDragEnd (event): void {
const { active, over } = event
setDraggedArea(null)

if (active.id !== over.id) {
const oldIndex = areasSortedState.indexOf(active.id)
const newIndex = areasSortedState.indexOf(over.id)
const reorderedChildAreas = arrayMove(areasSortedState, oldIndex, newIndex)
void updateAreasSortingOrderCmd(reorderedChildAreas.map((uuid, idx) => ({ areaId: uuid, leftRightIndex: idx })))
setAreasSortedState(reorderedChildAreas)
}
}

function handleDragStart (event): void {
const { active } = event
if (active.id != null) {
setDraggedArea(active.id)
}
}

return (
<>
<div className='flex items-center justify-between'>
Expand All @@ -27,33 +87,67 @@ export const AreaCRUD = ({ uuid: parentUuid, areaName: parentName, childAreas, e
<AddAreaTriggerButtonSm />
</AddAreaTrigger>)}
</div>
{/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions */}
<span className='text-base-300 text-sm'>{areaCount > 0 && `Total: ${areaCount}`}</span>
</div>

<hr className='mt-4 mb-8 border-1 border-base-content' />
<hr className='mt-4 border-1 border-base-content' />

{areaCount === 0 && (
<div>
<div className='mb-8 italic text-base-300'>This area doesn't have any child areas.</div>
{editMode && <AddAreaTrigger parentName={parentName} parentUuid={parentUuid} onSuccess={onChange} />}
</div>)}

{/* Build 2 column table on large screens */}
<div className='two-column-table'>
{childAreas.map((props, index) => (
<AreaItem
key={props.uuid}
index={index}
borderBottom={index === Math.ceil(areaCount / 2) - 1}
parentUuid={parentUuid}
{...props}
editMode={editMode}
onChange={onChange}
/>))}

{/* A hack to add bottom border */}
{areaCount > 1 && <div className='border-t' />}
</div>
{areaCount > 0 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
>
<div
className={`two-column-table ${editMode ? '' : 'xl:grid-cols-2 lg:grid-cols-2 md:grid-cols-2'
} fr-2`}
>
<SortableContext
items={areasSortedState}
strategy={rectSortingStrategy}
>
{areasSortedState.map((uuid, idx) => (
<SortableItem id={uuid} key={uuid} disabled={!editMode}>
<AreaItem
index={idx}
borderBottom={[Math.ceil(areaCount / 2) - 1, areaCount - 1].includes(idx)}
parentUuid={parentUuid}
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
...areaStore.get(uuid)!}
editMode={editMode}
onChange={onChange}
/>
</SortableItem>
))}
</SortableContext>
</div>
<DragOverlay>
{draggedArea != null
? (
<div className='bg-purple-100'>
<AreaItem
index={areasSortedState.indexOf(draggedArea)}
borderBottom
parentUuid={parentUuid}
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
...areaStore.get(draggedArea)!}
editMode={editMode}
onChange={onChange}
/>
</div>
)
: null}
</DragOverlay>
</DndContext>
)}
{areaCount > 0 && editMode && (
<div className='mt-8 md:text-right'>
<AddAreaTrigger parentName={parentName} parentUuid={parentUuid} onSuccess={onChange}>
Expand Down
31 changes: 31 additions & 0 deletions src/components/edit/SortableItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { CSS } from '@dnd-kit/utilities'
import { useSortable } from '@dnd-kit/sortable'

interface SortableItemProps {
id: string
children: JSX.Element
disabled: boolean
}

export const SortableItem = (props: SortableItemProps): JSX.Element => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: props.id, disabled: props.disabled })

const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : 1
}

return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{props.children}
</div>
)
}
1 change: 1 addition & 0 deletions src/js/graphql/gql/areaById.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const QUERY_AREA_BY_ID = gql`
metadata {
leaf
isBoulder
leftRightIndex
}
children {
uuid
Expand Down
11 changes: 11 additions & 0 deletions src/js/graphql/gql/contribs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ mutation ($uuid: String!) {
}
}`

export const MUTATION_UPDATE_AREAS_SORTING_ORDER = gql`
mutation ($input: [AreaSortingInput]) {
updateAreasSortingOrder(input: $input)
}
`

export interface AreaSortingInput {
areaId: string
leftRightIndex: number
}

export interface DeleteOneAreaInputType {
uuid: string
}
Expand Down
34 changes: 30 additions & 4 deletions src/js/hooks/useUpdateAreasCmd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { graphqlClient } from '../graphql/Client'
import {
MUTATION_UPDATE_AREA, MUTATION_ADD_AREA,
UpdateOneAreaInputType, UpdateAreaApiReturnType, AddAreaReturnType, AddAreaProps,
DeleteOneAreaInputType, DeleteOneAreaReturnType, MUTATION_REMOVE_AREA
DeleteOneAreaInputType, DeleteOneAreaReturnType,
MUTATION_REMOVE_AREA, MUTATION_UPDATE_AREAS_SORTING_ORDER, AreaSortingInput
} from '../graphql/gql/contribs'
import { QUERY_AREA_FOR_EDIT } from '../../js/graphql/gql/areaById'
import { AreaType } from '../../js/types'
Expand All @@ -14,6 +15,7 @@ type UpdateOneAreaCmdType = (input: UpdateOneAreaInputType) => Promise<void>
type AddOneAreCmdType = ({ name, parentUuid }: AddAreaProps) => Promise<void>
type DeleteOneAreaCmdType = ({ uuid }: DeleteOneAreaInputType) => Promise<void>
type GetAreaByIdCmdType = ({ skip }: { skip?: boolean }) => QueryResult<{ area: AreaType}>
type UpdateAreasSortingOrderCmdType = (input: AreaSortingInput[]) => Promise<void>

interface CallbackProps {
onUpdateCompleted?: (data: any) => void
Expand All @@ -29,11 +31,12 @@ type Props = CallbackProps & {
accessToken: string
}

interface UpdateClimbsHookReturn {
interface UpdateAreasHookReturn {
getAreaByIdCmd: GetAreaByIdCmdType
updateOneAreaCmd: UpdateOneAreaCmdType
addOneAreaCmd: AddOneAreCmdType
deleteOneAreaCmd: DeleteOneAreaCmdType
updateAreasSortingOrderCmd: UpdateAreasSortingOrderCmdType
}

/**
Expand All @@ -43,7 +46,7 @@ interface UpdateClimbsHookReturn {
* @param onUpdateCompleted Optional success callback
* @param onError Optiona error callback
*/
export default function useUpdateAreasCmd ({ areaId, accessToken = '', ...props }: Props): UpdateClimbsHookReturn {
export default function useUpdateAreasCmd ({ areaId, accessToken = '', ...props }: Props): UpdateAreasHookReturn {
const { onUpdateCompleted, onUpdateError, onAddCompleted, onAddError, onDeleteCompleted, onDeleteError } = props

const getAreaByIdCmd: GetAreaByIdCmdType = ({ skip = false }) => {
Expand Down Expand Up @@ -88,6 +91,29 @@ export default function useUpdateAreasCmd ({ areaId, accessToken = '', ...props
})
}

const [updateAreasSortingOrder] = useMutation<{ updateAreaSortingOrder: any }, { input: AreaSortingInput[] }>(
MUTATION_UPDATE_AREAS_SORTING_ORDER, {
client: graphqlClient,
onCompleted: async (data) => {
await refreshPage(`/api/revalidate?s=${areaId}`)
},
onError: (error) => {
toast.error(`Unexpected error: ${error.message}`)
}
}
)

const updateAreasSortingOrderCmd: UpdateAreasSortingOrderCmdType = async (input: AreaSortingInput[]) => {
await updateAreasSortingOrder({
variables: { input },
context: {
headers: {
authorization: `Bearer ${accessToken}`
}
}
})
}

const [addArea] = useMutation<{ addArea: AddAreaReturnType }, AddAreaProps>(
MUTATION_ADD_AREA, {
client: graphqlClient,
Expand Down Expand Up @@ -155,7 +181,7 @@ export default function useUpdateAreasCmd ({ areaId, accessToken = '', ...props
})
}

return { updateOneAreaCmd, addOneAreaCmd, deleteOneAreaCmd, getAreaByIdCmd }
return { updateOneAreaCmd, addOneAreaCmd, deleteOneAreaCmd, getAreaByIdCmd, updateAreasSortingOrderCmd }
}

export const refreshPage = async (url: string): Promise<void> => {
Expand Down
14 changes: 9 additions & 5 deletions src/js/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ClimbTypeToColor } from './constants'
import { formatDistanceToNowStrict, differenceInYears, format } from 'date-fns'

import { ClimbType, ClimbDisciplineRecord, ClimbDiscipline } from './types'
import { AreaType, ClimbType, ClimbDisciplineRecord, ClimbDiscipline } from './types'

/**
* Given a path or parent id and the type of the page generate the GitHub URL
Expand Down Expand Up @@ -243,13 +243,17 @@ export const getMapHref = ({ lat, lng }: { lat: number, lng: number}): string =>
}

/**
* Sort climb list by its left to right index
* @param climbs array of climbs
* Sort list by its left to right index.
* @param locales array of climbs or areas
* @returns sorted array
*/
export const sortClimbsByLeftRightIndex = (climbs: ClimbType[]): ClimbType[] => climbs.slice().sort(compareFn)
export function sortByLeftRightIndex<T extends AreaType | ClimbType> (locales: T[]): T[] {
return locales.slice().sort(compareFn)
}

const compareFn = (a: ClimbType, b: ClimbType): number => (a.metadata.leftRightIndex - b.metadata.leftRightIndex)
function compareFn<T extends AreaType | ClimbType> (a: T, b: T): number {
return (a.metadata.leftRightIndex - b.metadata.leftRightIndex)
}

/**
* Remove non-true and __typename fields from climb disciplines object. See https://github.com/apollographql/apollo-client/issues/1913
Expand Down
Loading

1 comment on commit 6759f2c

@vercel
Copy link

@vercel vercel bot commented on 6759f2c May 21, 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.