diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx index c906ce14d50..c7b72e0faa4 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx @@ -24,7 +24,6 @@ import IterateItemContext, { import SummaryListContext from '../../Value/SummaryList/SummaryListContext' import ValueBlockContext from '../../ValueBlock/ValueBlockContext' import FieldBoundaryProvider from '../../DataContext/FieldBoundary/FieldBoundaryProvider' -import DataContext from '../../DataContext/Context' import useDataValue from '../../hooks/useDataValue' import { useArrayLimit, useSwitchContainerMode } from '../hooks' import { getMessage } from '../../FieldBlock' @@ -90,7 +89,6 @@ function ArrayComponent(props: Props) { value: arrayValue, limit, error, - defaultValue, withoutFlex, emptyValue, placeholder, @@ -100,7 +98,11 @@ function ArrayComponent(props: Props) { setChanged, onChange, children, - } = useFieldProps(preparedProps) + } = useFieldProps(preparedProps, { + // To ensure the defaultValue set on the Iterate.Array is set in the data context, + // and will not overwrite defaultValues set by fields inside the Iterate.Array. + updateContextDataInSync: true, + }) useMountEffect(() => { // To ensure the validator is called when a new item is added @@ -129,15 +131,6 @@ function ArrayComponent(props: Props) { const omitFlex = withoutFlex ?? (summaryListContext || valueBlockContext) - // To support React.StrictMode, we inject the defaultValue into the data context this way. - // The routine inside useFieldProps where updateDataValueDataContext is called, does not support React.StrictMode - const { handlePathChange } = useContext(DataContext) || {} - useMountEffect(() => { - if (defaultValue) { - handlePathChange?.(path, defaultValue) - } - }) - useEffect(() => { // Update inside the useEffect, to support React.StrictMode valueCountRef.current = arrayValue || [] diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx index 73d8afc2f66..3dc24001bf1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx @@ -562,31 +562,133 @@ describe('Iterate.Array', () => { expect(onChangeIterate).toHaveBeenLastCalledWith(['foo']) }) - it('should handle "defaultValue" with React.StrictMode', () => { - const onSubmit = jest.fn() + describe('defaultValue', () => { + it('should validate required fields', async () => { + const onSubmit = jest.fn() - render( - + render( - - + + - - ) + ) - const form = document.querySelector('form') - const input = document.querySelector('input') + const form = document.querySelector('form') + fireEvent.submit(form) - expect(input).toHaveValue('') + expect(onSubmit).toHaveLength(0) - fireEvent.submit(form) + await waitFor(() => { + expect( + document.querySelectorAll('.dnb-form-status') + ).toHaveLength(1) + }) + }) - expect(onSubmit).toHaveBeenCalledTimes(1) - expect(onSubmit).toHaveBeenLastCalledWith( - { myList: [''] }, - expect.anything() - ) + it('should handle "defaultValue" (empty string) in React.StrictMode', () => { + const onSubmit = jest.fn() + + render( + + + + + + + + ) + + const form = document.querySelector('form') + const input = document.querySelector('input') + + expect(input).toHaveValue('') + + fireEvent.submit(form) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myList: [''] }, + expect.anything() + ) + }) + + it('should handle "defaultValue" (with value) in React.StrictMode', () => { + const onSubmit = jest.fn() + + render( + + + + + + + + ) + + const form = document.querySelector('form') + const input = document.querySelector('input') + + expect(input).toHaveValue('foo') + + fireEvent.submit(form) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myList: ['foo'] }, + expect.anything() + ) + }) + + it('should handle "defaultValue" (with null) in React.StrictMode', () => { + const onSubmit = jest.fn() + + render( + + + + + + + + ) + + const form = document.querySelector('form') + const input = document.querySelector('input') + + expect(input).toHaveValue('foo') + + fireEvent.submit(form) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myList: ['foo'] }, + expect.anything() + ) + }) + + it('should set empty array in the data context', () => { + const onSubmit = jest.fn() + + render( + + + + content + + + + ) + + const form = document.querySelector('form') + fireEvent.submit(form) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myList: [] }, + expect.anything() + ) + }) }) describe('with primitive elements', () => { @@ -1294,7 +1396,7 @@ describe('Iterate.Array', () => { @@ -1311,26 +1413,20 @@ describe('Iterate.Array', () => { ) const form = document.querySelector('form') - const [first, second, third, forth] = Array.from( + const [first, second, third] = Array.from( document.querySelectorAll('input') ) expect(first).toHaveValue('default value 1') expect(second).toHaveValue('default value 2') - expect(third).toHaveValue('default value 3') - expect(forth).toHaveValue('default value 4') + expect(third).toHaveValue('something') fireEvent.submit(form) expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenLastCalledWith( { - myList: [ - 'default value 1', - 'default value 2', - 'default value 3', - 'default value 4', - ], + myList: ['default value 1', 'default value 2', 'something'], }, expect.anything() ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx index 0db2e71b022..4cfff66df79 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx @@ -3,7 +3,6 @@ import Isolation from '../../Form/Isolation' import PushContainerContext from './PushContainerContext' import IterateItemContext from '../IterateItemContext' import DataContext from '../../DataContext/Context' -import { clearedData } from '../../DataContext/Provider' import useDataValue from '../../hooks/useDataValue' import EditContainer, { CancelButton, DoneButton } from '../EditContainer' import IterateArray, { ContainerMode } from '../Array' @@ -43,6 +42,11 @@ export type Props = { */ data?: unknown | Record + /** + * Prefilled data to add to the fields. + */ + defaultData?: unknown | Record + /** * A custom toolbar to be shown below the container. */ @@ -58,7 +62,8 @@ export type AllProps = Props & SpacingProps & ArrayItemAreaProps function PushContainer(props: AllProps) { const { - data, + data: dataProp, + defaultData: defaultDataProp, path, title, children, @@ -92,14 +97,24 @@ function PushContainer(props: AllProps) { } const { getValueByPath } = useDataValue() + const getFallback = useCallback(() => { + return Array.isArray(getValueByPath(path)) ? null : {} + }, [getValueByPath, path]) + + const data = useMemo(() => { + if (defaultDataProp) { + return // don't return a fallback, because we want to use the defaultData + } + return { newItems: [dataProp ?? getFallback()] } + }, [dataProp, defaultDataProp, getFallback]) + const defaultData = useMemo(() => { - const value = - data ?? (Array.isArray(getValueByPath(path)) ? null : clearedData) - return { newItems: [value] } - }, [data, getValueByPath, path]) + return { newItems: [defaultDataProp ?? getFallback()] } + }, [defaultDataProp, getFallback]) return ( { render( - + diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx index 141db4d1067..85179810776 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react' -import { Field, Form, Iterate, Value } from '../..' +import { Field, Form, Iterate, Tools, Value, Wizard } from '../..' import { Card, Flex, Section } from '../../../../components' export default { @@ -256,3 +256,42 @@ export const WithArrayValidator = () => { ) } + +export function InWizard() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx b/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx index dfe5302a714..c7bf9d7db93 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react' +import React, { useContext } from 'react' import DataContext from '../DataContext/Context' import Section, { SectionProps } from '../../../components/Section' diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx index 5eefdaf0be5..1cc6df7b644 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { wait } from '../../../../../core/jest/jestSetup' -import { Field, Form, OnSubmit, Wizard } from '../../..' +import { Field, Form, Iterate, OnSubmit, Wizard } from '../../..' import nbNO from '../../../constants/locales/nb-NO' const nb = nbNO['nb-NO'] @@ -1964,4 +1964,107 @@ describe('Wizard.Container', () => { expect(iframe.parentElement).toBeNull() }) }) + + describe('defaultValue', () => { + it('should set defaultValue of a Field.* only once between step changes', async () => { + const onChange = jest.fn() + const onStepChange = jest.fn() + + render( + + + + + + + + + + + + + ) + + expect(document.querySelector('input')).toHaveValue('123') + + await userEvent.type(document.querySelector('input'), '4') + + expect(document.querySelector('input')).toHaveValue('1234') + + await userEvent.click(nextButton()) + + expect(onStepChange).toHaveBeenLastCalledWith( + 1, + 'next', + expect.anything() + ) + + await userEvent.click(previousButton()) + + expect(onStepChange).toHaveBeenLastCalledWith( + 0, + 'previous', + expect.anything() + ) + + expect(document.querySelector('input')).toHaveValue('1234') + }) + + it('should set defaultValue of Iterate.Array only once between step changes', async () => { + const onChange = jest.fn() + const onStepChange = jest.fn() + + render( + + + + + + + + + + + + + + + + + + ) + + expect(document.querySelectorAll('input')).toHaveLength(1) + expect(document.querySelector('input')).toHaveValue('123') + + await userEvent.type(document.querySelector('input'), '4') + + expect(document.querySelector('input')).toHaveValue('1234') + + const pushButton = document.querySelector( + '.dnb-forms-iterate-push-button' + ) + await userEvent.click(pushButton) + + expect(document.querySelectorAll('input')).toHaveLength(2) + + await userEvent.click(nextButton()) + + expect(onStepChange).toHaveBeenLastCalledWith( + 1, + 'next', + expect.anything() + ) + + await userEvent.click(previousButton()) + + expect(document.querySelectorAll('input')).toHaveLength(2) + expect(onStepChange).toHaveBeenLastCalledWith( + 0, + 'previous', + expect.anything() + ) + expect(document.querySelector('input')).toHaveValue('1234') + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index 436c16cefcb..341c7640f8c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -82,7 +82,10 @@ export type DataAttributes = { export default function useFieldProps( localeProps: Props & FieldPropsGeneric, - { executeOnChangeRegardlessOfError = false } = {} + { + executeOnChangeRegardlessOfError = false, + updateContextDataInSync = false, + } = {} ): typeof localeProps & ReturnAdditional { const { extend } = useContext(FieldPropsContext) const props = extend(localeProps) @@ -1565,19 +1568,23 @@ export default function useFieldProps( // Use "useLayoutEffect" to avoid flickering when value/defaultValue gets set, and other fields dependent on it. // Form.Visibility is an example of a logic, where a field value/defaultValue can be used to set the set state of a path, // where again other fields depend on it. - const tmpValueRef = useRef>({}) - const setData = useCallback(() => { + const tmpTransValueRef = useRef>({}) + const setContextData = useCallback(() => { if (hasPath || hasItemPath) { - let value = valueProp + let valueToStore = valueProp + + const data = + hasItemPath || updateContextDataInSync + ? dataContext.internalDataRef?.current + : dataContext.data // First, look for existing data in the context - const hasValue = - pointer.has(dataContext.data, identifier) || identifier === '/' + const hasValue = pointer.has(data, identifier) || identifier === '/' const existingValue = identifier === '/' - ? dataContext.data + ? data : hasValue - ? pointer.get(dataContext.data, identifier) + ? pointer.get(data, identifier) : undefined // If no data where found in the dataContext, look for shared data @@ -1585,88 +1592,119 @@ export default function useFieldProps( dataContext.id && !hasValue && typeof existingValue === 'undefined' && - typeof value === 'undefined' + typeof valueToStore === 'undefined' ) { const sharedState = createSharedState(dataContext.id) const hasValue = pointer.has(sharedState.data, identifier) if (hasValue) { const sharedValue = pointer.get(sharedState.data, identifier) if (sharedValue) { - value = sharedValue + valueToStore = sharedValue } } } const hasDefaultValue = typeof defaultValueRef.current !== 'undefined' && - typeof value === 'undefined' + typeof valueToStore === 'undefined' if (hasDefaultValue) { - value = defaultValueRef.current + valueToStore = defaultValueRef.current defaultValueRef.current = undefined } + let skipEqualCheck = false + if (hasItemPath) { - if (hasDefaultValue) { - // When an itemPath is given, we rather want to use the defaultValue, - // even if the data context exists. Because the defaultValue should now be set in the data context. - valueRef.current = value - } else if ( - typeof value === 'undefined' && + if ( + typeof valueToStore === 'undefined' && typeof existingValue !== 'undefined' ) { // On the rerender (after defaultValue was set) and the data context was given, but as "undefined", // then we want to use the current value (the defaultValue from the previous render), // because else the comparison "valueRef.current !== existingValue" is true and we will set undefined as the new data context value. - valueRef.current = existingValue + valueToStore = existingValue } - } - if ( - (!hasValue && !hasItemPath) || - (value !== existingValue && - // Prevents an infinite loop by skipping the update if the value hasn't changed - valueRef.current !== existingValue) - ) { if ( - identifier in tmpValueRef.current && - tmpValueRef.current[identifier] === value + hasDefaultValue && + itemPath === '/' && + Array.isArray(existingValue) ) { - return // stop here, avoid infinite loop + // Ensures support to have a field with a defaultValue and a itemPath of "/" + // This way, we ensure the defaultValue is actually set in the data context. + skipEqualCheck = true } + } - const transformedValue = transformers.current.transformOut( - value, - transformers.current.provideAdditionalArgs(value) - ) - if (transformedValue !== value) { - // When the value got transformed, we want to update the internal value, and avoid an infinite loop - tmpValueRef.current[identifier] = value - value = transformedValue + if (updateContextDataInSync) { + // When an array is given (iterate), we don't want to overwrite the existing array + if (hasDefaultValue && hasValue) { + return // stop here, we don't want to overwrite the existing array } - // When an itemPath is given, we don't want to rerender the context on every iteration because of performance reasons. - // We know when the last item is reached, so we can prevent rerenders during the iteration. - const preventUpdate = - hasItemPath && iterateIndex < iterateArrayValue?.length - 1 + // React.StrictMode will come with "undefined" on the second render, + // because "defaultValueRef.current" was removed. + // But because we run "useMemo" on the first render when updateContextDataInSync is true, + // we have still a valid value/array. + if (!Array.isArray(valueToStore)) { + return // stop here, never use a non-array value when in "updateContextDataInSync" + } + } - // Update the data context when a pointer not exists, - // but was given initially. - updateDataValueDataContext?.(identifier, value, { preventUpdate }) + if ( + !skipEqualCheck && + hasValue && + (valueToStore === existingValue || + // Prevents an infinite loop by skipping the update if the value hasn't changed + valueRef.current === existingValue) + ) { + return // stop here, we don't want to set same value twice + } - if (!preventUpdate) { - validateDataDataContext?.() - } + if ( + identifier in tmpTransValueRef.current && + tmpTransValueRef.current[identifier] === valueToStore + ) { + return // stop here, avoid infinite loop + } + + const transformedValue = transformers.current.transformOut( + valueToStore, + transformers.current.provideAdditionalArgs(valueToStore) + ) + if (transformedValue !== valueToStore) { + // When the value got transformed, we want to update the internal value, and avoid an infinite loop + tmpTransValueRef.current[identifier] = valueToStore + valueToStore = transformedValue + } + + // When an itemPath is given, we don't want to rerender the context on every iteration because of performance reasons. + // We know when the last item is reached, so we can prevent rerenders during the iteration. + const preventUpdate = + updateContextDataInSync || + (hasItemPath && iterateIndex < iterateArrayValue?.length - 1) + + // Update the data context when a pointer not exists, + // but was given initially. + updateDataValueDataContext?.(identifier, valueToStore, { + preventUpdate, + }) + + if (!preventUpdate) { + validateDataDataContext?.() } } }, [ dataContext.data, dataContext.id, + dataContext.internalDataRef, hasItemPath, hasPath, identifier, iterateArrayValue?.length, iterateIndex, + updateContextDataInSync, updateDataValueDataContext, validateDataDataContext, valueProp, @@ -1698,18 +1736,26 @@ export default function useFieldProps( } }, [clearErrorState, defaultValue, hideError, isEmptyData]) + useMemo(() => { + if (updateContextDataInSync && !isEmptyData()) { + setContextData() + } + }, [isEmptyData, updateContextDataInSync, setContextData]) + useLayoutEffect(() => { - if (!isEmptyData()) { - setData() + if (!updateContextDataInSync && !isEmptyData()) { + setContextData() } - }, [isEmptyData, setData]) + }, [isEmptyData, updateContextDataInSync, setContextData]) useEffect(() => { if (isEmptyData()) { - setData() - validateValue() + requestAnimationFrame(() => { + setContextData() + validateValue() + }) } - }, [isEmptyData, setData, validateValue]) + }, [isEmptyData, setContextData, validateValue]) useEffect(() => { if (showAllErrors || showBoundaryErrors) { diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts index 24f1406272b..c9f32f2566c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts @@ -25,17 +25,16 @@ export default function usePath(props: Props = {}) { } const joinPath = useCallback((paths: Array) => { - return paths - .reduce((acc, cur) => (cur ? `${acc}/${cur}` : acc), '/') - .replace(/\/{2,}/g, '/') - .replace(/\/+$/, '') + return cleanPath( + paths.reduce((acc, cur) => (cur ? `${acc}/${cur}` : acc), '/') + ) }, []) const makeSectionPath = useCallback( (path: Path) => { - return `${ - sectionPath && sectionPath !== '/' ? sectionPath : '' - }${path}`.replace(/\/$/, '') + return cleanPath( + `${sectionPath && sectionPath !== '/' ? sectionPath : ''}${path}` + ) }, [sectionPath] ) @@ -51,15 +50,17 @@ export default function usePath(props: Props = {}) { root = makeSectionPath('') } - return `${root}${iteratePath || ''}/${iterateElementIndex}${ - itemPath && itemPath !== '/' ? itemPath : '' - }` + return cleanPath( + `${root}${iteratePath || ''}/${iterateElementIndex}${ + itemPath || '' + }` + ) }, [ + itemPathProp, iteratePathProp, sectionPath, iterateElementIndex, - itemPathProp, makeSectionPath, ] ) @@ -101,3 +102,9 @@ export default function usePath(props: Props = {}) { makeSectionPath, } } + +// Will remove duplicate slashes and trailing slashes +// /foo///bar/// => /foo/bar +function cleanPath(path: Path) { + return path.replace(/\/+$|\/(\/)+/g, '$1') +}