diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 281e9c39477..ef8edd97246 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -99,7 +99,11 @@ export interface ContextState { | unknown | Promise handlePathChangeUnvalidated: (path: Path, value: any) => void - updateDataValue: (path: Path, value: any) => void + updateDataValue: ( + path: Path, + value: any, + options?: { preventUpdate?: boolean } + ) => void setData: (data: any) => void clearData?: () => void mutateDataHandler?: MutateDataHandler diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx index 99a614b036f..8bafb53818d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -740,7 +740,7 @@ export default function Provider( }, [sessionStorageId]) const setData = useCallback( - (newData: Data) => { + (newData: Data, preventUpdate = false) => { // - Mutate the data context if (transformIn) { newData = mutateDataHandler(newData, transformIn) @@ -760,7 +760,9 @@ export default function Provider( storeInSession() } - forceUpdate() // Will rerender the whole form initially + if (!preventUpdate) { + forceUpdate() // Will rerender the whole form initially + } }, [ extendSharedData, @@ -778,7 +780,7 @@ export default function Provider( * Update the data set */ const updateDataValue: ContextState['updateDataValue'] = useCallback( - (path, value) => { + (path, value, { preventUpdate } = {}) => { if (!path) { return } @@ -804,7 +806,7 @@ export default function Provider( pointer.set(newData, path, value) } - setData(newData) + setData(newData, preventUpdate) }, [setData] ) 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 178d56be8f7..fef7b435027 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 2f4a0ac609b..a7eaabd11bb 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,64 +562,106 @@ describe('Iterate.Array', () => { expect(onChangeIterate).toHaveBeenLastCalledWith(['foo']) }) - it('should handle "defaultValue" with React.StrictMode', () => { - const onSubmit = jest.fn() + describe('defaultValue on Iterate.Array', () => { + 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() - it('should warn when "defaultValue" is used inside iterate', () => { - const log = jest.spyOn(console, 'log').mockImplementation() - const onSubmit = jest.fn() + render( + + + + + + + + ) - render( - - - - - - ) + const form = document.querySelector('form') + const input = document.querySelector('input') - const form = document.querySelector('form') - const input = document.querySelector('input') + expect(input).toHaveValue('') - expect(input).toHaveValue('') + fireEvent.submit(form) - fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myList: [''] }, + expect.anything() + ) + }) - expect(onSubmit).toHaveBeenCalledTimes(1) - expect(onSubmit).toHaveBeenLastCalledWith( - { myList: [''] }, - expect.anything() - ) + it('should handle "defaultValue" (with value) in React.StrictMode', () => { + const onSubmit = jest.fn() - expect(log).toHaveBeenCalledWith( - expect.any(String), - 'Using defaultValue="default value" prop inside Iterate is not supported yet' - ) + render( + + + + + + + + ) - log.mockRestore() + 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', () => { @@ -1278,46 +1320,6 @@ describe('Iterate.Array', () => { }) }) - describe('value and defaultValue', () => { - it('should warn when "value" prop is used', () => { - const log = jest.spyOn(console, 'log').mockImplementation() - - render( - - - - - - ) - - expect(log).toHaveBeenCalledWith( - expect.any(String), - 'Using value="bar" prop inside Iterate is not supported yet' - ) - - log.mockRestore() - }) - - it('should warn when "defaultValue" prop is used', () => { - const log = jest.spyOn(console, 'log').mockImplementation() - - render( - - - - - - ) - - expect(log).toHaveBeenCalledWith( - expect.any(String), - 'Using defaultValue="bar" prop inside Iterate is not supported yet' - ) - - log.mockRestore() - }) - }) - it('should contain tabindex of -1', () => { render(content) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/__tests__/PushContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/__tests__/PushContainer.test.tsx index efeb0495643..6195d6629d0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/__tests__/PushContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/__tests__/PushContainer.test.tsx @@ -495,47 +495,6 @@ describe('PushContainer', () => { ) }) - it('should push "null" to the array when "defaultValue" is given because it is not supported', async () => { - const log = jest.spyOn(console, 'log').mockImplementation() - const onChange = jest.fn() - - render( - - - View Content - Edit Content - - - - - - - ) - - const blocks = Array.from( - document.querySelectorAll('.dnb-forms-section-block') - ) - const [, , thirdBlock] = blocks - - const input = thirdBlock.querySelector('input') - expect(input).toHaveValue('bar') - - await userEvent.click(thirdBlock.querySelector('button')) - - expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith( - ['foo', null], - expect.anything() - ) - - expect(log).toHaveBeenCalledWith( - expect.any(String), - 'Using defaultValue="bar" prop inside Iterate is not supported yet' - ) - - log.mockRestore() - }) - it('should support {nextItemNo}', async () => { render( 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..86ca77233ae 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,102 @@ 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 remember an entered value between step changes', async () => { + const onChange = jest.fn() + const onStepChange = jest.fn() + + render( + + + + + + + + + + + + + + + + + + + + ) + + expect(document.querySelector('input')).toHaveValue('') + + await userEvent.type(document.querySelector('input'), '123') + + expect(document.querySelector('input')).toHaveValue('123') + + await userEvent.click(nextButton()) + expect(onStepChange).toHaveBeenLastCalledWith( + 1, + 'next', + expect.anything() + ) + expect(document.querySelector('input')).toHaveValue('123') + + await userEvent.type(document.querySelector('input'), '4') + expect(document.querySelector('input')).toHaveValue('1234') + + await userEvent.click(previousButton()) + 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 e307b8e55c6..df07449a505 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(FieldProviderContext) const props = extend(localeProps) @@ -1529,28 +1532,11 @@ export default function useFieldProps( validateValue() }, [schema, validateValue]) - const isEmptyData = useCallback(() => { - return ( - dataContext.internalDataRef?.current === - (dataContext.props?.emptyData ?? clearedData) - ) - }, [dataContext.internalDataRef, dataContext.props?.emptyData]) - - // Use "useLayoutEffect" to be in sync with the data context "updateDataValueDataContext" routine further down. - useLayoutEffect(() => { - if (isEmptyData()) { - removeError() - } - - // NB: ensure to include "externalValue" in order to properly remove errors - }, [externalValue, clearErrorState, hideError, isEmptyData, removeError]) - // Use "useLayoutEffect" and "externalValueDidChangeRef" // to cooperate with the the data context "updateDataValueDataContext" routine further down, // which also uses useLayoutEffect. const externalValueDidChangeRef = useRef(false) useLayoutEffect(() => { - // Error or removed error for this field from the surrounding data context (by path) if (valueRef.current !== externalValue) { valueRef.current = externalValue externalValueDidChangeRef.current = true @@ -1605,84 +1591,169 @@ 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>({}) - useLayoutEffect(() => { - if (hasPath) { - let value = valueProp - - // First, look for existing data in the context - const hasValue = - pointer.has(dataContext.data, identifier) || identifier === '/' - const existingValue = - identifier === '/' - ? dataContext.data - : hasValue - ? pointer.get(dataContext.data, identifier) - : undefined + const tmpTransValueRef = useRef>({}) + const setContextData = useCallback(() => { + if (!hasPath) { + return // stop here + } - // If no data where found in the dataContext, look for shared data - if ( - dataContext.id && - !hasValue && - typeof existingValue === 'undefined' && - typeof value === '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 as Value - } + let valueToStore = valueProp + + const data = wizardContext?.prerenderFieldProps + ? dataContext.data + : dataContext.internalDataRef?.current + + // First, look for existing data in the context + const hasValue = pointer.has(data, identifier) || identifier === '/' + const existingValue = + identifier === '/' + ? data + : hasValue + ? pointer.get(data, identifier) + : undefined + + // If no data where found in the dataContext, look for shared data + if ( + dataContext.id && + !hasValue && + typeof existingValue === '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) { + valueToStore = sharedValue as Value } } + } - if ( - typeof defaultValueRef.current !== 'undefined' && - typeof value === 'undefined' - ) { - value = defaultValueRef.current - defaultValueRef.current = undefined - } + const hasDefaultValue = + typeof defaultValueRef.current !== 'undefined' && + typeof valueToStore === 'undefined' - if ( - !hasValue || - (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 - ) { - return // stop here, avoid infinite loop - } + if (hasDefaultValue) { + valueToStore = defaultValueRef.current + defaultValueRef.current = undefined + } - const transformedValue = transformers.current.transformOut( - value, - transformers.current.provideAdditionalArgs(value) - ) - if (transformedValue !== value) { - tmpValueRef.current[identifier] = value - value = transformedValue - } + // Used by e.g. Iterate.Array + 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 + } - // Update the data context when a pointer not exists, - // but was given initially. - updateDataValueDataContext?.(identifier, value) - validateDataDataContext?.() + // 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" } } + + if ( + 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 ( + 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 + + // 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, hasPath, identifier, + updateContextDataInSync, updateDataValueDataContext, validateDataDataContext, valueProp, + wizardContext?.prerenderFieldProps, ]) + const isEmptyData = useCallback( + () => { + return ( + dataContext.internalDataRef?.current === + (dataContext.props?.emptyData ?? clearedData) + ) + }, + + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + dataContext.internalDataRef, + dataContext.props?.emptyData, + externalValue, // ensure to include "externalValue" in order to properly remove errors + ] + ) + + // Use "useLayoutEffect" to be in sync with the data context "updateDataValueDataContext". + useLayoutEffect(() => { + if (isEmptyData()) { + defaultValueRef.current = defaultValue + changedRef.current = false + hideError() + clearErrorState() + } + }, [clearErrorState, defaultValue, hideError, isEmptyData]) + + useMemo(() => { + if (updateContextDataInSync && !isEmptyData()) { + setContextData() + } + }, [isEmptyData, updateContextDataInSync, setContextData]) + + useLayoutEffect(() => { + if (!updateContextDataInSync && !isEmptyData()) { + setContextData() + } + }, [isEmptyData, updateContextDataInSync, setContextData]) + + useEffect(() => { + if (isEmptyData()) { + // Fill the data context with the default value after it has been cleared + requestAnimationFrame(() => { + setContextData() + validateValue() + }) + } + }, [isEmptyData, setContextData, validateValue]) + useEffect(() => { if (showAllErrors || showBoundaryErrors) { // In case of async validation, we don't want to show existing errors before the validation has been completed @@ -1765,6 +1836,7 @@ export default function useFieldProps( return () => { // Unmount procedure if (mountedFieldsRefFieldBlock) { + // eslint-disable-next-line react-hooks/exhaustive-deps mountedFieldsRefFieldBlock.current[identifier] = true } }