diff --git a/apps/examples/src/layout/Menu.tsx b/apps/examples/src/layout/Menu.tsx index 113292ae..f14d3132 100644 --- a/apps/examples/src/layout/Menu.tsx +++ b/apps/examples/src/layout/Menu.tsx @@ -33,9 +33,9 @@ export const Menu = ({ direction = "left" }) => { Async initial values - {/* + Nested forms - */} + Real life #1 diff --git a/apps/examples/src/pages/_app.tsx b/apps/examples/src/pages/_app.tsx index 51c1ba6d..cee059c8 100644 --- a/apps/examples/src/pages/_app.tsx +++ b/apps/examples/src/pages/_app.tsx @@ -1,6 +1,7 @@ import type { AppProps } from "next/app"; import { ChakraProvider } from "@chakra-ui/react"; import Head from "next/head"; +import { FormizDevTools, FormizProvider } from "@formiz/core"; function MyApp({ Component, pageProps }: AppProps) { return ( @@ -9,7 +10,10 @@ function MyApp({ Component, pageProps }: AppProps) { Formiz Examples - + + + + ); diff --git a/packages/formiz-core/src/FormizDevTools.tsx b/packages/formiz-core/src/FormizDevTools.tsx new file mode 100644 index 00000000..2418406b --- /dev/null +++ b/packages/formiz-core/src/FormizDevTools.tsx @@ -0,0 +1,498 @@ +import { useFormizContext } from "@/FormizProvider"; +import { + fieldExternalInterfaceSelector, + formInterfaceSelector, +} from "@/selectors"; +import { ExposedExternalFieldState, Store } from "@/types"; +import { getFormValues } from "@/utils/form"; +import Logo from "@/utils/Logo"; +import { deepEqual } from "fast-equals"; +import { CSSProperties, useRef, useState } from "react"; +import { StoreApi, UseBoundStore } from "zustand"; + +export const FormizDevTools = () => { + const formizContext = useFormizContext(); + + const [isOpenDebugger, setIsOpenDebugger] = useState(false); + + const storesRef = useRef(formizContext.stores); + storesRef.current = formizContext.stores; + + const storesIds = formizContext.stores.map( + (store) => store.getState().form.id + ); + + const [currentStoreId, setCurrentStoreId] = useState(); + + const currentStore = storesRef.current.find( + (store) => store.getState().form.id === currentStoreId + ); + + if (!isOpenDebugger) { + return ( + + ); + } + + if (!(currentStore || storesRef.current?.[0])) { + return null; + } + return ( +
+ setIsOpenDebugger(false)} + > +

+ Form{" "} + {storesRef.current?.length > 1 ? ( + + ) : ( + {storesIds?.[0]} + )} +

+
+
+ ); +}; + +export const FormizDevToolsContent = ({ + store: useStore, + closeDevTools, + children, +}: { + store: UseBoundStore>; + closeDevTools(): void; + children: React.ReactNode; +}) => { + const { statefields, formValues, formState } = (useStore ?? {})((state) => { + const statefields = Array.from(state.fields.values()).reduce( + (acc, field) => ({ + ...acc, + [field.name]: fieldExternalInterfaceSelector(state)(field), + }), + {} + ); + const formValues = getFormValues(state.fields); + return { statefields, formValues, formState: formInterfaceSelector(state) }; + }, deepEqual); + + const allFieldsEntries = + Object.entries>(statefields); + + const [currentField, setCurrentField] = useState< + [string, ExposedExternalFieldState] | undefined + >(); + + const [search, setSearch] = useState(""); + + const isSearched = (fieldName: string) => + search ? fieldName.toLowerCase().includes(search.toLowerCase()) : true; + + const fieldsBySteps = formState.steps + ?.map((step) => ({ + step, + fields: allFieldsEntries.filter( + (fieldEntry) => + isSearched(fieldEntry[0]) && fieldEntry[1].stepName === step.name + ), + })) + .filter((fieldsStep) => !!fieldsStep.fields?.length); + + return ( +
+
+
+ + {children} +
+ +
+ + +
+
+
+
+
+ setSearch(e.target.value)} + /> +
+ {!!fieldsBySteps?.length && + fieldsBySteps.map((fieldsStep) => ( +
+

{fieldsStep.step.name}

+ setCurrentField(field)} + isActive={(field) => currentField?.[0] === field[0]} + /> +
+ ))} + {!fieldsBySteps?.length && ( + + isSearched(field[0]) + )} + onClick={(field) => setCurrentField(field)} + isActive={(field) => currentField?.[0] === field[0]} + /> + )} +
+
+ {currentField ? ( +
+
+
+ +

{currentField[0]}

+
+
{JSON.stringify(currentField[1], null, 2)}
+
+
+ ) : ( + <> +
+

Values

+
{JSON.stringify(formValues, null, 2)}
+
+
+

State

+
{JSON.stringify(formState, null, 2)}
+ {/*

+ resetKey: {formState.resetKey} +

+

+ isReady: {displayBoolean(formState.isReady)} +

+

+ isSubmitted:{" "} + {displayBoolean(formState.isSubmitted)} +

+

+ isValid: {displayBoolean(formState.isValid)} +

+

+ isValidating:{" "} + {displayBoolean(formState.isValidating)} +

+

+ isPristine:{" "} + {displayBoolean(formState.isPristine)} +

*/} +
+ + )} +
+ {/* {formState.steps?.length ? ( +
+

Steps

+ {formState.steps.map((step) => ( +
+

{step.name}

+

+ index: {step.index} •{" "} + isCurrent: {displayBoolean(step.isCurrent)} •{" "} + isValid: {displayBoolean(step.isValid)} •{" "} + isPristine: {displayBoolean(step.isPristine)}{" "} + • isSubmitted:{" "} + {displayBoolean(step.isSubmitted)} •{" "} + isValidating:{" "} + {displayBoolean(step.isValidating)} •{" "} + isVisited: {displayBoolean(step.isVisited)}{" "} +

+ field.stepName === step.name + )} + /> +
+ ))} +
+ ) : ( + + )} */} +
+
+ ); +}; + +const FieldListing = ({ + fields, + onClick, + isActive, +}: { + fields: [string, ExposedExternalFieldState][]; + onClick(field?: [string, ExposedExternalFieldState]): void; + isActive(field: [string, ExposedExternalFieldState]): boolean; +}) => { + return ( +
+ {fields.map((field) => ( + + ))} +
+ ); +}; + +const displayBoolean = (value: boolean) => (value ? "✅" : "❌"); + +const displayField = (field: ExposedExternalFieldState) => ( +
+
+      value: {JSON.stringify(field.value, null, 2)}
+    
+
+      rawValue: {JSON.stringify(field.rawValue, null, 2)}
+    
+

+ isReady: {displayBoolean(field.isReady)} +

+

+ isTouched: {displayBoolean(field.isTouched)} +

+

+ isPristine: {displayBoolean(field.isPristine)} +

+

+ shouldDisplayError:{" "} + {displayBoolean(field.shouldDisplayError)} +

+

+ errorMessages: {JSON.stringify(field.errorMessages)} +

+

+ isValid: {displayBoolean(field.isValid)} +

+

+ isValidating: {displayBoolean(field.isValidating)} +

+

+ isExternalProcessing:{" "} + {displayBoolean(field.isExternalProcessing)} +

+

+ isDebouncing: {displayBoolean(field.isDebouncing)} +

+
+); + +const FieldEntries = ({ + fieldsEntries, + ...style +}: CSSProperties & { + fieldsEntries: Array<[string, ExposedExternalFieldState]>; +}) => ( +
+

Fields

+
+ {fieldsEntries.map(([fieldName, field]) => ( +
+

+ {fieldName} +

+ {displayField(field)} +
+ ))} +
+
+); diff --git a/packages/formiz-core/src/FormizProvider.tsx b/packages/formiz-core/src/FormizProvider.tsx new file mode 100644 index 00000000..81637902 --- /dev/null +++ b/packages/formiz-core/src/FormizProvider.tsx @@ -0,0 +1,51 @@ +import { FormizProviderProps, Store } from "@/types"; +import { createContext, useContext, useMemo, useRef, useState } from "react"; +import { UseBoundStore, StoreApi } from "zustand"; + +type FormizContextValue = { + stores: Array>>; + registerStore(store: UseBoundStore>): void; + unregisterStore(store: UseBoundStore>): void; +}; + +const FormizContext = createContext({ + stores: [], + registerStore: () => {}, + unregisterStore: () => {}, +}); + +export const useFormizContext = () => { + const formizContext = useContext(FormizContext); + + if (!formizContext) { + throw new Error("No FormizContext set, use FormizProvider to set one"); + } + + return formizContext; +}; + +export const FormizProvider = ({ children }: FormizProviderProps) => { + const [stores, setStores] = useState>>>( + [] + ); + const contextValue = useMemo(() => { + const value: FormizContextValue = { + stores, + registerStore: (newStore) => + setStores((currentStores) => [...currentStores, newStore]), + unregisterStore: (store) => + setStores((currentStores) => + currentStores.filter( + (s) => s.getState().form.id !== store.getState().form.id + ) + ), + }; + return value; + }, [stores]); + + return ( + + {children} + + ); +}; diff --git a/packages/formiz-core/src/index.ts b/packages/formiz-core/src/index.ts index 3226d666..1328c04f 100644 --- a/packages/formiz-core/src/index.ts +++ b/packages/formiz-core/src/index.ts @@ -21,3 +21,5 @@ export type { FormContext, } from "./types"; export type { StepInterface as Step } from "@/selectors"; +export { FormizProvider } from "./FormizProvider"; +export { FormizDevTools } from "./FormizDevTools"; diff --git a/packages/formiz-core/src/selectors.ts b/packages/formiz-core/src/selectors.ts index 86cf6f89..285122b9 100644 --- a/packages/formiz-core/src/selectors.ts +++ b/packages/formiz-core/src/selectors.ts @@ -202,6 +202,7 @@ export const fieldInterfaceSelector = isProcessing, isReady: state.ready, resetKey: state.form.resetKey, + stepName: fieldStep?.name, }; }; diff --git a/packages/formiz-core/src/store.ts b/packages/formiz-core/src/store.ts index 593f22bd..557a0d0e 100644 --- a/packages/formiz-core/src/store.ts +++ b/packages/formiz-core/src/store.ts @@ -19,6 +19,7 @@ import { } from "@/utils/form"; import type { DefaultFormValues, + Field, FormatValue, GetFieldSetValueOptions, NullablePartial, diff --git a/packages/formiz-core/src/types.ts b/packages/formiz-core/src/types.ts index 68288491..34d66029 100644 --- a/packages/formiz-core/src/types.ts +++ b/packages/formiz-core/src/types.ts @@ -152,6 +152,7 @@ export interface ExposedFieldState | "isValidating" | "isExternalProcessing" | "isDebouncing" + | "stepName" > { value: FieldValue | undefined; /** diff --git a/packages/formiz-core/src/useField.ts b/packages/formiz-core/src/useField.ts index 72cbffc2..1e92b530 100644 --- a/packages/formiz-core/src/useField.ts +++ b/packages/formiz-core/src/useField.ts @@ -95,7 +95,7 @@ export const useField = < throw new Error(ERROR_USE_FIELD_MISSING_NAME); } - const { useStore } = useFormStore() ?? {}; + const { useStore } = useFormStore() ?? {}; if (!useStore) { throw new Error(ERROR_USE_FIELD_MISSING_CONTEXT); diff --git a/packages/formiz-core/src/useForm.tsx b/packages/formiz-core/src/useForm.tsx index 6e90f13b..aa05e049 100644 --- a/packages/formiz-core/src/useForm.tsx +++ b/packages/formiz-core/src/useForm.tsx @@ -14,6 +14,7 @@ import { getFormValues, } from "@/utils/form"; import { deepEqual } from "fast-equals"; +import { useFormizContext } from "@/FormizProvider"; export const useForm = ( formConfig?: useFormProps @@ -38,6 +39,26 @@ export const useForm = ( } const useStore = useStoreRef.current; + const formizContext = useFormizContext(); + const registerStoreRef = useRef(formizContext.registerStore); + registerStoreRef.current = formizContext.registerStore; + const unregisterStoreRef = useRef(formizContext.unregisterStore); + unregisterStoreRef.current = formizContext.unregisterStore; + + useEffect(() => { + console.log("cc"); + if (!useStoreRef.current) { + return; + } + registerStoreRef.current(useStoreRef.current); + return () => { + if (!useStoreRef.current) { + return; + } + unregisterStoreRef.current(useStoreRef.current); + }; + }, []); + useOnValuesChange(useStore); useIsValidChange(useStore); diff --git a/packages/formiz-core/src/utils/Logo.tsx b/packages/formiz-core/src/utils/Logo.tsx new file mode 100644 index 00000000..e2d391be --- /dev/null +++ b/packages/formiz-core/src/utils/Logo.tsx @@ -0,0 +1,52 @@ +export default function Logo(props: any) { + return ( + + + + + + + + + + + + + ); +} diff --git a/packages/formiz-core/src/utils/context.ts b/packages/formiz-core/src/utils/context.ts index fd9b5bf4..de508db4 100644 --- a/packages/formiz-core/src/utils/context.ts +++ b/packages/formiz-core/src/utils/context.ts @@ -1,6 +1,8 @@ +import { Store } from "@/types"; import React from "react"; +import { UseBoundStore, StoreApi } from "zustand"; -export interface CreateContextOptions { +export interface CreateContextOptions { /** * If `true`, React will throw if context is `null` or `undefined` * In some cases, you might want to support nested context, so you can set it to `false` @@ -14,27 +16,36 @@ export interface CreateContextOptions { * The display name of the context */ name?: string; + + value?: Value; + formattedValue?: FormattedValue; } -type CreateContextReturn = [React.Provider, () => T, React.Context]; +export interface FormContextProps { + useStore: UseBoundStore>; +} /** * Creates a named context, provider, and hook. * * @param options create context options */ -export function createContext(options: CreateContextOptions = {}) { +export function createContext( + options: CreateContextOptions = {} +) { const { strict = true, errorMessage = "useContext: `context` is undefined. Seems you forgot to wrap component within the Provider", name, } = options; - const Context = React.createContext(undefined); + const Context = React.createContext< + FormContextProps | undefined + >(undefined); Context.displayName = name; - function useContext() { + function useContext() { const context = React.useContext(Context); if (!context && strict) { @@ -44,12 +55,8 @@ export function createContext(options: CreateContextOptions = {}) { throw error; } - return context; + return context as FormContextProps; } - return [ - Context.Provider, - useContext, - Context, - ] as CreateContextReturn; + return [Context.Provider, useContext, Context] as const; } diff --git a/packages/formiz-core/src/utils/validations.ts b/packages/formiz-core/src/utils/validations.ts index 6dfd7c93..23640dc9 100644 --- a/packages/formiz-core/src/utils/validations.ts +++ b/packages/formiz-core/src/utils/validations.ts @@ -1,4 +1,4 @@ -import { FieldProps } from "@/types"; +import { FieldProps, FormizConfig } from "@/types"; export const getFieldValidationsErrors = ( value: Value,