Skip to content

Commit

Permalink
wip: add latlng and isDirty state to submit btn
Browse files Browse the repository at this point in the history
  • Loading branch information
viet nguyen committed Nov 9, 2023
1 parent 5f1683e commit ed207c5
Show file tree
Hide file tree
Showing 12 changed files with 141 additions and 43 deletions.
25 changes: 13 additions & 12 deletions src/app/area/[...slug]/SingleEntryForm.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
'use client'
import { ReactNode, useState } from 'react'
import { FieldValues, FormProvider, useForm, DefaultValues } from 'react-hook-form'
import { PencilIcon } from '@heroicons/react/24/outline'
import { AREA_NAME_FORM_VALIDATION_RULES } from '@/components/edit/EditAreaForm'

import { InplaceTextInput } from '@/components/editor'
import { Input } from '@/components/ui/form'
import { ReactNode } from 'react'
import { FieldValues, FormProvider, useForm, DefaultValues, ValidationMode } from 'react-hook-form'

export interface SingleEntryFormProps<T> {
children: ReactNode
initialValues: DefaultValues<T>
submitHandler: (formData: T) => void
validationMode?: keyof ValidationMode
submitHandler: (formData: T) => Promise<void>
}

export function SingleEntryForm<T extends FieldValues> ({ children, initialValues, submitHandler }: SingleEntryFormProps<T>): ReactNode {
export function SingleEntryForm<T extends FieldValues> ({ children, initialValues, submitHandler, validationMode = 'onBlur' }: SingleEntryFormProps<T>): ReactNode {
const form = useForm<T>({
mode: 'onBlur',
mode: validationMode,
defaultValues: { ...initialValues }
})

const { handleSubmit } = form
const { handleSubmit, reset } = form

return (
<FormProvider {...form}>
{/* eslint-disable-next-line */}
<form onSubmit={handleSubmit(submitHandler)}>
<form onSubmit={handleSubmit(async data => {
await submitHandler(data)
reset() // clear isDirty flag
}
)}
>
{children}
</form>
</FormProvider>
Expand Down
32 changes: 25 additions & 7 deletions src/app/area/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
import { notFound, redirect } from 'next/navigation'
import slugify from 'slugify'
import { validate } from 'uuid'

import PhotoMontage from '@/components/media/PhotoMontage'
import { getArea } from '@/js/graphql/getArea'
import { StickyHeaderContainer } from '@/app/components/ui/StickyHeaderContainer'

import BreadCrumbs from '@/components/ui/BreadCrumbs'

export default async function Page ({ params }: { params: { slug: string[] } }): Promise<any> {
if (params.slug.length === 0) {
notFound()
export interface PageWithCatchAllUuidProps {
params: {
slug: string[]
}
const areaUuid = params.slug[0]
}

export default async function Page ({ params }: PageWithCatchAllUuidProps): Promise<any> {
const areaUuid = parseUuidAsFirstParam({ params })
const pageData = await getArea(areaUuid)
if (pageData == null) {
notFound()
}

// const secondSlug = params.slug?.[1] ?? undefined

const optionalNamedSlug = slugify(params.slug?.[1] ?? '', { lower: true, strict: true }).substring(0, 50)

const { area, getAreaHistory } = pageData
const { area } = pageData

const photoList = area?.media ?? []
const { uuid, pathTokens, ancestors, areaName, content } = area
Expand Down Expand Up @@ -77,3 +79,19 @@ export default async function Page ({ params }: { params: { slug: string[] } }):
</article>
)
}

/**
* Extract and validate uuid as the first param in a catch-all route
*/
export const parseUuidAsFirstParam = ({ params }: PageWithCatchAllUuidProps): string => {
if (params.slug.length === 0) {
notFound()
}

const uuid = params.slug[0]
if (!validate(uuid)) {
console.error('Invalid uuid', uuid)
notFound()
}
return uuid
}
9 changes: 7 additions & 2 deletions src/app/editArea/[slug]/general/AreaDescriptionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { AREA_DESCRIPTION_FORM_VALIDATION_RULES } from '@/components/edit/EditAr
import useUpdateAreasCmd from '@/js/hooks/useUpdateAreasCmd'
import { MDTextArea } from '@/components/ui/form/MDTextArea'

/**
* Area description edit form
* @param param0
* @returns
*/
export const AreaDescriptionForm: React.FC<{ initialValue: string, uuid: string }> = ({ initialValue, uuid }) => {
const session = useSession({ required: true })
const { updateOneAreaCmd } = useUpdateAreasCmd(
Expand All @@ -18,8 +23,8 @@ export const AreaDescriptionForm: React.FC<{ initialValue: string, uuid: string
return (
<SingleEntryForm<{ description: string }>
initialValues={{ description: initialValue }}
submitHandler={({ description }) => {
void updateOneAreaCmd({ description })
submitHandler={async ({ description }) => {
await updateOneAreaCmd({ description })
}}
>
<MDTextArea
Expand Down
40 changes: 40 additions & 0 deletions src/app/editArea/[slug]/general/AreaLatLngForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client'
import { useSession } from 'next-auth/react'

import { SingleEntryForm } from 'app/area/[...slug]/SingleEntryForm'
import { AREA_LATLNG_FORM_VALIDATION_RULES } from '@/components/edit/EditAreaForm'
import { DashboardInput } from '@/components/ui/form/Input'
import useUpdateAreasCmd from '@/js/hooks/useUpdateAreasCmd'
import { parseLatLng } from '@/components/crag/cragSummary'

export const AreaLatLngForm: React.FC<{ initLat: number, initLng: number, uuid: string }> = ({ uuid, initLat, initLng }) => {
const session = useSession({ required: true })
const { updateOneAreaCmd } = useUpdateAreasCmd({
areaId: uuid,
accessToken: session?.data?.accessToken as string
}
)
const latlngStr = `${initLat.toString()},${initLng.toString()}`
return (
<SingleEntryForm<{ latlngStr: string }>
initialValues={{ latlngStr }}
submitHandler={({ latlngStr }) => {
const latlng = parseLatLng(latlngStr)
if (latlng != null) {
void updateOneAreaCmd({ lat: latlng[0], lng: latlng[1] })
} else {
console.error('# form validation should catch this error')
}
}}
>
<DashboardInput
name='latlng'
label='Coordinates'
description='Specify the approximate latitude and longitude. The location may be where the trail meets the wall or in the middle of a long wall.'
helper='Please use <latitude>, <longitude>'
className='w-80'
registerOptions={AREA_LATLNG_FORM_VALIDATION_RULES}
/>
</SingleEntryForm>
)
}
5 changes: 2 additions & 3 deletions src/app/editArea/[slug]/general/AreaNameForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { useSession } from 'next-auth/react'

import { SingleEntryForm } from 'app/area/[...slug]/SingleEntryForm'
import { AREA_DESCRIPTION_FORM_VALIDATION_RULES } from '@/components/edit/EditAreaForm'
import { DashboardInput } from '@/components/ui/form/Input'
import useUpdateAreasCmd from '@/js/hooks/useUpdateAreasCmd'

Expand All @@ -16,8 +15,8 @@ export const AreaNameForm: React.FC<{ initialValue: string, uuid: string }> = ({
return (
<SingleEntryForm<{ areaName: string }>
initialValues={{ areaName: initialValue }}
submitHandler={({ areaName }) => {
void updateOneAreaCmd({ areaName })
submitHandler={async ({ areaName }) => {
await updateOneAreaCmd({ areaName })
}}
>
<DashboardInput
Expand Down
17 changes: 14 additions & 3 deletions src/app/editArea/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import { notFound } from 'next/navigation'
import { validate } from 'uuid'

import { AreaPageDataProps, getArea } from '@/js/graphql/getArea'
import { AreaNameForm } from './general/AreaNameForm'
import { AreaDescriptionForm } from './general/AreaDescriptionForm'
import { AreaLatLngForm } from './general/AreaLatLngForm'

// Opt out of caching for all data requests in the route segment
export const dynamic = 'force-dynamic'

export interface EditPageProps {
interface Props {
params: {
slug: string
}
}

export default async function AreaEditPage ({ params }: EditPageProps): Promise<any> {
const { area: { areaName, uuid, content: { description } } } = await getPageDataForEdit(params.slug)
export default async function AreaEditPage ({ params }: Props): Promise<any> {
const areaUuid = params.slug
if (!validate(areaUuid)) {
notFound()
}
const { area: { areaName, uuid, content: { description }, metadata: { lat, lng } } } = await getPageDataForEdit(areaUuid)
return (
<section className='w-full flex flex-col gap-y-8'>
<AreaNameForm initialValue={areaName} uuid={uuid} />
<AreaLatLngForm initLat={lat} initLng={lng} uuid={uuid} />
<AreaDescriptionForm initialValue={description} uuid={uuid} />
</section>
)
Expand Down
2 changes: 1 addition & 1 deletion src/components/crag/cragSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ export default function CragSummary ({ area, history }: CragSummaryProps): JSX.E
/**
* Split lat,lng string into lat and lng tuple. Return null if string is invalid.
*/
const parseLatLng = (s: string): [number, number] | null => {
export const parseLatLng = (s: string): [number, number] | null => {
const [latStr, lngStr] = s.split(',')
const lat = parseFloat(latStr)
const lng = parseFloat(lngStr)
Expand Down
13 changes: 9 additions & 4 deletions src/components/editor/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ export interface MarkdownEditorProps {
/**
* Multiline inplace editor with react-hook-form support.
*/
export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ fieldName, initialValue = '', preview = false, reset, placeholder = 'Enter some text' }) => {
export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ fieldName, initialValue = '', preview = false, reset, placeholder = 'Enter some text', rules }) => {
// const { field, fieldState: { error } } = useController({ name: fieldName, rules })

// const onChangeHandler = (arg0: EditorState, arg1: LexicalEditor): void => {
// onChange(arg0, arg1, field)
// }
const config = mdeditorConfig(initialValue, !preview)
return (
<div className='relative border'>
Expand All @@ -36,7 +41,7 @@ export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ fieldName, initi
? (
<>
<RichTextPlugin
contentEditable={<ContentEditable className={config.theme?.input} />}
contentEditable={<ContentEditable data-lpignore='true' className={config.theme?.input} />}
placeholder={<MDPlaceholder text='Nothing to preview' className={config.theme?.placeholder} />}
ErrorBoundary={LexicalErrorBoundary}
/>
Expand All @@ -48,7 +53,7 @@ export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ fieldName, initi
<>
<AutoFocusPlugin />
<PlainTextPlugin
contentEditable={<ContentEditable className={config.theme?.input} />}
contentEditable={<ContentEditable className={config.theme?.input} data-lpignore='true' />}
placeholder={<MDPlaceholder text={placeholder} className={config.theme?.placeholder} />}
ErrorBoundary={LexicalErrorBoundary}
/>
Expand All @@ -57,7 +62,7 @@ export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ fieldName, initi
editable={!preview}
resetSignal={reset}
/>
<ReactHookFormFieldPlugin fieldName={fieldName} />
<ReactHookFormFieldPlugin fieldName={fieldName} rules={rules} />
</>
)}

Expand Down
10 changes: 8 additions & 2 deletions src/components/editor/plugins/ReactHookFormFieldPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import { useEffect } from 'react'
import { $getRoot } from 'lexical'
import { useController } from 'react-hook-form'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { RulesType } from '@/js/types'

export const ReactHookFormFieldPlugin: React.FC<{ fieldName: string }> = ({ fieldName }) => {
const { field } = useController({ name: fieldName })
/**
* Lexical plugin responsible for updating React-hook-form field
*/
export const ReactHookFormFieldPlugin: React.FC<{ fieldName: string, rules?: RulesType }> = ({ fieldName, rules }) => {
const { field } = useController({ name: fieldName, rules })
const [editor] = useLexicalComposerContext()

useEffect(() => {
console.log('#RHF field plugin')
editor.getEditorState().read(() => {
const str = $getRoot().getTextContent()
console.log('#updating form')
field.onChange(str)
})
})
Expand Down
19 changes: 16 additions & 3 deletions src/components/ui/form/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RegisterOptions, useFormContext, UseFormReturn } from 'react-hook-form'
import { FormState, RegisterOptions, useFormContext, UseFormReturn } from 'react-hook-form'
import clx from 'classnames'
import { SpinnerGap } from '@phosphor-icons/react/dist/ssr'

interface InputProps {
label?: string
Expand Down Expand Up @@ -119,7 +120,7 @@ export interface DashboardInputProps {

export const DashboardInput: React.FC<DashboardInputProps> = ({ name, label, description, helper, placeholder, disabled = false, readOnly = false, registerOptions, type = 'text', spellCheck = false, className = '' }) => {
const formContext = useFormContext()
const { formState: { errors } } = formContext
const { formState: { errors, isValid, isSubmitting, isDirty } } = formContext

const error = errors?.[name]
return (
Expand Down Expand Up @@ -150,9 +151,21 @@ export const DashboardInput: React.FC<DashboardInputProps> = ({ name, label, des
(<span className='text-error'>{error?.message as string}</span>)}
{(error == null) && <span className='text-base-content/60'>{helper}</span>}
</label>
<button className='btn btn-primary btn-solid w-full lg:w-fit' type='submit'>Save</button>
<SubmitButton isDirty={isDirty} isSubmitting={isSubmitting} isValid={isValid} />
</div>
</div>
</div>
)
}

export const SubmitButton: React.FC<{ isValid: boolean, isSubmitting: boolean, isDirty: boolean }> = ({
isValid, isSubmitting, isDirty
}) => (
<button
className='btn btn-primary btn-solid w-full lg:w-fit'
disabled={!isValid || isSubmitting || !isDirty}
type='submit'
>
{isSubmitting && <SpinnerGap size={24} className='animate-spin' />} Save
</button>
)
10 changes: 5 additions & 5 deletions src/components/ui/form/MDTextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use client'
import { useState } from 'react'
import { useController } from 'react-hook-form'
import { useController, useFormContext } from 'react-hook-form'
import clx from 'classnames'
import dynamic from 'next/dynamic'

import { RulesType } from '@/js/types'
import { MarkdownEditorProps } from '@/components/editor/MarkdownEditor'
// import { MarkdownEditor } from '@/components/editor/MarkdownEditor'
import { SubmitButton } from './Input'

interface EditorProps {
initialValue?: string
Expand All @@ -23,9 +23,9 @@ interface EditorProps {
* Multiline inplace editor with react-hook-form support.
*/
export const MDTextArea: React.FC<EditorProps> = ({ initialValue = '', name, placeholder = 'Enter some text', label, description, helper, rules }) => {
const { fieldState: { error } } = useController({ name, rules })

const { fieldState: { error }, formState: { isValid, isDirty, isSubmitting } } = useController({ name, rules })
const [preview, setPreview] = useState(false)
console.log('#Formstate', isValid, isDirty)
return (
<div className='card card-compact card-bordered border-base-300 overflow-hidden w-full'>
<div className='form-control'>
Expand Down Expand Up @@ -54,7 +54,7 @@ export const MDTextArea: React.FC<EditorProps> = ({ initialValue = '', name, pla
(<span className='text-error'>{error?.message}</span>)}
{(error == null) && <span className='text-base-content/60'>{helper}</span>}
</label>
<button className='btn btn-primary btn-solid w-full lg:w-fit' type='submit'>Save</button>
<SubmitButton isDirty={isDirty} isSubmitting={isSubmitting} isValid={isValid} />
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/js/graphql/gql/areaById.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export const QUERY_AREA_BY_ID = gql`
}
}
content {
description
description
}
authorMetadata {
... AuthorMetadataFields
Expand Down

0 comments on commit ed207c5

Please sign in to comment.