Skip to content

Commit

Permalink
feat: apply friend modal
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <i@innei.in>
  • Loading branch information
Innei committed Jun 30, 2023
1 parent 4a1ac0f commit 32828d3
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 48 deletions.
114 changes: 100 additions & 14 deletions src/app/friends/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Form, FormInput } from '~/components/ui/form'
import { Loading } from '~/components/ui/loading'
import { BottomToUpTransitionView } from '~/components/ui/transition/BottomToUpTransitionView'
import { shuffle } from '~/lib/_'
import { toast } from '~/lib/toast'
import { useAggregationSelector } from '~/providers/root/aggregation-data-provider'
import { useModalStack } from '~/providers/root/modal-stack-provider'
import { apiClient } from '~/utils/request'
Expand Down Expand Up @@ -285,16 +286,79 @@ const ApplyLinkInfo: FC = () => {
const FormModal = () => {
const { dismissTop } = useModalStack()
const inputs = useRef([
{ name: 'author', placeholder: '昵称 *', required: true },
{ name: 'name', placeholder: '站点标题 *', required: true },
{ name: 'url', placeholder: '网站 * https://', required: true },
{ name: 'avatar', placeholder: '头像链接 * https://', required: true },
{ name: 'email', placeholder: '留下你的邮箱哦 *', required: true },
{
name: 'author',
placeholder: '昵称 *',
rules: [
{
validator: (value: string) => !!value,
message: '昵称不能为空',
},
{
validator: (value: string) => value.length <= 20,
message: '昵称不能超过20个字符',
},
],
},
{
name: 'name',
placeholder: '站点标题 *',
rules: [
{
validator: (value: string) => !!value,
message: '站点标题不能为空',
},
{
validator: (value: string) => value.length <= 20,
message: '站点标题不能超过20个字符',
},
],
},
{
name: 'url',
placeholder: '网站 * https://',
rules: [
{
validator: isHttpsUrl,
message: '请输入正确的网站链接 https://',
},
],
},
{
name: 'avatar',
placeholder: '头像链接 * https://',
rules: [
{
validator: isHttpsUrl,
message: '请输入正确的头像链接 https://',
},
],
},
{
name: 'email',
placeholder: '留下你的邮箱哦 *',

rules: [
{
validator: isEmail,
message: '请输入正确的邮箱',
},
],
},
{
name: 'description',
placeholder: '一句话描述一下自己吧 *',
maxLength: 50,
required: true,

rules: [
{
validator: (value: string) => !!value,
message: '一句话描述一下自己吧',
},
{
validator: (value: string) => value.length <= 50,
message: '一句话描述不要超过50个字啦',
},
],
},
]).current
const [state, setState] = useState({
Expand All @@ -314,13 +378,17 @@ const FormModal = () => {
setValue(e.target.name as keyof typeof state, e.target.value)
}, [])

const handleSubmit = useCallback((e: any) => {
e.preventDefault()
const handleSubmit = useCallback(
(e: any) => {
e.preventDefault()

apiClient.link.applyLink({ ...state }).then(() => {
dismissTop()
})
}, [])
apiClient.link.applyLink({ ...state }).then(() => {
dismissTop()
toast.success('好耶!')
})
},
[state],
)
return (
<Form className="w-[300px] space-y-4 text-center" onSubmit={handleSubmit}>
{inputs.map((input) => (
Expand All @@ -333,9 +401,27 @@ const FormModal = () => {
/>
))}

<StyledButton variant="primary" onClick={handleSubmit}>
<StyledButton variant="primary" type="submit">
好耶!
</StyledButton>
</Form>
)
}

const isHttpsUrl = (value: string) => {
return (
/^https?:\/\/.*/.test(value) &&
(() => {
try {
new URL(value)
return true
} catch {
return false
}
})()
)
}

const isEmail = (value: string) => {
return /^.+@.+\..+$/.test(value)
}
70 changes: 59 additions & 11 deletions src/components/ui/form/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useRef } from 'react'
import { useCallback, useMemo, useRef } from 'react'
import { produce } from 'immer'
import { atom } from 'jotai'
import type {
DetailedHTMLProps,
Expand All @@ -9,20 +10,27 @@ import type { Field } from './types'

import { jotaiStore } from '~/lib/store'

import { FormContext } from './FormContext'
import { FormConfigContext, FormContext, useForm } from './FormContext'

export const Form = (
props: PropsWithChildren<
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
showErrorMessage?: boolean
}
>,
) => {
const { showErrorMessage = true, ...formProps } = props
const fieldsAtom = useRef(atom({})).current
return (
<FormContext.Provider
value={
useRef({
showErrorMessage,
fields: fieldsAtom,

getField: (name: string) => {
// @ts-expect-error
return jotaiStore.get(fieldsAtom)[name]
},
addField: (name: string, field: Field) => {
jotaiStore.set(fieldsAtom, (p) => {
return {
Expand All @@ -43,7 +51,11 @@ export const Form = (
}).current
}
>
<FormInternal {...props} />
<FormConfigContext.Provider
value={useMemo(() => ({ showErrorMessage }), [showErrorMessage])}
>
<FormInternal {...formProps} />
</FormConfigContext.Provider>
</FormContext.Provider>
)
}
Expand All @@ -54,13 +66,49 @@ const FormInternal = (
>,
) => {
const { onSubmit, ...rest } = props
const fieldsAtom = useForm().fields
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()

const fields = jotaiStore.get(fieldsAtom)
for await (const [key, field] of Object.entries(fields)) {
const $ref = field.$ref
if (!$ref) continue
const value = $ref.value
const rules = field.rules
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]
try {
const isOk = await rule.validator(value)
if (!isOk) {
console.error(
`Form validation failed, at field \`${key}\`` +
`, got value \`${value}\``,
)
$ref.focus()
if (rule.message) {
jotaiStore.set(fieldsAtom, (prev) => {
return produce(prev, (draft) => {
;(draft[key] as Field).rules[i].status = 'error'
})
})
}
return
}
} catch (e) {
console.error('validate function throw error', e)
return
}
}
}

onSubmit?.(e)
},
[onSubmit],
)
return (
<form
onSubmit={(e) => {
onSubmit?.(e)
}}
{...rest}
>
<form onSubmit={handleSubmit} {...rest}>
{props.children}
</form>
)
Expand Down
13 changes: 9 additions & 4 deletions src/components/ui/form/FormContext.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { createContext, useContext } from 'react'
import type { Atom } from 'jotai'
import { atom } from 'jotai'
import type { Field } from './types'

const initialFields = atom({} as { [key: string]: Field })
export const FormContext = createContext<{
fields: Atom<{
[key: string]: Field
}>
fields: typeof initialFields

addField: (name: string, field: Field) => void
removeField: (name: string) => void
getField: (name: string) => Field | undefined
}>(null!)

export const FormConfigContext = createContext<{
showErrorMessage?: boolean
}>(null!)
export const useForm = () => {
return useContext(FormContext)
}
export const useFormConfig = () => useContext(FormConfigContext)
77 changes: 64 additions & 13 deletions src/components/ui/form/FormInput.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,95 @@
import { memo, useEffect } from 'react'
import { memo, useCallback, useEffect, useRef } from 'react'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import { selectAtom } from 'jotai/utils'
import type { DetailedHTMLProps, FC, InputHTMLAttributes } from 'react'
import type { FormFieldBaseProps } from './types'

import { AutoResizeHeight } from '~/components/widgets/shared/AutoResizeHeight'
import { jotaiStore } from '~/lib/store'
import { clsxm } from '~/utils/helper'

import { useForm } from './FormContext'
import { useForm, useFormConfig } from './FormContext'

export const FormInput: FC<
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> &
FormFieldBaseProps<string>
> = memo(({ className, rules, ...rest }) => {
> = memo(({ className, rules, onKeyDown, ...rest }) => {
const FormCtx = useForm()
if (!FormCtx) throw new Error('FormInput must be used inside <FormContext />')
const { addField, removeField } = FormCtx
const { showErrorMessage } = useFormConfig()
const { addField, removeField, fields } = FormCtx
const inputRef = useRef<HTMLInputElement>(null)

const errorMessage = useAtomValue(
selectAtom(
fields,
useCallback(
(atomValue) => {
if (!rest.name) return
return atomValue[rest.name]?.rules.find(
(rule) => rule.status === 'error',
)?.message
},
[rest.name],
),
),
)
useEffect(() => {
const name = rest.name
if (!rules) return
if (!name) return

addField(name, {
rules,
$ref: inputRef.current,
})

return () => {
removeField(name)
}
}, [rest.name, rules])

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (onKeyDown) onKeyDown(e)
// const currentField =
jotaiStore.set(fields, (p) => {
return produce(p, (draft) => {
if (!rest.name) return
draft[rest.name].rules.forEach((rule) => {
if (rule.status === 'error') rule.status = 'success'
})
})
})
},
[fields, onKeyDown, rest.name],
)

return (
<input
className={clsxm(
'relative h-12 w-full rounded-lg bg-gray-200/50 px-3 dark:bg-zinc-800/50',
'ring-accent/80 duration-200 focus:ring-2',
'appearance-none',
className,
<>
<input
ref={inputRef}
className={clsxm(
'relative h-12 w-full rounded-lg bg-gray-200/50 px-3 dark:bg-zinc-800/50',
'ring-accent/80 duration-200 focus:ring-2',
'appearance-none',
!!errorMessage && 'ring-2 ring-red-400 dark:ring-orange-700',
className,
)}
type="text"
onKeyDown={handleKeyDown}
{...rest}
/>

{showErrorMessage && (
<AutoResizeHeight duration={0.2}>
<p className="text-left text-sm text-red-400 dark:text-orange-700">
{errorMessage}
</p>
</AutoResizeHeight>
)}
type="text"
{...rest}
/>
</>
)
})

Expand Down
Loading

0 comments on commit 32828d3

Please sign in to comment.