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 (
+ <>
+
+
+
+
+
+
+
+
+
+ Undo
+
+
+ Redo
+
+
+
+
+ >
+ )
+ }
+
+ 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 (
+ <>
+
+
+
+
+
+
+ Undo
+ Redo
+ >
+ )
+ }
+
+ 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 (
+ <>
+ Undo
+ Redo
+ >
+ )
+ }
+
+ 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(
+
+
+
+
+ Form.clearData('unique-id')} />
+
+ )
+
+ const input = document.querySelector('input')
+ await userEvent.type(input, 'my string')
+ expect(input).toHaveValue('my string')
+ expect(document.querySelector('.dnb-form-status')).toBeNull()
+
+ const button = document.querySelector('button')
+ await userEvent.click(button)
+
+ expect(input).toHaveValue('')
+ expect(document.querySelector('.dnb-form-status')).toBeNull()
+ })
+
it('should call onClear', () => {
const onClear = jest.fn()
diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts
index 41445256794..9e79c2047b8 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/clearData.ts
@@ -1,9 +1,9 @@
import { createSharedState } from '../../../../shared/helpers/useSharedState'
+import { SharedAttachments } from '../../DataContext/Provider'
export default function clearData(id: string) {
- const sharedAttachments = createSharedState(id + '-attachments')
- sharedAttachments.set({})
-
- const sharedData = createSharedState(id)
- sharedData.update({ clearForm: true })
+ const sharedAttachments = createSharedState>(
+ id + '-attachments'
+ )
+ sharedAttachments.data.clearData?.()
}
diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx
index c527850e8e9..3265f595264 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/getData.tsx
@@ -3,10 +3,7 @@ import {
SharedStateId,
createSharedState,
} from '../../../../shared/helpers/useSharedState'
-import type {
- FilterDataHandler,
- VisibleDataHandler,
-} from '../../DataContext/Context'
+import { SharedAttachments } from '../../DataContext/Provider'
import type { Path } from '../../types'
import type {
UseDataReturnGetValue,
@@ -14,11 +11,6 @@ import type {
UseDataReturnVisibleData,
} from './useData'
-type SharedAttachment = {
- filterDataHandler: FilterDataHandler
- visibleDataHandler?: VisibleDataHandler
-}
-
type SetDataReturn = {
data: Data
getValue: UseDataReturnGetValue
@@ -30,7 +22,7 @@ export default function getData(
id: SharedStateId
): SetDataReturn {
const sharedState = createSharedState(id)
- const sharedAttachments = createSharedState>(
+ const sharedAttachments = createSharedState>(
id + '-attachments'
)
diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx
index 7b42630b30d..e525e7c75e0 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useData.tsx
@@ -14,9 +14,15 @@ import useMountEffect from '../../../../shared/helpers/useMountEffect'
import type { Path } from '../../types'
import DataContext, {
FilterData,
- FilterDataHandler,
VisibleDataHandler,
} from '../../DataContext/Context'
+import { SharedAttachments } from '../../DataContext/Provider'
+
+/**
+ * Deprecated, as it is supported by all major browsers and Node.js >=v18
+ * So its a question of time, when we will remove this polyfill
+ */
+import structuredClone from '@ungap/structured-clone'
type PathImpl = P extends `${infer Key}/${infer Rest}`
? Key extends keyof T
@@ -56,12 +62,6 @@ type UseDataReturn = {
reduceToVisibleFields: UseDataReturnVisibleData
}
-type SharedAttachment = {
- rerenderUseDataHook: () => void
- filterDataHandler?: FilterDataHandler
- visibleDataHandler?: VisibleDataHandler
-}
-
/**
* Custom hook that provides form data management functionality.
*
@@ -77,7 +77,9 @@ export default function useData(
const sharedDataRef =
useRef>>(null)
const sharedAttachmentsRef =
- useRef>>>(null)
+ useRef>>>(
+ null
+ )
const [, forceUpdate] = useReducer(() => ({}), {})
sharedDataRef.current = useSharedState(
@@ -86,7 +88,7 @@ export default function useData(
forceUpdate
)
- sharedAttachmentsRef.current = useSharedState>(
+ sharedAttachmentsRef.current = useSharedState>(
id + '-attachments',
{ rerenderUseDataHook: forceUpdate }
)
diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx
index b4bd9d02395..6b75302953a 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Form/data-context/useValidation.tsx
@@ -4,6 +4,7 @@ import {
useSharedState,
} from '../../../../shared/helpers/useSharedState'
import DataContext, { ContextState } from '../../DataContext/Context'
+import { SharedAttachments } from '../../DataContext/Provider'
import { EventStateObject, Path } from '../../types'
type UseDataReturn = {
@@ -17,9 +18,7 @@ export default function useValidation(
id: SharedStateId = undefined
): UseDataReturn {
const { data } = useSharedState<
- UseDataReturn & {
- setSubmitState: ContextState['setSubmitState']
- }
+ UseDataReturn & SharedAttachments
>(id + '-attachments')
const fallback = useCallback(() => false, [])
diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts
index ae7fd03bd51..1b59f78a1b3 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts
@@ -12,12 +12,14 @@ export { default as SubHeading } from './SubHeading'
export { default as Visibility } from './Visibility'
export { default as Section } from './Section'
export { default as Isolation } from './Isolation'
+export { default as Snapshot } from './Snapshot'
export { default as useData } from './data-context/useData'
export { default as setData } from './data-context/setData'
export { default as getData } from './data-context/getData'
export { default as clearData } from './data-context/clearData'
export { default as useValidation } from './data-context/useValidation'
export { default as useTranslation } from '../hooks/useTranslation'
+export { default as useSnapshot } from '../hooks/useSnapshot'
/**
* Can be removed in v11
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx
index fef7b435027..93706831626 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/Array.tsx
@@ -20,6 +20,7 @@ import {
} from '../../../../components/flex/Container'
import IterateItemContext, {
IterateItemContextState,
+ ModeOptions,
} from '../IterateItemContext'
import SummaryListContext from '../../Value/SummaryList/SummaryListContext'
import ValueBlockContext from '../../ValueBlock/ValueBlockContext'
@@ -117,7 +118,7 @@ function ArrayComponent(props: Props) {
{
current: ContainerMode
previous?: ContainerMode
- options?: { omitFocusManagement?: boolean }
+ options?: ModeOptions
}
>
>({})
@@ -180,7 +181,9 @@ function ArrayComponent(props: Props) {
modesRef.current[id].current = mode
modesRef.current[id].options = options
delete isNewRef.current?.[id]
- forceUpdate()
+ if (options?.preventUpdate !== true) {
+ forceUpdate()
+ }
},
handleChange: (path, value) => {
const newArrayValue = structuredClone(arrayValue)
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx
index 3c590b0fa8b..8a1ddce7f15 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/ArrayItemArea.tsx
@@ -1,10 +1,4 @@
-import React, {
- useCallback,
- useContext,
- useEffect,
- useReducer,
- useRef,
-} from 'react'
+import React, { useCallback, useContext, useReducer, useRef } from 'react'
import classnames from 'classnames'
import { Flex, HeightAnimation } from '../../../../components'
import IterateItemContext, {
@@ -15,6 +9,10 @@ import FieldBoundaryContext from '../../DataContext/FieldBoundary/FieldBoundaryC
import { Props as FlexContainerProps } from '../../../../components/flex/Container'
import { ContainerMode } from './types'
+// SSR warning fix: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
+const useLayoutEffect =
+ typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect
+
export type ArrayItemAreaProps = {
/**
* Defines the variant of the ViewContainer, EditContainer or PushContainer. Can be `outline` or `basic`.
@@ -50,32 +48,46 @@ function ArrayItemArea(props: Props & FlexContainerProps) {
useContext(FieldBoundaryContext) || {}
localContextRef.current = useContext(IterateItemContext) || {}
const nextFocusElementRef = useRef()
- const { isNew, value } = localContextRef.current
- if (hasSubmitError || !value) {
+ const { isNew } = localContextRef.current
+
+ const determineMode = useCallback(() => {
+ const { value, initialContainerMode } = localContextRef.current
+ if (initialContainerMode === 'auto') {
+ // - Set the container mode to "edit" if we have an error
+ if (
+ hasSubmitError ||
+ hasError ||
+ !value ||
+ (typeof value === 'object' && Object.keys(value).length === 0)
+ ) {
+ return 'edit'
+ }
+ }
+ }, [hasError, hasSubmitError])
+
+ if (determineMode() === 'edit') {
localContextRef.current.containerMode = 'edit'
+ if (!localContextRef.current.modeOptions) {
+ localContextRef.current.modeOptions = {}
+ }
+ localContextRef.current.modeOptions.omitFocusManagement = true
}
if (localContextRef.current.containerMode === 'auto') {
localContextRef.current.containerMode = 'view'
}
- const determineMode = useCallback(() => {
- const { initialContainerMode, switchContainerMode } =
- localContextRef.current
- if (
- mode === 'edit' &&
- !hasSubmitError &&
- initialContainerMode === 'auto'
- ) {
- // - Set the container mode to "edit" if we have an error
- if (hasError && !isNew) {
- switchContainerMode('edit', { omitFocusManagement: true })
+ useLayoutEffect(() => {
+ if (mode === 'edit') {
+ const editMode = determineMode()
+ if (editMode) {
+ const { switchContainerMode } = localContextRef.current
+ switchContainerMode?.(editMode, {
+ omitFocusManagement: true,
+ preventUpdate: true,
+ })
}
}
- }, [hasError, hasSubmitError, isNew, mode])
-
- useEffect(() => {
- determineMode()
- }, [determineMode])
+ }, [determineMode, mode])
const { handleRemove, index, previousContainerMode, containerMode } =
localContextRef.current
@@ -88,7 +100,7 @@ function ArrayItemArea(props: Props & FlexContainerProps) {
forceUpdate()
}, [])
- useEffect(() => {
+ useLayoutEffect(() => {
if (!isRemoving.current) {
// - Set the open state, if it's controlled
if (typeof open !== 'undefined') {
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx
index a7eaabd11bb..433d86a1fe1 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/Array.test.tsx
@@ -640,6 +640,61 @@ describe('Iterate.Array', () => {
)
})
+ it('should handle "defaultValue" (with null) in React.StrictMode', () => {
+ 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(
+ { myList: ['foo'] },
+ expect.anything()
+ )
+ })
+
+ it('should not set defaultValue when item gets removed', () => {
+ 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(
+ { myList: ['foo'] },
+ expect.anything()
+ )
+ })
+
it('should set empty array in the data context', () => {
const onSubmit = jest.fn()
@@ -1320,6 +1375,92 @@ describe('Iterate.Array', () => {
})
})
+ describe('value and defaultValue', () => {
+ it('should support "value" on fields inside iterate', () => {
+ const onSubmit = jest.fn()
+
+ render(
+
+
+ {(value, index) => {
+ return (
+
+ )
+ }}
+
+
+ )
+
+ const form = document.querySelector('form')
+ const [first, second, third, forth] = Array.from(
+ document.querySelectorAll('input')
+ )
+
+ expect(first).toHaveValue('value 1')
+ expect(second).toHaveValue('value 2')
+ expect(third).toHaveValue('value 3')
+ expect(forth).toHaveValue('value 4')
+
+ fireEvent.submit(form)
+
+ expect(onSubmit).toHaveBeenCalledTimes(1)
+ expect(onSubmit).toHaveBeenLastCalledWith(
+ {
+ myList: ['value 1', 'value 2', 'value 3', 'value 4'],
+ },
+ expect.anything()
+ )
+ })
+
+ it('should support "defaultValue" on fields inside iterate', () => {
+ const onSubmit = jest.fn()
+
+ render(
+
+
+ {(value, index) => {
+ return (
+
+ )
+ }}
+
+
+ )
+
+ const form = document.querySelector('form')
+ const [first, second, third] = Array.from(
+ document.querySelectorAll('input')
+ )
+
+ expect(first).toHaveValue('default value 1')
+ expect(second).toHaveValue('default value 2')
+ expect(third).toHaveValue('something')
+
+ fireEvent.submit(form)
+
+ expect(onSubmit).toHaveBeenCalledTimes(1)
+ expect(onSubmit).toHaveBeenLastCalledWith(
+ {
+ myList: ['default value 1', 'default value 2', 'something'],
+ },
+ expect.anything()
+ )
+ })
+ })
+
it('should contain tabindex of -1', () => {
render(content )
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayItemArea.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayItemArea.test.tsx
index fd57c5fd703..8aabb337542 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayItemArea.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/Array/__tests__/ArrayItemArea.test.tsx
@@ -331,7 +331,7 @@ describe('ArrayItemArea', () => {
expect(element).toHaveClass('dnb-height-animation--hidden')
})
- it('opens in edit mode when falsy value was given', async () => {
+ it('opens in view mode by default when mode is view', () => {
let containerMode = null
const ContextConsumer = () => {
@@ -343,7 +343,10 @@ describe('ArrayItemArea', () => {
render(
@@ -351,10 +354,131 @@ describe('ArrayItemArea', () => {
)
+ expect(containerMode).toBe('view')
+ })
+
+ it('opens in view mode by default when mode is edit', () => {
+ let containerMode = null
+
+ const ContextConsumer = () => {
+ const context = React.useContext(IterateItemContext)
+ containerMode = context.containerMode
+
+ return null
+ }
+
+ render(
+
+
+
+
+
+ )
+
+ expect(containerMode).toBe('view')
+ })
+
+ it('opens in edit mode when falsy value was given and mode is view', () => {
+ let containerMode = null
+ const switchContainerMode = jest.fn()
+
+ const ContextConsumer = () => {
+ const context = React.useContext(IterateItemContext)
+ containerMode = context.containerMode
+
+ return null
+ }
+
+ render(
+
+
+
+
+
+ )
+
+ expect(containerMode).toBe('edit')
+ expect(switchContainerMode).toHaveBeenCalledTimes(0)
+ })
+
+ it('opens in edit mode when falsy value was given and mode is edit', () => {
+ let containerMode = null
+ const switchContainerMode = jest.fn()
+
+ const ContextConsumer = () => {
+ const context = React.useContext(IterateItemContext)
+ containerMode = context.containerMode
+
+ return null
+ }
+
+ render(
+
+
+
+
+
+ )
+
expect(containerMode).toBe('edit')
+ expect(switchContainerMode).toHaveBeenCalledTimes(1)
+ expect(switchContainerMode).toHaveBeenLastCalledWith('edit', {
+ omitFocusManagement: true,
+ preventUpdate: true,
+ })
+ })
+
+ it('should thread empty object as falsy', () => {
+ let containerMode = null
+ const switchContainerMode = jest.fn()
+
+ const ContextConsumer = () => {
+ const context = React.useContext(IterateItemContext)
+ containerMode = context.containerMode
+
+ return null
+ }
+
+ render(
+
+
+
+
+
+ )
+
+ expect(containerMode).toBe('edit')
+ expect(switchContainerMode).toHaveBeenCalledTimes(1)
+ expect(switchContainerMode).toHaveBeenLastCalledWith('edit', {
+ omitFocusManagement: true,
+ preventUpdate: true,
+ })
})
- it('should call switchContainerMode when mode is edit and error is present', async () => {
+ it('should call switchContainerMode when mode is edit and error is present', () => {
const switchContainerMode = jest.fn()
render(
@@ -375,6 +499,7 @@ describe('ArrayItemArea', () => {
expect(switchContainerMode).toHaveBeenCalledTimes(1)
expect(switchContainerMode).toHaveBeenLastCalledWith('edit', {
omitFocusManagement: true,
+ preventUpdate: true,
})
})
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx
index 7da3dbf8647..72ab1170946 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/EditContainer.tsx
@@ -9,6 +9,7 @@ import Toolbar from '../Toolbar'
import { useSwitchContainerMode } from '../hooks'
import DoneButton from './DoneButton'
import CancelButton, { useWasNew } from './CancelButton'
+import { replaceItemNo } from '../ItemNo'
export type Props = {
/**
@@ -88,18 +89,12 @@ export function EditContainerWithoutToolbar(
} = props || {}
const wasNew = useWasNew({ isNew, containerMode })
- let itemTitle = wasNew && titleWhenNew ? titleWhenNew : title
- let ariaLabel = useMemo(() => convertJsxToString(itemTitle), [itemTitle])
- if (ariaLabel.includes('{itemN')) {
- /**
- * {itemNr} is deprecated, and can be removed in v11 in favor of {itemNo}
- * So in v11 we can use '{itemNo}' instead of a regex
- */
- itemTitle = ariaLabel = ariaLabel.replace(
- /\{itemN(r|o)\}/g,
- String(index + 1)
+ const itemTitle = useMemo(() => {
+ return replaceItemNo(
+ wasNew && titleWhenNew ? titleWhenNew : title,
+ index
)
- }
+ }, [index, title, titleWhenNew, wasNew])
useSwitchContainerMode({ path })
@@ -107,7 +102,7 @@ export function EditContainerWithoutToolbar(
{itemTitle && {itemTitle} }
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx
index 5ef9a263bd6..db4e2e32dc1 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/EditContainer/__tests__/EditAndViewContainer.test.tsx
@@ -208,7 +208,7 @@ describe('EditContainer and ViewContainer', () => {
}
render(
-
+
View Content
@@ -431,6 +431,53 @@ describe('EditContainer and ViewContainer', () => {
expect(elements[1]).toHaveFocus()
})
})
+
+ it('should removed item from data context', async () => {
+ const onChange = jest.fn()
+
+ render(
+
+
+ View Content
+ Edit Content
+
+
+ )
+
+ const elements = document.querySelectorAll(
+ '.dnb-forms-iterate__element'
+ )
+ expect(elements).toHaveLength(2)
+
+ const firstElement = elements[0]
+ const [viewBlock, editBlock] = Array.from(
+ firstElement.querySelectorAll('.dnb-forms-section-block')
+ )
+ const [, removeButton] = Array.from(
+ viewBlock.querySelectorAll('button')
+ )
+
+ expect(viewBlock).toHaveClass('dnb-forms-section-view-block')
+ expect(editBlock).toHaveClass('dnb-forms-section-edit-block')
+
+ // Remove the element
+ await userEvent.click(removeButton)
+
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenLastCalledWith(
+ {
+ myList: ['bar'],
+ },
+ expect.anything()
+ )
+
+ await waitFor(() => {
+ const elements = document.querySelectorAll(
+ '.dnb-forms-iterate__element'
+ )
+ expect(elements).toHaveLength(1)
+ })
+ })
})
it('should set variant to "outline" when variant is not set', async () => {
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/ItemNo.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/ItemNo.tsx
new file mode 100644
index 00000000000..6fb99f548f1
--- /dev/null
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/ItemNo.tsx
@@ -0,0 +1,35 @@
+import React, { useMemo } from 'react'
+import { useItem } from '../hooks'
+import { convertJsxToString } from '../../../../shared/component-helper'
+
+function ItemNo({ children }) {
+ const { index } = useItem()
+
+ children = useMemo(
+ () => replaceItemNo(children, index),
+ [children, index]
+ )
+
+ return <>{replaceItemNo(children, index)}>
+}
+
+export function replaceItemNo(
+ children: React.ReactNode,
+ index: number
+): string | React.ReactNode {
+ const text =
+ typeof children !== 'string' ? convertJsxToString(children) : children
+
+ if (text.includes('{itemN')) {
+ /**
+ * {itemNr} is deprecated, and can be removed in v11 in favor of {itemNo}
+ * So in v11 we can use '{itemNo}' instead of a regex
+ */
+ return text.replace(/\{itemN(r|o)\}/g, String(index + 1))
+ }
+
+ return children
+}
+
+ItemNo._supportsSpacingProps = false
+export default ItemNo
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/__tests__/ItemNo.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/__tests__/ItemNo.test.tsx
new file mode 100644
index 00000000000..d34d90d69f7
--- /dev/null
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/__tests__/ItemNo.test.tsx
@@ -0,0 +1,35 @@
+import React from 'react'
+import { render } from '@testing-library/react'
+import { Iterate } from '../../..'
+
+describe('Iterate.ItemNo', () => {
+ it('should replace {itemNo} in children given as a string', () => {
+ render(
+
+ {'Item no. {itemNo} string'}
+
+ )
+ expect(document.body).toHaveTextContent('Item no. 1 string')
+ })
+
+ it('should replace several array items', () => {
+ render(
+
+ {'Item no. {itemNo} string'}
+
+ )
+ expect(document.body).toHaveTextContent('Item no. 1 string')
+ expect(document.body).toHaveTextContent('Item no. 2 string')
+ })
+
+ it('should remove jsx and return only a string', () => {
+ render(
+
+
+ {'Item no. {itemNo} string'}
+
+
+ )
+ expect(document.body).toHaveTextContent('Item no. 1 string')
+ })
+})
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/index.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/index.ts
new file mode 100644
index 00000000000..8bfd3c6ca4a
--- /dev/null
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ItemNo/index.ts
@@ -0,0 +1,2 @@
+export { default } from './ItemNo'
+export * from './ItemNo'
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts
index 70d2238e103..8335eb74c80 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/IterateItemContext.ts
@@ -4,6 +4,7 @@ import { ContainerMode } from './Array/types'
export type ModeOptions = {
omitFocusManagement?: boolean
+ preventUpdate?: boolean
}
export interface IterateItemContextState {
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx
index eb8129a4198..f224bf523ec 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/PushContainer/PushContainer.tsx
@@ -8,12 +8,13 @@ import EditContainer, { CancelButton, DoneButton } from '../EditContainer'
import IterateArray, { ContainerMode } from '../Array'
import OpenButton from './OpenButton'
import { Flex, HeightAnimation } from '../../../../components'
-import { Path } from '../../types'
+import { OnCommit, Path } from '../../types'
import { SpacingProps } from '../../../../shared/types'
import { useArrayLimit, useSwitchContainerMode } from '../hooks'
import Toolbar from '../Toolbar'
import { useTranslation } from '../../hooks'
import { ArrayItemAreaProps } from '../Array/ArrayItemArea'
+import { clearedData } from '../../DataContext/Provider'
export type Props = {
/**
@@ -38,15 +39,35 @@ export type Props = {
showOpenButtonWhen?: (list: unknown[]) => boolean
/**
- * Prefilled data to add to the fields.
+ * Prefilled data to add to the fields. The data will be put into this path: "/pushContainerItems/0".
*/
data?: unknown | Record
+ /**
+ * Prefilled data to add to the fields. The data will be put into this path: "/pushContainerItems/0".
+ */
+ defaultData?: unknown | Record
+
+ /**
+ * Provide additional data that will be put into the root of the isolated data context (parallel to "/pushContainerItems/0").
+ */
+ isolatedData?: Record
+
+ /**
+ * Prevent the form from being submitted when there are fields with errors inside the PushContainer.
+ */
+ bubbleValidation?: boolean
+
/**
* A custom toolbar to be shown below the container.
*/
toolbar?: React.ReactNode
+ /**
+ * Will be called when the user clicks on the "Done" button.
+ */
+ onCommit?: OnCommit
+
/**
* The container contents.
*/
@@ -57,12 +78,16 @@ export type AllProps = Props & SpacingProps & ArrayItemAreaProps
function PushContainer(props: AllProps) {
const {
- data = null,
+ data: dataProp,
+ defaultData: defaultDataProp,
+ isolatedData,
+ bubbleValidation,
path,
title,
children,
openButton,
showOpenButtonWhen,
+ onCommit,
...rest
} = props
@@ -90,19 +115,49 @@ function PushContainer(props: AllProps) {
switchContainerMode: switchContainerModeRef.current,
}
+ const data = useMemo(() => {
+ if (defaultDataProp) {
+ return // don't return a fallback, because we want to use the defaultData
+ }
+ return {
+ ...isolatedData,
+ pushContainerItems: [dataProp ?? clearedData],
+ }
+ }, [dataProp, defaultDataProp, isolatedData])
+
const defaultData = useMemo(() => {
- return { newItems: [data] }
- }, [data])
+ return {
+ ...(!dataProp ? isolatedData : null),
+ pushContainerItems: [defaultDataProp ?? clearedData],
+ }
+ }, [dataProp, defaultDataProp, isolatedData])
+
+ const emptyData = useCallback(
+ (data: { pushContainerItems: unknown[] }) => {
+ const firstItem = data.pushContainerItems?.[0]
+ if (firstItem === null || typeof firstItem !== 'object') {
+ return {
+ ...isolatedData,
+ pushContainerItems: [null],
+ }
+ }
+ return defaultData
+ },
+ [defaultData, isolatedData]
+ )
return (
{
- return moveValueToPath(path, [...entries, ...newItems])
+ transformOnCommit={({ pushContainerItems }) => {
+ return moveValueToPath(path, [...entries, ...pushContainerItems])
}}
- onCommit={(data, { clearData, preventCommit }) => {
+ onCommit={(data, options) => {
+ const { clearData, preventCommit } = options
if (hasReachedLimit) {
preventCommit()
setShowStatus(true)
@@ -111,11 +166,12 @@ function PushContainer(props: AllProps) {
switchContainerModeRef.current?.('view')
clearData()
}
+ onCommit?.(data, options)
}}
>
{
@@ -43,6 +44,56 @@ describe('PushContainer', () => {
)
})
+ 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)
+ })
+ })
+
it('should show view container after adding a new entry', async () => {
render(
@@ -451,6 +502,262 @@ describe('PushContainer', () => {
)
})
+ describe('defaultValue', () => {
+ it('should render and set defaultValue in data context', async () => {
+ const onChange = jest.fn()
+
+ render(
+
+
+
+
+
+ )
+
+ expect(document.querySelector('input')).toHaveValue('bar')
+ await userEvent.click(document.querySelector('button'))
+
+ expect(document.querySelector('input')).toHaveValue('bar')
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenLastCalledWith(
+ {
+ myList: [{ foo: 'bar' }],
+ },
+ expect.anything()
+ )
+
+ await userEvent.click(document.querySelector('button'))
+
+ expect(document.querySelector('input')).toHaveValue('bar')
+ expect(onChange).toHaveBeenCalledTimes(2)
+ expect(onChange).toHaveBeenLastCalledWith(
+ {
+ myList: [{ foo: 'bar' }, { foo: 'bar' }],
+ },
+ expect.anything()
+ )
+ })
+
+ it('should support "/" as the path and push the defaultValue', async () => {
+ const onChange = jest.fn()
+
+ render(
+
+
+
+
+
+ )
+
+ expect(document.querySelector('input')).toHaveValue('foo')
+ await userEvent.click(document.querySelector('button'))
+
+ expect(document.querySelector('input')).toHaveValue('foo')
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenLastCalledWith(['foo'], expect.anything())
+
+ await userEvent.click(document.querySelector('button'))
+
+ expect(document.querySelector('input')).toHaveValue('foo')
+ expect(onChange).toHaveBeenCalledTimes(2)
+ expect(onChange).toHaveBeenLastCalledWith(
+ ['foo', 'foo'],
+ expect.anything()
+ )
+ })
+
+ it('should render and extend the data context', async () => {
+ const onChange = jest.fn()
+
+ render(
+
+
+ View Content
+ Edit Content
+
+
+
+
+
+
+ )
+
+ const blocks = Array.from(
+ document.querySelectorAll('.dnb-forms-section-block')
+ )
+ const [, , thirdBlock] = blocks
+
+ const input = thirdBlock.querySelector('input')
+ expect(input).toHaveValue('bar')
+
+ await userEvent.click(thirdBlock.querySelector('button'))
+
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenLastCalledWith(
+ ['foo', 'bar'],
+ expect.anything()
+ )
+ })
+
+ it('should not show error message after clearing', async () => {
+ const onChange = jest.fn()
+
+ render(
+
+
+
+
+
+
+ )
+
+ const [firstInput, lastInput] = Array.from(
+ document.querySelectorAll('input')
+ )
+ const button = document.querySelector('button')
+
+ expect(firstInput).toHaveValue('first name')
+ expect(lastInput).toHaveValue('last name')
+
+ await userEvent.click(button)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenLastCalledWith(
+ {
+ entries: [
+ {
+ first: 'first name',
+ last: 'last name',
+ },
+ ],
+ },
+ expect.anything()
+ )
+
+ expect(firstInput).toHaveValue('first name')
+ expect(lastInput).toHaveValue('last name')
+
+ await userEvent.click(button)
+ expect(onChange).toHaveBeenCalledTimes(2)
+ expect(onChange).toHaveBeenLastCalledWith(
+ {
+ entries: [
+ {
+ first: 'first name',
+ last: 'last name',
+ },
+ {
+ first: 'first name',
+ last: 'last name',
+ },
+ ],
+ },
+ expect.anything()
+ )
+
+ expect(firstInput).toHaveValue('first name')
+ expect(lastInput).toHaveValue('last name')
+
+ await userEvent.click(button)
+ expect(onChange).toHaveBeenCalledTimes(3)
+ expect(onChange).toHaveBeenLastCalledWith(
+ {
+ entries: [
+ {
+ first: 'first name',
+ last: 'last name',
+ },
+ {
+ first: 'first name',
+ last: 'last name',
+ },
+ {
+ first: 'first name',
+ last: 'last name',
+ },
+ ],
+ },
+ expect.anything()
+ )
+
+ expect(firstInput).toHaveValue('first name')
+ expect(lastInput).toHaveValue('last name')
+
+ await waitFor(() => {
+ expect(document.querySelector('.dnb-form-status')).toBeNull()
+ })
+ })
+
+ it('should keep the defaultValue after clearing', async () => {
+ const onChange = jest.fn()
+ const onCommit = jest.fn()
+
+ let internalContext = null
+ const CollectInternalData = () => {
+ internalContext = useContext(DataContext)
+ return null
+ }
+
+ render(
+
+
+
+
+
+
+ )
+
+ expect(internalContext).toMatchObject({
+ data: {
+ pushContainerItems: ['default value'],
+ },
+ })
+
+ const input = document.querySelector('input')
+
+ await userEvent.type(input, ' changed')
+
+ const button = document.querySelector('button')
+
+ await userEvent.click(button)
+ expect(internalContext.internalDataRef.current).toEqual({
+ pushContainerItems: ['default value'],
+ })
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenLastCalledWith(
+ ['default value changed'],
+ expect.anything()
+ )
+ expect(onCommit).toHaveBeenCalledTimes(1)
+ expect(onCommit).toHaveBeenLastCalledWith(
+ ['default value changed'],
+ expect.anything()
+ )
+
+ await userEvent.click(button)
+ expect(internalContext.internalDataRef.current).toEqual({
+ pushContainerItems: ['default value'],
+ })
+ expect(onChange).toHaveBeenCalledTimes(2)
+ expect(onChange).toHaveBeenLastCalledWith(
+ ['default value changed', 'default value'],
+ expect.anything()
+ )
+ expect(onCommit).toHaveBeenCalledTimes(2)
+ expect(onCommit).toHaveBeenLastCalledWith(
+ ['default value changed', 'default value'],
+ expect.anything()
+ )
+ })
+ })
+
it('should support initial data as a string', async () => {
const onChange = jest.fn()
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx
index e5002c5ba00..cc49e27fa4c 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/ViewContainer/ViewContainer.tsx
@@ -9,6 +9,7 @@ import IterateItemContext from '../IterateItemContext'
import Toolbar from '../Toolbar'
import EditButton from './EditButton'
import RemoveButton from './RemoveButton'
+import { replaceItemNo } from '../ItemNo'
export type Props = {
/**
@@ -37,19 +38,9 @@ function ViewContainer(props: AllProps) {
...restProps
} = props || {}
const { index, arrayValue } = useContext(IterateItemContext)
-
- let itemTitle = title
- let ariaLabel = useMemo(() => convertJsxToString(itemTitle), [itemTitle])
- if (ariaLabel.includes('{itemN')) {
- /**
- * {itemNr} is deprecated, and can be removed in v11 in favor of {itemNo}
- * So in v11 we can use '{itemNo}' instead of a regex
- */
- itemTitle = ariaLabel = ariaLabel.replace(
- /\{itemN(r|o)\}/g,
- String(index + 1)
- )
- }
+ const itemTitle = useMemo(() => {
+ return replaceItemNo(title, index)
+ }, [index, title])
let toolbarElement = toolbar
if (toolbarVariant === 'minimumOneItem' && arrayValue.length <= 1) {
@@ -69,7 +60,7 @@ function ViewContainer(props: AllProps) {
return (
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/index.ts b/packages/dnb-eufemia/src/extensions/forms/Iterate/index.ts
index 0b045e6969b..4f0a3712380 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/index.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/index.ts
@@ -9,6 +9,7 @@ export { default as EditContainer } from './EditContainer'
export { default as ViewContainer } from './ViewContainer'
export { default as AnimatedContainer } from './AnimatedContainer'
export { default as Toolbar } from './Toolbar'
+export { default as ItemNo } from './ItemNo'
export { useCount, count, Count } from './Count'
export { default as useItem } from './hooks/useItem'
export { default as IterateItemContext } from './IterateItemContext'
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx
index ef0ca112278..3e17807f36e 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/Iterate.stories.tsx
@@ -1,6 +1,6 @@
import React, { useCallback } from 'react'
-import { Field, Form, Iterate, Value, Wizard } from '../..'
-import { Card, Flex, Section } from '../../../../components'
+import { Field, Form, Iterate, Tools, Value, Wizard } from '../..'
+import { Card, Flex } from '../../../../components'
export default {
title: 'Eufemia/Extensions/Forms/Iterate',
@@ -211,7 +211,7 @@ export const InitialOpen = () => {
-
+
{count}
setCount(count + 1)}>
@@ -223,21 +223,6 @@ export const InitialOpen = () => {
)
}
-const Output = () => {
- const { data } = Form.useData()
-
- return (
-
- All data: {JSON.stringify(data)}
-
- )
-}
-
export const WithArrayValidator = () => {
return (
@@ -280,3 +265,58 @@ export const useCount = () => (
)
+
+export function InWizard() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx
new file mode 100644
index 00000000000..3b81ebd8f10
--- /dev/null
+++ b/packages/dnb-eufemia/src/extensions/forms/Iterate/stories/PushContainer.stories.tsx
@@ -0,0 +1,159 @@
+import React, { useLayoutEffect } from 'react'
+import { Field, Form, Iterate, Tools, Value } from '../..'
+import { Card, Flex } from '../../../../components'
+
+export default {
+ title: 'Eufemia/Extensions/Forms/Iterate/PushContainer',
+}
+
+const formData = {
+ persons: [
+ {
+ firstName: 'Ola',
+ lastName: 'Nordmann',
+ },
+ {
+ firstName: 'Kari',
+ lastName: 'Nordmann',
+ },
+ {
+ firstName: 'Per',
+ lastName: 'Hansen',
+ },
+ ],
+}
+
+export const ComplexPushContainer = () => {
+ return (
+
+ Representatives
+
+
+
+
+
+
+
+
+
+ )
+}
+
+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.
+ 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}
+ >
+
+
+ )
+}
diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx b/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx
index dfe5302a714..a4f8be3a236 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Tools/Log.tsx
@@ -1,8 +1,18 @@
-import { useContext } from 'react'
+import React, { useContext } from 'react'
import DataContext from '../DataContext/Context'
-import Section, { SectionProps } from '../../../components/Section'
+import Section, { SectionAllProps } from '../../../components/Section'
+import { FormLabel } from '../../../components'
-function Log(props: SectionProps) {
+function Log({
+ placeholder,
+ label,
+ data: logData,
+ ...props
+}: Omit & {
+ data?: unknown
+ label?: React.ReactNode
+ placeholder?: React.ReactNode
+}) {
const { data } = useContext(DataContext)
return (
@@ -13,13 +23,47 @@ function Log(props: SectionProps) {
innerSpace
{...props}
>
+ {label && {label} }
- {JSON.stringify(data, null, 2)}
+ {placeholder && Object.keys((logData ?? data) || {}).length === 0
+ ? placeholder
+ : JSON.stringify(
+ replaceUndefinedValues(logData ?? data),
+ null,
+ 2
+ )}
{' ' /* Ensure one line of spacing */}
)
}
+/**
+ * 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 = 'undefined' as unknown
+): 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
+ }
+}
+
Log._supportsSpacingProps = true
export default Log
diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/PhoneNumber/__tests__/PhoneNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/PhoneNumber/__tests__/PhoneNumber.test.tsx
index 9be8189feb5..4122f4ec12c 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Value/PhoneNumber/__tests__/PhoneNumber.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Value/PhoneNumber/__tests__/PhoneNumber.test.tsx
@@ -49,6 +49,6 @@ describe('Value.PhoneNumber', () => {
render( )
const element = document.querySelector('.dnb-forms-value-block')
- expect(element).toHaveTextContent('0047 11 22 33 44')
+ expect(element).toHaveTextContent('+47 11 22 33 44')
})
})
diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/SummaryList.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/SummaryList.tsx
index d4b9d252a41..4981ec6ba36 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/SummaryList.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/SummaryList.tsx
@@ -4,10 +4,12 @@ import { removeUndefinedProps } from '../../../../shared/component-helper'
import SummaryListContext from './SummaryListContext'
import Dl, { DlAllProps } from '../../../../elements/Dl'
import ValueProvider from '../Provider/ValueProvider'
+import { ValueProps } from '../../types'
export type Props = Omit & {
- inheritVisibility?: boolean
- inheritLabel?: boolean
+ transformLabel?: ValueProps['transformLabel']
+ inheritVisibility?: ValueProps['inheritVisibility']
+ inheritLabel?: ValueProps['inheritLabel']
}
function SummaryList(props: Props) {
@@ -15,12 +17,14 @@ function SummaryList(props: Props) {
className,
children,
layout,
+ transformLabel,
inheritVisibility,
inheritLabel,
...rest
} = props
const valueProviderProps = removeUndefinedProps({
+ transformLabel,
inheritVisibility,
inheritLabel,
})
diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/SummaryListDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/SummaryListDocs.ts
index e74f22681d1..14d90f78fc6 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/SummaryListDocs.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/SummaryListDocs.ts
@@ -6,6 +6,11 @@ export const SummaryListProperties: PropertiesTableProps = {
type: 'string',
status: 'optional',
},
+ transformLabel: {
+ doc: 'Transforms the label before it gets displayed. Receives the label as the first parameter. The second parameter is a object containing the `convertJsxToString` function.',
+ type: 'function',
+ status: 'optional',
+ },
inheritVisibility: {
doc: 'Use this property to propagate the `inheritVisibility` property to all nested values.',
type: 'boolean',
diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/__tests__/SummaryList.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/__tests__/SummaryList.test.tsx
index ce5784bd8b7..90de9a0c898 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/__tests__/SummaryList.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Value/SummaryList/__tests__/SummaryList.test.tsx
@@ -4,10 +4,6 @@ import userEvent from '@testing-library/user-event'
import SummaryList from '../SummaryList'
import { Field, Form, Value } from '../../..'
-beforeEach(() => {
- global.console.log = jest.fn()
-})
-
describe('Field.SummaryList', () => {
it('should forward HTML attributes', () => {
render(Aria Summary )
@@ -106,7 +102,7 @@ describe('Field.SummaryList', () => {
describe('inheritVisibility', () => {
it('renders value when visibility of field is initially true', async () => {
render(
-
+
{
expect(labelBar).toHaveTextContent('bar label')
})
})
+
+ describe('transformLabel', () => {
+ it('renders labels', async () => {
+ render(
+
+
+
+
+ label.toUpperCase()}
+ >
+
+
+
+
+ )
+
+ const [labelFoo, labelBar] = Array.from(
+ document.querySelectorAll('dt')
+ )
+
+ expect(labelFoo).toHaveTextContent('FOO LABEL')
+ expect(labelBar).toHaveTextContent('BAR LABEL')
+ })
+ })
})
diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/ValueDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Value/ValueDocs.ts
index d04fdf59e49..2b5492fa61a 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Value/ValueDocs.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/Value/ValueDocs.ts
@@ -16,6 +16,11 @@ export const ValueProperties: PropertiesTableProps = {
type: 'string',
status: 'optional',
},
+ transformLabel: {
+ doc: 'Transforms the label before it gets displayed. Receives the label as the first parameter. The second parameter is a object containing the `convertJsxToString` function.',
+ type: 'function',
+ status: 'optional',
+ },
inheritLabel: {
doc: 'Use `true` to inherit the label from a visible (rendered) field with the same path.',
type: 'boolean',
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Buttons/Buttons.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Buttons/Buttons.tsx
index 708f209d95b..70ae05cebfe 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Buttons/Buttons.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Buttons/Buttons.tsx
@@ -12,9 +12,9 @@ export type Props = ComponentProps & {
function Buttons(props: Props) {
const { className } = props
- const { activeIndex, titlesRef } = useContext(WizardContext) || {}
+ const { activeIndex, stepsRef } = useContext(WizardContext) || {}
- const totalSteps = Object.keys(titlesRef?.current || {}).length || 0
+ const totalSteps = Object.keys(stepsRef?.current || {}).length || 0
const showPreviousButton = activeIndex > 0
const showNextButton = activeIndex < totalSteps - 1
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx
index 3e180ac288a..3f3fc8d4571 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx
@@ -17,8 +17,11 @@ import useId from '../../../../shared/helpers/useId'
import Step, { Props as StepProps } from '../Step'
import WizardContext, {
OnStepChange,
+ OnStepChangeOptions,
+ OnStepsChangeMode,
SetActiveIndexOptions,
StepIndex,
+ Steps,
WizardContextState,
} from '../Context/WizardContext'
import DataContext, {
@@ -30,6 +33,7 @@ import {
useSharedState,
} from '../../../../shared/helpers/useSharedState'
import useHandleLayoutEffect from './useHandleLayoutEffect'
+import useStepAnimation from './useStepAnimation'
import { ComponentProps } from '../../types'
import useVisibility from '../../Form/Visibility/useVisibility'
@@ -127,6 +131,12 @@ function WizardContainer(props: Props) {
const errorOnStepRef = useRef>({})
const stepElementRef = useRef()
const preventNextStepRef = useRef(false)
+ const stepsRef = useRef({})
+ const tmpStepsRef = useRef({})
+ const updateTitlesRef = useRef<() => void>()
+ const prerenderFieldPropsRef = useRef<
+ Record React.ReactElement>
+ >({})
// - Handle shared state
const sharedStateRef =
@@ -146,19 +156,49 @@ function WizardContainer(props: Props) {
preventNextStepRef.current = shouldPrevent
}, [])
+ const getStepChangeOptions: (index: StepIndex) => OnStepChangeOptions =
+ useCallback(
+ (index) => {
+ const previousIndex = activeIndexRef.current
+ const options = {
+ preventNavigation,
+ previousStep: { index: previousIndex },
+ }
+
+ const id = stepsRef.current[index]?.id
+ if (id) {
+ const previousId = stepsRef.current[previousIndex]?.id
+ Object.assign(options, { id })
+ Object.assign(options.previousStep, { id: previousId })
+ }
+
+ return options
+ },
+ [preventNavigation]
+ )
+
const callOnStepChange = useCallback(
- async (index: StepIndex, mode: 'previous' | 'next') => {
+ async (index: StepIndex, mode: OnStepsChangeMode) => {
if (isAsync(onStepChange)) {
- return await onStepChange(index, mode, { preventNavigation })
+ return await onStepChange(index, mode, getStepChangeOptions(index))
}
- return onStepChange?.(index, mode, { preventNavigation })
+ return onStepChange?.(index, mode, getStepChangeOptions(index))
},
- [onStepChange, preventNavigation]
+ [getStepChangeOptions, onStepChange]
)
const { setFocus, scrollToTop, isInteractionRef } =
- useHandleLayoutEffect({ activeIndexRef, stepElementRef })
+ useHandleLayoutEffect({
+ stepElementRef,
+ })
+
+ const executeLayoutAnimationRef = useRef<() => void>()
+ useStepAnimation({
+ activeIndexRef,
+ stepElementRef,
+ executeLayoutAnimationRef,
+ })
const handleLayoutEffect = useCallback(() => {
if (!omitFocusManagement) {
@@ -179,7 +219,7 @@ function WizardContainer(props: Props) {
mode,
}: {
index: StepIndex
- mode: 'previous' | 'next'
+ mode: OnStepsChangeMode
} & SetActiveIndexOptions) => {
handleSubmitCall({
skipErrorCheck,
@@ -187,16 +227,21 @@ function WizardContainer(props: Props) {
enableAsyncBehavior: isAsync(onStepChange),
onSubmit: async () => {
if (!skipStepChangeCallFromHook) {
- sharedStateRef.current?.data?.onStepChange?.(index, mode, {
- preventNavigation,
- })
+ sharedStateRef.current?.data?.onStepChange?.(
+ index,
+ mode,
+ getStepChangeOptions(index)
+ )
}
- const result =
- skipStepChangeCall ||
- (skipStepChangeCallBeforeMounted && !isInteractionRef.current)
- ? undefined
- : await callOnStepChange(index, mode)
+ let result = undefined
+
+ if (
+ !skipStepChangeCall &&
+ !(skipStepChangeCallBeforeMounted && !isInteractionRef.current)
+ ) {
+ result = await callOnStepChange(index, mode)
+ }
// Hide async indicator
setFormState('abort')
@@ -221,11 +266,11 @@ function WizardContainer(props: Props) {
},
[
callOnStepChange,
+ getStepChangeOptions,
handleLayoutEffect,
handleSubmitCall,
isInteractionRef,
onStepChange,
- preventNavigation,
setFormState,
setShowAllErrors,
]
@@ -282,12 +327,6 @@ function WizardContainer(props: Props) {
)
dataContext.setHandleSubmit?.(handleSubmit)
- const titlesRef = useRef({})
- const updateTitlesRef = useRef<() => void>()
- const prerenderFieldPropsRef = useRef<
- Record React.ReactElement>
- >({})
-
const { check } = useVisibility()
const activeIndex = activeIndexRef.current
@@ -296,7 +335,7 @@ function WizardContainer(props: Props) {
id,
activeIndex,
stepElementRef,
- titlesRef,
+ stepsRef,
updateTitlesRef,
activeIndexRef,
totalStepsRef,
@@ -329,7 +368,23 @@ function WizardContainer(props: Props) {
useLayoutEffect(() => {
updateTitlesRef.current?.()
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [titlesRef.current])
+ }, [stepsRef.current])
+
+ const stepsLengthDidChange = useCallback(() => {
+ const count = Object.keys(stepsRef.current).length
+ const tmpCount = Object.keys(tmpStepsRef.current).length
+ return count !== 0 && tmpCount !== 0 && count !== tmpCount
+ }, [])
+
+ // - Call onStepChange when step gets replaced or added (e.g. via activeWhen)
+ useLayoutEffect(() => {
+ if (stepsLengthDidChange()) {
+ callOnStepChange(activeIndexRef.current, 'stepListModified')
+ executeLayoutAnimationRef.current?.()
+ }
+ tmpStepsRef.current = stepsRef.current
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [stepsRef.current, callOnStepChange, stepsLengthDidChange])
if (!hasContext) {
warn('You may wrap Wizard.Container in Form.Handler')
@@ -380,7 +435,7 @@ function DisplaySteps({
sidebarId,
}) {
const [, forceUpdate] = useReducer(() => ({}), {})
- const { id, activeIndexRef, titlesRef, updateTitlesRef } =
+ const { id, activeIndexRef, stepsRef, updateTitlesRef } =
useContext(WizardContext) || {}
updateTitlesRef.current = () => {
forceUpdate()
@@ -395,7 +450,7 @@ function DisplaySteps({
title)}
mode={mode}
no_animation={noAnimation}
on_change={handleChange}
@@ -408,14 +463,14 @@ function DisplaySteps({
function IterateOverSteps({ children }) {
const {
check,
- titlesRef,
+ stepsRef,
activeIndexRef,
totalStepsRef,
prerenderFieldProps,
prerenderFieldPropsRef,
} = useContext(WizardContext)
- titlesRef.current = {}
+ stepsRef.current = {}
let incrementIndex = -1
const childrenArray = React.Children.map(children, (child) => {
@@ -447,10 +502,13 @@ function IterateOverSteps({ children }) {
incrementIndex++
const index = incrementIndex
- titlesRef.current[index] =
- child.props.title !== undefined
- ? convertJsxToString(child.props.title)
- : 'Title missing'
+ stepsRef.current[index] = {
+ id: child.props.id,
+ title:
+ child.props.title !== undefined
+ ? convertJsxToString(child.props.title)
+ : 'Title missing',
+ }
const key = `${index}-${activeIndexRef.current}`
const clone = (props) =>
React.cloneElement(child as React.ReactElement, props)
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts
index 8626c03e9df..8cc04e48864 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainerDocs.ts
@@ -50,7 +50,7 @@ export const WizardContainerProperties: PropertiesTableProps = {
export const WizardContainerEvents: PropertiesTableProps = {
onStepChange: {
- doc: 'Will be called when the user navigate to a different step, with step `index` as the first argument and `previous` or `next` (string) as the second argument, and an options object containing `preventNavigation` function as the third parameter. When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related Form.Handler props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`.',
+ doc: 'Will be called when the user navigate to a different step, with step `index` as the first argument and `previous` or `next` (or `stepListModified` when a step gets replaced) as the second argument, and as the third parameter an options object containing a `preventNavigation` function, an `id` if given on the `Wizard.Step` and a `previousStep` object containing the previous `index` (and `id` if given on the `Wizard.Step`). When an async function is provided, it will show an indicator on the submit button during the form submission. All form elements will be disabled during the submit. The indicator will be shown for minimum 1 second. Related Form.Handler props: `minimumAsyncBehaviorTime` and `asyncSubmitTimeout`.',
type: 'function',
status: 'optional',
},
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx
index 86ca77233ae..74a06cb1e1e 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/__tests__/WizardContainer.test.tsx
@@ -64,20 +64,19 @@ describe('Wizard.Container', () => {
expect(secondStep.querySelector('.dnb-button').tagName).toBe('SPAN')
})
- it('should call event listener onStepChange', async () => {
+ it('should call event listener "onStepChange"', async () => {
const onStepChange = jest.fn()
render(
Step 1
-
+
Step 2
-
-
+
)
@@ -96,7 +95,6 @@ describe('Wizard.Container', () => {
)
await userEvent.click(firstStep.querySelector('.dnb-button'))
- await wait(1000)
expect(output()).toHaveTextContent('Step 1')
expect(onStepChange).toHaveBeenCalledTimes(2)
expect(onStepChange).toHaveBeenLastCalledWith(
@@ -106,7 +104,6 @@ describe('Wizard.Container', () => {
)
await userEvent.click(nextButton())
- expect(nextButton()).not.toBeDisabled()
expect(onStepChange).toHaveBeenCalledTimes(3)
expect(onStepChange).toHaveBeenLastCalledWith(
@@ -117,7 +114,6 @@ describe('Wizard.Container', () => {
// Use fireEvent to trigger the event fast
fireEvent.click(previousButton())
- expect(previousButton()).not.toBeDisabled()
await waitFor(() => {
expect(previousButton()).toBeNull()
@@ -131,6 +127,97 @@ describe('Wizard.Container', () => {
})
})
+ it('should have previousStep in "onStepChange" when navigating back and forth', async () => {
+ const onStepChange = jest.fn()
+
+ render(
+
+
+ Step 1
+
+
+
+
+ Step 2
+
+
+
+ )
+
+ await userEvent.click(nextButton())
+
+ expect(onStepChange).toHaveBeenCalledTimes(1)
+ expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ preventNavigation: expect.any(Function),
+ previousStep: { index: 0 },
+ })
+
+ await userEvent.click(previousButton())
+
+ expect(onStepChange).toHaveBeenCalledTimes(2)
+ expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous', {
+ preventNavigation: expect.any(Function),
+ previousStep: { index: 1 },
+ })
+
+ await userEvent.click(nextButton())
+
+ expect(onStepChange).toHaveBeenCalledTimes(3)
+ expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ preventNavigation: expect.any(Function),
+ previousStep: { index: 0 },
+ })
+ })
+
+ it('should provide id prop in "onStepChange"', async () => {
+ const onStepChange = jest.fn()
+
+ render(
+
+
+ Step 1
+
+
+
+
+ Step 2
+
+
+
+ )
+
+ const [firstStep, secondStep] = Array.from(
+ document.querySelectorAll('.dnb-step-indicator__item')
+ )
+
+ await userEvent.click(secondStep.querySelector('.dnb-button'))
+ expect(output()).toHaveTextContent('Step 2')
+ expect(onStepChange).toHaveBeenCalledTimes(1)
+ expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ id: 'step-2',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ })
+
+ await userEvent.click(firstStep.querySelector('.dnb-button'))
+ expect(output()).toHaveTextContent('Step 1')
+ expect(onStepChange).toHaveBeenCalledTimes(2)
+ expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous', {
+ id: 'step-1',
+ previousStep: { index: 1, id: 'step-2' },
+ preventNavigation: expect.any(Function),
+ })
+
+ await userEvent.click(nextButton())
+
+ expect(onStepChange).toHaveBeenCalledTimes(3)
+ expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ id: 'step-2',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ })
+ })
+
it('should show error on navigating back and forth', async () => {
render(
@@ -832,6 +919,10 @@ describe('Wizard.Container', () => {
)
+ const [groupOne, groupTwo] = Array.from(
+ document.querySelectorAll('.dnb-toggle-button button')
+ )
+
expect(output()).toHaveTextContent('Step 2')
expect(
document.querySelector('.dnb-step-indicator')
@@ -842,9 +933,7 @@ describe('Wizard.Container', () => {
expect(previousButton()).toBeNull()
expect(nextButton()).toBeNull()
- await userEvent.click(
- document.querySelectorAll('.dnb-toggle-button button')[0]
- )
+ await userEvent.click(groupOne)
expect(output()).toHaveTextContent('Step 1')
expect(
@@ -856,9 +945,7 @@ describe('Wizard.Container', () => {
expect(previousButton()).toBeNull()
expect(nextButton()).toBeInTheDocument()
- await userEvent.click(
- document.querySelectorAll('.dnb-toggle-button button')[1]
- )
+ await userEvent.click(groupTwo)
expect(output()).toHaveTextContent('Step 2')
expect(
@@ -884,6 +971,155 @@ describe('Wizard.Container', () => {
expect(previousButton()).toBeNull()
expect(nextButton()).toBeNull()
})
+
+ it('should provide "id" prop and "same" mode in "onStepChange"', async () => {
+ const onStepChange = jest.fn(async () => null)
+
+ render(
+
+
+
+
+
+
+
+
+ Step 1
+
+
+
+
+ Step 2
+
+
+
+
+ Step 3
+
+
+
+
+ )
+
+ expect(output()).toHaveTextContent('Step 2')
+ expect(onStepChange).toHaveBeenCalledTimes(0)
+
+ const [groupOne, groupTwo] = Array.from(
+ document.querySelectorAll('.dnb-toggle-button button')
+ )
+
+ await userEvent.click(groupOne)
+
+ expect(output()).toHaveTextContent('Step 1')
+ expect(onStepChange).toHaveBeenCalledTimes(1)
+ expect(onStepChange).toHaveBeenLastCalledWith(
+ 0,
+ 'stepListModified',
+ {
+ id: 'step-1',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ }
+ )
+
+ await userEvent.click(groupTwo)
+
+ expect(output()).toHaveTextContent('Step 2')
+ expect(onStepChange).toHaveBeenCalledTimes(2)
+ expect(onStepChange).toHaveBeenLastCalledWith(
+ 0,
+ 'stepListModified',
+ {
+ id: 'step-2',
+ previousStep: { index: 0, id: 'step-2' },
+ preventNavigation: expect.any(Function),
+ }
+ )
+
+ await userEvent.click(groupOne)
+
+ expect(output()).toHaveTextContent('Step 1')
+ expect(onStepChange).toHaveBeenCalledTimes(3)
+ expect(onStepChange).toHaveBeenLastCalledWith(
+ 0,
+ 'stepListModified',
+ {
+ id: 'step-1',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ }
+ )
+
+ await userEvent.click(nextButton())
+
+ expect(output()).toHaveTextContent('Step 3')
+ expect(onStepChange).toHaveBeenCalledTimes(4)
+ expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ id: 'step-3',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ })
+
+ await userEvent.click(groupTwo)
+
+ expect(output()).toHaveTextContent('Step 2')
+ expect(onStepChange).toHaveBeenCalledTimes(5)
+ expect(onStepChange).toHaveBeenLastCalledWith(
+ 0,
+ 'stepListModified',
+ {
+ id: 'step-2',
+ previousStep: { index: 0, id: 'step-2' },
+ preventNavigation: expect.any(Function),
+ }
+ )
+
+ await userEvent.click(groupOne)
+
+ expect(output()).toHaveTextContent('Step 1')
+ expect(onStepChange).toHaveBeenCalledTimes(6)
+ expect(onStepChange).toHaveBeenLastCalledWith(
+ 0,
+ 'stepListModified',
+ {
+ id: 'step-1',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ }
+ )
+
+ await userEvent.click(nextButton())
+
+ expect(output()).toHaveTextContent('Step 3')
+ expect(onStepChange).toHaveBeenCalledTimes(7)
+ expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ id: 'step-3',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ })
+
+ await userEvent.click(previousButton())
+
+ expect(output()).toHaveTextContent('Step 1')
+ expect(onStepChange).toHaveBeenCalledTimes(8)
+ expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous', {
+ id: 'step-1',
+ previousStep: { index: 1, id: 'step-3' },
+ preventNavigation: expect.any(Function),
+ })
+ })
})
it('should support drawer variant', () => {
@@ -930,7 +1166,7 @@ describe('Wizard.Container', () => {
})
describe('async step change', () => {
- it('should handle async onStepChange', async () => {
+ it('should disable and enable buttons', async () => {
const onStepChange = async () => null
render(
@@ -982,6 +1218,55 @@ describe('Wizard.Container', () => {
})
})
+ it('should provide id prop in "onStepChange"', async () => {
+ const onStepChange = jest.fn(async () => null)
+
+ render(
+
+
+ Step 1
+
+
+
+
+ Step 2
+
+
+
+ )
+
+ const [firstStep, secondStep] = Array.from(
+ document.querySelectorAll('.dnb-step-indicator__item')
+ )
+
+ await userEvent.click(secondStep.querySelector('.dnb-button'))
+ expect(output()).toHaveTextContent('Step 2')
+ expect(onStepChange).toHaveBeenCalledTimes(1)
+ expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ id: 'step-2',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ })
+
+ await userEvent.click(firstStep.querySelector('.dnb-button'))
+ expect(output()).toHaveTextContent('Step 1')
+ expect(onStepChange).toHaveBeenCalledTimes(2)
+ expect(onStepChange).toHaveBeenLastCalledWith(0, 'previous', {
+ id: 'step-1',
+ previousStep: { index: 1, id: 'step-2' },
+ preventNavigation: expect.any(Function),
+ })
+
+ await userEvent.click(nextButton())
+
+ expect(onStepChange).toHaveBeenCalledTimes(3)
+ expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ id: 'step-2',
+ previousStep: { index: 0, id: 'step-1' },
+ preventNavigation: expect.any(Function),
+ })
+ })
+
it('should handle async onSubmit', async () => {
const onSubmit = async () => null
@@ -1691,6 +1976,7 @@ describe('Wizard.Container', () => {
expect(output()).toHaveTextContent('Step 1')
expect(onStepChange).toHaveBeenCalledTimes(1)
expect(onStepChange).toHaveBeenLastCalledWith(1, 'next', {
+ previousStep: { index: 0 },
preventNavigation: expect.any(Function),
})
})
@@ -1779,7 +2065,6 @@ describe('Wizard.Container', () => {
fooStep3: undefined,
})
- await wait(10)
await userEvent.type(document.querySelector('input'), ' changed')
expect(onChange).toHaveBeenCalledTimes(8)
@@ -2061,5 +2346,62 @@ describe('Wizard.Container', () => {
)
expect(document.querySelector('input')).toHaveValue('1234')
})
+
+ it('should set defaultValue of Iterate.Array only once between step changes', async () => {
+ const onChange = jest.fn()
+ const onStepChange = jest.fn()
+
+ render(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+ expect(document.querySelectorAll('input')).toHaveLength(1)
+ expect(document.querySelector('input')).toHaveValue('123')
+
+ await userEvent.type(document.querySelector('input'), '4')
+
+ expect(document.querySelector('input')).toHaveValue('1234')
+
+ const pushButton = document.querySelector(
+ '.dnb-forms-iterate-push-button'
+ )
+ await userEvent.click(pushButton)
+
+ expect(document.querySelectorAll('input')).toHaveLength(2)
+
+ await userEvent.click(nextButton())
+
+ expect(onStepChange).toHaveBeenLastCalledWith(
+ 1,
+ 'next',
+ expect.anything()
+ )
+
+ await userEvent.click(previousButton())
+
+ expect(document.querySelectorAll('input')).toHaveLength(2)
+ expect(onStepChange).toHaveBeenLastCalledWith(
+ 0,
+ 'previous',
+ expect.anything()
+ )
+ expect(document.querySelector('input')).toHaveValue('1234')
+ })
})
})
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useHandleLayoutEffect.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useHandleLayoutEffect.tsx
index bdca8565a81..59529150359 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useHandleLayoutEffect.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useHandleLayoutEffect.tsx
@@ -1,10 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
-import useStepAnimation from './useStepAnimation'
-export default function useHandleLayoutEffect({
- activeIndexRef,
- stepElementRef,
-}) {
+export default function useHandleLayoutEffect({ stepElementRef }) {
const isInteractionRef = useRef(false)
useEffect(() => {
@@ -16,8 +12,6 @@ export default function useHandleLayoutEffect({
return () => clearTimeout(timeout)
})
- useStepAnimation({ activeIndexRef, stepElementRef })
-
const action = useCallback((fn: () => void) => {
// Wait for the next render cycle
window.requestAnimationFrame(() =>
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useStepAnimation.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useStepAnimation.tsx
index d9cc274903e..455fc4ee4f4 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useStepAnimation.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/useStepAnimation.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef } from 'react'
+import React, { useCallback, useEffect, useRef } from 'react'
// SSR warning fix: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
const useLayoutEffect =
@@ -7,6 +7,7 @@ const useLayoutEffect =
export default function useStepAnimation({
activeIndexRef,
stepElementRef,
+ executeLayoutAnimationRef,
}) {
const activeIndex = activeIndexRef.current
const indexRef = useRef(activeIndex)
@@ -15,13 +16,7 @@ export default function useStepAnimation({
indexRef.current = activeIndex
}, [activeIndex])
- useLayoutEffect(() => {
- // Use layout effect to compare the active step before we update the cached indexRef
- // This way we don't have an animation on the first render, but only on a step change.
- if (activeIndex === indexRef.current) {
- return // stop here
- }
-
+ const executeLayoutAnimation = useCallback(() => {
// Wait until "stepElementRef.current = currentElementRef.current" is set
// So we actually get the correct elements when useStep is called,
// as it rerenders the children
@@ -39,5 +34,16 @@ export default function useStepAnimation({
//
}
})
- }, [activeIndex, stepElementRef])
+ }, [stepElementRef])
+ executeLayoutAnimationRef.current = executeLayoutAnimation
+
+ useLayoutEffect(() => {
+ // Use layout effect to compare the active step before we update the cached indexRef
+ // This way we don't have an animation on the first render, but only on a step change.
+ if (activeIndex === indexRef.current) {
+ return // stop here
+ }
+
+ executeLayoutAnimation()
+ }, [activeIndex, executeLayoutAnimation])
}
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts b/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts
index d32d0c5422c..c63e6582732 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Context/WizardContext.ts
@@ -2,16 +2,26 @@ import React from 'react'
import { EventReturnWithStateObject } from '../../types'
import { VisibleWhen } from '../../Form/Visibility'
+export type OnStepsChangeMode = 'previous' | 'next' | 'stepListModified'
+export type OnStepChangeOptions = {
+ preventNavigation: (shouldPrevent?: boolean) => void
+ id?: string
+ previousStep: {
+ index: StepIndex
+ id?: string
+ }
+}
export type OnStepChange = (
index: StepIndex,
- mode: 'previous' | 'next',
- options: { preventNavigation: (shouldPrevent?: boolean) => void }
+ mode: OnStepsChangeMode,
+ options: OnStepChangeOptions
) =>
| EventReturnWithStateObject
| void
| Promise
export type StepIndex = number
+export type Steps = Record
export type SetActiveIndexOptions = {
skipStepChangeCall?: boolean
skipStepChangeCallBeforeMounted?: boolean
@@ -23,7 +33,7 @@ export interface WizardContextState {
totalSteps?: number
activeIndex?: StepIndex
stepElementRef?: React.MutableRefObject
- titlesRef?: React.MutableRefObject>
+ stepsRef?: React.MutableRefObject
updateTitlesRef?: React.MutableRefObject<() => void>
activeIndexRef?: React.MutableRefObject
totalStepsRef?: React.MutableRefObject
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/Step/Step.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/Step/Step.tsx
index da8ce39c1fc..27b0a7dcc57 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Step/Step.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Step/Step.tsx
@@ -59,15 +59,15 @@ function Step(props: Props): JSX.Element {
children,
...restProps
} = props
- const { check, activeIndex, titlesRef, stepElementRef } =
+ const { check, activeIndex, stepsRef, stepElementRef } =
useContext(WizardContext) || {}
const ariaLabel = useMemo(() => {
return (
- (!prerenderFieldProps && titlesRef?.current?.[index]) ??
+ (!prerenderFieldProps && stepsRef?.current?.[index]) ??
convertJsxToString(title)
)
- }, [index, prerenderFieldProps, title, titlesRef])
+ }, [index, prerenderFieldProps, title, stepsRef])
const currentElementRef = useRef()
useLayoutEffect(() => {
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/hooks/useStep.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/hooks/useStep.tsx
index fb6c561c449..e0e94811670 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/hooks/useStep.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/hooks/useStep.tsx
@@ -41,19 +41,19 @@ export default function useStep(
}
const value = data || context
- const { titlesRef } = value || {}
+ const { stepsRef } = value || {}
const setTotalSteps = useCallback(() => {
- const totalSteps = Object.keys(titlesRef?.current || {}).length || 0
+ const totalSteps = Object.keys(stepsRef?.current || {}).length || 0
if (value.totalSteps !== totalSteps) {
value.totalSteps = totalSteps
}
- }, [titlesRef, value])
+ }, [stepsRef, value])
if (data) {
setTotalSteps()
}
useLayoutEffect(() => {
setTotalSteps()
- }, [setTotalSteps, titlesRef, value])
+ }, [setTotalSteps, stepsRef, value])
return value
}
diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/stories/Wizard.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Wizard/stories/Wizard.stories.tsx
index 3c60be60cd2..73f9c1cb82c 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Wizard/stories/Wizard.stories.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/stories/Wizard.stories.tsx
@@ -41,122 +41,87 @@ export const Basic = () => {
)
}
-const Step1 = () => {
- const { data } = Form.useData()
+export const WizardDynamicStepsActiveWhen = () => {
+ const { createSnapshot, revertSnapshot } = Form.useSnapshot('my-form')
return (
- {
- return value === '1' || value === undefined
- },
- }}
- >
- Heading Step 1
+
+ {
+ console.log(
+ 'onStepChange',
+ index,
+ mode,
+ args.id,
+ args.previousStep
+ )
-
+ if (mode === 'previous') {
+ revertSnapshot(args.id, 'my-snapshot-slice')
+ } else {
+ createSnapshot(args.previousStep.id, 'my-snapshot-slice')
+ }
+ }}
+ >
+
+ Step A
-
- Contents
- Contents
-
-
- Contents
- Contents
-
-
-
- )
-}
-const Step2 = () => {
- const { data } = Form.useData()
- return (
-
- Heading Step 2
-
- Contents
- Contents
-
-
-
- )
-}
-const Step3 = () => {
- const { data } = Form.useData()
- const { summaryTitle } = Form.useLocale().Step
+
+
+
- return (
-
- Summary
-
- Contents
- Contents
- Contents
-
-
-
- )
-}
+
+
+
-const initialData = {
- step1: true,
- step2: true,
- step3: true,
-}
+
+ Step B
+
+
+
+
-export const WizardDynamicSteps = () => {
- return (
-
-
-
-
-
- {/* {data?.step1 && }
- {data?.step2 && }
- {data?.step3 && } */}
+
+ ['group-a', 'group-b'].includes(value),
+ }}
+ id="step-c"
+ >
+ Step C
+
+
+
+
+
+ Step D
+
+
+
-
- )
-}
-export const WizardDynamicStepsActiveWhen = () => {
- return (
-
console.log('onChange', value)}
+ optionsLayout="horizontal"
+ top
>
-
-
-
+
+
-
-
-
-
-
-
)
}
diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts
index 39fe56ad24c..8897a3a8a4c 100644
--- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts
@@ -117,21 +117,29 @@ export default {
},
NationalIdentityNumber: {
label: 'National identity number (11 digits)',
- errorRequired:
- 'Invalid national identity number. Enter a valid 11-digit number.',
+ errorRequired: 'You must enter a national identity number.',
errorFnr: 'Invalid national identity number.',
+ errorFnrLength:
+ 'Invalid national identity number. Enter a valid national identity number with 11 digits.',
errorDnr: 'Invalid D number.',
+ errorDnrLength:
+ 'Invalid D number. Enter a valid d-number with 11 digits.',
+ errorMinimumAgeValidator: 'Must be at least {age} years of age.',
+ errorMinimumAgeValidatorLength:
+ 'Invalid birth of date. Enter a valid birth of date (incl. century digit) with 7 digits.',
},
OrganizationNumber: {
label: 'Organisation number',
errorRequired: 'You must enter an organisation number.',
- errorPattern: 'This is not a valid organisation number.',
+ errorOrgNo: 'Invalid organisation number.',
+ errorOrgNoLength:
+ 'Invalid organisation number. Enter a valid organisation number with 9 digits.',
},
BankAccountNumber: {
label: 'Bank account',
errorRequired:
'Enter a valid account number. Account number is mandatory to fill out.',
- errorPattern: 'This is not a valid bank account number.',
+ errorPattern: 'Invalid bank account number.',
},
PhoneNumber: {
label: 'Mobile number',
diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-US.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-US.ts
index a47155dc065..66693af762f 100644
--- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-US.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-US.ts
@@ -8,7 +8,9 @@ export default {
OrganizationNumber: {
label: 'Organization number',
errorRequired: 'You must enter an organization number.',
- errorPattern: 'This is not a valid organization number.',
+ errorOrgNo: 'Invalid organization number.',
+ errorOrgNoLength:
+ 'Invalid organization number. Enter a valid organization number with 9 digits.',
},
},
}
diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts
index 0b569aa9381..8ce5c3920ec 100644
--- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts
@@ -115,21 +115,29 @@ export default {
},
NationalIdentityNumber: {
label: 'Fødselsnummer (11 siffer)',
- errorRequired:
- 'Ugyldig fødselsnummer. Skriv inn et gyldig fødselsnummer med 11 siffer.',
+ errorRequired: 'Du må fylle inn et fødselsnummer.',
errorFnr: 'Ugyldig fødselsnummer.',
+ errorFnrLength:
+ 'Ugyldig fødselsnummer. Skriv inn et gyldig fødselsnummer med 11 siffer.',
errorDnr: 'Ugyldig d-nummer.',
+ errorDnrLength:
+ 'Ugyldig d-nummer. Skriv inn et gyldig d-nummer med 11 siffer.',
+ errorMinimumAgeValidator: 'Må være minst {age} år.',
+ errorMinimumAgeValidatorLength:
+ 'Ugyldig fødselsdato. Skriv inn en gyldig fødselsdato (inkl. århundresiffer) med 7 siffer.',
},
OrganizationNumber: {
label: 'Organisasjonsnummer',
errorRequired: 'Du må fylle inn et organisasjonsnummer.',
- errorPattern: 'Dette er ikke et gyldig organisasjonsnummer.',
+ errorOrgNo: 'Ugyldig organisasjonsnummer.',
+ errorOrgNoLength:
+ 'Ugyldig organisasjonsnummer. Skriv inn et gyldig organisasjonsnummer med 9 siffer.',
},
BankAccountNumber: {
label: 'Bankkonto',
errorRequired:
'Skriv inn et gyldig kontonummer. Kontonummeret må fylles ut.',
- errorPattern: 'Dette er ikke et gyldig kontonummer.',
+ errorPattern: 'Ugyldig kontonummer.',
},
PhoneNumber: {
label: 'Mobilnummer',
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx
index c190ad15af4..26e8e3e66bb 100644
--- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx
@@ -2938,6 +2938,54 @@ describe('useFieldProps', () => {
expect(result.current.error).toBeInstanceOf(Error)
})
+ it('should set emptyValue when handleChange gets undefined', async () => {
+ const onSubmit = jest.fn(() => null)
+
+ const first = {}
+ const { result } = renderHook(useFieldProps, {
+ initialProps: {
+ path: '/foo',
+ emptyValue: first,
+ },
+ wrapper: (props) => ,
+ })
+
+ const form = document.querySelector('form')
+
+ fireEvent.submit(form)
+ expect(onSubmit).toHaveBeenCalledTimes(1)
+ expect(onSubmit).toHaveBeenLastCalledWith(
+ { foo: first },
+ expect.anything()
+ )
+ expect(result.current.value).toBe(first)
+
+ const second = {}
+ act(() => {
+ result.current.handleChange(second)
+ })
+
+ fireEvent.submit(form)
+ expect(onSubmit).toHaveBeenCalledTimes(2)
+ expect(onSubmit).toHaveBeenLastCalledWith(
+ { foo: second },
+ expect.anything()
+ )
+ expect(result.current.value).toBe(second)
+
+ act(() => {
+ result.current.handleChange(undefined)
+ })
+
+ fireEvent.submit(form)
+ expect(onSubmit).toHaveBeenCalledTimes(3)
+ expect(onSubmit).toHaveBeenLastCalledWith(
+ { foo: first },
+ expect.anything()
+ )
+ expect(result.current.value).toBe(first)
+ })
+
it('should call async context onChange regardless of error when executeOnChangeRegardlessOfError is true', async () => {
const onChange = jest.fn(async () => null)
@@ -2968,6 +3016,46 @@ describe('useFieldProps', () => {
})
describe('validator', () => {
+ describe('validateInitially', () => {
+ it('should show error message initially', async () => {
+ const validator = jest.fn(() => {
+ return new Error('My Error')
+ })
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).toBeInTheDocument()
+ })
+ expect(validator).toHaveBeenCalledTimes(1)
+ })
+
+ it('should show error message initially when validator is async', async () => {
+ const validator = jest.fn(async () => {
+ return new Error('My Error')
+ })
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).toBeInTheDocument()
+ })
+ expect(validator).toHaveBeenCalledTimes(1)
+ })
+ })
+
describe('connectWithPath', () => {
const validatorFn: UseFieldProps['validator'] = (
num,
@@ -3332,7 +3420,7 @@ describe('useFieldProps', () => {
})
})
- describe('exportValidators', () => {
+ describe('validators given as an array', () => {
it('should call all validators returned as an array', async () => {
const fooValidator = jest.fn((value) => {
if (value.includes('foo')) {
@@ -3377,7 +3465,7 @@ describe('useFieldProps', () => {
expect(screen.queryByRole('alert')).toHaveTextContent('bar')
})
expect(myValidator).toHaveBeenCalledTimes(5)
- expect(fooValidator).toHaveBeenCalledTimes(4)
+ expect(fooValidator).toHaveBeenCalledTimes(5)
expect(barValidator).toHaveBeenCalledTimes(4)
})
@@ -3427,10 +3515,12 @@ describe('useFieldProps', () => {
expect(screen.queryByRole('alert')).toHaveTextContent('bar')
})
expect(myValidator).toHaveBeenCalledTimes(5)
- expect(fooValidator).toHaveBeenCalledTimes(4)
+ expect(fooValidator).toHaveBeenCalledTimes(5)
expect(barValidator).toHaveBeenCalledTimes(4)
})
+ })
+ describe('exportValidators', () => {
it('should call exported validators from mock component', async () => {
let internalValidators, fooValidator, barValidator, bazValidator
@@ -3525,7 +3615,7 @@ describe('useFieldProps', () => {
})
expect(publicValidator).toHaveBeenCalledTimes(9)
expect(fooValidator).toHaveBeenCalledTimes(1)
- expect(barValidator).toHaveBeenCalledTimes(7)
+ expect(barValidator).toHaveBeenCalledTimes(8)
expect(bazValidator).toHaveBeenCalledTimes(7)
expect(internalValidators).toHaveBeenCalledTimes(0)
})
@@ -3549,13 +3639,123 @@ describe('useFieldProps', () => {
await userEvent.type(input, '123')
fireEvent.blur(input)
+ expect(onBlurValidator).toHaveBeenCalledTimes(1)
+ expect(document.querySelector('.dnb-form-status')).toBeNull()
+
+ await userEvent.type(input, '4')
+ fireEvent.blur(input)
+
expect(onBlurValidator).toHaveBeenCalledTimes(2)
+ await waitFor(() => {
+ expect(
+ document.querySelector('.dnb-form-status')
+ ).toHaveTextContent('Error message')
+ })
+ })
+
+ it('should show error on every value change', async () => {
+ const exportedValidator = jest.fn((value) => {
+ if (value === '1234') {
+ return Error('Error message')
+ }
+ })
+
+ const myValidator = jest.fn((value, { validators }) => {
+ const { exportedValidator } = validators
+
+ return [exportedValidator]
+ })
+
+ const MockComponent = (props) => {
+ return (
+
+ )
+ }
+
+ render( )
+
+ const input = document.querySelector('input')
+
+ await userEvent.type(input, '123')
+ fireEvent.blur(input)
+
+ expect(document.querySelector('.dnb-form-status')).toBeNull()
+
+ await userEvent.type(input, '4')
+ fireEvent.blur(input)
+
+ expect(exportedValidator).toHaveBeenCalledTimes(2)
+ expect(myValidator).toHaveBeenCalledTimes(2)
+ await waitFor(() => {
+ expect(
+ document.querySelector('.dnb-form-status')
+ ).toHaveTextContent('Error message')
+ })
+
+ await userEvent.type(input, '{Backspace}4')
+ fireEvent.blur(input)
+
+ expect(exportedValidator).toHaveBeenCalledTimes(3)
+ expect(myValidator).toHaveBeenCalledTimes(3)
+ await waitFor(() => {
+ expect(
+ document.querySelector('.dnb-form-status')
+ ).toHaveTextContent('Error message')
+ })
+ })
+
+ it('should support mixed sync and async validators', async () => {
+ const exportedValidator = jest.fn(async (value) => {
+ if (value === '1234') {
+ return Error('Error message')
+ }
+ })
+
+ const myValidator = jest.fn((value, { validators }) => {
+ const { exportedValidator } = validators
+
+ return [exportedValidator]
+ })
+
+ const MockComponent = (props) => {
+ return (
+
+ )
+ }
+
+ render( )
+
+ const input = document.querySelector('input')
+
+ await userEvent.type(input, '123')
+ fireEvent.blur(input)
+
expect(document.querySelector('.dnb-form-status')).toBeNull()
await userEvent.type(input, '4')
fireEvent.blur(input)
- expect(onBlurValidator).toHaveBeenCalledTimes(3)
+ expect(exportedValidator).toHaveBeenCalledTimes(1)
+ expect(myValidator).toHaveBeenCalledTimes(4)
+ await waitFor(() => {
+ expect(
+ document.querySelector('.dnb-form-status')
+ ).toHaveTextContent('Error message')
+ })
+
+ await userEvent.type(input, '{Backspace}4')
+ fireEvent.blur(input)
+
+ expect(exportedValidator).toHaveBeenCalledTimes(2)
+ expect(myValidator).toHaveBeenCalledTimes(6)
await waitFor(() => {
expect(
document.querySelector('.dnb-form-status')
@@ -3663,7 +3863,66 @@ describe('useFieldProps', () => {
expect(internalValidators).toHaveBeenCalledTimes(0)
})
- it('should call internal validates when they are not returned in the publicValidator', async () => {
+ it('should show error when validateInitially is set to true', async () => {
+ const exportedValidator = jest.fn(() => {
+ return Error('Error message')
+ })
+
+ const myValidator = jest.fn((value, { validators }) => {
+ const { exportedValidator } = validators
+ return [exportedValidator]
+ })
+
+ const MockComponent = (props) => {
+ return (
+
+ )
+ }
+
+ render( )
+
+ await waitFor(() => {
+ expect(
+ document.querySelector('.dnb-form-status')
+ ).toBeInTheDocument()
+ })
+ })
+
+ it('should not run exported internal validators when a validator is given', async () => {
+ const exportedValidator = jest.fn(() => {
+ return undefined
+ })
+
+ const myValidator = jest.fn(() => {
+ return undefined
+ })
+
+ const MockComponent = (props) => {
+ return (
+
+ )
+ }
+
+ render( )
+
+ await expect(() => {
+ expect(
+ document.querySelector('.dnb-form-status')
+ ).toBeInTheDocument()
+ }).toNeverResolve()
+ })
+
+ it('should not call internal validators when they are not returned in the publicValidator', async () => {
let internalValidators, barValidator, bazValidator
const MockComponent = (props) => {
@@ -3731,12 +3990,12 @@ describe('useFieldProps', () => {
document.querySelector('input'),
'{Backspace}bar'
)
- await waitFor(() => {
- expect(screen.queryByRole('alert')).toHaveTextContent('bar')
- })
+ await expect(() => {
+ expect(screen.queryByRole('alert')).toBeInTheDocument()
+ }).toNeverResolve()
expect(publicValidator).toHaveBeenCalledTimes(5)
- expect(barValidator).toHaveBeenCalledTimes(4)
- expect(bazValidator).toHaveBeenCalledTimes(3)
+ expect(barValidator).toHaveBeenCalledTimes(0)
+ expect(bazValidator).toHaveBeenCalledTimes(0)
expect(internalValidators).toHaveBeenCalledTimes(0)
await userEvent.type(
@@ -3744,18 +4003,61 @@ describe('useFieldProps', () => {
'{Backspace}baz'
)
- await waitFor(() => {
- expect(screen.queryByRole('alert')).toHaveTextContent('baz')
- })
+ await expect(() => {
+ expect(screen.queryByRole('alert')).toBeInTheDocument()
+ }).toNeverResolve()
expect(publicValidator).toHaveBeenCalledTimes(9)
- expect(barValidator).toHaveBeenCalledTimes(7)
- expect(bazValidator).toHaveBeenCalledTimes(7)
+ expect(barValidator).toHaveBeenCalledTimes(0)
+ expect(bazValidator).toHaveBeenCalledTimes(0)
expect(internalValidators).toHaveBeenCalledTimes(0)
})
})
})
describe('onBlurValidator', () => {
+ describe('validateInitially', () => {
+ it('should show error message initially', async () => {
+ const onBlurValidator = jest.fn(() => {
+ return new Error('My Error')
+ })
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).toBeInTheDocument()
+ })
+ expect(onBlurValidator).toHaveBeenCalledTimes(1)
+ })
+
+ it('should show error message initially when validator is async', async () => {
+ const onBlurValidator = jest.fn(async () => {
+ return new Error('My Error')
+ })
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(screen.queryByRole('alert')).toBeInTheDocument()
+ })
+ expect(onBlurValidator).toHaveBeenCalledTimes(1)
+ })
+ })
+
describe('connectWithPath', () => {
const validatorFn: UseFieldProps['validator'] = (
num,
@@ -4152,6 +4454,35 @@ describe('useFieldProps', () => {
expect(internalValidators).toHaveBeenCalledTimes(0)
})
})
+
+ it('should show error when validateInitially is set to true', async () => {
+ const exportedValidator = jest.fn(() => {
+ return Error('Error message')
+ })
+
+ const myValidator = jest.fn((value, { validators }) => {
+ const { exportedValidator } = validators
+ return [exportedValidator]
+ })
+
+ const MockComponent = (props) => {
+ return (
+
+ )
+ }
+
+ render( )
+
+ await waitFor(() => {
+ expect(
+ document.querySelector('.dnb-form-status')
+ ).toBeInTheDocument()
+ })
+ })
})
describe('setMountedFieldState', () => {
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.tsx
new file mode 100644
index 00000000000..4fc6bfa24ca
--- /dev/null
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useSnapshot.test.tsx
@@ -0,0 +1,139 @@
+import React from 'react'
+import { renderHook, act } from '@testing-library/react'
+import useSnapshot from '../useSnapshot'
+import { SnapshotId } from '../../Form/Snapshot'
+import { Context, ContextState, Provider } from '../../DataContext'
+
+describe('Form.useSnapshot', () => {
+ it('creates a snapshot and retrieves it correctly', () => {
+ const { result } = renderHook(useSnapshot, {
+ wrapper: (props) => ,
+ })
+ let snapshotId: SnapshotId
+
+ act(() => {
+ snapshotId = result.current.createSnapshot()
+ })
+
+ expect(result.current.createSnapshot).toBeDefined()
+ expect(result.current.revertSnapshot).toBeDefined()
+ expect(result.current.applySnapshot).toBeDefined()
+ expect(result.current.internalSnapshotsRef).toBeDefined()
+ expect(snapshotId).toBeTruthy()
+ })
+
+ it('reverts to a snapshot', () => {
+ let contextData: ContextState
+
+ const { result } = renderHook(useSnapshot, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+
+ {(context) => {
+ contextData = context
+ return null
+ }}
+
+
+ ),
+ })
+
+ act(() => {
+ const snapshotId = result.current.createSnapshot(
+ undefined,
+ undefined,
+ {
+ foo: 'bar',
+ }
+ )
+ result.current.revertSnapshot(snapshotId)
+ })
+
+ expect(contextData.data).toEqual({ foo: 'bar' })
+ })
+
+ it('applies a snapshot without deleting it', () => {
+ let snapshotId: SnapshotId
+ let contextData: ContextState
+
+ const { result } = renderHook(useSnapshot, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+
+ {(context) => {
+ contextData = context
+ return null
+ }}
+
+
+ ),
+ })
+
+ act(() => {
+ snapshotId = result.current.createSnapshot(undefined, undefined, {
+ foo: 'bar',
+ })
+ result.current.applySnapshot(snapshotId)
+ })
+
+ expect(contextData.data).toEqual({ foo: 'bar' })
+
+ act(() => {
+ result.current.applySnapshot(snapshotId)
+ })
+
+ expect(contextData.data).toEqual({ foo: 'bar' })
+ })
+
+ it('reverts and deletes the snapshot', () => {
+ let snapshotId: SnapshotId
+ let contextData: ContextState
+
+ const { result } = renderHook(useSnapshot, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+
+ {(context) => {
+ contextData = context
+ return null
+ }}
+
+
+ ),
+ })
+
+ act(() => {
+ snapshotId = result.current.createSnapshot(undefined, undefined, {
+ foo: 'bar',
+ })
+ })
+
+ expect(
+ Array.from(result.current.internalSnapshotsRef.current)
+ ).toHaveLength(1)
+
+ act(() => {
+ result.current.revertSnapshot(snapshotId)
+ })
+
+ expect(
+ Array.from(result.current.internalSnapshotsRef.current)
+ ).toHaveLength(0)
+ expect(contextData.data).toEqual({ foo: 'bar' })
+
+ act(() => {
+ result.current.revertSnapshot(snapshotId)
+ })
+
+ expect(
+ Array.from(result.current.internalSnapshotsRef.current)
+ ).toHaveLength(0)
+ expect(contextData.data).toEqual({ foo: 'bar' })
+ })
+})
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useValueProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useValueProps.test.tsx
index b4a69b54cfc..b93b31f2224 100644
--- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useValueProps.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useValueProps.test.tsx
@@ -663,4 +663,99 @@ describe('useValueProps', () => {
}
})
})
+
+ describe('transformLabel', () => {
+ it('should transform label', () => {
+ const transformLabel = jest.fn((label) => label.toUpperCase())
+ render(
+
+
+
+ )
+ expect(transformLabel).toHaveBeenCalledTimes(1)
+ expect(transformLabel).toHaveBeenLastCalledWith(
+ 'The label',
+ expect.anything()
+ )
+ expect(
+ document.querySelector('.dnb-forms-value-string')
+ ).toHaveTextContent('THE LABEL')
+ })
+
+ it('should transform a JSX label and return "convertJsxToString"', () => {
+ const transformLabel = jest.fn((label, { convertJsxToString }) =>
+ convertJsxToString(label).toUpperCase()
+ )
+ render(
+
+ The label}
+ transformLabel={transformLabel}
+ showEmpty
+ />
+
+ )
+ expect(transformLabel).toHaveBeenCalledTimes(1)
+ expect(transformLabel).toHaveBeenLastCalledWith(
+ The label ,
+ expect.anything()
+ )
+ expect(
+ document.querySelector('.dnb-forms-value-string')
+ ).toHaveTextContent('THE LABEL')
+ })
+
+ it('should transform label using inheritLabel', () => {
+ render(
+
+
+ label.toUpperCase()}
+ showEmpty
+ />
+
+ )
+ expect(
+ document.querySelector('.dnb-forms-field-string')
+ ).toHaveTextContent('The label')
+ expect(
+ document.querySelector('.dnb-forms-value-string')
+ ).toHaveTextContent('THE LABEL')
+ })
+
+ it('should transform label using Value.Provider', () => {
+ const transformLabel = jest.fn((label) => label.toUpperCase())
+ render(
+
+
+
+
+
+
+
+
+ )
+
+ const [first, second] = Array.from(document.querySelectorAll('dt'))
+ expect(first).toHaveTextContent('THE LABEL A')
+ expect(second).toHaveTextContent('THE LABEL B')
+ expect(transformLabel).toHaveBeenCalledTimes(2)
+ expect(transformLabel).toHaveBeenNthCalledWith(
+ 1,
+ 'The label A',
+ expect.anything()
+ )
+ expect(transformLabel).toHaveBeenNthCalledWith(
+ 2,
+ 'The label B',
+ expect.anything()
+ )
+ })
+ })
})
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx
new file mode 100644
index 00000000000..a8116150377
--- /dev/null
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useDataContext.tsx
@@ -0,0 +1,33 @@
+import { useCallback, useContext } from 'react'
+import {
+ SharedStateId,
+ useSharedState,
+} from '../../../shared/helpers/useSharedState'
+import DataContext, { ContextState } from '../DataContext/Context'
+
+export default function useDataContext(id: SharedStateId = undefined): {
+ dataContext?: ContextState
+ getContext: () => ContextState
+} {
+ const sharedDataContext = useSharedState(
+ id + '-data-context'
+ )
+
+ const dataContext = useContext(DataContext)
+ const getContext = useCallback(() => {
+ // If no id is provided, use the context data
+ if (!id) {
+ if (!dataContext.hasContext) {
+ throw new Error(
+ 'useDataContext needs to run inside DataContext (Form.Handler) or have a valid id'
+ )
+ } else {
+ return dataContext
+ }
+ }
+
+ return sharedDataContext.get()
+ }, [dataContext, id, sharedDataContext])
+
+ return { getContext, dataContext }
+}
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts
index e2c22c03060..0adc11d2de6 100644
--- a/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useExternalValue.ts
@@ -30,27 +30,29 @@ export default function useExternalValue(props: Props) {
return useMemo(() => {
if (value !== emptyValue) {
// Value-prop sent directly to the field has highest priority, overriding any surrounding source
- return transformers?.current?.fromExternal?.(value)
+ return transformers?.current?.fromExternal?.(value) ?? emptyValue
}
if (inIterate && itemPath) {
// This field is inside an iterate, and has a pointer from the base of the element being iterated
if (itemPath === '/') {
- return iterateElementValue
+ return iterateElementValue ?? emptyValue
}
- return pointer.has(iterateElementValue, itemPath)
- ? pointer.get(iterateElementValue, itemPath)
- : emptyValue
+ if (pointer.has(iterateElementValue, itemPath)) {
+ return pointer.get(iterateElementValue, itemPath) ?? emptyValue
+ }
}
if (data && path) {
// There is a surrounding data context and a path for where in the source to find the data
if (path === '/') {
- return data
+ return data ?? emptyValue
}
- return pointer.has(data, path) ? pointer.get(data, path) : emptyValue
+ if (pointer.has(data, path)) {
+ return pointer.get(data, path) ?? emptyValue
+ }
}
return emptyValue
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts
index 8fe2c444c22..7c4b0e24198 100644
--- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts
@@ -25,7 +25,7 @@ import {
import { Context as DataContext, ContextState } from '../DataContext'
import { clearedData } from '../DataContext/Provider/Provider'
import FieldProviderContext from '../Field/Provider/FieldProviderContext'
-import { combineDescribedBy, warn } from '../../../shared/component-helper'
+import { combineDescribedBy } from '../../../shared/component-helper'
import useId from '../../../shared/helpers/useId'
import useUpdateEffect from '../../../shared/helpers/useUpdateEffect'
import FieldBlockContext from '../FieldBlock/FieldBlockContext'
@@ -34,6 +34,7 @@ import SectionContext from '../Form/Section/SectionContext'
import FieldBoundaryContext from '../DataContext/FieldBoundary/FieldBoundaryContext'
import VisibilityContext from '../Form/Visibility/VisibilityContext'
import WizardContext from '../Wizard/Context'
+import SnapshotContext from '../Form/Snapshot/SnapshotContext'
import useProcessManager from './useProcessManager'
import usePath from './usePath'
import {
@@ -153,6 +154,8 @@ export default function useFieldProps(
const sectionContext = useContext(SectionContext)
const fieldBoundaryContext = useContext(FieldBoundaryContext)
const wizardContext = useContext(WizardContext)
+ const { setMountedField: setMountedFieldSnapshot } =
+ useContext(SnapshotContext) || {}
const { isVisible } = useContext(VisibilityContext) || {}
const translation = useTranslation()
@@ -194,8 +197,11 @@ export default function useFieldProps(
showFieldError: showFieldErrorFieldBlock,
mountedFieldsRef: mountedFieldsRefFieldBlock,
} = fieldBlockContext || {}
- const { handleChange: handleChangeIterateContext } =
- iterateItemContext || {}
+ const {
+ handleChange: handleChangeIterateContext,
+ index: iterateIndex,
+ arrayValue: iterateArrayValue,
+ } = iterateItemContext || {}
const { path: sectionPath, errorPrioritization } = sectionContext || {}
const {
setFieldError: setFieldErrorBoundary,
@@ -204,6 +210,7 @@ export default function useFieldProps(
} = fieldBoundaryContext || {}
const hasPath = Boolean(pathProp)
+ const hasItemPath = Boolean(itemPath)
const { path, identifier, makeIteratePath } = usePath({
id,
path: pathProp,
@@ -529,24 +536,6 @@ export default function useFieldProps(
return args
}, [contextErrorMessages, getValueByPath, setFieldEventListener])
- const extendWithExportedValidators = useCallback(
- (
- validator: Validator,
- result: ReturnType>
- ) => {
- if (
- exportValidatorsRef.current &&
- !result &&
- (validator === onChangeValidatorRef.current ||
- validator === onBlurValidatorRef.current)
- ) {
- return Object.values(exportValidatorsRef.current)
- }
-
- return result
- },
- []
- )
const callStackRef = useRef>>([])
const hasBeenCalledRef = useCallback((validator: Validator) => {
@@ -555,25 +544,23 @@ export default function useFieldProps(
return result
}, [])
- const callValidatorFnSync = useCallback(
- (
+ const callValidatorFnAsync = useCallback(
+ async (
validator: Validator,
value: Value = valueRef.current
- ): ReturnType> => {
+ ): Promise>> => {
if (typeof validator !== 'function') {
- return // stop here
+ return
}
- const result = extendWithExportedValidators(
- validator,
- validator(value, additionalArgs)
- )
+ const result = await validator(value, additionalArgs)
if (Array.isArray(result)) {
for (const validator of result) {
if (!hasBeenCalledRef(validator)) {
- const result = callValidatorFnSync(validator, value)
+ const result = await callValidatorFnAsync(validator, value)
if (result instanceof Error) {
+ callStackRef.current = []
return result
}
}
@@ -584,28 +571,37 @@ export default function useFieldProps(
return result
}
},
- [additionalArgs, extendWithExportedValidators, hasBeenCalledRef]
+ [additionalArgs, hasBeenCalledRef]
)
- const callValidatorFnAsync = useCallback(
- async (
+ const callValidatorFnSync = useCallback(
+ (
validator: Validator,
value: Value = valueRef.current
- ): Promise>> => {
+ ): ReturnType> => {
if (typeof validator !== 'function') {
- return
+ return // stop here
}
- const result = extendWithExportedValidators(
- validator,
- await validator(value, additionalArgs)
- )
+ const result = validator(value, additionalArgs)
if (Array.isArray(result)) {
+ const hasAsyncValidator = result.some((validator) =>
+ isAsync(validator)
+ )
+ if (hasAsyncValidator) {
+ return new Promise((resolve) => {
+ callValidatorFnAsync(validator, value).then((result) => {
+ resolve(result)
+ })
+ })
+ }
+
for (const validator of result) {
if (!hasBeenCalledRef(validator)) {
- const result = await callValidatorFnAsync(validator, value)
+ const result = callValidatorFnSync(validator, value)
if (result instanceof Error) {
+ callStackRef.current = []
return result
}
}
@@ -616,7 +612,7 @@ export default function useFieldProps(
return result
}
},
- [additionalArgs, extendWithExportedValidators, hasBeenCalledRef]
+ [additionalArgs, callValidatorFnAsync, hasBeenCalledRef]
)
/**
@@ -1005,6 +1001,19 @@ export default function useFieldProps(
}
}
+ // Only for when "validateInitially" is set to true
+ if (
+ onBlurValidatorRef.current &&
+ validateInitially &&
+ !changedRef.current
+ ) {
+ const { result } = await callOnBlurValidator()
+
+ if (result instanceof Error) {
+ throw result
+ }
+ }
+
if (isProcessActive()) {
clearErrorState()
}
@@ -1029,6 +1038,7 @@ export default function useFieldProps(
validateInitially,
validateUnchanged,
startOnChangeValidatorValidation,
+ callOnBlurValidator,
persistErrorState,
])
@@ -1091,10 +1101,16 @@ export default function useFieldProps(
// Field was put in focus (like when clicking in a text field or opening a dropdown menu)
hasFocusRef.current = true
onFocus?.apply(this, args)
+ setMountedFieldStateDataContext(identifier, {
+ isFocused: true,
+ })
} else {
// Field was removed from focus (like when tabbing out of a text field or closing a dropdown menu)
hasFocusRef.current = false
onBlur?.apply(this, args)
+ setMountedFieldStateDataContext(identifier, {
+ isFocused: false,
+ })
if (!changedRef.current && !validateUnchanged) {
// Avoid showing errors when blurring without having changed the value, so tabbing through several
@@ -1118,6 +1134,8 @@ export default function useFieldProps(
[
getEventArgs,
onFocus,
+ setMountedFieldStateDataContext,
+ identifier,
onBlur,
validateUnchanged,
addToPool,
@@ -1304,10 +1322,9 @@ export default function useFieldProps(
return
}
- const transformedValue = transformers.current.transformValue(
- newValue,
- currentValue
- )
+ const transformedValue =
+ transformers.current.transformValue(newValue, currentValue) ??
+ (emptyValue as unknown as Value)
const contextValue = transformers.current.transformOut(
transformedValue,
transformers.current.provideAdditionalArgs(
@@ -1346,6 +1363,7 @@ export default function useFieldProps(
})
},
[
+ emptyValue,
additionalArgs,
hasPath,
itemPath,
@@ -1509,6 +1527,7 @@ export default function useFieldProps(
isMounted: true,
isPreMounted: true,
})
+ setMountedFieldSnapshot?.(identifier, { isMounted: true })
// Unmount procedure.
return () => {
@@ -1516,8 +1535,13 @@ export default function useFieldProps(
isMounted: false,
isPreMounted: false,
})
+ setMountedFieldSnapshot?.(identifier, { isMounted: false })
}
- }, [identifier, setMountedFieldStateDataContext])
+ }, [
+ identifier,
+ setMountedFieldSnapshot,
+ setMountedFieldStateDataContext,
+ ])
useEffect(() => {
return () => {
@@ -1541,7 +1565,7 @@ export default function useFieldProps(
? dataContext.ajvInstance?.compile(schema)
: undefined
validateValue()
- }, [schema, validateValue])
+ }, [schema])
// Use "useLayoutEffect" and "externalValueDidChangeRef"
// to cooperate with the the data context "updateDataValueDataContext" routine further down,
@@ -1552,16 +1576,16 @@ export default function useFieldProps(
valueRef.current = externalValue
externalValueDidChangeRef.current = true
}
- }, [externalValue])
+ }, [externalValue, hasItemPath])
- useEffect(() => {
+ useUpdateEffect(() => {
// Error or removed error for this field from the surrounding data context (by path)
if (externalValueDidChangeRef.current) {
externalValueDidChangeRef.current = false
validateValue()
forceUpdate()
}
- }, [externalValue, validateValue]) // Keep "externalValue" in the dependency list, so it will be updated when it changes
+ }, [externalValue]) // Keep "externalValue" in the dependency list, so it will be updated when it changes
useEffect(() => {
// Check against the local error state,
@@ -1586,29 +1610,16 @@ export default function useFieldProps(
validateInitially,
])
- useEffect(() => {
- if (itemPath && valueProp !== undefined) {
- warn(
- `Using value="${valueProp}" prop inside Iterate is not supported yet`
- )
- }
- if (itemPath && defaultValue !== undefined) {
- warn(
- `Using defaultValue="${defaultValue}" prop inside Iterate is not supported yet`
- )
- }
- }, [defaultValue, itemPath, valueProp])
-
// Use "useLayoutEffect" to avoid flickering when value/defaultValue gets set, and other fields dependent on it.
// Form.Visibility is an example of a logic, where a field value/defaultValue can be used to set the set state of a path,
// where again other fields depend on it.
const tmpTransValueRef = useRef>({})
const setContextData = useCallback(() => {
- if (!hasPath) {
+ if (!hasPath && !hasItemPath) {
return // stop here
}
- let valueToStore: Value | unknown = valueProp
+ let valueToStore: Value | unknown = valueProp ?? emptyValue
const data = wizardContext?.prerenderFieldProps
? dataContext.data
@@ -1652,6 +1663,38 @@ export default function useFieldProps(
defaultValueRef.current = undefined
}
+ let skipEqualCheck = false
+
+ if (hasItemPath) {
+ if (existingValue === valueToStore) {
+ return // stop here, don't store the same value again
+ }
+
+ if (
+ typeof valueToStore === 'undefined' &&
+ typeof existingValue !== 'undefined'
+ ) {
+ // On the rerender (after defaultValue was set) and the data context was given, but as "undefined",
+ // then we want to use the current value (the defaultValue from the previous render),
+ // because else the comparison "valueRef.current !== existingValue" is true and we will set undefined as the new data context value.
+ valueToStore = existingValue
+ }
+
+ if (itemPath === '/') {
+ // The push container uses an object as the default value for the array.
+ // But when a root slash is used, we want to make sure the field don't gets the object.
+ if (existingValue === clearedData) {
+ valueRef.current = undefined
+ }
+
+ if (hasDefaultValue && Array.isArray(existingValue)) {
+ // Ensures support to have a field with a defaultValue and a itemPath of "/"
+ // This way, we ensure the defaultValue is actually set in the data context.
+ skipEqualCheck = true
+ }
+ }
+ }
+
// Used by e.g. Iterate.Array
if (updateContextDataInSync) {
// When an array is given (iterate), we don't want to overwrite the existing array
@@ -1669,6 +1712,7 @@ export default function useFieldProps(
}
if (
+ !skipEqualCheck &&
hasValue &&
(valueToStore === existingValue ||
// Prevents an infinite loop by skipping the update if the value hasn't changed
@@ -1696,7 +1740,9 @@ export default function useFieldProps(
// When an itemPath is given, we don't want to rerender the context on every iteration because of performance reasons.
// We know when the last item is reached, so we can prevent rerenders during the iteration.
- const preventUpdate = updateContextDataInSync
+ const preventUpdate =
+ updateContextDataInSync ||
+ (hasItemPath && iterateIndex < iterateArrayValue?.length - 1)
// Update the data context when a pointer not exists,
// but was given initially.
@@ -1711,8 +1757,13 @@ export default function useFieldProps(
dataContext.data,
dataContext.id,
dataContext.internalDataRef,
+ emptyValue,
+ hasItemPath,
hasPath,
identifier,
+ itemPath,
+ iterateArrayValue?.length,
+ iterateIndex,
updateContextDataInSync,
updateDataValueDataContext,
validateDataDataContext,
@@ -1723,8 +1774,9 @@ export default function useFieldProps(
const isEmptyData = useCallback(
() => {
return (
+ dataContext.isEmptyDataRef?.current ||
dataContext.internalDataRef?.current ===
- (dataContext.props?.emptyData ?? clearedData)
+ (dataContext.props?.emptyData ?? clearedData)
)
},
@@ -1760,11 +1812,8 @@ export default function useFieldProps(
useEffect(() => {
if (isEmptyData()) {
- // Fill the data context with the default value after it has been cleared
- requestAnimationFrame(() => {
- setContextData()
- validateValue()
- })
+ setContextData()
+ validateValue()
}
}, [isEmptyData, setContextData, validateValue])
@@ -1972,10 +2021,6 @@ export default function useFieldProps(
/** Documented APIs */
id,
- // value: valueRef.current,
- // value: transformers.current.transformIn(
- // transformers.current.toInput(valueRef.current)
- // ),
value: transformers.current.toInput(valueRef.current),
hasError: hasVisibleError,
isChanged: Boolean(changedRef.current),
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx
new file mode 100644
index 00000000000..62da13025d8
--- /dev/null
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useSnapshot.tsx
@@ -0,0 +1,101 @@
+import { useCallback, useRef } from 'react'
+import { makeUniqueId } from '../../../shared/component-helper'
+import pointer from '../utils/json-pointer'
+import { SharedStateId } from '../../../shared/helpers/useSharedState'
+import useDataContext from './useDataContext'
+import { SnapshotId, SnapshotName } from '../Form/Snapshot'
+import useData from '../Form/data-context/useData'
+
+export default function useSnapshot(id?: SharedStateId) {
+ const internalSnapshotsRef = useRef>()
+ if (!internalSnapshotsRef.current) {
+ internalSnapshotsRef.current = new Map()
+ }
+
+ const { getContext } = useDataContext(id)
+ const { set: setData, update: updateData } = useData(id)
+
+ const createSnapshot = useCallback(
+ (
+ id: SnapshotId = makeUniqueId(),
+ name: SnapshotName = null,
+ content: unknown = null
+ ): SnapshotId => {
+ const { internalDataRef, snapshotsRef } = getContext()
+
+ if (!content) {
+ const snapshotWithPaths = snapshotsRef?.current?.get?.(name)
+ if (snapshotWithPaths) {
+ const collectedData = new Map()
+ snapshotWithPaths.forEach((isMounted, path) => {
+ if (isMounted && pointer.has(internalDataRef.current, path)) {
+ collectedData.set(
+ path,
+ pointer.get(internalDataRef.current, path)
+ )
+ }
+ })
+ content = collectedData
+ } else {
+ content = internalDataRef.current
+ }
+ }
+
+ internalSnapshotsRef.current.set(
+ combineIdWithName(id, name),
+ content
+ )
+
+ return id
+ },
+ [getContext]
+ )
+
+ const getSnapshot = useCallback(
+ (id: SnapshotId, name: SnapshotName = null): unknown => {
+ return internalSnapshotsRef.current.get(combineIdWithName(id, name))
+ },
+ []
+ )
+
+ const deleteSnapshot = useCallback(
+ (id: SnapshotId, name: SnapshotName = null): void => {
+ internalSnapshotsRef.current.delete(combineIdWithName(id, name))
+ },
+ []
+ )
+
+ const applySnapshot = useCallback(
+ (id: SnapshotId, name: SnapshotName = null) => {
+ const snapshot = getSnapshot(id, name)
+
+ if (snapshot instanceof Map) {
+ snapshot.forEach((value, path) => {
+ updateData(path, value)
+ })
+ } else if (snapshot) {
+ setData(snapshot)
+ }
+ },
+ [getSnapshot, setData, updateData]
+ )
+
+ const revertSnapshot = useCallback(
+ (id: SnapshotId, name: SnapshotName = null) => {
+ applySnapshot(id, name)
+ deleteSnapshot(id, name)
+ },
+ [applySnapshot, deleteSnapshot]
+ )
+
+ return {
+ createSnapshot,
+ revertSnapshot,
+ applySnapshot,
+ internalSnapshotsRef,
+ }
+}
+
+function combineIdWithName(id: SnapshotId, name: SnapshotName = null) {
+ return name ? `${id}-${name}` : id
+}
diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useValueProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useValueProps.ts
index b21b7e3fede..02c28b6ad1e 100644
--- a/packages/dnb-eufemia/src/extensions/forms/hooks/useValueProps.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useValueProps.ts
@@ -6,6 +6,7 @@ import {
useRef,
} from 'react'
import { Path, ValueProps } from '../types'
+import { convertJsxToString } from '../../../shared/component-helper'
import useExternalValue from './useExternalValue'
import usePath from './usePath'
import DataContext from '../DataContext/Context'
@@ -13,6 +14,10 @@ import ValueProviderContext from '../Value/Provider/ValueProviderContext'
export type Props = ValueProps
+const transformLabelParameters = {
+ convertJsxToString,
+} as unknown as Parameters['transformLabel']>[1]
+
export default function useValueProps<
Value = unknown,
Props extends ValueProps = ValueProps,
@@ -29,6 +34,7 @@ export default function useValueProps<
defaultValue,
inheritVisibility,
inheritLabel,
+ transformLabel = (label: Props['label']) => label,
transformIn = (value: Value) => value,
toInput = (value: Value) => value,
fromExternal = (value: Value) => value,
@@ -87,9 +93,11 @@ export default function useValueProps<
? transformIn(toInput(externalValue))
: undefined
- const label =
+ const label = transformLabel(
props.label ??
- (inheritLabel ? fieldPropsRef?.current?.[path]?.label : undefined)
+ (inheritLabel ? fieldPropsRef?.current?.[path]?.label : undefined),
+ transformLabelParameters
+ )
return { ...props, label, value }
}
diff --git a/packages/dnb-eufemia/src/extensions/forms/stories/PizzaDemo.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/stories/PizzaDemo.stories.tsx
index bd389e6d0db..9fc1acbce44 100644
--- a/packages/dnb-eufemia/src/extensions/forms/stories/PizzaDemo.stories.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/stories/PizzaDemo.stories.tsx
@@ -1,7 +1,6 @@
import React from 'react'
-import { Card, Section } from '../../../components'
-import { Field, Form, Wizard, Value } from '..'
-import { Code } from '../../../elements'
+import { Card } from '../../../components'
+import { Field, Form, Wizard, Value, Tools } from '..'
import { Provider } from '../../../../shared'
export default {
@@ -150,21 +149,7 @@ export function PizzaDemo() {
- {data}
+
)
}
-
-function Output({ children }) {
- return (
-
- JSON Output: {JSON.stringify(children || {}, null, 4)}
-
- )
-}
diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts
index 56c351ff8f3..6ec8f27a635 100644
--- a/packages/dnb-eufemia/src/extensions/forms/types.ts
+++ b/packages/dnb-eufemia/src/extensions/forms/types.ts
@@ -469,6 +469,14 @@ export interface ValueProps
/** The max-width of a value block. Defaults to large */
maxWidth?: 'small' | 'medium' | 'large' | 'auto'
+ /**
+ * Transforms the label before it gets displayed. Receives the label as the first parameter. The second parameter is a object containing the `convertJsxToString` function.
+ */
+ transformLabel?: (
+ label: React.ReactNode,
+ convertJsxToString: (label: React.ReactNode) => string
+ ) => React.ReactNode
+
/**
* Transforms the `value` before its displayed in the field (e.g. input).
* Public API. Should not be used internally.
@@ -592,7 +600,10 @@ export type OnCommit = (
{
clearData,
preventCommit,
- }: { clearData: () => void; preventCommit?: () => void }
+ }: {
+ clearData: () => void
+ preventCommit?: () => void
+ }
) =>
| EventReturnWithStateObject
| void
diff --git a/packages/dnb-eufemia/src/extensions/payment-card/__tests__/__snapshots__/PaymentCard.test.tsx.snap b/packages/dnb-eufemia/src/extensions/payment-card/__tests__/__snapshots__/PaymentCard.test.tsx.snap
index 6300a64a851..1b953ef23f1 100644
--- a/packages/dnb-eufemia/src/extensions/payment-card/__tests__/__snapshots__/PaymentCard.test.tsx.snap
+++ b/packages/dnb-eufemia/src/extensions/payment-card/__tests__/__snapshots__/PaymentCard.test.tsx.snap
@@ -32,8 +32,9 @@ exports[`PaymentCard scss has to match style dependencies css 1`] = `
--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/shared/__tests__/component-helper.test.js b/packages/dnb-eufemia/src/shared/__tests__/component-helper.test.js
index 3a90b129ccf..250d35b5907 100644
--- a/packages/dnb-eufemia/src/shared/__tests__/component-helper.test.js
+++ b/packages/dnb-eufemia/src/shared/__tests__/component-helper.test.js
@@ -29,6 +29,7 @@ import {
matchAll,
convertJsxToString,
escapeRegexChars,
+ removeUndefinedProps,
} from '../component-helper'
beforeAll(() => {
@@ -664,3 +665,26 @@ describe('"escapeRegexChars" should', () => {
)
})
})
+
+describe('"removeUndefinedProps" should', () => {
+ const object = {
+ foo: undefined,
+ bar: null,
+ baz: undefined,
+ qux: null,
+ quux: undefined,
+ }
+
+ it('remove undefined props', () => {
+ expect(removeUndefinedProps(object)).toEqual({
+ bar: null,
+ baz: undefined,
+ qux: null,
+ quux: undefined,
+ })
+ })
+
+ it('remove support undefined as data', () => {
+ expect(removeUndefinedProps(undefined)).toBeUndefined()
+ })
+})
diff --git a/packages/dnb-eufemia/src/shared/component-helper.js b/packages/dnb-eufemia/src/shared/component-helper.js
index fe4f4e1fc2a..22009d16136 100644
--- a/packages/dnb-eufemia/src/shared/component-helper.js
+++ b/packages/dnb-eufemia/src/shared/component-helper.js
@@ -781,7 +781,7 @@ export function escapeRegexChars(str) {
}
export function removeUndefinedProps(object) {
- Object.keys(object).forEach((key) => {
+ Object.keys(object || {}).forEach((key) => {
if (object[key] === undefined) {
delete object[key]
}
diff --git a/packages/dnb-eufemia/src/style/themes/theme-eiendom/properties.js b/packages/dnb-eufemia/src/style/themes/theme-eiendom/properties.js
index f827586a7b3..b91cec0012b 100644
--- a/packages/dnb-eufemia/src/style/themes/theme-eiendom/properties.js
+++ b/packages/dnb-eufemia/src/style/themes/theme-eiendom/properties.js
@@ -12,8 +12,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/style/themes/theme-sbanken/properties.js b/packages/dnb-eufemia/src/style/themes/theme-sbanken/properties.js
index 5a4ef61377b..e0ba26fd86d 100644
--- a/packages/dnb-eufemia/src/style/themes/theme-sbanken/properties.js
+++ b/packages/dnb-eufemia/src/style/themes/theme-sbanken/properties.js
@@ -12,8 +12,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/style/themes/theme-sbanken/properties.scss b/packages/dnb-eufemia/src/style/themes/theme-sbanken/properties.scss
index b88a51752e6..ce17072a595 100644
--- a/packages/dnb-eufemia/src/style/themes/theme-sbanken/properties.scss
+++ b/packages/dnb-eufemia/src/style/themes/theme-sbanken/properties.scss
@@ -18,15 +18,16 @@
--sb-font-weight-bold: 700;
// Typography Sizes
- --sb-font-size-x-small: 0.75rem;
- --sb-font-size-small: 0.875rem;
- --sb-font-size-basis: 1rem;
+ --sb-font-size-x-small: 0.75rem; // 12px
+ --sb-font-size-small: 0.875rem; // 14px
+ --sb-font-size-basis: 1rem; // 16px
--sb-font-size-basis--em: 1em;
- --sb-font-size-lead: 1.25rem;
- --sb-font-size-medium: 1.625rem;
- --sb-font-size-large: 2rem;
- --sb-font-size-x-large: 2.375rem;
- --sb-font-size-xx-large: 3rem;
+ --sb-font-size-lead: var(--font-size-medium); // 20px
+ --sb-font-size-medium: 1.25rem; // 20px
+ --sb-font-size-medium--plus: 1.625rem; // 26px extra for Sbanken theme
+ --sb-font-size-large: 2rem; // 32px
+ --sb-font-size-x-large: 2.375rem; // 38px
+ --sb-font-size-xx-large: 3rem; // 48px
// Typography Line heights
--sb-line-height-x-small: 1.125rem;
diff --git a/packages/dnb-eufemia/src/style/themes/theme-ui/properties.js b/packages/dnb-eufemia/src/style/themes/theme-ui/properties.js
index f827586a7b3..b91cec0012b 100644
--- a/packages/dnb-eufemia/src/style/themes/theme-ui/properties.js
+++ b/packages/dnb-eufemia/src/style/themes/theme-ui/properties.js
@@ -12,8 +12,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',