Skip to content

Commit

Permalink
feat(sectionedForm): initial architecture and base components (#431)
Browse files Browse the repository at this point in the history
* feat(sectionedForm): initial SectionedForm architecture

* fix: add showcase for formstructure

* feat(dataSet): dataSet sectionedForm

* feat: sectioned form router and fixes

* fix: some cleanup

* refactor: cleanup

* refactor: more cleanup

* refactor: remove unused file

* refactor: cleanup and fix imports

* fix: call submit from footer

* fix: some cleanup

* refactor: rename context

* fix: add error noticebox

* feat: add section in one page - update selection by scroll

* refactor: remove unused code

* Revert "fix: add error noticebox"

This reverts commit 50e8bb3.

* fix: errornotice after revert

* fix(errorbox): allow to close box, fix styling

* fix: cleanup error notice

* fix: fix selectedsection scroll syncing

* fix: cleanup

* fix: fix import after bad merge

* feat: stop hiding form tab and give some temp vertical space to not yet developed sections

* feat: move data set form to different route temporarelly

---------

Co-authored-by: Flaminia Cavallo <flaminia@dhis2.org>
  • Loading branch information
Birkbjo and flaminic authored Dec 2, 2024
1 parent f44cb14 commit 74acd06
Show file tree
Hide file tree
Showing 31 changed files with 1,156 additions and 74 deletions.
4 changes: 4 additions & 0 deletions src/components/form/DefaultFormErrorNotice.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.noticeBox {
max-width: 640px;
}

.errorList {
padding: 0;
}
103 changes: 44 additions & 59 deletions src/components/form/DefaultFormErrorNotice.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,44 @@
import i18n from '@dhis2/d2-i18n'
import { NoticeBox } from '@dhis2/ui'
import { FormState } from 'final-form'
import React, { useEffect, useRef } from 'react'
import { useFormState } from 'react-final-form'
import React from 'react'
import css from './DefaultFormErrorNotice.module.css'

const formStateSubscriptions = {
errors: true,
submitError: true,
submitFailed: true,
hasValidationErrors: true,
hasSubmitErrors: true,
dirtySinceLastSubmit: true,
}

type FormStateErrors = Pick<
FormState<unknown>,
keyof typeof formStateSubscriptions
>
import { useFormStateErrors } from './useFormStateErrors'

export function DefaultFormErrorNotice() {
const partialFormState: FormStateErrors = useFormState({
subscription: formStateSubscriptions,
})
// only show after trying to submit
if (
!partialFormState.submitFailed ||
(partialFormState.submitFailed && partialFormState.dirtySinceLastSubmit)
) {
const formStateErrors = useFormStateErrors()

if (!formStateErrors.shouldShowErrors) {
return null
}

if (partialFormState.hasValidationErrors) {
return <ValidationErrors formStateErrors={partialFormState} />
if (formStateErrors.hasValidationErrors) {
return (
<ValidationErrorNotice>
<ErrorList
errors={errorsWithLabels(
formStateErrors.validationErrors || {}
)}
/>
</ValidationErrorNotice>
)
}

if (partialFormState.hasSubmitErrors) {
return <ServerSubmitError formStateErrors={partialFormState} />
if (formStateErrors.hasSubmitErrors) {
return (
<ServerSubmitErrorNotice>
{formStateErrors.submitError}
</ServerSubmitErrorNotice>
)
}
return null
}

const ValidationErrors = ({
formStateErrors,
export const ValidationErrorNotice = ({
children,
}: {
formStateErrors: FormStateErrors
children: React.ReactNode
}) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
ref.current.scrollIntoView({ behavior: 'smooth' })
}
}, [])
return (
<div ref={ref}>
<div>
<NoticeBox
className={css.noticeBox}
warning
Expand All @@ -64,19 +49,15 @@ const ValidationErrors = ({
'Some fields have validation errors. Please fix them before saving.'
)}
</p>
{formStateErrors.errors && (
<ErrorList errors={formStateErrors.errors} />
)}
{children}
</NoticeBox>
</div>
)
}

const ErrorList = ({ errors }: { errors: Record<string, string> }) => {
const labels = getFieldLabelsBestEffort()

export const ErrorList = ({ errors }: { errors: Record<string, string> }) => {
return (
<ul style={{ padding: '0 16px' }}>
<ul className={css.errorList}>
{Object.entries(errors).map(([key, value]) => {
return (
<li key={key} style={{ display: 'flex', gap: '8px' }}>
Expand All @@ -85,7 +66,7 @@ const ErrorList = ({ errors }: { errors: Record<string, string> }) => {
fontWeight: '600',
}}
>
{labels.get(key) || key}:
{key}:
</span>
<span>{JSON.stringify(value)}</span>
</li>
Expand All @@ -95,29 +76,33 @@ const ErrorList = ({ errors }: { errors: Record<string, string> }) => {
)
}

const ServerSubmitError = ({
formStateErrors,
export const ServerSubmitErrorNotice = ({
children,
}: {
formStateErrors: FormStateErrors
children: React.ReactNode
}) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
ref.current.scrollIntoView({ behavior: 'smooth' })
}
}, [])
return (
<div ref={ref}>
<div>
<NoticeBox
className={css.noticeBox}
error
title={i18n.t('Something went wrong when submitting the form')}
>
<p>{formStateErrors.submitError}</p>
<p>{children}</p>
</NoticeBox>
</div>
)
}

const errorsWithLabels = (errors: Record<string, string>) => {
const labels = getFieldLabelsBestEffort()
return Object.fromEntries(
Object.entries(errors).map(([key, value]) => {
return [labels.get(key) || key, value]
})
)
}

/**
* We don't have a good way to get the translated labels, so for now
* we get these from the DOM. This is a best-effort approach.
Expand Down
35 changes: 26 additions & 9 deletions src/components/form/FormBase.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { NoticeBox } from '@dhis2/ui'
import React, { useMemo } from 'react'
import { FormProps, Form as ReactFinalForm } from 'react-final-form'
import { defaultValueFormatter } from '../../lib/form/useOnSubmit'
import {
FormProps,
FormRenderProps,
Form as ReactFinalForm,
} from 'react-final-form'
import { defaultValueFormatter } from '../../lib/form/'
import {
PartialAttributeValue,
getAllAttributeValues,
Expand All @@ -17,14 +21,17 @@ type MaybeModelWithAttributes = {

type OwnProps<TValues = Record<string, unknown>> = {
initialValues: TValues | undefined
children: React.ReactNode
children: FormProps<TValues>['children']
includeAttributes?: boolean
// we cant remove these props due to FormProps definition, but set to never to avoid confusion
// since we're override this and just use children props
render?: never
component?: never
}

type FormBaseProps<TValues> = FormProps<TValues> & OwnProps<TValues>
export type FormBaseProps<TValues> = FormProps<TValues> & OwnProps<TValues>

export function FormBase<TInitialValues extends MaybeModelWithAttributes>({
children,
initialValues,
onSubmit,
validate,
Expand Down Expand Up @@ -57,18 +64,28 @@ export function FormBase<TInitialValues extends MaybeModelWithAttributes>({
return <LoadingSpinner />
}

const { children } = reactFinalFormProps

// by defualt we wrap children and add form
// but if it's a function - we let the consumer override it
const defaultRender =
typeof children === 'function'
? children
: ({ handleSubmit }: FormRenderProps<TInitialValues>) => (
<form onSubmit={handleSubmit}>{children}</form>
)

return (
<ReactFinalForm<TInitialValues>
validateOnBlur={true}
initialValues={initialValuesWithAttributes}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>{children}</form>
)}
onSubmit={(values, form) => onSubmit(valueFormatter(values), form)}
validate={(values) =>
validate ? validate(valueFormatter(values)) : undefined
}
{...reactFinalFormProps}
/>
>
{defaultRender}
</ReactFinalForm>
)
}
1 change: 1 addition & 0 deletions src/components/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { DefaultEditFormContents } from './DefaultFormContents'
export * from './attributes'
export * from './helpers'
export * from './FormBase'
export * from './useFormStateErrors'
56 changes: 56 additions & 0 deletions src/components/form/useFormStateErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { FormState, FormSubscription } from 'final-form'
import { useFormState } from 'react-final-form'

const formStateSubscriptions = {
errors: true,
submitError: true,
submitFailed: true,
hasValidationErrors: true,
hasSubmitErrors: true,
dirtySinceLastSubmit: true,
modifiedSinceLastSubmit: true,
} satisfies FormSubscription

type FinalFormErrorProps = Pick<
FormState<unknown>,
keyof typeof formStateSubscriptions
>

export type FormErrorState = Omit<FinalFormErrorProps, 'errors'> & {
// helper to decide wheter a noticebox should be shown
shouldShowErrors: boolean
// we rename "errors" to "validationErrors" to make it more clear that it only contain validation errors
validationErrors: Record<string, string> | undefined
}

export const useFormStateErrors = (): FormErrorState => {
const {
dirtySinceLastSubmit,
errors,
hasSubmitErrors,
hasValidationErrors,
submitError,
submitFailed,
modifiedSinceLastSubmit,
}: FinalFormErrorProps = useFormState({
subscription: formStateSubscriptions,
})

const hasAnyError = !!(hasSubmitErrors || hasValidationErrors)

// should only show errors after trying to submit
const shouldShowErrors =
(hasAnyError && submitFailed && !dirtySinceLastSubmit) ||
(submitFailed && hasSubmitErrors)

return {
dirtySinceLastSubmit,
hasSubmitErrors,
hasValidationErrors,
shouldShowErrors,
submitError,
submitFailed,
validationErrors: errors,
modifiedSinceLastSubmit,
}
}
1 change: 1 addition & 0 deletions src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './metadataFormControls'
export * from './SearchableSingleSelect'
export * from './sectionList'
export * from './standardForm'
export * from './sectionedForm'
59 changes: 59 additions & 0 deletions src/components/sectionedForm/DefaultSectionedFormFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import i18n from '@dhis2/d2-i18n'
import { Button } from '@dhis2/ui'
import React from 'react'
import { useForm } from 'react-final-form'
import { useSectionedFormContext, useSelectedSection } from '../../lib'
import { LinkButton } from '../LinkButton'
import { SectionedFormFooter } from './SectionedFormFooter'

export const DefaultSectionedFormFooter = () => {
const descriptor = useSectionedFormContext()
const [selected, setSelectedSection] = useSelectedSection()

const { submit } = useForm()
const currSelectedIndex = descriptor.sections.findIndex(
(s) => s.name === selected
)
const prevSection = descriptor.sections[currSelectedIndex - 1]
const nextSection = descriptor.sections[currSelectedIndex + 1]

const handleNavigateBack = () => {
if (prevSection) {
setSelectedSection(prevSection.name)
}
}
const handleNavigateNext = () => {
if (nextSection) {
setSelectedSection(nextSection.name)
}
}

return (
<SectionedFormFooter>
<SectionedFormFooter.SectionActions>
{prevSection && (
<Button small onClick={handleNavigateBack}>
{i18n.t('Go back')}
</Button>
)}
{nextSection && (
<Button
style={{ marginInlineStart: 'auto' }}
small
onClick={handleNavigateNext}
>
{i18n.t('Next section')}
</Button>
)}
</SectionedFormFooter.SectionActions>
<SectionedFormFooter.FormActions>
<Button primary type="submit" onClick={submit}>
{i18n.t('Save and exit')}
</Button>
<LinkButton to={'..'}>
{i18n.t('Exit without saving')}
</LinkButton>
</SectionedFormFooter.FormActions>
</SectionedFormFooter>
)
}
23 changes: 23 additions & 0 deletions src/components/sectionedForm/DefaultSectionedFormSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react'
import { useSectionedFormContext, useSelectedSection } from '../../lib'
import {
SectionedFormSidebar,
SectionedFormSidebarItem,
} from './SectionedFormSidebar'

export const DefaultSectionedFormSidebar = () => {
const { sections } = useSectionedFormContext()

const [selected] = useSelectedSection()

const items = sections.map((section) => (
<SectionedFormSidebarItem
key={section.name}
active={selected === section.name}
sectionName={section.name}
>
{section.label}
</SectionedFormSidebarItem>
))
return <SectionedFormSidebar>{items}</SectionedFormSidebar>
}
Loading

0 comments on commit 74acd06

Please sign in to comment.