diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/info.mdx index d58c2028121..debea5b2159 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/info.mdx @@ -27,16 +27,21 @@ const MyComponent = () => { ### TransformIn and TransformOut -You can use the `transformIn` and `transformOut` to transform the value before it gets displayed in the field and before it gets sent to the form. The second parameter is the country object. You may have a look at the demo below to see how it works. +You can use the `transformIn` and `transformOut` to transform the value before it gets displayed in the field and before it gets sent to the form context. The second parameter is the country object. You may have a look at the demo below to see how it works. ```tsx -const transformOut = (value, country) => { - if (value) { - return `${country.name} (${value})` +import type { CountryType } from '@dnb/eufemia/extensions/forms/Field/SelectCountry' + +// From the Field (internal value) to the data context or event parameter +const transformOut = (internal: string, country: CountryType) => { + if (internal) { + return `${country.name} (${internal})` } } -const transformIn = (value) => { - return String(value).match(/\((.*)\)/)?.[1] + +// To the Field (from e.g. defaultValue) +const transformIn = (external: unknown) => { + return String(external).match(/\((.*)\)/)?.[1] || 'NO' } ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/String/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/String/Examples.tsx index a0859b76e88..bdeb53ddd1c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/String/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/String/Examples.tsx @@ -466,10 +466,16 @@ export function TransformInAndOut() { return ( {() => { + // From the Field (internal value) to the data context or event parameter const transformOut = (value) => { return { value, foo: 'bar' } } + + // To the Field (from e.g. defaultValue) const transformIn = (data) => { + if (typeof data === 'string') { + return data + } return data?.value } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/Examples.tsx index 90baa8f6e12..54837b5ce45 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/Examples.tsx @@ -101,11 +101,14 @@ export function TransformInAndOut() { return ( {() => { + // From the Field (internal value) to the data context or event parameter const transformOut = (value, country) => { if (value) { return country } } + + // To the Field (from e.g. defaultValue) const transformIn = (country) => { return country?.iso } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/info.mdx index f67dc9d6114..8aa4948e6c6 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/info.mdx @@ -92,3 +92,71 @@ async function virusCheck(newFiles) { return await Promise.all(promises) } ``` + +### TransformIn and TransformOut + +You can use the `transformIn` and `transformOut` properties to transform the data from the internal format to the external format and vice versa. + +```tsx +import { Form, Field, Tools } from '@dnb/eufemia/extensions/forms' +import type { + UploadValue, + UploadFileNative, +} from '@dnb/eufemia/extensions/forms/Field/Upload' + +// Our external format +type DocumentMetadata = { + id: string + fileName: string +} + +const defaultValue = [ + { + id: '1234', + fileName: 'myFile.pdf', + }, +] satisfies DocumentMetadata[] as unknown as UploadValue + +const filesCache = new Map() + +// To the Field (from e.g. defaultValue) +const transformIn = (external?: DocumentMetadata[]) => { + return ( + external?.map(({ id, fileName }) => { + const file: File = + filesCache.get(id) || + new File([], fileName, { type: 'images/png' }) + + return { id, file } + }) || [] + ) +} + +// From the Field (internal value) to the data context or event parameter +const transformOut = (internal?: UploadValue) => { + return ( + internal?.map(({ id, file }) => { + if (!filesCache.has(id)) { + filesCache.set(id, file) + } + + return { id, fileName: file.name } + }) || [] + ) +} + +function MyForm() { + return ( + + + + + + ) +} +``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index b5a61763507..ea993ecf3c1 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -284,20 +284,20 @@ You may check out an [interactive example](/uilib/extensions/forms/Form/Handler/ #### Transforming data -Each [field](/uilib/extensions/forms/all-fields/) and [value](/uilib/extensions/forms/Value/) component supports transformer functions. These functions allow you to transform a value before it is processed into the form data object and vice versa: +Each [Field.\*](/uilib/extensions/forms/all-fields/) and [Value.\*](/uilib/extensions/forms/Value/) component supports transformer functions. + +These functions allow you to manipulate the field value to a different format than it uses internally and vice versa. ```tsx ``` -This allows you to show a value in a different format than it is stored in the form data object. - -- `transformIn` (in to the field or value) transforms the internal value before it is displayed. -- `transformOut` (out of the field) transforms the internal value before it gets forwarded to the data context or returned as e.g. the `onChange` value parameter. +- `transformOut` (out of the `Field.*` component) transforms the internal value before it gets forwarded to the data context or returned as e.g. the `onChange` value parameter. +- `transformIn` (in to the `Field.*` or `Value.*` component) transforms the external value before it is displayed and used internally. @@ -311,14 +311,18 @@ You can achieve this by using the `transformIn` and `transformOut` functions: ```tsx import { Field } from '@dnb/eufemia/extensions/forms' +import type { CountryType } from '@dnb/eufemia/extensions/forms/Field/SelectCountry' -const transformOut = (value, country) => { - if (value) { +// From the Field (internal value) to the data context or event parameter +const transformOut = (internal, country) => { + if (internal) { return country } } -const transformIn = (country) => { - return country?.iso + +// To the Field (from e.g. defaultValue) +const transformIn = (external: CountryType) => { + return external?.iso || 'NO' } const MyForm = () => { 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 3386daa24c1..c3ffd5f8193 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx @@ -23,6 +23,7 @@ export type CountryFilterSet = | 'Nordic' | 'Europe' | 'Prioritized' +export type { CountryType } export type Props = FieldPropsWithExtraValue< string, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/__tests__/SelectCountry.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/__tests__/SelectCountry.test.tsx index 13918582ed5..525fe1db2c6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/__tests__/SelectCountry.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/__tests__/SelectCountry.test.tsx @@ -391,11 +391,11 @@ describe('Field.SelectCountry', () => { return `${country.name} (${value})` } }) - const transformIn = jest.fn((value) => { - return String(value).match(/\((.*)\)/)?.[1] + const transformIn = jest.fn((external) => { + return String(external).match(/\((.*)\)/)?.[1] || external }) - const valueTransformIn = jest.fn((value) => { - return String(value).match(/\((.*)\)/)?.[1] + const valueTransformIn = jest.fn((internal) => { + return String(internal).match(/\((.*)\)/)?.[1] }) const onSubmit = jest.fn() @@ -434,7 +434,7 @@ describe('Field.SelectCountry', () => { } expect(transformOut).toHaveBeenCalledTimes(1) - expect(transformIn).toHaveBeenCalledTimes(4) + expect(transformIn).toHaveBeenCalledTimes(3) expect(valueTransformIn).toHaveBeenCalledTimes(2) const firstItemElement = () => @@ -452,7 +452,7 @@ describe('Field.SelectCountry', () => { ) expect(transformOut).toHaveBeenCalledTimes(1) - expect(transformIn).toHaveBeenCalledTimes(5) + expect(transformIn).toHaveBeenCalledTimes(4) expect(valueTransformIn).toHaveBeenCalledTimes(3) expect(input).toHaveValue('Norge') @@ -468,7 +468,7 @@ describe('Field.SelectCountry', () => { expect(value).toHaveTextContent('Sveits') expect(transformOut).toHaveBeenCalledTimes(4) - expect(transformIn).toHaveBeenCalledTimes(8) + expect(transformIn).toHaveBeenCalledTimes(6) expect(valueTransformIn).toHaveBeenCalledTimes(4) fireEvent.submit(form) @@ -479,7 +479,7 @@ describe('Field.SelectCountry', () => { ) expect(transformOut).toHaveBeenCalledTimes(4) - expect(transformIn).toHaveBeenCalledTimes(9) + expect(transformIn).toHaveBeenCalledTimes(7) expect(valueTransformIn).toHaveBeenCalledTimes(5) expect(transformOut).toHaveBeenNthCalledWith(1, 'NO', NO) @@ -487,15 +487,13 @@ describe('Field.SelectCountry', () => { expect(transformOut).toHaveBeenNthCalledWith(3, 'CH', CH) expect(transformOut).toHaveBeenNthCalledWith(4, 'CH', CH) - expect(transformIn).toHaveBeenNthCalledWith(1, undefined) - expect(transformIn).toHaveBeenNthCalledWith(2, undefined) + expect(transformIn).toHaveBeenNthCalledWith(1, 'NO') + expect(transformIn).toHaveBeenNthCalledWith(2, 'NO') expect(transformIn).toHaveBeenNthCalledWith(3, 'Norge (NO)') expect(transformIn).toHaveBeenNthCalledWith(4, 'Norge (NO)') expect(transformIn).toHaveBeenNthCalledWith(5, 'Norge (NO)') - expect(transformIn).toHaveBeenNthCalledWith(6, 'Norge (NO)') + expect(transformIn).toHaveBeenNthCalledWith(6, 'Sveits (CH)') expect(transformIn).toHaveBeenNthCalledWith(7, 'Sveits (CH)') - expect(transformIn).toHaveBeenNthCalledWith(8, 'Sveits (CH)') - expect(transformIn).toHaveBeenNthCalledWith(9, 'Sveits (CH)') expect(valueTransformIn).toHaveBeenNthCalledWith(1, undefined) expect(valueTransformIn).toHaveBeenNthCalledWith(2, 'Norge (NO)') @@ -504,6 +502,121 @@ describe('Field.SelectCountry', () => { expect(valueTransformIn).toHaveBeenNthCalledWith(5, 'Sveits (CH)') }) + it('should support "transformIn" and "transformOut" when value is given by the data context', async () => { + const transformOut = jest.fn((value, country) => { + if (value) { + return `${country.name} (${value})` + } + }) + const transformIn = jest.fn((external) => { + return String(external).match(/\((.*)\)/)?.[1] + }) + const valueTransformIn = jest.fn((internal) => { + return String(internal).match(/\((.*)\)/)?.[1] + }) + + const onSubmit = jest.fn() + + render( + + + + + + ) + + const NO = { + cdc: '47', + continent: 'Europe', + i18n: { en: 'Norway', nb: 'Norge' }, + iso: 'NO', + name: 'Norge', + regions: ['Scandinavia', 'Nordic'], + } + + const CH = { + cdc: '41', + continent: 'Europe', + i18n: { en: 'Switzerland', nb: 'Sveits' }, + iso: 'CH', + name: 'Sveits', + } + + expect(transformOut).toHaveBeenCalledTimes(0) + expect(transformIn).toHaveBeenCalledTimes(1) + expect(valueTransformIn).toHaveBeenCalledTimes(1) + + const firstItemElement = () => + document.querySelectorAll('li.dnb-drawer-list__option')[0] + + const form = document.querySelector('form') + const input = document.querySelector('input') + const value = document.querySelector('.dnb-forms-value-block__content') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { country: 'Norge (NO)' }, + expect.anything() + ) + + expect(transformOut).toHaveBeenCalledTimes(0) + expect(transformIn).toHaveBeenCalledTimes(2) + expect(valueTransformIn).toHaveBeenCalledTimes(2) + + expect(input).toHaveValue('Norge') + expect(value).toHaveTextContent('Norge') + + await userEvent.type(input, '{Backspace>10}Sveits') + await waitFor(() => { + expect(firstItemElement()).toBeInTheDocument() + }) + await userEvent.click(firstItemElement()) + + expect(input).toHaveValue('Sveits') + expect(value).toHaveTextContent('Sveits') + + expect(transformOut).toHaveBeenCalledTimes(3) + expect(transformIn).toHaveBeenCalledTimes(4) + expect(valueTransformIn).toHaveBeenCalledTimes(3) + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(2) + expect(onSubmit).toHaveBeenLastCalledWith( + { country: 'Sveits (CH)' }, + expect.anything() + ) + + expect(transformOut).toHaveBeenCalledTimes(3) + expect(transformIn).toHaveBeenCalledTimes(5) + expect(valueTransformIn).toHaveBeenCalledTimes(4) + + expect(transformOut).toHaveBeenNthCalledWith(1, 'NO', NO) + expect(transformOut).toHaveBeenNthCalledWith(2, 'CH', CH) + expect(transformOut).toHaveBeenNthCalledWith(3, 'CH', CH) + + expect(transformIn).toHaveBeenNthCalledWith(1, 'Norge (NO)') + expect(transformIn).toHaveBeenNthCalledWith(2, 'Norge (NO)') + expect(transformIn).toHaveBeenNthCalledWith(3, 'Norge (NO)') + expect(transformIn).toHaveBeenNthCalledWith(4, 'Sveits (CH)') + expect(transformIn).toHaveBeenNthCalledWith(5, 'Sveits (CH)') + + expect(valueTransformIn).toHaveBeenNthCalledWith(1, 'Norge (NO)') + expect(valueTransformIn).toHaveBeenNthCalledWith(2, 'Norge (NO)') + expect(valueTransformIn).toHaveBeenNthCalledWith(3, 'Sveits (CH)') + expect(valueTransformIn).toHaveBeenNthCalledWith(4, 'Sveits (CH)') + }) + it('should store "displayValue" in data context', async () => { let dataContext = null diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/stories/SelectCountry.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/stories/SelectCountry.stories.tsx index b32f0ff6bf2..86315700e30 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/stories/SelectCountry.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/stories/SelectCountry.stories.tsx @@ -24,13 +24,13 @@ export function SelectCountry() { ) } -const transformOut = (value, country: CountryType) => { - if (value) { - return `${country.name} (${value})` +const transformOut = (internal: string, country: CountryType) => { + if (internal) { + return `${country.name} (${internal})` } } -const transformIn = (value) => { - return String(value).match(/\((.*)\)/)?.[1] +const transformIn = (external: unknown) => { + return String(external).match(/\((.*)\)/)?.[1] || 'NO' } export function Transform() { 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 2e36e267f57..509a660a2b5 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 @@ -263,7 +263,7 @@ describe('Field.String', () => { const input = document.querySelector('input') expect(input).toHaveValue('XYZ') - expect(transformIn).toHaveBeenCalledTimes(2) + expect(transformIn).toHaveBeenCalledTimes(1) expect(transformIn).toHaveBeenLastCalledWith('xYz') expect(transformOut).toHaveBeenCalledTimes(0) expect(onChangeProvider).toHaveBeenCalledTimes(0) @@ -272,7 +272,7 @@ describe('Field.String', () => { await userEvent.type(input, '{Backspace>3}aBc') expect(input).toHaveValue('ABC') - expect(transformIn).toHaveBeenCalledTimes(16) + expect(transformIn).toHaveBeenCalledTimes(9) expect(transformIn).toHaveBeenLastCalledWith('abc') expect(transformOut).toHaveBeenCalledTimes(13) expect(transformOut).toHaveBeenLastCalledWith('ABc', undefined) @@ -287,7 +287,7 @@ describe('Field.String', () => { await userEvent.type(input, '{Backspace>3}EfG') expect(input).toHaveValue('EFG') - expect(transformIn).toHaveBeenCalledTimes(29) + expect(transformIn).toHaveBeenCalledTimes(16) expect(transformIn).toHaveBeenLastCalledWith('efg') expect(transformOut).toHaveBeenCalledTimes(25) expect(transformOut).toHaveBeenLastCalledWith('EFG', undefined) @@ -305,6 +305,9 @@ describe('Field.String', () => { return { value, foo: 'bar' } }) const transformIn = jest.fn((data) => { + if (typeof data === 'string') { + return data + } return data?.value }) const valueTransformIn = jest.fn((data) => { @@ -327,12 +330,14 @@ describe('Field.String', () => { ) expect(transformOut).toHaveBeenCalledTimes(1) - expect(transformIn).toHaveBeenCalledTimes(4) + expect(transformIn).toHaveBeenCalledTimes(3) expect(valueTransformIn).toHaveBeenCalledTimes(2) const form = document.querySelector('form') const input = document.querySelector('input') + expect(input).toHaveValue('A') + fireEvent.submit(form) expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenLastCalledWith( @@ -346,7 +351,7 @@ describe('Field.String', () => { ) expect(transformOut).toHaveBeenCalledTimes(1) - expect(transformIn).toHaveBeenCalledTimes(5) + expect(transformIn).toHaveBeenCalledTimes(4) expect(valueTransformIn).toHaveBeenCalledTimes(3) expect(input).toHaveValue('A') @@ -362,7 +367,7 @@ describe('Field.String', () => { ).toHaveTextContent('B') expect(transformOut).toHaveBeenCalledTimes(6) - expect(transformIn).toHaveBeenCalledTimes(9) + expect(transformIn).toHaveBeenCalledTimes(6) expect(valueTransformIn).toHaveBeenCalledTimes(5) fireEvent.submit(form) @@ -378,7 +383,7 @@ describe('Field.String', () => { ) expect(transformOut).toHaveBeenCalledTimes(6) - expect(transformIn).toHaveBeenCalledTimes(10) + expect(transformIn).toHaveBeenCalledTimes(7) expect(valueTransformIn).toHaveBeenCalledTimes(6) expect(transformOut).toHaveBeenNthCalledWith(1, 'A', undefined) @@ -396,8 +401,8 @@ describe('Field.String', () => { ) expect(transformOut).toHaveBeenNthCalledWith(6, 'B', undefined) - expect(transformIn).toHaveBeenNthCalledWith(1, undefined) - expect(transformIn).toHaveBeenNthCalledWith(2, undefined) + expect(transformIn).toHaveBeenNthCalledWith(1, 'A') + expect(transformIn).toHaveBeenNthCalledWith(2, 'A') expect(transformIn).toHaveBeenNthCalledWith(3, { foo: 'bar', value: 'A', @@ -407,26 +412,14 @@ describe('Field.String', () => { value: 'A', }) expect(transformIn).toHaveBeenNthCalledWith(5, { - foo: 'bar', - value: 'A', - }) - expect(transformIn).toHaveBeenNthCalledWith(6, { - foo: 'bar', - value: undefined, - }) - expect(transformIn).toHaveBeenNthCalledWith(7, { foo: 'bar', value: undefined, }) - expect(transformIn).toHaveBeenNthCalledWith(8, { - foo: 'bar', - value: 'B', - }) - expect(transformIn).toHaveBeenNthCalledWith(9, { + expect(transformIn).toHaveBeenNthCalledWith(6, { foo: 'bar', value: 'B', }) - expect(transformIn).toHaveBeenNthCalledWith(10, { + expect(transformIn).toHaveBeenNthCalledWith(7, { foo: 'bar', value: 'B', }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx index 0e132a3a0da..fa7021baeec 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx @@ -6,7 +6,7 @@ export default { title: 'Eufemia/Extensions/Forms/String', } -export const String = () => { +export const StringExample = () => { return ( @@ -35,11 +35,11 @@ export const String = () => { } export const Transform = () => { - const transformIn = (value) => { - return value?.toUpperCase() + const transformIn = (external: unknown) => { + return String(external)?.toUpperCase() } - const transformOut = (value) => { - return value?.toLowerCase() + const transformOut = (internal: string) => { + return internal?.toLowerCase() } return ( diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx index 32796efcf1d..b36bdcc99db 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx @@ -23,6 +23,7 @@ import { useTranslation as useSharedTranslation } from '../../../../shared' import { SpacingProps } from '../../../../shared/types' import { FormError } from '../../utils' +export type { UploadFile, UploadFileNative } export type UploadValue = Array export type Props = Omit< FieldProps, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx index 1dc6d31013a..c4a14a91047 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useContext } from 'react' import { fireEvent, render, waitFor, screen } from '@testing-library/react' import { DataContext, Field, Form, Wizard } from '../../..' import { BYTES_IN_A_MEGA_BYTE } from '../../../../../components/upload/UploadVerify' @@ -7,7 +7,7 @@ import { createMockFile } from '../../../../../components/upload/__tests__/testH import nbNOForms from '../../../constants/locales/nb-NO' import nbNOShared from '../../../../../shared/locales/nb-NO' import userEvent from '@testing-library/user-event' -import { UploadValue } from '../Upload' +import { UploadFileNative, UploadValue } from '../Upload' import { wait } from '../../../../../core/jest/jestSetup' const nbForms = nbNOForms['nb-NO'] @@ -1527,4 +1527,155 @@ describe('Field.Upload', () => { document.querySelectorAll('.dnb-upload__file-cell').length ).toBe(0) }) + + describe('transformIn and transformOut', () => { + type DocumentMetadata = { + id: string + fileName: string + } + + const defaultValue = [ + { + id: '1234', + fileName: 'myFile.pdf', + }, + ] satisfies DocumentMetadata[] as unknown as UploadValue + + const filesCache = new Map() + + // To the Field (from e.g. defaultValue) + const transformIn = (external?: DocumentMetadata[]) => { + return ( + external?.map(({ id, fileName }) => { + const file: File = filesCache.get(id) || new File([], fileName) + + return { id, file } satisfies UploadFileNative + }) || [] + ) + } + + // From the Field (internal value) to the data context or event parameter + const transformOut = (internal?: UploadValue) => { + return ( + internal?.map(({ id, file }) => { + if (!filesCache.has(id)) { + filesCache.set(id, file) + } + + return { id, fileName: file.name } satisfies DocumentMetadata + }) || [] + ) + } + + let dataContext = null + function LogContext() { + dataContext = useContext(DataContext.Context).data + return null + } + + it('should render files given in data context', async () => { + render( + + + + + ) + + expect( + document.querySelectorAll('.dnb-upload__file-cell').length + ).toBe(1) + expect(dataContext).toEqual({ + documents: [ + { + id: '1234', + fileName: 'myFile.pdf', + }, + ], + }) + + const file = createMockFile('secondFile.png', 100, 'image/png') + await waitFor(() => { + fireEvent.drop(document.querySelector('input'), { + dataTransfer: { + files: [file], + }, + }) + }) + + expect( + document.querySelectorAll('.dnb-upload__file-cell').length + ).toBe(2) + expect(dataContext).toEqual({ + documents: [ + { + id: '1234', + fileName: 'myFile.pdf', + }, + { + id: expect.any(String), + fileName: 'secondFile.png', + }, + ], + }) + }) + + it('should render files given by defaultValue', async () => { + render( + + + + + ) + + expect( + document.querySelectorAll('.dnb-upload__file-cell').length + ).toBe(1) + expect(dataContext).toEqual({ + documents: [ + { + id: '1234', + fileName: 'myFile.pdf', + }, + ], + }) + + const file = createMockFile('secondFile.png', 100, 'image/png') + await waitFor(() => { + fireEvent.drop(document.querySelector('input'), { + dataTransfer: { + files: [file], + }, + }) + }) + + expect( + document.querySelectorAll('.dnb-upload__file-cell').length + ).toBe(2) + expect(dataContext).toEqual({ + documents: [ + { + id: '1234', + fileName: 'myFile.pdf', + }, + { + id: expect.any(String), + fileName: 'secondFile.png', + }, + ], + }) + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx index db2837a8397..131fd1afffb 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx @@ -1,5 +1,6 @@ import { Field, Form, Tools } from '../../..' import { Flex } from '../../../../../components' +import { UploadFileNative } from '../../../../../components/Upload' import { createRequest } from '../../../Form/Handler/stories/FormHandler.stories' import { UploadValue } from '../Upload' @@ -167,3 +168,68 @@ export const AsyncEverything = () => { ) } + +interface DocumentMetadata { + id: string + fileName: string +} + +const defaultValue = [ + { + id: '1234', + fileName: 'myFile.pdf', + }, +] satisfies DocumentMetadata[] as unknown as UploadValue + +const filesCache = new Map() + +// To the Field (from e.g. defaultValue) +const transformIn = (external?: DocumentMetadata[]) => { + return ( + external?.map(({ id, fileName }) => { + const file: File = filesCache.get(id) || new File([], fileName) + + return { id, file } satisfies UploadFileNative + }) || [] + ) +} + +// From the Field (internal value) to the data context or event parameter +const transformOut = (internal?: UploadValue) => { + return ( + internal?.map(({ id, file }) => { + if (!filesCache.has(id)) { + filesCache.set(id, file) + } + + return { id, fileName: file.name } satisfies DocumentMetadata + }) || [] + ) +} + +export function TransformInAndOut() { + return ( + + + { + console.log('onFileClick', fileItem) + }} + /> + + + + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx index d2661e96c2a..bd49aceeaad 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx @@ -2639,7 +2639,7 @@ describe('useFieldProps', () => { it('should call "transformOut" initially when "defaultValue" is given', () => { const transformOut = jest.fn((v) => v + 1) const transformIn = jest.fn((v) => v - 1) - const defaultValue = 1 + const defaultValue = 2 const { result } = renderHook( () => @@ -2657,6 +2657,7 @@ describe('useFieldProps', () => { }) expect(result.current.value).toEqual(1) expect(transformOut).toHaveBeenCalledTimes(1) + expect(transformIn).toHaveBeenCalledTimes(3) }) it('should call "transformIn" and "transformOut" after "fromInput" and "toInput"', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useValueProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useValueProps.test.tsx index b4a69b54cfc..7f941a18690 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useValueProps.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useValueProps.test.tsx @@ -14,7 +14,7 @@ describe('useValueProps', () => { it('should prepare value', () => { const value = 1 - const transformIn = (value) => value + 1 + const transformIn = (external: unknown) => Number(external) + 1 const { result } = renderHook(() => useValueProps({ value, transformIn }) ) @@ -40,7 +40,7 @@ describe('useValueProps', () => { it('should prepare value from context', () => { const path = '/contextValue' - const transformIn = (value) => value + 1 + const transformIn = (external: unknown) => Number(external) + 1 const { result } = renderHook( () => useValueProps({ path, transformIn }), { diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index 0cdfbba0333..f40748481d7 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -129,8 +129,8 @@ export default function useFieldProps( validateInitially, validateUnchanged, continuousValidation, - transformIn = (value: Value) => value, - transformOut = (value: Value) => value, + transformIn = (external: unknown) => external as Value, + transformOut = (internal: Value) => internal, toInput = (value: Value) => value, fromInput = (value: Value) => value, toEvent = (value: Value) => value, @@ -248,16 +248,17 @@ export default function useFieldProps( defaultValueRef.current = defaultValue // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const externalValue = - transformers.current.transformIn( - useExternalValue({ - path, - itemPath, - value: valueProp, - transformers, - emptyValue, - }) - ) ?? defaultValueRef.current + const tmpValue = useExternalValue({ + path, + itemPath, + value: valueProp, + transformers, + emptyValue, + }) + const externalValueDeps = tmpValue + const externalValue = transformers.current.transformIn( + tmpValue ?? defaultValueRef.current + ) // Many variables are kept in refs to avoid triggering unnecessary update loops because updates using // useEffect depend on them (like the external `value`) @@ -1761,7 +1762,9 @@ export default function useFieldProps( valueRef.current = externalValue externalValueDidChangeRef.current = true } - }, [externalValue, hasItemPath]) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [externalValueDeps, hasItemPath]) useUpdateEffect(() => { // Error or removed error for this field from the surrounding data context (by path) @@ -1770,7 +1773,7 @@ export default function useFieldProps( validateValue() forceUpdate() } - }, [externalValue]) // Keep "externalValue" in the dependency list, so it will be updated when it changes + }, [externalValueDeps]) // Keep "externalValue" in the dependency list, so it will be updated when it changes useEffect(() => { // Check against the local error state, @@ -1813,13 +1816,12 @@ export default function useFieldProps( // First, look for existing data in the context const hasValue = pointer.has(data, identifier) || identifier === '/' - const existingValue = transformers.current.transformIn( + const existingValue = identifier === '/' ? data : hasValue ? pointer.get(data, identifier) : undefined - ) // If no data where found in the dataContext, look for shared data if ( @@ -1831,9 +1833,7 @@ export default function useFieldProps( const sharedState = createSharedState(dataContext.id) const hasValue = pointer.has(sharedState.data, identifier) if (hasValue) { - const sharedValue = transformers.current.transformIn( - pointer.get(sharedState.data, identifier) - ) + const sharedValue = pointer.get(sharedState.data, identifier) if (sharedValue) { valueToStore = sharedValue as Value } @@ -1845,6 +1845,8 @@ export default function useFieldProps( typeof valueToStore === 'undefined' if (hasDefaultValue) { + // Set the default value if it's not set yet. + // This takes precedence over the valueToStore. valueToStore = defaultValueRef.current defaultValueRef.current = undefined } @@ -1902,10 +1904,21 @@ export default function useFieldProps( } // Keep Iterate.Array in sync with the data context - valueRef.current = existingValue + valueRef.current = existingValue as Value } } + // When an Array or Object is used as the value, + // and the field uses transformIn, the instance may have been changed. + // The "valueToStore" can be undefined, + // we then need to ensure we don't overwrite the existing data context value with "undefined". + if ( + typeof valueToStore === 'undefined' && + typeof existingValue !== 'undefined' + ) { + valueToStore = existingValue + } + if ( !skipEqualCheck && hasValue && @@ -1923,9 +1936,10 @@ export default function useFieldProps( return // stop here, avoid infinite loop } + const valueIn = transformers.current.transformIn(valueToStore) const transformedValue = transformers.current.transformOut( - valueToStore as Value, - transformers.current.provideAdditionalArgs(valueToStore as Value) + valueIn, + transformers.current.provideAdditionalArgs(valueIn as Value) ) if (transformedValue !== valueToStore) { // When the value got transformed, we want to update the internal value, and avoid an infinite loop @@ -1981,7 +1995,7 @@ export default function useFieldProps( [ dataContext.internalDataRef, dataContext.props?.emptyData, - externalValue, // ensure to include "externalValue" in order to properly remove errors + externalValueDeps, // ensure to include "externalValue" in order to properly remove errors ] ) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useValueProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useValueProps.ts index c921241ca31..dcfe9e1bffd 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useValueProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useValueProps.ts @@ -34,9 +34,9 @@ export default function useValueProps< defaultValue, inheritVisibility, inheritLabel, - transformIn = (value: Value) => value, - toInput = (value: Value) => value, - fromExternal = (value: Value) => value, + transformIn = (external: unknown) => external as Value, + toInput = (internal: Value) => internal, + fromExternal = (external: Value) => external, } = props const transformers = useRef({