diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b6459ea98a3..c83e60b854e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -38,7 +38,7 @@ jobs: visual-test: name: Run visual e2e-tests - runs-on: macos-latest + runs-on: macos-15 timeout-minutes: 40 @@ -49,12 +49,12 @@ jobs: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Use yarn cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: yarn-cache with: path: ./.yarn/cache @@ -65,7 +65,7 @@ jobs: run: yarn install --immutable - name: Use Playwright cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: playwright-cache with: path: | @@ -86,15 +86,13 @@ jobs: if: env.RUN_POST_BUILD == 'true' run: yarn workspace @dnb/eufemia postbuild:ci - # restore-keys: ${{ secrets.CACHE_VERSION }}-${{ runner.os }}-gatsby- - - name: Build portal run: yarn workspace dnb-design-system-portal build:visual-test - name: Run visual tests run: yarn workspace dnb-design-system-portal test:screenshots:ci - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: failure() with: name: visual-test-artifact @@ -130,12 +128,12 @@ jobs: - uses: actions/checkout@v4 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Use node_modules cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: modules-cache with: path: '**/node_modules' @@ -146,7 +144,7 @@ jobs: run: yarn install --immutable - name: Use Playwright cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: playwright-cache with: path: | @@ -175,7 +173,7 @@ jobs: run: yarn workspace @dnb/eufemia test:e2e:ci - name: Store Playwright artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() with: name: playwright-develop-artifact diff --git a/.github/workflows/icons-lib.yml b/.github/workflows/icons-lib.yml index 780232b9674..403f5be7c2c 100644 --- a/.github/workflows/icons-lib.yml +++ b/.github/workflows/icons-lib.yml @@ -35,12 +35,12 @@ jobs: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Use node_modules cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: modules-cache with: path: '**/node_modules' @@ -64,7 +64,7 @@ jobs: run: yarn workspace dnb-design-system-portal build:visual-test - name: Store portal artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: portal-build-artifact path: ./packages/dnb-design-system-portal/public @@ -87,12 +87,12 @@ jobs: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Use yarn cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: yarn-cache with: path: ./.yarn/cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baad1287b10..9a997d9e8a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,12 +40,12 @@ jobs: fetch-depth: 2 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Use node_modules cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: modules-cache with: path: '**/node_modules' @@ -70,7 +70,7 @@ jobs: - name: Deploy portal if: (github.ref == 'refs/heads/release' || github.ref == 'refs/heads/portal') - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 with: personal_token: ${{ secrets.GH_TOKEN }} publish_dir: ./packages/dnb-design-system-portal/public diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 585df4e44a5..26aef7ea466 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -38,12 +38,12 @@ jobs: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Use node_modules cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: modules-cache with: path: '**/node_modules' @@ -92,12 +92,12 @@ jobs: fetch-depth: 20 # The "postbuild:ci" method "getCommittedFiles" needs all history - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Use node_modules cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: modules-cache with: path: '**/node_modules' diff --git a/packages/dnb-design-system-portal/package.json b/packages/dnb-design-system-portal/package.json index 7a76e31e8e6..8cd70d83e41 100644 --- a/packages/dnb-design-system-portal/package.json +++ b/packages/dnb-design-system-portal/package.json @@ -26,11 +26,13 @@ "prettier:write": "prettier --log-level warn --write '**/*.{md,mdx,js,ts,tsx}'", "reset": "yarn clean && rm -rf ./node_modules", "serve": "gatsby serve -p 8000", + "serve:8001": "gatsby serve -p 8001", + "serve:8002": "gatsby serve -p 8002", "start": "cross-env NODE_OPTIONS=--max-old-space-size=8192 gatsby develop", "test": "jest", "test:ci": "jest --ci --passWithNoTests", "test:e2e:portal": "yarn playwright test", - "test:e2e:portal:ci": "start-server-and-test serve http://localhost:8000 test:e2e:portal", + "test:e2e:portal:ci": "start-server-and-test serve:8002 http://localhost:8002 test:e2e:portal", "test:e2e:portal:watch": "playwright test --ui", "test:screenshots": "yarn workspace @dnb/eufemia test:screenshots", "test:screenshots:ci": "start-server-and-test serve http://localhost:8000 'yarn workspace @dnb/eufemia test:screenshots:ci'", diff --git a/packages/dnb-design-system-portal/playwright.config.ts b/packages/dnb-design-system-portal/playwright.config.ts index 8d7680dddf9..fefaeb48f09 100644 --- a/packages/dnb-design-system-portal/playwright.config.ts +++ b/packages/dnb-design-system-portal/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ use: { // Base URL to use in actions like `await page.goto('/')`. - baseURL: 'http://localhost:8000', + baseURL: 'http://localhost:8002', // Name of the browser that runs tests. For example `chromium`, `firefox`, `webkit`. browserName: 'firefox', diff --git a/packages/dnb-design-system-portal/src/docs/quickguide-designer/accessibility.mdx b/packages/dnb-design-system-portal/src/docs/quickguide-designer/accessibility.mdx index 1af50d9ceb7..12a5d52ed13 100644 --- a/packages/dnb-design-system-portal/src/docs/quickguide-designer/accessibility.mdx +++ b/packages/dnb-design-system-portal/src/docs/quickguide-designer/accessibility.mdx @@ -12,7 +12,7 @@ Remember - test for accessibility early in the design process. ## Useful Resources - [What is Universal Design? (DNB Sharepoint)](https://dnbasa.sharepoint.com/sites/n1317) -- [Gjeldende regelverk og krav (Uutilsynet)](https://www.uutilsynet.no/regelverk/gjeldende-regelverk-og-krav/746) (in norwegian) +- [Gjeldende regelverk og krav (Uutilsynet)](https://www.uutilsynet.no/regelverk/gjeldende-regelverk-og-krav/746) (in Norwegian) ## WCAG (Web Content and Accessibility Guide) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/about-the-lib/releases/dnb-ui-lib/v4.10-info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/about-the-lib/releases/dnb-ui-lib/v4.10-info.mdx index f2bbfdb4a62..fd9ba72b1cc 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/about-the-lib/releases/dnb-ui-lib/v4.10-info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/about-the-lib/releases/dnb-ui-lib/v4.10-info.mdx @@ -16,7 +16,7 @@ redirect_from: ## Since last release info - [Dropdown](/uilib/components/dropdown) got several new properties like `prevent_selection` (Popup Menu), `size` (small), `align_options` (right) and `more_menu`. For the `more_menu`, have a look at the Demos and also take a look on **Popup Menu**. -- [DatePicker](/uilib/components/date-picker) is now using v2 of `date-fns` and with this more east to translate to english (including new props `submit_button_text` and `cancel_button_text`), as Norwegian (`nb`) is the default `locale`. But also `align_picker` is a nice feature to have. +- [DatePicker](/uilib/components/date-picker) is now using v2 of `date-fns` and with this more east to translate to English (including new props `submit_button_text` and `cancel_button_text`), as Norwegian (`nb`) is the default `locale`. But also `align_picker` is a nice feature to have. - Also mostly every "from" component now supports HTML `data-*` attributes in event returns. ## GlobalStatus diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/number-format/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/components/number-format/Examples.tsx index dfeb38cc808..fc4cc9bb44a 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/number-format/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/number-format/Examples.tsx @@ -134,7 +134,7 @@ export const NumberPhone = () => (

- + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/skeleton/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/skeleton/info.mdx index 2e9e746d9fa..8439eb217a4 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/skeleton/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/skeleton/info.mdx @@ -18,15 +18,17 @@ import { Skeleton } from '@dnb/eufemia' ## Description -The Skeleton component is a visual building block helper. It will provide loading placeholders that display a non-interactive preview of the app’s actual UI to visually communicate that content is being processed. +The Skeleton component is a visual building block that helps provide loading placeholders. It displays a non-interactive preview of the actual UI of the component, visually communicating that content is being processed. -### Take in consideration +After 5 seconds an animation is shown that times out after 30 seconds. -It has to be used carefully and not as a quick loading indicator replacement. The reason lays in that, that the browser will use additional resources to render the additional state. And if it is misused, like showing not a nearly identical UI or it is shown for just a fraction of a second, then it will rather distract the user experience, than enhance it. +## Take in consideration -Also, the fact, that in some setups, the user is first downloading almost the whole web application before we actually are able to show some skeletons during the API calls. +It should be used carefully and not as a quick loading indicator replacement. The browser will use additional resources to render the additional state. If it is misused, such as showing a significantly different UI or being shown for just a fraction of a second, it can distract from the user experience rather than enhancing it. -#### Gatsby +Also, in some setups, the user may need to download almost the entire web application before skeletons can be shown during API calls. + +### Gatsby Gatsby as a framework makes the perfect fit to utilize a good skeleton user experience from the very first-page visit. Every page is optimized to load as fast as possible (in addition to page preloading and PWA). We can take advantage of this and show our skeleton as our initial state. @@ -35,33 +37,33 @@ Gatsby as a framework makes the perfect fit to utilize a good skeleton user expe 1. Now our applications renders. 1. And finally, we have the user data to display. -### Accessibility +## Accessibility - Elements and components should be still responsive to screen width and font-size. - Screen readers will get a mention that the loading state has finished as a aria-live update. - Components and interactive elements are not accessible for keyboard users. -### When not to use +## When not to use - For low-traffic pages, such as super-user-only admin pages, use a loading spinner instead. - For a tiny, inline action or feedback, e.g. clicked a button and the action will take time, use the [ProgressIndicator](/uilib/components/progress-indicator) instead (animation). - For fast processes that take less than `300ms`, consider the [ProgressIndicator](/uilib/components/progress-indicator) or no loading state at all. - For a background process or a long-running process, e.g. importing data or exporting reports, use the [ProgressIndicator](/uilib/components/progress-indicator) instead (percentage). -### When to use +## When to use - Use on high-traffic pages and landing pages, if they require a loading state. - Use when there’s more than one element loading at the same time that requires an indicator. - Use when the process would take more than `300ms` to load on an average internet connection. - Use the Skeleton component when the [ProgressIndicator](/uilib/components/progress-indicator) is not prominent enough. -### How to use +## How to use You can use the Skeleton component as a provider for all underlying components, like inputs and buttons. This way, you can simply toggle on and off the skeletons. And all the spacing and sizing will be given from the components themselves. But you can also use the Skeleton component to show a fake article or other figures. -### How it works +## How it works Every Eufemia component should support a skeleton natively. But for simplification, you can use the Skeleton component as a provider, so enable the skeletons for a group of components. @@ -71,25 +73,25 @@ But the Skeleton component also supports a set of ready-to-use figures. Use it l -### Global Provider +## Global Provider You can also use the global [Eufemia Provider](/uilib/usage/customisation/provider) to enable the underlying skeletons. You can even have multiple providers wrapped. -### Exclude a part +## Exclude a part You can easily exclude a part from being transformed to a skeleton by using `Skeleton.Exclude`. -### Suspense +## Suspense You can take advantage of an async component by using the React Suspense with a skeleton fallback. -### Create a custom skeleton +## Create a custom skeleton In order to create the same skeletons as the build-ins, you can make use of a couple of helper tools. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/elements/heading/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/elements/heading/Examples.tsx index 2757ecd91cc..e6b8ffe4644 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/elements/heading/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/elements/heading/Examples.tsx @@ -51,16 +51,19 @@ export const HeadingModifiersExample = () => (

- dnb-h--x-large Normal dnb-h--xx-large + .dnb-h--xx-large small

+

+ .dnb-h--x-large small +

- Normal dnb-h--large dnb-h--medium + .dnb-h--large small

- Normal dnb-h--medium dnb-h--basis + .dnb-h--medium small

- Normal dnb-lead small + .dnb-lead small

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 b5202311e29..aa91ca36952 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 @@ -1,7 +1,12 @@ import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Form, Field, Value } from '@dnb/eufemia/src/extensions/forms' +import { + Form, + Field, + Value, + Tools, +} from '@dnb/eufemia/src/extensions/forms' import { stop as stopIcon } from '@dnb/eufemia/src/icons' -import { Button, Card, Flex, P, Section } from '@dnb/eufemia/src' +import { Button, Card, Flex, P } from '@dnb/eufemia/src' import { debounceAsync } from '@dnb/eufemia/src/shared/helpers/debounce' import { createRequest } from '../SubmitIndicator/Examples' @@ -336,7 +341,7 @@ export const VisibleData = () => { export const FilterData = () => { return ( - + {() => { const id = 'my-form' const filterDataHandler = ({ props }) => !props.disabled @@ -374,22 +379,10 @@ export const FilterData = () => { const { hasErrors } = Form.useValidation(id) return ( -
- hasErrors: {JSON.stringify(hasErrors(), null, 2)} -
-                {JSON.stringify(
-                  replaceUndefinedValues(filterData(filterDataHandler)),
-                  null,
-                  2,
-                )}
-              
-
+ <> + + + ) } @@ -403,30 +396,3 @@ export const FilterData = () => {
) } - -/** - * Replaces undefined values in an object with a specified replacement value. - * @param value - The value to check for undefined values. - * @param replaceWith - The value to replace undefined values with. Default is null. - * @returns The object with undefined values replaced. - */ -function replaceUndefinedValues( - value: unknown, - replaceWith = null, -): unknown { - if (typeof value === 'undefined') { - return replaceWith - } else if (typeof value === 'object' && value !== replaceWith) { - return { - ...value, - ...Object.fromEntries( - Object.entries(value).map(([k, v]) => [ - k, - replaceUndefinedValues(v), - ]), - ), - } - } else { - return value - } -} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/Examples.tsx index a8caa60589d..9f5371c66e2 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/Examples.tsx @@ -1,6 +1,6 @@ import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Card, Flex, HeightAnimation, Section } from '@dnb/eufemia/src' -import { Field, Form } from '@dnb/eufemia/src/extensions/forms' +import { Card, Flex, HeightAnimation } from '@dnb/eufemia/src' +import { Field, Form, Tools } from '@dnb/eufemia/src/extensions/forms' import React from 'react' export const UsingCommitButton = () => { @@ -40,7 +40,7 @@ export const UsingCommitButton = () => { export const CommitHandleRef = () => { return ( - + {() => { const MyForm = () => { const commitHandleRef = React.useRef(null) @@ -88,7 +88,7 @@ export const CommitHandleRef = () => { - + @@ -103,20 +103,6 @@ export const CommitHandleRef = () => { ) } - const Log = () => { - const { data } = Form.useData() - return ( -
- {JSON.stringify(data || {}, null, 4)} -
- ) - } - return }}
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/info.mdx index 078909fcdcb..d28b6bcd1d9 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Isolation/info.mdx @@ -65,6 +65,23 @@ function MyForm() { render() ``` +## Prevent the form from being submitted + +To prevent the [Form.Handler](/uilib/extensions/forms/Form/Handler/) from being submitted when there are fields with errors inside the Isolation, you can use the `bubbleValidation` property. + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' + +render( + + + + + + , +) +``` + ## Schema support You can also use a `schema` to define the properties of the nested fields: diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/Examples.tsx index 0e466ad18e2..a8f08f769b9 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Section/Examples.tsx @@ -1,10 +1,11 @@ import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Card, Code, Flex, P, Section } from '@dnb/eufemia/src' +import { Card, Flex, P } from '@dnb/eufemia/src' import { Field, Form, JSONSchema, SectionProps, + Tools, Value, } from '@dnb/eufemia/src/extensions/forms' @@ -362,7 +363,7 @@ export const SchemaSupport = () => { export const WithVisibility = () => { return ( - + {() => { const MySection = ({ children, ...props }) => { return ( @@ -397,7 +398,7 @@ export const WithVisibility = () => { {children} - + ) } @@ -522,21 +523,3 @@ export const NestedSections = () => { ) } - -const Output = () => { - const { data } = Form.useData() - - return ( -
- -
{JSON.stringify(data, null, 2)}
-
-
- ) -} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/Examples.tsx index e30f7ddcc97..480d8b08450 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/Examples.tsx @@ -1,10 +1,11 @@ import React from 'react' import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Card, Flex, HeightAnimation, P, Section } from '@dnb/eufemia/src' +import { Card, Flex, HeightAnimation, P } from '@dnb/eufemia/src' import { Field, Form, TestElement, + Tools, Value, } from '@dnb/eufemia/src/extensions/forms' @@ -118,7 +119,7 @@ export const BasedOnContext = () => { export const NestedExample = () => { return ( - + {() => { const filterDataHandler = ({ props }) => !props['data-exclude-field'] @@ -176,16 +177,7 @@ export const NestedExample = () => { const { filterData } = Form.useData() const filteredData = filterData(filterDataHandler) - return ( -
- {JSON.stringify(filteredData || {}, null, 4)} -
- ) + return } return @@ -196,7 +188,7 @@ export const NestedExample = () => { export const FilterData = () => { return ( - + {() => { const filterDataPaths = { '/isVisible': false, @@ -263,16 +255,7 @@ export const FilterData = () => { const Output = () => { const { filterData } = Form.useData() const filteredData = filterData(filterDataPaths) - return ( -
- {JSON.stringify(filteredData || {}, null, 4)} -
- ) + return } return @@ -310,3 +293,25 @@ export function InheritVisibility() {
) } + +export function VisibilityOnValidation() { + return ( + + + + + + + + + + + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/demos.mdx index 8f740308f40..95fc389932e 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/demos.mdx @@ -56,3 +56,7 @@ In this example we filter out all fields that have the `data-exclude-field` attr ### Inherit visibility + +### Show children when field has no errors (validation) + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/info.mdx index a693a8948ab..652b98ad820 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/info.mdx @@ -4,20 +4,71 @@ showTabs: true ## Description -The `Form.Visibility` component makes it possible to show or hide components on the screen based on the state of data. It can either be fed with the values directly via properties, or it can read data from a surrounding [Form.Handler](/uilib/extensions/forms/Form/Handler) and show or hide components based on the data it points to. +The `Form.Visibility` component allows you to conditionally show or hide components based on the state of data or field validation. You can either provide the values directly via properties or let it read data from a surrounding [Form.Handler](/uilib/extensions/forms/Form/Handler). This enables dynamic visibility control based on the paths it points to. + +### Data driven visibility + +There are several [properties](/uilib/extensions/forms/Form/Visibility/properties/) you can use to control visibility, such as `pathDefined`, `pathTruthy`, `pathTrue` etc. ```tsx -import { Form } from '@dnb/eufemia/extensions/forms' +import { Form, Field } from '@dnb/eufemia/extensions/forms' + render( <> - show me when the state value is true + show me when the data value is true + + , +) +``` + +#### Dynamic value driven visibility + +You can also use the `visibleWhen` property to conditionally show the children based on the data value of the path. + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' + +render( + <> + + value === true, + }} + > + show me when the data value is true + + , +) +``` + +### Validation driven visibility + +You can conditionally display children based on field validation by using the `visibleWhen` property with `isValid: true`: + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' + +render( + <> + + + show me when the validation succeeds , ) ``` +To prevent visibility changes during user interactions like typing, it shows the children first when the field both has no errors and has lost focus (blurred). You can use the `continuousValidation: true` property to immediately show the children when the field has no errors. + ## Accessibility Children of the `Form.Visibility` component will be hidden from screen readers when visually hidden, even if `keepInDOM` is enabled. You don't need to do anything to make the content additionally inaccessible. @@ -32,7 +83,7 @@ render( <> - show me when the state value is true + show me when the data value is true , ) @@ -48,7 +99,7 @@ render( <> - show me when the state value is true + show me when the data value is true , ) 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 aa5bbe1fb04..251252bf9b8 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 @@ -1,8 +1,12 @@ import React from 'react' import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Button, Flex, Section } from '@dnb/eufemia/src' -import { Form, Field, Value } from '@dnb/eufemia/src/extensions/forms' -import { ScrollView } from '@dnb/eufemia/src/fragments' +import { Button, Flex } from '@dnb/eufemia/src' +import { + Form, + Field, + Value, + Tools, +} from '@dnb/eufemia/src/extensions/forms' export function Default() { return ( @@ -100,7 +104,7 @@ export function WithoutFormHandler() { export function FilterData() { return ( - + {() => { const filterDataPaths = { '/isVisible': false, @@ -162,23 +166,13 @@ export function FilterData() { const { data, filterData } = Form.useData() return ( -
- -
-                  Filtered: 
- {JSON.stringify(filterData(filterDataPaths), null, 2)} -
-
-                  All data: 
- {JSON.stringify(data, null, 2)} -
-
-
+ <> + + + ) } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot.mdx new file mode 100644 index 00000000000..41f3a26d950 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot.mdx @@ -0,0 +1,23 @@ +--- +title: 'useSnapshot' +description: '`Form.useSnapshot` lets you store data snapshots of your form data, either inside or outside of the form context.' +showTabs: true +tabs: + - title: Info + key: '/info' + - title: Demos + key: '/demos' +breadcrumb: + - text: Forms + href: /uilib/extensions/forms/ + - text: Form + href: /uilib/extensions/forms/Form/ + - text: Form.useSnapshot + href: /uilib/extensions/forms/Form/useSnapshot/ +--- + +import Info from 'Docs/uilib/extensions/forms/Form/useSnapshot/info' +import Demos from 'Docs/uilib/extensions/forms/Form/useSnapshot/demos' + + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx new file mode 100644 index 00000000000..7162b4c0f6a --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/Examples.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import { Button, Card } from '@dnb/eufemia/src' +import ComponentBox from '../../../../../../shared/tags/ComponentBox' +import { + Field, + Form, + Tools, + Wizard, +} from '@dnb/eufemia/src/extensions/forms' + +export const InWizard = () => { + return ( + + {() => { + const MyForm = () => { + const { createSnapshot, revertSnapshot } = + Form.useSnapshot('my-form') + + return ( + + { + if (mode === 'previous') { + revertSnapshot(String(args.id), 'my-snapshot-slice') + } else { + createSnapshot( + args.previousStep.id, + 'my-snapshot-slice', + ) + } + }} + > + + + + + + + + + + + + + + + + ) + } + + return + }} + + ) +} + +export const UndoRedo = () => { + return ( + + {() => { + const MyComponent = () => { + const { createSnapshot, applySnapshot } = Form.useSnapshot() + const pointerRef = React.useRef(0) + + React.useEffect(() => { + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + + const changeHandler = React.useCallback(() => { + pointerRef.current += 1 + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + const undoHandler = React.useCallback(() => { + pointerRef.current -= 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + const redoHandler = React.useCallback(() => { + pointerRef.current += 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + + return ( + <> + + + + + + + + + + + + + + + ) + } + + return ( + + + + ) + }} + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx new file mode 100644 index 00000000000..365dcc720c1 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/demos.mdx @@ -0,0 +1,17 @@ +--- +showTabs: true +--- + +import * as Examples from './Examples' + +## Demos + +### Undo / Redo + + + +### Used in a Wizard + +This example reverts the form data to its previous state when the user navigates back to a previous step. + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx new file mode 100644 index 00000000000..b4d6895f3c1 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useSnapshot/info.mdx @@ -0,0 +1,107 @@ +--- +showTabs: true +--- + +## Description + +The `Form.useSnapshot` hook lets you store data snapshots of your form data, either inside or outside of the form context. + +```tsx +import { Form } from '@dnb/eufemia/extensions/forms' + +function MyComponent() { + const { createSnapshot, applySnapshot, revertSnapshot } = + Form.useSnapshot() + + return <>MyComponent +} + +render( + + + , +) +``` + +The hook returns an object with the following properties: + +- `createSnapshot` will store the current data as a new snapshot with the given id. +- `applySnapshot` will revert the data to the snapshot with the given id (required). +- `revertSnapshot` will revert the data to the snapshot with the given id (required). A reverted snapshot gets deleted from the memory. + +## Partial data snapshots + +In order to create and revert a snapshot for a specific part of the data context, you can use the `Form.Snapshot` component: + +```tsx +import { Form, Field } from '@dnb/eufemia/extensions/forms' + +function MyForm() { + return ( + + + + + + + + + ) +} +``` + +When calling the `createSnapshot` or `revertSnapshot` functions, you can pass in your snapshot `name` (my-snapshot-slice-name) as the second parameter. This will make sure that the snapshot is only applied to the given part of the form data. + +```tsx +createSnapshot('my-snapshot-1', 'my-snapshot-slice-name') +revertSnapshot('my-snapshot-1', 'my-snapshot-slice-name') +``` + +You can check out examples in the demo section. + +## Usage of the `Form.useSnapshot` hook + +You can use the `Form.useSnapshot` hook with or without an `id` (string) property, which is optional and can be used to link the data to a specific [Form.Handler](/uilib/extensions/forms/Form/Handler/) component. + +### Without an `id` property + +Here "Component" is rendered inside the `Form.Handler` component and does not need an `id` property to access the snapshot: + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' + +function MyForm() { + return ( + + + + ) +} + +function Component() { + const { createSnapshot, revertSnapshot } = Form.useSnapshot() +} +``` + +### With an `id` property + +While in this example, "Component" is outside the `Form.Handler` context, but linked together via the `id` (string) property: + +```jsx +import { Form } from '@dnb/eufemia/extensions/forms' + +function MyForm() { + return ( + <> + ... + + + ) +} + +function Component() { + const { createSnapshot, revertSnapshot } = Form.useSnapshot('unique') +} +``` + +This is beneficial when you need to utilize the form data in other places within your application. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/Examples.tsx index 2fb748720f5..b63e661d72b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/useValidation/Examples.tsx @@ -1,11 +1,11 @@ import React from 'react' import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Form, Field } from '@dnb/eufemia/src/extensions/forms' -import { Flex, Section } from '@dnb/eufemia/src' +import { Form, Field, Tools } from '@dnb/eufemia/src/extensions/forms' +import { Flex } from '@dnb/eufemia/src' export function HasErrors() { return ( - + {() => { const Component = () => { const { data } = Form.useData('default-id', { @@ -18,18 +18,16 @@ export function HasErrors() { return ( -
+ -
-                    hasErrors: {JSON.stringify(hasErrors(), null, 2)}
-                    hasFieldError:{' '}
-                    {JSON.stringify(hasFieldError('/foo'), null, 2)}
-                  
-
+ /> { return ( @@ -26,7 +27,9 @@ export const PrimitiveItemsValues = () => { return ( - + @@ -39,7 +42,7 @@ export const ValueComposition = () => { { { return ( { return ( console.log('onChange', value)} > {(elementValue) => } @@ -136,7 +139,7 @@ export const RenderPropsObjectItems = () => { return ( { return ( - + { ) } -export const InitialOpen = () => { +export const InitiallyOpen = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export const InitialOpenWithToolbarVariant = () => { return ( {() => { @@ -440,7 +498,7 @@ export const InitialOpen = () => { export const ToolbarVariantMiniumOneItemOneItem = () => { return ( - + View Content @@ -455,7 +513,7 @@ export const ToolbarVariantMiniumOneItemOneItem = () => { export const ToolbarVariantMiniumOneItemTwoItems = () => { return ( - + View Content diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx index cf0f461a075..7b8e20894be 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Array/demos.mdx @@ -51,11 +51,15 @@ With an optional `title` and [Iterate.Toolbar](/uilib/extensions/forms/Iterate/T ### Initially open + + +### Minium one item + This example uses the container's `toolbarVariant` property with the value `minimumOneItem`. It hides the toolbar in the `EditContainer` when there is only one item in the array. And it hides the remove button in the `ViewContainer` when there is only one item in the array. - + ### With DataContext and add/remove buttons diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Count.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Count.mdx index a0f46dc4df7..9dcaf49444a 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Count.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Count.mdx @@ -1,7 +1,6 @@ --- title: 'Count' description: '`Iterate.Count` is a helper component / function that returns the count of a data array or object.' -order: 11 showTabs: true tabs: - title: Info diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo.mdx new file mode 100644 index 00000000000..b7e8da841b3 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo.mdx @@ -0,0 +1,25 @@ +--- +title: 'ItemNo' +description: '`Iterate.ItemNo` is a helper component that can be used to render the current item number (index) in a given string.' +showTabs: true +tabs: + - title: Info + key: '/info' + - title: Demos + key: '/demos' +breadcrumb: + - text: Forms + href: /uilib/extensions/forms/ + - text: Extended features + href: /uilib/extensions/forms/ + - text: Iterate + href: /uilib/extensions/forms/Iterate/ + - text: ItemNo + href: /uilib/extensions/forms/Iterate/ItemNo/ +--- + +import Info from 'Docs/uilib/extensions/forms/Iterate/ItemNo/info' +import Demos from 'Docs/uilib/extensions/forms/Iterate/ItemNo/demos' + + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/Examples.tsx new file mode 100644 index 00000000000..270e261dd60 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/Examples.tsx @@ -0,0 +1,14 @@ +import ComponentBox from '../../../../../../shared/tags/ComponentBox' +import { Form, Iterate } from '@dnb/eufemia/src/extensions/forms' + +export const Default = () => { + return ( + + + + {'Item no. {itemNo}'} + + + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/demos.mdx new file mode 100644 index 00000000000..13d1e6b62f6 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/demos.mdx @@ -0,0 +1,11 @@ +--- +showTabs: true +--- + +import * as Examples from './Examples' + +## Demos + +### Default + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/info.mdx new file mode 100644 index 00000000000..03205372016 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/ItemNo/info.mdx @@ -0,0 +1,21 @@ +--- +showTabs: true +--- + +## Description + +`Iterate.ItemNo` is a helper component that can be used to render the current item number (index) in a given string. It will replace `{itemNo}` with the current item number. + +```tsx +import { Form, Iterate } from '@dnb/eufemia/extensions/forms' + +const myString = 'Item no. {itemNo}' + +render( + + + {myString} + + , +) +``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushButton/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushButton/Examples.tsx index a2feffe89c9..2ac4e0ffe45 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushButton/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushButton/Examples.tsx @@ -1,15 +1,27 @@ +import { Flex } from '@dnb/eufemia/src' import ComponentBox from '../../../../../../shared/tags/ComponentBox' -import { Iterate, Field, Form } from '@dnb/eufemia/src/extensions/forms' +import { + Iterate, + Field, + Form, + Value, +} from '@dnb/eufemia/src/extensions/forms' export const PrimitiveItems = () => { return ( - console.log('onChange', value)} - /> + + + + + + + + ) } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer.mdx index 2f2b6fe73a7..748b1c55a6c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer.mdx @@ -1,7 +1,7 @@ --- title: 'PushContainer' description: '`Iterate.PushContainer` enables users to create a new item in the array.' -order: 9 +order: 7 showTabs: true tabs: - title: Info diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx index b61a1910194..1672f72837b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/Examples.tsx @@ -2,10 +2,12 @@ import { Field, Form, Iterate, + Tools, Value, } from '@dnb/eufemia/src/extensions/forms' import ComponentBox from '../../../../../../shared/tags/ComponentBox' import { Card, Flex } from '@dnb/eufemia/src' +import React from 'react' export { ViewAndEditContainer } from '../Array/Examples' @@ -97,3 +99,167 @@ export const InitiallyOpen = () => { ) } + +export const IsolatedData = () => { + return ( + + {() => { + const formData = { + persons: [ + { + firstName: 'Ola', + lastName: 'Nordmann', + }, + { + firstName: 'Kari', + lastName: 'Nordmann', + }, + { + firstName: 'Per', + lastName: 'Hansen', + }, + ], + } + + function RepresentativesView() { + return ( + + + + + + + ) + } + + function RepresentativesEdit() { + return ( + + + + + ) + } + + function ExistingPersonDetails() { + const { data, getValue } = Form.useData() + const person = getValue(data.selectedPerson)?.data || {} + + return ( + + + + + ) + } + + function NewPersonDetails() { + return ( + + + + + ) + } + + function PushContainerContent() { + const { data, update } = Form.useData() + + // Clear the PushContainer data when the selected person is "other", + // so the fields do not inherit existing data. + React.useLayoutEffect(() => { + if (data.selectedPerson === 'other') { + update('/pushContainerItems/0', {}) + } + }, [data.selectedPerson, update]) + + return ( + + + + + + typeof value === 'string' && value !== 'other', + }} + > + + + + value === 'other', + }} + > + + + + ) + } + + function RepresentativesCreateNew() { + return ( + { + return { + title: [data.firstName, data.lastName].join(' '), + value: '/persons/' + i, + data, + } + }), + }} + openButton={ + + } + showOpenButtonWhen={(list) => list.length > 0} + > + + + ) + } + + return ( + + Representatives + + + + + + + + + + + Data Context + + + + + ) + }} + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/demos.mdx index 15771354746..7a199f4c691 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/demos.mdx @@ -14,3 +14,9 @@ import * as Examples from './Examples' ### With existing data + +### Isolated data + +This demo shows how to use the `isolatedData` property to provide data to the PushContainer. + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/info.mdx index 946aa6c04de..bf145b4bc5f 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/info.mdx @@ -5,13 +5,19 @@ hideInMenu: true ## Description -`Iterate.PushContainer` enables users to create a new item in the array. It can be used instead of the [PushButton](/uilib/extensions/forms/Iterate/PushButton/). +`Iterate.PushContainer` enables users to create a new item in the array. It can be used instead of the [PushButton](/uilib/extensions/forms/Iterate/PushButton/), but with fields in the container. It allows the user to fill in the fields without storing them in the data context. -Fields inside the container must have an `itemPath` defined. +Good to know: -You can place it below the [Array](/uilib/extensions/forms/Iterate/Array/) component like this: +- Fields inside the container must have an `itemPath` defined, instead of a `path`. +- You can provide `data` or `defaultData` to prefill the fields. +- The `path` you define needs to point to an existing [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) path. + +## Usage + +You may place it below the [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) component like this: ```tsx import { Iterate, Field } from '@dnb/eufemia/extensions/forms' @@ -27,7 +33,23 @@ render( ) ``` -Technically it uses the [EditContainer](/uilib/extensions/forms/Iterate/EditContainer/) and the [Form.Isolation](/uilib/extensions/forms/Form/Isolation/) under the hood. +## Prevent the form from being submitted + +To prevent the [Form.Handler](/uilib/extensions/forms/Form/Handler/) from being submitted when there are fields with errors inside the PushContainer, you can use the `bubbleValidation` property. + +```tsx +import { Form, Field, Iterate } from '@dnb/eufemia/extensions/forms' + +render( + + ... + + + + + , +) +``` ## Show a button to create a new item @@ -84,3 +106,9 @@ render( , ) ``` + +## Technical details + +Under the hood, it uses the [Form.Isolation](/uilib/extensions/forms/Form/Isolation/) component to isolate the data from the rest of the form. It also uses the the [EditContainer](/uilib/extensions/forms/Iterate/EditContainer/) inside the [Iterate.Array](/uilib/extensions/forms/Iterate/Array/) component to render the fields. + +All fields inside the container will be stored in the data context at this path: `/pushContainerItems/0`. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/properties.mdx index f1664abf510..516cfa1f8b9 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/PushContainer/properties.mdx @@ -5,12 +5,19 @@ hideInMenu: true import TranslationsTable from 'dnb-design-system-portal/src/shared/parts/TranslationsTable' import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' -import { PushContainerProperties } from '@dnb/eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs' +import { + PushContainerProperties, + PushContainerEvents, +} from '@dnb/eufemia/src/extensions/forms/Iterate/PushContainer/PushContainerDocs' ## Properties +## Events + + + ## Translations diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar.mdx index cb6f333183f..8a16d1dc731 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Iterate/Toolbar.mdx @@ -1,7 +1,6 @@ --- title: 'Toolbar' description: '`Iterate.Toolbar` is a helper component to be used within an `Iterate.AnimatedContainer` to add a toolbar to each item in the array.' -order: 10 showTabs: true tabs: - title: Info diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/Examples.tsx index bae3fdafb92..acecd524fec 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/Examples.tsx @@ -37,7 +37,17 @@ export const Label = () => { export const LabelAndValue = () => { return ( - + + + ) +} + +export const InternationalSuffix = () => { + return ( + + + + ) } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/demos.mdx index e98e0253f68..52e52cdcc70 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/PhoneNumber/demos.mdx @@ -29,3 +29,7 @@ import * as Examples from './Examples' ### Inline + +### International Suffix + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/info.mdx index 1f7538e33d1..30ebc3397e4 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/info.mdx @@ -70,3 +70,25 @@ const MyForm = () => { ) } ``` + +## Transform labels + +You can use `transformLabel` to transform the label before it gets displayed. + +```tsx + label.toUpperCase()} +/> +``` + +You can combine it with `inheritLabel` to transform the label from the field with the same path. + +And by using the [Value.Provider](/uilib/extensions/forms/Value/Provider/), you can transform the labels of all nested value components. + +```tsx + label.replace(/\?$/, '')}> + + + +``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/Examples.tsx index 04ac4ef82c0..1ae62f30fc2 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/Examples.tsx @@ -11,9 +11,14 @@ export const DynamicSteps = () => { return ( - + { + console.log('onStepChange', index, mode, args.id) + }} + > Step A @@ -22,6 +27,7 @@ export const DynamicSteps = () => { Step B @@ -30,6 +36,7 @@ export const DynamicSteps = () => { @@ -42,6 +49,7 @@ export const DynamicSteps = () => { Step D diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/info.mdx index 390697917f3..915005f7a1b 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/Step/info.mdx @@ -45,3 +45,33 @@ In order to navigate back to another step, you can use the [EditButton](/uilib/e ## Events If you need an event to be triggered when the user changes the active step, you can use the `onStepChange` in the [Wizard.Container](/uilib/extensions/forms/Wizard/Container/). + +## Dynamic steps support + +You can use the `Wizard.Step` component to create dynamic steps. The `active` and `activeWhen` properties allow you to show or hide a step based on specific conditions. + +If a step is replaced by another step, the `onStepChange` event will trigger with `stepListModified` as the second argument. The current step index might remain the same. However, if the total number of steps becomes less than the current step, the index will adjust to the last step. + +To keep track of the current step, you can provide each step with an `id` property. This `id` can then be used to identify the current step and will be returned as part of an object in the `onStepChange` event. + +```tsx + { + const { + id, + preventNavigation, + previousStep: { index }, + } = args + }} +> + + content + + +``` + +In the demo section, you will find an example demonstrating how to use the `Wizard.Step` component with `activeWhen`. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/info.mdx index a453629e5d0..fcefb5f2c69 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Wizard/info.mdx @@ -26,7 +26,7 @@ render( ## Dynamic steps support -You can use the `Wizard.Step` component to create dynamic steps. The `active` and `activeWhen` properties can be used to enable or disable a step based on the current data. Here is a [demo of a dynamic step](/uilib/extensions/forms/Wizard/Step/). +You can use the `Wizard.Step` component to create dynamic steps. The `active` and `activeWhen` properties allow you to show or hide a step based on specific conditions. You find an [example](/uilib/extensions/forms/Wizard/Step/#dynamic-steps) in the demo section. ## Summary step diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks.mdx index 0b61f2b4dc7..63125958ee7 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks.mdx @@ -1,6 +1,7 @@ --- title: 'Blocks' order: 20 +status: 'beta' breadcrumb: - text: Forms href: /uilib/extensions/forms/ diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/ChildrenWithAge/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/ChildrenWithAge/Examples.tsx index f86f9f19708..9d931d5dbe8 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/ChildrenWithAge/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/blocks/ChildrenWithAge/Examples.tsx @@ -38,7 +38,7 @@ export const ChildrenWithAge = (props) => { export const ChildrenWithAgeWizard = (props) => { return ( - + {() => { const MyForm = () => { const { summaryTitle } = Form.useLocale().Step @@ -163,9 +163,7 @@ function Output({ title, generateRef, transform = (data) => data }) { return ( <> {title} -
-
{JSON.stringify(data, null, 2)}
-
+ ) } @@ -174,7 +172,7 @@ export const ChildrenWithAgePrefilledYes = () => { return ( { return ( { return ( @@ -265,7 +263,7 @@ export const ChildrenWithAgeSummaryNoChildren = () => { return ( diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/changelog.mdx index e5d59e942f5..790dad68ab7 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,30 @@ breadcrumb: Change log for the Eufemia Forms extension. +## v10.52 + +- Added `transformLabel` to all `Value.*` components. +- Added `bubbleValidation` to [Form.Isolation](/uilib/extensions/forms/Form/Isolation/) and [Iterate.PushContainer](/uilib/extensions/forms/Iterate/PushContainer/), to prevent the form from being submitted when there are fields with errors. +- Added [createMinimumAgeValidator](/uilib/extensions/forms/feature-fields/NationalIdentityNumber/properties/#createminimumagevalidator) in [Field.NationalIdentityNumber](/uilib/extensions/forms/feature-fields/NationalIdentityNumber/) to make a customizable minimum age validator. +- Added [Form.useSnapshot](/uilib/extensions/forms/Form/useSnapshot/) hook to handle snapshots of data. +- Added `id` to [Wizard.Step](/uilib/extensions/forms/Wizard/Step/) for when using dynamic steps with `activeWhen`. +- Added [Iterate.ItemNo](/uilib/extensions/forms/Iterate/ItemNo/). +- Added support for `Form.SubmitConfirmation` in [Wizard](/uilib/extensions/forms/Wizard/). +- Added `isolatedData` to [Iterate.PushContainer](/uilib/extensions/forms/Iterate/PushContainer/). +- Added displaying phone numbers in [Value.PhoneNumber](/uilib/extensions/forms/Value/PhoneNumber/) using prefix `+` instead of `00`. +- Added support for `defaultValue` (and `value`) for fields used in [Iterate.Array](/uilib/extensions/forms/Iterate/Array/). +- Added support for `isValid` in [Form.Visibility](/uilib/extensions/forms/Form/Visibility/) for showing content based on the validation of a field. +- Removed the internal `pattern` in [Field.OrganizationNumber](/uilib/extensions/forms/feature-fields/OrganizationNumber/), rather using the internal validator. +- Removed the internal `pattern` in [Field.NationalIdentityNumber](/uilib/extensions/forms/feature-fields/NationalIdentityNumber/), rather using the internal validator. +- Fixed so [Form.clearData](/uilib/extensions/forms/Form/clearData/) works in `React.StrictMode`. +- Fixed use of unpolyfilled structuredClone in [Form.useData](/uilib/extensions/forms/Form/useData/) hook. +- Fixed so `onBlurValidator` works with `validateInitially`. +- Fixed so [Iterate.EditContainer](/uilib/extensions/forms/Iterate/EditContainer/) keeps open when falsy value or empty object was given as the iterate value. +- Fixed so all errors on every value change is displayed when using exported validators from `exportValidators`. +- Fixed so `exportValidators` is not called when not exported as an array. +- Fixed so `emptyValue` is set in the data context when defined. +- Fixed so [Field.SelectCountry](/uilib/extensions/forms/feature-fields/SelectCountry/) has a fallback locale (nb-NO). + ## v10.51 - Added `rounding` property with support for `half-even` rounding to [Value.Number](/uilib/extensions/forms/Value/Number/) and [Value.Currency](/uilib/extensions/forms/Value/Currency/). @@ -36,7 +60,7 @@ Change log for the Eufemia Forms extension. - Added `reduceToVisibleFields` to the [Form.useData](/uilib/extensions/forms/Form/useData/) hook and [Form.Handler](/uilib/extensions/forms/Form/Handler/) `onSubmit`. - Added `inheritVisibility` to each `Value.*` component. - Added `variant` to [Value.ArraySelection](/uilib/extensions/forms/base-fields/ArraySelection/), to allow for list layout. -- Added validation of norwegian organization number to [Field.OrganizationNumber](/uilib/extensions/forms/feature-fields/OrganizationNumber/). +- Added validation of Norwegian organization number to [Field.OrganizationNumber](/uilib/extensions/forms/feature-fields/OrganizationNumber/). - Added `filterCountries` to [Field.PhoneNumber](/uilib/extensions/forms/feature-fields/PhoneNumber/), to be able to filter out countries. - Added `filterCountries` to [Field.SelectCountry](/uilib/extensions/forms/feature-fields/SelectCountry/), to be able to filter out countries. - Added `limit` in [Iterate](/uilib/extensions/forms/Iterate/Array/). diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/demo-cases/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/demo-cases/Examples.tsx index fb5d6ddc5be..37fd1d1e843 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/demo-cases/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/demo-cases/Examples.tsx @@ -1,36 +1,25 @@ import * as React from 'react' -import { Section, Code, Card, Flex } from '@dnb/eufemia/src' +import { Card, Flex } from '@dnb/eufemia/src' import { Form, Field, Value, Wizard, + Tools, } from '@dnb/eufemia/src/extensions/forms' import { Provider } from '@dnb/eufemia/src/shared' import ComponentBox from '../../../../../shared/tags/ComponentBox' export const BecomeCorporateCustomer = () => { return ( - + {() => { const Output = () => { const { data } = Form.useData('example-form', { website: 'www.dnb.no', }) - return ( -
- -
{JSON.stringify(data, null, 2)}
-
-
- ) + return } const MyForm = () => { @@ -187,26 +176,8 @@ export const BecomeCorporateCustomer = () => { export function PizzaDemo() { return ( - + {() => { - const Output = () => { - const { data } = Form.useData('pizza-demo', {}) - - return ( -
- -
{JSON.stringify(data, null, 2)}
-
-
- ) - } - const MyForm = () => { // Routers like "react-router" are supported as well Wizard.useQueryLocator('my-wizard') @@ -216,7 +187,6 @@ export function PizzaDemo() { console.log('onSubmit', data)} - id="pizza-demo" sessionStorageId="pizza-form" > @@ -346,7 +316,7 @@ export function PizzaDemo() { - + ) } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/BankAccountNumber/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/BankAccountNumber/info.mdx index 80bad028792..8d8aeb6138e 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/BankAccountNumber/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/BankAccountNumber/info.mdx @@ -6,7 +6,7 @@ showTabs: true `Field.BankAccountNumber` is a wrapper component for the [input of strings](/uilib/extensions/forms/base-fields/String), with user experience tailored for bank account values. -This field is meant for norwegian bank account numbers, and therefor takes a 11-digit string as a value. A norwegian bank account number can have a leading zero, which is why this value is a string and not a number. +This field is meant for Norwegian bank account numbers, and therefor takes a 11-digit string as a value. A Norwegian bank account number can have a leading zero, which is why this value is a string and not a number. More info can be found at [Wikipedia](https://no.wikipedia.org/wiki/Kontonummer) ```jsx diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/Expiry/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/Expiry/demos.mdx index aed2172692d..4fc6e3be2db 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/Expiry/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/Expiry/demos.mdx @@ -7,7 +7,7 @@ import * as Examples from './Examples' ## Demos -The locale is what determines the components `placeholder` format .e.g. `mm/åå` in norwegian, `mm/yy` in english. +The locale is what determines the components `placeholder` format .e.g. `mm/åå` in Norwegian, `mm/yy` in English. { @@ -152,7 +153,7 @@ export const ValidationFunction = () => { ) @@ -165,26 +166,70 @@ export const ValidationExtendValidator = () => { return ( {() => { - const bornInApril = (value: string) => - value.substring(2, 4) === '04' - ? { status: 'valid' } - : { status: 'invalid' } - - const myValidator = (value, { validators }) => { - const { dnrValidator, fnrValidator } = validators - const result = bornInApril(value) - if (result.status === 'invalid') { + const bornInAprilValidator = (value: string) => { + if (value.substring(2, 4) !== '04') { return new Error('My error') } + } + const myValidator = (value, { validators }) => { + const { dnrAndFnrValidator } = validators - return [dnrValidator, fnrValidator] + return [dnrAndFnrValidator, bornInAprilValidator] } return ( + ) + }} + + ) +} + +export const ValidationExtendValidatorAdult = () => { + return ( + + {() => { + const adultValidator = createMinimumAgeValidator(18) + const myAdultValidator = (value, { validators }) => { + const { dnrAndFnrValidator } = validators + + return [dnrAndFnrValidator, adultValidator] + } + + return ( + + ) + }} + + ) +} + +export const ValidationFnrAdult = () => { + return ( + + {() => { + const adultValidator = createMinimumAgeValidator(18) + const myFnrAdultValidator = (value, { validators }) => { + const { fnrValidator } = validators + + return [fnrValidator, adultValidator] + } + + 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 24a8c45cc02..446131cc0b7 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 @@ -54,18 +54,28 @@ Below is an example of the error message displayed when there's an invalid Norwe 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: +Below is an example of the error message displayed when there's an invalid D number(a D number has its first number in the identification number increased by 4): ### Validation function -You can provide your own validation function. +You can provide your own validation function, either to `validator` or `onBlurValidator`. ### Extend validation with custom validation function -You can [extend the existing validations](/uilib/extensions/forms/create-component/useFieldProps/info/#validators)(`dnrValidator` and `fnrValidator`) with your own validation function. +You can [extend the existing validations](/uilib/extensions/forms/create-component/useFieldProps/info/#validators)(`dnrValidator`, `fnrValidator`, `dnrAndFnrValidator`, and make your own age validator by using the `createMinimumAgeValidator` function) with your own validation function. + +### Extend validation with adult validator + +You can [extend the existing validations](/uilib/extensions/forms/create-component/useFieldProps/info/#validators)(`dnrValidator`, `fnrValidator`, and `dnrAndFnrValidator`) with your own age validator, by using the `createMinimumAgeValidator` function. + + + +### Validate only national identity numbers(fnr) above 18 years old + + 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 07c5e89299d..d6200d9feef 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,10 +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(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. +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). +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). +The validation happens on blur, internally using the `onBlurValidator` [property](/uilib/extensions/forms/feature-fields/NationalIdentityNumber/properties/#field-specific-properties). ```jsx import { Field } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/properties.mdx index a85c9c0780e..1df29f74df1 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/properties.mdx @@ -17,6 +17,35 @@ import { NationalIdentityNumberProperties } from '@dnb/eufemia/src/extensions/fo +## Validators + +### Internal validators exposed + +`Field.NationalIdentityNumber` expose the following validators through its `validator` and `onBlurValidator` property: + +- `dnrValidator`: validates a D number. +- `fnrValidator`: validates a national identity number (fødselsnummer). +- `dnrAndFnrValidator`: + - validates the identification number as a D number when first digit is 4 or greater (because a D number has its first number increased by 4). + - validates the identification number as a national identity number (fødselsnummer) when first digit is 3 or less. + +### createMinimumAgeValidator + +You can create your own age validator by using the `createMinimumAgeValidator` function. It takes an age as a parameter and returns a validator function. The validator function takes a value and returns an error message if the value is not above the given age. +It validates if the identification number has a date of birth that is 18 years or older. It uses only the 7 first digits of the identification number to validate. The first 6 digits representing the birth of date, and the next digit represents the century. +As it only use the 7 first digits, it does not validate the identification number, therefore it's quite common to use this validator together with one of the validators above (`dnrValidator`, `fnrValidator` or `dnrAndFnrValidator`) to validate the identification number as well. + +You need to import the `createMinimumAgeValidator` function from the `Field.NationalIdentityNumber` component: + +```tsx +import { createMinimumAgeValidator } from '@dnb/eufemia/extensions/forms/Field/NationalIdentityNumber' + +// Create a validator that validates if the value is above 18 years old +const above18YearsValidator = createMinimumAgeValidator(18) +``` + +See the following [example](/uilib/extensions/forms/feature-fields/NationalIdentityNumber/#extend-validation-with-custom-validation-function) on how to extend validation using the exposed validators. + ## Translations diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/Examples.tsx index e67d1220664..f5c8e0c0f06 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/Examples.tsx @@ -116,19 +116,16 @@ export const ValidationExtendValidator = () => { return ( {() => { - const firstNumIs1 = (value: string) => - value.substring(0, 1) === '1' - ? { status: 'valid' } - : { status: 'invalid' } + const firstNumIs1Validator = (value: string) => { + if (value.substring(0, 1) !== '1') { + return new Error('My error') + } + } const myValidator = (value, { validators }) => { const { organizationNumberValidator } = validators - const result = firstNumIs1(value) - if (result.status === 'invalid') { - return new Error('My error') - } - return [organizationNumberValidator] + return [organizationNumberValidator, firstNumIs1Validator] } return ( diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/info.mdx index e6c31c1561f..5ae39b8bdef 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/info.mdx @@ -7,7 +7,8 @@ showTabs: true `Field.OrganizationNumber` is a wrapper component for the [input of strings](/uilib/extensions/forms/base-fields/String), with user experience tailored for organization number values. This input expects a 9-digit number as its value. This is because Norwegian organization numbers are 9-digits long, based on info from [Brønnøysundregisteret](https://www.brreg.no/en/about-us-2/our-registers/about-the-central-coordinating-register-for-legal-entities-ccr/about-the-organisation-number/?nocache=1701776533136) -It validates input for norwegian organization numbers, as described by [Brønnøysundregistrene](https://www.brreg.no/om-oss/registrene-vare/om-enhetsregisteret/organisasjonsnummeret/). +It validates input for Norwegian organization numbers, as described by [Brønnøysundregistrene](https://www.brreg.no/om-oss/registrene-vare/om-enhetsregisteret/organisasjonsnummeret/). +The validation happens on blur, internally using the `onBlurValidator` [property](/uilib/extensions/forms/feature-fields/OrganizationNumber/properties/#field-specific-properties). ```jsx import { Field } from '@dnb/eufemia/extensions/forms' diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/properties.mdx index 3be76bc5c7a..8db85d4f5e7 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/OrganizationNumber/properties.mdx @@ -5,19 +5,17 @@ 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 { OrganizationNumberProperties } from '@dnb/eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumberDocs' ## Properties ### Field-specific properties -| Property | Type | Description | -| ---------- | --------- | ------------------------------------------------------------------------------- | -| `validate` | `boolean` | _(optional)_ Using this property you can disable the default validation. | -| `help` | `object` | _(optional)_ Provide a help button. Object consisting of `title` and `content`. | + ### General properties - + ## Translations 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 3d42ae7f0f7..2682645747e 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 @@ -18,17 +18,21 @@ import AsyncChangeExample from './Form/Handler/parts/async-change-example.mdx' **Table of Contents** -- [Quick start](#quick-start) +- [Getting started](#getting-started) - [Creating forms](#creating-forms) - [State management](#state-management) - [What is a JSON Pointer?](#what-is-a-json-pointer) - [Data handling](#data-handling) + - [Visible data](#visible-data) - [Filter data](#filter-data) - [Filter data during submit](#filter-data-during-submit) - [Transforming data](#transforming-data) + - [Complex objects in the data context](#complex-objects-in-the-data-context) - [Async form handling](#async-form-handling) - [Field components](#field-components) - [Value components](#value-components) + - [Inherit visibility from fields](#inherit-visibility-from-fields) + - [Conditionally display content](#conditionally-display-content) - [Async form behavior](#async-form-behavior) - [onChange and autosave](#onchange-and-autosave) - [Async field validation](#async-field-validation) @@ -37,7 +41,8 @@ import AsyncChangeExample from './Form/Handler/parts/async-change-example.mdx' - [required](#required) - [pattern](#pattern) - [schema](#schema) - - [validator](#validator) + - [onBlurValidator and validator](#onblurvalidator-and-validator) + - [Connect with another field](#connect-with-another-field) - [Async validation](#async-validation) - [Async validator with debounce](#async-validator-with-debounce) - [Localization and translation](#localization-and-translation) @@ -46,7 +51,7 @@ import AsyncChangeExample from './Form/Handler/parts/async-change-example.mdx' - [Use the shared Provider to customize translations](#use-the-shared-provider-to-customize-translations) - [Layout](#layout) - [Best practices](#best-practices) -- [Create your own component](#create-your-own-component) + - [Create your own component](#create-your-own-component) @@ -319,6 +324,23 @@ User entered data will always be stored internally in the data context, even if You can use the `inheritVisibility` property on the [Value.\*](/uilib/extensions/forms/Value/) components to inherit the visibility from the field with the same path. +### Conditionally display content + +You can conditionally display content using the [Form.Visibility](/uilib/extensions/forms/Form/Visibility/) component. This allows you to show or hide its children based on the validation (A.) or the value (B.) of another path (data entry). + +```tsx + value === 'foo', // A. Value based + isValid: true, // B. Validation based + }} +> + + +``` + ### Async form behavior This feature allows you to perform asynchronous operations such as fetching data from an API – without additional state management. diff --git a/packages/dnb-eufemia/.eslintrc b/packages/dnb-eufemia/.eslintrc index a194ca442d2..837a3601a44 100644 --- a/packages/dnb-eufemia/.eslintrc +++ b/packages/dnb-eufemia/.eslintrc @@ -143,6 +143,13 @@ } ] } + ], + "no-restricted-globals": [ + "error", + { + "name": "structuredClone", + "message": "Import `structuredClone` from '@ungap/structured-clone' instead." + } ] } }, diff --git a/packages/dnb-eufemia/jest.config.screenshots.js b/packages/dnb-eufemia/jest.config.screenshots.js index 8b38294f219..1866b12daf2 100644 --- a/packages/dnb-eufemia/jest.config.screenshots.js +++ b/packages/dnb-eufemia/jest.config.screenshots.js @@ -12,7 +12,7 @@ module.exports = { // before the test suite moves on to the next one. // You can change this value to be whatever number you want. // Defaults to 30 seconds (30e3) - headlessTimeout: 30e3, + headlessTimeout: 60e3, }, browsers: [ // 'chromium', @@ -21,6 +21,7 @@ module.exports = { ], }, }, + testTimeout: 60e3, testRegex: 'screenshot.test.(js|ts|tsx)$', testEnvironment: './src/core/jest/jestPuppeteerEnvironment.js', setupFilesAfterEnv: ['./src/core/jest/setupJestScreenshot.js'], diff --git a/packages/dnb-eufemia/jest.d.ts b/packages/dnb-eufemia/jest.d.ts index b92be3d6530..813c362c9ec 100644 --- a/packages/dnb-eufemia/jest.d.ts +++ b/packages/dnb-eufemia/jest.d.ts @@ -1,5 +1,6 @@ declare namespace jest { interface Matchers { toBeType(received: string, expected?: string): R; + toNeverResolve(): Promise; } } diff --git a/packages/dnb-eufemia/package.json b/packages/dnb-eufemia/package.json index a4db30b5ca9..2776dc2b8b9 100644 --- a/packages/dnb-eufemia/package.json +++ b/packages/dnb-eufemia/package.json @@ -79,7 +79,7 @@ "test:auto-generated-types": "yarn jest ./postTypeGeneration.test.ts --ci --testPathIgnorePatterns=[]", "test:ci": "yarn jest --ci", "test:e2e": "yarn playwright test", - "test:e2e:ci": "start-server-and-test 'yarn workspace dnb-design-system-portal serve' http://localhost:8000 test:e2e", + "test:e2e:ci": "start-server-and-test 'yarn workspace dnb-design-system-portal serve:8001' http://localhost:8001 test:e2e", "test:e2e:watch": "playwright test --ui", "test:postbuild": "yarn jest ./postbuild.test.ts --ci --testPathIgnorePatterns=[]", "test:screenshots": "yarn jest --config=./jest.config.screenshots.js --maxWorkers=1 --detectOpenHandles --testPathPattern ", diff --git a/packages/dnb-eufemia/playwright.config.ts b/packages/dnb-eufemia/playwright.config.ts index 3f1d7004c47..efa6ddadd12 100644 --- a/packages/dnb-eufemia/playwright.config.ts +++ b/packages/dnb-eufemia/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ use: { // Base URL to use in actions like `await page.goto('/')`. - baseURL: 'http://localhost:8000', + baseURL: 'http://localhost:8001', // Name of the browser that runs tests. For example `chromium`, `firefox`, `webkit`. browserName: 'firefox', diff --git a/packages/dnb-eufemia/scripts/prebuild/tasks/__tests__/__snapshots__/makePropertiesFile.test.ts.snap b/packages/dnb-eufemia/scripts/prebuild/tasks/__tests__/__snapshots__/makePropertiesFile.test.ts.snap index f6c0bef776a..c84adae7547 100644 --- a/packages/dnb-eufemia/scripts/prebuild/tasks/__tests__/__snapshots__/makePropertiesFile.test.ts.snap +++ b/packages/dnb-eufemia/scripts/prebuild/tasks/__tests__/__snapshots__/makePropertiesFile.test.ts.snap @@ -15,8 +15,9 @@ export default { '--sb-font-size-small': '0.875rem', '--sb-font-size-basis': '1rem', '--sb-font-size-basis--em': '1em', - '--sb-font-size-lead': '1.25rem', - '--sb-font-size-medium': '1.625rem', + '--sb-font-size-lead': 'var(--font-size-medium)', + '--sb-font-size-medium': '1.25rem', + '--sb-font-size-medium--plus': '1.625rem', '--sb-font-size-large': '2rem', '--sb-font-size-x-large': '2.375rem', '--sb-font-size-xx-large': '3rem', @@ -196,8 +197,9 @@ export default { '--sb-font-size-small': '0.875rem', '--sb-font-size-basis': '1rem', '--sb-font-size-basis--em': '1em', - '--sb-font-size-lead': '1.25rem', - '--sb-font-size-medium': '1.625rem', + '--sb-font-size-lead': 'var(--font-size-medium)', + '--sb-font-size-medium': '1.25rem', + '--sb-font-size-medium--plus': '1.625rem', '--sb-font-size-large': '2rem', '--sb-font-size-x-large': '2.375rem', '--sb-font-size-xx-large': '3rem', diff --git a/packages/dnb-eufemia/src/components/autocomplete/Autocomplete.js b/packages/dnb-eufemia/src/components/autocomplete/Autocomplete.js index e9e58f4e7c8..b954204cd23 100644 --- a/packages/dnb-eufemia/src/components/autocomplete/Autocomplete.js +++ b/packages/dnb-eufemia/src/components/autocomplete/Autocomplete.js @@ -961,6 +961,12 @@ class AutocompleteInstance extends React.PureComponent { } onInputClickHandler = (e) => { + // Show the entire list when an item is selected + if (!this.context.drawerList.opened && this.hasFilterActive()) { + this.ignoreEvents() + this.showAll() + } + const { value } = e.target this.setVisibleByContext({ value }) } diff --git a/packages/dnb-eufemia/src/components/autocomplete/__tests__/Autocomplete.test.tsx b/packages/dnb-eufemia/src/components/autocomplete/__tests__/Autocomplete.test.tsx index 2a3f72d2819..186488bfee4 100644 --- a/packages/dnb-eufemia/src/components/autocomplete/__tests__/Autocomplete.test.tsx +++ b/packages/dnb-eufemia/src/components/autocomplete/__tests__/Autocomplete.test.tsx @@ -14,7 +14,13 @@ import { mockImplementationForDirectionObserver, testDirectionObserver, } from '../../../fragments/drawer-list/__tests__/DrawerListTestMocks' -import { fireEvent, render, act, waitFor } from '@testing-library/react' +import { + fireEvent, + render, + act, + waitFor, + screen, +} from '@testing-library/react' import { DrawerListData, DrawerListDataObject, @@ -3270,6 +3276,25 @@ describe('Autocomplete component', () => { expect(input.value).toBe('second value') }) + + it('should show the whole list when clicking the input after item selection', async () => { + render() + + const input = document.querySelector('input') + + await userEvent.click(input) + + expect(screen.getAllByRole('option')).toHaveLength(3) + + await userEvent.type(input, 'aa') + + expect(screen.getAllByRole('option')).toHaveLength(2) + + await userEvent.click(screen.getAllByRole('option')[0]) + await userEvent.click(input) + + expect(screen.getAllByRole('option')).toHaveLength(3) + }) }) describe('Autocomplete markup', () => { diff --git a/packages/dnb-eufemia/src/components/autocomplete/__tests__/__image_snapshots__/autocomplete-for-sbanken-have-to-match-custom-input-width.snap.png b/packages/dnb-eufemia/src/components/autocomplete/__tests__/__image_snapshots__/autocomplete-for-sbanken-have-to-match-custom-input-width.snap.png index 841052e0973..7201fe878d7 100644 Binary files a/packages/dnb-eufemia/src/components/autocomplete/__tests__/__image_snapshots__/autocomplete-for-sbanken-have-to-match-custom-input-width.snap.png and b/packages/dnb-eufemia/src/components/autocomplete/__tests__/__image_snapshots__/autocomplete-for-sbanken-have-to-match-custom-input-width.snap.png differ diff --git a/packages/dnb-eufemia/src/components/autocomplete/__tests__/__image_snapshots__/autocomplete-for-sbanken-have-to-match-different-sizes.snap.png b/packages/dnb-eufemia/src/components/autocomplete/__tests__/__image_snapshots__/autocomplete-for-sbanken-have-to-match-different-sizes.snap.png index 05d7bd4b2fd..696741fbeaf 100644 Binary files a/packages/dnb-eufemia/src/components/autocomplete/__tests__/__image_snapshots__/autocomplete-for-sbanken-have-to-match-different-sizes.snap.png and b/packages/dnb-eufemia/src/components/autocomplete/__tests__/__image_snapshots__/autocomplete-for-sbanken-have-to-match-different-sizes.snap.png differ diff --git a/packages/dnb-eufemia/src/components/avatar/style/themes/dnb-avatar-theme-sbanken.scss b/packages/dnb-eufemia/src/components/avatar/style/themes/dnb-avatar-theme-sbanken.scss index a4d43e5531c..abbfdb5a453 100644 --- a/packages/dnb-eufemia/src/components/avatar/style/themes/dnb-avatar-theme-sbanken.scss +++ b/packages/dnb-eufemia/src/components/avatar/style/themes/dnb-avatar-theme-sbanken.scss @@ -36,7 +36,7 @@ &__group { --avatar-font-size-left--medium: var(--font-size-basis); - --avatar-font-size-left--large: var(--font-size-medium); - --avatar-font-size-left--x-large: var(--font-size-medium); + --avatar-font-size-left--large: var(--sb-font-size-medium--plus); + --avatar-font-size-left--x-large: var(--sb-font-size-medium--plus); } } diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-cookie-concent-confirmation.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-cookie-concent-confirmation.snap.png index 62e6b2525e3..3c20eab55c9 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-cookie-concent-confirmation.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-cookie-concent-confirmation.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-custom-dialog-window.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-custom-dialog-window.snap.png index 7dbd4fc73c3..7a955708c09 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-custom-dialog-window.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-custom-dialog-window.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-default-confirmation.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-default-confirmation.snap.png index e77cd29c214..bc1db32ada0 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-default-confirmation.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-default-confirmation.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-default-dialog-window.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-default-dialog-window.snap.png index 3751f060176..e7e5db4e185 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-default-dialog-window.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-default-dialog-window.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-delete-confirmation.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-delete-confirmation.snap.png index 899aac6cf30..81e13d72dc0 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-delete-confirmation.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-delete-confirmation.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-help-window.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-help-window.snap.png index d93b9fcf62d..3b32ee0faa1 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-help-window.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-help-window.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-window-using-custom-trigger.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-window-using-custom-trigger.snap.png index 32ac558369c..fbd90994c20 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-window-using-custom-trigger.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-window-using-custom-trigger.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-logged-out-confirmation.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-logged-out-confirmation.snap.png index be74ddf9074..a166973929f 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-logged-out-confirmation.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-logged-out-confirmation.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-scrollable-content-have-to-match-scrolled-to-bottom.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-scrollable-content-have-to-match-scrolled-to-bottom.snap.png index 427067d2067..77f2a8af53a 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-scrollable-content-have-to-match-scrolled-to-bottom.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-scrollable-content-have-to-match-scrolled-to-bottom.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-scrollable-content-have-to-match-scrolled-to-top.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-scrollable-content-have-to-match-scrolled-to-top.snap.png index 717341a968e..bfdd2393fc1 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-scrollable-content-have-to-match-scrolled-to-top.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-scrollable-content-have-to-match-scrolled-to-top.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/style/themes/dnb-dialog-theme-sbanken.scss b/packages/dnb-eufemia/src/components/dialog/style/themes/dnb-dialog-theme-sbanken.scss index 6f7844b1b77..c19ec1698f5 100644 --- a/packages/dnb-eufemia/src/components/dialog/style/themes/dnb-dialog-theme-sbanken.scss +++ b/packages/dnb-eufemia/src/components/dialog/style/themes/dnb-dialog-theme-sbanken.scss @@ -22,4 +22,11 @@ margin-top: calc(var(--dialog-icon-positioning) * -1); margin-bottom: calc(var(--dialog-icon-positioning) * -1); } + + &__title { + @include allBetween(small, medium) { + font-size: var(--sb-font-size-medium--plus) !important; + line-height: var(--line-height-medium) !important; + } + } } diff --git a/packages/dnb-eufemia/src/components/form-status/FormStatus.d.ts b/packages/dnb-eufemia/src/components/form-status/FormStatus.d.ts index 059d41ac1e4..f33145afda1 100644 --- a/packages/dnb-eufemia/src/components/form-status/FormStatus.d.ts +++ b/packages/dnb-eufemia/src/components/form-status/FormStatus.d.ts @@ -129,3 +129,9 @@ export interface MarketingIconProps { title?: string; } export declare const MarketingIcon: React.FC; + +export type FormStatusIconTypes = + | typeof ErrorIcon + | typeof WarnIcon + | typeof InfoIcon + | typeof MarketingIcon; diff --git a/packages/dnb-eufemia/src/components/icon/Icon.tsx b/packages/dnb-eufemia/src/components/icon/Icon.tsx index c685ceb4118..b8fd1b1ff52 100644 --- a/packages/dnb-eufemia/src/components/icon/Icon.tsx +++ b/packages/dnb-eufemia/src/components/icon/Icon.tsx @@ -12,6 +12,7 @@ import { createSkeletonClass } from '../skeleton/SkeletonHelper' import { iconCase } from './IconHelpers' import { SpacingProps } from '../../shared/types' import { SkeletonShow } from '../Skeleton' +import { FormStatusIconTypes } from '../FormStatus' export const DefaultIconSize = 16 export const DefaultIconSizes = { @@ -47,7 +48,7 @@ type IconType = | false /** For external usage */ -export type IconIcon = IconType | React.FC +export type IconIcon = IconType | FormStatusIconTypes | React.FC export type IconSize = | ValidIconNumericSize diff --git a/packages/dnb-eufemia/src/components/input/__tests__/__image_snapshots__/input-for-sbanken-have-to-match-text-align-with-icon.snap.png b/packages/dnb-eufemia/src/components/input/__tests__/__image_snapshots__/input-for-sbanken-have-to-match-text-align-with-icon.snap.png index 4fcc27af78c..e95c941f0d8 100644 Binary files a/packages/dnb-eufemia/src/components/input/__tests__/__image_snapshots__/input-for-sbanken-have-to-match-text-align-with-icon.snap.png and b/packages/dnb-eufemia/src/components/input/__tests__/__image_snapshots__/input-for-sbanken-have-to-match-text-align-with-icon.snap.png differ diff --git a/packages/dnb-eufemia/src/components/logo/Logo.tsx b/packages/dnb-eufemia/src/components/logo/Logo.tsx index ef4967330e8..0e615a3f71b 100644 --- a/packages/dnb-eufemia/src/components/logo/Logo.tsx +++ b/packages/dnb-eufemia/src/components/logo/Logo.tsx @@ -114,7 +114,7 @@ function Logo(localProps: LogoProps) { /** @deprecated Can remove this in v11 */ const height = parseFloat(size) > 0 ? size : heightProp - // Alt text for the logo does not need to be translated. DNB alt will be the same in english, and sbanken alt should always be in norwegian + // Alt text for the logo does not need to be translated. DNB alt will be the same in English, and sbanken alt should always be in Norwegian const altText = logoType === 'dnb' ? 'DNB Logo' : 'Sbanken - et konsept fra DNB logo' diff --git a/packages/dnb-eufemia/src/components/number-format/NumberUtils.d.ts b/packages/dnb-eufemia/src/components/number-format/NumberUtils.d.ts index b16aca3284b..6e2817331b4 100644 --- a/packages/dnb-eufemia/src/components/number-format/NumberUtils.d.ts +++ b/packages/dnb-eufemia/src/components/number-format/NumberUtils.d.ts @@ -163,3 +163,17 @@ export const roundHalfEven: ( num: number, decimalPlaces?: number ) => number; + +/** + * Formats a number as a phone number + * + * @param {string|number} number any number + * @param {string} locale as a string. Defaults to the global LOCALE constant + * @param {string} options formatting options based on the toLocaleString API + * @returns {string} a phone number + */ +export const formatPhone: ( + number: string | number, + locale?: string, + options?: Intl.NumberFormatOptions +) => { number: string; aria: string }; diff --git a/packages/dnb-eufemia/src/components/number-format/NumberUtils.js b/packages/dnb-eufemia/src/components/number-format/NumberUtils.js index ca721802778..750cf1f10db 100644 --- a/packages/dnb-eufemia/src/components/number-format/NumberUtils.js +++ b/packages/dnb-eufemia/src/components/number-format/NumberUtils.js @@ -591,18 +591,22 @@ export const formatPhone = (number, locale = null) => { number = String(number).replace(/[^+0-9]/g, '') let code = '' - if (number.length > 8 && number.substr(0, 2) !== '00') { + if ( + number.length > 8 && + number.substring(0, 2) !== '00' && + !number.startsWith('+') + ) { number = '+' + number - number = number.replace(/^\+{2,}/, '+') } + if (number[0] === '+') { - code = number.substr(0, 3) + ' ' - number = number.substr(3) - } else if (number.substr(0, 2) === '00') { - code = number.substr(0, 4) + ' ' - number = number.substr(4) + code = number.substring(0, 3) + ' ' + number = number.substring(3) + } else if (number.substring(0, 2) === '00') { + code = number.substring(0, 4) + ' ' + number = number.substring(4) } - code = code.replace('+', '00') + code = code.replace(/^00/, '+') const length = number.length // get 800 22 222 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 127698cb88b..d0c984b8b55 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 @@ -321,7 +321,7 @@ describe('NumberFormat component', () => { it('have to match phone number', () => { render(+47 99999999) expect(document.querySelector(displaySelector).textContent).toBe( - '0047 99 99 99 99' + '+47 99 99 99 99' ) }) 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 197c2a6c914..38e92d4ac0a 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 @@ -17,6 +17,7 @@ import { getCurrencySymbol, countDecimals, roundHalfEven, + formatPhone, } from '../NumberUtils' const locale = LOCALE @@ -295,7 +296,7 @@ describe('Currency format with dirty number', () => { ).toBe('-1 234 567,89 kr') }) - it('should treat norwegian style (SI style (French version))', () => { + it('should treat Norwegian style (SI style (French version))', () => { expect( format('prefix -12 345,678 suffix', { clean: true, currency: true }) ).toBe('-12 345,68 kr') @@ -307,7 +308,7 @@ describe('Currency format with dirty number', () => { ).toBe('-1 234 567,89 kr') }) - it('should treat english style (SI style (English version))', () => { + it('should treat English style (SI style (English version))', () => { expect( format('prefix -1 234 567.891 suffix', { clean: true, @@ -679,14 +680,14 @@ describe('NumberFormat cleanNumber', () => { ).toBe('1234567.0123') }) - it('should clean up norwegian style (SI style (French version))', () => { + it('should clean up Norwegian style (SI style (French version))', () => { expect(cleanNumber('prefix -12 345,678 suffix')).toBe('-12345.678') expect(cleanNumber('prefix -1 234 567,891 suffix')).toBe( '-1234567.891' ) }) - it('should clean up english style (SI style (English version))', () => { + it('should clean up English style (SI style (English version))', () => { expect(cleanNumber('prefix -1 234 567.891 suffix')).toBe( '-1234567.891' ) @@ -899,3 +900,64 @@ describe('rounding', () => { }) }) }) + +describe('formatPhone', () => { + it('should format phone number correctly', () => { + const { number } = formatPhone('12345678') + expect(number).toBe('12 34 56 78') + }) + + it('should format a phone number with country code', () => { + const result = formatPhone('+4712345678') + expect(result.number).toBe('+47 12 34 56 78') + expect(result.aria).toBe('+47 12 34 56 78') + }) + + it('should format a phone number without country code', () => { + const result = formatPhone('12345678') + expect(result.number).toBe('12 34 56 78') + expect(result.aria).toBe('12 34 56 78') + }) + + it('should format a phone number with leading 00 country code', () => { + const result = formatPhone('004712345678') + expect(result.number).toBe('+47 12 34 56 78') + expect(result.aria).toBe('+47 12 34 56 78') + }) + + it('should format a short phone number', () => { + const result = formatPhone('12345') + expect(result.number).toBe('12345') + expect(result.aria).toBe('12 34 5') + }) + + it('should format a special phone number starting with 8', () => { + const result = formatPhone('80022222') + expect(result.number).toBe('800 22 222') + expect(result.aria).toBe('80 02 22 22') + }) + + it('should handle invalid characters in phone number', () => { + const result = formatPhone('+47-123-456-78') + expect(result.number).toBe('+47 12 34 56 78') + expect(result.aria).toBe('+47 12 34 56 78') + }) + + it('should handle empty input', () => { + const result = formatPhone('') + expect(result.number).toBe('') + expect(result.aria).toBe('') + }) + + it('should handle null input', () => { + const result = formatPhone(null) + expect(result.number).toBe('') + expect(result.aria).toBe('') + }) + + it('should handle undefined input', () => { + const result = formatPhone(undefined) + expect(result.number).toBe('') + expect(result.aria).toBe('') + }) +}) diff --git a/packages/dnb-eufemia/src/components/number-format/__tests__/__image_snapshots__/numberformat-have-to-match-phone.snap.png b/packages/dnb-eufemia/src/components/number-format/__tests__/__image_snapshots__/numberformat-have-to-match-phone.snap.png index f9a371bc11d..e070187399e 100644 Binary files a/packages/dnb-eufemia/src/components/number-format/__tests__/__image_snapshots__/numberformat-have-to-match-phone.snap.png and b/packages/dnb-eufemia/src/components/number-format/__tests__/__image_snapshots__/numberformat-have-to-match-phone.snap.png differ diff --git a/packages/dnb-eufemia/src/components/number-format/__tests__/__image_snapshots__/numberformat-with-skeleton-have-to-match-phone.snap.png b/packages/dnb-eufemia/src/components/number-format/__tests__/__image_snapshots__/numberformat-with-skeleton-have-to-match-phone.snap.png index 6b2629e14af..2cf8ce755ef 100644 Binary files a/packages/dnb-eufemia/src/components/number-format/__tests__/__image_snapshots__/numberformat-with-skeleton-have-to-match-phone.snap.png and b/packages/dnb-eufemia/src/components/number-format/__tests__/__image_snapshots__/numberformat-with-skeleton-have-to-match-phone.snap.png differ diff --git a/packages/dnb-eufemia/src/components/progress-indicator/__tests__/__image_snapshots__/progressindicator-for-sbanken-have-to-match-customized-countdown.snap.png b/packages/dnb-eufemia/src/components/progress-indicator/__tests__/__image_snapshots__/progressindicator-for-sbanken-have-to-match-customized-countdown.snap.png index 69c523c2868..3b367230f60 100644 Binary files a/packages/dnb-eufemia/src/components/progress-indicator/__tests__/__image_snapshots__/progressindicator-for-sbanken-have-to-match-customized-countdown.snap.png and b/packages/dnb-eufemia/src/components/progress-indicator/__tests__/__image_snapshots__/progressindicator-for-sbanken-have-to-match-customized-countdown.snap.png differ diff --git a/packages/dnb-eufemia/src/components/skeleton/__tests__/__snapshots__/Skeleton.test.tsx.snap b/packages/dnb-eufemia/src/components/skeleton/__tests__/__snapshots__/Skeleton.test.tsx.snap index 0d8abcf311b..dd51eb0f98c 100644 --- a/packages/dnb-eufemia/src/components/skeleton/__tests__/__snapshots__/Skeleton.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/skeleton/__tests__/__snapshots__/Skeleton.test.tsx.snap @@ -19,6 +19,8 @@ exports[`Skeleton scss has to match style dependencies css 1`] = ` */ .dnb-skeleton { --skeleton-delay: 5s; + --skeleton-duration: 1.5s; + --skeleton-iteration-count: 20; } .dnb-skeleton img, .dnb-skeleton video { @@ -65,7 +67,7 @@ exports[`Skeleton scss has to match style dependencies css 1`] = ` background-repeat: repeat !important; background-size: 100% !important; clip-path: polygon(100% 0, 100% 0, 100% 100%, 100% 100%); - animation: skeletonLinearAnimation 1.5s linear infinite var(--skeleton-delay); + animation: skeletonLinearAnimation var(--skeleton-duration) linear var(--skeleton-iteration-count) var(--skeleton-delay); } .dnb-skeleton--code pre, .dnb-skeleton--code pre *, diff --git a/packages/dnb-eufemia/src/components/skeleton/style/dnb-skeleton.scss b/packages/dnb-eufemia/src/components/skeleton/style/dnb-skeleton.scss index d6dc3193932..fc1f1ab52ae 100644 --- a/packages/dnb-eufemia/src/components/skeleton/style/dnb-skeleton.scss +++ b/packages/dnb-eufemia/src/components/skeleton/style/dnb-skeleton.scss @@ -9,6 +9,8 @@ .dnb-skeleton { --skeleton-delay: 5s; + --skeleton-duration: 1.5s; + --skeleton-iteration-count: 20; img, video { @@ -74,8 +76,8 @@ background-size: 100% !important; // to take presence clip-path: polygon(100% 0, 100% 0, 100% 100%, 100% 100%); - animation: skeletonLinearAnimation 1.5s linear infinite - var(--skeleton-delay); + animation: skeletonLinearAnimation var(--skeleton-duration) linear + var(--skeleton-iteration-count) var(--skeleton-delay); } &--code, diff --git a/packages/dnb-eufemia/src/components/tabs/__tests__/Tabs.screenshot.test.ts b/packages/dnb-eufemia/src/components/tabs/__tests__/Tabs.screenshot.test.ts index 2c730e668c6..63ad4137fa9 100644 --- a/packages/dnb-eufemia/src/components/tabs/__tests__/Tabs.screenshot.test.ts +++ b/packages/dnb-eufemia/src/components/tabs/__tests__/Tabs.screenshot.test.ts @@ -115,6 +115,7 @@ describe.each(['ui', 'sbanken'])('Tabs for %s', (themeName) => { simulateSelector: '[data-visual-test="tabs-tablist"] .dnb-tabs__tabs__tablist .dnb-tabs__button__snap:nth-of-type(2) button', simulate: 'focus', + waitAfterSimulate: isCI ? 100 : 0, // ensure the buttons are "hidden", so give time for a slow CI }) expect(screenshot).toMatchImageSnapshot() }) diff --git a/packages/dnb-eufemia/src/core/jest/jestSetup.js b/packages/dnb-eufemia/src/core/jest/jestSetup.js index 1e3afff519e..e049a4951d8 100644 --- a/packages/dnb-eufemia/src/core/jest/jestSetup.js +++ b/packages/dnb-eufemia/src/core/jest/jestSetup.js @@ -7,18 +7,11 @@ import { axe, toHaveNoViolations } from 'jest-axe' import fs from 'fs-extra' import path from 'path' import sass from 'sass' -import { toBeType } from 'jest-tobetype' export { axe, toHaveNoViolations } -expect.extend({ toBeType }) expect.extend(toHaveNoViolations) -// To cleanup axe test leftovers from a test run before the current one -beforeEach(() => { - document.body.innerHTML = '' -}) - export const wait = (t) => new Promise((r) => setTimeout(r, t)) export const loadScss = (file, options = {}) => { @@ -136,33 +129,3 @@ export const axeComponent = async (...components) => { typeof components[1] === 'object' ? components[1] : null ) } - -// For Yarn v3 we need this fix in order to make jest-axe work properly -// https://github.com/nickcolley/jest-axe/issues/147 -if (typeof window !== 'undefined') { - const { getComputedStyle } = window - window.getComputedStyle = (...args) => getComputedStyle(...args) -} - -const originalError = console.error -export function bypassActWarning() { - // this is just a little hack to silence a warning that we'll get until we - // upgrade to 16.9. See also: https://github.com/facebook/react/pull/14853 - beforeAll(() => { - console.error = (...args) => { - if (/Warning.*not wrapped in act/.test(args[0])) { - return - } - originalError.call(console, ...args) - } - }) - - afterAll(() => { - console.error = originalError - }) -} - -// Call it for now regardless -// TODO: We may call this later only if enzyme is used -// but we can't call it "inside a test", because we use beforeAll / afterAll -bypassActWarning() diff --git a/packages/dnb-eufemia/src/core/jest/jestSetupScreenshots.ts b/packages/dnb-eufemia/src/core/jest/jestSetupScreenshots.ts index d408ac6ca2f..013be630f2d 100644 --- a/packages/dnb-eufemia/src/core/jest/jestSetupScreenshots.ts +++ b/packages/dnb-eufemia/src/core/jest/jestSetupScreenshots.ts @@ -257,7 +257,7 @@ export const setupPageScreenshot = ( pageViewport = null, headers = null, fullscreen = false, - timeout = null, + timeout = 60e3, matchConfig = null, } = { url: undefined } ) => { diff --git a/packages/dnb-eufemia/src/core/jest/setupJest.js b/packages/dnb-eufemia/src/core/jest/setupJest.js index 6a39514ac93..8a85712d731 100644 --- a/packages/dnb-eufemia/src/core/jest/setupJest.js +++ b/packages/dnb-eufemia/src/core/jest/setupJest.js @@ -4,3 +4,61 @@ */ import '@testing-library/jest-dom' +import { waitFor } from '@testing-library/react' +import { toBeType } from 'jest-tobetype' + +// To cleanup axe test leftovers from a test run before the current one +beforeEach(() => { + document.body.innerHTML = '' +}) + +expect.extend({ toBeType }) + +expect.extend({ + async toNeverResolve(callable) { + try { + await waitFor(callable) + return { + pass: false, + message: () => 'Expected the function to reject, but it resolved.', + } + } catch (error) { + // If it rejects, the test passes + return { + pass: true, + message: () => + 'Expected the function to resolve, but it correctly rejected.', + } + } + }, +}) + +// For Yarn v3 we need this fix in order to make jest-axe work properly +// https://github.com/nickcolley/jest-axe/issues/147 +if (typeof window !== 'undefined') { + const { getComputedStyle } = window + window.getComputedStyle = (...args) => getComputedStyle(...args) +} + +const originalError = console.error +export function bypassActWarning() { + // this is just a little hack to silence a warning that we'll get until we + // upgrade to 16.9. See also: https://github.com/facebook/react/pull/14853 + beforeAll(() => { + console.error = (...args) => { + if (/Warning.*not wrapped in act/.test(args[0])) { + return + } + originalError.call(console, ...args) + } + }) + + afterAll(() => { + console.error = originalError + }) +} + +// Call it for now regardless +// TODO: We may call this later only if enzyme is used +// but we can't call it "inside a test", because we use beforeAll / afterAll +bypassActWarning() diff --git a/packages/dnb-eufemia/src/core/jest/setupJestScreenshot.js b/packages/dnb-eufemia/src/core/jest/setupJestScreenshot.js index 294dea685df..3821a6d79ef 100644 --- a/packages/dnb-eufemia/src/core/jest/setupJestScreenshot.js +++ b/packages/dnb-eufemia/src/core/jest/setupJestScreenshot.js @@ -13,7 +13,7 @@ jest.setTimeout( ? config.delayDuringNonheadless : config.timeout > 0 ? config.timeout - : 30e3 + : 60e3 ) setMatchConfig(config.matchConfig) diff --git a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-eiendom-have-to-match-the-additional-heading-examples.snap.png b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-eiendom-have-to-match-the-additional-heading-examples.snap.png index cd81c904ff3..b4b8d2da943 100644 Binary files a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-eiendom-have-to-match-the-additional-heading-examples.snap.png and b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-eiendom-have-to-match-the-additional-heading-examples.snap.png differ diff --git a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-sbanken-have-to-match-the-additional-heading-examples.snap.png b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-sbanken-have-to-match-the-additional-heading-examples.snap.png index f252a23821a..e6a3a09971e 100644 Binary files a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-sbanken-have-to-match-the-additional-heading-examples.snap.png and b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-sbanken-have-to-match-the-additional-heading-examples.snap.png differ diff --git a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-sbanken-matches-all-sizes-and-variants.snap.png b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-sbanken-matches-all-sizes-and-variants.snap.png index 06e73a3a043..a5a2b06d683 100644 Binary files a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-sbanken-matches-all-sizes-and-variants.snap.png and b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-sbanken-matches-all-sizes-and-variants.snap.png differ diff --git a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-ui-have-to-match-the-additional-heading-examples.snap.png b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-ui-have-to-match-the-additional-heading-examples.snap.png index 95effbba30f..36b324f025c 100644 Binary files a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-ui-have-to-match-the-additional-heading-examples.snap.png and b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-for-ui-have-to-match-the-additional-heading-examples.snap.png differ diff --git a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-mobile-for-sbanken-have-to-match-the-additional-heading-examples.snap.png b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-mobile-for-sbanken-have-to-match-the-additional-heading-examples.snap.png index ae92c7d47be..9614678c93d 100644 Binary files a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-mobile-for-sbanken-have-to-match-the-additional-heading-examples.snap.png and b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-mobile-for-sbanken-have-to-match-the-additional-heading-examples.snap.png differ diff --git a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-mobile-for-sbanken-matches-all-sizes-and-variants.snap.png b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-mobile-for-sbanken-matches-all-sizes-and-variants.snap.png index 78adc984b4e..51a1ee74dc5 100644 Binary files a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-mobile-for-sbanken-matches-all-sizes-and-variants.snap.png and b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/heading-mobile-for-sbanken-matches-all-sizes-and-variants.snap.png differ diff --git a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/paragraph-for-sbanken-matches-all-sizes-and-weights.snap.png b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/paragraph-for-sbanken-matches-all-sizes-and-weights.snap.png index 6b555a9fd4a..99b7f00752c 100644 Binary files a/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/paragraph-for-sbanken-matches-all-sizes-and-weights.snap.png and b/packages/dnb-eufemia/src/elements/typography/__tests__/__image_snapshots__/paragraph-for-sbanken-matches-all-sizes-and-weights.snap.png differ diff --git a/packages/dnb-eufemia/src/elements/typography/style/themes/dnb-typography-theme-sbanken.scss b/packages/dnb-eufemia/src/elements/typography/style/themes/dnb-typography-theme-sbanken.scss index 2e10665bb44..50064e93b27 100644 --- a/packages/dnb-eufemia/src/elements/typography/style/themes/dnb-typography-theme-sbanken.scss +++ b/packages/dnb-eufemia/src/elements/typography/style/themes/dnb-typography-theme-sbanken.scss @@ -21,18 +21,37 @@ --typography-h-default-font-family: var(--sb-font-family-headings); --typography-h-xx-large-weight: var(--font-weight-regular); --typography-h-x-large-weight: var(--font-weight-regular); + --typography-h-large-small-font-size: var(--sb-font-size-medium--plus); --typography-h-large-weight: var(--font-weight-regular); + --typography-lead-small-font-size: var(--font-size-basis); + --typography-lead-small-line-height: var(--line-height-basis); --typography-lead-weight: var(--font-weight-regular); + --typography-h-medium-font-size: var(--sb-font-size-medium--plus); + --typography-h-medium-small-font-size: var(--font-size-medium); --typography-h-medium-weight: var(--font-weight-regular); @include utilities.allBelow(small) { + // xx-large --typography-h-xx-large-font-size: var(--font-size-x-large); --typography-h-xx-large-line-height: var(--line-height-x-large); + --typography-h-xx-large-small-font-size: var(--font-size-large); + --typography-h-xx-large-small-line-height: var(--line-height-large); + // x-large --typography-h-x-large-font-size: var(--font-size-large); --typography-h-x-large-line-height: var(--line-height-large); - --typography-h-large-font-size: var(--font-size-medium); + --typography-h-x-large-small-font-size: var( + --sb-font-size-medium--plus + ); + --typography-h-x-large-small-line-height: var(--line-height-medium); + // large + --typography-h-large-font-size: var(--sb-font-size-medium--plus); --typography-h-large-line-height: var(--line-height-medium); - --typography-h-medium-font-size: var(--font-size-lead); + --typography-h-large-small-font-size: var(--font-size-medium); + --typography-h-large-small-line-height: var(--line-height-lead); + // medium + --typography-h-medium-font-size: var(--font-size-medium); --typography-h-medium-line-height: var(--line-height-lead); + --typography-h-medium-small-font-size: var(--font-size-basis); + --typography-h-medium-small-line-height: var(--line-height-basis); } } diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index ef8edd97246..3799384cfc2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -15,11 +15,13 @@ import { OnSubmitParams, } from '../types' import { Props as ProviderProps } from './Provider' +import { SnapshotName } from '../Form/Snapshot' export type MountState = { isPreMounted?: boolean isMounted?: boolean isVisible?: boolean + isFocused?: boolean wasStepChange?: boolean } @@ -154,11 +156,16 @@ export interface ContextState { params?: { remove?: boolean } ) => void setFieldConnection?: (path: Path, connections: FieldConnections) => void + isEmptyDataRef?: React.MutableRefObject fieldPropsRef?: React.MutableRefObject> valuePropsRef?: React.MutableRefObject> fieldConnectionsRef?: React.RefObject> mountedFieldsRef?: React.MutableRefObject> + snapshotsRef?: React.MutableRefObject< + Map> + > formElementRef?: React.MutableRefObject + fieldErrorRef?: React.MutableRefObject> showAllErrors: boolean hasVisibleError: boolean formState: SubmitState 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 8bafb53818d..cd5e15f0f36 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -57,6 +57,18 @@ import structuredClone from '@ungap/structured-clone' const useLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect +export type SharedAttachments = { + visibleDataHandler?: VisibleDataHandler + filterDataHandler?: FilterDataHandler + hasErrors?: ContextState['hasErrors'] + hasFieldError?: ContextState['hasFieldError'] + setShowAllErrors?: ContextState['setShowAllErrors'] + setSubmitState?: ContextState['setSubmitState'] + rerenderUseDataHook?: () => void + clearData?: () => void + fieldConnectionsRef?: ContextState['fieldConnectionsRef'] +} + export interface Props extends IsolationProviderProps { /** @@ -237,6 +249,12 @@ export default function Provider( // - Paths const mountedFieldsRef: ContextState['mountedFieldsRef'] = useRef({}) + // - Snapshots + const snapshotsRef: ContextState['snapshotsRef'] = useRef() + if (!snapshotsRef.current) { + snapshotsRef.current = new Map() + } + // - Errors from provider validation (the whole data set) const hasVisibleErrorRef = useRef>({}) const errorsRef = useRef | undefined>() @@ -291,6 +309,7 @@ export default function Provider( // eslint-disable-next-line react-hooks/exhaustive-deps -- Avoid triggering code that should only run initially }, []) const internalDataRef = useRef(initialData) + const isEmptyDataRef = useRef(false) // - Validator const ajvValidatorRef = useRef() @@ -585,17 +604,13 @@ export default function Provider( }, []) // - Shared state - const sharedData = useSharedState void }>(id) - const sharedAttachments = useSharedState<{ - visibleDataHandler?: VisibleDataHandler - filterDataHandler?: FilterDataHandler - hasErrors?: ContextState['hasErrors'] - hasFieldError?: ContextState['hasFieldError'] - setShowAllErrors?: ContextState['setShowAllErrors'] - setSubmitState?: ContextState['setSubmitState'] - rerenderUseDataHook?: () => void - fieldConnectionsRef?: ContextState['fieldConnectionsRef'] - }>(id + '-attachments') + const sharedData = useSharedState(id) + const sharedAttachments = useSharedState>( + id + '-attachments' + ) + const sharedDataContext = useSharedState( + id + '-data-context' + ) const setSharedData = sharedData.set const extendSharedData = sharedData.extend @@ -628,7 +643,7 @@ export default function Provider( ) { return { ...internalDataRef.current, - ...sharedData.data, + ...(sharedData.data || {}), } } @@ -653,16 +668,15 @@ export default function Provider( ) { cacheRef.current.shared = sharedData.data - // Reset the shared state, if clearForm is set - if (sharedData.data?.clearForm) { - const clear = (cacheRef.current.shared = clearedData as Data) - setSharedData(clear) - return clear + if (isEmptyDataRef.current) { + return ( + Array.isArray(internalDataRef.current) ? [] : clearedData + ) as Data } return { ...internalDataRef.current, - ...sharedData.data, + ...(sharedData.data || {}), } } @@ -673,18 +687,31 @@ export default function Provider( } return internalDataRef.current - }, [id, initialData, sharedData, data, setSharedData]) + }, [id, initialData, sharedData, data]) internalDataRef.current = props.path && pointer.has(internalData, props.path) ? pointer.get(internalData, props.path) : internalData - useEffect(() => { - if (sharedData.data?.clearForm) { - onClear?.() + const clearData = useCallback(() => { + isEmptyDataRef.current = true + internalDataRef.current = ((typeof emptyData === 'function' + ? emptyData(internalDataRef.current) + : emptyData) ?? + (Array.isArray(internalDataRef.current) ? [] : clearedData)) as Data + + if (id) { + setSharedData?.(internalDataRef.current) } - }, [onClear, sharedData.data?.clearForm]) + + forceUpdate() + onClear?.() + + requestAnimationFrame?.(() => { + isEmptyDataRef.current = false + }) // Delay so the field validation error message are not shown + }, [emptyData, id, onClear, setSharedData]) useLayoutEffect(() => { // Set the shared state, if initialData was given @@ -708,6 +735,7 @@ export default function Provider( hasFieldError, setShowAllErrors, setSubmitState, + clearData, fieldConnectionsRef, }) if (filterSubmitData) { @@ -725,6 +753,7 @@ export default function Provider( rerenderUseDataHook, setShowAllErrors, setSubmitState, + clearData, ]) const storeInSession = useMemo(() => { @@ -926,17 +955,6 @@ export default function Provider( } }, []) - const clearData = useCallback(() => { - internalDataRef.current = (emptyData ?? clearedData) as Data - - if (id) { - setSharedData?.(internalDataRef.current) - } else { - forceUpdate() - } - onClear?.() - }, [emptyData, id, onClear, setSharedData]) - /** * Shared logic dedicated to submit the whole form */ @@ -1268,67 +1286,73 @@ export default function Provider( ? true : undefined + const contextValue: ContextState = { + /** Method */ + handlePathChange, + handlePathChangeUnvalidated, + handleSubmit, + setMountedFieldState, + handleSubmitCall, + setFormState, + setSubmitState, + setShowAllErrors, + setVisibleError, + setFieldEventListener, + setFieldState, + setFieldError, + setFieldConnection, + setFieldProps, + setValueProps, + hasErrors, + hasFieldError, + hasFieldState, + validateData, + updateDataValue, + setData, + clearData, + visibleDataHandler, + filterDataHandler, + getSubmitData, + getSubmitOptions, + addOnChangeHandler, + setHandleSubmit, + scrollToTop, + + /** State handling */ + schema, + disabled, + required, + formState, + submitState, + contextErrorMessages, + hasContext: true, + errors: errorsRef.current, + showAllErrors: showAllErrorsRef.current, + hasVisibleError: Object.keys(hasVisibleErrorRef.current).length > 0, + fieldConnectionsRef, + fieldPropsRef, + valuePropsRef, + mountedFieldsRef, + snapshotsRef, + formElementRef, + isEmptyDataRef, + fieldErrorRef, + ajvInstance: ajvRef.current, + + /** Additional */ + id, + data: internalDataRef.current, + internalDataRef, + props, + ...rest, + } + + if (id) { + sharedDataContext.set(contextValue) + } + return ( - 0, - fieldConnectionsRef, - fieldPropsRef, - valuePropsRef, - mountedFieldsRef, - formElementRef, - ajvInstance: ajvRef.current, - - /** Additional */ - id, - data: internalDataRef.current, - internalDataRef, - props, - ...rest, - }} - > + { - - hasErrors: {JSON.stringify(hasErrors(), null, 2)} -
-            {JSON.stringify(
-              replaceUndefinedValues(filterData(filterDataHandler)),
-              null,
-              2
-            )}
-          
-
+ +
) } - -/** - * Replaces undefined values in an object with a specified replacement value. - * @param value - The value to check for undefined values. - * @param replaceWith - The value to replace undefined values with. Default is null. - * @returns The object with undefined values replaced. - */ -function replaceUndefinedValues( - value: unknown, - replaceWith = null -): unknown { - if (typeof value === 'undefined') { - return replaceWith - } else if (typeof value === 'object' && value !== replaceWith) { - return { - ...value, - ...Object.fromEntries( - Object.entries(value).map(([k, v]) => [ - k, - replaceUndefinedValues(v), - ]) - ), - } - } else { - return value - } -} 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 34b55978e7d..64c97dc00d0 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 @@ -55,7 +55,7 @@ describe('ArraySelection', () => { it('handles emptyValue correctly', () => { const handleChange = jest.fn() render( - + Option 1 Option 2 @@ -63,7 +63,7 @@ describe('ArraySelection', () => { fireEvent.click(screen.getByText('Option 1')) fireEvent.click(screen.getByText('Option 1')) - expect(handleChange).toHaveBeenLastCalledWith('empty') + expect(handleChange).toHaveBeenLastCalledWith([]) }) it('displays error message when error prop is provided', () => { 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 a3cc7ed9d8b..ee9f7c9c527 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx @@ -1,27 +1,108 @@ import React, { useCallback, useMemo } from 'react' import StringField, { Props as StringFieldProps } from '../String' import { dnr, fnr } from '@navikt/fnrvalidator' +import { FormError, Validator } from '../../types' import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' -export type Props = StringFieldProps & { +export type Props = Omit & { omitMask?: boolean validate?: boolean + onBlurValidator?: Validator | false } function NationalIdentityNumber(props: Props) { - const { validate = true, omitMask } = props - const translations = useTranslation().NationalIdentityNumber - const { label, errorRequired, errorFnr, errorDnr } = translations + const { + label, + errorRequired, + errorFnr, + errorFnrLength, + errorDnr, + errorDnrLength, + errorMinimumAgeValidator, + errorMinimumAgeValidatorLength, + } = translations const errorMessages = useErrorMessage(props.path, props.errorMessages, { required: errorRequired, - pattern: errorRequired, + pattern: errorFnr, errorFnr, + errorFnrLength, errorDnr, + errorDnrLength, + errorMinimumAgeValidator, + errorMinimumAgeValidatorLength, }) + const identificationNumberIsOfLength = ( + identificationNumber: string, + length: number + ) => { + return identificationNumber?.length === length + } + + const fnrValidator = useCallback( + (value: string) => { + if (value !== undefined) { + if (Number.parseInt(value.substring(0, 1)) > 3) { + return Error(errorFnr) + } + + const fnrIs11Digits = identificationNumberIsOfLength(value, 11) + + if (!fnrIs11Digits) { + return Error(errorFnrLength) + } + if (fnrIs11Digits && fnr(value).status === 'invalid') { + return Error(errorFnr) + } + } + }, + [errorFnr, errorFnrLength] + ) + + const dnrValidator = useCallback( + (value: string) => { + if (value !== undefined) { + if (Number.parseInt(value.substring(0, 1)) < 4) { + return Error(errorDnr) + } + + const dnrIs11Digits = identificationNumberIsOfLength(value, 11) + + if (!dnrIs11Digits) { + return Error(errorDnrLength) + } + if (dnrIs11Digits && dnr(value).status === 'invalid') { + return Error(errorDnr) + } + } + }, + [errorDnr, errorDnrLength] + ) + + const dnrAndFnrValidator = useCallback( + (value: string) => { + const dnrValidationPattern = '^[4-9].*' // 1st num is increased by 4. i.e, if 01.01.1985, D number would be 410185. + + if (new RegExp(dnrValidationPattern).test(value)) { + return dnrValidator(value) + } + return fnrValidator(value) + }, + [dnrValidator, fnrValidator] + ) + + const { + validate = true, + omitMask, + onBlurValidator = dnrAndFnrValidator, + validator, + width, + label: labelProp, + } = props + const mask = useMemo( () => omitMask @@ -42,61 +123,98 @@ 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(errorFnr) - } - }, - [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(errorDnr) - } - }, - [errorDnr] - ) - - const dnrAndFnrValidator = useCallback( - (value: string) => { - return dnrValidator(value) || fnrValidator(value) - }, - [dnrValidator, fnrValidator] - ) + const onBlurValidatorToUse = + onBlurValidator === false ? undefined : onBlurValidator - const StringFieldProps: Props = { + const StringFieldProps: StringFieldProps = { ...props, - pattern: - validate && props.pattern - ? props.pattern - : validate && !props.validator - ? validationPattern - : undefined, - label: props.label ?? label, + label: labelProp ?? label, errorMessages, mask, - width: props.width ?? 'medium', + width: width ?? 'medium', inputMode: 'numeric', - validator: validate - ? props.validator || dnrAndFnrValidator - : undefined, - exportValidators: { dnrValidator, fnrValidator }, + validator: validate ? validator : undefined, + onBlurValidator: validate ? onBlurValidatorToUse : undefined, + exportValidators: { + dnrValidator, + fnrValidator, + dnrAndFnrValidator, + }, } return } +export function getAgeByBirthDate(birthDate: Date): number { + const today = new Date() + const age = today.getFullYear() - birthDate.getFullYear() + const month = today.getMonth() - birthDate.getMonth() + const day = today.getDate() - birthDate.getDate() + + if (month < 0 || (month === 0 && day < 0)) { + return age - 1 + } + + return age +} + +export function getBirthDateByFnrOrDnr(value: string) { + if (value === undefined) { + return // stop here + } + + const yearPart = value.substring(4, 6) + const centuryNumber = Number.parseInt(value.substring(6, 7)) + + const isBornIn20XX = centuryNumber >= 5 + const year = isBornIn20XX ? `20${yearPart}` : `19${yearPart}` + const month = Number.parseInt(value.substring(2, 4)) + + const differentiatorValue = + value.length > 0 ? Number.parseInt(value.substring(0, 1)) : undefined + const isDnr = differentiatorValue && differentiatorValue > 3 + + const day = isDnr + ? Number.parseInt(value.substring(0, 2)) - 40 + : Number.parseInt(value.substring(0, 2)) + + return new Date(Number.parseInt(year), month - 1, day) +} + +export function createMinimumAgeValidator(age: number) { + return (value: string) => { + if (typeof value !== 'string') { + return // stop here + } + + const identificationNumberIs7DigitsOrMore = value?.length >= 7 + + if (!identificationNumberIs7DigitsOrMore) { + return new FormError( + 'NationalIdentityNumber.errorMinimumAgeValidatorLength', + { + validationRule: 'errorMinimumAgeValidatorLength', // "validationRule" Will be removed in future PR + } + ) + } + + if (identificationNumberIs7DigitsOrMore) { + const date = getBirthDateByFnrOrDnr(value) + if (getAgeByBirthDate(date) >= age) { + return // stop here + } + } + + return new FormError( + 'NationalIdentityNumber.errorMinimumAgeValidator', + { + validationRule: 'errorMinimumAgeValidator', // "validationRule" Will be removed in future PR + messageValues: { age: String(age) }, + } + ) + } +} + NationalIdentityNumber._supportsSpacingProps = true export default NationalIdentityNumber diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumberDocs.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumberDocs.tsx index 2445ff8175b..809a0109cc4 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumberDocs.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumberDocs.tsx @@ -11,4 +11,9 @@ export const NationalIdentityNumberProperties: PropertiesTableProps = { type: 'object', status: 'optional', }, + onBlurValidator: { + doc: 'Custom validator function that is triggered when the user leaves a field (e.g., blurring a text input or closing a dropdown). The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }. Defaults to validation of the identification number(national identity numbers and D numbers), using `dnrAndFnrValidator`. Can be disabled using `false`.', + type: 'function', + status: 'optional', + }, } 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 d89d405f1b0..e220fd4695a 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 @@ -3,13 +3,10 @@ import { fireEvent, render, waitFor, screen } from '@testing-library/react' import { Props } from '..' import { Field, Form, Validator } from '../../..' import nbNO from '../../../constants/locales/nb-NO' +import userEvent from '@testing-library/user-event' 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', () => { const props: Props = {} @@ -53,20 +50,90 @@ describe('Field.NationalIdentityNumber', () => { expect(screen.queryByRole('alert')).toBeInTheDocument() }) - it('should execute validateInitially if required', async () => { + it('should validate "required"', async () => { + render() + + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorRequired + ) + }) + + it('should validate internal validator', async () => { const { rerender } = render( - + ) - expect(screen.queryByRole('alert')).toBeInTheDocument() + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorFnr + ) + }) - rerender() + rerender( + + ) await waitFor(() => { - expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorDnr + ) }) }) + it('should support custom pattern', async () => { + render( + + + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert').textContent).toBe( + nb.NationalIdentityNumber.errorFnr + ) + }) + }) + + it('should support custom pattern without validator', async () => { + const dummyValidator = jest.fn() + + render( + + { + return [dummyValidator] + }} + /> + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + + expect(dummyValidator).toHaveBeenCalledTimes(1) + expect(dummyValidator).toHaveBeenCalledWith('6', expect.anything()) + }) + it('should validate given function', async () => { const text = 'Custom Error message' const validator = jest.fn((value) => { @@ -111,8 +178,8 @@ describe('Field.NationalIdentityNumber', () => { errorMessages: expect.objectContaining({ maxLength: expect.stringContaining('{maxLength}'), minLength: expect.stringContaining('{minLength}'), - pattern: expect.stringContaining('11'), - required: expect.stringContaining('11'), + pattern: expect.stringContaining('fødselsnummer'), + required: expect.stringContaining('fødselsnummer'), errorDnr: expect.stringContaining('d-nummer'), errorFnr: expect.stringContaining('fødselsnummer'), }), @@ -128,35 +195,86 @@ 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. + it('should not provide an error for empty/undefined value when not required', async () => { + render() + + const element = document.querySelector('input') + await userEvent.type(element, '12312312312') + expect(element.value).toBe('123123 12312') + await userEvent.type(element, '{Backspace>11}') + expect(element).toHaveValue('') + + element.blur() + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + }) + + it('should provide an error for empty/undefined value when required', async () => { + render() + + const element = document.querySelector('input') + await userEvent.type(element, '12312312312') + expect(element.value).toBe('123123 12312') + await userEvent.type(element, '{Backspace>11}') + expect(element).toHaveValue('') + + element.blur() + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorRequired + ) + }) + }) + + it('should display error if required and validateInitially', async () => { + render() + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorRequired + ) + }) + }) + + it('should display error when validateInitially and value', async () => { + render() + + await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorFnr + ) }) }) - it('should not validate custom pattern when validate false', async () => { - const invalidPattern = '1234' + it('should not display error when validateInitially and no value', async () => { + render() + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + }) + + it('should not validate when onBlurValidator is 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. + + fireEvent.blur(document.querySelector('input')) + + await expect(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() - }) + }).toNeverResolve() }) it('should not validate dnum when validate false', async () => { @@ -168,10 +286,10 @@ describe('Field.NationalIdentityNumber', () => { validate={false} /> ) - 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() - }) + + fireEvent.blur(document.querySelector('input')) + + expect(screen.queryByRole('alert')).toBeNull() }) it('should not validate fnr when validate false', async () => { @@ -183,46 +301,40 @@ describe('Field.NationalIdentityNumber', () => { validate={false} /> ) - 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() - }) + + fireEvent.blur(document.querySelector('input')) + + expect(screen.queryByRole('alert')).toBeNull() }) 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 - }) + const customValidator: Validator = (value) => { + if (value?.length < 4) { + return new Error('My error') + } + } 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() - }) + expect(screen.queryByRole('alert')).toBeNull() }) it('should not validate extended validator when validate false', async () => { - const invalidFnr = '29040112345' + const invalidFnrBornInApril = '29040112345' - const bornInApril = (value: string) => - value.substring(2, 4) === '04' - ? { status: 'valid' } - : { status: 'invalid' } + const bornInApril = (value: string) => value.substring(2, 4) === '04' const customValidator: Validator = (value, { validators }) => { const { dnrValidator, fnrValidator } = validators - const result = bornInApril(value) - if (result.status === 'invalid') { + if (bornInApril(value)) { return new Error('custom error') } @@ -231,16 +343,14 @@ describe('Field.NationalIdentityNumber', () => { 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() - }) + + expect(screen.queryByRole('alert')).toBeNull() }) describe('should validate Norwegian D number', () => { @@ -262,14 +372,22 @@ describe('Field.NationalIdentityNumber', () => { '53137248022', ] + const invalidDNumTooShort = [ + '6', + '5309724803', + '5309724', + '72127248', + '5313', + ] + 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() - }) + + fireEvent.blur(document.querySelector('input')) + + expect(screen.queryByRole('alert')).toBeNull() }) it.each(invalidDNum)('Invalid D number: %s', async (dNum) => { @@ -277,6 +395,8 @@ describe('Field.NationalIdentityNumber', () => { ) + fireEvent.blur(document.querySelector('input')) + await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() expect(screen.queryByRole('alert')).toHaveTextContent( @@ -284,6 +404,21 @@ describe('Field.NationalIdentityNumber', () => { ) }) }) + + it.each(invalidDNumTooShort)('Invalid D number: %s', async (dNum) => { + render( + + ) + + fireEvent.blur(document.querySelector('input')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorDnrLength + ) + }) + }) }) describe('should validate Norwegian national identity number(fnr)', () => { @@ -308,16 +443,24 @@ describe('Field.NationalIdentityNumber', () => { '13137248022', ] + const invalidFnrNumTooShort = [ + '2', + '1309724803', + '1309724', + '321', + '131372480', + ] + 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() - }) + + fireEvent.blur(document.querySelector('input')) + + expect(screen.queryByRole('alert')).toBeNull() } ) @@ -327,6 +470,9 @@ describe('Field.NationalIdentityNumber', () => { render( ) + + fireEvent.blur(document.querySelector('input')) + await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() expect(screen.queryByRole('alert')).toHaveTextContent( @@ -335,6 +481,24 @@ describe('Field.NationalIdentityNumber', () => { }) } ) + + it.each(invalidFnrNumTooShort)( + 'Invalid national identity number(fnr): %s', + async (fnrNum) => { + render( + + ) + + fireEvent.blur(document.querySelector('input')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorFnrLength + ) + }) + } + ) }) describe('should extend validation using custom validator', () => { @@ -345,6 +509,8 @@ describe('Field.NationalIdentityNumber', () => { const invalidFnrNumApril = ['29040112345', '13047248032'] const invalidDNumApril = ['69040112345', '53047248032'] + const invalidFnrTooShort = ['2904011234', '1'] + const invalidDNumTooShort = ['6904011234', '5'] const validFnrNumNotApril = [ '58081633086', @@ -355,19 +521,16 @@ describe('Field.NationalIdentityNumber', () => { const invalidIds = [...validFnrNumNotApril, ...validDNumNotApril] - const bornInApril = (value: string) => - value.substring(2, 4) === '04' - ? { status: 'valid' } - : { status: 'invalid' } - - const customValidator: Validator = (value, { validators }) => { - const { dnrValidator, fnrValidator } = validators - const result = bornInApril(value) - if (result.status === 'invalid') { + const bornInAprilValidator = (value: string) => { + if (value.substring(2, 4) !== '04') { return new Error('custom error') } + } - return [dnrValidator, fnrValidator] + const customValidator: Validator = (value, { validators }) => { + const { dnrAndFnrValidator } = validators + + return [dnrAndFnrValidator, bornInAprilValidator] } it.each(validIds)('Valid identity number: %s', async (fnrNum) => { @@ -378,10 +541,8 @@ describe('Field.NationalIdentityNumber', () => { value={fnrNum} /> ) - 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() - }) + + expect(screen.queryByRole('alert')).toBeNull() }) it.each(invalidIds)('Invalid identity number: %s', async (id) => { @@ -392,6 +553,7 @@ describe('Field.NationalIdentityNumber', () => { value={id} /> ) + await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() expect(screen.queryByRole('alert')).toHaveTextContent( @@ -408,6 +570,7 @@ describe('Field.NationalIdentityNumber', () => { value={dNum} /> ) + await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() expect(screen.queryByRole('alert')).toHaveTextContent( @@ -416,6 +579,23 @@ describe('Field.NationalIdentityNumber', () => { }) }) + it.each(invalidDNumTooShort)('Invalid D number: %s', async (dNum) => { + render( + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorDnrLength + ) + }) + }) + it.each(invalidFnrNumApril)( 'Invalid national identity number(fnr): %s', async (fnr) => { @@ -426,6 +606,7 @@ describe('Field.NationalIdentityNumber', () => { value={fnr} /> ) + await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() expect(screen.queryByRole('alert')).toHaveTextContent( @@ -434,5 +615,25 @@ describe('Field.NationalIdentityNumber', () => { }) } ) + + it.each(invalidFnrTooShort)( + 'Invalid national identity number(fnr): %s', + async (fnr) => { + render( + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorFnrLength + ) + }) + } + ) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumberMinimumAgeValidator.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumberMinimumAgeValidator.test.tsx new file mode 100644 index 00000000000..30e958f7212 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumberMinimumAgeValidator.test.tsx @@ -0,0 +1,669 @@ +import React from 'react' +import { render, waitFor, screen } from '@testing-library/react' +import { Field, Validator } from '../../..' +import { createMinimumAgeValidator } from '../NationalIdentityNumber' + +import nbNO from '../../../constants/locales/nb-NO' + +const nb = nbNO['nb-NO'] + +describe('Field.NationalIdentityNumber with minimumAgeValidator', () => { + const errorMinimumAgeValidator = + nb.NationalIdentityNumber.errorMinimumAgeValidator.replace( + '{age}', + '18' + ) + + const minimum18YearsValidator = createMinimumAgeValidator(18) + const extendingDnrAndFnrValidatorWithMin18Validator: Validator< + string + > = (value, { validators }) => { + const { dnrAndFnrValidator } = validators + + return [dnrAndFnrValidator, minimum18YearsValidator] + } + + const extendingDnrValidatorWithMin18Validator: Validator = ( + value, + { validators } + ) => { + const { dnrValidator } = validators + + return [dnrValidator, minimum18YearsValidator] + } + + const extendingFnrValidatorWithMin18Validator: Validator = ( + value, + { validators } + ) => { + const { fnrValidator } = validators + + return [fnrValidator, minimum18YearsValidator] + } + + const myMinimum18YearsValidator: Validator = () => { + return [minimum18YearsValidator] + } + + it('should display error if required and validateInitially', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorRequired + ) + }) + }) + + it('should display error when value is invalid', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorMinimumAgeValidatorLength + ) + }) + }) + + it('should not display error when validateInitially and no value', async () => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + }) + + describe('should validate if identity numbers is adult(18 years and older)', () => { + jest.useFakeTimers().setSystemTime(new Date('2024-10-09').getTime()) + const fnr0YearsOld = [ + '10072476609', + '29082499936', + '03022450718', + '11032455001', + '30082489912', + ] + + const fnr17YearsOld = [ + '31050752669', + '10040752779', + '28050772596', + '25060798446', + '07100782566', + '08100787300', + ] + + const fnrUnder18YearsOld = [...fnr0YearsOld, ...fnr17YearsOld] + + const fnr18Years = [ + '09100654021', + '09100696336', + '24040664900', + '26020682328', + '07070663990', + '11030699302', + '31010699021', + ] + const fnr99Years = [ + '14102535759', + '20042528022', + '14082523414', + '01022537632', + '01022504416', + ] + const fnr18YearsOldTo99 = [ + '25047441741', + '06118836551', + '19042648291', + '18053526132', + '29075642618', + ] + const fnr99To120YearsOld = [ + '22041330302', + '02061234694', + '23020704845', + '28021741177', + '10121933999', + ] + const fnr18YearsOldAndOlder = [ + ...fnr18Years, + ...fnr99Years, + ...fnr18YearsOldTo99, + ...fnr99To120YearsOld, + ] + + const dnrUnder18YearsOld = [ + '42011660597', + '44011957371', + '45010886213', + '60050972871', + '65052062378', + '70121275293', + '71072354979', + '43072496079', + '44052351836', + '56052459244', + '59082354829', + '63032486179', + '48100754692', + ] + const dnr18YearsOldAndOlder = [ + '49100651997', + '49100697466', + '41070663889', + '42020653633', + '41012413597', + '41062421922', + '41080422588', + '44081020024', + '71081924796', + '60067139081', + '60075812380', + ] + + const validIds = [...fnr18YearsOldAndOlder, ...dnr18YearsOldAndOlder] + + const invalidIds = [...fnrUnder18YearsOld, ...dnrUnder18YearsOld] + + const invalidDnums = ['69020112345', '690'] + const invalidFnrs = ['29020112345', '290'] + + describe('when provided as the only validator validation function', () => { + it.each(validIds)( + 'Identity number is 18 years or older : %s', + async (validId) => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) + + it.each(invalidIds)( + 'Invalid identity number is not 18 years or older: %s', + async (invalidId) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + errorMinimumAgeValidator + ) + }) + } + ) + }) + + describe('when provided as the only onBlurValidation validation function', () => { + it.each(validIds)( + 'Identity number is 18 years or older : %s', + async (validId) => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) + + it.each(invalidIds)( + 'Invalid identity number is not 18 years or older: %s', + async (invalidId) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + errorMinimumAgeValidator + ) + }) + } + ) + }) + + describe('when extending the dnrAndFnrValidator as validator', () => { + it.each(validIds)( + 'Identity number is 18 years or older : %s', + async (validId) => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) + + it.each(invalidIds)( + 'Invalid identity number is not 18 years or older: %s', + async (invalidId) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + errorMinimumAgeValidator + ) + }) + } + ) + + it.each(invalidDnums)( + 'Invalid identity number is not 18 years or older: %s', + async (invalidDnum) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorDnr + ) + }) + } + ) + + it.each(invalidFnrs)( + 'Invalid identity number is not 18 years or older: %s', + async (invalidFnr) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorFnr + ) + }) + } + ) + }) + + describe('when extending the dnrAndFnrValidator as onBlurValidator', () => { + it.each(validIds)( + 'Identity number is 18 years or older : %s', + async (validId) => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) + + it.each(invalidIds)( + 'Invalid identity number is not 18 years or older: %s', + async (invalidId) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + errorMinimumAgeValidator + ) + }) + } + ) + + it.each(invalidDnums)( + 'Invalid identity number is not 18 years or older: %s', + async (invalidDnum) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorDnr + ) + }) + } + ) + + it.each(invalidFnrs)( + 'Invalid identity number is not 18 years or older: %s', + async (invalidFnr) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorFnr + ) + }) + } + ) + }) + + describe('when extending the dnrValidator as validator', () => { + it.each(dnr18YearsOldAndOlder)( + 'D number is 18 years or older : %s', + async (validDnum) => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) + + it.each(dnrUnder18YearsOld)( + 'D number is not 18 years or older: %s', + async (invalidDnum) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + errorMinimumAgeValidator + ) + }) + } + ) + + it.each([...invalidDnums, ...invalidFnrs, ...fnr18YearsOldAndOlder])( + 'Invalid d number: %s', + async (invalidDnum) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorDnr + ) + }) + } + ) + }) + + describe('when extending the dnrValidator as onBlurValidator', () => { + it.each(dnr18YearsOldAndOlder)( + 'D number is 18 years or older : %s', + async (validDnum) => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) + + it.each(dnrUnder18YearsOld)( + 'D number is not 18 years or older: %s', + async (invalidDnum) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + errorMinimumAgeValidator + ) + }) + } + ) + + it.each([ + ...invalidDnums, + ...invalidFnrs, + ...fnr18YearsOldAndOlder, + ...fnrUnder18YearsOld, + ])('Invalid d number: %s', async (invalidDnum) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorDnr + ) + }) + }) + }) + + describe('when extending the fnrValidator as validator', () => { + it.each(fnr18YearsOldAndOlder)( + 'Identity number(fnr) is 18 years or older : %s', + async (validFnr) => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) + + it.each(fnrUnder18YearsOld)( + 'Identity number(fnr) is not 18 years or older: %s', + async (invalidFnr) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + errorMinimumAgeValidator + ) + }) + } + ) + + it.each([...invalidFnrs, ...invalidDnums, ...dnr18YearsOldAndOlder])( + 'Invalid identity number(fnr): %s', + async (invalidFnr) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.NationalIdentityNumber.errorFnr + ) + }) + } + ) + }) + + describe('when extending the fnrValidator as onBlurValidator', () => { + it.each(fnr18YearsOldAndOlder)( + 'Identity number(fnr) is 18 years or older : %s', + async (validFnr) => { + render( + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) + + it.each(fnrUnder18YearsOld)( + 'Identity number(fnr) is not 18 years or older: %s', + async (invalidFnr) => { + render( + + ) + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + errorMinimumAgeValidator + ) + }) + } + ) + + it.each([ + ...invalidFnrs, + ...invalidDnums, + ...dnr18YearsOldAndOlder, + ...dnrUnder18YearsOld, + ])('Invalid identity number(fnr): %s', async (invalidFnr) => { + 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 index a6870b12bf2..02b55500e24 100644 --- 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 @@ -1,16 +1,758 @@ import React from 'react' -import { Field } from '../../..' +import { Field, Validator } from '../../..' +import { Wrapper } from 'storybook-utils/helpers' +import { createMinimumAgeValidator } from '../NationalIdentityNumber' export default { title: 'Eufemia/Extensions/Forms/NationalIdentityNumber', } -export function NationalIdentityNumber() { +const simpleValidator = (value) => { + return value?.length < 4 ? Error('At least 4 characters') : undefined +} + +const adultValidator = createMinimumAgeValidator(18) + +const myAdultValidator: Validator = () => { + return [adultValidator] +} + +const myAdultFnrDnrValidator: Validator = ( + value, + { validators } +) => { + const { dnrAndFnrValidator } = validators + + return [dnrAndFnrValidator, adultValidator] +} + +const myFnrValidator: Validator = (value, { validators }) => { + const { fnrValidator } = validators + + return [fnrValidator] +} + +const myDnrValidator: Validator = (value, { validators }) => { + const { dnrValidator } = validators + + return [dnrValidator] +} + +const myFnrAndDnrValidator: Validator = ( + value, + { validators } +) => { + const { dnrAndFnrValidator } = validators + + return [dnrAndFnrValidator] +} + +export function ValidatorsUndefinedFalse() { return ( - <> + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function NationalIdentityNumberDefault() { + return ( + - - + +

Validate Initially:

+ + + +
+ ) +} + +export function NationalIdentityNumberAndDNumberOnBlurValidator() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function NationalIdentityNumberOnBlurValidator() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function NationalIdentityNumberValidator() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function DNumberOnBlurValidator() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function DNumberValidator() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function AdultOnBlurValidator() { + return ( + + + + + + + +

Validate Initially:

+ + + + + + +
+ ) +} + +export function AdultValidator() { + return ( + + + + + + + +

Validate Initially:

+ + + + + + +
+ ) +} + +export function AdultOnBlurValidatorAndDefaultValidator() { + return ( + + + + + + +

Validate Initially:

+ + + + + +
+ ) +} + +export function AdultValidatorAndDefaultValidator() { + return ( + + + + + + +

Validate Initially:

+ + + + + +
+ ) +} + +export function CustomValidatorFunction() { + return ( + + + + + + +

Validate Initially:

+ + + + + +
+ ) +} + +export function CustomOnBlurValidatorFunction() { + return ( + + + + + + +

Validate Initially:

+ + + + + +
+ ) +} + +export function CustomValidatorFunctionReturnArray() { + const validatorX = (value) => { + return value?.length < 4 ? Error('At least 4 characters') : undefined + } + + const simpleValidator = () => { + return [validatorX] + } + + return ( + + + + + + +

Validate Initially:

+ + + + + +
+ ) +} + +export function CustomOnBlurValidatorFunctionReturnArray() { + const validatorX = (value) => { + return value?.length < 4 ? Error('At least 4 characters') : undefined + } + + const simpleValidator = () => { + return [validatorX] + } + + return ( + + + + + + +

Validate Initially:

+ + + + + +
) } 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 1e8afc1c1b1..e7844d0036d 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 @@ -844,4 +844,45 @@ describe('Field.Number', () => { expect(input).toHaveAttribute('aria-invalid', 'true') }) }) + + describe('emptyValue', () => { + it('should use the given emptyValue and set in the data context', async () => { + const onSubmit = jest.fn() + + render( + + + + ) + + const form = document.querySelector('form') + const input = document.querySelector('input') + expect(input).toHaveValue('0') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: 0 }, + expect.anything() + ) + + await userEvent.type(input, '1') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(2) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: 1 }, + expect.anything() + ) + + await userEvent.type(input, '{Backspace}') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(3) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: 0 }, + expect.anything() + ) + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumber.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumber.tsx index 45cc060a2c6..bc34a90f4a2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumber.tsx @@ -2,25 +2,51 @@ import React, { useCallback, useMemo } from 'react' import StringField, { Props as StringFieldProps } from '../String' import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' +import { Validator } from '../../types' -export type Props = StringFieldProps & { +export type Props = Omit & { validate?: boolean omitMask?: boolean + onBlurValidator?: Validator | false } function OrganizationNumber(props: Props) { const translations = useTranslation().OrganizationNumber - const { errorPattern, errorRequired, label } = translations - - const { validate = true, omitMask } = props - - const validationPattern = '^[0-9]{9}$' + const { errorOrgNo, errorOrgNoLength, errorRequired, label } = + translations const errorMessages = useErrorMessage(props.path, props.errorMessages, { required: errorRequired, - pattern: errorPattern, + pattern: errorOrgNo, + errorOrgNo, + errorOrgNoLength, }) + const organizationNumberValidator = useCallback( + (value: string) => { + if (value !== undefined) { + const orgNoIs9Digits = value?.length === 9 + + if (!orgNoIs9Digits) { + return Error(errorOrgNoLength) + } + if (orgNoIs9Digits && !isValidOrgNumber(value)) { + return Error(errorOrgNo) + } + } + }, + [errorOrgNo, errorOrgNoLength] + ) + + const { + validate = true, + omitMask, + validator, + onBlurValidator = organizationNumberValidator, + label: labelProp, + width, + } = props + const mask = useMemo( () => omitMask @@ -29,30 +55,19 @@ function OrganizationNumber(props: Props) { [omitMask] ) - const organizationNumberValidator = useCallback( - (value: string) => { - if ( - new RegExp(validationPattern).test(value) && - !isValidOrgNumber(value) - ) { - return Error(errorPattern) - } - }, - [errorPattern] - ) + const onBlurValidatorToUse = + onBlurValidator === false ? undefined : onBlurValidator - const StringFieldProps: Props = { + const StringFieldProps: StringFieldProps = { ...props, className: 'dnb-forms-field-organization-number', - pattern: props.pattern ?? (validate ? validationPattern : undefined), - label: props.label ?? label, + label: labelProp ?? label, errorMessages, mask, - width: props.width ?? 'medium', + width: width ?? 'medium', inputMode: 'numeric', - onBlurValidator: validate - ? props.onBlurValidator || organizationNumberValidator - : undefined, + validator: validate ? validator : undefined, + onBlurValidator: validate ? onBlurValidatorToUse : undefined, exportValidators: { organizationNumberValidator }, } diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumberDocs.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumberDocs.tsx new file mode 100644 index 00000000000..b6f82415f9e --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumberDocs.tsx @@ -0,0 +1,19 @@ +import { PropertiesTableProps } from '../../../../shared/types' + +export const OrganizationNumberProperties: PropertiesTableProps = { + validate: { + doc: 'Using this prop you can disable the default validation.', + type: 'boolean', + status: 'optional', + }, + help: { + doc: 'Provide a help button. Object consisting of `title` and `content`.', + type: 'object', + status: 'optional', + }, + onBlurValidator: { + doc: 'Custom validator function that is triggered when the user leaves a field (e.g., blurring a text input or closing a dropdown). The function can be either asynchronous or synchronous. The first parameter is the value, and the second parameter returns an object containing { errorMessages, connectWithPath, validators }. Defaults to organization number validation, using `organizationNumberValidator`.', + type: 'function', + status: 'optional', + }, +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/__tests__/OrganizationNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/__tests__/OrganizationNumber.test.tsx index 575c34cb0ce..24566744f23 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/__tests__/OrganizationNumber.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/__tests__/OrganizationNumber.test.tsx @@ -60,7 +60,195 @@ describe('Field.OrganizationNumber', () => { expect(input).toHaveAttribute('inputmode', 'numeric') }) - it('should not validate organization number when validate false', async () => { + it('should not provide an error for empty/undefined value when not required', async () => { + render() + + const element = document.querySelector('input') + await userEvent.type(element, '123123123') + expect(element.value).toBe('123 123 123') + await userEvent.type(element, '{Backspace>9}') + expect(element).toHaveValue('') + + element.blur() + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + }) + + it('should provide an error for empty/undefined value when required', async () => { + render() + + const element = document.querySelector('input') + await userEvent.type(element, '123123123') + expect(element.value).toBe('123 123 123') + await userEvent.type(element, '{Backspace>9}') + expect(element).toHaveValue('') + + element.blur() + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.OrganizationNumber.errorRequired + ) + }) + }) + + it('should display error if required and validateInitially', async () => { + render() + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.OrganizationNumber.errorRequired + ) + }) + }) + + it('should display error when validateInitially and value', async () => { + render() + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.OrganizationNumber.errorOrgNo + ) + }) + }) + + it('should not display error when validateInitially and no value', async () => { + render() + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + }) + + it('should support custom pattern', async () => { + render( + + + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert').textContent).toBe( + nb.OrganizationNumber.errorOrgNo + ) + }) + }) + + it('should support custom pattern without validator', async () => { + const dummyValidator = jest.fn() + + render( + + { + return [dummyValidator] + }} + /> + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + + expect(dummyValidator).toHaveBeenCalledTimes(1) + expect(dummyValidator).toHaveBeenCalledWith('6', expect.anything()) + }) + + it('should validate organization number based on the internal validator', async () => { + render( + + + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert').textContent).toBe( + nb.OrganizationNumber.errorOrgNo + ) + }) + }) + + it('should not validate organization number based on the internal validator when onBlurValidator is false', async () => { + render( + + + + ) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + }) + + it('should replace the internal validator with the given one', async () => { + const myValidator = jest.fn(() => { + return new Error('My error message') + }) + const onBlurValidator = jest.fn(() => { + return [myValidator] + }) + + render( + + ) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert').textContent).toBe( + 'My error message' + ) + }) + + expect(myValidator).toHaveBeenCalledTimes(1) + expect(myValidator).toHaveBeenCalledWith('123', expect.anything()) + expect(onBlurValidator).toHaveBeenCalledTimes(1) + expect(onBlurValidator).toHaveBeenCalledWith('123', expect.anything()) + }) + + it('should not validate organization number when "onBlurValidator" is set to false', async () => { + const invalidOrgNo = '987654321' + + render( + + + + ) + + fireEvent.blur(document.querySelector('input')) + + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + }) + + it('should not validate organization number when "validate" is set to false', async () => { const invalidOrgNo = '987654321' render( @@ -75,10 +263,12 @@ describe('Field.OrganizationNumber', () => { fireEvent.blur(document.querySelector('input')) - expect(screen.queryByRole('alert')).toBeNull() + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() }) - it('should not validate custom validator when validate false', async () => { + it('should not validate custom validator when "validate" is set to false', async () => { const invalidOrgNo = '987654321' const firstNumIs1 = (value: string) => @@ -100,16 +290,19 @@ describe('Field.OrganizationNumber', () => { validateInitially validate={false} validator={customValidator} + onBlurValidator={false} /> ) fireEvent.blur(document.querySelector('input')) - expect(screen.queryByRole('alert')).toBeNull() + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() }) - it('should not validate extended validator when validate false', async () => { + it('should not validate extended validator when "validate" is set to false', async () => { const invalidOrgNo = '987654321' const firstNumIs1 = (value: string) => @@ -134,13 +327,16 @@ describe('Field.OrganizationNumber', () => { validateInitially validate={false} validator={customValidator} + onBlurValidator={false} /> ) fireEvent.blur(document.querySelector('input')) - expect(screen.queryByRole('alert')).toBeNull() + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() }) describe('should validate Norwegian organization number', () => { @@ -161,29 +357,31 @@ describe('Field.OrganizationNumber', () => { '148623902', ] - const invalidOrgNum = ['123', '123456789', '148623907', '987654321'] + const invalidOrgNum = ['123456789', '148623907', '987654321'] + const invalidOrgNumTooShort = ['123', '321', '123123', '321321'] - it.each(validOrgNum)('Valid organization number: %s', (orgNo) => { - render( - - - - ) + it.each(validOrgNum)( + 'Valid organization number: %s', + async (orgNo) => { + render( + + + + ) - fireEvent.blur(document.querySelector('input')) + fireEvent.blur(document.querySelector('input')) - expect(screen.queryByRole('alert')).toBeNull() - }) + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() + } + ) it.each(invalidOrgNum)( 'Invalid organization number: %s', async (orgNo) => { render( - + ) fireEvent.blur(document.querySelector('input')) @@ -191,7 +389,25 @@ describe('Field.OrganizationNumber', () => { await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() expect(screen.queryByRole('alert')).toHaveTextContent( - nb.OrganizationNumber.errorPattern + nb.OrganizationNumber.errorOrgNo + ) + }) + } + ) + + it.each(invalidOrgNumTooShort)( + 'Invalid organization number: %s', + async (orgNo) => { + render( + + ) + + fireEvent.blur(document.querySelector('input')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.OrganizationNumber.errorOrgNoLength ) }) } @@ -216,39 +432,39 @@ describe('Field.OrganizationNumber', () => { '756299263', ] - const invalidOrgNum = ['123', '123456789', '148623907', '987654321'] + const invalidOrgNum = ['123456789', '148623907', '987654321'] + const invalidOrgNumTooShort = ['123', '321', '123123', '321321'] - const firstNumIs1 = (value: string) => - value.substring(0, 1) === '1' - ? { status: 'valid' } - : { status: 'invalid' } + const firstNumIs1Validator = (value: string) => { + if (value.substring(0, 1) !== '1') { + return new Error('My error') + } + } const customValidator: Validator = (value, { validators }) => { const { organizationNumberValidator } = validators - const result = firstNumIs1(value) - if (result.status === 'invalid') { - return new Error('My error') - } - return [organizationNumberValidator] + return [organizationNumberValidator, firstNumIs1Validator] } it.each(validOrgNumStartingWith1)( 'Valid organization number: %s', - (orgNo) => { + async (orgNo) => { render( ) fireEvent.blur(document.querySelector('input')) - expect(screen.queryByRole('alert')).toBeNull() + await expect(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + }).toNeverResolve() } ) @@ -259,13 +475,10 @@ describe('Field.OrganizationNumber', () => { ) - fireEvent.blur(document.querySelector('input')) - await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() expect(screen.queryByRole('alert')).toHaveTextContent('My error') @@ -280,8 +493,29 @@ describe('Field.OrganizationNumber', () => { + ) + + fireEvent.blur(document.querySelector('input')) + + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeInTheDocument() + expect(screen.queryByRole('alert')).toHaveTextContent( + nb.OrganizationNumber.errorOrgNo + ) + }) + } + ) + + it.each(invalidOrgNumTooShort)( + 'Invalid organization number: %s', + async (orgNo) => { + render( + ) @@ -290,7 +524,7 @@ describe('Field.OrganizationNumber', () => { await waitFor(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() expect(screen.queryByRole('alert')).toHaveTextContent( - nb.OrganizationNumber.errorPattern + nb.OrganizationNumber.errorOrgNoLength ) }) } diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/stories/OrganizationNumber.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/stories/OrganizationNumber.stories.tsx new file mode 100644 index 00000000000..4af93be8b42 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/stories/OrganizationNumber.stories.tsx @@ -0,0 +1,327 @@ +import React from 'react' +import { Field, Validator } from '../../..' +import { Wrapper } from 'storybook-utils/helpers' + +export default { + title: 'Eufemia/Extensions/Forms/OrganizationNumber', +} + +const myOrganizationNumberValidator: Validator = ( + value, + { validators } +) => { + const { organizationNumberValidator } = validators + + return [organizationNumberValidator] +} + +export function OnBlurValidatorFalse() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function OrganizationNumberDefault() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function OrganizationNumberOnBlurValidator() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function OrganizationNumberValidator() { + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function CustomValidator() { + const simpleValidator = (value) => { + return value?.length < 4 ? Error('At least 4 characters') : undefined + } + + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function CustomOnBlurValidator() { + const simpleValidator = (value) => { + return value?.length < 4 ? Error('At least 4 characters') : undefined + } + + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function CustomValidatorReturnArray() { + const validatorX = (value) => { + return value?.length < 4 ? Error('At least 4 characters') : undefined + } + + const simpleValidator = () => { + return [validatorX] + } + + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function CustomOnBlurValidatorReturnArray() { + const validatorX = (value) => { + return value?.length < 4 ? Error('At least 4 characters') : undefined + } + + const simpleValidator = () => { + return [validatorX] + } + + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} + +export function StringValidatorSimple() { + const validatorX = (value) => { + return value?.length < 4 ? Error('At least 4 characters') : undefined + } + + const simpleValidator = () => { + return [validatorX] + } + + return ( + + + + +

Validate Initially:

+ + + +
+ ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx index 6d2988c7291..9c8100748fd 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/SelectCountry/SelectCountry.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useMemo, useRef } from 'react' import classnames from 'classnames' import SharedContext from '../../../../shared/Context' +import { LOCALE } from '../../../../shared/defaults' import { Autocomplete, HelpButton } from '../../../../components' import { pickSpacingProps } from '../../../../components/flex/utils' import countries, { @@ -51,7 +52,9 @@ export type Props = FieldHelpProps & function SelectCountry(props: Props) { const sharedContext = useContext(SharedContext) const translations = useTranslation().SelectCountry - const lang = sharedContext.locale?.split('-')[0] as CountryLang + const lang = (sharedContext.locale || LOCALE).split( + '-' + )[0] as CountryLang const getCountryObjectByIso = useCallback( (value: CountryType['iso']) => { @@ -291,7 +294,7 @@ export function getCountryData({ } } - return a[lang].localeCompare(b[lang]) + return String(a[lang])?.localeCompare?.(b[lang]) }) .map((country) => makeObject(country, lang)) } 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 ccf47a55194..5e24d3809fb 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 @@ -15,10 +15,6 @@ import enGB from '../../../../../shared/locales/en-GB' const gb = enGB['en-GB'] -async function expectNever(callable: () => unknown): Promise { - await expect(() => waitFor(callable)).rejects.toEqual(expect.anything()) -} - const syncValidatorReturningUndefined = () => undefined const syncValidatorReturningError = () => @@ -663,10 +659,10 @@ describe('Field.String', () => { render() const input = document.querySelector('input') await userEvent.type(input, 'def') - expect(onChange.mock.calls).toHaveLength(3) - expect(onChange.mock.calls[0][0]).toEqual('abcd') - expect(onChange.mock.calls[1][0]).toEqual('abcde') - expect(onChange.mock.calls[2][0]).toEqual('abcdef') + expect(onChange).toHaveBeenCalledTimes(3) + expect(onChange).toHaveBeenNthCalledWith(1, 'abcd') + expect(onChange).toHaveBeenNthCalledWith(2, 'abcde') + expect(onChange).toHaveBeenNthCalledWith(3, 'abcdef') }) it('calls onFocus with current value', () => { @@ -676,8 +672,8 @@ describe('Field.String', () => { act(() => { input.focus() }) - expect(onFocus.mock.calls).toHaveLength(1) - expect(onFocus.mock.calls[0][0]).toEqual('blah') + expect(onFocus).toHaveBeenCalledTimes(1) + expect(onFocus).toHaveBeenNthCalledWith(1, 'blah') }) it('calls onBlur with current value', async () => { @@ -687,12 +683,12 @@ describe('Field.String', () => { input.focus() fireEvent.blur(input) await wait(0) - expect(onBlur.mock.calls).toHaveLength(1) - expect(onBlur.mock.calls[0][0]).toEqual('song2') + expect(onBlur).toHaveBeenCalledTimes(1) + expect(onBlur).toHaveBeenNthCalledWith(1, 'song2') await userEvent.type(input, '345') fireEvent.blur(input) - expect(onBlur.mock.calls).toHaveLength(2) - expect(onBlur.mock.calls[1][0]).toEqual('song2345') + expect(onBlur).toHaveBeenCalledTimes(2) + expect(onBlur).toHaveBeenNthCalledWith(2, 'song2345') }) }) @@ -881,8 +877,12 @@ describe('Field.String', () => { ) await waitFor(() => { // Wait for since external validators are processed asynchronously - expect(validator.mock.calls).toHaveLength(1) - expect((validator.mock.calls[0] as unknown[])[0]).toEqual('abc') + expect(validator).toHaveBeenCalledTimes(1) + expect(validator).toHaveBeenNthCalledWith( + 1, + 'abc', + expect.anything() + ) expect( screen.getByText('I think this is wrong') ).toBeInTheDocument() @@ -893,13 +893,21 @@ describe('Field.String', () => { fireEvent.blur(input) await waitFor(() => { - expect(validator.mock.calls).toHaveLength(4) - expect((validator.mock.calls[1] as unknown[])[0]).toEqual('abcd') - expect((validator.mock.calls[2] as unknown[])[0]).toEqual( - 'abcde' + expect(validator).toHaveBeenCalledTimes(4) + expect(validator).toHaveBeenNthCalledWith( + 2, + 'abcd', + expect.anything() + ) + expect(validator).toHaveBeenNthCalledWith( + 3, + 'abcde', + expect.anything() ) - expect((validator.mock.calls[3] as unknown[])[0]).toEqual( - 'abcdef' + expect(validator).toHaveBeenNthCalledWith( + 4, + 'abcdef', + expect.anything() ) expect( screen.getByText('I think this is wrong') @@ -916,10 +924,9 @@ describe('Field.String', () => { validateInitially /> ) - 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. + await expect(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() - }) + }).toNeverResolve() }) }) @@ -935,8 +942,12 @@ describe('Field.String', () => { ) await waitFor(() => { // Wait for since external validators are processed asynchronously - expect(validator.mock.calls).toHaveLength(1) - expect((validator.mock.calls[0] as unknown[])[0]).toEqual('abc') + expect(validator).toHaveBeenCalledTimes(1) + expect(validator).toHaveBeenNthCalledWith( + 1, + 'abc', + expect.anything() + ) expect( screen.getByText('Whats left when nothing is right?') ).toBeInTheDocument() @@ -949,10 +960,22 @@ describe('Field.String', () => { fireEvent.blur(input) }) - expect(validator.mock.calls).toHaveLength(4) - expect((validator.mock.calls[1] as unknown[])[0]).toEqual('abcd') - expect((validator.mock.calls[2] as unknown[])[0]).toEqual('abcde') - expect((validator.mock.calls[3] as unknown[])[0]).toEqual('abcdef') + expect(validator).toHaveBeenCalledTimes(4) + expect(validator).toHaveBeenNthCalledWith( + 2, + 'abcd', + expect.anything() + ) + expect(validator).toHaveBeenNthCalledWith( + 3, + 'abcde', + expect.anything() + ) + expect(validator).toHaveBeenNthCalledWith( + 4, + 'abcdef', + expect.anything() + ) expect( screen.getByText('Whats left when nothing is right?') ).toBeInTheDocument() @@ -968,10 +991,9 @@ describe('Field.String', () => { /> ) - 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. + await expect(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() - }) + }).toNeverResolve() }) }) @@ -988,8 +1010,8 @@ describe('Field.String', () => { await waitFor(() => { // Wait for since external validators are processed asynchronously - expect(validator.mock.calls).toHaveLength(0) - expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(validator).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('alert')).toBeInTheDocument() }) const input = document.querySelector('input') await userEvent.type(input, 'def') @@ -997,9 +1019,16 @@ describe('Field.String', () => { await waitFor(() => { // Wait for since external validators are processed asynchronously - expect(validator.mock.calls).toHaveLength(1) - expect((validator.mock.calls[0] as unknown[])[0]).toEqual( - 'abcdef' + expect(validator).toHaveBeenCalledTimes(2) + expect(validator).toHaveBeenNthCalledWith( + 1, + 'abc', + expect.anything() + ) + expect(validator).toHaveBeenNthCalledWith( + 2, + 'abcdef', + expect.anything() ) expect( @@ -1020,10 +1049,9 @@ describe('Field.String', () => { const input = document.querySelector('input') await userEvent.type(input, 'd') fireEvent.blur(input) - 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. + await expect(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() - }) + }).toNeverResolve() }) }) @@ -1040,8 +1068,8 @@ describe('Field.String', () => { await waitFor(() => { // Wait for since external validators are processed asynchronously - expect(validator.mock.calls).toHaveLength(0) - expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(validator).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('alert')).toBeInTheDocument() }) const input = document.querySelector('input') await userEvent.type(input, 'def') @@ -1049,9 +1077,16 @@ describe('Field.String', () => { await waitFor(() => { // Wait for since external validators are processed asynchronously - expect(validator.mock.calls).toHaveLength(1) - expect((validator.mock.calls[0] as unknown[])[0]).toEqual( - 'abcdef' + expect(validator).toHaveBeenCalledTimes(2) + expect(validator).toHaveBeenNthCalledWith( + 1, + 'abc', + expect.anything() + ) + expect(validator).toHaveBeenNthCalledWith( + 2, + 'abcdef', + expect.anything() ) expect( @@ -1072,10 +1107,9 @@ describe('Field.String', () => { const input = document.querySelector('input') await userEvent.type(input, 'd') fireEvent.blur(input) - 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. + await expect(() => { expect(screen.queryByRole('alert')).toBeInTheDocument() - }) + }).toNeverResolve() }) }) @@ -1153,29 +1187,36 @@ describe('Field.String', () => { await userEvent.type(input, 'O!') await waitFor(() => { - expect(inputOnChange.mock.calls).toHaveLength(2) - expect(inputOnChange.mock.calls[0][0]).toEqual('FOOO') - expect(inputOnChange.mock.calls[1][0]).toEqual('FOOO!') - - expect(dataContextOnChange.mock.calls).toHaveLength(2) - expect(dataContextOnChange.mock.calls[0][0]).toEqual({ - foo: 'FOOO', - bar: 'BAAAR', - }) - expect(dataContextOnChange.mock.calls[1][0]).toEqual({ - foo: 'FOOO!', - bar: 'BAAAR', - }) + expect(inputOnChange).toHaveBeenNthCalledWith(1, 'FOOO') + expect(inputOnChange).toHaveBeenNthCalledWith(2, 'FOOO!') + + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 1, + { + foo: 'FOOO', + bar: 'BAAAR', + }, + expect.anything() + ) + expect(dataContextOnChange).toHaveBeenNthCalledWith( + 2, + { + foo: 'FOOO!', + bar: 'BAAAR', + }, + expect.anything() + ) - expect(dataContextOnPathChange.mock.calls).toHaveLength(2) - expect(dataContextOnPathChange.mock.calls[0]).toEqual([ + expect(dataContextOnPathChange).toHaveBeenNthCalledWith( + 1, '/foo', - 'FOOO', - ]) - expect(dataContextOnPathChange.mock.calls[1]).toEqual([ + 'FOOO' + ) + expect(dataContextOnPathChange).toHaveBeenNthCalledWith( + 2, '/foo', - 'FOOO!', - ]) + 'FOOO!' + ) }) }) }) @@ -1488,4 +1529,84 @@ describe('Field.String', () => { expect(third).toHaveTextContent(inputInfo) }) }) + + describe('emptyValue', () => { + it('should use the given emptyValue and set in the data context', async () => { + const onSubmit = jest.fn() + + render( + + + + ) + + const form = document.querySelector('form') + const input = document.querySelector('input') + expect(input).toHaveValue('') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: '' }, + expect.anything() + ) + + await userEvent.type(input, ' ') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(2) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: ' ' }, + expect.anything() + ) + + await userEvent.type(input, '{Backspace}') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(3) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: '' }, + expect.anything() + ) + }) + + it('should set the emptyValue when string gets empty', async () => { + const onSubmit = jest.fn() + + render( + + + + ) + + const form = document.querySelector('form') + const input = document.querySelector('input') + expect(input).toHaveValue('foo') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: 'foo' }, + expect.anything() + ) + + await userEvent.type(input, ' ') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(2) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: 'foo ' }, + expect.anything() + ) + + await userEvent.type(input, '{Backspace>4}') + + fireEvent.submit(form) + expect(onSubmit).toHaveBeenCalledTimes(3) + expect(onSubmit).toHaveBeenLastCalledWith( + { myValue: 'foo' }, + expect.anything() + ) + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx index 7fcab7e799b..5363f40070c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Field, Form } from '../../..' +import { Field, Form, Tools } from '../../..' import { Flex } from '../../../../../components' export default { @@ -43,6 +43,26 @@ export const Transform = () => { ) } +export const TransformInOnFormHandler = () => { + const transformIn = ({ value }) => { + if (value === undefined) { + return '' + } + return value + } + return ( + + + + + ) +} + export function TransformObject() { const defaultData = { myLabel: { value: 'Some value', test: 'test' }, 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 9d18ea3dd38..c8662b07499 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/Isolation.tsx @@ -9,8 +9,13 @@ import React, { import pointer, { JsonObject } from '../../utils/json-pointer' import { extendDeep } from '../../../../shared/component-helper' import { isAsync } from '../../../../shared/helpers/isAsync' +import useId from '../../../../shared/helpers/useId' import useDataValue from '../../hooks/useDataValue' -import { Context, ContextState, Provider } from '../../DataContext' +import { + Context as DataContext, + ContextState, + Provider, +} from '../../DataContext' import SectionContext from '../Section/SectionContext' import IsolationCommitButton from './IsolationCommitButton' import { @@ -43,6 +48,10 @@ export type IsolationProviderProps = { isolatedData: JsonObject, handlerData: JsonObject ) => unknown + /** + * Prevent the form from being submitted when there are fields with errors inside the Form.Isolation. + */ + bubbleValidation?: boolean /** * Used internally by the Form.Isolation component */ @@ -80,6 +89,7 @@ function IsolationProvider( onClear: onClearProp, transformOnCommit: transformOnCommitProp, commitHandleRef, + bubbleValidation, data, defaultData, } = props @@ -88,7 +98,7 @@ function IsolationProvider( const internalDataRef = useRef() const localDataRef = useRef>({}) const dataContextRef = useRef(null) - const outerContext = useContext(Context) + const outerContext = useContext(DataContext) const { path: pathSection } = useContext(SectionContext) || {} const { handlePathChange: handlePathChangeOuter, data: dataOuter } = outerContext || {} @@ -225,7 +235,7 @@ function IsolationProvider( return ( - + {(dataContext) => { dataContextRef.current = dataContext @@ -235,11 +245,34 @@ function IsolationProvider( return children }} - + + + {bubbleValidation && ( + + )} ) } +function BubbleValidation({ outerContext }) { + const { setMountedFieldState, setFieldError } = outerContext || {} + const dataContext = useContext(DataContext) + + const id = useId() + useEffect(() => { + const path = `/${id}` + const errors = dataContext.hasErrors() + if (errors) { + setMountedFieldState?.(path, { + isMounted: true, + }) + } + setFieldError?.(path, errors ? new Error('Form.Isolation') : undefined) + }, [dataContext, id, setFieldError, setMountedFieldState]) + + return null +} + IsolationProvider.CommitButton = IsolationCommitButton IsolationProvider._supportsSpacingProps = undefined 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 679b984bba3..7f092aa3201 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/IsolationDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Isolation/IsolationDocs.ts @@ -20,6 +20,11 @@ export const IsolationProperties: PropertiesTableProps = { type: 'React.Ref', status: 'optional', }, + bubbleValidation: { + doc: 'Prevent the form from being submitted when there are fields with errors inside the Form.Isolation.', + type: 'boolean', + status: 'optional', + }, ...ProviderProperties, minimumAsyncBehaviorTime: undefined, asyncSubmitTimeout: 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 d3fc9d6cd3f..7694dd89ad6 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 @@ -1643,4 +1643,49 @@ describe('Form.Isolation', () => { expect(synced).toHaveValue('inside changed') expect(regular).toHaveValue('regular') }) + + describe('bubbleValidation', () => { + it('should prevent the form from submitting as long as there are errors', async () => { + const onSubmitRequest = jest.fn() + const onSubmit = jest.fn() + const onCommit = jest.fn() + + render( + + + + + + + ) + + const input = document.querySelector('input') + const form = document.querySelector('form') + const commitButton = document.querySelector('button') + + await userEvent.click(commitButton) + fireEvent.submit(form) + + expect(document.querySelector('.dnb-form-status')).toHaveTextContent( + nb.Field.errorRequired + ) + + expect(onSubmit).toHaveBeenCalledTimes(0) + expect(onSubmitRequest).toHaveBeenCalledTimes(1) + expect(onCommit).toHaveBeenCalledTimes(0) + + await userEvent.type(input, 'Tony') + fireEvent.submit(form) + + expect(onSubmit).toHaveBeenCalledTimes(1) + expect(onSubmitRequest).toHaveBeenCalledTimes(1) + expect(onCommit).toHaveBeenCalledTimes(0) + + await userEvent.click(commitButton) + expect(onCommit).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/Snapshot.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/Snapshot.tsx new file mode 100644 index 00000000000..1a8f8a0bd58 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/Snapshot.tsx @@ -0,0 +1,47 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react' +import SnapshotContext, { + SnapshotContextState, + SnapshotMap, +} from './SnapshotContext' +import DataContext from '../../DataContext/Context' + +export type SnapshotId = string | number +export type SnapshotName = string + +export type SnapshotProps = { + name: SnapshotName + children: React.ReactNode +} + +function SnapshotProvider(props: SnapshotProps) { + const { name, children } = props + + const { snapshotsRef } = useContext(DataContext) || {} + const mountedFieldsRef: SnapshotMap = useRef() + if (!mountedFieldsRef.current) { + mountedFieldsRef.current = new Map() + } + + const setMountedField: SnapshotContextState['setMountedField'] = + useCallback((path, state) => { + mountedFieldsRef.current.set(path, state) + }, []) + + useEffect(() => { + if (snapshotsRef) { + snapshotsRef.current.set(name, mountedFieldsRef.current) + } + }, [snapshotsRef, name]) + + const contextValue = { name, setMountedField } + + return ( + + {children} + + ) +} + +SnapshotProvider._supportsSpacingProps = undefined + +export default SnapshotProvider diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotContext.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotContext.tsx new file mode 100644 index 00000000000..0103f40c091 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotContext.tsx @@ -0,0 +1,17 @@ +import { createContext } from 'react' +import { Path } from '../../types' +import { SnapshotName } from './Snapshot' + +export type SnapshotMap = React.MutableRefObject> + +export type SnapshotContextState = { + name: SnapshotName + setMountedField: ( + path: Path, + { isMounted }: { isMounted: boolean } + ) => void +} + +const SnapshotContext = createContext(null) + +export default SnapshotContext diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotDocs.ts new file mode 100644 index 00000000000..c58ee0ea315 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/SnapshotDocs.ts @@ -0,0 +1,11 @@ +import { PropertiesTableProps } from '../../../../shared/types' + +export const SnapshotProperties: PropertiesTableProps = { + name: { + doc: 'A unique name for the sliced snapshot area.', + type: 'string', + status: 'optional', + }, +} + +export const SnapshotEvents: PropertiesTableProps = {} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/__tests__/Snapshot.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/__tests__/Snapshot.test.tsx new file mode 100644 index 00000000000..f3b751513ce --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/__tests__/Snapshot.test.tsx @@ -0,0 +1,210 @@ +import React, { createRef, useCallback, useEffect, useRef } from 'react' +import { render, screen } from '@testing-library/react' +import { Field, Form } from '../../..' +import userEvent from '@testing-library/user-event' + +describe('Form.Snapshot', () => { + it('should handle sliced snapshots', async () => { + const MockComponent = () => { + const { createSnapshot, applySnapshot } = Form.useSnapshot() + const pointerRef = useRef(0) + + useEffect(() => { + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + + const changeHandler = useCallback(() => { + pointerRef.current += 1 + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + + const undoHandler = useCallback(() => { + pointerRef.current -= 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + + const redoHandler = useCallback(() => { + pointerRef.current += 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + + return ( + <> + + + + + + + + + + ) + } + + render( + + + + ) + + const willBeRevertedInput = screen.getByLabelText( + 'Will be reverted' + ) as HTMLInputElement + const willStayInput = screen.getByLabelText( + 'Will stay' + ) as HTMLInputElement + const undoButton = screen.getByText('Undo') + const redoButton = screen.getByText('Redo') + + expect(willBeRevertedInput.value).toBe('') + expect(willStayInput.value).toBe('') + + await userEvent.type(willBeRevertedInput, 'Hello World') + + expect(willBeRevertedInput.value).toBe('Hello World') + expect(willStayInput.value).toBe('') + + await userEvent.click(undoButton) + await userEvent.click(undoButton) + await userEvent.click(undoButton) + + expect(willBeRevertedInput.value).toBe('Hello Wo') + + await userEvent.type(willStayInput, 'Stay') + + await userEvent.click(redoButton) + await userEvent.click(redoButton) + + expect(willBeRevertedInput.value).toBe('Hello Worl') + expect(willStayInput.value).toBe('Stay') + + await userEvent.click(redoButton) + + expect(willBeRevertedInput.value).toBe('Hello World') + expect(willStayInput.value).toBe('Stay') + + await userEvent.type(willBeRevertedInput, ' 123') + + expect(willBeRevertedInput.value).toBe('Hello World 123') + expect(willStayInput.value).toBe('Stay') + + await userEvent.click(undoButton) + + expect(willBeRevertedInput.value).toBe('Hello World 12') + expect(willStayInput.value).toBe('Stay') + }) + + it('should handle sliced snapshots from outside of the form', async () => { + const pointerRef: React.MutableRefObject = createRef() + pointerRef.current = 0 + + const MockHookFromOutside = () => { + const { createSnapshot, applySnapshot } = Form.useSnapshot('form-id') + + useEffect(() => { + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + + const undoHandler = useCallback(() => { + pointerRef.current -= 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + + const redoHandler = useCallback(() => { + pointerRef.current += 1 + applySnapshot(pointerRef.current, 'my-snapshot-slice') + }, [applySnapshot]) + + return ( + <> + + + + ) + } + + const MockComponent = () => { + const { createSnapshot } = Form.useSnapshot() + + const changeHandler = useCallback(() => { + pointerRef.current += 1 + createSnapshot(pointerRef.current, 'my-snapshot-slice') + }, [createSnapshot]) + + return ( + <> + + + + + + + ) + } + + render( + <> + + + + + + + ) + + const willBeRevertedInput = screen.getByLabelText( + 'Will be reverted' + ) as HTMLInputElement + const willStayInput = screen.getByLabelText( + 'Will stay' + ) as HTMLInputElement + const undoButton = screen.getByText('Undo') + const redoButton = screen.getByText('Redo') + + expect(willBeRevertedInput.value).toBe('') + expect(willStayInput.value).toBe('') + + await userEvent.type(willBeRevertedInput, 'Hello World') + + expect(willBeRevertedInput.value).toBe('Hello World') + expect(willStayInput.value).toBe('') + + await userEvent.click(undoButton) + await userEvent.click(undoButton) + await userEvent.click(undoButton) + + expect(willBeRevertedInput.value).toBe('Hello Wo') + + await userEvent.type(willStayInput, 'Stay') + + await userEvent.click(redoButton) + await userEvent.click(redoButton) + + expect(willBeRevertedInput.value).toBe('Hello Worl') + expect(willStayInput.value).toBe('Stay') + + await userEvent.click(redoButton) + + expect(willBeRevertedInput.value).toBe('Hello World') + expect(willStayInput.value).toBe('Stay') + + await userEvent.type(willBeRevertedInput, ' 123') + + expect(willBeRevertedInput.value).toBe('Hello World 123') + expect(willStayInput.value).toBe('Stay') + + await userEvent.click(undoButton) + + expect(willBeRevertedInput.value).toBe('Hello World 12') + expect(willStayInput.value).toBe('Stay') + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/index.ts new file mode 100644 index 00000000000..10c983829f4 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Snapshot/index.ts @@ -0,0 +1,2 @@ +export { default } from './Snapshot' +export * from './Snapshot' diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/SubmitConfirmation/SubmitConfirmation.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/SubmitConfirmation/SubmitConfirmation.tsx index 4cbca8098c0..a82175075ad 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/SubmitConfirmation/SubmitConfirmation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/SubmitConfirmation/SubmitConfirmation.tsx @@ -127,7 +127,7 @@ function SubmitConfirmation(props: ConfirmProps) { }) useMemo(() => { - if (Object.keys(removeUndefinedProps(submitState)).length > 0) { + if (Object.keys(removeUndefinedProps(submitState) || {}).length > 0) { submitStateRef.current = { ...submitState, } as EventStateObject diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/SubmitConfirmation/__tests__/SubmitConfirmation.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/SubmitConfirmation/__tests__/SubmitConfirmation.test.tsx index d2aa2604e73..bd4bde19831 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/SubmitConfirmation/__tests__/SubmitConfirmation.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/SubmitConfirmation/__tests__/SubmitConfirmation.test.tsx @@ -1,6 +1,6 @@ import React from 'react' import { act, fireEvent, render, waitFor } from '@testing-library/react' -import { Form } from '../../..' +import { Form, Wizard } from '../../..' import { Button, Dialog } from '../../../../../components' import { ConfirmParams } from '../SubmitConfirmation' import userEvent from '@testing-library/user-event' @@ -630,6 +630,66 @@ describe('Form.SubmitConfirmation', () => { }) }) + it('should prevent "onSubmit" when used inside a Wizard.Container (with prerender)', async () => { + const onSubmit = jest.fn() + const onStepChange = jest.fn() + + render( + + + + + + + true} /> + + + + + + ) + + const form = document.querySelector('form') + await act(async () => { + fireEvent.submit(form) + }) + + expect(onSubmit).toHaveBeenCalledTimes(0) + expect(onStepChange).toHaveBeenCalledTimes(1) + expect(onStepChange).toHaveBeenLastCalledWith( + 1, + 'next', + expect.anything() + ) + + await userEvent.click( + document.querySelector('.dnb-forms-previous-button') + ) + expect(onStepChange).toHaveBeenCalledTimes(2) + expect(onStepChange).toHaveBeenLastCalledWith( + 0, + 'previous', + expect.anything() + ) + + await userEvent.click( + document.querySelector('.dnb-forms-submit-button') + ) + expect(onSubmit).toHaveBeenCalledTimes(0) + expect(onStepChange).toHaveBeenCalledTimes(3) + expect(onStepChange).toHaveBeenLastCalledWith( + 1, + 'next', + expect.anything() + ) + + await userEvent.click( + document.querySelector('dnb-forms-submit-button') + ) + expect(onSubmit).toHaveBeenCalledTimes(0) + expect(onStepChange).toHaveBeenCalledTimes(3) + }) + it('should not disable buttons when disabled is set to true', () => { render( diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/Visibility.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/Visibility.tsx index b832a75faae..7583639a269 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/Visibility.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/Visibility.tsx @@ -16,12 +16,23 @@ import VisibilityContext from './VisibilityContext' export type VisibleWhen = | { path: Path - hasValue: unknown + hasValue: unknown | ((value: unknown) => boolean) } | { itemPath: Path - hasValue: unknown + hasValue: unknown | ((value: unknown) => boolean) } + | { + path: Path + isValid: boolean + continuousValidation?: boolean + } + | { + itemPath: Path + isValid: boolean + continuousValidation?: boolean + } + /** * @deprecated Will be removed in v11! */ diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/VisibilityDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/VisibilityDocs.ts index 15046254e1e..d008e5e4e97 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/VisibilityDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/VisibilityDocs.ts @@ -1,6 +1,16 @@ import { PropertiesTableProps } from '../../../../shared/types' export const VisibilityProperties: PropertiesTableProps = { + visibleWhen: { + doc: 'Provide a `path` or `itemPath` and a `hasValue` method that returns a boolean or the excepted value in order to show children. The first parameter is the value of the path. You can also use `isValid` instead of `hasValue` to only show the children when the field has no errors and has lost focus (blurred). You can change that behavior by using the `continuousValidation` property.', + type: 'object', + status: 'optional', + }, + visibleWhenNot: { + doc: 'Same as `visibleWhen`, but with inverted logic.', + type: 'object', + status: 'optional', + }, pathDefined: { doc: 'Given data context path must be defined to show children.', type: 'string', @@ -31,16 +41,6 @@ export const VisibilityProperties: PropertiesTableProps = { type: 'string', status: 'optional', }, - visibleWhen: { - doc: 'Provide a `path` or `itemPath` and a `hasValue` method that returns a boolean or the excepted value in order to show children. The first parameter is the value of the path.', - type: 'object', - status: 'optional', - }, - visibleWhenNot: { - doc: 'Same as `visibleWhen`, but with inverted logic.', - type: 'object', - status: 'optional', - }, inferData: { doc: 'Will be called to decide by external logic, and show/hide contents based on the return value.', type: 'function', diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/__tests__/Visibility.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/__tests__/Visibility.test.tsx index 61cc564545e..d78c714d272 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/__tests__/Visibility.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/__tests__/Visibility.test.tsx @@ -1,8 +1,9 @@ import React from 'react' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FilterData, Provider } from '../../../DataContext' import Visibility from '../Visibility' +import useVisibility from '../useVisibility' import { Field, Form, Iterate } from '../../..' import { Flex } from '../../../../../components' import { P } from '../../../../../elements' @@ -285,7 +286,7 @@ describe('Visibility', () => { it('should render with whole path', async () => { render( - + { value.length > 0, + hasValue: (value: string) => value?.length > 0, }} > { render( - + { value.length > 0, + hasValue: (value: string) => value?.length > 0, }} > { expect(screen.getByText('Child')).toBeInTheDocument() }) }) + + describe('visibleWhen with "isValid"', () => { + it('should return only false when field path is non existent', () => { + const collectResult = [] + + const MockComponent = () => { + const result = useVisibility().check({ + visibleWhen: { + path: '/non-existent-path', + isValid: true, + }, + }) + collectResult.push(result) + return null + } + + render( + + + + ) + + expect(collectResult).toEqual([false]) + }) + + it('should return only false on first render', () => { + const collectResult = [] + + const MockComponent = () => { + const result = useVisibility().check({ + visibleWhen: { + path: '/myPath', + isValid: true, + }, + }) + collectResult.push(result) + return null + } + + render( + + + + + ) + + expect(collectResult).toEqual([false, false, false]) + + fireEvent.focus(document.querySelector('input')) + fireEvent.change(document.querySelector('input'), { + target: { value: '2' }, + }) + expect(collectResult).toEqual([false, false, false, false]) + + fireEvent.blur(document.querySelector('input')) + expect(collectResult).toEqual([false, false, false, false, true]) + }) + + it('should support fields without focus and blur events', async () => { + const collectResult = [] + + const MockComponent = () => { + const result = useVisibility().check({ + visibleWhen: { + path: '/myPath', + isValid: true, + }, + }) + collectResult.push(result) + return null + } + + render( + + + + + ) + + expect(collectResult).toEqual([false, false, false]) + + await userEvent.click(document.querySelector('input')) + expect(collectResult).toEqual([false, false, false, true]) + + // Should have no effect + fireEvent.focus(document.querySelector('input')) + fireEvent.blur(document.querySelector('input')) + expect(collectResult).toEqual([false, false, false, true]) + }) + }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/__tests__/useVisibility.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/__tests__/useVisibility.test.tsx index 6f90af85770..73d39756ab2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/__tests__/useVisibility.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/__tests__/useVisibility.test.tsx @@ -1,7 +1,8 @@ import React from 'react' -import { renderHook } from '@testing-library/react' +import { fireEvent, renderHook } from '@testing-library/react' import { Provider } from '../../../DataContext' import useVisibility from '../useVisibility' +import { Field } from '../../..' describe('useVisibility', () => { describe('visibility', () => { @@ -121,7 +122,7 @@ describe('useVisibility', () => { }) it('does not render children when target path is not truthy', () => { - const { result } = renderHook(() => useVisibility(), { + const { result } = renderHook(useVisibility, { wrapper: ({ children }) => ( {children} ), @@ -134,7 +135,7 @@ describe('useVisibility', () => { }) it('does not render children when target path is not defined', () => { - const { result } = renderHook(() => useVisibility(), { + const { result } = renderHook(useVisibility, { wrapper: ({ children }) => ( {children} ), @@ -164,7 +165,7 @@ describe('useVisibility', () => { }) it('renders children when target path is not defined', () => { - const { result } = renderHook(() => useVisibility(), { + const { result } = renderHook(useVisibility, { wrapper: ({ children }) => ( {children} ), @@ -177,7 +178,7 @@ describe('useVisibility', () => { }) it('does not render children when target path is not falsy', () => { - const { result } = renderHook(() => useVisibility(), { + const { result } = renderHook(useVisibility, { wrapper: ({ children }) => ( {children} ), @@ -210,7 +211,7 @@ describe('useVisibility', () => { }) it('should not render children when hasValue does not match', () => { - const { result } = renderHook(() => useVisibility(), { + const { result } = renderHook(useVisibility, { wrapper: ({ children }) => ( {children} ), @@ -226,7 +227,7 @@ describe('useVisibility', () => { }) it('should not render children when path does not match', () => { - const { result } = renderHook(() => useVisibility(), { + const { result } = renderHook(useVisibility, { wrapper: ({ children }) => ( {children} ), @@ -266,7 +267,7 @@ describe('useVisibility', () => { it('should not render children when withValue does not match', () => { const log = jest.spyOn(console, 'warn').mockImplementation() - const { result } = renderHook(() => useVisibility(), { + const { result } = renderHook(useVisibility, { wrapper: ({ children }) => ( {children} ), @@ -282,6 +283,151 @@ describe('useVisibility', () => { log.mockRestore() }) + + describe('isValid', () => { + it('should return false when path is not existing', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => {children}, + }) + + expect( + result.current.check({ + visibleWhen: { + path: '/something', + isValid: true, + }, + }) + ).toBe(false) + }) + + it('should return false when path did validate', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => ( + + + {children} + + ), + }) + + expect( + result.current.check({ + visibleWhen: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(false) + }) + + it('should return true children when path did validate initially', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => ( + + + {children} + + ), + }) + + expect( + result.current.check({ + visibleWhen: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(true) + }) + + it('should return true when path did validate after blur', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => ( + + + {children} + + ), + }) + + expect( + result.current.check({ + visibleWhen: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(false) + + fireEvent.focus(document.querySelector('input')) + fireEvent.change(document.querySelector('input'), { + target: { value: '2' }, + }) + expect( + result.current.check({ + visibleWhen: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(false) + + fireEvent.blur(document.querySelector('input')) + expect( + result.current.check({ + visibleWhen: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(true) + }) + + it('should return true immediately when "continuousValidation" is true', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => ( + + + {children} + + ), + }) + + expect( + result.current.check({ + visibleWhen: { + path: '/myPath', + isValid: true, + continuousValidation: true, + }, + }) + ).toBe(false) + + fireEvent.focus(document.querySelector('input')) + fireEvent.change(document.querySelector('input'), { + target: { value: '2' }, + }) + expect( + result.current.check({ + visibleWhen: { + path: '/myPath', + isValid: true, + continuousValidation: true, + }, + }) + ).toBe(true) + + fireEvent.blur(document.querySelector('input')) + expect( + result.current.check({ + visibleWhen: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(true) + }) + }) }) describe('visibleWhenNot', () => { @@ -304,7 +450,7 @@ describe('useVisibility', () => { }) it('should not render children when hasValue does not match', () => { - const { result } = renderHook(() => useVisibility(), { + const { result } = renderHook(useVisibility, { wrapper: ({ children }) => ( {children} ), @@ -318,5 +464,105 @@ describe('useVisibility', () => { }) ).toBe(true) }) + + describe('isValid', () => { + it('should return true when path is not existing', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => {children}, + }) + + expect( + result.current.check({ + visibleWhenNot: { + path: '/something', + isValid: true, + }, + }) + ).toBe(true) + }) + + it('should return true when path did validate', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => ( + + + {children} + + ), + }) + + expect( + result.current.check({ + visibleWhenNot: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(true) + }) + + it('should return false children when path did validate initially', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => ( + + + {children} + + ), + }) + + expect( + result.current.check({ + visibleWhenNot: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(false) + }) + + it('should return false when path did validate after blur', () => { + const { result } = renderHook(useVisibility, { + wrapper: ({ children }) => ( + + + {children} + + ), + }) + + expect( + result.current.check({ + visibleWhenNot: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(true) + + fireEvent.focus(document.querySelector('input')) + fireEvent.change(document.querySelector('input'), { + target: { value: '2' }, + }) + expect( + result.current.check({ + visibleWhenNot: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(true) + + fireEvent.blur(document.querySelector('input')) + expect( + result.current.check({ + visibleWhenNot: { + path: '/myPath', + isValid: true, + }, + }) + ).toBe(false) + }) + }) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/stories/Visibility.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/stories/Visibility.stories.tsx index 55e75db802a..04f230583d8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/stories/Visibility.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/stories/Visibility.stories.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react' -import { Field, Form } from '../../..' +import { Field, Form, Value } from '../../..' import { Flex, Section, Card } from '../../../../../components' import { P, Ul, Li } from '../../../../../elements' @@ -473,3 +473,23 @@ export const wrappingSingleVisibilityInRootFragment = () => { ) } + +export function VisibilityOnValidation() { + return ( + + + + + + + + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/useVisibility.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/useVisibility.tsx index c2c8b91a26a..34c8c671d92 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/useVisibility.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/useVisibility.tsx @@ -8,7 +8,12 @@ import { Props } from './Visibility' export type { Props } export default function useVisibility(props?: Partial) { - const { filterDataHandler, data: originalData } = useContext(DataContext) + const { + hasFieldError, + filterDataHandler, + mountedFieldsRef, + data: originalData, + } = useContext(DataContext) const { makePath, makeIteratePath } = usePath() @@ -51,33 +56,48 @@ export default function useVisibility(props?: Partial) { 'itemPath' in visibleWhen ? makeIteratePath(visibleWhen.itemPath) : makePath(visibleWhen.path) - const hasPath = pointer.has(data, path) - if (hasPath) { - const value = pointer.get(data, path) - - if (visibleWhen?.['withValue']) { - console.warn( - 'VisibleWhen: "withValue" is deprecated, use "hasValue" instead' - ) + if ('isValid' in visibleWhen) { + const item = mountedFieldsRef.current[path] + if (!item || item.isMounted !== true) { + return visibleWhenNot ? true : false } - - const hasValue = - visibleWhen?.['hasValue'] ?? visibleWhen?.['withValue'] const result = - typeof hasValue === 'function' - ? hasValue(value) === false - : hasValue !== value + (visibleWhen.continuousValidation + ? true + : item.isFocused !== true) && hasFieldError(path) === false + return visibleWhenNot ? !result : result + } + + if ('hasValue' in visibleWhen || 'withValue' in visibleWhen) { + const hasPath = pointer.has(data, path) + + if (hasPath) { + const value = pointer.get(data, path) + + if (visibleWhen?.['withValue']) { + console.warn( + 'VisibleWhen: "withValue" is deprecated, use "hasValue" instead' + ) + } - if (visibleWhenNot) { - if (!result) { + const hasValue = + visibleWhen?.['hasValue'] ?? visibleWhen?.['withValue'] + const result = + typeof hasValue === 'function' + ? hasValue(value) === false + : hasValue !== value + + if (visibleWhenNot) { + if (!result) { + return false + } + } else if (result) { return false } - } else if (result) { + } else { return false } - } else { - return false } } @@ -120,7 +140,14 @@ export default function useVisibility(props?: Partial) { return true }, - [filterDataHandler, originalData, makePath, makeIteratePath] + [ + filterDataHandler, + originalData, + makePath, + makeIteratePath, + mountedFieldsRef, + hasFieldError, + ] ) return { check } diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/clearData.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/clearData.test.tsx index bde98346af7..c507c8ea98a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/clearData.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/__tests__/clearData.test.tsx @@ -1,5 +1,6 @@ import React from 'react' import { act, render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { Field, Form } from '../../..' describe('Form.clearData', () => { @@ -17,6 +18,28 @@ describe('Form.clearData', () => { expect(document.querySelector('input')).toHaveValue('') }) + it('should not show an error when clearing a form in React.StrictMode', async () => { + render( + + + + +