diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/Examples.tsx index a613dea4543..e1a3679a4e2 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/Examples.tsx @@ -5,6 +5,7 @@ import { Field, Value, Form, + Tools, } from '@dnb/eufemia/src/extensions/forms' export { Default as AnimatedContainer } from '../AnimatedContainer/Examples' @@ -172,7 +173,7 @@ export const ArrayFromFormHandler = () => { }} onChange={(data) => console.log('DataContext/onChange', data)} > - + Avengers @@ -212,7 +213,7 @@ export const ArrayFromFormHandler = () => { pushValue={{}} /> - + ) @@ -282,7 +283,7 @@ export const ViewAndEditContainer = () => { accounts: [ { firstName: 'Tony', - lastName: undefined, // initiate error + lastName: 'Rogers', }, ], }} @@ -291,7 +292,7 @@ export const ViewAndEditContainer = () => { } onSubmit={async (data) => console.log('onSubmit', data)} > - + Accounts @@ -304,7 +305,7 @@ export const ViewAndEditContainer = () => { - + ) } @@ -364,3 +365,80 @@ export const WithVisibility = () => { ) } + +export const InitialOpen = () => { + return ( + + console.log('onSubmit', data)} + onSubmitRequest={() => console.log('onSubmitRequest')} + > + + Statsborgerskap + + + + + {({ EditButton, RemoveButton, items }) => { + if (items.length === 1) { + return + } + return ( + <> + + + + ) + }} + + } + > + + + + + {({ DoneButton, CancelButton, items }) => { + if (items.length === 1) { + return null + } + + return ( + <> + + + + ) + }} + + } + > + + + + + + + + + + + + + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx index 13221d8dc00..429366e4a83 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx @@ -47,6 +47,14 @@ With an optional `title` and [Iterate.Toolbar](/uilib/extensions/forms/Iterate/T +### Initially open + +This example uses a customized [Iterate.Toolbar](/uilib/extensions/forms/Iterate/Toolbar/). + +It hides the toolbar from the `EditContainer` when there is only one item in the array. And it hides the remove button from the `ViewContainer` when there is only one item in the array. + + + ### With DataContext and add/remove buttons diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/info.mdx index 3887fd0aa60..4fedb43fd87 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/info.mdx @@ -88,6 +88,14 @@ render( ) ``` +### Initial container mode + +This section describes the behavior of the `EditContainer` and the `ViewContainer` components. + +By default, the container mode is set to `auto`. This means that the container will open (switch to `edit` mode) when there is an error in the container or the value is falsy (empty string, null, undefined, etc.). + +When a new item is added via the [Iterate.PushButton](/uilib/extensions/forms/Iterate/PushButton/) component, the item before it will change to `view` mode, if it had no validation errors. + ## Filter data You can filter data by paths specific or all paths. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx index c90549eab1b..6d9992ff1b4 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx @@ -74,7 +74,7 @@ export const InitiallyOpen = () => { } onSubmit={async (data) => console.log('onSubmit', data)} > - + Accounts @@ -87,7 +87,7 @@ export const InitiallyOpen = () => { - + ) } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar/info.mdx index 98959df27f6..0663d0dd4a5 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar/info.mdx @@ -7,6 +7,8 @@ hideInMenu: true Use `Iterate.Toolbar` to enhance each item in the array with additional functionality. It's particularly useful within components like [Iterate.AnimatedContainer](/uilib/extensions/forms/Iterate/AnimatedContainer) to incorporate a toolbar with extra tools. +The Toolbar is integrated into the containers [Iterate.ViewContainer](/uilib/extensions/forms/Iterate/ViewContainer/) and [Iterate.EditContainer](/uilib/extensions/forms/Iterate/EditContainer/). + ```tsx import { Iterate } from '@dnb/eufemia/extensions/forms' @@ -22,4 +24,46 @@ render( ) ``` -The Toolbar is integrated into the containers [Iterate.ViewContainer](/uilib/extensions/forms/Iterate/ViewContainer/) and [Iterate.EditContainer](/uilib/extensions/forms/Iterate/EditContainer/). +## Cusomize the Toolbar + +You can customize the toolbar by passing a function as a child to `Iterate.Toolbar`. The function receives the following parameters as an object: + +- `index` the index of the current item in the array. +- `items` the array of items. +- `EditButton` used in the `Iterate.ViewContainer`. +- `RemoveButton` used in the `Iterate.ViewContainer`. +- `CancelButton` used in the `Iterate.EditContainer`. +- `DoneButton` used in the `Iterate.EditContainer`. + +```tsx +import { Iterate } from '@dnb/eufemia/extensions/forms' + +render( + + + Item Content + + {({ + index, + items, + EditButton, + RemoveButton, + CancelButton, + DoneButton, + }) => { + if (items.length === 1) { + return null + } + + return ( + <> + + + + ) + }} + + + , +) +``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx index 19a50a653fd..eed8f89757b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx @@ -13,6 +13,10 @@ breadcrumb: Change log for the Eufemia Forms extension. +## v10.48 + +- Make [Iterate.Toolbar](/uilib/extensions/forms/Iterate/Toolbar/) customizable when used inside [Iterate.Array](/uilib/extensions/forms/Iterate/Array/). + ## v10.46 - Added [Value.SelectCountry](/uilib/extensions/forms/Value/SelectCountry/) component to render a country value. diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 460417e9b67..8f8ca056d86 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -81,6 +81,7 @@ export interface ContextState { handlePathChangeUnvalidated: (path: Path, value: any) => void updateDataValue: (path: Path, value: any) => void setData: (data: any) => void + clearData?: () => void mutateDataHandler?: (data: any, mutate: TransformData) => any filterDataHandler?: (data: any, filter: FilterData) => any validateData: () => void diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx index 803b8a47dea..05133db43f8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/FieldBoundary/FieldBoundaryProvider.tsx @@ -11,7 +11,7 @@ export default function FieldBoundaryProvider({ children }) { const errorsRef = useRef>({}) const showBoundaryErrorsRef = useRef(false) - const hasError = Object.keys(errorsRef.current || {}).length > 0 + const hasError = Object.keys(errorsRef.current).length > 0 const hasSubmitError = showAllErrors && hasError const setFieldError = useCallback((path: Path, error: Error) => { @@ -20,6 +20,7 @@ export default function FieldBoundaryProvider({ children }) { } else { delete errorsRef.current?.[path] } + forceUpdate() }, []) const setShowBoundaryErrors = useCallback( 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 ab8e71cc69b..ccf84cea562 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -1167,6 +1167,7 @@ export default function Provider( validateData, updateDataValue, setData, + clearData, filterDataHandler, addOnChangeHandler, setHandleSubmit, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx index 9dcbd080369..62d5504e8b6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx @@ -195,7 +195,7 @@ function SelectCountry(props: Props) { label={label} input_icon={false} data={dataRef.current} - value={value} + value={typeof value === 'string' ? value : null} disabled={disabled} on_show={fillData} on_focus={onFocusHandler} diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx index 0be62c731d9..0a443c6d8b6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx @@ -274,7 +274,7 @@ describe('Field.String', () => { await userEvent.type(input, '{Backspace>3}aBc') expect(input).toHaveValue('ABC') - expect(transformIn).toHaveBeenCalledTimes(13) + expect(transformIn).toHaveBeenCalledTimes(7) expect(transformIn).toHaveBeenLastCalledWith('abc') expect(transformOut).toHaveBeenCalledTimes(6) expect(transformOut).toHaveBeenLastCalledWith('ABc') @@ -289,7 +289,7 @@ describe('Field.String', () => { await userEvent.type(input, '{Backspace>3}EfG') expect(input).toHaveValue('EFG') - expect(transformIn).toHaveBeenCalledTimes(25) + expect(transformIn).toHaveBeenCalledTimes(13) expect(transformIn).toHaveBeenLastCalledWith('efg') expect(transformOut).toHaveBeenCalledTimes(12) expect(transformOut).toHaveBeenLastCalledWith('EFG') diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx index ffad2987bf2..0996397a262 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx @@ -174,7 +174,9 @@ function IsolationProvider( // Commit the internal data to the nested context data handlePathChangeOuter?.( path, - extendDeep({}, outerData, isolatedData) + Array.isArray(isolatedData) + ? isolatedData + : extendDeep({}, outerData, isolatedData) ) return await onCommitProp?.( @@ -202,8 +204,7 @@ function IsolationProvider( const providerProps: IsolationProps = { ...props, - data: internalDataRef.current, - defaultData: undefined, + [defaultData ? 'defaultData' : 'data']: internalDataRef.current, onPathChange: onPathChangeHandler, onCommit, onClear, diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlock.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlock.tsx index 0a971946f9f..87f05c923aa 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlock.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/ElementBlock.tsx @@ -33,27 +33,6 @@ export type Props = { function ElementBlock(props: Props & FlexContainerProps) { const [, forceUpdate] = useReducer(() => ({}), {}) - const contextRef = useRef< - IterateItemContextState & { - hasError?: boolean - hasSubmitError?: boolean - } - >() - contextRef.current = useContext(IterateItemContext) || {} - - const { hasError, hasSubmitError } = - useContext(FieldBoundaryContext) || {} - contextRef.current.hasError = hasError - contextRef.current.hasSubmitError = hasSubmitError - - // - Set the container mode to "edit" if we have an error - if (hasSubmitError) { - contextRef.current.containerMode = 'edit' - } - - const { handleRemove, switchContainerMode, containerMode, isNew } = - contextRef.current - const { mode, open, @@ -66,6 +45,43 @@ function ElementBlock(props: Props & FlexContainerProps) { ...restProps } = props + const localContextRef = useRef() + + const { hasError, hasSubmitError } = + useContext(FieldBoundaryContext) || {} + localContextRef.current = useContext(IterateItemContext) || {} + const omitFocusManagementRef = useRef(false) + const { isNew, value } = localContextRef.current + if (hasSubmitError || !value) { + localContextRef.current.containerMode = 'edit' + } + if (localContextRef.current.containerMode === 'auto') { + localContextRef.current.containerMode = 'view' + } + + const determineMode = useCallback(() => { + const { initialContainerMode, switchContainerMode } = + localContextRef.current + if ( + mode === 'edit' && + !hasSubmitError && + initialContainerMode === 'auto' + ) { + // - Set the container mode to "edit" if we have an error + if (hasError && !isNew) { + omitFocusManagementRef.current = true + switchContainerMode('edit') + } + } + }, [hasError, hasSubmitError, isNew, mode]) + + useEffect(() => { + determineMode() + }, [determineMode]) + + const { handleRemove, previousContainerMode, containerMode } = + localContextRef.current + const openRef = useRef(open ?? (containerMode === mode && !isNew)) const isRemoving = useRef(false) @@ -94,19 +110,17 @@ function ElementBlock(props: Props & FlexContainerProps) { } }, [containerMode, isNew, mode, open, openDelay, setOpenState]) - // - Remove the block with animation, if it's in the right mode - const handleAnimationEnd = useCallback( + const setFocus = useCallback( (state) => { - // - Keep the block open if we have an error - if (contextRef.current.hasSubmitError) { - switchContainerMode?.('edit') - } - - const preventFocusOnErrorOpening = !contextRef.current.hasSubmitError - if (preventFocusOnErrorOpening) { + if ( + !omitFocusManagementRef.current && + !hasSubmitError && + containerMode === mode && // ensure we match the correct mode + containerMode !== previousContainerMode // ensure we have a new mode + ) { if (state === 'opened') { - contextRef.current?.elementRef?.current?.focus?.() - } else { + localContextRef.current?.elementRef?.current?.focus?.() + } else if (state === 'closed') { // Wait until the element is removed, then check if we can set focus window.requestAnimationFrame(() => { // try to focus on the second last element @@ -118,26 +132,35 @@ function ElementBlock(props: Props & FlexContainerProps) { ) ) { const elements = - contextRef.current?.containerRef.current.querySelectorAll( + localContextRef.current?.containerRef.current.querySelectorAll( '.dnb-forms-iterate__element' ) elements[elements.length - 1].focus() } } catch (e) { - /**/ + /* do nothing */ } }) } } + omitFocusManagementRef.current = false + }, + [containerMode, hasSubmitError, mode, previousContainerMode] + ) + + // - Remove the block with animation, if it's in the right mode + const handleAnimationEnd = useCallback( + (state) => { if (!openRef.current && isRemoving.current) { isRemoving.current = false - contextRef.current?.fulfillRemove?.() + localContextRef.current?.fulfillRemove?.() } + setFocus(state) onAnimationEnd?.(state) }, - [onAnimationEnd, switchContainerMode] + [onAnimationEnd, setFocus] ) const handleRemoveBlock = useCallback(() => { isRemoving.current = true @@ -152,8 +175,7 @@ function ElementBlock(props: Props & FlexContainerProps) { 'dnb-forms-section-block', variant && `dnb-forms-section-block--variant-${variant}`, isNew && 'dnb-forms-section-block--new', - contextRef.current.hasSubmitError && - 'dnb-forms-section-block--error', + hasSubmitError && 'dnb-forms-section-block--error', className )} open={openRef.current} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/__tests__/ElementBlock.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/__tests__/ElementBlock.test.tsx index 783db2a76cd..ebf1e6d552d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/__tests__/ElementBlock.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/AnimatedContainer/__tests__/ElementBlock.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render } from '@testing-library/react' import IterateItemContext from '../../IterateItemContext' import ElementBlock from '../ElementBlock' import RemoveButton from '../../RemoveButton' +import FieldBoundaryContext from '../../../DataContext/FieldBoundary/FieldBoundaryContext' import { wait } from '../../../../../core/jest/jestSetup' import { DataContext, Field, Form, Iterate } from '../../..' import { simulateAnimationEnd } from '../../../../../components/height-animation/__tests__/HeightAnimationUtils' @@ -12,7 +13,9 @@ describe('ElementBlock', () => { const onAnimationEnd = jest.fn() const wrapper = ({ children }) => ( - + {children} ) @@ -57,7 +60,7 @@ describe('ElementBlock', () => { const wrapper = ({ children }) => ( {children} @@ -279,7 +282,12 @@ describe('ElementBlock', () => { render( @@ -301,7 +309,9 @@ describe('ElementBlock', () => { it('opens component based on containerMode', async () => { const { rerender } = render( - + content ) @@ -311,7 +321,9 @@ describe('ElementBlock', () => { expect(element).not.toHaveClass('dnb-height-animation--hidden') rerender( - + content ) @@ -319,6 +331,51 @@ describe('ElementBlock', () => { expect(element).toHaveClass('dnb-height-animation--hidden') }) + it('opens in edit mode when falsy value was given', async () => { + let containerMode = null + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode = context.containerMode + + return null + } + + render( + + + + + + ) + + expect(containerMode).toBe('edit') + }) + + it('should call switchContainerMode when mode is edit and error is present', async () => { + const switchContainerMode = jest.fn() + + render( + + + content + + + ) + + expect(switchContainerMode).toHaveBeenCalledTimes(1) + expect(switchContainerMode).toHaveBeenLastCalledWith('edit') + }) + it('opens component based on "open" prop', async () => { const { rerender } = render( @@ -370,6 +427,7 @@ describe('ElementBlock', () => { value={{ handleRemove, containerMode: 'view', + value: 'foo', }} > @@ -378,7 +436,10 @@ describe('ElementBlock', () => { ) - fireEvent.click(document.querySelector('button')) + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(1) + + fireEvent.click(buttons[0]) expect(handleRemove).toHaveBeenCalledTimes(1) 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 8bab72afc3b..9579340129f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx @@ -13,6 +13,7 @@ import { useFieldProps } from '../../hooks' import { makeUniqueId } from '../../../../shared/component-helper' import { Flex } from '../../../../components' import { pickSpacingProps } from '../../../../components/flex/utils' +import useMountEffect from '../../../../shared/helpers/useMountEffect' import { BasicProps as FlexContainerProps, Props as FlexContainerAllProps, @@ -24,7 +25,9 @@ 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 { useSwitchContainerMode } from '../hooks' import type { ContainerMode, ElementChild, Props, Value } from './types' import type { Identifier, Path } from '../../types' @@ -81,9 +84,11 @@ function ArrayComponent(props: Props) { const { path, value: arrayValue, + defaultValue, withoutFlex, emptyValue, placeholder, + containerMode, handleChange, onChange, children, @@ -91,7 +96,12 @@ function ArrayComponent(props: Props) { const idsRef = useRef>([]) const isNewRef = useRef>({}) - const modesRef = useRef>({}) + const modesRef = useRef< + Record< + Identifier, + { current: ContainerMode; previous?: ContainerMode } + > + >({}) const valueWhileClosingRef = useRef() const valueCountRef = useRef(arrayValue) const containerRef = useRef() @@ -102,89 +112,108 @@ 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 || [] }, [arrayValue]) - const elementData = useMemo(() => { - return ((valueWhileClosingRef.current || arrayValue) ?? []).map( - (value, index) => { - const id = idsRef.current[index] || makeUniqueId() + const { getNextContainerMode } = useSwitchContainerMode() - const hasNewItems = - arrayValue.length > valueCountRef.current?.length - - if (!idsRef.current[index]) { - isNewRef.current[id] = hasNewItems - idsRef.current.push(id) - } + const arrayItems = useMemo(() => { + const list = valueWhileClosingRef.current || arrayValue + return (list ?? []).map((value, index) => { + const id = idsRef.current[index] || makeUniqueId() - const isNew = isNewRef.current[id] || false - if (!modesRef.current[id]) { - modesRef.current[id] = - value?.['__containerMode'] ?? (isNew ? 'edit' : 'view') - delete value?.['__containerMode'] - } - - return { - id, - path, - value, - index, - arrayValue, - containerRef, - isNew, - containerMode: modesRef.current[id], - switchContainerMode: (mode: ContainerMode) => { - modesRef.current[id] = mode - delete isNewRef.current?.[id] - forceUpdate() - }, - handleChange: (path: Path, value: unknown) => { - const newArrayValue = structuredClone(arrayValue) + const hasNewItems = + arrayValue?.length > valueCountRef.current?.length - // Make sure we have a new object reference, - // else two new objects will be the same - newArrayValue[index] = { ...newArrayValue[index] } + if (!idsRef.current[index]) { + isNewRef.current[id] = hasNewItems + idsRef.current.push(id) + } - pointer.set(newArrayValue, path, value) - handleChange(newArrayValue) - }, - handlePush: (element: unknown) => { - hadPushRef.current = true - handleChange([...(arrayValue ?? []), element]) - }, - handleRemove: ({ keepItems = false } = {}) => { - if (keepItems) { - // Add a backup as the array value while animating - valueWhileClosingRef.current = arrayValue - } + const isNew = isNewRef.current[id] || false + if (!modesRef.current[id]?.current) { + modesRef.current[id] = { + current: + containerMode ?? + (isNew ? getNextContainerMode() ?? 'edit' : 'auto'), + } + } - const newArrayValue = structuredClone(arrayValue) - newArrayValue.splice(index, 1) - handleChange(newArrayValue) - }, - - // - Called after animation end - fulfillRemove: () => { - valueWhileClosingRef.current = null - delete modesRef.current?.[id] - delete isNewRef.current?.[id] - const findIndex = idsRef.current.indexOf(id) - idsRef.current.splice(findIndex, 1) - forceUpdate() - }, - - // - Called when cancel button press - restoreOriginalValue: (value: unknown) => { + const itemContext: IterateItemContextState = { + id, + path, + value, + index, + arrayValue, + containerRef, + isNew, + containerMode: modesRef.current[id].current, + previousContainerMode: modesRef.current[id].previous, + initialContainerMode: containerMode || 'auto', + switchContainerMode: (mode: ContainerMode) => { + modesRef.current[id].previous = modesRef.current[id].current + modesRef.current[id].current = mode + delete isNewRef.current?.[id] + forceUpdate() + }, + handleChange: (path: Path, value: unknown) => { + const newArrayValue = structuredClone(arrayValue) + + // Make sure we have a new object reference, + // else two new objects will be the same + newArrayValue[index] = { ...newArrayValue[index] } + + pointer.set(newArrayValue, path, value) + handleChange(newArrayValue) + }, + handlePush: (element: unknown) => { + hadPushRef.current = true + handleChange([...(arrayValue || []), element]) + }, + handleRemove: ({ keepItems = false } = {}) => { + if (keepItems) { + // Add a backup as the array value while animating + valueWhileClosingRef.current = arrayValue + } + + const newArrayValue = structuredClone(arrayValue) + newArrayValue.splice(index, 1) + handleChange(newArrayValue) + }, + + // - Called after animation end + fulfillRemove: () => { + valueWhileClosingRef.current = null + delete modesRef.current?.[id] + delete isNewRef.current?.[id] + const findIndex = idsRef.current.indexOf(id) + idsRef.current.splice(findIndex, 1) + forceUpdate() + }, + + // - Called when cancel button press + restoreOriginalValue: (value: unknown) => { + if (value) { const newArrayValue = structuredClone(arrayValue) newArrayValue[index] = value handleChange(newArrayValue) - }, - } as IterateItemContextState + } + }, } - ) + + return itemContext + }) // In order to update "valueWhileClosingRef" we need to have "salt" in the deps array // eslint-disable-next-line react-hooks/exhaustive-deps @@ -192,13 +221,13 @@ function ArrayComponent(props: Props) { // - Call the onChange callback when a new element is added without calling "handlePush" useMemo(() => { - const last = elementData?.[elementData.length - 1] + const last = arrayItems?.[arrayItems.length - 1] if (last?.isNew && !hadPushRef.current) { onChange?.(arrayValue) } else { hadPushRef.current = false } - }, [arrayValue, elementData, onChange]) + }, [arrayValue, arrayItems, onChange]) const flexProps: FlexContainerProps & { innerRef: FlexContainerAllProps['innerRef'] @@ -219,8 +248,8 @@ function ArrayComponent(props: Props) { {arrayValue === emptyValue || props?.value?.length === 0 ? placeholder - : elementData.map((elementProps) => { - const { id, value, index } = elementProps + : arrayItems.map((itemProps) => { + const { id, value, index } = itemProps const elementRef = (innerRefs.current[id] = innerRefs.current[id] || createRef()) @@ -231,7 +260,7 @@ function ArrayComponent(props: Props) { } const contextValue = { - ...elementProps, + ...itemProps, elementRef, } @@ -267,5 +296,5 @@ function ArrayComponent(props: Props) { ) } -ArrayComponent._supportsSpacingProps = true +ArrayComponent._supportsSpacingProps = false // disable flex support to avoid rerender, which could result in flickering export default ArrayComponent diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts index 9a92c5ebde7..d6035e1b3ac 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayDocs.ts @@ -41,6 +41,11 @@ export const ArrayProperties: PropertiesTableProps = { type: 'unknown', status: 'optional', }, + containerMode: { + doc: 'Defines the container mode for all nested containers. Can be `view`, `edit` or `auto`. When using `auto`, it will automatically open if there is an error in the container. When a new item is added, the item before it will change to `view` mode, if it had no validation errors. Defaults to `auto`.', + type: 'string', + status: 'optional', + }, children: { doc: 'React.Node or a function so you can get the current value and index.', type: 'boolean', diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.screenshot.test.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.screenshot.test.ts index 8e80bdcef01..a4583a99dcc 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.screenshot.test.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.screenshot.test.ts @@ -29,7 +29,7 @@ describe('Iterate.Array', () => { const screenshot = await makeScreenshot({ url, selector: - '[data-visual-test="view-and-edit-container"] .dnb-forms-section-block', + '[data-visual-test="view-and-edit-container"] .dnb-forms-section-view-block', }) expect(screenshot).toMatchImageSnapshot() }) 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 8a3979283e1..5d48916f75e 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 @@ -416,6 +416,66 @@ describe('Iterate.Array', () => { expect(onChangeIterate).toHaveBeenLastCalledWith(['foo']) }) + it('should handle "defaultValue" with 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 warn when "defaultValue" is used inside iterate', () => { + const log = jest.spyOn(console, 'log').mockImplementation() + 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() + ) + + expect(log).toHaveBeenCalledWith( + expect.any(String), + 'Using defaultValue="default value" prop inside Iterate is not supported yet' + ) + + log.mockRestore() + }) + describe('with primitive elements', () => { describe('referenced with path', () => { it('should distribute values and receive callbacks on both iterate and context', async () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-edit-container.snap.png b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-edit-container.snap.png index ccbc44fbe3a..9a25b32facc 100644 Binary files a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-edit-container.snap.png and b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-edit-container.snap.png differ diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-view-container.snap.png b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-view-container.snap.png index de37f704140..d854dd7ee4b 100644 Binary files a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-view-container.snap.png and b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/__image_snapshots__/iteratearray-have-to-match-view-container.snap.png differ diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/types.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/types.ts index 9edf1e39ca0..86e6c3048b9 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/types.ts @@ -1,7 +1,7 @@ import { Path, UseFieldProps } from '../../types' import { Props as FlexContainerProps } from '../../../../components/flex/Container' -export type ContainerMode = 'view' | 'edit' +export type ContainerMode = 'view' | 'edit' | 'auto' export type Value = Array> export type ElementChild = | React.ReactNode @@ -12,13 +12,14 @@ export type Props = Omit< > & Pick< UseFieldProps, - 'value' | 'emptyValue' | 'onChange' + 'value' | 'defaultValue' | 'emptyValue' | 'onChange' > & { children: ElementChild | Array path?: Path countPath?: Path countPathLimit?: number - countPathTransform?: (params: { value: any; index: number }) => any withoutFlex?: boolean placeholder?: React.ReactNode + containerMode?: ContainerMode + countPathTransform?: (params: { value: any; index: number }) => any } diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/CancelButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/CancelButton.tsx new file mode 100644 index 00000000000..e8c6bc8cba2 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/CancelButton.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react' +import { Button } from '../../../../components' +import useTranslation from '../../hooks/useTranslation' +import IterateItemContext from '../IterateItemContext' +import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryContext' +import { close } from '../../../../icons' +import { ButtonProps } from '../../../../components/Button' +import RemoveButton, { Props as RemoveButtonProps } from '../RemoveButton' +import { ContainerMode } from '../Array' + +type Props = ButtonProps + +export default function EditToolbarTools(props: Props) { + const { + restoreOriginalValue, + switchContainerMode, + containerMode, + initialContainerMode, + arrayValue, + isNew, + index, + } = useContext(IterateItemContext) || {} + const { hasError, setShowBoundaryErrors } = + useContext(FieldBoundaryContext) || {} + + const { cancelButton, removeButton } = + useTranslation().IterateEditContainer + const valueBackupRef = useRef() + + useEffect(() => { + if (containerMode === 'edit' && !valueBackupRef.current) { + valueBackupRef.current = arrayValue?.[index] + } + if (containerMode === 'view') { + valueBackupRef.current = null + } + }, [arrayValue, containerMode, index]) + + const cancelHandler = useCallback(() => { + if (hasError && initialContainerMode === 'auto') { + setShowBoundaryErrors?.(true) + } else { + restoreOriginalValue?.(valueBackupRef.current) + setShowBoundaryErrors?.(false) + switchContainerMode?.('view') + } + }, [ + hasError, + initialContainerMode, + restoreOriginalValue, + setShowBoundaryErrors, + switchContainerMode, + ]) + + const wasNew = useWasNew({ isNew, containerMode }) + + if (containerMode === 'edit' && arrayValue?.length === 0) { + return <> + } + + if (wasNew) { + return ( + + ) + } + + return ( + + ) +} + +export function useWasNew({ + isNew, + containerMode, +}: { + isNew: boolean + containerMode: ContainerMode +}) { + const wasNewRef = useRef(isNew) + + useEffect(() => { + if (containerMode === 'view') { + wasNewRef.current = false + } + }, [isNew, containerMode]) + + return wasNewRef.current +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/DoneButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/DoneButton.tsx new file mode 100644 index 00000000000..e5b7b954df4 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/DoneButton.tsx @@ -0,0 +1,60 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react' +import { Button } from '../../../../components' +import useTranslation from '../../hooks/useTranslation' +import IterateItemContext from '../IterateItemContext' +import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryContext' +import PushContainerContext from '../PushContainer/PushContainerContext' +import { check } from '../../../../icons' +import { ButtonProps } from '../../../../components/Button' + +type Props = ButtonProps + +export default function DoneButton(props: Props) { + const { switchContainerMode, containerMode, arrayValue, index } = + useContext(IterateItemContext) || {} + const { hasError, setShowBoundaryErrors } = + useContext(FieldBoundaryContext) || {} + const { commitHandleRef } = useContext(PushContainerContext) || {} + + const { doneButton } = useTranslation().IterateEditContainer + const valueBackupRef = useRef() + + useEffect(() => { + if (containerMode === 'edit' && !valueBackupRef.current) { + valueBackupRef.current = arrayValue?.[index] + } + if (containerMode === 'view') { + valueBackupRef.current = null + } + }, [arrayValue, containerMode, index]) + + const doneHandler = useCallback(() => { + if (hasError) { + setShowBoundaryErrors?.(true) + } else { + setShowBoundaryErrors?.(false) + if (commitHandleRef) { + commitHandleRef.current?.() + } else { + switchContainerMode?.('view') + } + } + }, [ + commitHandleRef, + hasError, + setShowBoundaryErrors, + switchContainerMode, + ]) + + return ( + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx index 6946f1b2480..43be399fbfa 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx @@ -4,11 +4,13 @@ import { convertJsxToString } from '../../../../shared/component-helper' import { Lead } from '../../../../elements' import { Props as FlexContainerProps } from '../../../../components/flex/Container' import IterateItemContext from '../IterateItemContext' -import EditToolbarTools, { useWasNew } from './EditToolbarTools' import ElementBlock, { ElementSectionProps, } from '../AnimatedContainer/ElementBlock' import Toolbar from '../Toolbar' +import { useSwitchContainerMode } from '../hooks' +import DoneButton from './DoneButton' +import CancelButton, { useWasNew } from './CancelButton' export type Props = { /** @@ -36,12 +38,14 @@ export type AllProps = Props & FlexContainerProps & ElementSectionProps export default function EditContainer(props: AllProps) { const { toolbar, ...rest } = props + return ( - + + ) } @@ -53,8 +57,8 @@ export default function EditContainer(props: AllProps) { export function EditContainerWithoutToolbar( props: Props & FlexContainerProps & { toolbar?: React.ReactNode } ) { - const iterateItemContext = useContext(IterateItemContext) - const { containerMode, isNew } = iterateItemContext ?? {} + const { containerMode, isNew, index, path } = + useContext(IterateItemContext) const { children, @@ -69,12 +73,11 @@ export function EditContainerWithoutToolbar( let itemTitle = wasNew && titleWhenNew ? titleWhenNew : title let ariaLabel = useMemo(() => convertJsxToString(itemTitle), [itemTitle]) if (ariaLabel.includes('{itemNr}')) { - itemTitle = ariaLabel = ariaLabel.replace( - '{itemNr}', - iterateItemContext.index + 1 - ) + itemTitle = ariaLabel = ariaLabel.replace('{itemNr}', index + 1) } + useSwitchContainerMode({ path }) + return ( () - const wasNew = useWasNew({ isNew, containerMode }) - const [showError, setShowError] = useState(false) - - useEffect(() => { - if (containerMode === 'edit' && !valueBackupRef.current) { - valueBackupRef.current = arrayValue?.[index] - } - if (containerMode === 'view') { - valueBackupRef.current = null - } - }, [arrayValue, containerMode, index]) - - const cancelHandler = useCallback(() => { - if (valueBackupRef.current) { - restoreOriginalValue?.(valueBackupRef.current) - } - setShowError(false) - setShowBoundaryErrors?.(false) - switchContainerMode?.('view') - }, [restoreOriginalValue, setShowBoundaryErrors, switchContainerMode]) - const doneHandler = useCallback(() => { - if (hasError) { - setShowBoundaryErrors?.(true) - if (hasVisibleError) { - setShowError(true) - } - } else { - setShowBoundaryErrors?.(false) - setShowError(false) - if (commitHandleRef) { - commitHandleRef.current?.() - } else { - switchContainerMode?.('view') - } - } - }, [ - commitHandleRef, - hasError, - hasVisibleError, - setShowBoundaryErrors, - switchContainerMode, - ]) - - return ( - <> - - {errorInSection} - - - {commitHandleRef ? ( - - ) : ( - - )} - - {(!entries || (entries?.length > 0 && containerMode === 'edit')) && - (wasNew ? ( - - ) : ( - - ))} - - - ) -} - -export function useWasNew({ - isNew, - containerMode, -}: { - isNew: boolean - containerMode: ContainerMode -}) { - const wasNewRef = useRef(isNew) - - useEffect(() => { - if (containerMode === 'view') { - wasNewRef.current = false - } - }, [isNew, containerMode]) - - return wasNewRef.current -} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx index d509f08f7b0..9fd0ca2ceab 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx @@ -7,7 +7,10 @@ import { Field, Form, Iterate } from '../../..' import userEvent from '@testing-library/user-event' import nbNO from '../../../constants/locales/nb-NO' -const nb = nbNO['nb-NO'].IterateEditContainer +const tr = { + viewContainer: nbNO['nb-NO'].IterateViewContainer, + editContainer: nbNO['nb-NO'].IterateEditContainer, +} describe('EditContainer and ViewContainer', () => { it('should switch mode on pressing edit button', async () => { @@ -69,6 +72,43 @@ describe('EditContainer and ViewContainer', () => { expect(secondElement).toHaveTextContent('Edit Content') }) + it('should switch mode from view to edit when a field errors', async () => { + let containerMode = null + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode = context.containerMode + + return null + } + + render( + + + + { + if (value === '01') { + return new Error('error') + } + }} + /> + + content + + + + ) + + expect(containerMode).toBe('view') + + const input = document.querySelector('input') + await userEvent.type(input, '1') + + expect(containerMode).toBe('edit') + }) + it('should switch mode from view to edit on error during submit', async () => { let containerMode = null @@ -81,22 +121,33 @@ describe('EditContainer and ViewContainer', () => { render( - + - + { + if (value === '01') { + return new Error('error') + } + }} + /> content + ) + expect(containerMode).toBe('view') + const input = document.querySelector('input') - await userEvent.type(input, 'x{Backspace}') + await userEvent.type(input, '1') + expect(containerMode).toBe('view') - const form = document.querySelector('form') - fireEvent.submit(form) + const button = document.querySelector('button') + await userEvent.click(button) expect(containerMode).toBe('edit') }) @@ -185,7 +236,7 @@ describe('EditContainer and ViewContainer', () => { // Remove the element fireEvent.click(removeButton) - expect(removeButton).toHaveTextContent(nb.removeButton) + expect(removeButton).toHaveTextContent(tr.editContainer.removeButton) await waitFor(() => { const elements = document.querySelectorAll( @@ -257,22 +308,21 @@ describe('EditContainer and ViewContainer', () => { const [doneButton, cancelButton, editButton] = Array.from( document.querySelectorAll('button') ) - expect(doneButton).toHaveTextContent(nb.doneButton) - expect(cancelButton).toHaveTextContent(nb.cancelButton) - expect(editButton).toHaveTextContent( - nbNO['nb-NO'].IterateViewContainer.editButton - ) + expect(doneButton).toHaveTextContent(tr.editContainer.doneButton) + expect(cancelButton).toHaveTextContent(tr.editContainer.cancelButton) + expect(editButton).toHaveTextContent(tr.viewContainer.editButton) await userEvent.click(doneButton) expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() expect(onChange).toHaveBeenCalledTimes(0) await userEvent.click(cancelButton) + + expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() + await userEvent.click(editButton) - expect( - document.querySelector('.dnb-form-status') - ).not.toBeInTheDocument() + expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() await userEvent.click(doneButton) @@ -281,8 +331,316 @@ describe('EditContainer and ViewContainer', () => { await userEvent.type(document.querySelector('input'), 'foo') await userEvent.click(doneButton) + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status') + ).not.toBeInTheDocument() + }) + }) + + it('should set all items to the given containerMode', async () => { + let containerMode = null + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode = context.containerMode + + return null + } + + render( + + View Content + Edit Content + + + ) + + expect(containerMode).toBe('edit') + + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(2) + + const [firstElement, secondElement] = Array.from(elements) + + { + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(viewBlock).toHaveClass('dnb-forms-section-view-block') + expect(viewBlock).toHaveClass('dnb-height-animation--hidden') + expect(editBlock).toHaveClass('dnb-forms-section-edit-block') + expect(editBlock).not.toHaveClass('dnb-height-animation--hidden') + } + + { + const [viewBlock, editBlock] = Array.from( + secondElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(viewBlock).toHaveClass('dnb-forms-section-view-block') + expect(viewBlock).toHaveClass('dnb-height-animation--hidden') + expect(editBlock).toHaveClass('dnb-forms-section-edit-block') + expect(editBlock).not.toHaveClass('dnb-height-animation--hidden') + } + + expect(containerMode).toBe('edit') + }) + + it('should hide toolbar in view mode', async () => { + render( + + + View Content + + Edit Content + + ) + + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(1) + + const [firstElement] = Array.from(elements) + + { + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(editBlock.querySelectorAll('button')).toHaveLength(2) + expect(viewBlock.querySelectorAll('button')).toHaveLength(0) + } + }) + + it('should render the given toolbar buttons', () => { + const viewToolbar = ( + + {({ EditButton, RemoveButton, items }) => { + if (items.length === 1) { + return + } + return ( + <> + + + + ) + }} + + ) + + const editToolbar = ( + + {({ DoneButton, CancelButton, items }) => { + if (items.length === 1) { + return null + } + return ( + <> + + + + ) + }} + + ) + + const { rerender } = render( + + + View Content + + + Edit Content + + + ) + + { + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(1) + + const [firstElement] = Array.from(elements) + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(editBlock.querySelectorAll('button')).toHaveLength(0) + expect(viewBlock.querySelectorAll('button')).toHaveLength(1) + expect(viewBlock.querySelectorAll('button')[0]).toHaveTextContent( + tr.viewContainer.editButton + ) + } + + rerender( + + + View Content + + + Edit Content + + + ) + + { + const elements = document.querySelectorAll( + '.dnb-forms-iterate__element' + ) + expect(elements).toHaveLength(2) + + const [firstElement] = Array.from(elements) + const [viewBlock, editBlock] = Array.from( + firstElement.querySelectorAll('.dnb-forms-section-block') + ) + expect(editBlock.querySelectorAll('button')).toHaveLength(2) + expect(viewBlock.querySelectorAll('button')).toHaveLength(2) + } + }) + + it('should validate on submit', () => { + render( + + + + + + content + + + ) + expect( document.querySelector('.dnb-form-status') ).not.toBeInTheDocument() + + const input = document.querySelector('input') + fireEvent.submit(input) + + expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() + }) + + it('should open in "edit" mode without focusing', async () => { + const containerMode = {} + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode[context.index] = context.containerMode + + return null + } + + render( + + + View Content + + + + + + + + ) + + expect(containerMode[0]).toBe('edit') + expect(document.body).toHaveFocus() + }) + + it('should set first item to "view" mode when a new is pushed to the array', async () => { + const containerMode = {} + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode[context.index] = context.containerMode + + return null + } + + render( + + + View Content + + + + + + + + + ) + + expect(containerMode[0]).toBe('edit') + + const input = document.querySelector('input') + await userEvent.type(input, 'foo') + + await userEvent.click( + document.querySelector('button.dnb-forms-iterate-push-button') + ) + + const blocks = Array.from( + document.querySelectorAll('.dnb-forms-section-block') + ) + expect(blocks).toHaveLength(4) + const [, secondBlock] = blocks + + expect(containerMode[1]).toBe('edit') + + await userEvent.click(secondBlock.querySelector('button')) + + await waitFor(() => { + expect(containerMode[0]).toBe('view') + expect(containerMode[1]).toBe('edit') + }) + }) + + it('should keep first item in "edit" mode when there is an error', async () => { + const containerMode = {} + + const ContextConsumer = () => { + const context = React.useContext(IterateItemContext) + containerMode[context.index] = context.containerMode + + return null + } + + render( + + + View Content + + + + + + + + + ) + + expect(containerMode[0]).toBe('edit') + + await userEvent.click( + document.querySelector('button.dnb-forms-iterate-push-button') + ) + + const blocks = Array.from( + document.querySelectorAll('.dnb-forms-section-block') + ) + expect(blocks).toHaveLength(4) + const [, secondBlock] = blocks + + expect(containerMode[0]).toBe('edit') + expect(containerMode[1]).toBe('edit') + + await userEvent.click(secondBlock.querySelector('button')) + + expect(containerMode[0]).toBe('edit') + expect(containerMode[1]).toBe('edit') }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditContainer.test.tsx index 31984fd95c1..920ff488216 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditContainer.test.tsx @@ -11,7 +11,9 @@ const nb = nbNO['nb-NO'].IterateEditContainer describe('EditContainer', () => { it('renders content and without errors', () => { const { rerender } = render( - + content ) @@ -26,7 +28,9 @@ describe('EditContainer', () => { expect(inner).toHaveTextContent('content') rerender( - + content ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditToolbarTools.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditToolbarTools.test.tsx index 9cb2864d79d..10f293001d4 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditToolbarTools.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditToolbarTools.test.tsx @@ -2,7 +2,6 @@ import React from 'react' import { render, fireEvent } from '@testing-library/react' import IterateItemContext from '../../IterateItemContext' import Toolbar from '../../Toolbar' -import EditToolbarTools from '../EditToolbarTools' import nbNO from '../../../constants/locales/nb-NO' @@ -15,7 +14,14 @@ describe('EditToolbarTools', () => { render( - + {({ DoneButton, CancelButton }) => { + return ( + <> + + + + ) + }} ) @@ -34,7 +40,14 @@ describe('EditToolbarTools', () => { value={{ switchContainerMode, isNew: true }} > - + {({ DoneButton, CancelButton }) => { + return ( + <> + + + + ) + }} ) @@ -58,7 +71,14 @@ describe('EditToolbarTools', () => { }} > - + {({ DoneButton, CancelButton }) => { + return ( + <> + + + + ) + }} ) @@ -79,7 +99,14 @@ describe('EditToolbarTools', () => { }} > - + {({ DoneButton, CancelButton }) => { + return ( + <> + + + + ) + }} ) @@ -95,7 +122,14 @@ describe('EditToolbarTools', () => { render( - + {({ DoneButton, CancelButton }) => { + return ( + <> + + + + ) + }} ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/index.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/index.ts index d3b957bc949..bebeac850c3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/index.ts @@ -1,2 +1,4 @@ export { default } from './EditContainer' export * from './EditContainer' +export { default as DoneButton } from './DoneButton' +export { default as CancelButton } from './CancelButton' diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts index a18365aa887..7977b51270d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts @@ -1,6 +1,6 @@ import React from 'react' import { Path } from '../types' -import { ContainerMode } from './Array/types' +import { ContainerMode, ElementChild } from './Array/types' export interface IterateItemContextState { id?: string @@ -8,8 +8,10 @@ export interface IterateItemContextState { value?: unknown isNew?: boolean path?: Path - arrayValue?: Array + arrayValue?: Array containerMode?: ContainerMode + previousContainerMode?: ContainerMode + initialContainerMode?: ContainerMode containerRef?: React.RefObject elementRef?: React.RefObject switchContainerMode?: (mode: ContainerMode) => void diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushButton/PushButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushButton/PushButton.tsx index f9f56253258..2a58b6215b8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushButton/PushButton.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushButton/PushButton.tsx @@ -3,43 +3,62 @@ import classnames from 'classnames' import { Button } from '../../../../components' import { ButtonProps } from '../../../../components/Button' import IterateItemContext from '../IterateItemContext' -import { useFieldProps } from '../../hooks' -import { - DataValueReadWriteComponentProps, - omitDataValueReadWriteProps, -} from '../../types' +import { useSwitchContainerMode } from '../hooks' +import { omitDataValueReadWriteProps, Path } from '../../types' import { add } from '../../../../icons' +import DataContext from '../../DataContext/Context' +import useDataValue from '../../hooks/useDataValue' -export type Props = ButtonProps & - DataValueReadWriteComponentProps & { - pushValue: unknown | ((value: unknown) => void) - } +export type Props = ButtonProps & { + path?: Path + pushValue: unknown | ((value: unknown) => void) + + /** + * Used internally + */ + value?: unknown +} function PushButton(props: Props) { + const { handlePathChange } = useContext(DataContext) || {} const iterateItemContext = useContext(IterateItemContext) const { handlePush } = iterateItemContext ?? {} - const { pushValue, className, ...restProps } = props + const { pushValue, className, path, children, ...restProps } = props const buttonProps = omitDataValueReadWriteProps(restProps) - const { value, handleChange, children } = useFieldProps(restProps) + const value = useDataValue().getValueByPath(path) if (value !== undefined && !Array.isArray(value)) { throw new Error('PushButton received a non-array value') } - const handleClick = useCallback(() => { + const { setLastItemContainerMode } = useSwitchContainerMode({ + path, + }) + + const handleClick = useCallback(async () => { const newValue = typeof pushValue === 'function' ? pushValue(value) : pushValue if (handlePush) { // Inside an Iterate element - make the change through the Iterate component handlePush(newValue) - return // stop here + } else { + // If not inside an iterate, it could still manipulate a source data set through useFieldProps + await handlePathChange?.(path, [...(value ?? []), newValue]) } - // If not inside an iterate, it could still manipulate a source data set through useFieldProps - handleChange([...(value ?? []), newValue]) - }, [value, pushValue, handlePush, handleChange]) + setTimeout(() => { + setLastItemContainerMode('view') + }, 100) // UX improvement because of the "openDelay" + }, [ + handlePathChange, + handlePush, + path, + pushValue, + setLastItemContainerMode, + value, + ]) return ( + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx new file mode 100644 index 00000000000..1f26e3467f2 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/RemoveButton.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import RemoveButton from '../RemoveButton' +import useTranslation from '../../hooks/useTranslation' + +export default function ViewContainerRemoveButton() { + const { removeButton } = useTranslation().IterateViewContainer + + return +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx index 40524d58fee..ca444ef1415 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx @@ -9,7 +9,8 @@ import ElementBlock, { } from '../AnimatedContainer/ElementBlock' import IterateItemContext from '../IterateItemContext' import Toolbar from '../Toolbar' -import ViewToolbarTools from './ViewToolbarTools' +import EditButton from './EditButton' +import RemoveButton from './RemoveButton' export type Props = { /** @@ -26,15 +27,12 @@ export type AllProps = Props & FlexContainerProps & ElementSectionProps function ViewContainer(props: AllProps) { const { children, className, title, toolbar, ...restProps } = props || {} - const iterateItemContext = useContext(IterateItemContext) + const { index } = useContext(IterateItemContext) let itemTitle = title let ariaLabel = useMemo(() => convertJsxToString(itemTitle), [itemTitle]) if (ariaLabel.includes('{itemNr}')) { - itemTitle = ariaLabel = ariaLabel.replace( - '{itemNr}', - iterateItemContext.index + 1 - ) + itemTitle = ariaLabel = ariaLabel.replace('{itemNr}', index + 1) } return ( @@ -49,7 +47,8 @@ function ViewContainer(props: AllProps) { {children} {toolbar ?? ( - + + )} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewToolbarTools.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewToolbarTools.tsx deleted file mode 100644 index c105e64f00f..00000000000 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewToolbarTools.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { useCallback, useContext } from 'react' -import { Button, Flex } from '../../../../components' -import RemoveButton from '../RemoveButton' -import useTranslation from '../../hooks/useTranslation' -import IterateItemContext from '../IterateItemContext' -import { edit } from '../../../../icons' - -export default function ViewToolbarTools() { - const iterateItemContext = useContext(IterateItemContext) - const { switchContainerMode } = iterateItemContext ?? {} - - const translation = useTranslation().IterateViewContainer - - const editHandler = useCallback(() => { - switchContainerMode?.('edit') - }, [switchContainerMode]) - - return ( - - - - - - ) -} diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewContainer.test.tsx index bf95b4a7946..dba3f137ee5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewContainer.test.tsx @@ -10,7 +10,9 @@ const nb = nbNO['nb-NO'].IterateViewContainer describe('ViewContainer', () => { it('renders content and without errors', () => { const { rerender } = render( - + content ) @@ -25,7 +27,9 @@ describe('ViewContainer', () => { expect(inner).toHaveTextContent('content') rerender( - + content ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewToolbarTools.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewToolbarTools.test.tsx index 111c09f859d..2fe8822aee2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewToolbarTools.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/__tests__/ViewToolbarTools.test.tsx @@ -2,7 +2,6 @@ import React from 'react' import { fireEvent, render } from '@testing-library/react' import IterateItemContext from '../../IterateItemContext' import Toolbar from '../../Toolbar' -import ViewToolbarTools from '../ViewToolbarTools' import nbNO from '../../../constants/locales/nb-NO' @@ -13,7 +12,14 @@ describe('ViewToolbarTools', () => { render( - + {({ EditButton, RemoveButton }) => { + return ( + <> + + + + ) + }} ) @@ -31,7 +37,14 @@ describe('ViewToolbarTools', () => { render( - + {({ EditButton, RemoveButton }) => { + return ( + <> + + + + ) + }} ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/index.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/index.ts index 3cdea15bcb3..196e8cc7d2c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/index.ts @@ -1,2 +1,4 @@ export { default } from './ViewContainer' export * from './ViewContainer' +export { default as EditButton } from './EditButton' +export { default as RemoveButton } from './RemoveButton' diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/index.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/index.ts new file mode 100644 index 00000000000..02f30c1811c --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/index.ts @@ -0,0 +1,2 @@ +export { default as useItem } from './useItem' +export { default as useSwitchContainerMode } from './useSwitchContainerMode' diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useSwitchContainerMode.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useSwitchContainerMode.ts new file mode 100644 index 00000000000..bece908ab80 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/hooks/useSwitchContainerMode.ts @@ -0,0 +1,95 @@ +import { useCallback, useContext, useEffect, useRef } from 'react' +import { ContainerMode } from '../Array' +import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryContext' +import IterateItemContext from '../IterateItemContext' +import { Path } from '../../types' + +const globalContainerModeRef = { current: undefined } +const globalCache = {} + +/** + * This is a helper for the Iterate component. + * It is used to switch the container mode of the items inside the Iterate component. + * You can use the hook outside of the Iterate component, and it will communicate with the items inside the Iterate component. + * Therefore, it is imported and used in both e.g. the EditContainer and e.g. the PushButton. + */ +export default function useSwitchContainerMode({ + path, +}: { path?: Path } = {}) { + const nextContainerModeRef = useRef() + const { hasError } = useContext(FieldBoundaryContext) || {} + const iterateItemContext = useContext(IterateItemContext) + + const id = iterateItemContext?.id + const hash = (path || '') + 'useSwitchContainerMode' + + useEffect(() => { + nextContainerModeRef.current = globalContainerModeRef.current + requestAnimationFrame(() => { + if (nextContainerModeRef.current) { + globalContainerModeRef.current = undefined + } + }) + }) + + useEffect(() => { + if (hash && iterateItemContext) { + globalCache[hash] = globalCache[hash] || {} + const { index, arrayValue, switchContainerMode } = iterateItemContext + globalCache[hash][id] = { + index, + hasError, + count: arrayValue?.length, + switchContainerMode, + } + } + + return () => { + if (id) { + globalCache[hash][id] = undefined + } + } + }, [hasError, hash, id, iterateItemContext]) + + const setNextContainerMode = useCallback((mode: ContainerMode) => { + globalContainerModeRef.current = mode + }, []) + + const setContainerMode = useCallback( + (fn: ({ hasError, index, count }) => ContainerMode) => { + const data = globalCache[hash] + for (const id in data) { + const item = data[id] + const mode = item && fn?.(item) + if (mode) { + item.switchContainerMode(mode) + } + } + }, + [hash] + ) + + const setLastItemContainerMode = useCallback( + (mode: ContainerMode) => { + setContainerMode(({ hasError, index, count }) => { + if (!hasError && index === count - 2) { + return mode + } + }) + }, + [setContainerMode] + ) + + const getNextContainerMode = useCallback((): + | ContainerMode + | undefined => { + return nextContainerModeRef.current + }, []) + + return { + getNextContainerMode, + nextContainerModeRef, + setNextContainerMode, + setLastItemContainerMode, + } +} 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 c18f34716ea..794c32640ae 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,6 +1,6 @@ -import React from 'react' +import React, { useCallback } from 'react' import { Field, Form, Iterate, Value } from '../..' -import { Card, Flex } from '../../../../components' +import { Card, Flex, Section } from '../../../../components' export default { title: 'Eufemia/Extensions/Forms/Iterate', @@ -102,16 +102,16 @@ const MyViewItem = () => { export const ViewAndEditContainer = () => { return ( - + <> console.log('onSubmit', data)} @@ -131,6 +131,127 @@ export const ViewAndEditContainer = () => { - + + ) +} + +export const InitialOpen = () => { + const MyEditItemForm = useCallback(() => { + return ( + + ) + }, []) + + const MyEditItem = useCallback(() => { + return ( + + {({ DoneButton, CancelButton, items }) => { + if (items.length === 1) { + return null + } + + return ( + <> + + + + ) + }} + + } + > + + + ) + }, [MyEditItemForm]) + + const MyViewItem = useCallback(() => { + return ( + + {({ EditButton, RemoveButton, items }) => { + if (items.length === 1) { + return + } + + return ( + <> + + + + ) + }} + + } + > + + + ) + }, []) + + const [count, setCount] = React.useState(0) + + return ( + <> + console.log('onSubmit', data)} + onSubmitRequest={() => console.log('onSubmitRequest')} + > + + Statsborgerskap + + + + + + + + + + + + + + + {count} + + + + + ) +} + +const Output = () => { + const { data } = Form.useData() + + return ( +
+
All data: {JSON.stringify(data)}
+
) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx b/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx new file mode 100644 index 00000000000..28861e3e34c --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx @@ -0,0 +1,25 @@ +import { useContext } from 'react' +import DataContext from '../DataContext/Context' +import Section, { SectionProps } from '../../../components/Section' + +function Log(props: SectionProps) { + const { data } = useContext(DataContext) + + return ( +
+
+        {JSON.stringify(data)}
+        {' ' /* Ensure one line of spacing */}
+      
+
+ ) +} + +Log._supportsSpacingProps = true +export default Log diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/index.ts b/packages/dnb-eufemia/src/extensions/forms/Tools/index.ts index 402da7a21b2..97c7f557999 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Tools/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/index.ts @@ -1,2 +1,3 @@ export { default as GenerateSchema } from './GenerateSchema' export { default as ListAllProps } from './ListAllProps' +export { default as Log } from './Log' diff --git a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx index 05db24eb0e3..2c57b4a2f3a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx @@ -114,7 +114,6 @@ function EditContent({ enableAdditionalQuestions }: Props) { diff --git a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap index a8952bb93b4..1dff11bfff6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap @@ -107,7 +107,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "width": "small", }, "hasDaycare": { - "defaultValue": false, "itemPath": "/hasDaycare", "label": "Er på SFO/AKS", "required": true, @@ -194,7 +193,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "valueType": "boolean", }, "jointResponsibility": { - "defaultValue": false, "itemPath": "/jointResponsibility", "label": "Delt omsorg", "required": true, @@ -385,7 +383,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "width": "small", }, "hasDaycare": { - "defaultValue": false, "itemPath": "/hasDaycare", "label": "Er på SFO/AKS", "required": true, @@ -472,7 +469,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "valueType": "boolean", }, "jointResponsibility": { - "defaultValue": false, "itemPath": "/jointResponsibility", "label": "Delt omsorg", "required": true, @@ -563,10 +559,6 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "countPathLimit": 20, "path": "/children", "required": true, - "space": { - "bottom": 0, - "top": "medium", - }, "translations": { "en-GB": { "ChildrenWithAge": { diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts index 15862f5b307..e31bfc6dbef 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts @@ -48,7 +48,6 @@ export default { removeButton: 'Remove', doneButton: 'Done', cancelButton: 'Cancel', - errorInSection: 'Please correct the errors above', }, IteratePushContainer: { createButton: 'Add', diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts index 1941d5e5208..25c1792ebf7 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts @@ -48,7 +48,6 @@ export default { removeButton: 'Fjern', doneButton: 'Ferdig', cancelButton: 'Avbryt', - errorInSection: 'Feilene ovenfor må rettes', }, IteratePushContainer: { createButton: 'Legg til', diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/usePath.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/usePath.test.tsx index a30180f0b86..05d54e66d9b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/usePath.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/usePath.test.tsx @@ -43,6 +43,13 @@ describe('usePath', () => { expect(result.current.path).toBe(`${sectionPath}${path}`) }) + it('joinPath', () => { + const { result } = renderHook(() => usePath(), {}) + expect( + result.current.joinPath([undefined, null, '', 'foo/', '/bar//']) + ).toBe('/foo/bar') + }) + it('should return the correct identifier when itemPath is defined', () => { const path = '/path' const iteratePath = '/iteratePath' diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts index 788ba213666..159e1e10d8f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts @@ -22,10 +22,10 @@ export default function useExternalValue(props: Props) { transformers, emptyValue = undefined, } = props - const dataContext = useContext(DataContext) + const { data } = useContext(DataContext) || {} const iterateItemContext = useContext(IterateElementContext) const inIterate = Boolean(iterateItemContext) - const { value: iterateElementValue } = iterateItemContext ?? {} + const { value: iterateElementValue } = iterateItemContext || {} return useMemo(() => { if (value !== emptyValue) { @@ -44,20 +44,18 @@ export default function useExternalValue(props: Props) { : emptyValue } - if (dataContext.data && path) { + if (data && path) { // There is a surrounding data context and a path for where in the source to find the data if (path === '/') { - return dataContext.data + return data } - return pointer.has(dataContext.data, path) - ? pointer.get(dataContext.data, path) - : emptyValue + return pointer.has(data, path) ? pointer.get(data, path) : emptyValue } return emptyValue }, [ - dataContext.data, + data, emptyValue, inIterate, itemPath, diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index af070f875e3..936345a70da 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -1390,19 +1390,26 @@ export default function useFieldProps( validateValue() }, [schema, validateValue]) + const isEmptyData = useCallback(() => { + return ( + dataContext.internalDataRef?.current === + (dataContext.props?.emptyData ?? clearedData) + ) + }, [dataContext.internalDataRef, dataContext.props?.emptyData]) + useEffect(() => { - if (dataContext.data === clearedData) { + if (isEmptyData()) { hideError() } - }, [dataContext.data, hideError]) + }, [externalValue, hideError, isEmptyData]) useUpdateEffect(() => { // Error or removed error for this field from the surrounding data context (by path) if (valueRef.current !== externalValue) { valueRef.current = externalValue validateValue() + forceUpdate() } - forceUpdate() }, [externalValue, validateValue]) useEffect(() => { diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts index 7f6496436d7..24f1406272b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/usePath.ts @@ -24,6 +24,13 @@ export default function usePath(props: Props = {}) { throw new Error(`itemPath="${itemPathProp}" must start with a slash`) } + const joinPath = useCallback((paths: Array) => { + return paths + .reduce((acc, cur) => (cur ? `${acc}/${cur}` : acc), '/') + .replace(/\/{2,}/g, '/') + .replace(/\/+$/, '') + }, []) + const makeSectionPath = useCallback( (path: Path) => { return `${ @@ -44,7 +51,7 @@ export default function usePath(props: Props = {}) { root = makeSectionPath('') } - return `${root}${iteratePath}/${iterateElementIndex}${ + return `${root}${iteratePath || ''}/${iterateElementIndex}${ itemPath && itemPath !== '/' ? itemPath : '' }` }, @@ -88,6 +95,7 @@ export default function usePath(props: Props = {}) { identifier, path, itemPath, + joinPath, makePath, makeIteratePath, makeSectionPath,