Created
July 7, 2025 03:18
-
-
Save wildfrontend/dc4404d005b13ff13479b9099aaffb93 to your computer and use it in GitHub Desktop.
tab form vaildation error switch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AddAuthor } from '@gosugamers/backoffice-api-types'; | |
import { DevTool } from '@hookform/devtools'; | |
import { Button, Card, Space, Tabs, TabsProps } from 'antd'; | |
import { Base64 } from 'js-base64'; | |
import _ from 'lodash'; | |
import React, { | |
ComponentProps, | |
useCallback, | |
useEffect, | |
useMemo, | |
useState, | |
} from 'react'; | |
import { | |
FormProvider, | |
UseFormReturn, | |
useForm, | |
useFormContext, | |
} from 'react-hook-form'; | |
import { toast } from 'react-toastify'; | |
import { createArticle, updapteArticle } from 'apis/articles'; | |
import ErrorMessage from 'components/error/message'; | |
import { ApiErrorCode } from 'constants/api-code'; | |
import { EXPIRATION_TIME_MS } from 'constants/editorial'; | |
import { ArticleFormValues, ArticlePermission } from 'types/pages/articles'; | |
import { axiosError } from 'utils/global/axios'; | |
import dayjs from 'utils/global/day-js'; | |
import LoadPreviousButton from '../editor-form/load-previous'; | |
import ContentForm from './content'; | |
import ExtraForm from './extra'; | |
import RatingForm from './rating'; | |
import SEOForm from './seo'; | |
import SettingsForm from './settings'; | |
/** | |
* ANCHOR Form Panel | |
*/ | |
const items: TabsProps['items'] = [ | |
{ | |
key: '1', | |
label: `Content`, | |
children: <ContentForm />, | |
}, | |
{ | |
key: '2', | |
label: `Article settings`, | |
children: <SettingsForm />, | |
}, | |
{ | |
key: '3', | |
label: `Related contents`, | |
children: <ExtraForm />, | |
}, | |
{ | |
key: '4', | |
label: `Reviews & ratings`, | |
children: <RatingForm />, | |
}, | |
{ | |
key: '5', | |
label: `SEO tools`, | |
children: <SEOForm />, | |
}, | |
]; | |
type ArticleFormKey = ObjectDotNotation<ArticleFormValues>; | |
export enum ArticleFormMode { | |
create = 'create', | |
edit = 'edit', | |
view = 'view', | |
} | |
const contentFormKeys: ArticleFormKey[] = [ | |
'title', | |
'teaser', | |
'headlineImageText', | |
'headlineImgFile', | |
'content', | |
]; | |
const contentFormRequiredKeys: ArticleFormKey[] = [ | |
'title', | |
'teaser', | |
'content', | |
]; | |
const settingsFormKeys: ArticleFormKey[] = [ | |
// 'subTypeId', | |
'frontendId', | |
'localeId', | |
'siteSectionIds', | |
'isFeatured', | |
'isPublished', | |
'isSectionSticky', | |
'publishedAt', | |
// 'timeSpent', | |
'isTgPush', | |
'authors', | |
]; | |
const settingsFormRequiredKeys: ArticleFormKey[] = [ | |
// 'subTypeId', | |
'frontendId', | |
'localeId', | |
'siteSectionIds', | |
'isFeatured', | |
'isSectionSticky', | |
// 'timeSpent', | |
'authors', | |
]; | |
const extraFormKey: ArticleFormKey[] = [ | |
'teamIds', | |
'tournamentIds', | |
// 'quickpoll', | |
]; | |
const seoFormKey: ArticleFormKey[] = ['metadata']; | |
const formatDefaultValues = ( | |
initialValues?: Partial<ArticleFormValues> | |
): Partial<ArticleFormValues> => { | |
if (initialValues) { | |
return { | |
// subTypeId: initialValues?.subTypeId, | |
title: initialValues?.title, | |
teaser: initialValues?.teaser, | |
content: initialValues?.content, | |
frontendId: initialValues?.frontendId, | |
localeId: initialValues?.localeId, | |
headlineImageText: initialValues?.headlineImageText, | |
headlineImageCaption: initialValues?.headlineImageCaption, | |
siteSectionIds: initialValues?.siteSectionIds, | |
isSectionSticky: initialValues?.isSectionSticky, | |
isFeatured: initialValues?.isFeatured, | |
isPublished: initialValues?.isPublished, | |
publishedAt: initialValues.publishedAt, | |
isProofread: initialValues?.isProofread, | |
// timeSpentProofreading: initialValues?.timeSpentProofreading, | |
// timeSpent: initialValues?.timeSpent, | |
teamIds: initialValues?.teamIds, | |
playerIds: initialValues?.playerIds, | |
authors: initialValues?.authors, | |
// quickpoll: initialValues?.quickpoll, | |
metadata: initialValues?.metadata, | |
isTgPush: initialValues?.isTgPush, | |
ratings: initialValues?.ratings, | |
recommendation: initialValues?.recommendation, | |
}; | |
} | |
return { | |
isFeatured: false, | |
isSectionSticky: false, | |
isTgPush: true, | |
}; | |
}; | |
const useKeepPreviousValues = ( | |
methods: UseFormReturn<ArticleFormValues, any, undefined> | |
) => { | |
const formData = methods.watch(); | |
const articleStoreKey = `articles_create_${localStorage.getItem('userId')}`; | |
const storeFormValues = useCallback( | |
(data: ArticleFormValues) => { | |
const metadata = data.metadata?.filter( | |
(value) => value.id && value.content | |
); | |
localStorage.setItem( | |
articleStoreKey, | |
JSON.stringify({ | |
data: { | |
...data, | |
// filter empty | |
metadata: (metadata?.length ?? 0) > 0 ? metadata : undefined, | |
}, | |
expirationTime: dayjs() | |
.add(EXPIRATION_TIME_MS, 'millisecond') | |
.toISOString(), | |
}) | |
); | |
}, | |
[articleStoreKey] | |
); | |
const loadPreviousValues = useCallback(() => { | |
const previousValues = localStorage.getItem(articleStoreKey); | |
if (previousValues) { | |
methods.reset(JSON.parse(previousValues).data); | |
} | |
}, [articleStoreKey, methods]); | |
const removePreviousValues = useCallback(() => { | |
console.log('remove'); | |
localStorage.removeItem(articleStoreKey); | |
}, [articleStoreKey]); | |
const [hasPreviousValues, setHasPreviousValues] = useState(false); | |
useEffect(() => { | |
if (!_.isEmpty(methods.formState.dirtyFields)) { | |
storeFormValues(formData); | |
} | |
}, [formData, methods.formState.dirtyFields, storeFormValues]); | |
// when expired | |
useEffect(() => { | |
const interval = setInterval(() => { | |
const previousValues = localStorage.getItem(articleStoreKey); | |
if (previousValues) { | |
const { expirationTime } = JSON.parse(previousValues); | |
if (dayjs(expirationTime).isBefore(dayjs())) { | |
removePreviousValues(); | |
} | |
} | |
setHasPreviousValues(!!previousValues); | |
}, 1000); | |
return () => clearInterval(interval); | |
}, [removePreviousValues, articleStoreKey]); | |
return { | |
hasPreviousValues, | |
loadPreviousValues, | |
removePreviousValues, | |
}; | |
}; | |
const useArticleForm = ({ | |
initialValues, | |
formMode, | |
permission, | |
}: { | |
initialValues: ComponentProps<typeof ArticleForm>['initialValues']; | |
formMode: ComponentProps<typeof ArticleForm>['formMode']; | |
permission: ComponentProps<typeof ArticleForm>['permission']; | |
}) => { | |
const methods = useForm<ArticleFormValues>({ | |
defaultValues: formatDefaultValues(initialValues), | |
}); | |
const hasFormError = useMemo( | |
() => (list: ArticleFormKey[]) => { | |
return list.some((key) => | |
Object.keys(methods.formState.errors).includes(key) | |
); | |
}, | |
[methods.formState.errors] | |
); | |
const needRequired = useMemo( | |
() => (list: ArticleFormKey[]) => { | |
return !list.every((key) => { | |
return Object.keys(methods.getValues()).includes(key); | |
}); | |
}, | |
[methods] | |
); | |
const isCreate = useMemo( | |
() => formMode === ArticleFormMode.create, | |
[formMode] | |
); | |
const isEdit = useMemo(() => formMode === ArticleFormMode.edit, [formMode]); | |
const { hasPreviousValues, loadPreviousValues, removePreviousValues } = | |
useKeepPreviousValues(methods); | |
return { | |
...methods, | |
formMode, | |
initialValues, | |
isCreate, | |
isEdit, | |
permission, | |
hasPreviousValues, | |
hasFormError, | |
needRequired, | |
loadPreviousValues, | |
removePreviousValues, | |
}; | |
}; | |
export const useArticleFormContext = () => { | |
const methods = useFormContext<ArticleFormValues>(); | |
return { ...methods } as ReturnType<typeof useArticleForm>; | |
}; | |
/** | |
* ANCHOR Form | |
*/ | |
type ArticleCreateFormProps = { | |
initialValues?: undefined; | |
formMode: ArticleFormMode.create; | |
permission: Partial<ArticlePermission>; | |
onSuccess?: (value: Awaited<ReturnType<typeof createArticle>>) => void; | |
onError?: (error: any) => void; | |
onCancel?: () => void; | |
}; | |
type ArticleEditFormProps = { | |
initialValues?: Partial<ArticleFormValues>; | |
formMode: ArticleFormMode.edit; | |
permission: Partial<ArticlePermission>; | |
onSuccess?: (value: Awaited<ReturnType<typeof updapteArticle>>) => void; | |
onError?: (error: any) => void; | |
onCancel?: () => void; | |
}; | |
type ArticleViewFormProps = { | |
initialValues?: Partial<ArticleFormValues>; | |
formMode: ArticleFormMode.view; | |
permission: Partial<ArticlePermission>; | |
onSuccess?: (value: Awaited<ReturnType<typeof updapteArticle>>) => void; | |
onError?: (error: any) => void; | |
onCancel?: () => void; | |
}; | |
const ArticleForm: React.FC< | |
ArticleCreateFormProps | ArticleEditFormProps | ArticleViewFormProps | |
> = ({ initialValues, formMode, permission, onSuccess, onError, onCancel }) => { | |
const methods = useArticleForm({ formMode, permission, initialValues }); | |
const [tab, setTab] = useState<string>(items[0]['key']); | |
const onSubmit = useCallback(async () => { | |
await methods.trigger(); | |
const isUnfinishContentValue = methods.needRequired( | |
contentFormRequiredKeys | |
); | |
const isUnfinishSettingsValue = methods.needRequired( | |
settingsFormRequiredKeys | |
); | |
const isContentFormError = methods.hasFormError(contentFormKeys); | |
const isSettingsFormError = methods.hasFormError(settingsFormKeys); | |
const isExtraFormError = methods.hasFormError(extraFormKey); | |
const isSeoFormError = methods.hasFormError(seoFormKey); | |
if (isUnfinishContentValue || isContentFormError) { | |
setTab(items[0]['key']); | |
} else if (isUnfinishSettingsValue || isSettingsFormError) { | |
setTab(items[1]['key']); | |
} else if (isExtraFormError) { | |
setTab(items[2]['key']); | |
} else if (isSeoFormError) { | |
setTab(items[4]['key']); | |
} else { | |
methods.handleSubmit(async (data) => { | |
if (formMode === ArticleFormMode.edit) { | |
try { | |
const isClearAllPlayers = | |
data.playerIds && data.playerIds.length === 0 ? true : false; | |
const isClearAllTeams = | |
data.teamIds && data.teamIds.length === 0 ? true : false; | |
const isClearAllTournaments = | |
data.tournamentIds && data.tournamentIds.length === 0 | |
? true | |
: false; | |
const result = await updapteArticle({ | |
...data, | |
id: initialValues?.id, | |
isClearAllPlayers, | |
isClearAllTeams, | |
isClearAllTournaments, | |
metadata: | |
data.metadata && data.metadata.length > 0 | |
? JSON.stringify(data.metadata) | |
: undefined, | |
content: Base64.encode(data.content), | |
authors: data.authors.map<AddAuthor>((o) => ({ | |
userId: o.userId!, | |
order: o.order!, | |
})), | |
}); | |
console.log('success', result); | |
toast.success('Updated successfully'); | |
onSuccess?.(result); | |
} catch (error) { | |
toast.error( | |
<ErrorMessage | |
error={axiosError<ApiErrorCode>(error)} | |
message={{ moduleName: 'Article', action: 'update' }} | |
/> | |
); | |
onError?.(error); | |
} | |
} else { | |
console.log('data', data.metadata); | |
try { | |
const result = await createArticle({ | |
...data, | |
metadata: | |
data.metadata && data.metadata.length > 0 | |
? JSON.stringify(data.metadata) | |
: undefined, | |
content: Base64.encode(data.content), | |
authors: data.authors.map<AddAuthor>((o) => ({ | |
userId: o.userId!, | |
order: o.order!, | |
})), | |
}); | |
console.log('success', result); | |
methods.removePreviousValues(); | |
toast.success('Create article successfully'); | |
onSuccess?.(result); | |
} catch (error) { | |
toast.error( | |
<ErrorMessage | |
error={axiosError<ApiErrorCode>(error)} | |
message={{ moduleName: 'Article', action: 'create' }} | |
/> | |
); | |
onError?.(error); | |
} | |
} | |
})(); | |
} | |
}, [methods, initialValues?.id, formMode, onSuccess, onError]); | |
return ( | |
<FormProvider {...methods}> | |
<Card> | |
<Tabs | |
activeKey={tab} | |
className="mx-auto mt-[20px] max-w-[890px]" | |
items={items} | |
onChange={(activeKey) => { | |
setTab(activeKey); | |
}} | |
tabBarExtraContent={ | |
<div className="relative"> | |
{formMode === ArticleFormMode.create && ( | |
<div className="absolute -top-[40px] mb-2"> | |
<LoadPreviousButton /> | |
</div> | |
)} | |
<Space wrap> | |
<Button | |
disabled={formMode === ArticleFormMode.view} | |
loading={methods.formState.isSubmitting} | |
onClick={onSubmit} | |
type="primary" | |
> | |
Save & Close | |
</Button> | |
<Button | |
disabled={formMode === ArticleFormMode.view} | |
onClick={onCancel} | |
> | |
Cancel | |
</Button> | |
</Space> | |
</div> | |
} | |
/> | |
</Card> | |
<DevTool control={methods.control} /> | |
</FormProvider> | |
); | |
}; | |
export default ArticleForm; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment