-
-
{name}
-
{gradeStr}
+
-
+
+
+ {editMode &&
+
}
)
}
diff --git a/src/app/editArea/[slug]/general/page.tsx b/src/app/editArea/[slug]/general/page.tsx
index ea3adeb22..661b03209 100644
--- a/src/app/editArea/[slug]/general/page.tsx
+++ b/src/app/editArea/[slug]/general/page.tsx
@@ -1,7 +1,8 @@
import { notFound } from 'next/navigation'
import { validate } from 'uuid'
-import { ReactNode } from 'react'
import { Metadata } from 'next'
+import { FetchPolicy } from '@apollo/client'
+import { ArrowCircleRight } from '@phosphor-icons/react/dist/ssr'
import { AreaPageDataProps, getArea } from '@/js/graphql/getArea'
import { AreaNameForm } from './components/AreaNameForm'
@@ -10,7 +11,7 @@ import { AreaLatLngForm } from './components/AreaLatLngForm'
import { AddAreaForm } from './components/AddAreaForm'
import { AreaListForm } from './components/AreaList'
import { AreaTypeForm } from './components/AreaTypeForm'
-import { FetchPolicy } from '@apollo/client'
+import { PageContainer, SectionContainer } from '../components/EditAreaContainers'
// Opt out of caching for all data requests in the route segment
export const dynamic = 'force-dynamic'
@@ -38,30 +39,32 @@ export default async function AreaEditPage ({ params }: DashboardPageProps): Pro
metadata: { lat, lng, leaf }
} = area
+ const canAddClimbs = leaf && children.length === 0
+
return (
-
-
+
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
{!leaf &&
-
+
- }
-
+ }
+
+
+
+
+
Manage Climbs
+ {canAddClimbs
+ ? (
+
+ )
+ :
This area contains subareas. Please add climbs to areas designated as crags or boulders.
}
+
+
+
+
)
}
-export const PageContainer: React.FC<{ children: ReactNode, id: string }> = ({ id, children }) => (
-
-
-
-)
-
export const getPageDataForEdit = async (pageSlug: string, fetchPolicy?: FetchPolicy): Promise
=> {
if (pageSlug == null) notFound()
diff --git a/src/app/editArea/[slug]/layout.tsx b/src/app/editArea/[slug]/layout.tsx
index a22ad6932..e0a7dca30 100644
--- a/src/app/editArea/[slug]/layout.tsx
+++ b/src/app/editArea/[slug]/layout.tsx
@@ -34,7 +34,7 @@ export default async function EditAreaDashboardLayout ({
-
+
{children}
diff --git a/src/app/editArea/[slug]/loading.tsx b/src/app/editArea/[slug]/loading.tsx
index 3d4e9e9ec..0c8a5bdfd 100644
--- a/src/app/editArea/[slug]/loading.tsx
+++ b/src/app/editArea/[slug]/loading.tsx
@@ -1,14 +1,14 @@
-import { PageContainer } from './general/page'
+import { SectionContainer } from './components/EditAreaContainers'
/**
* Loading skeleton
*/
export default function Loading (): JSX.Element {
return (
-
+
-
+
)
}
diff --git a/src/app/editArea/[slug]/manageClimbs/components/AddClimbsForm.tsx b/src/app/editArea/[slug]/manageClimbs/components/AddClimbsForm.tsx
new file mode 100644
index 000000000..d35f44a5c
--- /dev/null
+++ b/src/app/editArea/[slug]/manageClimbs/components/AddClimbsForm.tsx
@@ -0,0 +1,62 @@
+'use client'
+import { useSession } from 'next-auth/react'
+import { WarningOctagon } from '@phosphor-icons/react/dist/ssr'
+import { useRouter } from 'next/navigation'
+
+import { SingleEntryForm } from 'app/editArea/[slug]/components/SingleEntryForm'
+import useUpdateClimbsCmd from '@/js/hooks/useUpdateClimbsCmd'
+import { DynamicClimbInputList } from './DynamicClimbInputList'
+import { GradeContexts } from '@/js/grades/Grade'
+import { defaultDisciplines } from '@/js/grades/util'
+import { IndividualClimbChangeInput } from '@/js/graphql/gql/contribs'
+
+export type QuickAddNewClimbProps =
+ Partial
&
+ Required>
+
+export interface AddClimbsFormData {
+ climbList: QuickAddNewClimbProps[]
+}
+
+/**
+ * Add new climbs to an area form
+ */
+export const AddClimbsForm: React.FC<{ parentAreaName: string, parentAreaUuid: string, gradeContext: GradeContexts, canAddClimbs: boolean }> = ({ parentAreaName, parentAreaUuid, gradeContext, canAddClimbs }) => {
+ const router = useRouter()
+ const session = useSession({ required: true })
+ const { updateClimbCmd } = useUpdateClimbsCmd(
+ {
+ parentId: parentAreaUuid,
+ accessToken: session?.data?.accessToken as string
+ }
+ )
+
+ return (
+
+ title={`Add climbs to ${parentAreaName} area`}
+ initialValues={{ climbList: [{ name: '', disciplines: defaultDisciplines() }] }}
+ validationMode='onSubmit'
+ ignoreIsValid
+ keepValuesAfterReset={false}
+ submitHandler={async (data) => {
+ const { climbList } = data
+ const changes = climbList.filter(el => el.name.trim() !== '')
+ await updateClimbCmd({ parentId: parentAreaUuid, changes })
+ router.refresh() // Ask Next to refresh props from the server
+ }}
+ >
+ {canAddClimbs
+ ?
+ : (
+
+
+ This area is either a crag or a boulder. Adding new child areas is not allowed.
+
+ )}
+
+
+ )
+}
diff --git a/src/app/editArea/[slug]/manageClimbs/components/ClimbListMiniToolbar.tsx b/src/app/editArea/[slug]/manageClimbs/components/ClimbListMiniToolbar.tsx
new file mode 100644
index 000000000..9573aeb27
--- /dev/null
+++ b/src/app/editArea/[slug]/manageClimbs/components/ClimbListMiniToolbar.tsx
@@ -0,0 +1,35 @@
+'use client'
+import { useSession } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
+import { Trash } from '@phosphor-icons/react/dist/ssr'
+import useUpdateClimbsCmd from '@/js/hooks/useUpdateClimbsCmd'
+import Confirmation from '@/components/ui/micro/AlertDialogue'
+
+export const ClimbListMiniToolbar: React.FC<{ parentAreaId: string, climbId: string, climbName: string }> = ({ parentAreaId, climbId, climbName }) => {
+ const session = useSession({ required: true })
+ const router = useRouter()
+ const { deleteClimbsCmd } = useUpdateClimbsCmd({ parentId: parentAreaId, accessToken: session.data?.accessToken ?? '' })
+ const onConfirm = (): void => {
+ deleteClimbsCmd([climbId]).then((count) => {
+ router.refresh()
+ }).catch((error) => {
+ console.error(error)
+ })
+ }
+ return (
+
+
+ Delete
+
+ }
+ onConfirm={onConfirm}
+ >
+ You're about to delete climb "{climbName}" . This cannot be undone.
+
+
+ )
+}
diff --git a/src/app/editArea/[slug]/manageClimbs/components/DisciplinesSelection.tsx b/src/app/editArea/[slug]/manageClimbs/components/DisciplinesSelection.tsx
new file mode 100644
index 000000000..13905060f
--- /dev/null
+++ b/src/app/editArea/[slug]/manageClimbs/components/DisciplinesSelection.tsx
@@ -0,0 +1,236 @@
+'use client'
+import { useState, useEffect } from 'react'
+import { UseFormReturn, useWatch } from 'react-hook-form'
+import clx from 'classnames'
+import { getScale } from '@openbeta/sandbag'
+
+import { BaseInput } from '@/components/ui/form/Input'
+import { ClimbDisciplineRecord, RulesType } from '@/js/types'
+import { CLIMB_ARRAY_FIELD_NAME } from './DynamicClimbInputList'
+import { defaultDisciplines } from '@/js/grades/util'
+import { GradeContexts, gradeContextToGradeScales } from '@/js/grades/Grade'
+import { AddClimbsFormData } from './AddClimbsForm'
+
+interface FieldArrayInputProps {
+ index: number
+ formContext: UseFormReturn
+ gradeContext: GradeContexts
+}
+
+/**
+ * Disciplines selection and grade input.
+ * When the climb name is not empty, at least one discipline must be selected.
+ */
+export const DisciplinesSelection: React.FC = ({ formContext, index, gradeContext }) => {
+ const { setValue, setError, clearErrors, formState: { errors } } = formContext
+ const fieldName = `${CLIMB_ARRAY_FIELD_NAME}[${index}].disciplines`
+
+ const disciplines: Partial = useWatch({ name: fieldName })
+ const climbName: string = useWatch({ name: `${CLIMB_ARRAY_FIELD_NAME}[${index}].name` })
+
+ const numberOfCheckedDisciplines = Object.values(disciplines).filter(el => el).length
+
+ useEffect(() => {
+ if (disciplines != null) {
+ const hasDiscipline = Object.values(disciplines).some(el => el)
+ if (hasDiscipline) {
+ // @ts-expect-error
+ clearErrors(fieldName)
+ } else if (climbName != null && climbName.trim() !== '') {
+ // @ts-expect-error
+ setError(fieldName, { type: 'custom', message: 'Please select at least one discipline.' })
+ }
+ }
+ }, [disciplines, climbName])
+
+ const clearAll = (): void => {
+ // @ts-expect-error
+ setValue(fieldName, defaultDisciplines())
+ }
+
+ const hasError = (errors?.climbList?.[index]?.disciplines ?? null) != null && (climbName?.trim() ?? '') !== ''
+
+ return (
+ <>
+
+ Disciplines:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clear all
+
+
+
+ {hasError && 'Please select at least one discipline'}
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+/**
+ * Checkbox for each discipline
+ */
+const Checkbox: React.FC<{ label: string, discipline: keyof ClimbDisciplineRecord } & Omit> = ({ label, index, discipline, formContext }) => {
+ const { register, watch } = formContext
+ const fieldName = `${CLIMB_ARRAY_FIELD_NAME}[${index}].disciplines.${discipline}` as keyof ClimbDisciplineRecord
+
+ // @ts-expect-error
+ const checked = watch(fieldName) as boolean
+
+ return (
+
+
+ {label}
+
+ )
+}
+
+/**
+ * Grade textbox
+ */
+const GradeInput: React.FC = ({ formContext, index, gradeContext }) => {
+ const [validationRules, setValidationRules] = useState()
+ const [gradeScale, setGradeScale] = useState>()
+
+ const { watch, formState: { errors } } = formContext
+
+ const fieldName = `${CLIMB_ARRAY_FIELD_NAME}[${index}].grade`
+ // @ts-expect-error
+ const disciplines = watch(`${CLIMB_ARRAY_FIELD_NAME}[${index}].disciplines`) as ClimbDisciplineRecord
+
+ useEffect(() => {
+ const rules = getGradeValationRules(gradeContext, disciplines)
+ setValidationRules(rules?.rules)
+ setGradeScale(rules?.scale)
+ }, [JSON.stringify(disciplines)])
+
+ const disciplinesError = errors?.climbList?.[index]?.grade?.message as string
+
+ const gradeScaleDisplay = gradeScale?.displayName?.toUpperCase() ?? 'Unknown'
+ return (
+
+
+
+ Grade
+ Context={gradeContext.toUpperCase()}
+ Scale={gradeScaleDisplay}
+
+
+
+
+ {disciplinesError != null
+ ? (
+
+ {disciplinesError != null && disciplinesError}
+
)
+ : null}
+
+
+ )
+}
+
+interface ValidationRules {
+ rules: RulesType
+ scale: ReturnType
+}
+
+const getGradeValationRules = (gradeContext: GradeContexts, disciplines: Partial): ValidationRules | undefined => {
+ const gradescales = gradeContextToGradeScales?.[gradeContext]
+ if (gradescales == null) {
+ throw new Error('Unknown grade context')
+ }
+
+ const getValidationRules = (discipline: keyof ClimbDisciplineRecord): ValidationRules => {
+ const gradeScale = getScale(gradescales[discipline])
+
+ const isValidGrade = (userInput: string): string | undefined => {
+ if (userInput == null || userInput === '') return undefined // possible to have unknown grade (Ex: route under development)
+ const score = gradeScale?.getScore(userInput) ?? -1
+ return Array.isArray(score) || score >= 0 ? undefined : 'Invalid grade'
+ }
+ return {
+ scale: gradeScale,
+ rules: {
+ validate: {
+ isValidGrade
+ }
+ }
+ }
+ }
+
+ // Processing priority
+ if (disciplines?.tr ?? false) {
+ return getValidationRules('tr')
+ }
+
+ if (disciplines?.sport ?? false) {
+ return getValidationRules('sport')
+ }
+
+ if (disciplines?.trad ?? false) {
+ return getValidationRules('trad')
+ }
+
+ if (disciplines?.bouldering ?? false) {
+ return getValidationRules('bouldering')
+ }
+
+ if (disciplines?.deepwatersolo ?? false) {
+ return getValidationRules('deepwatersolo')
+ }
+
+ if (disciplines?.aid ?? false) {
+ return getValidationRules('aid')
+ }
+
+ if (disciplines?.ice ?? false) {
+ return getValidationRules('ice')
+ }
+
+ if (disciplines?.mixed ?? false) {
+ return getValidationRules('mixed')
+ }
+
+ if (disciplines?.alpine ?? false) {
+ return getValidationRules('alpine')
+ }
+
+ if (disciplines?.snow ?? false) {
+ return getValidationRules('snow')
+ }
+
+ return undefined
+}
diff --git a/src/app/editArea/[slug]/manageClimbs/components/DynamicClimbInputList.tsx b/src/app/editArea/[slug]/manageClimbs/components/DynamicClimbInputList.tsx
new file mode 100644
index 000000000..d6810e112
--- /dev/null
+++ b/src/app/editArea/[slug]/manageClimbs/components/DynamicClimbInputList.tsx
@@ -0,0 +1,120 @@
+import { useCallback, useEffect, useState } from 'react'
+import { useFieldArray, useFormContext } from 'react-hook-form'
+import { ListPlus, LightbulbFilament } from '@phosphor-icons/react/dist/ssr'
+
+import { BaseInput } from '@/components/ui/form/Input'
+import { DisciplinesSelection } from './DisciplinesSelection'
+import { GradeContexts } from '@/js/grades/Grade'
+import { AREA_NAME_FORM_VALIDATION_RULES } from '@/components/edit/EditAreaForm'
+import { AddClimbsFormData } from './AddClimbsForm'
+import { defaultDisciplines } from '@/js/grades/util'
+import { RulesType } from '@/js/types'
+
+export const CLIMB_ARRAY_FIELD_NAME = 'climbList'
+const EMPTY_ENTRY_PER_CLICK = 5
+const MAX_EMPTY_ENTRIES = 30
+
+/**
+ * New climb entry form
+ */
+export const DynamicClimbInputList: React.FC<{ parentAreaUuid: string, gradeContext: GradeContexts }> = ({ parentAreaUuid, gradeContext }) => {
+ const {
+ fields,
+ append,
+ remove
+ } = useFieldArray({
+ name: CLIMB_ARRAY_FIELD_NAME
+ })
+
+ const addMoreFields = useCallback(() => {
+ for (let i = 0; i < EMPTY_ENTRY_PER_CLICK; i++) {
+ append({ name: '', grade: '', disciplines: defaultDisciplines() })
+ }
+ }, [append])
+
+ return (
+
+
+ {fields.map((item, index) => {
+ return (
+
+ remove(removeIndex)}
+ />
+
+ )
+ })}
+
+
+
+
+
+
Entering more than one climb?
+
+ = MAX_EMPTY_ENTRIES}
+ >
+ Bulk entry
+
+
+
+
+
+ )
+}
+
+const NewClimbInput: React.FC<{ gradeContext: GradeContexts, index: number, count: number, onRemove: (index: number) => void }> =
+ ({ gradeContext, index, count, onRemove }) => {
+ const formContext = useFormContext()
+ const { getValues } = formContext
+ const inputName = `${CLIMB_ARRAY_FIELD_NAME}[${index}].name`
+
+ const { formState: { errors } } = formContext
+ const errorMsg = errors?.[CLIMB_ARRAY_FIELD_NAME]?.[index]?.name?.message as string ?? null
+
+ const [validationRules, setValidationRules] = useState()
+ useEffect(() => {
+ const disciplines = getValues(`climbList.${index}.disciplines`)
+ if (disciplines != null && Object.values(disciplines).some(el => el)) {
+ setValidationRules(AREA_NAME_FORM_VALIDATION_RULES)
+ } else {
+ setValidationRules(undefined)
+ }
+ })
+ return (
+
+
Climb name {count > 1 ? index + 1 : ''}
+
+
+ {count > 1 &&
+ onRemove(index)}
+ >
+ Remove
+ }
+
+
+
+ {errorMsg != null && {errorMsg} }
+
+
+
+
+
+ )
+ }
diff --git a/src/app/editArea/[slug]/manageClimbs/page.tsx b/src/app/editArea/[slug]/manageClimbs/page.tsx
new file mode 100644
index 000000000..d9a8119ee
--- /dev/null
+++ b/src/app/editArea/[slug]/manageClimbs/page.tsx
@@ -0,0 +1,33 @@
+import { Metadata } from 'next'
+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'
+
+// Opt out of caching for all data requests in the route segment
+export const dynamic = 'force-dynamic'
+export const fetchCache = 'force-no-store' // opt out of Nextjs version of 'fetch'
+
+// Page metadata
+export async function generateMetadata ({ params }: DashboardPageProps): Promise {
+ const { area: { areaName } } = await getPageDataForEdit(params.slug, 'cache-first')
+ return {
+ title: `Manage climbs in area ${areaName}`
+ }
+}
+
+export default async function AddClimbsPage ({ params: { slug } }: DashboardPageProps): Promise {
+ const { area } = await getPageDataForEdit(slug)
+ const { areaName, uuid, gradeContext, metadata } = area
+ const { leaf, isBoulder } = metadata
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/edit/EditAreaForm.tsx b/src/components/edit/EditAreaForm.tsx
index 160b54bf9..fbe5a3911 100644
--- a/src/components/edit/EditAreaForm.tsx
+++ b/src/components/edit/EditAreaForm.tsx
@@ -27,7 +27,7 @@ export const AREA_LATLNG_FORM_VALIDATION_RULES: RulesType = {
validate: {
validLatLng:
(v: string): string | undefined => {
- if (v.length === 0) return undefined
+ if ((v?.length ?? 0) === 0) return undefined
return LATLNG_PATTERN.test(v) ? undefined : 'Invalid coordinates. Example: 46.433333,11.85'
}
}
diff --git a/src/js/grades/Grade.ts b/src/js/grades/Grade.ts
index b875a45a8..76f107f17 100644
--- a/src/js/grades/Grade.ts
+++ b/src/js/grades/Grade.ts
@@ -27,7 +27,9 @@ export enum GradeContexts {
export type ClimbGradeContextType = Record
-const gradeContextToGradeScales: Partial> = {
+export type GradeContextType = Partial>
+
+export const gradeContextToGradeScales: GradeContextType = {
[GradeContexts.AU]: {
trad: GradeScales.EWBANK,
sport: GradeScales.EWBANK,
diff --git a/src/js/hooks/useUpdateAreasCmd.tsx b/src/js/hooks/useUpdateAreasCmd.tsx
index fefcacf57..02eb3b748 100644
--- a/src/js/hooks/useUpdateAreasCmd.tsx
+++ b/src/js/hooks/useUpdateAreasCmd.tsx
@@ -191,6 +191,6 @@ export const refreshPage = async (url: string): Promise => {
} catch {}
}
-const updateAreaPageCache = async (uuid: string): Promise => {
+export const updateAreaPageCache = async (uuid: string): Promise => {
await fetch(`/api/updateAreaPage?uuid=${uuid}`)
}
diff --git a/src/js/hooks/useUpdateClimbsCmd.tsx b/src/js/hooks/useUpdateClimbsCmd.tsx
index b54a4e388..56acfcf58 100644
--- a/src/js/hooks/useUpdateClimbsCmd.tsx
+++ b/src/js/hooks/useUpdateClimbsCmd.tsx
@@ -3,7 +3,7 @@ import { GraphQLError } from 'graphql'
import { toast } from 'react-toastify'
import { graphqlClient } from '../graphql/Client'
import { MUTATION_UPDATE_CLIMBS, MUTATION_DELETE_CLIMBS, UpdateClimbsInput, DeleteManyClimbsInputType } from '../graphql/gql/contribs'
-import { refreshPage } from './useUpdateAreasCmd'
+import { refreshPage, updateAreaPageCache } from './useUpdateAreasCmd'
type UpdateClimbCmdType = (input: UpdateClimbsInput) => Promise
type DeleteClimbsCmdType = (idList: string[]) => Promise
@@ -45,7 +45,7 @@ export default function useUpdateClimbsCmd ({ parentId, accessToken = '', onUpda
})
// Rebuild the parent area page
- void refreshPage(`/api/revalidate?s=${parentId}`)
+ void updateAreaPageCache(parentId)
toast('Climbs updated ✨')
@@ -64,6 +64,10 @@ export default function useUpdateClimbsCmd ({ parentId, accessToken = '', onUpda
)
const updateClimbCmd: UpdateClimbCmdType = async (input) => {
+ if (input.changes.length === 0) {
+ toast('Nothing to submit. Please check your input.')
+ return
+ }
await updateClimbsApi({
variables: {
input
@@ -83,7 +87,7 @@ export default function useUpdateClimbsCmd ({ parentId, accessToken = '', onUpda
MUTATION_DELETE_CLIMBS, {
client: graphqlClient,
onCompleted: (data) => {
- void refreshPage(`/api/revalidate?s=${parentId}`)
+ void updateAreaPageCache(parentId)
toast('Climbs deleted ✔️')
if (onDeleteCompleted != null) {
onDeleteCompleted(data)