From 76d72cb1aab72e7c85b91cd4265621e022b019e9 Mon Sep 17 00:00:00 2001 From: Anders Date: Mon, 26 Aug 2024 14:02:19 +0200 Subject: [PATCH 01/17] chore: minor doc improvements (#3868) --- .../dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts index 10f1f85c870..98ee894b8e0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts @@ -52,17 +52,17 @@ export const dataValueProperties: PropertiesTableProps = { status: 'optional', }, validateInitially: { - doc: 'Set `true` to show validation based errors initially (from given value-prop or source data) before the user interacts with the field.', + doc: 'Set to `true` to show validation based errors initially (from given value-prop or source data) before the user interacts with the field.', type: 'boolean', status: 'optional', }, validateUnchanged: { - doc: 'Set `true` to show validation based errors when the field is touched (like focusing a field and blurring) without having changed the value. Since the user did not introduce a new error, this will apply when the value was initially invalid based on validation.', + doc: 'Set to `true` to show validation based errors when the field is touched (like focusing a field and blurring) without having changed the value. Since the user did not introduce a new error, this will apply when the value was initially invalid based on validation.', type: 'boolean', status: 'optional', }, continuousValidation: { - doc: 'Set `true` to show validation based errors continuously while writing, not just when blurring the field.', + doc: 'Set to `true` to show validation based errors continuously while writing, not just when blurring the field.', type: 'boolean', status: 'optional', }, From 96204e5449d3885ff894280b5f26a86dafeaf720 Mon Sep 17 00:00:00 2001 From: Anders Date: Mon, 26 Aug 2024 14:23:51 +0200 Subject: [PATCH 02/17] chore: minor doc improvements to dataPath docs (#3866) --- .../src/extensions/forms/Field/Selection/Selection.tsx | 2 +- .../src/extensions/forms/Field/Selection/SelectionDocs.ts | 2 +- .../src/extensions/forms/Value/Selection/SelectionDocs.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Selection/Selection.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Selection/Selection.tsx index 1fd73bcbca1..ff7ab5009b0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Selection/Selection.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Selection/Selection.tsx @@ -63,7 +63,7 @@ export type Props = FieldHelpProps & /** * The path to the context data (Form.Handler). - * The object needs to have a `value` and a `title` property. + * The context data object needs to have a `value` and a `title` property. */ dataPath?: Path diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Selection/SelectionDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Field/Selection/SelectionDocs.ts index 11b1110b16f..8c01d397cff 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Selection/SelectionDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Selection/SelectionDocs.ts @@ -27,7 +27,7 @@ export const SelectionProperties: PropertiesTableProps = { status: 'optional', }, dataPath: { - doc: 'The path to the context data (Form.Handler). The object needs to have a `value` and a `title` property. The generated options will be placed above given JSX based children.', + doc: 'The path to the context data (Form.Handler). The context data object needs to have a `value` and a `title` property. The generated options will be placed above given JSX based children.', type: 'string', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/Selection/SelectionDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Value/Selection/SelectionDocs.ts index e97008b1bc9..9814dbf4c54 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Value/Selection/SelectionDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Value/Selection/SelectionDocs.ts @@ -2,7 +2,7 @@ import { PropertiesTableProps } from '../../../../shared/types' export const SelectionProperties: PropertiesTableProps = { dataPath: { - doc: 'The path to the context data (Form.Handler). The object needs to have a `value` and a `title` property. The generated options will be placed above given JSX based children.', + doc: 'The path to the context data (Form.Handler). The context data object needs to have a `value` and a `title` property. The generated options will be placed above given JSX based children.', type: 'string', status: 'optional', }, From 5d325f9867373d388b4c29c899cd9a5c0a01fcca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Mon, 26 Aug 2024 15:26:59 +0200 Subject: [PATCH 03/17] chore(CI): remove portal build cache (#3870) It seems to me that the `Build portal` step is not slower by removing the cache. So we probably should do that so we avoid the CSS file import issues, like [here](https://github.com/dnbexperience/eufemia/actions/runs/10559105350/job/29249868805). --- .github/workflows/e2e.yml | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 16c650aea5b..b6459ea98a3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -86,19 +86,7 @@ jobs: if: env.RUN_POST_BUILD == 'true' run: yarn workspace @dnb/eufemia postbuild:ci - - name: Get current date - id: date - run: echo "::set-output name=timestamp::$(date +'%Y-%W')" - - - name: Use Gatsby cache - uses: actions/cache@v3 - id: gatsby-cache - with: - path: | - ./packages/dnb-design-system-portal/.cache - ./packages/dnb-design-system-portal/public - key: ${{ secrets.CACHE_VERSION }}-${{ runner.os }}-gatsby-${{ steps.date.outputs.timestamp }} - restore-keys: ${{ secrets.CACHE_VERSION }}-${{ runner.os }}-gatsby- + # restore-keys: ${{ secrets.CACHE_VERSION }}-${{ runner.os }}-gatsby- - name: Build portal run: yarn workspace dnb-design-system-portal build:visual-test @@ -177,20 +165,6 @@ jobs: - name: Postbuild Library run: yarn workspace @dnb/eufemia postbuild:ci - - name: Get current date - id: date - run: echo "::set-output name=timestamp::$(date +'%Y-%W')" - - - name: Use Gatsby cache - uses: actions/cache@v3 - id: gatsby-cache - with: - path: | - ./packages/dnb-design-system-portal/.cache - ./packages/dnb-design-system-portal/public - key: ${{ secrets.CACHE_VERSION }}-${{ runner.os }}-gatsby-${{ steps.date.outputs.timestamp }} - restore-keys: ${{ secrets.CACHE_VERSION }}-${{ runner.os }}-gatsby- - - name: Build portal run: yarn workspace dnb-design-system-portal build From a53ff85ebbe5fc8a8004d09393da5270eec33379 Mon Sep 17 00:00:00 2001 From: Anders Date: Tue, 27 Aug 2024 10:26:25 +0200 Subject: [PATCH 04/17] chore: renames mockGetSelection to mockClipboard (#3871) --- .../components/number-format/__tests__/NumberFormat.test.tsx | 4 ++-- .../components/number-format/__tests__/NumberUtils.test.ts | 4 ++-- packages/dnb-eufemia/src/core/jest/jestSetup.js | 2 +- packages/dnb-eufemia/src/shared/__tests__/helpers.test.js | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/dnb-eufemia/src/components/number-format/__tests__/NumberFormat.test.tsx b/packages/dnb-eufemia/src/components/number-format/__tests__/NumberFormat.test.tsx index ce8ca9215eb..4f37c6d7af2 100644 --- a/packages/dnb-eufemia/src/components/number-format/__tests__/NumberFormat.test.tsx +++ b/packages/dnb-eufemia/src/components/number-format/__tests__/NumberFormat.test.tsx @@ -7,7 +7,7 @@ import React from 'react' import { axeComponent, loadScss, - mockGetSelection, + mockClipboard, } from '../../../core/jest/jestSetup' import { fireEvent, render } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -44,7 +44,7 @@ beforeAll(() => { isMac() // just to update the exported const: IS_MAC - mockGetSelection() + mockClipboard() }) describe('NumberFormat component', () => { diff --git a/packages/dnb-eufemia/src/components/number-format/__tests__/NumberUtils.test.ts b/packages/dnb-eufemia/src/components/number-format/__tests__/NumberUtils.test.ts index 87c2df21250..ec707a0440e 100644 --- a/packages/dnb-eufemia/src/components/number-format/__tests__/NumberUtils.test.ts +++ b/packages/dnb-eufemia/src/components/number-format/__tests__/NumberUtils.test.ts @@ -3,7 +3,7 @@ * */ -import { mockGetSelection } from '../../../core/jest/jestSetup' +import { mockClipboard } from '../../../core/jest/jestSetup' import { InternalLocale } from '../../../shared/Context' import { LOCALE } from '../../../shared/defaults' import * as helpers from '../../../shared/helpers' @@ -35,7 +35,7 @@ beforeAll(() => { helpers.isMac() // just to update the exported const: IS_MAC - mockGetSelection() + mockClipboard() }) describe('Node', () => { diff --git a/packages/dnb-eufemia/src/core/jest/jestSetup.js b/packages/dnb-eufemia/src/core/jest/jestSetup.js index 4d280ef2c12..1e3afff519e 100644 --- a/packages/dnb-eufemia/src/core/jest/jestSetup.js +++ b/packages/dnb-eufemia/src/core/jest/jestSetup.js @@ -49,7 +49,7 @@ export const loadScss = (file, options = {}) => { } } -export const mockGetSelection = () => { +export const mockClipboard = () => { let memory Object.defineProperty(window.navigator, 'clipboard', { configurable: true, diff --git a/packages/dnb-eufemia/src/shared/__tests__/helpers.test.js b/packages/dnb-eufemia/src/shared/__tests__/helpers.test.js index 9954204022d..221c7a71434 100644 --- a/packages/dnb-eufemia/src/shared/__tests__/helpers.test.js +++ b/packages/dnb-eufemia/src/shared/__tests__/helpers.test.js @@ -24,7 +24,7 @@ import { warn, } from '../helpers' -import { mockGetSelection } from '../../core/jest/jestSetup' +import { mockClipboard } from '../../core/jest/jestSetup' // make it possible to change the navigator lang // because "navigator.language" defaults to en-GB @@ -34,7 +34,7 @@ beforeAll(() => { userAgentGetter = jest.spyOn(window.navigator, 'userAgent', 'get') platformGetter = jest.spyOn(window.navigator, 'platform', 'get') - mockGetSelection() + mockClipboard() }) describe('"applyPageFocus" should', () => { From aab8f5b9e1a40c2fc09812cb3cf8f4850fdca467 Mon Sep 17 00:00:00 2001 From: Anders Date: Tue, 27 Aug 2024 21:43:24 +0200 Subject: [PATCH 05/17] docs(Selection): value should be string when variant is radio or button (#3869) --- .../extensions/forms/base-fields/Selection/properties.mdx | 5 +---- .../src/extensions/forms/Field/Selection/SelectionDocs.ts | 5 +++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Selection/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Selection/properties.mdx index e56ced869de..21e90b931d1 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Selection/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Selection/properties.mdx @@ -14,7 +14,4 @@ import { SelectionProperties } from '@dnb/eufemia/src/extensions/forms/Field/Sel ### General props - + diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Selection/SelectionDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Field/Selection/SelectionDocs.ts index 8c01d397cff..2e73b62c7f1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Selection/SelectionDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Selection/SelectionDocs.ts @@ -6,6 +6,11 @@ export const SelectionProperties: PropertiesTableProps = { type: 'string', status: 'optional', }, + value: { + doc: 'Defines the `value`. When using variant `radio` or `button`, value has to be a `string`.', + type: ['number', 'string'], + status: 'optional', + }, optionsLayout: { doc: 'Layout for the list of options. Can be `horizontal` or `vertical`.', type: 'string', From 3b02e45b4cb7d413202d136cf412d1f3215fafc9 Mon Sep 17 00:00:00 2001 From: Anders Date: Tue, 27 Aug 2024 21:47:53 +0200 Subject: [PATCH 06/17] feat(Blocks.ChildrenWithAge): removes step controls in age field (#3867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now looking like this: image --------- Co-authored-by: Tobias Høegh --- .../forms/blocks/ChildrenWithAge.mdx | 2 + .../extensions/forms/blocks/Examples.tsx | 11 +- .../ChildrenWithAge/ChildrenWithAge.tsx | 6 +- .../__tests__/ChildrenWithAge.test.tsx | 101 ++++++++++++++++-- .../ChildrenWithAge.test.tsx.snap | 15 ++- 5 files changed, 115 insertions(+), 20 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/ChildrenWithAge.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/ChildrenWithAge.mdx index 87fe037e609..0a2eae14e68 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/ChildrenWithAge.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/ChildrenWithAge.mdx @@ -27,6 +27,8 @@ render() ## In Wizard +All features are enabled in this example. + ## Basic diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/Examples.tsx index 2327851d6af..5c33078c38f 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/Examples.tsx @@ -17,8 +17,15 @@ export const ChildrenWithAge = (props) => { - - + + 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 e9223c8e9d8..05db24eb0e3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx @@ -86,6 +86,7 @@ function EditContent({ enableAdditionalQuestions }: Props) { minimum={1} maximum={20} showStepControls + decimalLimit={0} /> diff --git a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/ChildrenWithAge.test.tsx b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/ChildrenWithAge.test.tsx index 41a8f5e1e65..1e51b421810 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/ChildrenWithAge.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/ChildrenWithAge.test.tsx @@ -1,12 +1,15 @@ import React from 'react' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Form, Tools } from '../../..' import ChildrenWithAge from '../ChildrenWithAge' import { translations } from '../ChildrenWithAgeTranslations' import { GenerateRef } from '../../../Tools/ListAllProps' +import nbNO from '../../../constants/locales/nb-NO' describe('ChildrenWithAge', () => { + const translationsNO = translations['nb-NO'] + it('should render correct fields', async () => { render( @@ -17,7 +20,7 @@ describe('ChildrenWithAge', () => { ) expect(document.querySelector('legend')).toHaveTextContent( - translations['nb-NO'].ChildrenWithAge.hasChildren.fieldLabel + translationsNO.ChildrenWithAge.hasChildren.fieldLabel ) expect( document.querySelectorAll('.dnb-forms-field-block__grid') @@ -45,23 +48,22 @@ describe('ChildrenWithAge', () => { expect( screen.queryByText( - translations['nb-NO'].ChildrenWithAge.hasChildren.fieldLabel + translationsNO.ChildrenWithAge.hasChildren.fieldLabel ) ).toBeInTheDocument() expect( screen.queryByText( - translations['nb-NO'].ChildrenWithAge.hasJointResponsibility - .fieldLabel + translationsNO.ChildrenWithAge.hasJointResponsibility.fieldLabel ) ).toBeInTheDocument() expect( screen.queryByText( - translations['nb-NO'].ChildrenWithAge.usesDaycare.fieldLabel + translationsNO.ChildrenWithAge.usesDaycare.fieldLabel ) ).toBeInTheDocument() expect( screen.queryByText( - translations['nb-NO'].ChildrenWithAge.countChildren.fieldLabel + translationsNO.ChildrenWithAge.countChildren.fieldLabel ) ).toBeInTheDocument() expect( @@ -73,6 +75,91 @@ describe('ChildrenWithAge', () => { ).toBeInTheDocument() }) + it('should render number of children with step controls', async () => { + render() + + await userEvent.click(document.querySelector('button')) + + const countChildrenFieldBlock = screen.queryByText( + translationsNO.ChildrenWithAge.countChildren.fieldLabel + ).parentElement.parentElement.parentElement + + expect( + within(countChildrenFieldBlock).getByTitle('Reduser (0)') + ).toBeInTheDocument() + expect( + within(countChildrenFieldBlock).getByTitle('Øk (2)') + ).toBeInTheDocument() + expect( + document.querySelectorAll('.dnb-input__input')[0] + ).toHaveAttribute('inputmode', 'numeric') + }) + + it('should render age of child without step controls', async () => { + render() + + await userEvent.click(document.querySelector('button')) + + const childreAgenFieldBlock = screen.queryByText( + translationsNO.ChildrenWithAge.childrenAge.fieldLabel.replace( + '{itemNr}', + '1' + ) + ).parentElement.parentElement.parentElement + + expect( + within(childreAgenFieldBlock).queryByRole('Reduser') + ).not.toBeInTheDocument() + + expect( + document.querySelectorAll('.dnb-input__input')[1] + ).toHaveAttribute('inputmode', 'numeric') + }) + + it('should display error if age of child is older than 17 years', async () => { + render() + + await userEvent.click(document.querySelector('button')) + + const input = document.querySelectorAll('.dnb-input__input')[1] + + const value18 = '18' + + fireEvent.change(input, { + target: { + value: value18, + }, + }) + expect(input).toHaveValue(value18) + + fireEvent.blur(input) + + expect(screen.getByRole('alert')).toHaveTextContent( + nbNO['nb-NO'].NumberField.errorMaximum.replace('{maximum}', '17') + ) + }) + + it('should accept 0 as age of child', async () => { + render() + + await userEvent.click(document.querySelector('button')) + + const input = document.querySelectorAll('.dnb-input__input')[1] + + const value0 = '0' + + fireEvent.change(input, { + target: { + value: value0, + }, + }) + expect(input).toHaveValue(value0) + + fireEvent.blur(input) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + it('should replace translations', async () => { const myTranslations = { 'en-GB': { 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 276f1e87716..a8952bb93b4 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 @@ -5,6 +5,7 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "children": { "0": { "age": { + "decimalLimit": 0, "errorMessages": { "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", @@ -14,20 +15,18 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "required": "Du må skrive inn alder på barnet.", }, "itemPath": "/age", - "maximum": 100, + "maximum": 17, "minimum": 0, "placeholder": "0", "required": true, "schema": { "exclusiveMaximum": undefined, "exclusiveMinimum": undefined, - "maximum": 100, + "maximum": 17, "minimum": 0, "multipleOf": undefined, "type": "number", }, - "showStepControls": true, - "startWith": -1, "translations": { "en-GB": { "ChildrenWithAge": { @@ -284,6 +283,7 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` }, "1": { "age": { + "decimalLimit": 0, "errorMessages": { "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", @@ -293,20 +293,18 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "required": "Du må skrive inn alder på barnet.", }, "itemPath": "/age", - "maximum": 100, + "maximum": 17, "minimum": 0, "placeholder": "0", "required": true, "schema": { "exclusiveMaximum": undefined, "exclusiveMinimum": undefined, - "maximum": 100, + "maximum": 17, "minimum": 0, "multipleOf": undefined, "type": "number", }, - "showStepControls": true, - "startWith": -1, "translations": { "en-GB": { "ChildrenWithAge": { @@ -647,6 +645,7 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` }, }, "countChildren": { + "decimalLimit": 0, "defaultValue": 1, "errorMessages": { "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", From 07ba09d8334388586853d54e221076609c9c5cb9 Mon Sep 17 00:00:00 2001 From: Thayanan Tharmapalan Date: Tue, 27 Aug 2024 22:48:34 +0200 Subject: [PATCH 07/17] feat(CopyOnClick): add new component (#3834) --- .../docs/uilib/components/copy-on-click.mdx | 15 +++ .../components/copy-on-click/Examples.tsx | 38 ++++++ .../uilib/components/copy-on-click/demos.mdx | 15 +++ .../uilib/components/copy-on-click/info.mdx | 22 ++++ .../components/copy-on-click/properties.mdx | 10 ++ .../src/shared/parts/icons/ListAllIcons.tsx | 5 +- .../src/shared/tags/Copy.module.scss | 3 - .../src/shared/tags/Copy.tsx | 79 ------------- .../src/shared/tags/index.tsx | 7 +- .../dnb-eufemia/src/components/CopyOnClick.ts | 14 +++ .../components/copy-on-click/CopyOnClick.tsx | 84 ++++++++++++++ .../copy-on-click/CopyOnClickDocs.ts | 19 +++ .../__tests__/CopyOnClick.test.tsx | 109 ++++++++++++++++++ .../src/components/copy-on-click/index.ts | 8 ++ .../stories/CopyOnClick.stories.tsx | 27 +++++ .../src/components/copy-on-click/style.ts | 6 + .../style/dnb-copy-on-click.scss | 10 ++ .../components/copy-on-click/style/index.ts | 6 + .../src/components/copy-on-click/types.ts | 24 ++++ packages/dnb-eufemia/src/components/index.ts | 2 + packages/dnb-eufemia/src/components/lib.ts | 3 + packages/dnb-eufemia/src/index.ts | 2 + .../dnb-eufemia/src/shared/locales/en-GB.ts | 3 + .../dnb-eufemia/src/shared/locales/nb-NO.ts | 3 + .../src/style/dnb-ui-components.scss | 1 + 25 files changed, 426 insertions(+), 89 deletions(-) create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click.mdx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/Examples.tsx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/demos.mdx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/info.mdx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/properties.mdx delete mode 100644 packages/dnb-design-system-portal/src/shared/tags/Copy.module.scss delete mode 100644 packages/dnb-design-system-portal/src/shared/tags/Copy.tsx create mode 100644 packages/dnb-eufemia/src/components/CopyOnClick.ts create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/CopyOnClick.tsx create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/CopyOnClickDocs.ts create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/__tests__/CopyOnClick.test.tsx create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/index.ts create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/stories/CopyOnClick.stories.tsx create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/style.ts create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/style/dnb-copy-on-click.scss create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/style/index.ts create mode 100644 packages/dnb-eufemia/src/components/copy-on-click/types.ts diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click.mdx new file mode 100644 index 00000000000..252e1c9a089 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click.mdx @@ -0,0 +1,15 @@ +--- +title: 'CopyOnClick' +description: 'The CopyOnClick component allows users to copy text to their clipboard simply by clicking on it.' +showTabs: true +hideTabs: + - title: Events +theme: 'sbanken' +status: 'new' +--- + +import CopyOnClickInfo from 'Docs/uilib/components/copy-on-click/info' +import CopyOnClickDemos from 'Docs/uilib/components/copy-on-click/demos' + + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/Examples.tsx new file mode 100644 index 00000000000..21e425be49d --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/Examples.tsx @@ -0,0 +1,38 @@ +/** + * UI lib Component Example + * + */ + +import React from 'react' +import ComponentBox from '../../../../shared/tags/ComponentBox' +import { CopyOnClick, P } from '@dnb/eufemia/src' + +export const Default = () => { + return ( + +

+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi + cursus pharetra elit in bibendum. Praesent nunc ipsum, convallis + eget convallis gravida, vehicula vitae metus. + +

+
+ ) +} + +export const CopyCursorHidden = () => { + return ( + +

+ + Praesent nunc ipsum, convallis eget convallis gravida, vehicula + vitae metus. + +

+
+ ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/demos.mdx new file mode 100644 index 00000000000..fcaa3af7258 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/demos.mdx @@ -0,0 +1,15 @@ +--- +showTabs: true +--- + +import * as Examples from './Examples' + +## Demo + +### Default + + + +### CopyOnClick cursor hidden + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/info.mdx new file mode 100644 index 00000000000..a9763a7c415 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/info.mdx @@ -0,0 +1,22 @@ +--- +showTabs: true +--- + +## Description + +The `CopyOnClick` component provides a convenient way to copy text to the clipboard with a single click. This component is particularly useful in scenarios where users need to quickly copy text, such as when copying codes, IDs, URLs, or any other text content that needs to be easily shared or reused. +Upon hovering, the component can optionally provide visual feedback to the user, displaying a copy cursor or other visual cues that indicate the component's functionality. +Upon clicking, the component provides a visual feedback to the user, displaying a tooltip with a confirmation message, indicating that the text has been successfully copied. + +### Example + +Here’s a simple usage example of the `CopyOnClick` component: + +```jsx +import { CopyOnClick, P } from '@dnb/eufemia' +render( +

+ This is the text to copy! +

, +) +``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/properties.mdx new file mode 100644 index 00000000000..d9b095777c2 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/copy-on-click/properties.mdx @@ -0,0 +1,10 @@ +--- +showTabs: true +--- + +import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' +import { CopyOnClickProperties } from '@dnb/eufemia/src/components/copy-on-click/CopyOnClickDocs' + +## Properties + + diff --git a/packages/dnb-design-system-portal/src/shared/parts/icons/ListAllIcons.tsx b/packages/dnb-design-system-portal/src/shared/parts/icons/ListAllIcons.tsx index f139284e4f2..28c37a7de46 100644 --- a/packages/dnb-design-system-portal/src/shared/parts/icons/ListAllIcons.tsx +++ b/packages/dnb-design-system-portal/src/shared/parts/icons/ListAllIcons.tsx @@ -3,7 +3,7 @@ */ import React from 'react' -import { Icon } from '@dnb/eufemia/src/components' +import { Icon, CopyOnClick } from '@dnb/eufemia/src/components' import { P } from '@dnb/eufemia/src' import * as PrimaryIcons from '@dnb/eufemia/src/icons/dnb/primary_icons' import * as SecondaryIcons from '@dnb/eufemia/src/icons/dnb/secondary_icons' @@ -11,7 +11,6 @@ import * as PrimaryIconsMedium from '@dnb/eufemia/src/icons/dnb/primary_icons_me import * as SecondaryIconsMedium from '@dnb/eufemia/src/icons/dnb/secondary_icons_medium' import iconsMetaData from '@dnb/eufemia/src/icons/dnb/icons-meta.json' import AutoLinkHeader from '../../tags/AutoLinkHeader' -import Copy from '../../tags/Copy' import { listStyle, listItemStyle, @@ -107,7 +106,7 @@ export default class ListAllIcons extends React.PureComponent { element="figcaption" useSlug={iconName} > - {iconName} + {iconName}

{tags.length > 0 ? tags.join(', ') : '(no tags)'}

diff --git a/packages/dnb-design-system-portal/src/shared/tags/Copy.module.scss b/packages/dnb-design-system-portal/src/shared/tags/Copy.module.scss deleted file mode 100644 index 5ef9961fd6f..00000000000 --- a/packages/dnb-design-system-portal/src/shared/tags/Copy.module.scss +++ /dev/null @@ -1,3 +0,0 @@ -.copyStyle { - cursor: copy; -} diff --git a/packages/dnb-design-system-portal/src/shared/tags/Copy.tsx b/packages/dnb-design-system-portal/src/shared/tags/Copy.tsx deleted file mode 100644 index 5d80c618342..00000000000 --- a/packages/dnb-design-system-portal/src/shared/tags/Copy.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copy Tag - * - */ - -import React from 'react' -import PropTypes from 'prop-types' -import classnames from 'classnames' -import { IS_IOS, hasSelectedText } from '@dnb/eufemia/src/shared/helpers' -import { - convertJsxToString, - warn, -} from '@dnb/eufemia/src/shared/component-helper' -import { - useCopyWithNotice, - runIOSSelectionFix, -} from '@dnb/eufemia/src/components/number-format/NumberUtils' -import { copyStyle } from './Copy.module.scss' - -let hasiOSFix = false - -const Copy = ({ children, className = null, ...rest }) => { - const ref = React.useRef() - - React.useEffect(() => { - if (IS_IOS) { - if (!hasiOSFix) { - hasiOSFix = true - runIOSSelectionFix() - } - } - }, []) - - const { copy } = useCopyWithNotice() - - const onClickHandler = () => { - if (!hasSelectedText()) { - try { - const str = convertJsxToString(children) - - if (String(str).length > 0) { - const selection = window.getSelection() - const range = document.createRange() - range.selectNodeContents(ref.current) - selection.removeAllRanges() - selection.addRange(range) - - copy(str, ref.current) // use copyWithNotice only to use the nice effect / animation - } - } catch (e) { - warn(e) - } - } - } - - const params = { - onClick: onClickHandler, - } - - return ( - - {children} - - ) -} -Copy.propTypes = { - className: PropTypes.string, - children: PropTypes.node.isRequired, -} -Copy.defaultProps = { - className: null, -} - -export default Copy diff --git a/packages/dnb-design-system-portal/src/shared/tags/index.tsx b/packages/dnb-design-system-portal/src/shared/tags/index.tsx index e245d6f4455..4a1b596ecbe 100644 --- a/packages/dnb-design-system-portal/src/shared/tags/index.tsx +++ b/packages/dnb-design-system-portal/src/shared/tags/index.tsx @@ -4,7 +4,7 @@ import React from 'react' import CodeBlock from './CodeBlock' -import { Checkbox, Input } from '@dnb/eufemia/src/components' +import { Checkbox, Input, CopyOnClick } from '@dnb/eufemia/src/components' import { Ul, Ol, @@ -20,7 +20,6 @@ import Table from './Table' // import Img from './Img' import Anchor from './Anchor' import Header from './AutoLinkHeader' -import Copy from './Copy' import VisibilityByTheme from '@dnb/eufemia/src/shared/VisibilityByTheme' import { TypographyBox } from '../parts/TypographyBox' @@ -51,7 +50,7 @@ export const basicComponents = { } return ( - {children} + {children} ) }, @@ -73,7 +72,7 @@ export const basicComponents = { } export default { - Copy, + CopyOnClick, TypographyBox, VisibilityByTheme, VisibleWhenVisualTest: ({ children }) => { diff --git a/packages/dnb-eufemia/src/components/CopyOnClick.ts b/packages/dnb-eufemia/src/components/CopyOnClick.ts new file mode 100644 index 00000000000..6d8023c512e --- /dev/null +++ b/packages/dnb-eufemia/src/components/CopyOnClick.ts @@ -0,0 +1,14 @@ +/** + * ATTENTION: This file is auto generated by using "prepareTemplates". + * Do not change the content! + * + */ + +/** + * Library Index copy to autogenerate all the components and extensions + * Used by "prepareCopyOnClicks" + */ + +import CopyOnClick from './copy-on-click/CopyOnClick' +export * from './copy-on-click/CopyOnClick' +export default CopyOnClick diff --git a/packages/dnb-eufemia/src/components/copy-on-click/CopyOnClick.tsx b/packages/dnb-eufemia/src/components/copy-on-click/CopyOnClick.tsx new file mode 100644 index 00000000000..8f223250f37 --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/CopyOnClick.tsx @@ -0,0 +1,84 @@ +/** + * Web CopyOnClick Component + */ + +import React, { useCallback, useEffect, useRef } from 'react' +import classnames from 'classnames' +import type { CopyOnClickAllProps } from './types' +import { + runIOSSelectionFix, + copyWithEffect, +} from '../number-format/NumberUtils' +import { hasSelectedText, IS_IOS, warn } from '../../shared/helpers' +import { convertJsxToString } from '../../shared/component-helper' +import { useTranslation } from '../../shared' +import { Span } from '../../elements' + +const CopyOnClick = ({ + children, + className = null, + disabled, + showCursor = true, + ...props +}: CopyOnClickAllProps) => { + const ref = useRef(null) + + useEffect(() => { + if (IS_IOS) { + runIOSSelectionFix() + } + }, []) + + const { + CopyOnClick: { clipboard_copy }, + } = useTranslation() + + const copy = useCallback( + (value: string, positionElement: HTMLElement) => { + copyWithEffect(value, clipboard_copy, positionElement) // use copyWithNotice only to use the nice effect / animation + }, + [clipboard_copy] + ) + + const onClickHandler = useCallback(() => { + if (!hasSelectedText()) { + try { + const str = convertJsxToString(children) + + if (str) { + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(ref.current) + selection.removeAllRanges() + selection.addRange(range) + + copy(str, ref.current) + } + } catch (e) { + warn(e) + } + } + }, [children, copy]) + + const params = { + onClick: disabled ? undefined : onClickHandler, + } + + return ( + + {children} + + ) +} + +CopyOnClick._supportsSpacingProps = true +export default CopyOnClick diff --git a/packages/dnb-eufemia/src/components/copy-on-click/CopyOnClickDocs.ts b/packages/dnb-eufemia/src/components/copy-on-click/CopyOnClickDocs.ts new file mode 100644 index 00000000000..7367beac5fa --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/CopyOnClickDocs.ts @@ -0,0 +1,19 @@ +import type { PropertiesTableProps } from '../../shared/types' + +export const CopyOnClickProperties: PropertiesTableProps = { + showCursor: { + doc: 'Define if the copy cursor should be visible. Defaults to `true`.', + type: 'boolean', + status: 'optional', + }, + disabled: { + doc: 'If `false`, the copy functionality and copy cursor will be omitted. Defaults to `true`.', + type: 'boolean', + status: 'optional', + }, + children: { + doc: 'Contents.', + type: 'React.Node', + status: 'required', + }, +} diff --git a/packages/dnb-eufemia/src/components/copy-on-click/__tests__/CopyOnClick.test.tsx b/packages/dnb-eufemia/src/components/copy-on-click/__tests__/CopyOnClick.test.tsx new file mode 100644 index 00000000000..3324dd430cc --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/__tests__/CopyOnClick.test.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import CopyOnClick from '../CopyOnClick' +import { mockClipboard } from '../../../core/jest/jestSetup' +import { copyWithEffect } from '../../../components/number-format/NumberUtils' + +describe('CopyOnClick', () => { + beforeAll(() => { + mockClipboard() + }) + + it('renders with default props', async () => { + render(CopyOnClick text) + + await waitFor(() => + expect(screen.getByText('CopyOnClick text')).toBeInTheDocument() + ) + + const element = document.querySelector('.dnb-copy-on-click') + + expect(Array.from(element.classList)).toEqual([ + 'dnb-copy-on-click', + 'dnb-copy-on-click--cursor', + 'dnb-span', + ]) + }) + + it('does not render the cursor when disabled', async () => { + render(Disabled cursor) + + const element = document.querySelector('.dnb-copy-on-click') + + expect(Array.from(element.classList)).not.toContain([ + 'dnb-copy-on-click--cursor', + ]) + }) + + it('updates when children changes', async () => { + const { rerender } = render(First copy text) + + await waitFor(() => + expect(screen.getByText('First copy text')).toBeInTheDocument() + ) + + rerender(Second copy text) + + await waitFor(() => + expect(screen.getByText('Second copy text')).toBeInTheDocument() + ) + }) + + it('renders with a paragraph element', async () => { + render( + +

CopyOnClick text

+
+ ) + + await waitFor(() => + expect(screen.getByText('CopyOnClick text')).toBeInTheDocument() + ) + }) + + it('should set any given HTML attribute on the element', () => { + render( + +

CopyOnClick text

+
+ ) + + const element = document.querySelector('.dnb-copy-on-click') + + expect(element).toHaveAttribute('id', 'test-id') + expect(element).toHaveAttribute('data-test', 'test-data') + }) + + it('should have dnb-copy-on-click--cursor class', () => { + render( + +

CopyOnClick text

+
+ ) + + const element = document.querySelector('.dnb-copy-on-click') + + expect(element).toHaveClass('dnb-copy-on-click--cursor') + }) + + it('should set a custom HTML class name on the element', () => { + render( + CopyOnClick text + ) + + const element = document.querySelector('.dnb-copy-on-click') + expect(element).toHaveClass('custom-class') + expect(element).toHaveClass('dnb-copy-on-click') + }) + + it('should copy to clipboard', async () => { + copyWithEffect('CopyOnClick') + expect(await navigator.clipboard.readText()).toBe('CopyOnClick') + }) + + it('should support spacing props', async () => { + render(CopyOnClick text) + const element = document.querySelector('.dnb-copy-on-click') + expect(element).toHaveClass('dnb-space__top--large') + }) +}) diff --git a/packages/dnb-eufemia/src/components/copy-on-click/index.ts b/packages/dnb-eufemia/src/components/copy-on-click/index.ts new file mode 100644 index 00000000000..e7756a94c32 --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/index.ts @@ -0,0 +1,8 @@ +/** + * Component Entry + * + */ + +import CopyOnClick from './CopyOnClick' +export default CopyOnClick +export * from './CopyOnClick' diff --git a/packages/dnb-eufemia/src/components/copy-on-click/stories/CopyOnClick.stories.tsx b/packages/dnb-eufemia/src/components/copy-on-click/stories/CopyOnClick.stories.tsx new file mode 100644 index 00000000000..f9f744b1fb6 --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/stories/CopyOnClick.stories.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import CopyOnClick from '../CopyOnClick' +import { Box, Wrapper } from 'storybook-utils/helpers' + +export default { + title: 'Eufemia/Components/CopyOnClick', +} + +export const CopyOnClickSandbox = () => ( + + + + Text to be copied + + + + + Text to be copy is disabled + + + + + CopyOnClick cursor is disabled + + + +) diff --git a/packages/dnb-eufemia/src/components/copy-on-click/style.ts b/packages/dnb-eufemia/src/components/copy-on-click/style.ts new file mode 100644 index 00000000000..102edf0fc45 --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/style.ts @@ -0,0 +1,6 @@ +/** + * Web Style Import + * + */ + +import './style/dnb-copy-on-click.scss' diff --git a/packages/dnb-eufemia/src/components/copy-on-click/style/dnb-copy-on-click.scss b/packages/dnb-eufemia/src/components/copy-on-click/style/dnb-copy-on-click.scss new file mode 100644 index 00000000000..d306c3552c6 --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/style/dnb-copy-on-click.scss @@ -0,0 +1,10 @@ +/* +* CopyOnClick component +* +*/ + +.dnb-copy-on-click { + &--cursor { + cursor: copy; + } +} diff --git a/packages/dnb-eufemia/src/components/copy-on-click/style/index.ts b/packages/dnb-eufemia/src/components/copy-on-click/style/index.ts new file mode 100644 index 00000000000..9b4db3145c6 --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/style/index.ts @@ -0,0 +1,6 @@ +/** + * Web Style Import + * + */ + +import './dnb-copy-on-click.scss' diff --git a/packages/dnb-eufemia/src/components/copy-on-click/types.ts b/packages/dnb-eufemia/src/components/copy-on-click/types.ts new file mode 100644 index 00000000000..5a8a5ab154e --- /dev/null +++ b/packages/dnb-eufemia/src/components/copy-on-click/types.ts @@ -0,0 +1,24 @@ +import { SpacingProps } from '../space/types' + +export type CopyOnClickProps = { + /** + * Whether to show the copy cursor or not. + * @default true + */ + showCursor?: boolean + + /** + * Whether the CopyOnClick component is on or off. + * @default false + */ + disabled?: boolean + + /** + * The content to be copied. + */ + children: React.ReactNode +} + +export type CopyOnClickAllProps = CopyOnClickProps & + SpacingProps & + React.HTMLAttributes diff --git a/packages/dnb-eufemia/src/components/index.ts b/packages/dnb-eufemia/src/components/index.ts index 167ecea1f17..d6db5c6f083 100644 --- a/packages/dnb-eufemia/src/components/index.ts +++ b/packages/dnb-eufemia/src/components/index.ts @@ -20,6 +20,7 @@ import Breadcrumb from './breadcrumb/Breadcrumb' import Button from './button/Button' import Card from './card/Card' import Checkbox from './checkbox/Checkbox' +import CopyOnClick from './copy-on-click/CopyOnClick' import DatePicker from './date-picker/DatePicker' import Dialog from './dialog/Dialog' import Drawer from './drawer/Drawer' @@ -75,6 +76,7 @@ export { Button, Card, Checkbox, + CopyOnClick, DatePicker, Dialog, Drawer, diff --git a/packages/dnb-eufemia/src/components/lib.ts b/packages/dnb-eufemia/src/components/lib.ts index bfc5bda9799..27b88599da6 100644 --- a/packages/dnb-eufemia/src/components/lib.ts +++ b/packages/dnb-eufemia/src/components/lib.ts @@ -20,6 +20,7 @@ import Breadcrumb from './breadcrumb/Breadcrumb' import Button from './button/Button' import Card from './card/Card' import Checkbox from './checkbox/Checkbox' +import CopyOnClick from './copy-on-click/CopyOnClick' import DatePicker from './date-picker/DatePicker' import Dialog from './dialog/Dialog' import Drawer from './drawer/Drawer' @@ -75,6 +76,7 @@ export { Button, Card, Checkbox, + CopyOnClick, DatePicker, Dialog, Drawer, @@ -130,6 +132,7 @@ export const getComponents = () => { Breadcrumb, Button, Card, + CopyOnClick, Checkbox, DatePicker, Dialog, diff --git a/packages/dnb-eufemia/src/index.ts b/packages/dnb-eufemia/src/index.ts index afbc4444be3..47f7cefc36e 100644 --- a/packages/dnb-eufemia/src/index.ts +++ b/packages/dnb-eufemia/src/index.ts @@ -47,6 +47,7 @@ import Breadcrumb from './components/breadcrumb/Breadcrumb' import Button from './components/button/Button' import Card from './components/card/Card' import Checkbox from './components/checkbox/Checkbox' +import CopyOnClick from './components/copy-on-click/CopyOnClick' import DatePicker from './components/date-picker/DatePicker' import Dialog from './components/dialog/Dialog' import Drawer from './components/drawer/Drawer' @@ -129,6 +130,7 @@ export { Button, Card, Checkbox, + CopyOnClick, DatePicker, Dialog, Drawer, diff --git a/packages/dnb-eufemia/src/shared/locales/en-GB.ts b/packages/dnb-eufemia/src/shared/locales/en-GB.ts index 26e29082f4d..faee7c7ab10 100644 --- a/packages/dnb-eufemia/src/shared/locales/en-GB.ts +++ b/packages/dnb-eufemia/src/shared/locales/en-GB.ts @@ -87,6 +87,9 @@ export default { declineText: 'Cancel', confirmText: 'Approve', }, + CopyOnClick: { + clipboard_copy: 'Copied', + }, NumberFormat: { clipboard_copy: 'Copied', not_available: 'Not available', diff --git a/packages/dnb-eufemia/src/shared/locales/nb-NO.ts b/packages/dnb-eufemia/src/shared/locales/nb-NO.ts index bcd79871391..c8e8476e70e 100644 --- a/packages/dnb-eufemia/src/shared/locales/nb-NO.ts +++ b/packages/dnb-eufemia/src/shared/locales/nb-NO.ts @@ -87,6 +87,9 @@ export default { declineText: 'Avbryt', confirmText: 'Godta', }, + CopyOnClick: { + clipboard_copy: 'Kopiert', + }, NumberFormat: { clipboard_copy: 'Kopiert', not_available: 'Ikke tilgjengelig', diff --git a/packages/dnb-eufemia/src/style/dnb-ui-components.scss b/packages/dnb-eufemia/src/style/dnb-ui-components.scss index 842445bee91..f927324a196 100644 --- a/packages/dnb-eufemia/src/style/dnb-ui-components.scss +++ b/packages/dnb-eufemia/src/style/dnb-ui-components.scss @@ -16,6 +16,7 @@ @import '../components/button/style/dnb-button.scss'; @import '../components/card/style/dnb-card.scss'; @import '../components/checkbox/style/dnb-checkbox.scss'; +@import '../components/copy-on-click/style/dnb-copy-on-click.scss'; @import '../components/date-picker/style/dnb-date-picker.scss'; @import '../components/dialog/style/dnb-dialog.scss'; @import '../components/drawer/style/dnb-drawer.scss'; From aee005f901e00423aebf675454d4604ef2118379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Wed, 28 Aug 2024 08:53:46 +0200 Subject: [PATCH 08/17] fix(Forms): enhance `Field.Email` validation pattern (#3874) See the tests for more details about what we now allow. In short, its not possible to have the perfect validation. Here is an [article about the challenge](https://www.abstractapi.com/guides/email-validation/email-address-pattern-validation). The Regex gets way larger when we try to validate IPv4 and especially IPv6. But I think we don't need a perfect validation for these, as these are exceptions anyways. --- .../extensions/forms/Field/Email/Email.tsx | 12 ++- .../Field/Email/__tests__/Email.test.tsx | 73 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Email/Email.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Email/Email.tsx index 50ccabc895e..c189c0b69ea 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Email/Email.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Email/Email.tsx @@ -18,7 +18,17 @@ function Email(props: Props) { autoComplete: 'email', inputMode: 'email', pattern: - "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*$", + `^(?!.*\\.\\.)(?!.*--)(?!.*\\.-)(?!.*-\\.)` + // No consecutive dots, hyphens, or dot-hyphen sequences + `[a-zA-Z0-9]+([._%+-]?[a-zA-Z0-9]+)*@` + // Local part: letters, numbers, dots, etc. + `(?:` + + `([a-zA-Z0-9]+([.-]?[a-zA-Z0-9]+)*\\.[a-zA-Z]{2,})` + // Domain part: standard domain names + `|` + + `\\[(?:` + + `(?:\\d{1,3}\\.){3}\\d{1,3}` + // Allow IPv4 address (no validation) + `|` + + `IPv6:[0-9a-fA-F:]+` + // Allow IPv6 address (no validation) + `)\\]` + + `)$`, trim: true, ...props, errorMessages, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Email/__tests__/Email.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Email/__tests__/Email.test.tsx index 4b8acfe9e14..560d627d4eb 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Email/__tests__/Email.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Email/__tests__/Email.test.tsx @@ -4,6 +4,9 @@ import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Props } from '..' import { Field, Form } from '../../..' +import nbNO from '../../../constants/locales/nb-NO' + +const nb = nbNO['nb-NO'] describe('Field.Email', () => { it('should render with props', () => { @@ -128,6 +131,76 @@ describe('Field.Email', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) + describe('should validate the correctness of email addresses', () => { + const validNames = [ + 'simple@example.com', + 'user.name+tag@example.co.uk', + 'user-name@example.org', + 'user_name@sub.domain.com', + 'user.name@domain.io', + 'user%email@example.com', + 'user.name@example.travel', + 'user123@example.info', + 'user@example123.com', + 'firstname.lastname@example.co', + + // IP address literal + 'user@[192.168.1.1]', + 'user@[255.255.255.255]', + 'user@[0.0.0.0]', + 'user@[10.0.0.1]', + 'user@[172.16.254.1]', + + // IPv6 address literal + 'user@[IPv6:2001:db8::1]', + 'admin@[IPv6:2607:fe80::1ff:fe23:4567:890a]', + 'info@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]', + 'contact@[IPv6:2001:db8:1234:5678:9abc:def0:1234:5678]', + 'support@[IPv6:2001:db8:85a3:0000:0000:8a2e:0370:7334]', + 'user@[IPv6:::1]', + 'user@[IPv6:2001:db8:ff00:42:8329]', + 'user@[IPv6:2001:db8:abcd:1234::]', + 'service@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]', + 'test@[IPv6:2001:db8:1234::abcd]', + ] + + const invalidNames = [ + 'user..name@example.com', // Consecutive dots in local part + 'user.name.@example.com', // Dot at the end of the local part + '.username@example.com', // Dot at the beginning of the local part + 'user.@example.com', // Dot before @ + 'user@.example.com', // Dot at the beginning of the domain + 'user@sub_domain.com', // Underscore in domain part + 'user@domain.com-', // Hyphen at the end of domain + 'user@domain..com', // Consecutive dots in the domain + 'user@domain.c', // TLD too short + 'user@-domain.com', // Hyphen at the start of the domain + + // The regex we have does not take such invalid addresses into account + // 'user@[256.100.50.25]', // Invalid IPv4, 256 is out of range + // 'user@[192.168.1.300]', // Invalid IPv4, 300 is out of range + // 'user@[192.168.1.1.1]', // Invalid IPv4, too many octets + // 'user@[192.168.1]', // Invalid IPv4, too few octets + // 'user@[abc.def.ghi.jkl]', // Invalid IPv4, non-numeric characters + // 'user@[300.168.1.1]', // Invalid IPv4 address + // 'user@[192.168.1.256]', // Invalid IPv4 address + // 'user@[IPv6:2001::85a3::8a2e]', // Invalid IPv6 address + ] + + it.each(validNames)('Valid email: %s', (email) => { + render() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it.each(invalidNames)('Invalid email: %s', (email) => { + render() + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.Email.errorPattern + ) + }) + }) + describe('ARIA', () => { it('should validate with ARIA rules', async () => { const result = render() From 416df35d64e37c1cbbbe400d368721041dc02374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Wed, 28 Aug 2024 09:38:07 +0200 Subject: [PATCH 09/17] feat(Forms): add `Value.SelectCountry` component (#3875) Quick example: ```tsx Input: Output: ``` --- .../extensions/forms/Value/SelectCountry.mdx | 27 +++++++ .../forms/Value/SelectCountry/Examples.tsx | 70 +++++++++++++++++ .../forms/Value/SelectCountry/demos.mdx | 35 +++++++++ .../forms/Value/SelectCountry/info.mdx | 14 ++++ .../forms/Value/SelectCountry/properties.mdx | 15 ++++ .../docs/uilib/extensions/forms/changelog.mdx | 4 + .../feature-fields/SelectCountry/events.mdx | 4 +- .../feature-fields/SelectCountry/info.mdx | 2 + .../SelectCountry/properties.mdx | 6 +- .../Field/SelectCountry/SelectCountryDocs.ts | 11 ++- .../Value/SelectCountry/SelectCountry.tsx | 48 ++++++++++++ .../__tests__/SelectCountry.test.tsx | 75 +++++++++++++++++++ .../forms/Value/SelectCountry/index.ts | 2 + .../stories/SelectCountry.stories.tsx | 26 +++++++ .../src/extensions/forms/Value/index.ts | 1 + 15 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry.mdx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/Examples.tsx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/demos.mdx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/info.mdx create mode 100644 packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/properties.mdx create mode 100644 packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/SelectCountry.tsx create mode 100644 packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/__tests__/SelectCountry.test.tsx create mode 100644 packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/index.ts create mode 100644 packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/stories/SelectCountry.stories.tsx diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry.mdx new file mode 100644 index 00000000000..30e9f33a5e5 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry.mdx @@ -0,0 +1,27 @@ +--- +title: 'SelectCountry' +description: '`Value.SelectCountry` will render the selected country.' +componentType: 'base-value' +hideInMenu: true +showTabs: true +tabs: + - title: Info + key: '/info' + - title: Demos + key: '/demos' + - title: Properties + key: '/properties' +breadcrumb: + - text: Forms + href: /uilib/extensions/forms/ + - text: Value + href: /uilib/extensions/forms/Value/ + - text: SelectCountry + href: /uilib/extensions/forms/Value/SelectCountry/ +--- + +import Info from 'Docs/uilib/extensions/forms/Value/SelectCountry/info' +import Demos from 'Docs/uilib/extensions/forms/Value/SelectCountry/demos' + + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/Examples.tsx new file mode 100644 index 00000000000..13b78bf884a --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/Examples.tsx @@ -0,0 +1,70 @@ +import ComponentBox from '../../../../../../shared/tags/ComponentBox' +import { Flex, P } from '@dnb/eufemia/src' +import { Field, Form, Value } from '@dnb/eufemia/src/extensions/forms' + +export const Placeholder = () => { + return ( + + + + ) +} + +export const WithValue = () => { + return ( + + + + ) +} + +export const DifferentLocale = () => { + return ( + + + + + + ) +} + +export const Label = () => { + return ( + + + + ) +} + +export const LabelAndValue = () => { + return ( + + + + ) +} + +export const Inline = () => { + return ( + +

+ This is before the component + + This is after the component +

+
+ ) +} + +export const WithFieldAndValue = () => { + return ( + + + + + + + + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/demos.mdx new file mode 100644 index 00000000000..ea3a986e10e --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/demos.mdx @@ -0,0 +1,35 @@ +--- +showTabs: true +--- + +import * as Examples from './Examples' + +## Demos + +### Interactive + + + +### Placeholder + + + +### Value + + + +### Use different locale + + + +### Label + + + +### Label and value + + + +### Inline + + 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 new file mode 100644 index 00000000000..df2b39f5bf3 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/info.mdx @@ -0,0 +1,14 @@ +--- +showTabs: true +--- + +## Description + +`Value.SelectCountry` will render the selected country. + +```jsx +import { Value } from '@dnb/eufemia/extensions/forms' +render() +``` + +There is a corresponding [Field.SelectCountry](/uilib/extensions/forms/feature-fields/SelectCountry) component. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/properties.mdx new file mode 100644 index 00000000000..10fbbffea95 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/SelectCountry/properties.mdx @@ -0,0 +1,15 @@ +--- +showTabs: true +--- + +import TranslationsTable from 'dnb-design-system-portal/src/shared/parts/TranslationsTable' +import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' +import { ValueProperties } from '@dnb/eufemia/src/extensions/forms/Value/ValueDocs' + +## Properties + + + +## Translations + + 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 a411a3ea6b3..19a50a653fd 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.46 + +- Added [Value.SelectCountry](/uilib/extensions/forms/Value/SelectCountry/) component to render a country value. + ## v10.45 - Added [Iterate.PushContainer](/uilib/extensions/forms/Iterate/PushContainer/) to create new items in an array. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/events.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/events.mdx index 1a8a6c70c01..0b4e9942f61 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/events.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/events.mdx @@ -3,11 +3,11 @@ showTabs: true --- import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' -import { selectCountryGeneralEvents } from '@dnb/eufemia/src/extensions/forms/Field/SelectCountry/SelectCountryDocs' +import { SelectCountryGeneralEvents } from '@dnb/eufemia/src/extensions/forms/Field/SelectCountry/SelectCountryDocs' ## Events - + ### Details about argument values diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/info.mdx index db4c4d43de6..c012f92b4cb 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/info.mdx @@ -14,6 +14,8 @@ render() It supports the HTML [autofill](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) attribute (`country-name`) if no value is given. +There is a corresponding [Value.SelectCountry](/uilib/extensions/forms/Value/SelectCountry) component. + ### Filter or prioritize country listing You can filter countries with the `countries` property's values `Scandinavia`, `Nordic` or `Europe`. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/properties.mdx index 38e82db1e8a..97610378b38 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/SelectCountry/properties.mdx @@ -5,13 +5,11 @@ showTabs: true import TranslationsTable from 'dnb-design-system-portal/src/shared/parts/TranslationsTable' import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' import { fieldProperties } from '@dnb/eufemia/src/extensions/forms/Field/FieldDocs' +import { SelectCountryProperties } from '@dnb/eufemia/src/extensions/forms/Field/SelectCountry/SelectCountryDocs' ### Field-specific props -| Property | Type | Description | -| ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `help` | `object` | _(optional)_ Provide a help button. Object consisting of `title` and `content`. | -| `countries` | `string` | _(optional)_ List only a certain set of countries: `Scandinavia`, `Nordic`, `Europe` or `Prioritized`(all countries [sorted by priority](/uilib/extensions/forms/feature-fields/SelectCountry/#filter-or-prioritize-country-listing)). Defaults to `Prioritized` | + ### General props diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountryDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountryDocs.ts index fd79c30add2..8a15497bef6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountryDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountryDocs.ts @@ -1,6 +1,15 @@ +import { PropertiesTableProps } from '../../../../shared/types' import { getFieldEventsWithTypes } from '../FieldDocs' -export const selectCountryGeneralEvents = getFieldEventsWithTypes( +export const SelectCountryProperties: PropertiesTableProps = { + countries: { + doc: 'List only a certain set of countries: `Scandinavia`, `Nordic`, `Europe` or `Prioritized`(all countries [sorted by priority](/uilib/extensions/forms/feature-fields/SelectCountry/#filter-or-prioritize-country-listing)). Defaults to `Prioritized`.', + type: 'string', + status: 'optional', + }, +} + +export const SelectCountryGeneralEvents = getFieldEventsWithTypes( { type: 'string', optional: true }, { type: 'object', optional: true } ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/SelectCountry.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/SelectCountry.tsx new file mode 100644 index 00000000000..f2325d85f55 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/SelectCountry.tsx @@ -0,0 +1,48 @@ +import React, { useContext, useMemo } from 'react' +import classnames from 'classnames' +import { useTranslation, useValueProps } from '../../hooks' +import { ValueProps } from '../../types' +import ValueBlock from '../../ValueBlock' +import SharedContext from '../../../../shared/Context' +import { getCountryData } from '../../Field/SelectCountry' +import { CountryLang } from '../../constants/countries' + +export type Props = ValueProps + +function SelectCountry(props: Props) { + const { locale } = useContext(SharedContext) + const translations = useTranslation().SelectCountry + const { + value, + className, + label = translations.label, + ...rest + } = useValueProps(props) + + const countryName = useMemo(() => { + if (!value) { + return null + } + + const lang = locale?.split('-')[0] as CountryLang + return getCountryData({ + lang, + filter: (country) => { + return country.iso === value + }, + }).at(0)?.content + }, [locale, value]) + + return ( + + {countryName} + + ) +} + +SelectCountry._supportsSpacingProps = true +export default SelectCountry diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/__tests__/SelectCountry.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/__tests__/SelectCountry.test.tsx new file mode 100644 index 00000000000..6b8cf409c1b --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/__tests__/SelectCountry.test.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { screen, render } from '@testing-library/react' +import { Value, Form } from '../../..' + +describe('Value.SelectCountry', () => { + it('renders string values', () => { + render() + + expect( + document.querySelector( + '.dnb-forms-value-select-country .dnb-forms-value-block__content' + ) + ).toHaveTextContent('Norge') + }) + + it('renders label when showEmpty is true', () => { + render() + expect(document.querySelector('.dnb-form-label')).toHaveTextContent( + 'My label' + ) + }) + + it('renders value and label', () => { + render() + expect( + document.querySelector( + '.dnb-forms-value-select-country .dnb-forms-value-block__content' + ) + ).toHaveTextContent('Norge') + + expect(document.querySelector('.dnb-form-label')).toHaveTextContent( + 'My selections' + ) + }) + + it('renders custom label', () => { + render() + expect(document.querySelector('.dnb-form-label')).toHaveTextContent( + 'Custom label' + ) + }) + + it('renders placeholder', () => { + render() + expect(screen.getByText('Please select a value')).toBeInTheDocument() + }) + + it('renders value from path', () => { + render( + + + + ) + + expect( + document.querySelector( + '.dnb-forms-value-select-country .dnb-forms-value-block__content' + ) + ).toHaveTextContent('Sveits') + }) + + it('formats value in different locale', () => { + render( + + + + ) + + expect( + document.querySelector( + '.dnb-forms-value-select-country .dnb-forms-value-block__content' + ) + ).toHaveTextContent('Switzerland') + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/index.ts b/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/index.ts new file mode 100644 index 00000000000..4c9671dce76 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/index.ts @@ -0,0 +1,2 @@ +export { default } from './SelectCountry' +export * from './SelectCountry' diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/stories/SelectCountry.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/stories/SelectCountry.stories.tsx new file mode 100644 index 00000000000..acdf197b94b --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Value/SelectCountry/stories/SelectCountry.stories.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { Field, Form, Value } from '../../..' +import { Flex } from '../../../../../components' + +export default { + title: 'Eufemia/Extensions/Forms/Value/SelectCountry', +} + +export function SelectCountryValue() { + return ( + + + + + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/index.ts b/packages/dnb-eufemia/src/extensions/forms/Value/index.ts index 4469a10abd0..ea148918129 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Value/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Value/index.ts @@ -12,5 +12,6 @@ export { default as BankAccountNumber } from './BankAccountNumber' export { default as OrganizationNumber } from './OrganizationNumber' export { default as SummaryList } from './SummaryList' export { default as Composition } from './Composition' +export { default as SelectCountry } from './SelectCountry' export { default as ArraySelection } from './ArraySelection' export { default as Selection } from './Selection' From 645a7b3a96c551ba927ecbb2e824a9d71db67130 Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes Date: Wed, 28 Aug 2024 11:03:40 +0200 Subject: [PATCH 10/17] fix(InfinityScroller): forward load button props (#3737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tobias Høegh --- .../components/pagination/properties.mdx | 1 + .../src/components/pagination/Pagination.d.ts | 34 ++++++++++- .../src/components/pagination/Pagination.js | 2 + .../pagination/PaginationInfinity.js | 30 ++++++++-- .../pagination/__tests__/Pagination.test.tsx | 56 +++++++++++++++++++ 5 files changed, 116 insertions(+), 7 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/pagination/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/pagination/properties.mdx index 553df00697a..7577804f262 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/pagination/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/pagination/properties.mdx @@ -32,6 +32,7 @@ showTabs: true | `more_pages` | _(optional)_ The title used in the dots. Relevant for screen-readers. Defaults to `%s flere sider`. | | `is_loading_text` | _(optional)_ Shown until new content is inserted in to the page. Defaults to `Laster nytt innhold`. | | `load_button_text` | _(optional)_ Used during infinity mode. If `use_load_button` is set to `true`, then a button is show on the bottom. If the `startup_page` is higher than 1. Defaults to `Vis mer innhold`. | +| `loadButton` | _(optional)_ Used to set load button text and icon aligment. Accepts a function returning a ReactNode too, so you can replace the button with your own component. | | `disabled` | _(optional)_ if set to `true`, all pagination bar buttons are disabled. | | `skeleton` | _(optional)_ if set to `true`, an overlaying skeleton with animation will be shown. | | [Space](/uilib/layout/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. | diff --git a/packages/dnb-eufemia/src/components/pagination/Pagination.d.ts b/packages/dnb-eufemia/src/components/pagination/Pagination.d.ts index ac55d2d1725..167e199f922 100644 --- a/packages/dnb-eufemia/src/components/pagination/Pagination.d.ts +++ b/packages/dnb-eufemia/src/components/pagination/Pagination.d.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import type { SkeletonShow } from '../Skeleton'; import type { SpacingProps } from '../space/types'; import PaginationBar from './PaginationBar'; +import { ButtonIconPosition } from '../Button'; type PaginationStartupPage = string | number; type PaginationCurrentPage = string | number; type PaginationPageCount = string | number; @@ -34,6 +35,20 @@ type PaginationIndicatorElement = | ((...args: any[]) => any) | string; type PaginationChildren = React.ReactNode | ((...args: any[]) => any); + +type LoadButtonProps = + | (() => React.ReactNode) + | { + /** + * Used during infinity mode. If `use_load_button` is set to true, then a button is show on the bottom. If the `startup_page` is higher than 1. Defaults to `Vis mer innhold`. + */ + text: string; + /** + * Used during infinity mode. Sets the icon position on the `use_load_button`. Default: `left`. + */ + iconPosition: ButtonIconPosition; + }; + export interface PaginationProps extends Omit, 'ref'>, SpacingProps { @@ -143,9 +158,14 @@ export interface PaginationProps */ is_loading_text?: string; /** - * Used during infinity mode. If `use_load_button` is set to `true`, then a button is show on the bottom. If the `startup_page` is higher than 1. Defaults to `Vis mer innhold`. + * Used during infinity mode. If `use_load_button` is set to true, then a button is show on the bottom. If the `startup_page` is higher than 1. Defaults to `Vis mer innhold`. + * @deprecated use `loadButtonProps.text` instead */ load_button_text?: string; + /** + * Used to set loadButton text and icon aligment. Accepts a function returning a ReactNode too, so you can replace the button with your own component. + */ + loadButton?: LoadButtonProps; className?: string; /** * The given content can be either a function or a React node, depending on your needs. A function contains several helper functions. More details down below and have a look at the examples in the demos section. @@ -217,6 +237,7 @@ type PaginationInstanceIndicatorElement = type PaginationInstanceChildren = | React.ReactNode | ((...args: any[]) => any); + interface PaginationInstanceProps extends SpacingProps { /** * The page shown in the very beginning. If `current_page` is set, then it may not make too much sense to set this as well. @@ -325,9 +346,13 @@ interface PaginationInstanceProps extends SpacingProps { is_loading_text?: string; /** * Used during infinity mode. If `use_load_button` is set to true, then a button is show on the bottom. If the `startup_page` is higher than 1. Defaults to `Vis mer innhold`. + * @deprecated use `loadButtonProps.text` instead */ load_button_text?: string; - className?: string; + /** + * Used to set loadButton text and icon aligment. Accepts a function returning a ReactNode too, so you can replace the button with your own component. + */ + loadButton?: LoadButtonProps; /** * The given content can be either a function or a React node, depending on your needs. A function contains several helper functions. More details down below and have a look at the examples in the demos section. */ @@ -500,8 +525,13 @@ interface InfinityMarkerProps extends SpacingProps { is_loading_text?: string; /** * Used during infinity mode. If `use_load_button` is set to true, then a button is show on the bottom. If the `startup_page` is higher than 1. Defaults to `Vis mer innhold`. + * @deprecated use `loadButtonProps.text` instead */ load_button_text?: string; + /** + * Used to set loadButton text and icon aligment. Accepts a function returning a ReactNode too, so you can replace the button with your own component. + */ + loadButton?: LoadButtonProps; className?: string; /** * The given content can be either a function or a React node, depending on your needs. A function contains several helper functions. More details down below and have a look at the examples in the demos section. diff --git a/packages/dnb-eufemia/src/components/pagination/Pagination.js b/packages/dnb-eufemia/src/components/pagination/Pagination.js index d2d9e72141d..55d86d3c96b 100644 --- a/packages/dnb-eufemia/src/components/pagination/Pagination.js +++ b/packages/dnb-eufemia/src/components/pagination/Pagination.js @@ -92,6 +92,7 @@ const paginationPropTypes = { more_pages: PropTypes.string, is_loading_text: PropTypes.string, load_button_text: PropTypes.string, + loadButton: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), ...spacingPropTypes, @@ -125,6 +126,7 @@ const paginationDefaultProps = { more_pages: null, is_loading_text: null, load_button_text: null, + loadButton: null, startup_count: 1, parallel_load_count: 1, place_maker_before_content: false, diff --git a/packages/dnb-eufemia/src/components/pagination/PaginationInfinity.js b/packages/dnb-eufemia/src/components/pagination/PaginationInfinity.js index cbb435b8d11..509644d5620 100644 --- a/packages/dnb-eufemia/src/components/pagination/PaginationInfinity.js +++ b/packages/dnb-eufemia/src/components/pagination/PaginationInfinity.js @@ -282,6 +282,8 @@ export default class InfinityScroller extends React.PureComponent { fallback_element, marker_element, indicator_element, + load_button_text, + loadButton, } = this.context.pagination // invoke startup if needed @@ -351,8 +353,14 @@ export default class InfinityScroller extends React.PureComponent { pageNumber > 1 && pageNumber <= startupPage && ( this.getNewContent(pageNumber - 1, { position: 'before', @@ -379,7 +387,13 @@ export default class InfinityScroller extends React.PureComponent { (typeof pageCount === 'undefined' || pageNumber < pageCount) && ( this.getNewContent(pageNumber + 1, { @@ -528,11 +542,15 @@ export class InfinityLoadButton extends React.PureComponent { ]), icon: PropTypes.string.isRequired, on_click: PropTypes.func.isRequired, + text: PropTypes.string, + icon_position: PropTypes.string, } static defaultProps = { element: 'div', pressed_element: null, icon: 'arrow_down', + text: null, + icon_position: 'left', } state = { isPressed: false } onClickHandler = (e) => { @@ -542,7 +560,7 @@ export class InfinityLoadButton extends React.PureComponent { } } render() { - const { element, icon } = this.props + const { element, icon, text, icon_position } = this.props const Element = element const ElementChild = isTrElement(Element) ? 'td' : 'div' @@ -554,8 +572,10 @@ export class InfinityLoadButton extends React.PureComponent { + )} + /> + ) + + await waitForComponent() + + const loadButton = document.querySelector( + '.my-cool-button' + ) as HTMLButtonElement + + expect(loadButton).toHaveTextContent('The best load button') + expect(loadButton.tagName).toBe('BUTTON') + }) }) describe('Pagination ARIA', () => { From f05bdd2d8f35fbc37580c5089ee00d6812fe2572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Wed, 28 Aug 2024 15:21:32 +0200 Subject: [PATCH 11/17] feat(Forms): deprecate filterSubmitData in Form.Handler and return `filterData` in onSubmit and onChange instead (#3873) The property `filterSubmitData` on the `Form.Handler` was a way to define a filter, so data returned in the `onSubmit` got filtered. But I think we rather should return the filter function and let devs do it right where it happens. We do that already in `useData` and in `getData` that way. And now we also return `filterData` in the `onChange` event. The motivation is to keep consistency and rather offer declarative ways of defining filters. --------- Co-authored-by: Anders --- .../forms/DataContext/Provider/events.mdx | 21 -- .../forms/Form/Handler/Examples.tsx | 5 +- .../extensions/forms/Form/Handler/demos.mdx | 6 +- .../extensions/forms/Form/Handler/info.mdx | 40 ++- .../forms/Form/getData/Examples.tsx | 24 +- .../extensions/forms/Form/getData/demos.mdx | 2 +- .../extensions/forms/Form/getData/info.mdx | 2 +- .../forms/Form/useData/Examples.tsx | 12 +- .../extensions/forms/Form/useData/info.mdx | 2 +- .../extensions/forms/Iterate/Array/info.mdx | 7 +- .../extensions/forms/getting-started.mdx | 26 +- .../extensions/forms/DataContext/Context.ts | 3 +- .../forms/DataContext/Provider/Provider.tsx | 75 ++--- .../DataContext/Provider/ProviderDocs.ts | 9 +- .../Provider/__tests__/Provider.test.tsx | 272 ++++++++++++------ .../Provider/stories/Provider.stories.tsx | 5 +- .../__tests__/Indeterminate.test.tsx | 63 ++-- .../__tests__/PhoneNumber.test.tsx | 5 +- .../Field/String/__tests__/String.test.tsx | 10 +- .../Field/Upload/__tests__/Upload.test.tsx | 241 +++++++++------- .../extensions/forms/Form/Handler/Handler.tsx | 2 - .../Form/Handler/__tests__/Handler.test.tsx | 57 ++-- .../forms/Form/Isolation/Isolation.tsx | 1 - .../forms/Form/Isolation/IsolationDocs.ts | 1 - .../Isolation/__tests__/Isolation.test.tsx | 210 +++++++++----- .../extensions/forms/Form/Section/Section.tsx | 2 +- .../Form/Section/__tests__/Section.test.tsx | 240 ++++++++++------ .../forms/Form/data-context/useData.tsx | 23 +- .../Iterate/Array/__tests__/Array.test.tsx | 121 +++++--- .../__tests__/PushContainer.test.tsx | 17 +- .../__tests__/WizardContainer.test.tsx | 23 +- .../hooks/__tests__/useFieldProps.test.tsx | 10 +- .../dnb-eufemia/src/extensions/forms/types.ts | 11 +- 33 files changed, 976 insertions(+), 572 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/DataContext/Provider/events.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/DataContext/Provider/events.mdx index 8cf72920e86..2b182a536b8 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/DataContext/Provider/events.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/DataContext/Provider/events.mdx @@ -8,24 +8,3 @@ import { ProviderEvents } from '@dnb/eufemia/src/extensions/forms/DataContext/Pr ## Events - -### onSubmit parameters - -The `onSubmit` event returns additional methods to can call: - -- `resetForm` Deletes `sessionStorage` and browser stored autocomplete data. -- `clearData` Empties the given/internal data set. - -```jsx -render( - { - resetForm() - clearData() - }} - sessionStorageId="session-key" - > - - , -) -``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/Examples.tsx index b3506e99bcd..a1aa1a1ab22 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/Examples.tsx @@ -293,8 +293,9 @@ export const FilterData = () => { return ( console.log('onSubmit', data)} - filterSubmitData={filterDataHandler} + onSubmit={(data, { filterData }) => { + console.log('onSubmit', filterData(filterDataHandler)) + }} > diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/demos.mdx index 1450f96debb..29ad9fd2c71 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/demos.mdx @@ -38,14 +38,12 @@ This example is only for demo purpose and will NOT redirect to a new location. I ### Filter your data -By using the `filterSubmitData` prop you can filter out data that you don't want to send to your server. +By using the `filterData` method from the `onSubmit` event callback you can filter out data that you don't want to send to your server. -It will filter out data from the `onSubmit` event property. +More info about `filterData` can be found in the [Getting Started](/uilib/extensions/forms/getting-started/#filter-data) section. In this example we filter out all fields that are disabled. -More info about `filterData` can be found in the [Getting Started](/uilib/extensions/forms/getting-started/#filter-data) section. - ### With session storage diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx index 2fb573c5adb..ae0d53d882c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/info.mdx @@ -129,7 +129,7 @@ It will flush the storage once the form gets submitted. ## Filter data -You can use the `filterSubmitData` method to filter your `onSubmit` data. It might be useful, for example, to **exclude disabled fields** or filter out empty fields. The callback function receives the following arguments: +You can use the `filterData` method to filter your `onSubmit` data. It might be useful, for example, to **exclude disabled fields** or filter out empty fields. The callback function receives the following arguments: 1. `path` as the first argument. 2. `value` as the second argument. @@ -142,8 +142,42 @@ It returns the filtered form data. The [useData](/uilib/extensions/forms/Form/useData/#filter-data) hook and the [getData](/uilib/extensions/forms/Form/getData/#filter-data) method also returns a `filterData` function you can use to filter data the same way. -In the demo section is an example of how to use the `filterSubmitData` method. +In the demo section is an example of how to use the `filterData` method. ### Filter arrays -You can filter arrays by using the `filterSubmitData` method. You can find more information about this in the [Iterate.Array](/uilib/extensions/forms/Iterate/Array/#filter-data) docs. +You can filter arrays by using the `filterData` method. You can find more information about this in the [Iterate.Array](/uilib/extensions/forms/Iterate/Array/#filter-data) docs. + +### onSubmit parameters + +The `onSubmit` event returns additional methods you can call: + +- `filterData` Filters the given/internal data set. +- `resetForm` Deletes `sessionStorage` and browser stored autocomplete data. +- `clearData` Empties the given/internal data set. + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' + +const myFilter = { + '/myPath': (value) => { + return value.length > 0 + }, +} + +const MyForm = () => { + return ( + { + resetForm() + clearData() + + const myData = filterData(myFilter) + }} + sessionStorageId="session-key" + > + + + ) +} +``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/Examples.tsx index acb67db88d9..4e8f20ff7b0 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/Examples.tsx @@ -1,7 +1,7 @@ import React from 'react' import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Section } from '@dnb/eufemia/src' -import { Form, Field, Value } from '@dnb/eufemia/src/extensions/forms' +import { Flex, Section } from '@dnb/eufemia/src' +import { Form, Field } from '@dnb/eufemia/src/extensions/forms' export function Default() { return ( @@ -31,7 +31,11 @@ export function FilterData() { {() => { // Method A (if you know the paths) const filterDataPaths = { - '/foo': ({ value }) => value !== 'bar', + '/foo': ({ value }) => { + if (value === 'foo') { + return false + } + }, } // Method B (will iterate over all fields regardless of the path) @@ -44,24 +48,24 @@ export function FilterData() { const Component = () => { return ( - {' '} - + + + + ) } - const { data, filterData } = Form.getData('filter-data') + const { filterData } = Form.getData('filter-data') return ( - <> + -
-
{JSON.stringify(data)}
{JSON.stringify(filterData(filterDataPaths))}
{JSON.stringify(filterData(filterDataHandler))}
- +
) }} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/demos.mdx index 95cdad8d186..d03c4dca564 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/demos.mdx @@ -10,6 +10,6 @@ import * as Examples from './Examples' -### Filter data +### Filter your data diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx index f83a3965526..91fa594d637 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/getData/info.mdx @@ -39,7 +39,7 @@ Related helpers: You can use the `filterData` method to filter your data. -You simply give it the same kind of callback function as you would with the `Form.Handler` [filterSubmitData](/uilib/extensions/forms/Form/Handler/demos/#filter-your-data) property method. +You simply give it the [same kind of filter](/uilib/extensions/forms/Form/Handler/demos/#filter-your-data) as you would within the `onSubmit` event callback. The callback function receives the following arguments: diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/Examples.tsx index 4beaa57c8d8..aa0ff9cb98f 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/Examples.tsx @@ -151,9 +151,9 @@ export function FilterData() { />
- - + + ) } @@ -170,9 +170,13 @@ export function FilterData() { >
-                  Filtered: {JSON.stringify(filterData(filterDataPaths))}
+                  Filtered: 
+ {JSON.stringify(filterData(filterDataPaths), null, 2)} +
+
+                  All data: 
+ {JSON.stringify(data, null, 2)}
-
All data: {JSON.stringify(data)}
) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx index 842b73cb3e2..c6be2a7cffb 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useData/info.mdx @@ -135,7 +135,7 @@ function MyForm() { You can use the `filterData` method to filter your data. Check out [the example below](#filter-your-data). -You simply give it the same kind of callback function as you would with the `Form.Handler` [filterSubmitData](/uilib/extensions/forms/Form/Handler/demos/#filter-your-data) property method. +You simply give it the [same kind of filter](/uilib/extensions/forms/Form/Handler/demos/#filter-your-data) as you would within the `onSubmit` event callback. The callback function receives the following arguments: 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 8279bbd2851..3887fd0aa60 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 @@ -102,20 +102,21 @@ In the example below, the data given in `onSubmit` will still have "foo2" and "b ```tsx import { Iterate, Form, Field } from '@dnb/eufemia/extensions/forms' -const filterSubmitData = { +const myFilter = { '/myList/0': false, } render( { + console.log('onSubmit', filterData(myFilter)) + }} > 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 bef73dfe2a3..87929c54d26 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 @@ -139,9 +139,7 @@ As you can see in the code above, you can even handle the state outside the `For #### Filter data -The internal data will always contain all the fields, even if they are not visible. But you may want to filter out some data based on your own logic. - -You can filter data by any given criteria. This is done by utilizing the `filterData` method from e.g.: +In this seciton we will show how to filter out some data based on your own logic. You can filter data by any given criteria. This is done by utilizing the `filterData` method from e.g.: - [useData](/uilib/extensions/forms/Form/useData/#filter-data) hook. - [getData](/uilib/extensions/forms/Form/getData/#filter-data) method. @@ -149,7 +147,9 @@ You can filter data by any given criteria. This is done by utilizing the `filter You can provide either a function handler or an object with the paths (JSON Pointer) you want to filter out. -Return `false` to exclude an entry: +Return `false` to exclude an entry. + +Here is an example of how to filter data outlining the different ways to filter data: ```tsx // 1. Method – by using paths (JSON Pointer) @@ -186,7 +186,23 @@ You may check out an [interactive example](/uilib/extensions/forms/Form/useData/ ##### Filter data during submit -For filtering data during form submit (`onSubmit`), you can use the `filterSubmitData` property from: +For filtering data during form submit (`onSubmit`), you can use the `filterData` method given as a parameter to the `onSubmit` event callback: + +```tsx +const onSubmit = (data, { filterData }) => { + // Same method as in the previous example + const filteredDataA = filterData(filterDataPaths) + const filteredDataB = filterData(filterDataHandler) + console.log(filteredDataA) + console.log(filteredDataB) +} + +render( + + + , +) +``` - [Form.Handler](/uilib/extensions/forms/Form/Handler/) diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index 24a17bc8860..551e61830bd 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -11,6 +11,7 @@ import { FieldProps, FormError, ValueProps, + OnChange, } from '../types' import { Props as ProviderProps } from './Provider' @@ -94,7 +95,7 @@ export interface ContextState { handleUnMountField: (path: Path) => void setFormState?: (state: SubmitState) => void setSubmitState?: (state: EventStateObject) => void - addOnChangeHandler?: (callback: (data: unknown) => void) => void + addOnChangeHandler?: (callback: OnChange) => void handleSubmitCall: ({ onSubmit, enableAsyncBehaviour, 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 04bca7dab56..3b66092cc00 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -39,6 +39,7 @@ import Context, { ContextState, EventListenerCall, FilterData, + FilterDataHandler, HandleSubmitCallback, TransformData, } from '../Context' @@ -84,13 +85,9 @@ export interface Props */ errorMessages?: CustomErrorMessagesWithPaths /** - * Filter the `onSubmit` output data, based on your criteria: `({ path, value, data, props, internal }) => !props?.disabled`. It will iterate on each data entry (/path). Return false to exclude the entry. + * @deprecated Use the `filterData` in the second event parameter in the `onSubmit` or `onChange` events. */ filterSubmitData?: FilterData - /** - * @deprecated Use `filterSubmitData` instead - */ - filterData?: FilterData /** * Transform the data context (internally as well) based on your criteria: `({ path, value, data, props, internal }) => 'new value'`. It will iterate on each data entry (/path). */ @@ -198,7 +195,6 @@ export default function Provider( transformIn, transformOut, filterSubmitData, - filterData, locale, translations, required, @@ -472,14 +468,21 @@ export default function Provider( * Filter the data set based on the filterData function */ const filterDataHandler = useCallback( - (data: Data, filter = filterData || filterSubmitData) => { + (data: Data, filter: FilterData) => { if (filter) { return mutateDataHandler(data, filter, true) } return data }, - [filterData, filterSubmitData, mutateDataHandler] + [mutateDataHandler] + ) + + const filterData = useCallback( + (filter: FilterData, data = internalDataRef.current) => { + return filterDataHandler(data, filter) + }, + [filterDataHandler] ) const fieldPropsRef = useRef>({}) @@ -512,7 +515,7 @@ export default function Provider( // - Shared state const sharedData = useSharedState(id) const sharedAttachments = useSharedState<{ - filterDataHandler?: Props['filterSubmitData'] + filterDataHandler?: FilterDataHandler hasErrors?: ContextState['hasErrors'] hasFieldError?: ContextState['hasFieldError'] setShowAllErrors?: ContextState['setShowAllErrors'] @@ -629,13 +632,12 @@ export default function Provider( setShowAllErrors, setSubmitState, }) - if (filterData || filterSubmitData) { + if (filterSubmitData) { rerenderUseDataHook?.() } } }, [ extendAttachment, - filterData, filterDataHandler, filterSubmitData, hasErrors, @@ -698,7 +700,7 @@ export default function Provider( if (id) { // Will ensure that Form.getData() gets the correct data extendSharedData?.(newData) - if (filterData || filterSubmitData) { + if (filterSubmitData) { rerenderUseDataHook?.() } } @@ -711,7 +713,6 @@ export default function Provider( }, [ extendSharedData, - filterData, filterSubmitData, id, mutateDataHandler, @@ -766,25 +767,27 @@ export default function Provider( validateData() const data = internalDataRef.current as Data + const options = { filterData } const transformedData = transformOut ? mutateDataHandler(data, transformOut) : data for (const cb of changeHandlerStackRef.current) { if (isAsync(onChange)) { - await cb(transformedData) + await cb(transformedData, options) } else { - cb(transformedData) + cb(transformedData, options) } } if (isAsync(onChange)) { - return await onChange(transformedData) + return await onChange(transformedData, options) } - return onChange?.(transformedData) + return onChange?.(transformedData, options) }, [ + filterData, handlePathChangeUnvalidated, mutateDataHandler, onChange, @@ -794,17 +797,14 @@ export default function Provider( ) const changeHandlerStackRef = useRef>>([]) - const addOnChangeHandler = useCallback( - (callback: (data: unknown) => void) => { - const exists = changeHandlerStackRef.current.some((cb) => { - return callback === cb - }) - if (!exists) { - changeHandlerStackRef.current.push(callback) - } - }, - [] - ) + const addOnChangeHandler = useCallback((callback: OnChange) => { + const exists = changeHandlerStackRef.current.some((cb) => { + return callback === cb + }) + if (!exists) { + changeHandlerStackRef.current.push(callback) + } + }, []) // - Mounted fields const handleMountField = useCallback((path: Path) => { @@ -993,10 +993,14 @@ export default function Provider( // - Mutate the data context const data = internalDataRef.current - const filteredData = filterDataHandler( - transformOut ? mutateDataHandler(data, transformOut) : data - ) - const opts = { + const mutatedData = transformOut + ? mutateDataHandler(data, transformOut) + : data + const filteredData = filterSubmitData + ? filterDataHandler(mutatedData, filterSubmitData) + : mutatedData // @deprecated – can be removed in v11 + const options = { + filterData, resetForm: () => { formElement?.reset?.() @@ -1014,9 +1018,9 @@ export default function Provider( let result = undefined if (isAsync(onSubmit)) { - result = await onSubmit(filteredData, opts) + result = await onSubmit(filteredData, options) } else { - result = onSubmit?.(filteredData, opts) + result = onSubmit?.(filteredData, options) } const completeResult = await onSubmitComplete?.( @@ -1040,8 +1044,11 @@ export default function Provider( }, [ clearData, + filterData, filterDataHandler, + filterSubmitData, handleSubmitCall, + handleSubmitListeners, mutateDataHandler, onSubmit, onSubmitComplete, diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts index e25ee2bce63..64749c9f220 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/ProviderDocs.ts @@ -61,11 +61,6 @@ export const ProviderProperties: PropertiesTableProps = { type: 'function', status: 'optional', }, - filterSubmitData: { - doc: 'Filter the `onSubmit` output data, based on your criteria: `({ path, value, data, props, internal }) => !props?.disabled`. It will iterate on each data entry (/path). Return false to exclude the entry.', - type: 'function', - status: 'optional', - }, globalStatusId: { doc: 'If needed, you can define a custom [GlobalStatus](/uilib/components/global-status) id. Defaults to `main`.', type: 'string', @@ -95,7 +90,7 @@ export const ProviderProperties: PropertiesTableProps = { export const ProviderEvents: PropertiesTableProps = { onChange: { - doc: "Will be called when a value of a field was changed by the user, with the data set (including the changed value) as argument. When an async function is provided, it will show an indicator on the current label during a field change. Related props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`. You can return an error or an object with these keys `{ info: 'Info message', warning: 'Warning message', error: Error('My error') } as const` in addition to { success: 'saved' } indicate the field was saved. Will emit unvalidated by default and validated when an async function is provided (like `onSubmit`).", + doc: "Will be called when a value of a field was changed by the user, with the data set (including the changed value) as argument. When an async function is provided, it will show an indicator on the current label during a field change. Related props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`. You can return an error or an object with these keys `{ info: 'Info message', warning: 'Warning message', error: Error('My error') } as const` in addition to { success: 'saved' } indicate the field was saved. Will emit unvalidated by default and validated when an async function is provided (like `onSubmit`). The second parameter is an object containing the `filterData`, `resetForm` and `clearData` functions.", type: 'function', status: 'optional', }, @@ -105,7 +100,7 @@ export const ProviderEvents: PropertiesTableProps = { status: 'optional', }, onSubmit: { - doc: "Will be called (on validation success) when the user submit the form (i.e by clicking a [SubmitButton](/uilib/extensions/forms/Form/SubmitButton) component inside), with the data set as argument. When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`. You can return an error or an object with these keys `{ status: 'pending', info: 'Info message', warning: 'Warning message', error: Error('My error') } as const` to be shown in a [FormStatus](/uilib/components/form-status). Will only emit when every validation has passed.", + doc: "Will be called (on validation success) when the user submit the form (i.e by clicking a [SubmitButton](/uilib/extensions/forms/Form/SubmitButton) component inside), with the data set as argument. When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`. You can return an error or an object with these keys `{ status: 'pending', info: 'Info message', warning: 'Warning message', error: Error('My error') } as const` to be shown in a [FormStatus](/uilib/components/form-status). Will only emit when every validation has passed. The second parameter is an object containing the `filterData` function.", type: 'function', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx index 9dafb184a68..cab8d95b955 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx @@ -20,6 +20,7 @@ import { Ajv, OnChange, DataValueWriteProps, + OnSubmit, } from '../../../' import { isCI } from 'repo-utils' import { Props as StringFieldProps } from '../../../Field/String' @@ -187,7 +188,10 @@ describe('DataContext.Provider', () => { }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith({ foo: 'New Value' }) + expect(onChange).toHaveBeenCalledWith( + { foo: 'New Value' }, + expect.anything() + ) rerender( { }) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ fooBar: 'Second Value' }) + expect(onChange).toHaveBeenLastCalledWith( + { fooBar: 'Second Value' }, + expect.anything() + ) }) it('should update data context with initially given "value"', () => { const onChange = jest.fn() - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() render( { expect(onSubmit).toHaveBeenCalledTimes(1) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith({ - foo: 'New Value', - other: 'original', - }) + expect(onChange).toHaveBeenCalledWith( + { + foo: 'New Value', + other: 'original', + }, + expect.anything() + ) }) it('should work without any data provided, using an empty object as default when pointing to an object subkey', () => { @@ -262,7 +272,10 @@ describe('DataContext.Provider', () => { }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith({ foo: 'New Value' }) + expect(onChange).toHaveBeenCalledWith( + { foo: 'New Value' }, + expect.anything() + ) }) it('should work without any data provided, using an empty array as default when pointing to an array index subkey', () => { @@ -281,7 +294,10 @@ describe('DataContext.Provider', () => { }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith([{ foo: 'New Value' }]) + expect(onChange).toHaveBeenCalledWith( + [{ foo: 'New Value' }], + expect.anything() + ) }) it('should call async "onPathChange" on path change', () => { @@ -333,7 +349,7 @@ describe('DataContext.Provider', () => { }) it('should call "onSubmit" on submit', async () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() const { rerender } = render( @@ -427,16 +443,16 @@ describe('DataContext.Provider', () => { await userEvent.type(element, '1') expect(onChangeSync).toHaveBeenCalledTimes(1) - expect(onChangeSync).toHaveBeenLastCalledWith({ foo: '1' }) + expect(onChangeSync).toHaveBeenLastCalledWith( + { foo: '1' }, + expect.anything() + ) log.mockRestore() }) - describe('filterSubmitData', () => { - it('should filter data based on the given "filterSubmitData" property paths', () => { - let filteredData = undefined - const onSubmit = jest.fn((data) => (filteredData = data)) - + describe('filterData', () => { + it('should filter data based on the given filterData paths', () => { const fooHandler: FilterDataPathCondition = jest.fn( ({ props }) => { if (props.disabled === true) { @@ -457,11 +473,13 @@ describe('DataContext.Provider', () => { '/bar': barHandler, } + let filteredData = undefined + const onSubmit: OnSubmit = jest.fn((data, { filterData }) => { + filteredData = filterData(filterDataPaths) + }) + const { rerender } = render( - + Submit @@ -477,6 +495,10 @@ describe('DataContext.Provider', () => { { bar: 'bar', foo: 'Include this value' }, expect.anything() ) + expect(filteredData).toEqual({ + bar: 'bar', + foo: 'Include this value', + }) expect(fooHandler).toHaveBeenCalledTimes(1) expect(barHandler).toHaveBeenCalledTimes(1) @@ -495,10 +517,7 @@ describe('DataContext.Provider', () => { }) rerender( - + Submit @@ -514,9 +533,12 @@ describe('DataContext.Provider', () => { expect(onSubmit).toHaveBeenCalledTimes(2) expect(onSubmit).toHaveBeenLastCalledWith( - { bar: 'bar value' }, + { bar: 'bar value', foo: 'Skip this value' }, expect.anything() ) + expect(filteredData).toEqual({ + bar: 'bar value', + }) expect(fooHandler).toHaveBeenCalledTimes(2) expect(barHandler).toHaveBeenCalledTimes(2) @@ -537,21 +559,20 @@ describe('DataContext.Provider', () => { expect(filteredData).toEqual({ bar: 'bar value' }) }) - it('should filter data based on the given "filterSubmitData" property method', () => { - let filteredData = undefined - const onSubmit = jest.fn((data) => (filteredData = data)) - + it('should filter data based on the given filterData method', () => { const filterDataHandler: FilterData = jest.fn(({ props }) => { if (props.disabled === true) { return false } }) + let filteredData = undefined + const onSubmit: OnSubmit = jest.fn((data, { filterData }) => { + return (filteredData = filterData(filterDataHandler)) + }) + const { rerender } = render( - + Submit @@ -567,6 +588,10 @@ describe('DataContext.Provider', () => { { bar: 'bar', foo: 'Include this value' }, expect.anything() ) + expect(filteredData).toEqual({ + bar: 'bar', + foo: 'Include this value', + }) expect(filterDataHandler).toHaveBeenCalledTimes(2) expect(filterDataHandler).toHaveBeenNthCalledWith(1, { @@ -599,10 +624,7 @@ describe('DataContext.Provider', () => { }) rerender( - + Submit @@ -618,9 +640,12 @@ describe('DataContext.Provider', () => { expect(onSubmit).toHaveBeenCalledTimes(2) expect(onSubmit).toHaveBeenLastCalledWith( - { bar: 'bar value' }, + { bar: 'bar value', foo: 'Skip this value' }, expect.anything() ) + expect(filteredData).toEqual({ + bar: 'bar value', + }) expect(filterDataHandler).toHaveBeenCalledTimes(4) expect(filterDataHandler).toHaveBeenNthCalledWith(3, { @@ -656,7 +681,7 @@ describe('DataContext.Provider', () => { }) it('"filterSubmitData" should not mutate internal data', async () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() const onChange = jest.fn() const filterDataHandler: FilterData = jest.fn(({ value }) => { @@ -697,7 +722,10 @@ describe('DataContext.Provider', () => { expect(filteredData).toMatchObject({ myField: 'remove m' }) expect(originalData).toMatchObject({ myField: 'remove m' }) expect(onChange).toHaveBeenCalledTimes(8) - expect(onChange).toHaveBeenLastCalledWith({ myField: 'remove m' }) + expect(onChange).toHaveBeenLastCalledWith( + { myField: 'remove m' }, + expect.anything() + ) fireEvent.click(submitButton) expect(onSubmit).toHaveBeenCalledTimes(1) @@ -705,20 +733,68 @@ describe('DataContext.Provider', () => { { myField: 'remove m' }, expect.anything() ) + expect(filteredData).toEqual({ + myField: 'remove m', + }) await userEvent.type(input, 'e') expect(filteredData).toMatchObject({}) expect(originalData).toMatchObject({ myField: 'remove me' }) expect(onChange).toHaveBeenCalledTimes(9) - expect(onChange).toHaveBeenLastCalledWith({ myField: 'remove me' }) + expect(onChange).toHaveBeenLastCalledWith( + { myField: 'remove me' }, + expect.anything() + ) fireEvent.click(submitButton) expect(onSubmit).toHaveBeenCalledTimes(2) expect(onSubmit).toHaveBeenLastCalledWith({}, expect.anything()) + expect(filteredData).toEqual({}) + }) + + it('onChange should return filterData', async () => { + const filterDataHandler: FilterData = jest.fn(({ value }) => { + if (value === 'remove me') { + return false + } + }) + + let filteredData = undefined + const onChange: OnChange = jest.fn((data, { filterData }) => { + filteredData = filterData(filterDataHandler) + }) + + render( + + + Submit + + ) + + const input = document.querySelector('input') + + await userEvent.type(input, 'remove m') + expect(onChange).toHaveBeenCalledTimes(8) + expect(onChange).toHaveBeenLastCalledWith( + { myField: 'remove m' }, + expect.anything() + ) + expect(filteredData).toMatchObject({ myField: 'remove m' }) + + await userEvent.type(input, 'e') + expect(onChange).toHaveBeenCalledTimes(9) + expect(onChange).toHaveBeenLastCalledWith( + { myField: 'remove me' }, + expect.anything() + ) + expect(filteredData).toMatchObject({}) }) it('should add and remove fieldProps properly', async () => { - const onSubmit = jest.fn() + let filteredData = undefined + const onSubmit: OnSubmit = jest.fn((data, { filterData }) => { + filteredData = filterData(filterDataHandler) + }) const filterDataHandler = ({ props }) => { return !props['data-exclude-field'] @@ -726,10 +802,7 @@ describe('DataContext.Provider', () => { const MockForm = () => { return ( - + { render() fireEvent.submit(document.querySelector('form')) - expect(onSubmit).toHaveBeenLastCalledWith({}, expect.anything()) + expect(onSubmit).toHaveBeenLastCalledWith( + { + isVisible: undefined, + mySelection: 'less', + myString: 'foo', + }, + expect.anything() + ) + expect(filteredData).toMatchObject({}) await userEvent.click(screen.getByText('Toggle')) fireEvent.submit(document.querySelector('form')) expect(onSubmit).toHaveBeenLastCalledWith( - { mySelection: 'less' }, + { isVisible: true, mySelection: 'less', myString: 'foo' }, expect.anything() ) + expect(filteredData).toMatchObject({ mySelection: 'less' }) await userEvent.click(screen.getByText('More')) fireEvent.submit(document.querySelector('form')) expect(onSubmit).toHaveBeenLastCalledWith( - { - mySelection: 'more', - myString: 'foo', - }, + { isVisible: true, mySelection: 'more', myString: 'foo' }, expect.anything() ) + expect(filteredData).toMatchObject({ + mySelection: 'more', + myString: 'foo', + }) await userEvent.click(screen.getByText('Less')) fireEvent.submit(document.querySelector('form')) expect(onSubmit).toHaveBeenLastCalledWith( - { mySelection: 'less' }, + { isVisible: true, mySelection: 'less', myString: 'foo' }, expect.anything() ) + expect(filteredData).toMatchObject({ mySelection: 'less' }) await userEvent.click(screen.getByText('Toggle')) fireEvent.submit(document.querySelector('form')) - expect(onSubmit).toHaveBeenLastCalledWith({}, expect.anything()) + expect(onSubmit).toHaveBeenLastCalledWith( + { isVisible: false, mySelection: 'less', myString: 'foo' }, + expect.anything() + ) + expect(filteredData).toMatchObject({}) }) }) @@ -958,7 +1046,7 @@ describe('DataContext.Provider', () => { }) it('should abort async submit onSubmit using asyncSubmitTimeout', async () => { - const onSubmit = jest.fn().mockImplementation(async () => { + const onSubmit: OnSubmit = jest.fn().mockImplementation(async () => { await wait(30) // ensure we never finish onSubmit before the timeout }) @@ -1110,7 +1198,7 @@ describe('DataContext.Provider', () => { }) it('should evaluate sync validation, such as required, before continue with async validation', async () => { - const onSubmit = jest.fn(async () => { + const onSubmit: OnSubmit = jest.fn(async () => { return { info: 'Info message' } as const }) const validator = jest.fn(async (value) => { @@ -1398,10 +1486,12 @@ describe('DataContext.Provider', () => { }) it('the user should be able to set the form in pending mode while an async validation is on going', async () => { - const onSubmit = jest.fn().mockImplementation(async () => null) + const onSubmit: OnSubmit = jest + .fn() + .mockImplementation(async () => null) const validator = debounceAsync(async (value) => { - await wait(300) + await wait(400) if (value === 'invalid') { return Error('My error') } @@ -1593,7 +1683,10 @@ describe('DataContext.Provider', () => { await waitFor(() => { expect(onChangeContext).toHaveBeenCalledTimes(1) - expect(onChangeContext).toHaveBeenLastCalledWith({ foo: 'valid' }) + expect(onChangeContext).toHaveBeenLastCalledWith( + { foo: 'valid' }, + expect.anything() + ) }) await waitFor(() => { expect(onChangeField).toHaveBeenCalled() @@ -2029,7 +2122,7 @@ describe('DataContext.Provider', () => { }) it('should scroll on top when "scrollTopOnSubmit" is true', async () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() const scrollTo = jest.fn() jest.spyOn(window, 'scrollTo').mockImplementation(scrollTo) @@ -3168,14 +3261,13 @@ describe('DataContext.Provider', () => { return false } }) - const onSubmit = jest.fn() + let filteredData = undefined + const onSubmit: OnSubmit = jest.fn((data, { filterData }) => { + filteredData = filterData(filterDataHandler) + }) const { rerender } = render( - + ) @@ -3184,7 +3276,10 @@ describe('DataContext.Provider', () => { fireEvent.submit(form) expect(onSubmit).toHaveBeenCalledTimes(1) - expect(onSubmit).toHaveBeenLastCalledWith({}, expect.anything()) + expect(onSubmit).toHaveBeenLastCalledWith( + { myField: 'foo' }, + expect.anything() + ) expect(filterDataHandler).toHaveBeenCalledTimes(1) expect(filterDataHandler).toHaveBeenLastCalledWith({ path: '/myField', @@ -3199,13 +3294,10 @@ describe('DataContext.Provider', () => { error: undefined, }, }) + expect(filteredData).toEqual({}) rerender( - + ) @@ -3231,6 +3323,9 @@ describe('DataContext.Provider', () => { error: undefined, }, }) + expect(filteredData).toEqual({ + myField: 'bar', + }) }) it('should only render once', () => { @@ -3915,18 +4010,17 @@ describe('DataContext.Provider', () => { return false } }) - const onSubmit = jest.fn() + let filteredData = undefined + const onSubmit: OnSubmit = jest.fn((data, { filterData }) => { + filteredData = filterData(filterDataHandler) + }) const { result } = renderHook((props = { myField: 'foo' }) => Form.useData(id, props) ) const { rerender } = render( - + ) @@ -3942,7 +4036,10 @@ describe('DataContext.Provider', () => { set: expect.any(Function), }) expect(onSubmit).toHaveBeenCalledTimes(1) - expect(onSubmit).toHaveBeenLastCalledWith({}, expect.anything()) + expect(onSubmit).toHaveBeenLastCalledWith( + { myField: 'foo' }, + expect.anything() + ) expect(filterDataHandler).toHaveBeenCalledTimes(1) expect(filterDataHandler).toHaveBeenLastCalledWith({ path: '/myField', @@ -3957,17 +4054,14 @@ describe('DataContext.Provider', () => { error: undefined, }, }) + expect(filteredData).toEqual({}) act(() => { result.current.set({ myField: 'bar' }) }) rerender( - + ) @@ -4000,13 +4094,12 @@ describe('DataContext.Provider', () => { error: undefined, }, }) + expect(filteredData).toEqual({ + myField: 'bar', + }) rerender( - + ) @@ -4029,6 +4122,7 @@ describe('DataContext.Provider', () => { error: undefined, }, }) + expect(filteredData).toEqual({ myField: 'bar' }) }) describe('context support without id', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/stories/Provider.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/stories/Provider.stories.tsx index 9eaedb46210..3dbf54f8564 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/stories/Provider.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/stories/Provider.stories.tsx @@ -109,8 +109,9 @@ export const FilterData = () => { return ( console.log('onSubmit', data)} - filterSubmitData={filterDataHandler} + onSubmit={(data) => { + console.log('onSubmit', filterDataHandler(data)) + }} > diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Indeterminate/__tests__/Indeterminate.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Indeterminate/__tests__/Indeterminate.test.tsx index 869a3c1defb..faa05159df6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Indeterminate/__tests__/Indeterminate.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Indeterminate/__tests__/Indeterminate.test.tsx @@ -62,7 +62,8 @@ describe('Indeterminate', () => { child2: undefined, child3: undefined, parent: false, - }) + }), + expect.anything() ) expect(child1).toBeChecked() @@ -88,7 +89,8 @@ describe('Indeterminate', () => { child2: 'checked', child3: 'checked', parent: false, - }) + }), + expect.anything() ) await userEvent.click(child1) @@ -115,7 +117,8 @@ describe('Indeterminate', () => { child2: 'unchecked', child3: 'unchecked', parent: false, - }) + }), + expect.anything() ) }) @@ -173,7 +176,8 @@ describe('Indeterminate', () => { child2: 'checked', child3: 'checked', parent: true, - }) + }), + expect.anything() ) await userEvent.click(child2) @@ -198,7 +202,8 @@ describe('Indeterminate', () => { child2: 'checked', child3: 'checked', parent: true, - }) + }), + expect.anything() ) }) @@ -246,7 +251,8 @@ describe('Indeterminate', () => { child1: true, child2: 'custom-on', parent: 'what-ever', - }) + }), + expect.anything() ) await userEvent.click(child2) @@ -268,7 +274,8 @@ describe('Indeterminate', () => { child1: true, child2: 'custom-on', parent: 'what-ever', - }) + }), + expect.anything() ) }) }) @@ -331,7 +338,8 @@ describe('Indeterminate', () => { child2: undefined, child3: undefined, parent: false, - }) + }), + expect.anything() ) expect(child1).toBeChecked() @@ -357,7 +365,8 @@ describe('Indeterminate', () => { child2: 'checked', child3: 'checked', parent: true, - }) + }), + expect.anything() ) await userEvent.click(child1) @@ -384,7 +393,8 @@ describe('Indeterminate', () => { child2: 'unchecked', child3: 'unchecked', parent: true, - }) + }), + expect.anything() ) }) @@ -442,7 +452,8 @@ describe('Indeterminate', () => { child2: 'unchecked', child3: 'unchecked', parent: false, - }) + }), + expect.anything() ) await userEvent.click(child2) @@ -467,7 +478,8 @@ describe('Indeterminate', () => { child2: 'unchecked', child3: 'unchecked', parent: false, - }) + }), + expect.anything() ) }) @@ -515,7 +527,8 @@ describe('Indeterminate', () => { child1: false, child2: 'custom-off', parent: 'you-name-it', - }) + }), + expect.anything() ) await userEvent.click(child2) @@ -537,7 +550,8 @@ describe('Indeterminate', () => { child1: false, child2: 'custom-off', parent: 'you-name-it', - }) + }), + expect.anything() ) }) }) @@ -600,7 +614,8 @@ describe('Indeterminate', () => { child2: undefined, child3: undefined, parent: false, - }) + }), + expect.anything() ) expect(child1).toBeChecked() @@ -626,7 +641,8 @@ describe('Indeterminate', () => { child2: 'checked', child3: 'checked', parent: false, - }) + }), + expect.anything() ) await userEvent.click(child1) @@ -653,7 +669,8 @@ describe('Indeterminate', () => { child2: 'unchecked', child3: 'unchecked', parent: true, - }) + }), + expect.anything() ) }) @@ -711,7 +728,8 @@ describe('Indeterminate', () => { child2: 'checked', child3: 'checked', parent: true, - }) + }), + expect.anything() ) await userEvent.click(child2) @@ -736,7 +754,8 @@ describe('Indeterminate', () => { child2: 'unchecked', child3: 'unchecked', parent: false, - }) + }), + expect.anything() ) }) @@ -784,7 +803,8 @@ describe('Indeterminate', () => { child1: true, child2: 'custom-on', parent: 'what-ever', - }) + }), + expect.anything() ) await userEvent.click(child2) @@ -806,7 +826,8 @@ describe('Indeterminate', () => { child1: false, child2: 'custom-off', parent: 'you-name-it', - }) + }), + expect.anything() ) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx index 0491956c189..c6e090b84fc 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx @@ -378,7 +378,10 @@ describe('Field.PhoneNumber', () => { await userEvent.type(phoneElement, '9999') expect(onChange).toHaveBeenCalledTimes(4) - expect(onChange).toHaveBeenLastCalledWith({ phone: '+47 9999' }) + expect(onChange).toHaveBeenLastCalledWith( + { phone: '+47 9999' }, + expect.anything() + ) }) it('should handle events correctly with initial value', async () => { 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 d9ea4d35690..5278a499511 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 @@ -279,7 +279,10 @@ describe('Field.String', () => { expect(transformOut).toHaveBeenCalledTimes(6) expect(transformOut).toHaveBeenLastCalledWith('ABc') expect(onChangeProvider).toHaveBeenCalledTimes(6) - expect(onChangeProvider).toHaveBeenLastCalledWith({ myField: 'abc' }) + expect(onChangeProvider).toHaveBeenLastCalledWith( + { myField: 'abc' }, + expect.anything() + ) expect(onChangeField).toHaveBeenCalledTimes(6) expect(onChangeField).toHaveBeenLastCalledWith('abc') @@ -291,7 +294,10 @@ describe('Field.String', () => { expect(transformOut).toHaveBeenCalledTimes(12) expect(transformOut).toHaveBeenLastCalledWith('EFG') expect(onChangeProvider).toHaveBeenCalledTimes(12) - expect(onChangeProvider).toHaveBeenLastCalledWith({ myField: 'efg' }) + expect(onChangeProvider).toHaveBeenLastCalledWith( + { myField: 'efg' }, + expect.anything() + ) expect(onChangeField).toHaveBeenCalledTimes(12) expect(onChangeField).toHaveBeenLastCalledWith('efg') }) 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 cf642fef1b1..4259ddfe6a7 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 @@ -204,24 +204,27 @@ describe('Field.Upload', () => { ) expect(onChangeContext).toHaveBeenCalledTimes(1) - expect(onChangeContext).toHaveBeenLastCalledWith({ - myFiles: [ - { - file: file1, - exists: false, - id: expect.anything(), - }, - { - errorMessage: nbShared.Upload.errorLargeFile.replace( - '%size', - '5' - ), - file: file2, - exists: false, - id: expect.anything(), - }, - ], - }) + expect(onChangeContext).toHaveBeenLastCalledWith( + { + myFiles: [ + { + file: file1, + exists: false, + id: expect.anything(), + }, + { + errorMessage: nbShared.Upload.errorLargeFile.replace( + '%size', + '5' + ), + file: file2, + exists: false, + id: expect.anything(), + }, + ], + }, + expect.anything() + ) expect(onChangeField).toHaveBeenCalledTimes(1) expect(onChangeField).toHaveBeenLastCalledWith([ { @@ -298,15 +301,18 @@ describe('Field.Upload', () => { ) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [ - expect.objectContaining({ - exists: false, - file: file1, - id: expect.any(String), - }), - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [ + expect.objectContaining({ + exists: false, + file: file1, + id: expect.any(String), + }), + ], + }, + expect.anything() + ) expect( document.querySelector('.dnb-form-status') @@ -341,15 +347,18 @@ describe('Field.Upload', () => { fireEvent.submit(document.querySelector('form')) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [ - expect.objectContaining({ - exists: false, - file: file1, - id: expect.any(String), - }), - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [ + expect.objectContaining({ + exists: false, + file: file1, + id: expect.any(String), + }), + ], + }, + expect.anything() + ) expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenLastCalledWith( { @@ -364,6 +373,7 @@ describe('Field.Upload', () => { { clearData: expect.any(Function), resetForm: expect.any(Function), + filterData: expect.any(Function), } ) }) @@ -400,19 +410,22 @@ describe('Field.Upload', () => { ) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [ - { - errorMessage: nbShared.Upload.errorLargeFile.replace( - '%size', - '0,2' - ), - file: file1, - exists: false, - id: expect.anything(), - }, - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [ + { + errorMessage: nbShared.Upload.errorLargeFile.replace( + '%size', + '0,2' + ), + file: file1, + exists: false, + id: expect.anything(), + }, + ], + }, + expect.anything() + ) expect( document.querySelector( @@ -443,7 +456,10 @@ describe('Field.Upload', () => { fireEvent.submit(document.querySelector('form')) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ myFiles: [] }) + expect(onChange).toHaveBeenLastCalledWith( + { myFiles: [] }, + expect.anything() + ) expect( document.querySelector( @@ -463,15 +479,18 @@ describe('Field.Upload', () => { fireEvent.submit(document.querySelector('form')) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [ - expect.objectContaining({ - exists: false, - file: file2, - id: expect.any(String), - }), - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [ + expect.objectContaining({ + exists: false, + file: file2, + id: expect.any(String), + }), + ], + }, + expect.anything() + ) expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenLastCalledWith( { @@ -486,6 +505,7 @@ describe('Field.Upload', () => { { clearData: expect.any(Function), resetForm: expect.any(Function), + filterData: expect.any(Function), } ) }) @@ -528,15 +548,18 @@ describe('Field.Upload', () => { ) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [ - expect.objectContaining({ - exists: false, - file: file1, - id: expect.any(String), - }), - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [ + expect.objectContaining({ + exists: false, + file: file1, + id: expect.any(String), + }), + ], + }, + expect.anything() + ) expect( document.querySelector( @@ -577,15 +600,18 @@ describe('Field.Upload', () => { await userEvent.click(submitButton) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [ - expect.objectContaining({ - exists: false, - file: file1, - id: expect.any(String), - }), - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [ + expect.objectContaining({ + exists: false, + file: file1, + id: expect.any(String), + }), + ], + }, + expect.anything() + ) expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenLastCalledWith( { @@ -600,6 +626,7 @@ describe('Field.Upload', () => { { clearData: expect.any(Function), resetForm: expect.any(Function), + filterData: expect.any(Function), } ) }) @@ -645,19 +672,22 @@ describe('Field.Upload', () => { ) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [ - { - errorMessage: nbShared.Upload.errorLargeFile.replace( - '%size', - '0,2' - ), - file: file1, - exists: false, - id: expect.anything(), - }, - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [ + { + errorMessage: nbShared.Upload.errorLargeFile.replace( + '%size', + '0,2' + ), + file: file1, + exists: false, + id: expect.anything(), + }, + ], + }, + expect.anything() + ) expect( document.querySelector( @@ -688,9 +718,12 @@ describe('Field.Upload', () => { await userEvent.click(submitButton) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [], + }, + expect.anything() + ) expect( document.querySelector( @@ -710,15 +743,18 @@ describe('Field.Upload', () => { await userEvent.click(submitButton) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenLastCalledWith({ - myFiles: [ - expect.objectContaining({ - exists: false, - file: file2, - id: expect.any(String), - }), - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + myFiles: [ + expect.objectContaining({ + exists: false, + file: file2, + id: expect.any(String), + }), + ], + }, + expect.anything() + ) expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenLastCalledWith( { @@ -733,6 +769,7 @@ describe('Field.Upload', () => { { clearData: expect.any(Function), resetForm: expect.any(Function), + filterData: expect.any(Function), } ) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx index 578307522d6..0b9a71af1d8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/Handler.tsx @@ -29,7 +29,6 @@ export default function FormHandler({ errorMessages, globalStatusId, filterSubmitData, - filterData, transformIn, onChange, onPathChange, @@ -57,7 +56,6 @@ export default function FormHandler({ errorMessages, globalStatusId, filterSubmitData, - filterData, transformIn, onChange, onPathChange, diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx index 3d74ab7e857..e8243b2c7f3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx @@ -4,7 +4,13 @@ import React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { wait } from '../../../../../core/jest/jestSetup' -import { Form, Field, JSONSchema, JSONSchemaType } from '../../..' +import { + Form, + Field, + JSONSchema, + JSONSchemaType, + OnSubmit, +} from '../../..' import type { Props as StringFieldProps } from '../../../Field/String' import nbNO from '../../../constants/locales/nb-NO' import enGB from '../../../constants/locales/en-GB' @@ -14,7 +20,7 @@ const en = enGB['en-GB'] describe('Form.Handler', () => { it('should call "onSubmit"', () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() render( { }) it('should call "onSubmit" from Provider at the same time', () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() render( { it('should call preventDefault', () => { const preventDefault = jest.fn() - const onSubmit = jest.fn(preventDefault) + const onSubmit: OnSubmit = jest.fn(preventDefault) render( { }) it('should call HTMLFormElement.reset on "resetForm" call', () => { - const onSubmit = jest.fn((data, { resetForm }) => { + const onSubmit: OnSubmit = jest.fn((data, { resetForm }) => { resetForm() }) const onChange = jest.fn() @@ -242,16 +248,19 @@ describe('Form.Handler', () => { expect.anything() ) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - other: 'data', - foo: 'New Value', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + other: 'data', + foo: 'New Value', + }, + expect.anything() + ) expect(reset).toHaveBeenCalledTimes(1) expect(inputElement.value).toBe('New Value') }) it('should empty whole data set "clearData" call', () => { - const onSubmit = jest.fn((data, { clearData }) => { + const onSubmit: OnSubmit = jest.fn((data, { clearData }) => { clearData() }) const onChange = jest.fn() @@ -292,7 +301,10 @@ describe('Form.Handler', () => { expect(inputElement.value).toBe('unset me') expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ foo: 'unset me' }) + expect(onChange).toHaveBeenLastCalledWith( + { foo: 'unset me' }, + expect.anything() + ) fireEvent.click(submitElement) @@ -357,7 +369,7 @@ describe('Form.Handler', () => { Object.getPrototypeOf(window.sessionStorage), 'setItem' ) - const onSubmit = jest.fn((data, { resetForm }) => { + const onSubmit: OnSubmit = jest.fn((data, { resetForm }) => { resetForm() }) @@ -396,7 +408,7 @@ describe('Form.Handler', () => { }) it('should show errors if form is invalid on submit', () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() render( @@ -412,7 +424,7 @@ describe('Form.Handler', () => { }) it('should include values from fields in data, without any change', () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() render( @@ -432,7 +444,7 @@ describe('Form.Handler', () => { }) it('should show error message given in onSubmit', async () => { - const onSubmit = jest.fn(() => { + const onSubmit: OnSubmit = jest.fn(() => { throw new Error('Form error') }) @@ -451,7 +463,7 @@ describe('Form.Handler', () => { }) it('should have error message that is connected with aria-labelledby', async () => { - const onSubmit = jest.fn(() => { + const onSubmit: OnSubmit = jest.fn(() => { throw new Error('Form error') }) @@ -573,7 +585,7 @@ describe('Form.Handler', () => { }) it('should abort async submit when onSubmit returns error', async () => { - const onSubmit = jest.fn(async () => { + const onSubmit: OnSubmit = jest.fn(async () => { await wait(1) return new Error('Error message') @@ -616,7 +628,7 @@ describe('Form.Handler', () => { }) it('should call onSubmit and onSubmitComplete on async submit call', async () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() const onSubmitComplete = jest.fn() render( @@ -640,6 +652,7 @@ describe('Form.Handler', () => { { clearData: expect.any(Function), resetForm: expect.any(Function), + filterData: expect.any(Function), } ) @@ -652,7 +665,7 @@ describe('Form.Handler', () => { }) it('should handle onSubmit return with "info" and handle pending status', async () => { - const onSubmit = jest.fn().mockImplementation(async () => { + const onSubmit: OnSubmit = jest.fn().mockImplementation(async () => { await wait(500) // ensure we never finish onSubmit before the timeout return { @@ -735,7 +748,7 @@ describe('Form.Handler', () => { }) it('should call onSubmit and onSubmitComplete with async validator', async () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() const onSubmitComplete = jest.fn() const asyncValidator = async () => { @@ -767,6 +780,7 @@ describe('Form.Handler', () => { { clearData: expect.any(Function), resetForm: expect.any(Function), + filterData: expect.any(Function), } ) @@ -779,7 +793,7 @@ describe('Form.Handler', () => { }) it('should not call async validator when field is not mounted anymore', async () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() const asyncValidator = jest.fn(async () => { return null }) @@ -806,6 +820,7 @@ describe('Form.Handler', () => { { clearData: expect.any(Function), resetForm: expect.any(Function), + filterData: expect.any(Function), } ) 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 cd3d8b04018..ffad2987bf2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx @@ -58,7 +58,6 @@ export type IsolationProps = Omit< | 'asyncSubmitTimeout' | 'scrollTopOnSubmit' | 'sessionStorageId' - | 'filterSubmitData' | 'globalStatusId' > & { /** diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/IsolationDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/IsolationDocs.ts index dac99bb05ac..679b984bba3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/IsolationDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/IsolationDocs.ts @@ -25,7 +25,6 @@ export const IsolationProperties: PropertiesTableProps = { asyncSubmitTimeout: undefined, scrollTopOnSubmit: undefined, sessionStorageId: undefined, - filterSubmitData: undefined, globalStatusId: undefined, } diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/__tests__/Isolation.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/__tests__/Isolation.test.tsx index 9109abf3e00..1f59a513666 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/__tests__/Isolation.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/__tests__/Isolation.test.tsx @@ -277,10 +277,13 @@ describe('Form.Isolation', () => { expect(regular).toHaveValue('Regular') expect(isolated).toHaveValue('Something') expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - regular: 'Regular', - isolated: 'Something', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + regular: 'Regular', + isolated: 'Something', + }, + expect.anything() + ) await userEvent.type(regular, ' updated') await userEvent.click(button) @@ -288,10 +291,13 @@ describe('Form.Isolation', () => { expect(regular).toHaveValue('Regular updated') expect(isolated).toHaveValue('Something') expect(onChange).toHaveBeenCalledTimes(10) - expect(onChange).toHaveBeenLastCalledWith({ - regular: 'Regular updated', - isolated: 'Something', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + regular: 'Regular updated', + isolated: 'Something', + }, + expect.anything() + ) await userEvent.type(isolated, ' 2') await userEvent.click(button) @@ -299,10 +305,13 @@ describe('Form.Isolation', () => { expect(regular).toHaveValue('Regular updated') expect(isolated).toHaveValue('Something 2') expect(onChange).toHaveBeenCalledTimes(11) - expect(onChange).toHaveBeenLastCalledWith({ - regular: 'Regular updated', - isolated: 'Something 2', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + regular: 'Regular updated', + isolated: 'Something 2', + }, + expect.anything() + ) await userEvent.type(regular, ' 2') await userEvent.click(button) @@ -310,10 +319,13 @@ describe('Form.Isolation', () => { expect(regular).toHaveValue('Regular updated 2') expect(isolated).toHaveValue('Something 2') expect(onChange).toHaveBeenCalledTimes(14) - expect(onChange).toHaveBeenLastCalledWith({ - regular: 'Regular updated 2', - isolated: 'Something 2', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + regular: 'Regular updated 2', + isolated: 'Something 2', + }, + expect.anything() + ) }) it('should not change the data path from outside', async () => { @@ -466,9 +478,12 @@ describe('Form.Isolation', () => { await userEvent.type(regular, 'Regular') expect(onChange).toHaveBeenCalledTimes(7) - expect(onChange).toHaveBeenLastCalledWith({ - regular: 'Regular', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + regular: 'Regular', + }, + expect.anything() + ) }) it('should call local onChange', async () => { @@ -493,9 +508,12 @@ describe('Form.Isolation', () => { await userEvent.type(isolated, 'Isolated') expect(onChange).toHaveBeenCalledTimes(8) - expect(onChange).toHaveBeenLastCalledWith({ - isolated: 'Isolated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + isolated: 'Isolated', + }, + expect.anything() + ) await userEvent.type(regular, 'Regular') @@ -531,17 +549,23 @@ describe('Form.Isolation', () => { }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - isolated: 'Isolated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + isolated: 'Isolated', + }, + expect.anything() + ) await userEvent.type(regular, 'Regular') expect(onChange).toHaveBeenCalledTimes(8) - expect(onChange).toHaveBeenLastCalledWith({ - regular: 'Regular', - isolated: 'Isolated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + regular: 'Regular', + isolated: 'Isolated', + }, + expect.anything() + ) }) it('onCommit should only return the isolated data', async () => { @@ -716,22 +740,28 @@ describe('Form.Isolation', () => { }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - regular: 'Regular', - nested: { - isolated: 'Isolated changed', + expect(onChange).toHaveBeenLastCalledWith( + { + regular: 'Regular', + nested: { + isolated: 'Isolated changed', + }, }, - }) + expect.anything() + ) await userEvent.type(regular, ' changed') expect(onChange).toHaveBeenCalledTimes(9) - expect(onChange).toHaveBeenLastCalledWith({ - regular: 'Regular changed', - nested: { - isolated: 'Isolated changed', + expect(onChange).toHaveBeenLastCalledWith( + { + regular: 'Regular changed', + nested: { + isolated: 'Isolated changed', + }, }, - }) + expect.anything() + ) }) it('should show provider schema type error with path', async () => { @@ -813,9 +843,12 @@ describe('Form.Isolation', () => { await userEvent.click(button) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - isolated: 'Isolated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + isolated: 'Isolated', + }, + expect.anything() + ) expect(onCommit).toHaveBeenCalledTimes(1) expect(onCommit).toHaveBeenLastCalledWith( { @@ -828,9 +861,12 @@ describe('Form.Isolation', () => { await userEvent.type(isolated, '-updated') expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ - isolated: 'Isolated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + isolated: 'Isolated', + }, + expect.anything() + ) expect(onCommit).toHaveBeenCalledTimes(2) expect(onCommit).toHaveBeenLastCalledWith( { @@ -842,9 +878,12 @@ describe('Form.Isolation', () => { await userEvent.click(button) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenLastCalledWith({ - isolated: 'Isolated-updated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + isolated: 'Isolated-updated', + }, + expect.anything() + ) expect(onCommit).toHaveBeenCalledTimes(3) expect(onCommit).toHaveBeenLastCalledWith( { @@ -882,9 +921,12 @@ describe('Form.Isolation', () => { await userEvent.click(button) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - isolated: 'Isolated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + isolated: 'Isolated', + }, + expect.anything() + ) expect(onCommit).toHaveBeenCalledTimes(1) expect(onCommit).toHaveBeenLastCalledWith( { @@ -897,9 +939,12 @@ describe('Form.Isolation', () => { await userEvent.type(isolated, '-updated') expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ - isolated: 'Isolated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + isolated: 'Isolated', + }, + expect.anything() + ) expect(onCommit).toHaveBeenCalledTimes(2) expect(onCommit).toHaveBeenLastCalledWith( { @@ -911,9 +956,12 @@ describe('Form.Isolation', () => { await userEvent.click(button) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenLastCalledWith({ - isolated: 'Isolated-updated', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + isolated: 'Isolated-updated', + }, + expect.anything() + ) expect(onCommit).toHaveBeenCalledTimes(3) expect(onCommit).toHaveBeenLastCalledWith( { @@ -1250,19 +1298,25 @@ describe('Form.Isolation', () => { await userEvent.click(commitButton) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - existing: 'data', - persons: [{ name: 'John' }, { name: 'Oda' }], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + existing: 'data', + persons: [{ name: 'John' }, { name: 'Oda' }], + }, + expect.anything() + ) await userEvent.type(isolated, '{Backspace>3}Odd') await userEvent.click(commitButton) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ - existing: 'data', - persons: [{ name: 'John' }, { name: 'Oda' }, { name: 'Odd' }], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + existing: 'data', + persons: [{ name: 'John' }, { name: 'Oda' }, { name: 'Odd' }], + }, + expect.anything() + ) }) it('should render inside section with correct paths', async () => { @@ -1336,12 +1390,15 @@ describe('Form.Isolation', () => { { isolated: 'inside changed' }, { clearData: expect.any(Function) } ) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { - isolated: 'inside changed', - regular: 'regular changed', + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { + isolated: 'inside changed', + regular: 'regular changed', + }, }, - }) + expect.anything() + ) expect(isolated).toHaveValue('inside changed') expect(synced).toHaveValue('inside changed') @@ -1421,12 +1478,15 @@ describe('Form.Isolation', () => { { isolated: 'inside' }, { clearData: expect.any(Function) } ) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { - isolated: 'inside', - regular: 'regular', + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { + isolated: 'inside', + regular: 'regular', + }, }, - }) + expect.anything() + ) }) it('clears the form data when "clearData" is called inside the "onCommit" event', async () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/Section.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Section/Section.tsx index 2a132dd9fd0..0744f3be102 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/Section.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/Section.tsx @@ -80,7 +80,7 @@ function SectionComponent(props: LocalProps) { const { path: nestedPath, props: nestedProps } = useContext(SectionContext) || {} - const handleChange = useCallback>( + const handleChange = useCallback( (...args) => onChange?.(...args), [onChange] ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/__tests__/Section.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Section/__tests__/Section.test.tsx index edb5f8ab7d3..9f3127e185d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/__tests__/Section.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/__tests__/Section.test.tsx @@ -205,17 +205,23 @@ describe('Form.Section', () => { fireEvent.change(first, { target: { value: 'foo' } }) - expect(onChange).toHaveBeenLastCalledWith({ - firstName: 'foo', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + firstName: 'foo', + }, + expect.anything() + ) fireEvent.change(last, { target: { value: 'bar' } }) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ - firstName: 'foo', - lastName: 'bar', - }) + expect(onChange).toHaveBeenLastCalledWith( + { + firstName: 'foo', + lastName: 'bar', + }, + expect.anything() + ) }) it('should call onChange with path', () => { @@ -227,16 +233,22 @@ describe('Form.Section', () => { fireEvent.change(first, { target: { value: 'foo' } }) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { firstName: 'foo' }, - }) + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { firstName: 'foo' }, + }, + expect.anything() + ) fireEvent.change(last, { target: { value: 'bar' } }) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { firstName: 'foo', lastName: 'bar' }, - }) + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { firstName: 'foo', lastName: 'bar' }, + }, + expect.anything() + ) }) it('should call onChange on Form.Handler without a path', () => { @@ -254,11 +266,19 @@ describe('Form.Section', () => { fireEvent.change(last, { target: { value: 'bar' } }) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenNthCalledWith(1, { firstName: 'foo' }) - expect(onChange).toHaveBeenNthCalledWith(2, { - firstName: 'foo', - lastName: 'bar', - }) + expect(onChange).toHaveBeenNthCalledWith( + 1, + { firstName: 'foo' }, + expect.anything() + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + { + firstName: 'foo', + lastName: 'bar', + }, + expect.anything() + ) }) it('should call onChange on Form.Handler with a path', () => { @@ -276,12 +296,20 @@ describe('Form.Section', () => { fireEvent.change(last, { target: { value: 'bar' } }) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenNthCalledWith(1, { - mySection: { firstName: 'foo' }, - }) - expect(onChange).toHaveBeenNthCalledWith(2, { - mySection: { firstName: 'foo', lastName: 'bar' }, - }) + expect(onChange).toHaveBeenNthCalledWith( + 1, + { + mySection: { firstName: 'foo' }, + }, + expect.anything() + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + { + mySection: { firstName: 'foo', lastName: 'bar' }, + }, + expect.anything() + ) }) it('should call onChange from nested fields', () => { @@ -299,36 +327,45 @@ describe('Form.Section', () => { fireEvent.change(first, { target: { value: 'foo' } }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { - innerSection: { - firstName: 'foo', + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { + innerSection: { + firstName: 'foo', + }, }, }, - }) + expect.anything() + ) fireEvent.change(last, { target: { value: 'bar' } }) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { - innerSection: { - firstName: 'foo', - lastName: 'bar', + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { + innerSection: { + firstName: 'foo', + lastName: 'bar', + }, }, }, - }) + expect.anything() + ) fireEvent.change(addition, { target: { value: 'baz' } }) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { - innerSection: { - firstName: 'foo', - lastName: 'bar', + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { + innerSection: { + firstName: 'foo', + lastName: 'bar', + }, + otherField: 'baz', }, - otherField: 'baz', }, - }) + expect.anything() + ) }) it('should support onChange without Form.Handler', () => { @@ -342,36 +379,45 @@ describe('Form.Section', () => { fireEvent.change(first, { target: { value: 'foo' } }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { - innerSection: { - firstName: 'foo', + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { + innerSection: { + firstName: 'foo', + }, }, }, - }) + expect.anything() + ) fireEvent.change(last, { target: { value: 'bar' } }) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { - innerSection: { - firstName: 'foo', - lastName: 'bar', + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { + innerSection: { + firstName: 'foo', + lastName: 'bar', + }, }, }, - }) + expect.anything() + ) fireEvent.change(addition, { target: { value: 'baz' } }) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenLastCalledWith({ - mySection: { - innerSection: { - firstName: 'foo', - lastName: 'bar', + expect(onChange).toHaveBeenLastCalledWith( + { + mySection: { + innerSection: { + firstName: 'foo', + lastName: 'bar', + }, + otherField: 'baz', }, - otherField: 'baz', }, - }) + expect.anything() + ) }) }) @@ -585,12 +631,20 @@ describe('Form.Section', () => { fireEvent.change(last, { target: { value: 'bar' } }) expect(onChange).toHaveBeenCalledTimes(2) - expect(onChange).toHaveBeenNthCalledWith(1, { - mySection: { bar: undefined, foo: 'foo' }, - }) - expect(onChange).toHaveBeenNthCalledWith(2, { - mySection: { bar: 'bar', foo: 'foo' }, - }) + expect(onChange).toHaveBeenNthCalledWith( + 1, + { + mySection: { bar: undefined, foo: 'foo' }, + }, + expect.anything() + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + { + mySection: { bar: 'bar', foo: 'foo' }, + }, + expect.anything() + ) }) describe('nested', () => { @@ -813,33 +867,45 @@ describe('Form.Section', () => { fireEvent.change(addition, { target: { value: 'baz' } }) expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenNthCalledWith(1, { - mySection: { - innerSection: { - bar: undefined, - foo: 'foo', + expect(onChange).toHaveBeenNthCalledWith( + 1, + { + mySection: { + innerSection: { + bar: undefined, + foo: 'foo', + }, + otherField: undefined, }, - otherField: undefined, }, - }) - expect(onChange).toHaveBeenNthCalledWith(2, { - mySection: { - innerSection: { - bar: 'bar', - foo: 'foo', + expect.anything() + ) + expect(onChange).toHaveBeenNthCalledWith( + 2, + { + mySection: { + innerSection: { + bar: 'bar', + foo: 'foo', + }, + otherField: undefined, }, - otherField: undefined, }, - }) - expect(onChange).toHaveBeenNthCalledWith(3, { - mySection: { - innerSection: { - bar: 'bar', - foo: 'foo', + expect.anything() + ) + expect(onChange).toHaveBeenNthCalledWith( + 3, + { + mySection: { + innerSection: { + bar: 'bar', + foo: 'foo', + }, + otherField: 'baz', }, - otherField: 'baz', }, - }) + expect.anything() + ) }) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx index b974a553e81..fcd7462eb9d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx @@ -80,25 +80,30 @@ export default function useData( forceUpdate ) + sharedAttachmentsRef.current = useSharedState>( + id + '-attachments', + { rerenderUseDataHook: forceUpdate } + ) + // If no id is provided, use the context data const context = useContext(DataContext) if (!id) { - if (context?.data) { - sharedDataRef.current.data = context.data - } else if (!context?.hasContext) { + if (!context?.hasContext) { throw new Error( 'useData needs to run inside DataContext (Form.Handler) or have a valid id' ) } + + if (context) { + sharedDataRef.current.data = context.data + sharedAttachmentsRef.current.data.filterDataHandler = + context.filterDataHandler + } } + const updateDataValue = context?.updateDataValue const setData = context?.setData - sharedAttachmentsRef.current = useSharedState>( - id + '-attachments', - { rerenderUseDataHook: forceUpdate } - ) - const setHandler = useCallback( (newData: Data) => { if (id) { @@ -174,6 +179,6 @@ export default function useData( getValue, filterData, }), - [data, filterData, getValue, setHandler, updateHandler] + [data, getValue, setHandler, updateHandler, filterData] ) } 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 2198bc5c7c8..81ef490f66b 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 @@ -406,9 +406,12 @@ describe('Iterate.Array', () => { expect(elements).toHaveLength(1) expect(onChangeDataContext).toHaveBeenCalledTimes(1) - expect(onChangeDataContext).toHaveBeenLastCalledWith({ - myList: ['foo'], - }) + expect(onChangeDataContext).toHaveBeenLastCalledWith( + { + myList: ['foo'], + }, + expect.anything() + ) expect(onChangeIterate).toHaveBeenCalledTimes(1) expect(onChangeIterate).toHaveBeenLastCalledWith(['foo']) }) @@ -481,38 +484,70 @@ describe('Iterate.Array', () => { ]) expect(dataContextOnChange).toHaveBeenCalledTimes(8) - expect(dataContextOnChange).toHaveBeenNthCalledWith(1, { - someList: ['fool', 'bar'], - otherValue: 'lorem ipsu', - }) - expect(dataContextOnChange).toHaveBeenNthCalledWith(2, { - someList: ['fools', 'bar'], - otherValue: 'lorem ipsu', - }) - expect(dataContextOnChange).toHaveBeenNthCalledWith(3, { - someList: ['fools', 'bar'], - otherValue: 'lorem ipsum', - }) - expect(dataContextOnChange).toHaveBeenNthCalledWith(4, { - someList: ['fools', 'bar '], - otherValue: 'lorem ipsum', - }) - expect(dataContextOnChange).toHaveBeenNthCalledWith(5, { - someList: ['fools', 'bar c'], - otherValue: 'lorem ipsum', - }) - expect(dataContextOnChange).toHaveBeenNthCalledWith(6, { - someList: ['fools', 'bar co'], - otherValue: 'lorem ipsum', - }) - expect(dataContextOnChange).toHaveBeenNthCalledWith(7, { - someList: ['fools', 'bar cod'], - otherValue: 'lorem ipsum', - }) - expect(dataContextOnChange).toHaveBeenNthCalledWith(8, { - someList: ['fools', 'bar code'], - otherValue: 'lorem ipsum', - }) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 1, + { + someList: ['fool', 'bar'], + otherValue: 'lorem ipsu', + }, + expect.anything() + ) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 2, + { + someList: ['fools', 'bar'], + otherValue: 'lorem ipsu', + }, + expect.anything() + ) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 3, + { + someList: ['fools', 'bar'], + otherValue: 'lorem ipsum', + }, + expect.anything() + ) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 4, + { + someList: ['fools', 'bar '], + otherValue: 'lorem ipsum', + }, + expect.anything() + ) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 5, + { + someList: ['fools', 'bar c'], + otherValue: 'lorem ipsum', + }, + expect.anything() + ) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 6, + { + someList: ['fools', 'bar co'], + otherValue: 'lorem ipsum', + }, + expect.anything() + ) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 7, + { + someList: ['fools', 'bar cod'], + otherValue: 'lorem ipsum', + }, + expect.anything() + ) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 8, + { + someList: ['fools', 'bar code'], + otherValue: 'lorem ipsum', + }, + expect.anything() + ) }) it('should filter data based on the given "filterSubmitData" property method', () => { @@ -889,15 +924,17 @@ describe('Iterate.Array', () => { it('should filter data based with multi wildcard paths', () => { let filteredData = undefined - const onSubmit = jest.fn((data) => (filteredData = data)) + const onSubmit = jest.fn( + (data, { filterData }) => + (filteredData = filterData({ + '/firstList/0/secondList/*/foo': false, + '/firstList/1/secondList/*/bar': false, + })) + ) render( { foo: 'foo 1', secondList: [ { + foo: 'foo 1', bar: 'bar 1', }, { + foo: 'foo 2', bar: 'bar 2', }, ], @@ -945,9 +984,11 @@ describe('Iterate.Array', () => { secondList: [ { foo: 'foo 1', + bar: 'bar 1', }, { foo: 'foo 2', + bar: 'bar 2', }, ], }, diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/__tests__/PushContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/__tests__/PushContainer.test.tsx index d26bc7015cf..384618e2cf5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/__tests__/PushContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/__tests__/PushContainer.test.tsx @@ -30,13 +30,16 @@ describe('PushContainer', () => { await userEvent.click(button) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ - entries: [ - { - name: 'Tony', - }, - ], - }) + expect(onChange).toHaveBeenLastCalledWith( + { + entries: [ + { + name: 'Tony', + }, + ], + }, + expect.anything() + ) }) it('should show view container after adding a new entry', async () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx index e6e501fa66b..b0bddccef66 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { wait } from '../../../../../core/jest/jestSetup' -import { Field, Form, Wizard } from '../../..' +import { Field, Form, OnSubmit, Wizard } from '../../..' import nbNO from '../../../constants/locales/nb-NO' const nb = nbNO['nb-NO'] @@ -452,7 +452,7 @@ describe('Wizard.Container', () => { }) it('should trigger next step when submitting the form', async () => { - const onSubmit = jest.fn() + const onSubmit: OnSubmit = jest.fn() const onStepChange = jest.fn() render( @@ -1621,14 +1621,17 @@ describe('Wizard.Container', () => { await userEvent.type(document.querySelector('input'), ' changed') expect(onChange).toHaveBeenCalledTimes(8) - expect(onChange).toHaveBeenLastCalledWith({ - barStep1: 'has value', - barStep2: 'has value', - barStep3: undefined, - fooStep1: 'has value', - fooStep2: 'has value changed', - fooStep3: undefined, - }) + expect(onChange).toHaveBeenLastCalledWith( + { + barStep1: 'has value', + barStep2: 'has value', + barStep3: undefined, + fooStep1: 'has value', + fooStep2: 'has value changed', + fooStep3: undefined, + }, + expect.anything() + ) await userEvent.click(nextButton()) 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 0aabd97d503..72672319054 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 @@ -2667,7 +2667,10 @@ describe('useFieldProps', () => { }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ foo: 'new-value' }) + expect(onChange).toHaveBeenLastCalledWith( + { foo: 'new-value' }, + expect.anything() + ) expect(result.current.error).toBeUndefined() }) @@ -2712,7 +2715,10 @@ describe('useFieldProps', () => { }) expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenLastCalledWith({ foo: 'new-value' }) + expect(onChange).toHaveBeenLastCalledWith( + { foo: 'new-value' }, + expect.anything() + ) expect(result.current.error).toBeInstanceOf(Error) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index 8fa5129257d..f32e3ddd374 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -3,6 +3,7 @@ import type { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema' import type { JSONSchemaType } from 'ajv/dist/2020' import { JsonObject } from 'json-pointer' import { AriaAttributes } from 'react' +import { FilterData } from './DataContext' export type * from 'json-schema' export type JSONSchema = JSONSchema7 @@ -511,6 +512,9 @@ export type EventReturnWithStateObjectAndSuccess = | EventStateObjectWithSuccess export type OnSubmitParams = { + /** Will filter data based on the given "filterDataHandler" method */ + filterData: (filterDataHandler: FilterData) => Partial + /** Will remove browser-side stored autocomplete data */ resetForm: () => void @@ -520,7 +524,7 @@ export type OnSubmitParams = { export type OnSubmit = ( data: Data, - { resetForm, clearData }: OnSubmitParams + { filterData, resetForm, clearData }: OnSubmitParams ) => | EventReturnWithStateObject | void @@ -534,7 +538,10 @@ export type OnCommit = ( | void | Promise -export type OnChange = (data: Data) => OnChangeReturnType +export type OnChange = ( + data: Data, + additionalArgs: Pick +) => OnChangeReturnType type OnChangeReturnType = | EventReturnWithStateObjectAndSuccess From 7f37704c379f92d1b03be189783f8ccea024855b Mon Sep 17 00:00:00 2001 From: Anders Date: Thu, 29 Aug 2024 06:39:14 +0200 Subject: [PATCH 12/17] chore: display SectionEditContainer and SectionViewContainer props (#3881) --- .../docs/uilib/extensions/forms/Form/Section/properties.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/properties.mdx index bc8429cd616..99ba06044fa 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/properties.mdx @@ -13,4 +13,6 @@ import { SectionProperties } from '@dnb/eufemia/src/extensions/forms/Form/Sectio ## Translations - + From 35427d70ab71660cb5a274068ec17d3890aacffa Mon Sep 17 00:00:00 2001 From: Joakim Bjerknes Date: Fri, 30 Aug 2024 08:18:05 +0200 Subject: [PATCH 13/17] feat(Field.ArraySelection): add dataPath prop (#3872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tobias Høegh --- .../base-fields/ArraySelection/Examples.tsx | 74 ++++++++++++ .../base-fields/ArraySelection/demos.mdx | 16 +++ .../Field/ArraySelection/ArraySelection.tsx | 35 +++++- .../ArraySelection/ArraySelectionDocs.ts | 10 ++ .../__tests__/ArraySelection.test.tsx | 106 ++++++++++++++++++ 5 files changed, 236 insertions(+), 5 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/ArraySelection/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/ArraySelection/Examples.tsx index 66eed07f7fc..bf29b3dd6d9 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/ArraySelection/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/ArraySelection/Examples.tsx @@ -237,6 +237,42 @@ export const CheckboxNestingWithLogic = () => ( ) +export const CheckboxWithDataPath = () => { + return ( + + + + + + ) +} + +export const CheckboxWithData = () => { + return ( + + + + ) +} + // Button export const ButtonEmpty = () => ( @@ -498,3 +534,41 @@ export const ButtonNestingWithLogic = () => ( ) + +export const ButtonWithDataPath = () => { + return ( + + + + + + ) +} + +export const ButtonWithData = () => { + return ( + + + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/ArraySelection/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/ArraySelection/demos.mdx index 96f932a8e57..2aec3bc099d 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/ArraySelection/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/ArraySelection/demos.mdx @@ -68,6 +68,14 @@ You can nest other fields and show them based on your desired logic. +#### Checkbox with a path to populate the data + + + +#### Checkbox with the data property + + + --- ### Button variant demos @@ -112,6 +120,14 @@ You can nest other fields and show them based on your desired logic. +#### Button with a path to populate the data + + + +#### Button with the data property + + + #### Button with nested fields and logic You can nest other fields and show them based on your desired logic. diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelection.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelection.tsx index 0f55a004e82..ba17f0d1657 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelection.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelection.tsx @@ -4,12 +4,13 @@ import classnames from 'classnames' import FieldBlock from '../../FieldBlock' import { useFieldProps } from '../../hooks' import { ReturnAdditional } from '../../hooks/useFieldProps' -import { FieldHelpProps, FieldProps, FormError } from '../../types' +import { FieldHelpProps, FieldProps, FormError, Path } from '../../types' import { pickSpacingProps } from '../../../../components/flex/utils' -import { getStatus, mapOptions } from '../Selection' +import { getStatus, mapOptions, Data } from '../Selection' import { HelpButtonProps } from '../../../../components/HelpButton' import ToggleButtonGroupContext from '../../../../components/toggle-button/ToggleButtonGroupContext' import DataContext from '../../DataContext/Context' +import useDataValue from '../../hooks/useDataValue' type OptionProps = React.ComponentProps< React.FC<{ @@ -28,12 +29,25 @@ export type Props = FieldHelpProps & children?: React.ReactNode variant?: 'checkbox' | 'button' | 'checkbox-button' optionsLayout?: 'horizontal' | 'vertical' + /** + * The path to the context data (Form.Handler). + * The context data object needs to have a `value` and a `title` property. + */ + dataPath?: Path + + /** + * Data to be used for the component. The object needs to have a `value` and a `title` property. + * The generated options will be placed above given JSX based children. + */ + data?: Data } function ArraySelection(props: Props) { const { id, path, + dataPath, + data, className, variant = 'checkbox', layout = 'vertical', @@ -53,6 +67,9 @@ function ArraySelection(props: Props) { children, } = useFieldProps(props) + const { getValueByPath } = useDataValue() + const dataList = dataPath ? getValueByPath(dataPath) : data + const fieldBlockProps = { forId: id, className: classnames( @@ -96,6 +113,7 @@ function ArraySelection(props: Props) { warning, emptyValue, htmlAttributes, + dataList, children, value, disabled, @@ -132,6 +150,7 @@ export function useCheckboxOrToggleOptions({ warning, emptyValue, htmlAttributes, + dataList, children, value, disabled, @@ -145,6 +164,7 @@ export function useCheckboxOrToggleOptions({ warning?: Props['warning'] emptyValue?: Props['emptyValue'] htmlAttributes?: Props['htmlAttributes'] + dataList?: Props['data'] children?: Props['children'] value?: Props['value'] disabled?: Props['disabled'] @@ -153,8 +173,8 @@ export function useCheckboxOrToggleOptions({ }) { const { setFieldProps } = useContext(DataContext) const optionsCount = useMemo( - () => React.Children.count(children), - [children] + () => React.Children.count(children) + (dataList?.length || 0), + [dataList, children] ) const collectedData = [] @@ -234,7 +254,12 @@ export function useCheckboxOrToggleOptions({ ] ) - const result = mapOptions(children, { createOption }) + const result = [ + ...(dataList || []).map((props, i) => + createOption(props as OptionProps, i) + ), + ...(mapOptions(children, { createOption }) || []).filter(Boolean), + ] if (path) { setFieldProps?.(path + '/arraySelectionData', collectedData) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelectionDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelectionDocs.ts index 09c696cf0fc..ea09b6ab9a7 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelectionDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelectionDocs.ts @@ -18,4 +18,14 @@ export const arraySelectionProperties: PropertiesTableProps = { type: 'React.Node', status: 'optional', }, + data: { + doc: 'Data to be used for the component. The object needs to have a `value` and a `title` property. Provide the Dropdown or Autocomplete data in the format documented here: [Dropdown](/uilib/components/dropdown) and [Autocomplete](/uilib/components/autocomplete) documentation.', + type: 'array', + status: 'optional', + }, + dataPath: { + doc: 'The path to the context data (Form.Handler). The context data object needs to have a `value` and a `title` property. The generated options will be placed above given JSX based children.', + type: 'string', + status: 'optional', + }, } diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/__tests__/ArraySelection.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/__tests__/ArraySelection.test.tsx index 6ccdc6e62e9..34b55978e7d 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/__tests__/ArraySelection.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/__tests__/ArraySelection.test.tsx @@ -257,6 +257,59 @@ describe('ArraySelection', () => { expect(optionC).toHaveClass('dnb-checkbox__status--error') expect(optionD).toHaveClass('dnb-checkbox__status--error') }) + + it('should support "dataPath"', () => { + render( + + + Baz! + + + ) + + const options = Array.from( + document.querySelectorAll('.dnb-checkbox') + ) + expect(options).toHaveLength(3) + + const [option1, option2, option3] = options + + expect(option1).toHaveTextContent('Foo!') + expect(option2).toHaveTextContent('Bar!') + expect(option3).toHaveTextContent('Baz!') + + expect(option1.querySelector('input').checked).toBe(false) + expect(option2.querySelector('input').checked).toBe(true) + expect(option3.querySelector('input').checked).toBe(false) + + expect(option1.querySelector('input').id).toBe( + option1.querySelector('label').getAttribute('for') + ) + expect(option2.querySelector('input').id).toBe( + option2.querySelector('label').getAttribute('for') + ) + expect(option3.querySelector('input').id).toBe( + option3.querySelector('label').getAttribute('for') + ) + expect(option1.querySelector('input').id).not.toBe( + option2.querySelector('label').getAttribute('for') + ) + expect(option1.querySelector('input').id).not.toBe( + option3.querySelector('label').getAttribute('for') + ) + }) }) describe.each(['button', 'checkbox-button'])( @@ -441,6 +494,59 @@ describe('ArraySelection', () => { expect(optionC).toHaveClass('dnb-toggle-button__status--error') expect(optionD).toHaveClass('dnb-toggle-button__status--error') }) + + it('should support "dataPath"', () => { + render( + + + Baz! + + + ) + + const options = Array.from( + document.querySelectorAll( + `.dnb-forms-field-array-selection__button ` + ) + ) + expect(options).toHaveLength(3) + + const [option1, option2, option3] = options + + expect(option1).toHaveTextContent('Foo!') + expect(option2).toHaveTextContent('Bar!') + expect(option3).toHaveTextContent('Baz!') + + expect(option1.querySelector('button')).toHaveAttribute( + 'aria-pressed', + 'false' + ) + expect(option1).not.toHaveClass('dnb-toggle-button--checked') + + expect(option2.querySelector('button')).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(option2).toHaveClass('dnb-toggle-button--checked') + + expect(option3.querySelector('button')).toHaveAttribute( + 'aria-pressed', + 'false' + ) + expect(option3).not.toHaveClass('dnb-toggle-button--checked') + }) } ) }) From 413c568758d27a938f3c7133fbfc01d520ef296d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Mon, 2 Sep 2024 11:10:52 +0200 Subject: [PATCH 14/17] fix(Forms): add support for `gap={false}` to Value.Composition (ValueBlock) (#3884) --- .../forms/ValueBlock/ValueBlock.tsx | 33 ++++--------------- .../ValueBlock/__tests__/ValueBlock.test.tsx | 22 +++++++++++++ .../ValueBlock/style/dnb-value-block.scss | 3 ++ 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/ValueBlock/ValueBlock.tsx b/packages/dnb-eufemia/src/extensions/forms/ValueBlock/ValueBlock.tsx index 6b893b0b957..b9070fff69b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/ValueBlock/ValueBlock.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/ValueBlock/ValueBlock.tsx @@ -87,6 +87,10 @@ function ValueBlock(props: Props) { composition === true ? 'horizontal' : composition }` ) + const defaultClass = classnames( + 'dnb-forms-value-block__content', + `dnb-forms-value-block__content--gap-${gap === false ? 'none' : gap}` + ) if (summaryListContext) { const Item = summaryListContext.isNested @@ -96,16 +100,7 @@ function ValueBlock(props: Props) { : Fragment if (!label && valueBlockContext?.composition) { - content = ( - - {children} - - ) ?? ( + content = {children} ?? ( {placeholder} @@ -129,14 +124,7 @@ function ValueBlock(props: Props) { )} > {children ? ( - - {children} - + {children} ) : ( {placeholder} @@ -170,14 +158,7 @@ function ValueBlock(props: Props) { )} {children ? ( - - {children} - + {children} ) : ( {placeholder} diff --git a/packages/dnb-eufemia/src/extensions/forms/ValueBlock/__tests__/ValueBlock.test.tsx b/packages/dnb-eufemia/src/extensions/forms/ValueBlock/__tests__/ValueBlock.test.tsx index 8b82db984b4..9fb5c92d8f0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/ValueBlock/__tests__/ValueBlock.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/ValueBlock/__tests__/ValueBlock.test.tsx @@ -194,6 +194,28 @@ describe('ValueBlock', () => { }) }) + it('renders support gap', () => { + const { rerender } = render( + + Value + + ) + + expect( + document.querySelector('.dnb-forms-value-block__content') + ).toHaveClass('dnb-forms-value-block__content--gap-medium') + + rerender( + + Value + + ) + + expect( + document.querySelector('.dnb-forms-value-block__content') + ).toHaveClass('dnb-forms-value-block__content--gap-none') + }) + it('should warn when ValueBlocks are siblings without being in a SummaryList', () => { const log = jest.spyOn(console, 'log').mockImplementation() diff --git a/packages/dnb-eufemia/src/extensions/forms/ValueBlock/style/dnb-value-block.scss b/packages/dnb-eufemia/src/extensions/forms/ValueBlock/style/dnb-value-block.scss index 173b9f79c74..8275d3fe422 100644 --- a/packages/dnb-eufemia/src/extensions/forms/ValueBlock/style/dnb-value-block.scss +++ b/packages/dnb-eufemia/src/extensions/forms/ValueBlock/style/dnb-value-block.scss @@ -67,6 +67,9 @@ row-gap: var(--row-gap, var(--spacing-medium)); column-gap: var(--column-gap, 0); + &--gap-none { + --column-gap: 0; + } &--gap-xx-small { --column-gap: var(--spacing-xx-small); } From 652448c0e07b476d0573b9acafe0904a939c33fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Mon, 2 Sep 2024 11:11:04 +0200 Subject: [PATCH 15/17] feat(Forms): add function support for the `prefix` or `suffix` props in `Field.Number` (#3880) --- .../forms/base-fields/Number/Examples.tsx | 21 ++++ .../forms/base-fields/Number/demos.mdx | 6 + .../extensions/forms/Field/Number/Number.tsx | 105 +++++++++--------- .../Field/Number/__tests__/Number.test.tsx | 24 ++++ 4 files changed, 106 insertions(+), 50 deletions(-) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx index ac8f94efafa..3d39b4595bf 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/Examples.tsx @@ -49,6 +49,27 @@ export const LabelAndValue = () => { ) } +export const PrefixAndSuffix = () => { + return ( + + + console.log('onChange', value)} + /> + (value === 1 ? ' year' : ' years')} + onChange={(value) => console.log('onChange', value)} + /> + + + ) +} + export const Alignment = () => { return ( diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx index 0f7f1f6bc72..6211adb87ea 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/Number/demos.mdx @@ -22,6 +22,12 @@ import * as Examples from './Examples' +### Prefix and suffix + +You can also use a function as a prefix or suffix. + + + ### Alignment diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx index 7af70a386c5..97e03e542e1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx @@ -44,8 +44,8 @@ export type Props = FieldHelpProps & decimalLimit?: number allowNegative?: boolean disallowLeadingZeroes?: boolean - prefix?: string - suffix?: string + prefix?: string | ((value: number) => string) + suffix?: string | ((value: number) => string) // Validation minimum?: number // aka greater than or equal to maximum?: number // aka less than or equal to @@ -77,8 +77,8 @@ function NumberComponent(props: Props) { decimalLimit = 12, allowNegative = true, disallowLeadingZeroes = false, - prefix, - suffix, + prefix: prefixProp, + suffix: suffixProp, showStepControls, } = props @@ -127,52 +127,6 @@ function NumberComponent(props: Props) { [props.emptyValue] ) - const maskProps: Partial = useMemo(() => { - const mask_options = { - prefix, - suffix, - decimalLimit, - allowNegative, - disallowLeadingZeroes, - } - - if (currency) { - return { - as_currency: currency, - mask_options, - currency_mask: { - currencyDisplay, - }, - } - } - - if (percent) { - return { - as_percent: percent, - mask_options, - } - } - - // Custom mask based on props - return { - as_number: true, - mask, - number_mask: { - ...mask_options, - }, - } - }, [ - currency, - currencyDisplay, - decimalLimit, - mask, - percent, - prefix, - suffix, - allowNegative, - disallowLeadingZeroes, - ]) - const preparedProps: Props = { valueType: 'number', ...props, @@ -333,6 +287,57 @@ function NumberComponent(props: Props) { ), } + const prefix = + typeof prefixProp === 'function' ? prefixProp(value) : prefixProp + const suffix = + typeof suffixProp === 'function' ? suffixProp(value) : suffixProp + + const maskProps: Partial = useMemo(() => { + const mask_options = { + prefix, + suffix, + decimalLimit, + allowNegative, + disallowLeadingZeroes, + } + + if (currency) { + return { + as_currency: currency, + mask_options, + currency_mask: { + currencyDisplay, + }, + } + } + + if (percent) { + return { + as_percent: percent, + mask_options, + } + } + + // Custom mask based on props + return { + as_number: true, + mask, + number_mask: { + ...mask_options, + }, + } + }, [ + currency, + currencyDisplay, + decimalLimit, + mask, + percent, + prefix, + suffix, + allowNegative, + disallowLeadingZeroes, + ]) + const ariaParams = showStepControls && { role: 'spinbutton', 'aria-valuemin': String(minimum), diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Number/__tests__/Number.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Number/__tests__/Number.test.tsx index f2f281910c6..cc238a0e9f6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Number/__tests__/Number.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Number/__tests__/Number.test.tsx @@ -220,6 +220,30 @@ describe('Field.Number', () => { '12 345 suffix' ) }) + + it('formats with prefix as a function', () => { + const prefix = jest.fn(() => { + return 'prefix ' + }) + render() + expect(document.querySelector('input')).toHaveValue( + 'prefix 12 345 kr' + ) + expect(prefix).toHaveBeenCalledTimes(1) + expect(prefix).toHaveBeenCalledWith(12345) + }) + + it('formats with suffix as a function', () => { + const suffix = jest.fn(() => { + return ' suffix' + }) + render() + expect(document.querySelector('input')).toHaveValue( + '12 345 suffix' + ) + expect(suffix).toHaveBeenCalledTimes(1) + expect(suffix).toHaveBeenCalledWith(12345) + }) }) describe('decimalLimit', () => { From 8a2da43f0e82d4d09c8a472771e6eb286275a2be Mon Sep 17 00:00:00 2001 From: Anders Date: Mon, 2 Sep 2024 12:07:20 +0200 Subject: [PATCH 16/17] feat(Forms): add validation for dnr and fnr in `Field.NationalIdentityNumber` (#3771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation from https://dnb-it.slack.com/archives/CMXABCHEY/p1720521252444219 --------- Co-authored-by: Tobias Høegh --- .../NationalIdentityNumber/Examples.tsx | 22 ++ .../NationalIdentityNumber/demos.mdx | 18 +- .../NationalIdentityNumber/info.mdx | 4 +- packages/dnb-eufemia/package.json | 1 + .../NationalIdentityNumber.tsx | 48 ++++- .../__tests__/NationalIdentityNumber.test.tsx | 202 +++++++++++++++++- .../NationalIdentityNumber.stories.tsx | 16 ++ .../forms/constants/locales/en-GB.ts | 2 + .../forms/constants/locales/nb-NO.ts | 2 + yarn.lock | 8 + 10 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/stories/NationalIdentityNumber.stories.tsx diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx index dd6623f243f..b96e3f62225 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx @@ -112,6 +112,28 @@ export const ValidationRequired = () => { ) } +export const ValidationFnr = () => { + return ( + + + + ) +} + +export const ValidationDnr = () => { + return ( + + + + ) +} + export const ValidationFunction = () => { return ( diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/demos.mdx index 298a245bf04..097fd7e3b2e 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/demos.mdx @@ -42,8 +42,24 @@ import * as Examples from './Examples' +### Validation - Norwegian national identity numbers + +It validates [Norwegian national identity numbers(fnr)](https://www.skatteetaten.no/en/person/national-registry/identitetsnummer/fodselsnummer/) using the [fnrvalidator](https://github.com/navikt/fnrvalidator). + +Below is an example of the error message displayed when there's an invalid Norwegian national identity number(fnr): + + + +### Validation - D numbers + +It validates [D numbers](https://www.skatteetaten.no/en/person/national-registry/identitetsnummer/d-nummer/) using the [fnrvalidator](https://github.com/navikt/fnrvalidator). + +Below is an example of the error message displayed when there's an invalid D number: + + + ### Validation function -You can provide your own validation function or may use the one [from NAV](https://github.com/navikt/fnrvalidator). +You can provide your own validation function. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/info.mdx index 32c213ea342..07c5e89299d 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/info.mdx @@ -6,9 +6,11 @@ showTabs: true `Field.NationalIdentityNumber` is a wrapper component for the [input of strings](/uilib/extensions/forms/base-fields/String), with user experience tailored for national identity number values. -This field is meant for norwegian national identity numbers, and therefor takes a 11-digit string as a value. A norwegian national identity number can have a leading zero, hence why its a string and not a number. +This field is meant for [norwegian national identity numbers(fnr)](https://www.skatteetaten.no/en/person/national-registry/identitetsnummer/fodselsnummer/) and [D numbers](https://www.skatteetaten.no/en/person/national-registry/identitetsnummer/d-nummer/), and therefor takes a 11-digit string as a value. A norwegian national identity number can have a leading zero, hence why its a string and not a number. More info can be found at [Skatteetaten](https://www.skatteetaten.no/en/person/national-registry/identitetsnummer/fodselsnummer/#:~:text=A%20national%20identity%20number%20consists,national%20identity%20number%20are%20220676) +It validates input for [norwegian national identity numbers(fnr)](https://www.skatteetaten.no/en/person/national-registry/identitetsnummer/fodselsnummer/) and [D numbers](https://www.skatteetaten.no/en/person/national-registry/identitetsnummer/d-nummer/) using the [fnrvalidator](https://github.com/navikt/fnrvalidator). + ```jsx import { Field } from '@dnb/eufemia/extensions/forms' render() diff --git a/packages/dnb-eufemia/package.json b/packages/dnb-eufemia/package.json index 5b99550afe6..a7a21ec637e 100644 --- a/packages/dnb-eufemia/package.json +++ b/packages/dnb-eufemia/package.json @@ -106,6 +106,7 @@ "typings": "./index.d.ts", "dependencies": { "@babel/runtime": "7.22.5", + "@navikt/fnrvalidator": "1.3.0", "@ungap/structured-clone": "1.2.0", "ajv": "8.14.0", "ajv-errors": "3.0.0", diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx index 30ce1fbd30d..2f09f81749b 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx @@ -1,5 +1,6 @@ -import React, { useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import StringField, { Props as StringFieldProps } from '../String' +import { dnr, fnr } from '@navikt/fnrvalidator' import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' @@ -40,17 +41,58 @@ function NationalIdentityNumber(props: Props) { ], [omitMask] ) + const validationPattern = '^[0-9]{11}$' + + const fnrValidator = useCallback( + (value: string) => { + if ( + new RegExp(validationPattern).test(value) && + fnr(value).status === 'invalid' + ) { + return Error(translations.errorFnr) + } + return undefined + }, + [translations.errorFnr] + ) + + const dnrValidator = useCallback( + (value: string) => { + const validationPattern = '^[4-7]([0-9]{10}$)' // 1st num is increased by 4. i.e, if 01.01.1985, D number would be 410185. + if ( + new RegExp(validationPattern).test(value) && + dnr(value).status === 'invalid' + ) { + return Error(translations.errorDnr) + } + return undefined + }, + [translations.errorDnr] + ) + + const dnrAndFnrValidator = useCallback( + (value: string) => { + return dnrValidator(value) || fnrValidator(value) + }, + [dnrValidator, fnrValidator] + ) const StringFieldProps: Props = { ...props, pattern: - props.pattern ?? - (validate && !props.validator ? '^[0-9]{11}$' : undefined), + validate && props.pattern + ? props.pattern + : validate && !props.validator + ? validationPattern + : undefined, label: props.label ?? translations.label, errorMessages, mask, width: props.width ?? 'medium', inputMode: 'numeric', + validator: validate + ? props.validator || dnrAndFnrValidator + : undefined, } return diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx index a821e122fae..eb05b2b7697 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx @@ -1,7 +1,14 @@ import React from 'react' -import { fireEvent, render, waitFor } from '@testing-library/react' +import { fireEvent, render, waitFor, screen } from '@testing-library/react' import { Props } from '..' import { Field, Form } from '../../..' +import nbNO from '../../../constants/locales/nb-NO' + +const nb = nbNO['nb-NO'] + +async function expectNever(callable: () => unknown): Promise { + await expect(() => waitFor(callable)).rejects.toEqual(expect.anything()) +} describe('Field.NationalIdentityNumber', () => { it('should render with props', () => { @@ -39,27 +46,25 @@ describe('Field.NationalIdentityNumber', () => { '.dnb-forms-submit-button' ) - expect( - document.querySelector('.dnb-form-status') - ).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() fireEvent.click(buttonElement) - expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toBeInTheDocument() }) - it('should execute validateInitially if required', () => { + it('should execute validateInitially if required', async () => { const { rerender } = render( ) - expect(document.querySelector('.dnb-form-status')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toBeInTheDocument() rerender() - expect( - document.querySelector('.dnb-form-status') - ).not.toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) }) it('should validate given function', async () => { @@ -115,4 +120,181 @@ describe('Field.NationalIdentityNumber', () => { expect(input).toHaveAttribute('inputmode', 'numeric') }) + + it('should not validate pattern when validate false', async () => { + const invalidPattern = '1234' + render( + + ) + await expectNever(() => { + // Can't just waitFor and expect not to be in the document, it would approve the first render before the error might appear async. + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + }) + + it('should not validate custom pattern when validate false', async () => { + const invalidPattern = '1234' + render( + + ) + await expectNever(() => { + // Can't just waitFor and expect not to be in the document, it would approve the first render before the error might appear async. + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + }) + + it('should not validate dnum when validate false', async () => { + const invalidDnum = '69020112345' + render( + + ) + await expectNever(() => { + // Can't just waitFor and expect not to be in the document, it would approve the first render before the error might appear async. + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + }) + + it('should not validate fnr when validate false', async () => { + const invalidFnr = '29020112345' + render( + + ) + await expectNever(() => { + // Can't just waitFor and expect not to be in the document, it would approve the first render before the error might appear async. + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + }) + + it('should not validate custom validator when validate false', async () => { + const text = 'Custom Error message' + const validator = jest.fn((value) => { + return value.length < 4 ? new Error(text) : undefined + }) + + render( + + ) + + await expectNever(() => { + // Can't just waitFor and expect not to be in the document, it would approve the first render before the error might appear async. + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + }) + + describe('should validate Norwegian D number', () => { + const validDNum = [ + '53097248016', + '51041678171', + '58081633086', + '53050129159', + '65015439860', + '51057844748', + '71075441007', + ] + + const invalidDNum = [ + '69020112345', + '53097248032', + '53097248023', + '72127248022', + '53137248022', + ] + + it.each(validDNum)('Valid D number: %s', async (dNum) => { + render( + + ) + await expectNever(() => { + // Can't just waitFor and expect not to be in the document, it would approve the first render before the error might appear async. + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + }) + + it.each(invalidDNum)('Invalid D number: %s', async (dNum) => { + render( + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorDnr + ) + }) + }) + }) + + describe('should validate Norwegian national identity number(fnr)', () => { + const validFnrNum = [ + '08121312590', + '12018503288', + '03025742965', + '14046512368', + '21033601864', + '27114530463', + '07014816857', + '11069497545', + '22032012969', + '10042223293', + ] + + const invalidFnrNum = [ + '29020112345', + '13097248032', + '13097248023', + '32127248022', + '13137248022', + ] + + it.each(validFnrNum)( + 'Valid national identity number(fnr): %s', + async (fnrNum) => { + render( + + ) + await expectNever(() => { + // Can't just waitFor and expect not to be in the document, it would approve the first render before the error might appear async. + expect(screen.queryByRole('alert')).toBeInTheDocument() + }) + } + ) + + it.each(invalidFnrNum)( + 'Invalid national identity number(fnr): %s', + async (fnrNum) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorFnr + ) + }) + } + ) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/stories/NationalIdentityNumber.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/stories/NationalIdentityNumber.stories.tsx new file mode 100644 index 00000000000..a6870b12bf2 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/stories/NationalIdentityNumber.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { Field } from '../../..' + +export default { + title: 'Eufemia/Extensions/Forms/NationalIdentityNumber', +} + +export function NationalIdentityNumber() { + return ( + <> + + + + + ) +} 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 821d946aad6..15862f5b307 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 @@ -116,6 +116,8 @@ export default { label: 'National identity number (11 digits)', errorRequired: 'Invalid national identity number. Enter a valid 11-digit number.', + errorFnr: 'Invalid national identity number.', + errorDnr: 'Invalid D number.', }, OrganizationNumber: { label: 'Organisation number', 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 8eb8c39dfac..1941d5e5208 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 @@ -115,6 +115,8 @@ export default { label: 'Fødselsnummer (11 siffer)', errorRequired: 'Ugyldig fødselsnummer. Skriv inn et gyldig fødselsnummer med 11 siffer.', + errorFnr: 'Ugyldig fødselsnummer.', + errorDnr: 'Ugyldig d-nummer.', }, OrganizationNumber: { label: 'Organisasjonsnummer', diff --git a/yarn.lock b/yarn.lock index 1759dd07429..713823afd00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3424,6 +3424,7 @@ __metadata: "@babel/traverse": "npm:7.23.2" "@emotion/react": "npm:11.11.0" "@emotion/styled": "npm:11.11.0" + "@navikt/fnrvalidator": "npm:1.3.0" "@playwright/test": "npm:1.42.1" "@rollup/plugin-babel": "npm:6.0.3" "@rollup/plugin-commonjs": "npm:24.0.1" @@ -5170,6 +5171,13 @@ __metadata: languageName: node linkType: hard +"@navikt/fnrvalidator@npm:1.3.0": + version: 1.3.0 + resolution: "@navikt/fnrvalidator@npm:1.3.0" + checksum: 97ed8e0dd1d10501d811c39695adba7dcde90a14fd84a7fa94bb0412e9e21786f56cee69a638fd6daa01eede15f010a1de17f08da9de698037eb4757f46bf1a0 + languageName: node + linkType: hard + "@ndelangen/get-tarball@npm:^3.0.7": version: 3.0.9 resolution: "@ndelangen/get-tarball@npm:3.0.9" From f7686138161ffc87c96dfb1389e3a8f16b2ea0ca Mon Sep 17 00:00:00 2001 From: Anders Date: Mon, 2 Sep 2024 12:08:27 +0200 Subject: [PATCH 17/17] fix(Forms): fallback to use default locale for translations when providing a non-existent locale (#3817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/dnbexperience/eufemia/issues/3818 --------- Co-authored-by: Tobias Høegh --- .../__tests__/ChildrenWithAge.test.tsx | 12 ++++++++++++ packages/dnb-eufemia/src/shared/Context.tsx | 11 +++++------ .../src/shared/__tests__/Context.test.tsx | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/ChildrenWithAge.test.tsx b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/ChildrenWithAge.test.tsx index 1e51b421810..25d0939c254 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/ChildrenWithAge.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/ChildrenWithAge.test.tsx @@ -181,6 +181,18 @@ describe('ChildrenWithAge', () => { ) }) + it('should display default translations when providing a non-existent locale to Form.Handler', () => { + render( + + + + ) + + expect(document.querySelector('.dnb-p')).toHaveTextContent( + 'Antall barn' + ) + }) + it('should match snapshot', () => { const generateRef = React.createRef() diff --git a/packages/dnb-eufemia/src/shared/Context.tsx b/packages/dnb-eufemia/src/shared/Context.tsx index 47f592bcf69..be9f01168ef 100644 --- a/packages/dnb-eufemia/src/shared/Context.tsx +++ b/packages/dnb-eufemia/src/shared/Context.tsx @@ -272,7 +272,8 @@ export function prepareContext( const context = { ...props, updateTranslation: (locale, newTranslations) => { - context.translation = newTranslations[locale] + context.translation = + newTranslations[locale] || newTranslations[LOCALE] context.translations = newTranslations if (context.locales) { @@ -309,12 +310,10 @@ function handleLocaleFallbacks( locale: InternalLocale | AnyLocale, translations: Translations = {} ) { - if (!translations[locale]) { - if (locale === 'en' || String(locale).split('-')[0] === 'en') { - return 'en-GB' - } + if (locale === 'en' || String(locale).split('-')[0] === 'en') { + return 'en-GB' } - return locale + return translations[locale] ? locale : LOCALE } // If no provider is given, we use the default context from here diff --git a/packages/dnb-eufemia/src/shared/__tests__/Context.test.tsx b/packages/dnb-eufemia/src/shared/__tests__/Context.test.tsx index db595667e6f..043d4db950c 100644 --- a/packages/dnb-eufemia/src/shared/__tests__/Context.test.tsx +++ b/packages/dnb-eufemia/src/shared/__tests__/Context.test.tsx @@ -117,4 +117,22 @@ describe('Context', () => { rerender(content) expect(screen.queryByLabelText(title_gb)).toBeInTheDocument() }) + + it('should support fallback "translation" for non-existent locale', () => { + let translation = undefined + + render( + + + {(context) => { + translation = context.translation + return null + }} + + + ) + + expect(translation).not.toBeUndefined() + expect(translation.DatePicker.month).toBe('måned') + }) })