'use client'; import { AssetFormProgress } from '@/components/blocks/asset-form/asset-form-progress'; import { Card, CardContent } from '@/components/ui/card'; import { Form } from '@/components/ui/form'; import { useInvalidateTags } from '@/hooks/use-invalidate-tags'; import type { AssetDetailConfig } from '@/lib/config/assets'; import { queryKeys } from '@/lib/react-query'; import { revalidatePaths } from '@/lib/revalidate'; import { waitForTransactionMining } from '@/lib/wait-for-transaction'; import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks'; import type { QueryKey } from '@tanstack/react-query'; import type { Infer, Schema } from 'next-safe-action/adapters/types'; import type { HookSafeActionFn } from 'next-safe-action/hooks'; import type { ComponentType, ReactElement } from 'react'; import { useEffect, useState } from 'react'; import type { DefaultValues, Path, Resolver } from 'react-hook-form'; import { toast } from 'sonner'; import type { Address } from 'viem'; import { AssetFormButton } from './asset-form-button'; import { AssetFormSkeleton } from './asset-form-skeleton'; type AssetFormMessages = { onCreate: (input: T) => string; onSuccess: (input: T) => string; onError: (input: T, error: Error) => string; }; const defaultMessages = (): AssetFormMessages => ({ onCreate: () => 'Transaction sent. Waiting for confirmation...', onSuccess: () => 'Transaction confirmed successfully', onError: (_, error: Error) => `Transaction failed: ${error.message}`, }); export type CacheInvalidationConfig = { /** * Primary query key to invalidate in the client-side cache. * Related data will be automatically invalidated based on dependencies. * @example ['assets', 'bonds'] */ clientCacheKey: QueryKey; /** * Function to generate the server-side cache path that should be revalidated. * Use this for refreshing Next.js server-side rendered (SSR) or statically generated pages. * @example () => `/admin/bonds` */ serverCachePath?: () => string; }; type FormStepComponent = ComponentType & { validatedFields: readonly (keyof Infer)[]; }; type FormStepElement = ReactElement>; export type AssetFormProps< ServerError, S extends Schema, BAS extends readonly Schema[], CVE, CBAVE, FormContext = unknown, > = { children: FormStepElement | FormStepElement[]; // Accepts a single component or an array of components storeAction: HookSafeActionFn; // biome-ignore lint/suspicious/noExplicitAny: resolverAction: Resolver : any, FormContext>; onClose?: () => void; /** * Asset configuration for automatic cache invalidation. * The form will handle both client-side and server-side cache updates. */ assetConfig: AssetDetailConfig; /** * Optional address for detail forms (e.g., pause, burn, mint). * If provided, the form will invalidate the specific asset's cache. */ address?: Address; submitLabel?: string; submittingLabel?: string; processingLabel?: string; messages?: Partial>>; defaultValues?: DefaultValues>; }; export function AssetForm< ServerError, S extends Schema, BAS extends readonly Schema[], CVE, CBAVE, FormContext = unknown, >({ children, storeAction, resolverAction, onClose, assetConfig, address, submitLabel, submittingLabel, processingLabel, messages: customMessages = {}, defaultValues, }: AssetFormProps) { const defaultMessageHandlers = defaultMessages>(); const messages = { ...defaultMessageHandlers, ...customMessages, }; const [mounted, setMounted] = useState(false); const [currentStep, setCurrentStep] = useState(0); const [, setIsValidating] = useState(false); const invalidateTags = useInvalidateTags(); const totalSteps = Array.isArray(children) ? children.length : 1; useEffect(() => { setMounted(true); }, []); // Build cache invalidation config based on whether this is a detail form or not const cacheInvalidation = { clientCacheKey: address ? queryKeys.asset.detail({ type: assetConfig.queryKey, address }) : [queryKeys.asset.all(assetConfig.queryKey), queryKeys.asset.any()], serverCachePath: address ? () => `/admin/${assetConfig.urlSegment}/${address}` : () => `/admin/${assetConfig.urlSegment}`, } satisfies CacheInvalidationConfig; const { form, handleSubmitWithAction, resetFormAndAction } = useHookFormAction(storeAction, resolverAction, { actionProps: { onSuccess: async ({ data, input }) => { if (!data) { toast.error('Server error. Please try again or contact support if the issue persists.'); resetFormAndAction(); onClose?.(); return; } // Support both single string and array of strings toast.promise(waitForTransactionMining(data), { loading: messages.onCreate(input as Infer), success: async () => { // Invalidate both client and server caches await Promise.all([ invalidateTags.invalidateQueries(cacheInvalidation.clientCacheKey), cacheInvalidation.serverCachePath ? revalidatePaths([cacheInvalidation.serverCachePath()]) : Promise.resolve(), ]); return messages.onSuccess(input as Infer); }, error: (error: Error) => messages.onError(input as Infer, error), }); resetFormAndAction(); onClose?.(); }, onError: (data) => { // biome-ignore lint/suspicious/noConsole: debug purposes console.error(data); if (data.error.serverError) { let errorMessage = 'Unknown server error'; const serverErrorWithContext = data.error.serverError as | (typeof data.error.serverError & { context?: { details: string }; }) | string; if (serverErrorWithContext instanceof Error) { errorMessage = serverErrorWithContext.message; } else if (typeof serverErrorWithContext === 'string') { errorMessage = serverErrorWithContext; } else if ( typeof serverErrorWithContext === 'object' && typeof serverErrorWithContext.context?.details === 'string' ) { errorMessage = serverErrorWithContext.context.details; } toast.error(`Server error: ${errorMessage}. Please try again or contact support if the issue persists.`); } if (data.error.validationErrors) { const errors = Object.entries(data.error.validationErrors) .map(([field, error]) => `${field}: ${error}`) .join('\n'); toast.error(`Validation errors:\n${errors}`); } resetFormAndAction(); onClose?.(); }, }, formProps: { mode: 'onSubmit', criteriaMode: 'all', defaultValues, }, errorMapProps: { joinBy: '\n', }, }); const { errors } = form.formState; const handleNext = async () => { const CurrentStep = Array.isArray(children) ? children[currentStep].type : children.type; const fieldsToValidate = CurrentStep.validatedFields; if (!fieldsToValidate?.length) { setCurrentStep((prev) => Math.min(prev + 1, totalSteps - 1)); return; } setIsValidating(true); // Mark fields as touched for (const field of fieldsToValidate) { // biome-ignore lint/suspicious/noExplicitAny: const value = form.getValues(field as Path : any>); // biome-ignore lint/suspicious/noExplicitAny: form.setValue(field as Path : any>, value, { shouldValidate: true, shouldTouch: true, }); } // Validate fields const results = await Promise.all( fieldsToValidate.map((field) => // biome-ignore lint/suspicious/noExplicitAny: form.trigger(field as Path : any>, { shouldFocus: true }) ) ); if (results.every(Boolean)) { setCurrentStep((prev) => Math.min(prev + 1, totalSteps - 1)); } setIsValidating(false); }; const handlePrev = () => { setCurrentStep((prev) => Math.max(prev - 1, 0)); }; const isLastStep = currentStep === totalSteps - 1; if (!mounted) { return ; } return (
{/* Step indicator */} {totalSteps > 1 && } {/* Current step content */}
{Array.isArray(children) ? children[currentStep] : children} {/* {process.env.NODE_ENV === 'development' && (
{JSON.stringify( { errors: form.formState.errors, isDirty: form.formState.isDirty, dirtyFields: form.formState.dirtyFields, touchedFields: form.formState.touchedFields, isSubmitting: form.formState.isSubmitting, isSubmitted: form.formState.isSubmitted, isValid: form.formState.isValid, }, null, 2 )}
)} */}
{/* Navigation buttons */} 0} submitLabel={submitLabel} submittingLabel={submittingLabel} processingLabel={processingLabel} />
); }