Skip to content

Commit

Permalink
Events v2: Implement optimistic file upload and FILE type input (#8246)
Browse files Browse the repository at this point in the history
* implement optimistic file upload and FILE type input

* fix broken offline record creation

* fix one type error

* fix: update tests, add missing license header

* fix: explicitly throw if cache not found

* fix type errors

* fix eslint errors

* fix review page

* add signing for files fetched from events service

* cleanup

* cleanup

* use undefined instead of null in FileInput as the default value of selected file

* remove default empty string value

* remove fallback output

* fix linter error

* fix: clean up types and lint issues

* fix review page exception

* fix: add missing flush

* fix: update test mock name

* move debugger to top level of events v2

* fix: add mock for files service call

* chore: add msw, mock documents api call

* fix: add missing license header

* refactor react query logic a bit

* fix: remove unused export

* fix: change cache invalidation order

* fix: add flushPromises

* fix: add flushPromises to top-level

* fix merge

* add types back

* improve knip diff output

---------

Co-authored-by: Markus <[email protected]>
Co-authored-by: Markus Laurila <[email protected]>
  • Loading branch information
3 people authored Jan 3, 2025
1 parent 2a80871 commit 1acab85
Show file tree
Hide file tree
Showing 48 changed files with 1,612 additions and 1,346 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ jobs:
- name: Run knip on base branch
id: knip_base
run: |
npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown > knip_report.md
npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown | sed -E 's/ +/ /g' | sed -E 's/:[0-9]+:[0-9]+//' > knip_report.md
TOTAL=$(grep -oP '## [A-Za-z\s]+ \(\K[0-9]+' knip_report.md | awk '{sum+=$1} END {print sum}')
echo "Total $TOTAL issue(s) on base branch."
echo "total=${TOTAL}" >> $GITHUB_OUTPUT
Expand All @@ -192,7 +192,7 @@ jobs:
- name: Run knip on PR branch
id: knip_pr
run: |
npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown > knip_report.md
npx knip --tags=-knipignore --no-exit-code --exports --reporter=markdown | sed -E 's/ +/ /g' | sed -E 's/:[0-9]+:[0-9]+//' > knip_report.md
TOTAL=$(grep -oP '## [A-Za-z\s]+ \(\K[0-9]+' knip_report.md | awk '{sum+=$1} END {print sum}')
echo "Total $TOTAL issue(s) on PR branch."
echo "total=${TOTAL}" >> $GITHUB_OUTPUT
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ services:
- MONGO_URL=mongodb://mongo1/events
- ES_HOST=elasticsearch:9200
- COUNTRY_CONFIG_URL=http://countryconfig:3040/
- DOCUMENTS_URL=http://documents:9050

# User facing services
workflow:
Expand Down
164 changes: 105 additions & 59 deletions packages/client/src/v2-events/components/forms/FormFieldGenerator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,32 @@
*
* Copyright (C) The OpenCRVS Authors located at https://github.com/opencrvs/opencrvs-core/blob/master/AUTHORS.
*/

/* eslint-disable */
import { InputField } from '@client/components/form/InputField'
import { DATE, PARAGRAPH, TEXT } from '@client/forms'
import { IAdvancedSearchFormState } from '@client/search/advancedSearch/utils'
import { DateField } from '@opencrvs/components/lib/DateField'
import { Text } from '@opencrvs/components/lib/Text'
import { TextInput } from '@opencrvs/components/lib/TextInput'
import * as React from 'react'

import styled, { keyframes } from 'styled-components'
import {
evalExpressionInFieldDefinition,
getConditionalActionsForField,
getDependentFields,
handleInitialValue,
hasInitialValueDependencyInfo
} from './utils'
import { Errors, getValidationErrorsForForm } from './validation'

import {
FieldConfig,
FieldValue,
FieldValueByType,
FileFieldValue
} from '@opencrvs/commons/client'
import {
Field,
FieldProps,
Expand All @@ -26,30 +48,8 @@ import {
MessageDescriptor,
useIntl
} from 'react-intl'
import { FieldConfig } from '@opencrvs/commons'
import { TextInput } from '@opencrvs/components/lib/TextInput'
import { Text } from '@opencrvs/components/lib/Text'
import { DateField } from '@opencrvs/components/lib/DateField'
import { IAdvancedSearchFormState } from '@client/search/advancedSearch/utils'
import {
DATE,
HIDDEN,
IFormFieldValue,
IFormSectionData,
PARAGRAPH,
TEXT
} from '@client/forms'
import { InputField } from '@client/components/form/InputField'
import {
evalExpressionInFieldDefinition,
flatten,
getConditionalActionsForField,
getDependentFields,
handleInitialValue,
hasInitialValueDependencyInfo,
unflatten
} from './utils'
import { Errors, getValidationErrorsForForm } from './validation'
import { FileInput } from './inputs/FileInput/FileInput'
import { ActionFormData } from '@opencrvs/commons'

const fadeIn = keyframes`
from { opacity: 0; }
Expand All @@ -64,27 +64,27 @@ const FormItem = styled.div<{
ignoreBottomMargin ? '0px' : '22px'};
`

interface GeneratedInputFieldProps {
fieldDefinition: FieldConfig
interface GeneratedInputFieldProps<FieldType extends FieldConfig> {
fieldDefinition: FieldType
fields: FieldConfig[]
values: IFormSectionData
setFieldValue: (name: string, value: IFormFieldValue) => void
values: ActionFormData
setFieldValue: (name: string, value: FieldValue | undefined) => void
onClick?: () => void
onChange: (e: React.ChangeEvent) => void
onBlur: (e: React.FocusEvent) => void
resetDependentSelectValues: (name: string) => void
value: IFormFieldValue
value: FieldValueByType[FieldType['type']]
touched: boolean
error: string
formData: IFormSectionData
formData: ActionFormData
disabled?: boolean
onUploadingStateChanged?: (isUploading: boolean) => void
requiredErrorMessage?: MessageDescriptor
setFieldTouched: (name: string, isTouched?: boolean) => void
}

const GeneratedInputField = React.memo<GeneratedInputFieldProps>(
({
const GeneratedInputField = React.memo(
<FieldType extends FieldConfig>({
fieldDefinition,
onChange,
onBlur,
Expand All @@ -99,7 +99,7 @@ const GeneratedInputField = React.memo<GeneratedInputFieldProps>(
requiredErrorMessage,
fields,
values
}) => {
}: GeneratedInputFieldProps<FieldType>) => {
const intl = useIntl()

const inputFieldProps = {
Expand Down Expand Up @@ -133,6 +133,12 @@ const GeneratedInputField = React.memo<GeneratedInputFieldProps>(
intl.formatMessage(fieldDefinition.placeholder)
}

const handleFileChange = React.useCallback(
(value: FileFieldValue | undefined) =>
setFieldValue(fieldDefinition.id, value),
[fieldDefinition.id, setFieldValue]
)

if (fieldDefinition.type === DATE) {
return (
<InputField {...inputFieldProps}>
Expand Down Expand Up @@ -166,17 +172,6 @@ const GeneratedInputField = React.memo<GeneratedInputFieldProps>(
)
}

if (fieldDefinition.type === HIDDEN) {
const { error, touched, ...allowedInputProps } = inputProps

return (
<input
type="hidden"
{...allowedInputProps}
value={inputProps.value as string}
/>
)
}
if (fieldDefinition.type === TEXT) {
return (
<InputField {...inputFieldProps}>
Expand All @@ -190,13 +185,25 @@ const GeneratedInputField = React.memo<GeneratedInputFieldProps>(
</InputField>
)
}
if (fieldDefinition.type === 'FILE') {
const value = formData[fieldDefinition.id] as FileFieldValue
return (
<InputField {...inputFieldProps}>
<FileInput
{...inputProps}
value={value}
onChange={handleFileChange}
/>
</InputField>
)
}
return <div>Unsupported field type {fieldDefinition.type}</div>
}
)

GeneratedInputField.displayName = 'MemoizedGeneratedInputField'

type FormData = Record<string, IFormFieldValue>
type FormData = Record<string, FieldValue>

const mapFieldsToValues = (fields: FieldConfig[], formData: FormData) =>
fields.reduce((memo, field) => {
Expand All @@ -211,15 +218,15 @@ interface ExposedProps {
id: string
fieldsToShowValidationErrors?: FieldConfig[]
setAllFieldsDirty: boolean
onChange: (values: IFormSectionData) => void
formData: Record<string, IFormFieldValue>
onChange: (values: ActionFormData) => void
formData: Record<string, FieldValue>
onSetTouched?: (func: ISetTouchedFunction) => void
requiredErrorMessage?: MessageDescriptor
onUploadingStateChanged?: (isUploading: boolean) => void
initialValues?: IAdvancedSearchFormState
}

type AllProps = ExposedProps & IntlShapeProps & FormikProps<IFormSectionData>
type AllProps = ExposedProps & IntlShapeProps & FormikProps<ActionFormData>

class FormSectionComponent extends React.Component<AllProps> {
componentDidUpdate(prevProps: AllProps) {
Expand Down Expand Up @@ -289,7 +296,7 @@ class FormSectionComponent extends React.Component<AllProps> {

setFieldValuesWithDependency = (
fieldName: string,
value: IFormFieldValue
value: FieldValue | undefined
) => {
const updatedValues = cloneDeep(this.props.values)
set(updatedValues, fieldName, value)
Expand Down Expand Up @@ -328,13 +335,24 @@ class FormSectionComponent extends React.Component<AllProps> {
}

render() {
const { values, fields, setFieldTouched, touched, intl, formData } =
this.props
const {
values,
fields: fieldsWithDotIds,
setFieldTouched,
touched,
intl,
formData
} = this.props

const language = this.props.intl.locale

const errors = this.props.errors as unknown as Errors

const fields = fieldsWithDotIds.map((field) => ({
...field,
id: field.id.replaceAll('.', FIELD_SEPARATOR)
}))

return (
<section>
{fields.map((field) => {
Expand All @@ -348,7 +366,7 @@ class FormSectionComponent extends React.Component<AllProps> {

const conditionalActions: string[] = getConditionalActionsForField(
field,
{ ...formData, ...values }
{ $form: values, $now: new Date().toISOString().split('T')[0] }
)

if (conditionalActions.includes('hide')) {
Expand All @@ -374,7 +392,11 @@ class FormSectionComponent extends React.Component<AllProps> {
error={isFieldDisabled ? '' : error}
fields={fields}
formData={formData}
touched={flatten(touched)[field.id] || false}
touched={
makeFormikFieldIdsOpenCRVSCompatible(touched)[
field.id
] || false
}
values={values}
onUploadingStateChanged={
this.props.onUploadingStateChanged
Expand All @@ -391,26 +413,50 @@ class FormSectionComponent extends React.Component<AllProps> {
}
}

/*
* Formik has a feature that automatically nests all form keys that have a dot in them.
* Because our form field ids can have dots in them, we temporarily transform those dots
* to a different character before passing the data to Formik. This function unflattens
*/
const FIELD_SEPARATOR = '____'
function makeFormFieldIdsFormikCompatible<T>(data: Record<string, T>) {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [
key.replaceAll('.', FIELD_SEPARATOR),
value
])
)
}

function makeFormikFieldIdsOpenCRVSCompatible<T>(data: Record<string, T>) {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [
key.replaceAll(FIELD_SEPARATOR, '.'),
value
])
)
}

export const FormFieldGenerator: React.FC<ExposedProps> = (props) => {
const intl = useIntl()

const nestedFormData = unflatten(props.formData)
const nestedFormData = makeFormFieldIdsFormikCompatible(props.formData)

const onChange = (values: IFormSectionData) => {
props.onChange(flatten(values))
const onChange = (values: ActionFormData) => {
props.onChange(makeFormikFieldIdsOpenCRVSCompatible(values))
}

const initialValues = unflatten<IFormFieldValue>(
const initialValues = makeFormFieldIdsFormikCompatible<FieldValue>(
props.initialValues ?? mapFieldsToValues(props.fields, nestedFormData)
)

return (
<Formik<IFormSectionData>
<Formik<ActionFormData>
initialValues={initialValues}
validate={(values) =>
getValidationErrorsForForm(
props.fields,
flatten(values),
makeFormikFieldIdsOpenCRVSCompatible(values),
props.requiredErrorMessage
)
}
Expand Down
Loading

0 comments on commit 1acab85

Please sign in to comment.