From 32828d33a1a6d93cd5e04b01dfe37dd2e5a9817e Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 30 Jun 2023 21:32:43 +0800 Subject: [PATCH] feat: apply friend modal Signed-off-by: Innei --- src/app/friends/page.tsx | 114 +++++++++++++++--- src/components/ui/form/Form.tsx | 70 +++++++++-- src/components/ui/form/FormContext.tsx | 13 +- src/components/ui/form/FormInput.tsx | 77 ++++++++++-- src/components/ui/form/types.ts | 8 +- .../CommentBox/CommentBoxLegacyForm.tsx | 9 +- src/providers/root/modal-stack-provider.tsx | 2 +- 7 files changed, 245 insertions(+), 48 deletions(-) diff --git a/src/app/friends/page.tsx b/src/app/friends/page.tsx index c6fed23e14..1060413f7b 100644 --- a/src/app/friends/page.tsx +++ b/src/app/friends/page.tsx @@ -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' @@ -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({ @@ -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 (
{inputs.map((input) => ( @@ -333,9 +401,27 @@ const FormModal = () => { /> ))} - + 好耶! ) } + +const isHttpsUrl = (value: string) => { + return ( + /^https?:\/\/.*/.test(value) && + (() => { + try { + new URL(value) + return true + } catch { + return false + } + })() + ) +} + +const isEmail = (value: string) => { + return /^.+@.+\..+$/.test(value) +} diff --git a/src/components/ui/form/Form.tsx b/src/components/ui/form/Form.tsx index b6319b885f..0f7b01b2a9 100644 --- a/src/components/ui/form/Form.tsx +++ b/src/components/ui/form/Form.tsx @@ -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, @@ -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, HTMLFormElement> + DetailedHTMLProps, HTMLFormElement> & { + showErrorMessage?: boolean + } >, ) => { + const { showErrorMessage = true, ...formProps } = props const fieldsAtom = useRef(atom({})).current return ( { + // @ts-expect-error + return jotaiStore.get(fieldsAtom)[name] + }, addField: (name: string, field: Field) => { jotaiStore.set(fieldsAtom, (p) => { return { @@ -43,7 +51,11 @@ export const Form = ( }).current } > - + ({ showErrorMessage }), [showErrorMessage])} + > + + ) } @@ -54,13 +66,49 @@ const FormInternal = ( >, ) => { const { onSubmit, ...rest } = props + const fieldsAtom = useForm().fields + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + 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 ( -
{ - onSubmit?.(e) - }} - {...rest} - > + {props.children}
) diff --git a/src/components/ui/form/FormContext.tsx b/src/components/ui/form/FormContext.tsx index cde6f74afd..7cc68cd9cd 100644 --- a/src/components/ui/form/FormContext.tsx +++ b/src/components/ui/form/FormContext.tsx @@ -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) diff --git a/src/components/ui/form/FormInput.tsx b/src/components/ui/form/FormInput.tsx index 4b17a217b0..d03bc3c84a 100644 --- a/src/components/ui/form/FormInput.tsx +++ b/src/components/ui/form/FormInput.tsx @@ -1,19 +1,40 @@ -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, HTMLInputElement> & FormFieldBaseProps -> = memo(({ className, rules, ...rest }) => { +> = memo(({ className, rules, onKeyDown, ...rest }) => { const FormCtx = useForm() if (!FormCtx) throw new Error('FormInput must be used inside ') - const { addField, removeField } = FormCtx + const { showErrorMessage } = useFormConfig() + const { addField, removeField, fields } = FormCtx + const inputRef = useRef(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 @@ -21,6 +42,7 @@ export const FormInput: FC< addField(name, { rules, + $ref: inputRef.current, }) return () => { @@ -28,17 +50,46 @@ export const FormInput: FC< } }, [rest.name, rules]) + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + 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 ( - + + + {showErrorMessage && ( + +

+ {errorMessage} +

+
)} - type="text" - {...rest} - /> + ) }) diff --git a/src/components/ui/form/types.ts b/src/components/ui/form/types.ts index ad6644116b..aca81b37f6 100644 --- a/src/components/ui/form/types.ts +++ b/src/components/ui/form/types.ts @@ -1,9 +1,13 @@ export interface Rule { message: string - validator: (value: T) => boolean + validator: (value: T) => boolean | Promise } + +type ValidateStatus = 'error' | 'success' export interface Field { - rules: Rule[] + rules: (Rule & { status?: ValidateStatus })[] + + $ref: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null } export type FormFieldBaseProps = { diff --git a/src/components/widgets/comment/CommentBox/CommentBoxLegacyForm.tsx b/src/components/widgets/comment/CommentBox/CommentBoxLegacyForm.tsx index 98f26aaf79..02ac313955 100644 --- a/src/components/widgets/comment/CommentBox/CommentBoxLegacyForm.tsx +++ b/src/components/widgets/comment/CommentBox/CommentBoxLegacyForm.tsx @@ -3,7 +3,7 @@ import { useAtom } from 'jotai' import Image from 'next/image' import { useIsLogged } from '~/atoms' -import { FormInput as FInput } from '~/components/ui/form' +import { FormInput as FInput, Form } from '~/components/ui/form' import { useAggregationSelector } from '~/providers/root/aggregation-data-provider' import { clsxm } from '~/utils/helper' @@ -40,7 +40,10 @@ const FormInput = (props: { fieldKey: FormKey; required?: boolean }) => { } const FormWithUserInfo = () => { return ( -
+
@@ -51,7 +54,7 @@ const FormWithUserInfo = () => {
- + ) } diff --git a/src/providers/root/modal-stack-provider.tsx b/src/providers/root/modal-stack-provider.tsx index 4b87f42698..870817e7d2 100644 --- a/src/providers/root/modal-stack-provider.tsx +++ b/src/providers/root/modal-stack-provider.tsx @@ -134,7 +134,7 @@ export const Modal: Component<{ 'relative flex flex-col overflow-hidden rounded-lg', 'bg-slate-50/90 dark:bg-neutral-900/90', 'p-2 shadow-2xl shadow-stone-300 backdrop-blur-sm dark:shadow-stone-800', - 'max-h-[70vh] min-w-[300px] max-w-[90vw] lg:max-h-[50vh] lg:max-w-[50vw]', + 'max-h-[70vh] min-w-[300px] max-w-[90vw] lg:max-h-[calc(100vh-20rem)] lg:max-w-[50vw]', 'border border-slate-200 dark:border-neutral-800', item.modalClassName, )}