From 3f03cdb6f62cd51ecc941b8453fe82a18f0bc5cf Mon Sep 17 00:00:00 2001 From: Birk Johansson Date: Mon, 4 Nov 2024 01:43:37 +0100 Subject: [PATCH] feat(sectionedForm): initial SectionedForm architecture --- src/lib/form/index.ts | 1 + .../form/sectionedForm/SectionedFormBase.tsx | 14 ++++ .../sectionedForm/SectionedFormContext.tsx | 20 +++++ .../form/sectionedForm/SectionedFormField.tsx | 21 ++++++ .../sectionedForm/SectionedFormSection.tsx | 24 ++++++ .../SectionedFormSectionContext.tsx | 36 +++++++++ src/lib/form/sectionedForm/formStore.ts | 73 +++++++++++++++++++ src/lib/form/sectionedForm/index.ts | 4 + src/lib/form/sectionedForm/sectionStore.ts | 26 +++++++ src/lib/form/sectionedForm/types.ts | 9 +++ src/lib/form/sectionedForm/useRegister.ts | 28 +++++++ .../sectionedForm/useSectionedFormState.ts | 16 ++++ 12 files changed, 272 insertions(+) create mode 100644 src/lib/form/sectionedForm/SectionedFormBase.tsx create mode 100644 src/lib/form/sectionedForm/SectionedFormContext.tsx create mode 100644 src/lib/form/sectionedForm/SectionedFormField.tsx create mode 100644 src/lib/form/sectionedForm/SectionedFormSection.tsx create mode 100644 src/lib/form/sectionedForm/SectionedFormSectionContext.tsx create mode 100644 src/lib/form/sectionedForm/formStore.ts create mode 100644 src/lib/form/sectionedForm/index.ts create mode 100644 src/lib/form/sectionedForm/sectionStore.ts create mode 100644 src/lib/form/sectionedForm/types.ts create mode 100644 src/lib/form/sectionedForm/useRegister.ts create mode 100644 src/lib/form/sectionedForm/useSectionedFormState.ts diff --git a/src/lib/form/index.ts b/src/lib/form/index.ts index 7c368a48..0a796e6c 100644 --- a/src/lib/form/index.ts +++ b/src/lib/form/index.ts @@ -5,3 +5,4 @@ export { required } from './validators' export { validate, createFormValidate } from './validate' export { useOnSubmitEdit, useOnSubmitNew } from './useOnSubmit' export { modelFormSchemas } from './modelFormSchemas' +export * from './sectionedForm' diff --git a/src/lib/form/sectionedForm/SectionedFormBase.tsx b/src/lib/form/sectionedForm/SectionedFormBase.tsx new file mode 100644 index 00000000..fcfc0c01 --- /dev/null +++ b/src/lib/form/sectionedForm/SectionedFormBase.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { SectionedFormProvider } from './SectionedFormContext' + +type SectionedFormProps = { + name: string + children: React.ReactNode +} +export const SectionedFormBase = ({ name, children }: SectionedFormProps) => { + return ( + + {children} + + ) +} diff --git a/src/lib/form/sectionedForm/SectionedFormContext.tsx b/src/lib/form/sectionedForm/SectionedFormContext.tsx new file mode 100644 index 00000000..a84e87b8 --- /dev/null +++ b/src/lib/form/sectionedForm/SectionedFormContext.tsx @@ -0,0 +1,20 @@ +import React, { createContext, useState } from 'react' +import { createFormStore, FormProps, FormStore } from './formStore' + +export const SectionedFormContext = createContext(null) + +export const SectionedFormProvider = ({ + children, + initialValue, +}: { + initialValue: Partial + children: React.ReactNode +}) => { + const [store] = useState(() => createFormStore(initialValue)) + + return ( + + {children} + + ) +} diff --git a/src/lib/form/sectionedForm/SectionedFormField.tsx b/src/lib/form/sectionedForm/SectionedFormField.tsx new file mode 100644 index 00000000..1bf058cb --- /dev/null +++ b/src/lib/form/sectionedForm/SectionedFormField.tsx @@ -0,0 +1,21 @@ +import React, { useEffect } from 'react' +import { useRegisterField } from './useRegister' + +type SectionedFormField = { + label: string + name: string + children: React.ReactNode +} +export const SectionFormField = ({ + label, + name, + children, +}: SectionedFormField) => { + const register = useRegisterField() + + useEffect(() => { + register({ name, label }) + }, [register, name, label]) + + return children +} diff --git a/src/lib/form/sectionedForm/SectionedFormSection.tsx b/src/lib/form/sectionedForm/SectionedFormSection.tsx new file mode 100644 index 00000000..79af2c3d --- /dev/null +++ b/src/lib/form/sectionedForm/SectionedFormSection.tsx @@ -0,0 +1,24 @@ +import React, { useEffect, useState } from 'react' +import { SectionedFormSectionProvider } from './SectionedFormSectionContext' +import { createSectionStore } from './sectionStore' +import { SectionedFormSectionContext } from './SectionedFormSectionContext' +import { useRegisterFormSection } from './useRegister' + +type SectionFormSectionProps = { + label: string + name: string + children: React.ReactNode +} +export const SectionedFormSection = ({ + label, + name, + children, +}: SectionFormSectionProps) => { + return ( + + {children} + + ) +} diff --git a/src/lib/form/sectionedForm/SectionedFormSectionContext.tsx b/src/lib/form/sectionedForm/SectionedFormSectionContext.tsx new file mode 100644 index 00000000..7ee63ec1 --- /dev/null +++ b/src/lib/form/sectionedForm/SectionedFormSectionContext.tsx @@ -0,0 +1,36 @@ +import React, { createContext, useEffect, useState } from 'react' +import { useStore } from 'zustand' +import { SectionedFormContext } from './SectionedFormContext' +import { createSectionStore, SectionProps, SectionStore } from './sectionStore' + +export const SectionedFormSectionContext = createContext( + null +) + +export const SectionedFormSectionProvider = ({ + children, + initialValue, +}: { + initialValue: SectionProps + children: React.ReactNode +}) => { + const [store] = useState(() => createSectionStore(initialValue)) + const formContext = React.useContext(SectionedFormContext) + if (!formContext) { + throw new Error( + 'SectionedFormSectionProvider must be wrapped in a SectionFormSectionProvider' + ) + } + + const addSectionToForm = useStore(formContext, (state) => state.addSection) + + useEffect(() => { + addSectionToForm(store.getState().section) + }, [store, addSectionToForm]) + + return ( + + {children} + + ) +} diff --git a/src/lib/form/sectionedForm/formStore.ts b/src/lib/form/sectionedForm/formStore.ts new file mode 100644 index 00000000..6bc7db71 --- /dev/null +++ b/src/lib/form/sectionedForm/formStore.ts @@ -0,0 +1,73 @@ +import { createStore } from 'zustand' +import { devtools } from 'zustand/middleware' +import { uniqueBy } from '../../../lib/utils' +import { SectionedFormSection, SectionFormField } from './types' + +type SectionIdentifier = string | SectionedFormSection + +export interface FormProps { + name: string + sections: SectionedFormSection[] + sectionFields: Map +} + +export interface FormState extends FormProps { + addSection: (section: SectionedFormSection) => void + addField: ( + section: string | SectionedFormSection, + field: SectionFormField + ) => void + getFieldsForSection: (section: SectionIdentifier) => SectionFormField[] + getSectionsForField: ( + field: string | SectionFormField + ) => SectionedFormSection[] | undefined +} + +export type FormStore = ReturnType + +export const createFormStore = (initialProps: Partial) => + createStore()( + devtools((set, get) => ({ + name: '', + sections: [], + sectionFields: new Map(), + ...initialProps, + addSection: (section: SectionedFormSection) => { + const prevSections = get().sections + set({ sections: prevSections.concat(section) }) + }, + addField: (section, field) => { + const sectionName = resolveSectionName(section) + const prevFieldsMap = get().sectionFields + const newFields = uniqueBy( + prevFieldsMap.get(sectionName)?.concat(field) || [field], + (field) => field.name + ) + + const sectionFields = new Map(prevFieldsMap).set( + sectionName, + newFields + ) + set({ sectionFields }) + }, + getSections: () => get().sections, + getFieldsForSection: (section: SectionIdentifier) => { + const sectionName = resolveSectionName(section) + return get().sectionFields.get(sectionName) || [] + }, + getSectionsForField: (field: string | SectionFormField) => { + const fieldName = typeof field === 'string' ? field : field.name + const fieldsBySection = get().sectionFields + for (const [section, fields] of fieldsBySection.entries()) { + if (fields.find((f) => f.name === fieldName)) { + return get().sections.filter((s) => s.name === section) + } + } + return undefined + }, + })) + ) + +const resolveSectionName = (section: SectionIdentifier) => { + return typeof section === 'string' ? section : section.name +} diff --git a/src/lib/form/sectionedForm/index.ts b/src/lib/form/sectionedForm/index.ts new file mode 100644 index 00000000..5a68f14a --- /dev/null +++ b/src/lib/form/sectionedForm/index.ts @@ -0,0 +1,4 @@ +export { SectionFormField } from './SectionedFormField' +export { SectionedFormSection } from './SectionedFormSection' +export { SectionedFormBase } from './SectionedFormBase' +export { useSectionedFormState } from './useSectionedFormState' diff --git a/src/lib/form/sectionedForm/sectionStore.ts b/src/lib/form/sectionedForm/sectionStore.ts new file mode 100644 index 00000000..fbea0cd3 --- /dev/null +++ b/src/lib/form/sectionedForm/sectionStore.ts @@ -0,0 +1,26 @@ +import { create, createStore } from 'zustand' +import { devtools } from 'zustand/middleware' +import { SectionedFormSection, SectionFormField } from './types' + +export interface SectionProps { + section: SectionedFormSection +} + +export interface SectionState extends SectionProps { + setSection: (section: SectionedFormSection) => void + getSection: () => SectionedFormSection +} +export type SectionStore = ReturnType + +export const createSectionStore = (initialProps: SectionProps) => + createStore()( + devtools((set, get) => ({ + ...initialProps, + setSection: (section: SectionedFormSection) => { + set({ section }) + }, + getSection: () => { + return get().section + }, + })) + ) diff --git a/src/lib/form/sectionedForm/types.ts b/src/lib/form/sectionedForm/types.ts new file mode 100644 index 00000000..5dd3a6ca --- /dev/null +++ b/src/lib/form/sectionedForm/types.ts @@ -0,0 +1,9 @@ +export type SectionedFormSection = { + name: string + label: string +} + +export type SectionFormField = { + name: string + label: string +} diff --git a/src/lib/form/sectionedForm/useRegister.ts b/src/lib/form/sectionedForm/useRegister.ts new file mode 100644 index 00000000..31aa7efe --- /dev/null +++ b/src/lib/form/sectionedForm/useRegister.ts @@ -0,0 +1,28 @@ +import React, { useCallback } from 'react' +import { useStore } from 'zustand' +import { SectionedFormContext } from './SectionedFormContext' +import { SectionedFormSectionContext } from './SectionedFormSectionContext' +import { SectionFormField } from './types' + +export const useRegisterField = () => { + const sectionContext = React.useContext(SectionedFormSectionContext)! + const formContext = React.useContext(SectionedFormContext)! + + const currentSection = useStore(sectionContext, (state) => + state.getSection() + ) + + const addFieldToForm = useStore(formContext, (state) => state.addField) + + return useCallback( + (field: SectionFormField) => { + if (currentSection) { + addFieldToForm(currentSection, field) + } else { + console.error(`Tried to register field ${field.name} in section, but no section is set. +Make sure to wrap fields in a SectionedFormSection component`) + } + }, + [addFieldToForm, currentSection] + ) +} diff --git a/src/lib/form/sectionedForm/useSectionedFormState.ts b/src/lib/form/sectionedForm/useSectionedFormState.ts new file mode 100644 index 00000000..0987fa3e --- /dev/null +++ b/src/lib/form/sectionedForm/useSectionedFormState.ts @@ -0,0 +1,16 @@ +import { useContext } from 'react' +import { useStore } from 'zustand' +import { FormState } from './formStore' +import { SectionedFormContext } from './SectionedFormContext' + +export function useSectionedFormState(): FormState +export function useSectionedFormState(selector: (state: FormState) => T): T +export function useSectionedFormState(selector?: (state: FormState) => T) { + const formStore = useContext(SectionedFormContext) + if (!formStore) { + throw new Error( + 'useFormState must be used within a SectionedFormProvider' + ) + } + return useStore(formStore, selector!) +}