diff --git a/packages/dnb-design-system-portal/gatsby-node.js b/packages/dnb-design-system-portal/gatsby-node.js index 978c8a56ce9..fe133d635f5 100644 --- a/packages/dnb-design-system-portal/gatsby-node.js +++ b/packages/dnb-design-system-portal/gatsby-node.js @@ -3,6 +3,7 @@ * */ +const fs = require('fs').promises const path = require('path') const { isCI } = require('repo-utils') const { init } = require('./scripts/version.js') @@ -128,6 +129,13 @@ exports.onPostBuild = async (params) => { .join('\n')}\n\n`, ) } + + // Copy the fonts folder + const { program } = params.store.getState() + const publicDir = path.join(program.directory, 'public', 'fonts') + const rootPath = path.dirname(require.resolve('@dnb/eufemia')) + const src = path.resolve(rootPath, 'assets', 'fonts') + await copyDirectory(src, publicDir) } const deletedPages = [] @@ -312,3 +320,20 @@ exports.onCreateDevServer = (params) => { ) } } + +async function copyDirectory(src, dest) { + await fs.mkdir(dest, { recursive: true }) + + const entries = await fs.readdir(src, { withFileTypes: true }) + + for await (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + if (entry.isDirectory()) { + await copyDirectory(srcPath, destPath) + } else { + await fs.copyFile(srcPath, destPath) + } + } +} diff --git a/packages/dnb-design-system-portal/playwright.config.ts b/packages/dnb-design-system-portal/playwright.config.ts index fefaeb48f09..6ef6fe0ceca 100644 --- a/packages/dnb-design-system-portal/playwright.config.ts +++ b/packages/dnb-design-system-portal/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from '@playwright/test' +import { isCI } from 'repo-utils' export default defineConfig({ timeout: 30000, @@ -8,7 +9,7 @@ export default defineConfig({ use: { // Base URL to use in actions like `await page.goto('/')`. - baseURL: 'http://localhost:8002', + baseURL: isCI ? 'http://localhost:8002' : 'http://localhost:8000', // Name of the browser that runs tests. For example `chromium`, `firefox`, `webkit`. browserName: 'firefox', diff --git a/packages/dnb-design-system-portal/src/docs/EUFEMIA_CHANGELOG.mdx b/packages/dnb-design-system-portal/src/docs/EUFEMIA_CHANGELOG.mdx index 3607dff227b..da97ae88469 100644 --- a/packages/dnb-design-system-portal/src/docs/EUFEMIA_CHANGELOG.mdx +++ b/packages/dnb-design-system-portal/src/docs/EUFEMIA_CHANGELOG.mdx @@ -49,12 +49,12 @@ ## November, 22. 2022 -- New default [Table](https://eufemia.dnb.no/uilib/components/table) styles. -- Support `medium` and `small` [Table](https://eufemia.dnb.no/uilib/components/table) sizes. +- New default [Table](/uilib/components/table) styles. +- Support `medium` and `small` [Table](/uilib/components/table) sizes. ## November, 15. 2022 -- Add [typography paragraph](https://eufemia.dnb.no/uilib/typography/paragraph) styling for superscript `` and subscript `` HTML elements. +- Add [typography paragraph](/uilib/typography/paragraph) styling for superscript `` and subscript `` HTML elements. ## October, 27. 2022 @@ -64,7 +64,7 @@ ## October, 5. 2022 -- New **Definition List** layout direction: `direction="horizontal"` including `Dl.Item` [demo](https://eufemia.dnb.no/uilib/elements/lists/#definition-list-in-horizontal-direction). +- New **Definition List** layout direction: `direction="horizontal"` including `Dl.Item` [demo](/uilib/elements/lists/#definition-list-in-horizontal-direction). - New components released: - [Badge](/uilib/components/badge) - [HeightAnimation](/uilib/components/height-animation) diff --git a/packages/dnb-design-system-portal/src/docs/contribute/deploy.mdx b/packages/dnb-design-system-portal/src/docs/contribute/deploy.mdx index 07b3c5e7826..1c85491f3ce 100644 --- a/packages/dnb-design-system-portal/src/docs/contribute/deploy.mdx +++ b/packages/dnb-design-system-portal/src/docs/contribute/deploy.mdx @@ -10,7 +10,7 @@ Publishing new versions to the NPM Package (`@dnb/eufemia`) is handled by a Depl ## Continuous Integration (CI) -The Portal (`dnb-design-system-portal`), all the [icons](https://eufemia.dnb.no/icons/) and the NPM Package (`@dnb/eufemia`) are build, deployed and released by a Continuous Integration (CI) server. +The Portal (`dnb-design-system-portal`), all the [icons](/icons/) and the NPM Package (`@dnb/eufemia`) are build, deployed and released by a Continuous Integration (CI) server. ### Release GitFlow diff --git a/packages/dnb-design-system-portal/src/docs/contribute/getting-started/make-and-run-tests.mdx b/packages/dnb-design-system-portal/src/docs/contribute/getting-started/make-and-run-tests.mdx index 8b6f6b59b0b..24bf8c7520e 100644 --- a/packages/dnb-design-system-portal/src/docs/contribute/getting-started/make-and-run-tests.mdx +++ b/packages/dnb-design-system-portal/src/docs/contribute/getting-started/make-and-run-tests.mdx @@ -77,7 +77,11 @@ yarn start yarn test:e2e /Slider\|Button/ # You can also start it in watch mode -yarn test:e2e:watch /Slider\|Button/ +yarn test:e2e:watch + +# Or run the tests for the portal +yarn test:e2e:portal +yarn test:e2e:portal:watch ``` Playwright uses this naming convention: `/__tests__/{ComponentName}.screenshot.test.ts` diff --git a/packages/dnb-design-system-portal/src/docs/contribute/style-guides/issues.mdx b/packages/dnb-design-system-portal/src/docs/contribute/style-guides/issues.mdx index 3e08cb7a502..c52f6101e59 100644 --- a/packages/dnb-design-system-portal/src/docs/contribute/style-guides/issues.mdx +++ b/packages/dnb-design-system-portal/src/docs/contribute/style-guides/issues.mdx @@ -13,7 +13,7 @@ bug report to the GitHub repository. Thanks for helping out. When reporting issues or suggesting new features, we would appreciate if you use [GitHub Issues](https://github.com/dnbexperience/eufemia/issues) or our [Jira Kanban board](https://jira.tech.dnb.no/projects/EDS/summary#). Another option is to send a Slack message in [#eufemia-web](https://dnb-it.slack.com/archives/CMXABCHEY). -For reproduction of issues you can use our [codesandbox starter template](https://eufemia.dnb.no/issue/). Including this in your report helps us out a lot. +For reproduction of issues you can use our [codesandbox starter template](/issue/). Including this in your report helps us out a lot. ## GitHub issues diff --git a/packages/dnb-design-system-portal/src/docs/design-system/about.mdx b/packages/dnb-design-system-portal/src/docs/design-system/about.mdx index 3aff4425580..55d914b90b8 100644 --- a/packages/dnb-design-system-portal/src/docs/design-system/about.mdx +++ b/packages/dnb-design-system-portal/src/docs/design-system/about.mdx @@ -50,7 +50,7 @@ We at UX have created the Eufemia design system to streamline both design and de Access to the code and documentation is absolutely essential to being able to build good relationships and balance between willingness to contribute, further development and communication. -To ensure this transparency, the code on [GitHub](http://github.com/dnbexperience/eufemia) and the [Eufemia Portal](https://eufemia.dnb.no/) are made available without restrictions. +To ensure this transparency, the code on [GitHub](http://github.com/dnbexperience/eufemia) and the [Eufemia Portal](/) are made available without restrictions. In summary – we experience that it; diff --git a/packages/dnb-design-system-portal/src/docs/quickguide-designer.mdx b/packages/dnb-design-system-portal/src/docs/quickguide-designer.mdx index 1a11b646a0f..90dd14f7e12 100644 --- a/packages/dnb-design-system-portal/src/docs/quickguide-designer.mdx +++ b/packages/dnb-design-system-portal/src/docs/quickguide-designer.mdx @@ -30,12 +30,12 @@ What you should read from brand guidelines before starting to design for DNB ### Getting started -1. Open Figma -2. Make sure you are a member of the DNB UX team. If not, then contact a lead designer (https://eufemia.dnb.no/design-system/contact) +1. Open Figma. +2. Make sure you are a member of the DNB UX team. If not, then contact a [lead designer](/design-system/contact). 3. When you click on the 'You' dropdown, you should see DNB Bank ASA as a team to choose from. -4. Choose DNB Bank ASA -5. Create a new file -6. Add Eufemia library to your file by selecting the 'open book' icon on the top right of the Figma interface. -7. This opens a new dialogue window. Choose Eufemia by toggling the switch: -8. In preferences set your nudge amount to 8px - this will snap items to the 8px grid -9. Add a layout grid and set it to 8px: +4. Choose DNB Bank ASA . +5. Create a new file. +6. Add Eufemia library to your file by selecting the 'open book' icon on the top right of the Figma interface. . +7. This opens a new dialogue window. Choose Eufemia by toggling the switch: . +8. In preferences set your nudge amount to 8px - this will snap items to the 8px grid. +9. Add a layout grid and set it to 8px: . diff --git a/packages/dnb-design-system-portal/src/docs/quickguide-designer/typography/typographic-rules.mdx b/packages/dnb-design-system-portal/src/docs/quickguide-designer/typography/typographic-rules.mdx index 0928dd83b97..b95efc7b71f 100644 --- a/packages/dnb-design-system-portal/src/docs/quickguide-designer/typography/typographic-rules.mdx +++ b/packages/dnb-design-system-portal/src/docs/quickguide-designer/typography/typographic-rules.mdx @@ -1,11 +1,12 @@ + + ## In general Sbanken has two fonts in its profile; Roboto and Maison Neue. The latter is used mainly for headlines, 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 fd9ba72b1cc..6b899852274 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 @@ -57,7 +57,7 @@ import { GlobalStatus } from 'dnb-ui-lib/components' item="Item from status #1" /> -// 3. and remove it again when ever you want +// 3. and remove it again whenever you want ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/global-status/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/components/global-status/Examples.tsx index 0f8b130ea27..6f7e663d319 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/global-status/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/global-status/Examples.tsx @@ -14,7 +14,7 @@ import { } from '@dnb/eufemia/src' import { Provider } from '@dnb/eufemia/src/shared' -export const GlobalStatusError = () => ( +export const GlobalInfoOverlayError = () => ( ( ) -export const GlobalStatusSuccess = () => ( +export const GlobalInfoOverlaySuccess = () => ( + ### GlobalStatus displaying info status @@ -31,7 +31,7 @@ import { ### GlobalStatus displaying success status - + ### To showcase the automated coupling between **FormStatus** and **GlobalStatus** diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/pagination/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/pagination/properties.mdx index 6b73c4e362e..3570973ddeb 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/pagination/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/pagination/properties.mdx @@ -6,38 +6,38 @@ import TranslationsTable from 'dnb-design-system-portal/src/shared/parts/Transla ## Properties -| Properties | Description | -| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mode` | _(optional)_ if set to `infinity`, then the pagination bar will be now shown and but infinity scrolling will do the content presentation. For more information, check out the [Infinity Scroller](https://eufemia.dnb.no/uilib/components/pagination/infinity-scroller). Defaults to `pagination`. | -| `children` | _(optional)_ the given content can be either a function or a React node, depending on your needs. A function contains several helper functions. More details down below and have a look at the examples in the demos section. | -| `align` | _(optional)_ define the alignment of the pagination button bar. Can be `center`, `left` or `right`. Defaults to `left`. | -| `startup_page` | _(optional)_ the page shown in the very beginning. If `current_page` is set, then it may not make too much sense to set this as well. | -| `current_page` | _(optional)_ the page shown at the moment the component renders. Defaults to `1`. | -| `page_count` | _(optional)_ the total pages count. Defaults to `1`. | -| `startup_count` | _(optional)_ defines how many `infinity` pages should be loaded / shown on the first render. Defaults to `1`. | -| `parallel_load_count` | _(optional)_ defines how many `infinity` pages should be loaded / shown once the user scrolls down. Defaults to `1`. | -| `min_wait_time` | _(optional)_ the minimum time to wait, if the infinity scroll was invoked under that time threshold. This prevents not intentional infinity scroll loop calls. Defaults to `400` milliseconds. | -| `place_maker_before_content` | _(optional)_ if set to `true`, the infinity marker will be placed before the content (on top off). This could potentially have negative side effects. But it depends really on the content if this would make more sense to use instead. Defaults to `false`. | -| `use_load_button` | _(optional)_ if set to `true` it will disable the automated infinity scrolling, but shows a load more button at the of the content instead. | -| `hide_progress_indicator` | _(optional)_ if set to `true` no indicator will be shown. | -| `page_element` | _(optional)_ by default a `
` is used. Set it to any element you have to use. Adds also a class: `dnb-pagination__page` shown. | -| `fallback_element` | _(optional)_ (infinity mode) is used by the _indicator_, _load more_ bar as well as by the marker. Defaults to a `div`. | -| `indicator_element` | _(optional)_ (infinity mode) is used by the _indicator_. Falls back to `fallback_element` if not defined. | -| `marker_element` | _(optional)_ (infinity mode) is used by the internal marker. Falls back to `fallback_element` if not defined. | -| `set_content_handler` | _(optional)_ callback function to get the `setContent` handler from the current pagination instance. e.g. `set_content_handler={fn => (...)}`. Use this handler to insert content during infinity mode. | -| `reset_content_handler` | _(optional)_ callback function to get the `resetContent` handler from the current pagination instance. e.g. `reset_content_handler={fn => (...)}`. Use this handler to reset all the content. You can set it to `true`, to programmatically reset the content. | -| `reset_pagination_handler` | _(optional)_ callback function to get the `resetInfinity` handler from the current pagination instance. e.g. `reset_pagination_handler={fn => (...)}`. Use this handler to reset all the internal states. You can set it to `true`, to programmatically reset the states. | -| `end_infinity_handler` | _(optional)_ callback function to get the `endInfinity` handler from the current pagination instance. e.g. `end_infinity_handler={fn => (...)}`. Use this handler to end the infinity scrolling procedure, in case the `page_count` is unknown. | -| `button_title` | _(optional)_ The title used in every button shown in the bar. Defaults to `Side %s`. | -| `next_title` | _(optional)_ The title used in the next page button. Defaults to `Neste side`. | -| `prev_title` | _(optional)_ The title used in the previous page button. Defaults to `Forrige side`. | -| `more_pages` | _(optional)_ The title used in the dots. Relevant for screen readers. Defaults to `%s flere sider`. | -| `is_loading_text` | _(optional)_ Shown until new content is inserted in to the page. Defaults to `Laster nytt innhold`. | -| `load_button_text` | _(optional)_ Used during infinity mode. If `use_load_button` is set to `true`, then a button is show on the bottom. If the `startup_page` is higher than 1. Defaults to `Vis mer innhold`. | -| `loadButton` | _(optional)_ Used to set load button text and icon alignment. Accepts a function returning a ReactNode too, so you can replace the button with your own component. | -| `disabled` | _(optional)_ if set to `true`, all pagination bar buttons are disabled. | -| `skeleton` | _(optional)_ if set to `true`, an overlaying skeleton with animation will be shown. | -| [Space](/uilib/layout/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. | +| Properties | Description | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mode` | _(optional)_ if set to `infinity`, then the pagination bar will be now shown and but infinity scrolling will do the content presentation. For more information, check out the [Infinity Scroller](/uilib/components/pagination/infinity-scroller). Defaults to `pagination`. | +| `children` | _(optional)_ the given content can be either a function or a React node, depending on your needs. A function contains several helper functions. More details down below and have a look at the examples in the demos section. | +| `align` | _(optional)_ define the alignment of the pagination button bar. Can be `center`, `left` or `right`. Defaults to `left`. | +| `startup_page` | _(optional)_ the page shown in the very beginning. If `current_page` is set, then it may not make too much sense to set this as well. | +| `current_page` | _(optional)_ the page shown at the moment the component renders. Defaults to `1`. | +| `page_count` | _(optional)_ the total pages count. Defaults to `1`. | +| `startup_count` | _(optional)_ defines how many `infinity` pages should be loaded / shown on the first render. Defaults to `1`. | +| `parallel_load_count` | _(optional)_ defines how many `infinity` pages should be loaded / shown once the user scrolls down. Defaults to `1`. | +| `min_wait_time` | _(optional)_ the minimum time to wait, if the infinity scroll was invoked under that time threshold. This prevents not intentional infinity scroll loop calls. Defaults to `400` milliseconds. | +| `place_maker_before_content` | _(optional)_ if set to `true`, the infinity marker will be placed before the content (on top off). This could potentially have negative side effects. But it depends really on the content if this would make more sense to use instead. Defaults to `false`. | +| `use_load_button` | _(optional)_ if set to `true` it will disable the automated infinity scrolling, but shows a load more button at the of the content instead. | +| `hide_progress_indicator` | _(optional)_ if set to `true` no indicator will be shown. | +| `page_element` | _(optional)_ by default a `
` is used. Set it to any element you have to use. Adds also a class: `dnb-pagination__page` shown. | +| `fallback_element` | _(optional)_ (infinity mode) is used by the _indicator_, _load more_ bar as well as by the marker. Defaults to a `div`. | +| `indicator_element` | _(optional)_ (infinity mode) is used by the _indicator_. Falls back to `fallback_element` if not defined. | +| `marker_element` | _(optional)_ (infinity mode) is used by the internal marker. Falls back to `fallback_element` if not defined. | +| `set_content_handler` | _(optional)_ callback function to get the `setContent` handler from the current pagination instance. e.g. `set_content_handler={fn => (...)}`. Use this handler to insert content during infinity mode. | +| `reset_content_handler` | _(optional)_ callback function to get the `resetContent` handler from the current pagination instance. e.g. `reset_content_handler={fn => (...)}`. Use this handler to reset all the content. You can set it to `true`, to programmatically reset the content. | +| `reset_pagination_handler` | _(optional)_ callback function to get the `resetInfinity` handler from the current pagination instance. e.g. `reset_pagination_handler={fn => (...)}`. Use this handler to reset all the internal states. You can set it to `true`, to programmatically reset the states. | +| `end_infinity_handler` | _(optional)_ callback function to get the `endInfinity` handler from the current pagination instance. e.g. `end_infinity_handler={fn => (...)}`. Use this handler to end the infinity scrolling procedure, in case the `page_count` is unknown. | +| `button_title` | _(optional)_ The title used in every button shown in the bar. Defaults to `Side %s`. | +| `next_title` | _(optional)_ The title used in the next page button. Defaults to `Neste side`. | +| `prev_title` | _(optional)_ The title used in the previous page button. Defaults to `Forrige side`. | +| `more_pages` | _(optional)_ The title used in the dots. Relevant for screen readers. Defaults to `%s flere sider`. | +| `is_loading_text` | _(optional)_ Shown until new content is inserted in to the page. Defaults to `Laster nytt innhold`. | +| `load_button_text` | _(optional)_ Used during infinity mode. If `use_load_button` is set to `true`, then a button is show on the bottom. If the `startup_page` is higher than 1. Defaults to `Vis mer innhold`. | +| `loadButton` | _(optional)_ Used to set load button text and icon alignment. Accepts a function returning a ReactNode too, so you can replace the button with your own component. | +| `disabled` | _(optional)_ if set to `true`, all pagination bar buttons are disabled. | +| `skeleton` | _(optional)_ if set to `true`, an overlaying skeleton with animation will be shown. | +| [Space](/uilib/layout/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. | ## Translations diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/step-indicator/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/step-indicator/info.mdx index b3161fb8e75..23997e81aba 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/step-indicator/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/step-indicator/info.mdx @@ -16,7 +16,7 @@ If the user should be able to navigate back and forth, use the `mode="loose"` pr The current active step is set with the `current_step` property or within the data with the `is_current` object property. -**NB:** Ensure, when ever possible, to bind the `current_step` to the browsers path location. See the [example below](/uilib/components/step-indicator/#stepindicator-with-a-router) or [the example on CodeSandbox](https://codesandbox.io/s/eufemia-step-indicator-with-reach-router-mhu0bh?file=/src/App.tsx). +**NB:** Ensure, whenever possible, to bind the `current_step` to the browsers path location. See the [example below](/uilib/components/step-indicator/#stepindicator-with-a-router) or [the example on CodeSandbox](https://codesandbox.io/s/eufemia-step-indicator-with-reach-router-mhu0bh?file=/src/App.tsx). ## Sidebar diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/textarea/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/textarea/info.mdx index ed1d6ddc6fd..3d9161c89c4 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/textarea/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/textarea/info.mdx @@ -16,7 +16,7 @@ The Textarea component is used as a multi-line text input control with an unlimi ### Accessibility -Please avoid using the `maxlength` attribute when ever possible, as it is not accessible. Instead, use the `characterCounter` property. +Please avoid using the `maxlength` attribute whenever possible, as it is not accessible. Instead, use the `characterCounter` property. This way the user gets a visual feedback of the number of characters entered and the maximum number of characters allowed. And it will not limit the user in their workflow. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx index ed3ce133a2d..fd62aa24129 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx @@ -12,6 +12,7 @@ import { Section, Upload, } from '@dnb/eufemia/src' +import { createRequest } from '../../extensions/forms/Form/SubmitIndicator/Examples' export function createMockFile(name: string, size: number, type: string) { const file = new File([], name, { type }) @@ -23,21 +24,10 @@ export function createMockFile(name: string, size: number, type: string) { return file } -const useMockFiles = (setFiles, extend) => { - React.useEffect(() => { - setFiles([ - { - file: createMockFile('fileName.png', 123, 'image/png'), - ...extend, - }, - ]) - }, []) -} - export const UploadPrefilledFileList = () => ( {() => { const Component = () => { @@ -47,7 +37,14 @@ export const UploadPrefilledFileList = () => ( console.log('files', files) } - useMockFiles(setFiles, { errorMessage: 'This is no real file!' }) + React.useEffect(() => { + setFiles([ + { + file: createMockFile('fileName.png', 123, 'image/png'), + errorMessage: 'This is no real file!', + }, + ]) + }, []) return } @@ -156,14 +153,21 @@ export const UploadRemoveFile = () => ( export const UploadIsLoading = () => ( {() => { const Component = () => { const { files, setFiles } = Upload.useUpload('upload-is-loading') - useMockFiles(setFiles, { isLoading: true }) + React.useEffect(() => { + setFiles([ + { + file: createMockFile('fileName.png', 123, 'image/png'), + isLoading: true, + }, + ]) + }, []) return ( <> @@ -323,3 +327,75 @@ export const UploadNoTitleNoText = () => ( /> ) + +export const UploadOnFileDeleteAsync = () => ( + + {() => { + async function mockAsyncFileRemoval({ fileItem }) { + const request = createRequest() + console.log('making API request to remove: ' + fileItem.file.name) + await request(3000) // Simulate a request + const mockResponse = { + successful_removal: Math.random() < 0.5, // Randomly fails to remove the file + } + if (!mockResponse.successful_removal) { + throw new Error('Unable to remove this file') + } + } + + return ( + + ) + }} + +) + +export const UploadOnFileClick = () => ( + + {() => { + const Component = () => { + const { setFiles } = Upload.useUpload('upload-on-file-click') + + React.useEffect(() => { + setFiles([ + { + file: createMockFile('1501870.jpg', 123, 'image/png'), + }, + ]) + }, []) + + async function mockAsyncFileFetching({ fileItem }) { + const request = createRequest() + console.log( + 'making API request to fetch the url of the file: ' + + fileItem.file.name, + ) + await request(2000) // Simulate a request + window.open( + 'https://eufemia.dnb.no/images/avatars/' + fileItem.file.name, + '_blank', + ) + } + + return ( + <> + + + ) + } + + return + }} + +) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.mdx index 8760e6cb8cd..3a3dcba43d0 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.mdx @@ -14,6 +14,8 @@ import { UploadFileMaxSizeBasedOnFileType, UploadFileMaxSizeBasedOnFileTypeDisabled, UploadNoTitleNoText, + UploadOnFileDeleteAsync, + UploadOnFileClick, } from 'Docs/uilib/components/upload/Examples' ## Demos @@ -22,7 +24,7 @@ import { -### 'useUpload' React Hook +### `useUpload` React Hook By using the `Upload.useUpload` you can remove or add files or the status displayed in the component. @@ -78,3 +80,11 @@ This can also be used to manually implement more complex file max size verificat ### Upload without title and text + +### Upload with async `onFileDelete` + + + +### Upload with `onFileClick` + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/events.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/events.mdx index c03e28485cd..474d7eff62c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/events.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/events.mdx @@ -9,4 +9,4 @@ import { UploadEvents } from '@dnb/eufemia/src/components/upload/UploadDocs' -Each `fileItem` will contain a `{ file, id }` (File Object and an unique ID) along with other information. +Each `fileItem` will contain a `{ file, id, exists }` (File Object, an unique ID and if the file exists or not) along with other information. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/elements/lists/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/elements/lists/demos.mdx index b0d66b6829a..7cfd3885766 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/elements/lists/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/elements/lists/demos.mdx @@ -44,7 +44,7 @@ Ordered lists do support natively other types, like _letters_ and _roman numeral ### Definition Lists -Use Definition Lists when ever you have to tie together any items that have a direct relationship with each other (name/value sets). +Use Definition Lists whenever you have to tie together any items that have a direct relationship with each other (name/value sets). You can use multiples of `
` and `
` within a definition list. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay.mdx new file mode 100644 index 00000000000..6d815dc5072 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay.mdx @@ -0,0 +1,25 @@ +--- +title: 'InfoOverlay' +description: '`Form.InfoOverlay` is used to display an informational message that fully covers the available space.' +showTabs: true +tabs: + - title: Info + key: '/info' + - title: Demos + key: '/demos' + - title: Properties + key: '/properties' +breadcrumb: + - text: Forms + href: /uilib/extensions/forms/ + - text: Form + href: /uilib/extensions/forms/Form/ + - text: InfoOverlay + href: /uilib/extensions/forms/Form/InfoOverlay +--- + +import Info from 'Docs/uilib/extensions/forms/Form/InfoOverlay/info' +import Demos from 'Docs/uilib/extensions/forms/Form/InfoOverlay/demos' + + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/Examples.tsx new file mode 100644 index 00000000000..f5a4401488a --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/Examples.tsx @@ -0,0 +1,120 @@ +import ComponentBox from '../../../../../../shared/tags/ComponentBox' +import { createRequest } from '../SubmitIndicator/Examples' +import { Field, Form, Wizard } from '@dnb/eufemia/src/extensions/forms' +import { Button } from '@dnb/eufemia/src' + +const request = createRequest() + +export const ErrorMessage = () => { + return ( + + {() => { + // myFormId can be anything, as long as it's a unique instance + const myFormId = () => null + + return ( + { + await request(1000) // Simulate a request + + Form.InfoOverlay.setContent(myFormId, 'error') + }} + > + + + + + + + + + + + ) + }} + + ) +} + +export const SuccessMessage = () => { + return ( + + {() => { + // myFormId can be anything, as long as it's a unique instance + const myFormId = () => null + + return ( + { + await request(1000) // Simulate a request + + Form.InfoOverlay.setContent(myFormId, 'success') + }} + > + + + + + + + + ) + }} + + ) +} + +export const WithAWizard = () => { + const request = createRequest() + return ( + + {() => { + // myFormId can be anything, as long as it's a unique instance + const myFormId = () => null + + return ( + { + await request(1000) + Form.InfoOverlay.setContent(myFormId, 'success') + }} + > + + { + await request(1000) + }} + > + + + + + + + + + + + + + + + + ) + }} + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/demos.mdx new file mode 100644 index 00000000000..40073d16691 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/demos.mdx @@ -0,0 +1,20 @@ +--- +showTabs: true +hideInMenu: true +--- + +import * as Examples from './Examples' + +## Demos + +### Error message + + + +### Success message + + + +### With a Wizard + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/info.mdx new file mode 100644 index 00000000000..bd63178e998 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/info.mdx @@ -0,0 +1,111 @@ +--- +showTabs: true +hideInMenu: true +--- + +## Description + +`Form.InfoOverlay` is used to display an informational message that fully covers the available space. It can show a custom message or content, a `success` message as a receipt, or an `error` message to indicate an issue. + +## Usage + +By default the given children will be shown. + +```tsx +import { Form } from '@dnb/eufemia/extensions/forms' + +render( + + visible content + , +) +``` + +## Display a message + +There are two ways to display a message: + +- Using the `Form.InfoOverlay.setContent` method. +- Using the `content` prop. + +### Using the `Form.InfoOverlay.setContent` method + +You can show the success or error message by using the `Form.InfoOverlay.setContent` method: + +```tsx +Form.InfoOverlay.setContent(myId, <>info content) +// or +Form.InfoOverlay.setContent(myId, 'success') +// or +Form.InfoOverlay.setContent(myId, 'error') +``` + +And render the component with an `id` prop: + +```tsx +content +``` + +You can call it whenever you need to show the success message. Here is an example of how to use it. + +**Note:** the `id` prop is inherited from the `Form.Handler` component in this example. + +```tsx +import { Form } from '@dnb/eufemia/extensions/forms' + +// myFormId can be anything, as long as it's a unique instance +const myFormId = () => null + +render( + { + // 1. Send the request + + // 2. Show the success message + Form.InfoOverlay.setContent(myFormId, 'success') + }} + > + fallback content + , +) +``` + +### Using the `content` prop + +You can show the success or error message by using the `content` prop: + +```tsx +info content}>fallback content +fallback content +fallback content +``` + +## Customization of the `success` and `error` messages + +You can customize the `success` and `error` messages by using the `success` and `error` props. + +```tsx + {}, + }} + error={{ + title: 'Custom title', + description: 'Custom description', + cancelButton: 'Custom cancel', + retryButton: 'Custom retry', + retryingText: 'Custom retrying text', + }} +> + fallback content + +``` + +## Accessibility + +The component will manage focus handling, which is important for screen readers and users using keyboard navigation. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/properties.mdx new file mode 100644 index 00000000000..ebf75180e9d --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/InfoOverlay/properties.mdx @@ -0,0 +1,27 @@ +--- +showTabs: true +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 { + InfoOverlaySuccessProperties, + InfoOverlayErrorProperties, +} from '@dnb/eufemia/src/extensions/forms/Form/InfoOverlay/InfoOverlayDocs' + +## Properties + +### Error + + + +### Success + + + +## Translations + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility.mdx index 9ff6fa1d38b..de444c270d6 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility.mdx @@ -9,6 +9,8 @@ tabs: key: '/demos' - title: Properties key: '/properties' + - title: Events + key: '/events' breadcrumb: - text: Forms href: /uilib/extensions/forms/ diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/events.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/events.mdx new file mode 100644 index 00000000000..120df0da2f6 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Visibility/events.mdx @@ -0,0 +1,10 @@ +--- +showTabs: true +--- + +import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' +import { VisibilityEvents } from '@dnb/eufemia/src/extensions/forms/Form/Visibility/VisibilityDocs' + +## Events + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload.mdx index 2f5ba725e3e..4914aecf77e 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload.mdx @@ -10,6 +10,8 @@ tabs: key: '/demos' - title: Properties key: '/properties' + - title: Events + key: '/events' breadcrumb: - text: Forms href: /uilib/extensions/forms/ diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/Examples.tsx index 97c24738739..e77ff37b67d 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/Examples.tsx @@ -393,3 +393,31 @@ export const ListTypes = () => { ) } + +export const OnFileClick = () => { + return ( + + { + window.open( + 'https://eufemia.dnb.no/images/avatars/' + fileItem.file.name, + '_blank', + ) + }} + /> + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/demos.mdx index 845ea216ee0..0ea47024a75 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/demos.mdx @@ -49,3 +49,7 @@ import * as Examples from './Examples' ### Field.Upload path + +### Using `onFileClick` + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/events.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/events.mdx new file mode 100644 index 00000000000..ed52d1722e8 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/events.mdx @@ -0,0 +1,10 @@ +--- +showTabs: true +--- + +import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' +import { UploadValueEvents } from '@dnb/eufemia/src/extensions/forms/Value/Upload/UploadDocs' + +## Events + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/properties.mdx index a47f5154732..6c96248b14c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/properties.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Value/Upload/properties.mdx @@ -2,7 +2,7 @@ showTabs: true --- -import { UploadProperties } from '@dnb/eufemia/src/extensions/forms/Value/Upload/UploadDocs' +import { UploadValueProperties } from '@dnb/eufemia/src/extensions/forms/Value/Upload/UploadDocs' import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' import { ValueProperties } from '@dnb/eufemia/src/extensions/forms/Value/ValueDocs' @@ -10,7 +10,7 @@ import { ValueProperties } from '@dnb/eufemia/src/extensions/forms/Value/ValueDo ### Value-specific properties - + ### General properties 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 f2184166ec1..0af2fe694f2 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,17 @@ breadcrumb: Change log for the Eufemia Forms extension. +## v10.60 + +- Added [Form.InfoOverlay](/uilib/extensions/forms/Form/InfoOverlay/) to display error, success (receipt), or custom messages to users. +- Added async `onFileDelete` support to [Field.Upload](/uilib/extensions/forms/feature-fields/more-fields/Upload/). +- Added async `onFileClick` support to [Field.Upload](/uilib/extensions/forms/feature-fields/more-fields/Upload/). +- Added `onFileClick` support to [Value.Upload](/uilib/extensions/forms/Value/Upload/). +- Added `onVisible` property in [Form.Visibility](/uilib/extensions/forms/Form/Visibility/). +- Added `onAnimationEnd` property in [Form.Visibility](/uilib/extensions/forms/Form/Visibility/). +- Fixed unnecessary rerenders in [Form.Handler](/uilib/extensions/forms/Form/Handler/). +- Fixed handling of multiple file upload actions when using async `fileHandler` in [Field.Upload](/uilib/extensions/forms/feature-fields/more-fields/Upload/). + ## v10.58 - Added `variant="filled"` to [Iterate.ViewContainer](/uilib/extensions/forms/Iterate/ViewContainer/) and [Iterate.EditContainer](/uilib/extensions/forms/Iterate/EditContainer/), to render with a background color. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/Examples.tsx index ced06d01900..5cac2ffc81e 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/Examples.tsx @@ -184,3 +184,70 @@ export const WithSyncFileHandler = () => { ) } + +export const WithAsyncOnFileDelete = () => { + return ( + + {() => { + async function mockAsyncFileRemoval({ fileItem }) { + const request = createRequest() + console.log( + 'making API request to remove: ' + fileItem.file.name, + ) + await request(3000) // Simulate a request + const mockResponse = { + successful_removal: Math.random() < 0.5, // Randomly fails to remove the file + } + if (!mockResponse.successful_removal) { + throw new Error('Unable to remove this file') + } + } + + return ( + + ) + }} + + ) +} + +export const WithAsyncOnFileClick = () => { + return ( + + {() => { + async function mockAsyncFileClick({ fileItem }) { + const request = createRequest() + console.log( + 'making API request to fetch the url of the file: ' + + fileItem.file.name, + ) + await request(2000) // Simulate a request + window.open( + 'https://eufemia.dnb.no/images/avatars/' + fileItem.file.name, + '_blank', + ) + } + + return ( + + + + ) + }} + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/demos.mdx index f7ce6eed8eb..370f8503667 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/more-fields/Upload/demos.mdx @@ -37,3 +37,11 @@ The `fileHandler` property supports an asynchronous function, and can be used fo The `fileHandler` property supports a synchronous function, and can be used for handling/validating files synchronously, like to check for file names that's too long: + +### With asynchronous `onFileDelete` + + + +### With asynchronous `onFileClick` + + 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 a90124edf63..b5a61763507 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 @@ -185,7 +185,7 @@ function MyComponent() { // You can also use the setData: Form.setData(myFormId, { companyName: 'DNB' }) -// ... and the getData – method when ever you need to: +// ... and the getData – method whenever you need to: const { getValue, data, filterData, reduceToVisibleFields } = Form.getData(myFormId) ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/typography.mdx b/packages/dnb-design-system-portal/src/docs/uilib/typography.mdx index daef198fe08..c0bbfcad33c 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/typography.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/typography.mdx @@ -73,3 +73,23 @@ Use it either by a CSS class `.dnb-t__family--monospace` or define your own like font-style: normal; } ``` + +## Hosted Fonts (CDN) + +The font files are hosted under the following URLs: + +### DNB + +- `https://eufemia.dnb.no/fonts/dnb/DNB-Regular.woff2` +- `https://eufemia.dnb.no/fonts/dnb/DNB-Medium.woff2` +- `https://eufemia.dnb.no/fonts/dnb/DNB-Bold.woff2` +- `https://eufemia.dnb.no/fonts/dnb/DNBMono-Regular.woff2` +- `https://eufemia.dnb.no/fonts/dnb/DNBMono-Medium.woff2` +- `https://eufemia.dnb.no/fonts/dnb/DNBMono-Bold.woff2` + +### Sbanken + +- `https://eufemia.dnb.no/fonts/sbanken/MaisonNeue.woff2` +- `https://eufemia.dnb.no/fonts/sbanken/Roboto-Regular.woff2` +- `https://eufemia.dnb.no/fonts/sbanken/Roboto-Medium.woff2` +- `https://eufemia.dnb.no/fonts/sbanken/Roboto-Bold.woff2` diff --git a/packages/dnb-eufemia/playwright.config.ts b/packages/dnb-eufemia/playwright.config.ts index efa6ddadd12..96507f97207 100644 --- a/packages/dnb-eufemia/playwright.config.ts +++ b/packages/dnb-eufemia/playwright.config.ts @@ -1,15 +1,16 @@ import { defineConfig } from '@playwright/test' +import { isCI } from 'repo-utils' export default defineConfig({ timeout: 30000, globalTimeout: 600000, reporter: 'list', testDir: './src/', - testMatch: '*__tests__/*.spec.ts', + testMatch: '*__tests__/**/*.spec.ts', use: { // Base URL to use in actions like `await page.goto('/')`. - baseURL: 'http://localhost:8001', + baseURL: isCI ? 'http://localhost:8001' : 'http://localhost:8000', // Name of the browser that runs tests. For example `chromium`, `firefox`, `webkit`. browserName: 'firefox', diff --git a/packages/dnb-eufemia/src/__tests__/e2e/Fonts.e2e.spec.ts b/packages/dnb-eufemia/src/__tests__/e2e/Fonts.e2e.spec.ts new file mode 100644 index 00000000000..a3562e9ba37 --- /dev/null +++ b/packages/dnb-eufemia/src/__tests__/e2e/Fonts.e2e.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test' + +test.describe('Fonts', () => { + test('verify fonts are served correctly', async ({ page }) => { + const fonts = [ + // DNB + 'dnb/DNB-Regular.woff2', + 'dnb/DNB-Medium.woff2', + 'dnb/DNB-Bold.woff2', + 'dnb/DNBMono-Regular.woff2', + 'dnb/DNBMono-Medium.woff2', + 'dnb/DNBMono-Bold.woff2', + 'dnb/skeleton/DNB-Skeleton-Regular.woff2', + 'dnb/skeleton/DNB-Skeleton-Medium.woff2', + 'dnb/skeleton/DNB-Skeleton-Bold.woff2', + + // Sbanken + 'sbanken/MaisonNeue.woff2', + 'sbanken/Roboto-Regular.woff2', + 'sbanken/Roboto-Medium.woff2', + 'sbanken/Roboto-Bold.woff2', + ] + + for await (const font of fonts) { + const url = `/fonts/${font}` + const response = await page.request.get(url) + console.log('response', response) + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toContain('font') // Ensure it's a font file + } + }) +}) diff --git a/packages/dnb-design-system-portal/src/e2e/typography.spec.ts b/packages/dnb-eufemia/src/__tests__/e2e/Typography.e2e.spec.ts similarity index 99% rename from packages/dnb-design-system-portal/src/e2e/typography.spec.ts rename to packages/dnb-eufemia/src/__tests__/e2e/Typography.e2e.spec.ts index 53e3dce4e78..b112753d468 100644 --- a/packages/dnb-design-system-portal/src/e2e/typography.spec.ts +++ b/packages/dnb-eufemia/src/__tests__/e2e/Typography.e2e.spec.ts @@ -12,7 +12,7 @@ test.afterEach(async ({ page }) => { test.describe('Typography for UI', () => { test.beforeEach(async ({ page }) => { await page.goto( - '/quickguide-designer/fonts?data-visual-test&eufemia-theme=ui', + '/quickguide-designer/fonts?data-visual-test&eufemia-theme=ui' ) // Check if app is mounted @@ -99,7 +99,7 @@ test.describe('Typography for UI', () => { test.describe('Typography for Sbanken', () => { test.beforeEach(async ({ page }) => { await page.goto( - '/quickguide-designer/fonts?data-visual-test&eufemia-theme=sbanken', + '/quickguide-designer/fonts?data-visual-test&eufemia-theme=sbanken' ) // Check if app is mounted @@ -166,7 +166,7 @@ test.describe('Typography for Sbanken', () => { '.typography-box > .dnb-t__weight--medium', { state: 'attached', - }, + } ) const element = page .locator('.typography-box > .dnb-t__weight--medium') diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-fullscreen-window.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-fullscreen-window.snap.png index 656a3a466da..83037aff3eb 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-fullscreen-window.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-sbanken-have-to-match-the-dialog-fullscreen-window.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-ui-have-to-match-the-dialog-fullscreen-window.snap.png b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-ui-have-to-match-the-dialog-fullscreen-window.snap.png index bed79ee25e8..e0b0ff824bd 100644 Binary files a/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-ui-have-to-match-the-dialog-fullscreen-window.snap.png and b/packages/dnb-eufemia/src/components/dialog/__tests__/__image_snapshots__/dialog-for-ui-have-to-match-the-dialog-fullscreen-window.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap b/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap index 62b3becfd47..4bfa4b3c2d2 100644 --- a/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap +++ b/packages/dnb-eufemia/src/components/dialog/__tests__/__snapshots__/Dialog.test.tsx.snap @@ -1006,7 +1006,8 @@ html[data-visual-test] .dnb-modal__overlay, .dnb-modal__overlay--no-animation { max-height: 100vh; overflow: hidden; } -.dnb-dialog .dnb-scroll-view { +.dnb-dialog > .dnb-scroll-view { + height: 100%; max-height: 90vh; } .dnb-dialog__inner { diff --git a/packages/dnb-eufemia/src/components/dialog/style/dnb-dialog.scss b/packages/dnb-eufemia/src/components/dialog/style/dnb-dialog.scss index 006d812d090..fa182dcd56f 100644 --- a/packages/dnb-eufemia/src/components/dialog/style/dnb-dialog.scss +++ b/packages/dnb-eufemia/src/components/dialog/style/dnb-dialog.scss @@ -28,7 +28,8 @@ max-height: 100vh; overflow: hidden; - .dnb-scroll-view { + & > .dnb-scroll-view { + height: 100%; // ensure a Dropdown opens with the full height max-height: 90vh; // make it scrollable } diff --git a/packages/dnb-eufemia/src/components/dropdown/__tests__/Dropdown.screenshot.test.ts b/packages/dnb-eufemia/src/components/dropdown/__tests__/Dropdown.screenshot.test.ts index a7229155f37..29a77f5298d 100644 --- a/packages/dnb-eufemia/src/components/dropdown/__tests__/Dropdown.screenshot.test.ts +++ b/packages/dnb-eufemia/src/components/dropdown/__tests__/Dropdown.screenshot.test.ts @@ -160,70 +160,70 @@ describe.each(['ui', 'sbanken'])('Dropdown for %s', (themeName) => { url: '/uilib/components/dropdown/demos', pageViewport: { width: 480, - height: 480, + height: 480 * 2, }, }) - it('have to match different item directions', async () => { + it('have to match the tertiary variant opened on left side', async () => { const screenshot = await makeScreenshot({ - style: { - 'padding-top': '16rem', - }, - selector: '[data-visual-test="dropdown-item-directions"]', + selector: '[data-visual-test="dropdown-tertiary"]', simulate: 'click', simulateSelector: - '[data-visual-test="dropdown-item-directions"] .dnb-dropdown__trigger', + '[data-visual-test="dropdown-tertiary"] .dnb-dropdown__trigger', simulateAfter: { keypress: 'Escape' }, + style: { + 'padding-bottom': '16rem', + }, }) expect(screenshot).toMatchImageSnapshot() }) - it('have to match the dropdown as more_menu opened on left side', async () => { + it('have to match the tertiary variant opened on right side', async () => { const screenshot = await makeScreenshot({ - selector: '[data-visual-test="dropdown-more_menu"]', + selector: '[data-visual-test="dropdown-tertiary-right"]', simulate: 'click', simulateSelector: - '[data-visual-test="dropdown-more_menu"] .dnb-dropdown:first-child button', + '[data-visual-test="dropdown-tertiary-right"] .dnb-dropdown__trigger', simulateAfter: { keypress: 'Escape' }, + style: { + 'padding-bottom': '16rem', + }, }) expect(screenshot).toMatchImageSnapshot() }) - it('have to match the dropdown as more_menu opened on right side', async () => { + it('have to match different item directions', async () => { const screenshot = await makeScreenshot({ - selector: '[data-visual-test="dropdown-more_menu"]', + style: { + 'padding-top': '16rem', + }, + selector: '[data-visual-test="dropdown-item-directions"]', simulate: 'click', simulateSelector: - '[data-visual-test="dropdown-more_menu"] .dnb-dropdown:nth-child(2) button', + '[data-visual-test="dropdown-item-directions"] .dnb-dropdown__trigger', simulateAfter: { keypress: 'Escape' }, }) expect(screenshot).toMatchImageSnapshot() }) - it('have to match the tertiary variant opened on left side', async () => { + it('have to match the dropdown as more_menu opened on left side', async () => { const screenshot = await makeScreenshot({ - selector: '[data-visual-test="dropdown-tertiary"]', + selector: '[data-visual-test="dropdown-more_menu"]', simulate: 'click', simulateSelector: - '[data-visual-test="dropdown-tertiary"] .dnb-dropdown__trigger', + '[data-visual-test="dropdown-more_menu"] .dnb-dropdown:first-child button', simulateAfter: { keypress: 'Escape' }, - style: { - 'padding-bottom': '16rem', - }, }) expect(screenshot).toMatchImageSnapshot() }) - it('have to match the tertiary variant opened on right side', async () => { + it('have to match the dropdown as more_menu opened on right side', async () => { const screenshot = await makeScreenshot({ - selector: '[data-visual-test="dropdown-tertiary-right"]', + selector: '[data-visual-test="dropdown-more_menu"]', simulate: 'click', simulateSelector: - '[data-visual-test="dropdown-tertiary-right"] .dnb-dropdown__trigger', + '[data-visual-test="dropdown-more_menu"] .dnb-dropdown:nth-child(2) button', simulateAfter: { keypress: 'Escape' }, - style: { - 'padding-bottom': '16rem', - }, }) expect(screenshot).toMatchImageSnapshot() }) @@ -234,6 +234,7 @@ describe.each(['ui', 'sbanken'])('Dropdown for %s', (themeName) => { simulate: 'click', simulateSelector: '[data-visual-test="dropdown-action_menu-custom"] .dnb-dropdown__trigger', + simulateAfter: { keypress: 'Escape' }, style: { width: '14rem', }, diff --git a/packages/dnb-eufemia/src/components/dropdown/__tests__/__image_snapshots__/dropdown-for-sbanken-have-to-match-the-tertiary-variant-opened-on-right-side.snap.png b/packages/dnb-eufemia/src/components/dropdown/__tests__/__image_snapshots__/dropdown-for-sbanken-have-to-match-the-tertiary-variant-opened-on-right-side.snap.png index 5530e7253e2..634dbcc89af 100644 Binary files a/packages/dnb-eufemia/src/components/dropdown/__tests__/__image_snapshots__/dropdown-for-sbanken-have-to-match-the-tertiary-variant-opened-on-right-side.snap.png and b/packages/dnb-eufemia/src/components/dropdown/__tests__/__image_snapshots__/dropdown-for-sbanken-have-to-match-the-tertiary-variant-opened-on-right-side.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dropdown/__tests__/__image_snapshots__/dropdown-for-ui-have-to-match-the-tertiary-variant-opened-on-right-side.snap.png b/packages/dnb-eufemia/src/components/dropdown/__tests__/__image_snapshots__/dropdown-for-ui-have-to-match-the-tertiary-variant-opened-on-right-side.snap.png index eed969d0109..bd913178271 100644 Binary files a/packages/dnb-eufemia/src/components/dropdown/__tests__/__image_snapshots__/dropdown-for-ui-have-to-match-the-tertiary-variant-opened-on-right-side.snap.png and b/packages/dnb-eufemia/src/components/dropdown/__tests__/__image_snapshots__/dropdown-for-ui-have-to-match-the-tertiary-variant-opened-on-right-side.snap.png differ diff --git a/packages/dnb-eufemia/src/components/dropdown/stories/Dropdown.stories.tsx b/packages/dnb-eufemia/src/components/dropdown/stories/Dropdown.stories.tsx index e2ff853fd2e..b612481f121 100644 --- a/packages/dnb-eufemia/src/components/dropdown/stories/Dropdown.stories.tsx +++ b/packages/dnb-eufemia/src/components/dropdown/stories/Dropdown.stories.tsx @@ -19,9 +19,10 @@ import { Drawer, GlobalStatus, } from '../..' -import { Flex, Link } from '../../..' +import { Dialog, Flex, Link } from '../../..' import { DrawerListDataArray } from '../../../fragments/DrawerList' import { Provider } from '../../../shared' +import { Field, Form } from '../../../extensions/forms' export default { title: 'Eufemia/Components/Dropdown', @@ -1004,3 +1005,42 @@ export const GlobalStatusExample = () => { ) } + +export function InDialog() { + const list = Array(30).fill('Content') + return ( + + + + + + + + + + + + + + ) +} diff --git a/packages/dnb-eufemia/src/components/height-animation/HeightAnimation.tsx b/packages/dnb-eufemia/src/components/height-animation/HeightAnimation.tsx index ec0781c9a16..f8feff01647 100644 --- a/packages/dnb-eufemia/src/components/height-animation/HeightAnimation.tsx +++ b/packages/dnb-eufemia/src/components/height-animation/HeightAnimation.tsx @@ -48,7 +48,7 @@ export type HeightAnimationProps = { export type HeightAnimationAllProps = HeightAnimationProps & SpacingProps & - Omit, 'ref'> + Omit, 'ref' | 'onAnimationEnd'> function HeightAnimation({ open = true, diff --git a/packages/dnb-eufemia/src/components/height-animation/HeightAnimationDocs.ts b/packages/dnb-eufemia/src/components/height-animation/HeightAnimationDocs.ts index 078afa71f57..f2fea3049b4 100644 --- a/packages/dnb-eufemia/src/components/height-animation/HeightAnimationDocs.ts +++ b/packages/dnb-eufemia/src/components/height-animation/HeightAnimationDocs.ts @@ -60,12 +60,12 @@ export const HeightAnimationEvents: PropertiesTableProps = { status: 'optional', }, onAnimationStart: { - doc: 'Is called when animation has started.', + doc: 'Is called when animation has started. The first parameter is a string. Depending on the state, the value can be `opening`, `closing` or `adjusting`.', type: 'function', status: 'optional', }, onAnimationEnd: { - doc: 'Is called when animation is done and the full height is reached.', + doc: 'Is called when animation is done and the full height is reached. The first parameter is a string. Depending on the state, the value can be `opened`, `closed` or `adjusted`.', type: 'function', status: 'optional', }, diff --git a/packages/dnb-eufemia/src/components/upload/Upload.tsx b/packages/dnb-eufemia/src/components/upload/Upload.tsx index 4357615d4b6..67d82d287e4 100644 --- a/packages/dnb-eufemia/src/components/upload/Upload.tsx +++ b/packages/dnb-eufemia/src/components/upload/Upload.tsx @@ -52,6 +52,7 @@ const Upload = (localProps: UploadAllProps) => { fileMaxSize, onChange, onFileDelete, // eslint-disable-line + onFileClick, // eslint-disable-line download, // eslint-disable-line title, // eslint-disable-line text, // eslint-disable-line diff --git a/packages/dnb-eufemia/src/components/upload/UploadDocs.ts b/packages/dnb-eufemia/src/components/upload/UploadDocs.ts index 4e497a8674d..d4c71fa8cf5 100644 --- a/packages/dnb-eufemia/src/components/upload/UploadDocs.ts +++ b/packages/dnb-eufemia/src/components/upload/UploadDocs.ts @@ -67,4 +67,9 @@ export const UploadEvents: PropertiesTableProps = { type: 'function', status: 'optional', }, + onFileClick: { + doc: 'Will be called once a file gets clicked on by the user. Access the clicked file with `{ fileItem }`.', + type: 'function', + status: 'optional', + }, } diff --git a/packages/dnb-eufemia/src/components/upload/UploadFileList.tsx b/packages/dnb-eufemia/src/components/upload/UploadFileList.tsx index 3476c03f3ff..08477a46be1 100644 --- a/packages/dnb-eufemia/src/components/upload/UploadFileList.tsx +++ b/packages/dnb-eufemia/src/components/upload/UploadFileList.tsx @@ -1,8 +1,9 @@ -import React from 'react' +import React, { useEffect, useRef } from 'react' import { UploadFile } from './types' import { UploadContext } from './UploadContext' import UploadFileListCell from './UploadFileListCell' import useUpload from './useUpload' +import { isAsync } from '../../shared/helpers/isAsync' function UploadFileList() { const context = React.useContext(UploadContext) @@ -14,32 +15,110 @@ function UploadFileList() { download, loadingText, onFileDelete, + onFileClick, onChange, } = context const { files, setFiles, setInternalFiles } = useUpload(id) + const filesRef = useRef(null) + + useEffect(() => { + filesRef.current = files + }, [files]) + if (files === null || files.length < 1) { return null } + const removeFile = (fileToBeRemoved: UploadFile) => { + return filesRef.current.filter( + (fileListElement) => fileListElement.file != fileToBeRemoved.file + ) + } + + const updateFile = ( + fileToBeUpdated: UploadFile, + props: Partial + ) => { + return filesRef.current.map((fileListElement) => + fileListElement.id === fileToBeUpdated.id + ? { + ...fileListElement, + ...props, + } + : fileListElement + ) + } + + const updateFiles = (updatedFiles: UploadFile[]) => { + setFiles(updatedFiles) + setInternalFiles(updatedFiles) + + if (typeof onChange === 'function') { + onChange({ files: updatedFiles }) + } + } + + const handleDeleteAsync = async (uploadFile: UploadFile) => { + updateFiles( + updateFile(uploadFile, { + isLoading: true, + errorMessage: null, + }) + ) + + try { + await onFileDelete({ fileItem: uploadFile }) + updateFiles(removeFile(uploadFile)) + } catch (error) { + updateFiles( + updateFile(uploadFile, { + isLoading: false, + errorMessage: error.message, + }) + ) + } + } + + const handleFileClickAsync = async (uploadFile: UploadFile) => { + updateFiles( + updateFile(uploadFile, { + isLoading: true, + }) + ) + + await onFileClick({ fileItem: uploadFile }) + updateFiles( + updateFile(uploadFile, { + isLoading: false, + }) + ) + } + return (
    {files.map((uploadFile: UploadFile, index: number) => { - const onDeleteHandler = () => { + const onDeleteHandler = async () => { if (typeof onFileDelete === 'function') { - onFileDelete({ fileItem: uploadFile }) + if (isAsync(onFileDelete)) { + handleDeleteAsync(uploadFile) + } else { + onFileDelete({ fileItem: uploadFile }) + updateFiles(removeFile(uploadFile)) + } + } else { + updateFiles(removeFile(uploadFile)) } + } - const cleanedFiles = files.filter( - (fileListElement) => fileListElement.file != uploadFile.file - ) - - setFiles(cleanedFiles) - setInternalFiles(cleanedFiles) - - if (typeof onChange === 'function') { - onChange({ files: cleanedFiles }) + const onFileClickHandler = async () => { + if (typeof onFileClick === 'function') { + if (isAsync(onFileClick)) { + handleFileClickAsync(uploadFile) + } else { + onFileClick({ fileItem: uploadFile }) + } } } @@ -49,6 +128,7 @@ function UploadFileList() { id={id} uploadFile={uploadFile} onDelete={onDeleteHandler} + onClick={onFileClick && onFileClickHandler} deleteButtonText={deleteButton} loadingText={loadingText} download={download} diff --git a/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx b/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx index 8de4f53a3a0..f01b542aac9 100644 --- a/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx +++ b/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx @@ -2,7 +2,6 @@ import React, { useRef } from 'react' import classnames from 'classnames' // Components -import Anchor from '../../components/Anchor' import Button from '../button/Button' import Icon from '../../components/Icon' import FormStatus from '../../components/FormStatus' @@ -29,6 +28,7 @@ import { UploadFile, UploadFileNative } from './types' import { getPreviousSibling, warn } from '../../shared/component-helper' import useUpload from './useUpload' import { getFileTypeFromExtension } from './UploadVerify' +import UploadFileLink from './UploadFileListLink' // Will be deprecated - and then default to only showing the file icon, // and not file icon per file extension type @@ -60,6 +60,11 @@ export type UploadFileListCellProps = { */ onDelete: () => void + /** + * Calls onClick when clicking the file name + */ + onClick?: () => void + /** * Causes the browser to treat all listed files as downloadable instead of opening them in a new browser tab or window. * Default: false @@ -77,6 +82,7 @@ const UploadFileListCell = ({ id, uploadFile, onDelete, + onClick, loadingText, deleteButtonText, download, @@ -182,18 +188,12 @@ const UploadFileListCell = ({
) : (
- - {file.name} - + download={download} + onClick={onClick} + />
) } diff --git a/packages/dnb-eufemia/src/components/upload/UploadFileListLink.tsx b/packages/dnb-eufemia/src/components/upload/UploadFileListLink.tsx new file mode 100644 index 00000000000..9a96178d4ca --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/UploadFileListLink.tsx @@ -0,0 +1,71 @@ +import React from 'react' + +import Anchor from '../../components/Anchor' +import Button from '../button/Button' +import { SpacingProps } from '../space/types' +import { createSpacingClasses } from '../space/SpacingUtils' +import classNames from 'classnames' + +export type UploadFileLinkProps = UploadFileAnchorProps & + UploadFileButtonProps + +export const UploadFileLink = (props: UploadFileLinkProps) => { + const { onClick, text, href, download, ...rest } = props + if (onClick) + return + return ( + + ) +} + +export default UploadFileLink + +type UploadFileButtonProps = { + text: string + onClick?: () => void +} & SpacingProps + +const UploadFileButton = (props: UploadFileButtonProps) => { + const { text, onClick } = props + + const spacingClasses = createSpacingClasses(props) + return ( + + ) +} + +type UploadFileAnchorProps = { + text: string + href: string + download?: boolean +} & SpacingProps + +const UploadFileAnchor = (props: UploadFileAnchorProps) => { + const { text, href, download } = props + + const spacingClasses = createSpacingClasses(props) + + return ( + + {text} + + ) +} diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.ts b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.ts index 0f6e1f243e8..494d8ffd0ab 100644 --- a/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.ts +++ b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.ts @@ -57,6 +57,13 @@ describe.each(['ui', 'sbanken'])('Upload for %s', (themeName) => { }) expect(screenshot).toMatchImageSnapshot() }) + + it('have to match anchor looks when displaying a button', async () => { + const screenshot = await makeScreenshot({ + selector: '[data-visual-test="upload-on-file-click"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) }) describe('Upload', () => { diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx index bfce15d7d00..81d2c928c6a 100644 --- a/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx +++ b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx @@ -10,7 +10,7 @@ import Upload from '../Upload' import nbNO from '../../../shared/locales/nb-NO' import enGB from '../../../shared/locales/en-GB' import { createMockFile } from './testHelpers' -import { loadScss, axeComponent } from '../../../core/jest/jestSetup' +import { loadScss, axeComponent, wait } from '../../../core/jest/jestSetup' import { UploadAllProps } from '../types' import useUpload from '../useUpload' import Provider from '../../../shared/Provider' @@ -967,8 +967,77 @@ describe('Upload', () => { expect(onChange).toHaveBeenCalledWith({ files: [] }) }) + it('will call onFileClick when file gets clicked', async () => { + const id = 'onFileClick-sync' + const onFileClick = jest.fn() + + render( + + ) + + const inputElement = document.querySelector( + '.dnb-upload__file-input' + ) + + const file1 = createMockFile('fileName-1.png', 100, 'image/png') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1] }, + }) + ) + + const fileButton = document.querySelector( + '.dnb-upload__file-cell button' + ) + + await waitFor(() => fireEvent.click(fileButton)) + + expect(onFileClick).toHaveBeenCalledTimes(1) + expect(onFileClick).toHaveBeenCalledWith({ + fileItem: { + file: file1, + id: expect.any(String), + exists: false, + }, + }) + }) + + it('will display loading state when onFileClick is async function', async () => { + const id = 'onFileClick-async' + const onFileClick = jest.fn(async () => { + await wait(1) + }) + + render( + + ) + + const inputElement = document.querySelector( + '.dnb-upload__file-input' + ) + const file1 = createMockFile('fileName-1.png', 100, 'image/png') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1] }, + }) + ) + + const fileButton = document.querySelector( + '.dnb-upload__file-cell button' + ) + + await waitFor(() => { + fireEvent.click(fileButton) + expect( + document.querySelector('.dnb-progress-indicator') + ).toBeInTheDocument() + }) + }) + it('will call onFileDelete when file gets removed', async () => { - const id = 'onFileDelete' + const id = 'onFileDelete-sync' const onFileDelete = jest.fn() render( @@ -997,6 +1066,153 @@ describe('Upload', () => { fileItem: { file: file1, id: expect.any(String), exists: false }, }) }) + + it('will display loading state when onFileDelete is async function', async () => { + const id = 'onFileDelete-async' + const onFileDelete = jest.fn(async () => { + await wait(1) + }) + + render( + + ) + + const inputElement = document.querySelector( + '.dnb-upload__file-input' + ) + const file1 = createMockFile('fileName-1.png', 100, 'image/png') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1] }, + }) + ) + + const deleteButton = screen.queryByRole('button', { + name: nb.deleteButton, + }) + + await waitFor(() => { + fireEvent.click(deleteButton) + expect( + document.querySelector('.dnb-progress-indicator') + ).toBeInTheDocument() + }) + }) + + it('will call onFileDelete when async function succeed', async () => { + const id = 'onFileDelete-async-success' + const onFileDelete = jest.fn(async () => { + await wait(1) + }) + + render( + + ) + + const inputElement = document.querySelector( + '.dnb-upload__file-input' + ) + const file1 = createMockFile('fileName-1.png', 100, 'image/png') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1] }, + }) + ) + + const deleteButton = screen.queryByRole('button', { + name: nb.deleteButton, + }) + + await waitFor(() => fireEvent.click(deleteButton)) + + await waitFor(() => { + expect(onFileDelete).toHaveBeenCalledTimes(1) + expect(onFileDelete).toHaveBeenCalledWith({ + fileItem: { + file: file1, + id: expect.any(String), + exists: false, + }, + }) + }) + }) + + it('will call onFileDelete when async function fails', async () => { + const id = 'onFileDelete-async-fail' + const onFileDelete = jest.fn(async () => { + await wait(1) + + throw new Error('My remove file message error') + }) + + render( + + ) + + const inputElement = document.querySelector( + '.dnb-upload__file-input' + ) + const file1 = createMockFile('fileName-1.png', 100, 'image/png') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1] }, + }) + ) + + const deleteButton = screen.queryByRole('button', { + name: nb.deleteButton, + }) + + await waitFor(() => fireEvent.click(deleteButton)) + + await waitFor(() => { + expect(onFileDelete).toHaveBeenCalledTimes(1) + expect(onFileDelete).toHaveBeenCalledWith({ + fileItem: { + file: file1, + id: expect.any(String), + exists: false, + }, + }) + }) + }) + + it('will display error message when async onFileDelete function fails', async () => { + const id = 'onFileDelete-async-fail-error-message' + const onFileDelete = jest.fn(async () => { + await wait(1) + + throw new Error('My remove file message error') + }) + + render( + + ) + + const inputElement = document.querySelector( + '.dnb-upload__file-input' + ) + const file1 = createMockFile('fileName-1.png', 100, 'image/png') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1] }, + }) + ) + + const deleteButton = screen.queryByRole('button', { + name: nb.deleteButton, + }) + + await waitFor(() => fireEvent.click(deleteButton)) + + expect( + screen.queryByText('My remove file message error') + ).toBeInTheDocument() + }) }) }) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx index 2549306ead0..901b33df4fa 100644 --- a/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx +++ b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx @@ -2,7 +2,7 @@ import UploadFileListCell, { UploadFileListCellProps, } from '../UploadFileListCell' import { createMockFile } from './testHelpers' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import React from 'react' global.URL.createObjectURL = jest.fn(() => 'url') @@ -48,7 +48,9 @@ describe('UploadFileListCell', () => { /> ) - const element = document.querySelector('.dnb-upload__file-cell__title') + const element = document.querySelector( + '.dnb-upload__file-cell__text-container a' + ) expect(element.textContent).toMatch('file.dat') }) @@ -272,6 +274,27 @@ describe('UploadFileListCell', () => { 'dnb-upload__file-cell--error' ) }) + + it('executes onClick event when button is clicked', () => { + const onClick = jest.fn() + + render( + + ) + const element = document.querySelector( + '.dnb-upload__file-cell button' + ) + + fireEvent.click(element) + + expect(onClick).toHaveBeenCalledTimes(1) + }) }) describe('Delete Button', () => { @@ -322,6 +345,25 @@ describe('UploadFileListCell', () => { ).toBeInTheDocument() }) + it('executes onDelete event when delete button is clicked', () => { + const onDelete = jest.fn() + + render( + + ) + const element = screen.getByRole('button') + + fireEvent.click(element) + + expect(onDelete).toHaveBeenCalledTimes(1) + }) + it('renders the delete button as disabled when loading state', () => { render( 'url') + +const defaultProps: UploadFileLinkProps = { + text: 'text', + href: 'href', + download: false, + onClick: undefined, +} + +describe('UploadFileListLink', () => { + describe('as an anchor', () => { + it('renders the anchor', () => { + render() + expect(document.querySelector('.dnb-button')).not.toBeInTheDocument() + expect(document.querySelector('.dnb-a')).toBeInTheDocument() + }) + + it('renders the anchor text', () => { + const fileName = 'file.png' + + render() + expect(screen.queryByText(fileName)).toBeInTheDocument() + }) + + it('renders the anchor href', () => { + const fileName = 'file.png' + + const href = 'mock-url' + + render( + + ) + + const anchorElement = screen.queryByText( + fileName + ) as HTMLAnchorElement + expect(anchorElement.href).toMatch(href) + }) + + it('renders the download attribute', () => { + const fileName = 'file.png' + + render( + + ) + + const element = document.querySelector('.dnb-a') + + expect(element).toHaveAttribute('download', fileName) + }) + + it('supports spacing props', () => { + render() + + const element = document.querySelector('.dnb-a') + expect(element).toHaveClass('dnb-space__top--large') + }) + }) + + describe('as a button', () => { + it('renders the button', () => { + render() + expect(document.querySelector('.dnb-a')).not.toBeInTheDocument() + expect(document.querySelector('.dnb-button')).toBeInTheDocument() + }) + + it('renders the button text', () => { + const fileName = 'file.png' + + render( + + ) + expect(screen.queryByText(fileName)).toBeInTheDocument() + }) + + it('executes onClick event when button is clicked', () => { + const onClick = jest.fn() + + render() + const element = document.querySelector('.dnb-button') + + fireEvent.click(element) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('supports spacing props', () => { + render( + + ) + + const element = document.querySelector('.dnb-button') + expect(element).toHaveClass('dnb-space__top--large') + }) + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-sbanken-have-to-match-anchor-looks-when-displaying-a-button.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-sbanken-have-to-match-anchor-looks-when-displaying-a-button.snap.png new file mode 100644 index 00000000000..2439b622f99 Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-sbanken-have-to-match-anchor-looks-when-displaying-a-button.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-sbanken-have-to-match-the-loading-state.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-sbanken-have-to-match-the-loading-state.snap.png index 25ac1f4c40a..2cb3ce24874 100644 Binary files a/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-sbanken-have-to-match-the-loading-state.snap.png and b/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-sbanken-have-to-match-the-loading-state.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-ui-have-to-match-anchor-looks-when-displaying-a-button.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-ui-have-to-match-anchor-looks-when-displaying-a-button.snap.png new file mode 100644 index 00000000000..bb5f72a88ea Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-ui-have-to-match-anchor-looks-when-displaying-a-button.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-ui-have-to-match-the-loading-state.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-ui-have-to-match-the-loading-state.snap.png index e3c2d535a2b..9e0cdd81cce 100644 Binary files a/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-ui-have-to-match-the-loading-state.snap.png and b/packages/dnb-eufemia/src/components/upload/__tests__/__image_snapshots__/upload-for-ui-have-to-match-the-loading-state.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/types.ts b/packages/dnb-eufemia/src/components/upload/types.ts index e6c5f029c59..8b3ba2a634b 100644 --- a/packages/dnb-eufemia/src/components/upload/types.ts +++ b/packages/dnb-eufemia/src/components/upload/types.ts @@ -51,7 +51,20 @@ export type UploadProps = { /** * will be called once a file gets deleted by the user. Access the deleted file with `{ fileItem }`. */ - onFileDelete?: ({ fileItem }: { fileItem: UploadFile }) => void + onFileDelete?: ({ + fileItem, + }: { + fileItem: UploadFile + }) => void | Promise + + /** + * Will be called once a file gets clicked on by the user. Access the clicked file with `{ fileItem }`. + */ + onFileClick?: ({ + fileItem, + }: { + fileItem: UploadFile + }) => void | Promise /** * Causes the browser to treat all listed files as downloadable instead of opening them in a new browser tab or window. 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 d185f0c27da..f1b4f27a8e2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -718,7 +718,7 @@ export default function Provider( (Array.isArray(internalDataRef.current) ? [] : clearedData)) as Data if (id) { - setSharedData?.(internalDataRef.current) + setSharedData(internalDataRef.current) } forceUpdate() @@ -731,29 +731,28 @@ export default function Provider( useLayoutEffect(() => { // Set the shared state, if initialData was given - if (id && initialData && !sharedData.data) { - extendSharedData?.(initialData) + if (id) { + if (initialData && !sharedData.data) { + extendSharedData(initialData, { preventSyncOfSameInstance: true }) + } } }, [id, initialData, extendSharedData, sharedData.data]) - useMemo(() => { - executeAjvValidator() - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [internalDataRef.current]) // run validation when internal data has changed - useLayoutEffect(() => { if (id) { - extendAttachment?.({ - visibleDataHandler, - filterDataHandler, - hasErrors, - hasFieldError, - setShowAllErrors, - setSubmitState, - clearData, - fieldConnectionsRef, - }) + extendAttachment( + { + visibleDataHandler, + filterDataHandler, + hasErrors, + hasFieldError, + setShowAllErrors, + setSubmitState, + clearData, + fieldConnectionsRef, + }, + { preventSyncOfSameInstance: true } + ) if (filterSubmitData) { rerenderUseDataHook?.() } @@ -770,8 +769,15 @@ export default function Provider( setShowAllErrors, setSubmitState, clearData, + extendSharedData, ]) + useMemo(() => { + executeAjvValidator() + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [internalDataRef.current]) // run validation when internal data has changed + const storeInSession = useMemo(() => { return debounce( () => { @@ -795,7 +801,7 @@ export default function Provider( if (id) { // Will ensure that Form.getData() gets the correct data - extendSharedData?.(newData) + extendSharedData(newData, { preventSyncOfSameInstance: true }) if (filterSubmitData) { rerenderUseDataHook?.() } diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx index cc4c06c3e9f..e87c9352ec9 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx @@ -3423,12 +3423,8 @@ describe('DataContext.Provider', () => { ) - expect(nestedMockData).toHaveLength(3) - expect(nestedMockData).toEqual([ - initialData, - initialData, - initialData, - ]) + expect(nestedMockData).toHaveLength(2) + expect(nestedMockData).toEqual([initialData, initialData]) const inputElement = document.querySelector('input') expect(inputElement).toHaveValue('bar') @@ -3462,12 +3458,8 @@ describe('DataContext.Provider', () => { ) - expect(sidecarMockData).toHaveLength(3) - expect(sidecarMockData).toEqual([ - undefined, - initialData, - initialData, - ]) + expect(sidecarMockData).toHaveLength(2) + expect(sidecarMockData).toEqual([undefined, initialData]) expect(nestedMockData).toHaveLength(2) expect(nestedMockData).toEqual([initialData, initialData]) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx index 08dae3728d1..eceb14fa643 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' import classnames from 'classnames' import FieldBlock, { Props as FieldBlockProps, @@ -38,7 +38,9 @@ export type Props = Omit< | 'filesAmountLimit' | 'fileMaxSize' | 'onFileDelete' + | 'onFileClick' | 'skeleton' + | 'download' > & { fileHandler?: ( newFiles: UploadValue @@ -113,10 +115,17 @@ function UploadComponent(props: Props) { fileMaxSize = 5, skeleton, onFileDelete, + onFileClick, } = rest const { files: fileContext, setFiles } = useUpload(id) + const filesRef = useRef>() + + useEffect(() => { + filesRef.current = fileContext + }, [fileContext]) + useEffect(() => { // Files stored in session storage will not have a property (due to serialization). const hasInvalidFiles = value?.some(({ file }) => !file?.name) @@ -126,10 +135,10 @@ function UploadComponent(props: Props) { }, [setFiles, value]) const handleChangeAsync = useCallback( - async (files: UploadValue) => { + async (existingFiles: UploadValue) => { // Filter out existing files const existingFileIds = fileContext?.map((file) => file.id) || [] - const newFiles = files.filter( + const newFiles = existingFiles.filter( (file) => !existingFileIds.includes(file.id) ) @@ -140,15 +149,26 @@ function UploadComponent(props: Props) { ...updateFileLoadingState(newFiles, { isLoading: true }), ]) - const uploadedFiles = updateFileLoadingState( - await fileHandler(newFiles), - { isLoading: false } + const incomingFiles = await fileHandler(newFiles) + + const uploadedFiles = updateFileLoadingState(incomingFiles, { + isLoading: false, + }) + + const indexOfFirstNewFile = filesRef.current.findIndex( + ({ id }) => id === newFiles[0].id ) + const updatedFiles = [ + ...filesRef.current.slice(0, indexOfFirstNewFile), + ...uploadedFiles, + ...filesRef.current.slice(indexOfFirstNewFile + newFiles.length), + ] + // Set error, if any - handleChange([...fileContext, ...uploadedFiles]) + handleChange(updatedFiles) } else { - handleChange(files) + handleChange(existingFiles) } }, [fileContext, setFiles, fileHandler, handleChange] @@ -190,6 +210,7 @@ function UploadComponent(props: Props) { skeleton={skeleton} onChange={changeHandler} onFileDelete={onFileDelete} + onFileClick={onFileClick} title={label ?? title} text={ help ? ( diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx index 97488363022..27400b7e807 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/__tests__/Upload.test.tsx @@ -72,6 +72,24 @@ describe('Field.Upload', () => { expect(text).toHaveTextContent('My Text') }) + it('should support onFileClick event', () => { + const onFileClick = jest.fn() + render( + + ) + + const element = document.querySelector('.dnb-upload__file-cell button') + + fireEvent.click(element) + + expect(onFileClick).toHaveBeenCalledTimes(1) + }) + it('should render files given in data context', () => { render( { }) }) + it('should add new files from fileHandler with async function with multiple actions', async () => { + const newFile = (fileId) => { + return createMockFile(`${fileId}.png`, 100, 'image/png') + } + + const files = [ + newFile(0), + newFile(1), + newFile(2), + newFile(3), + newFile(4), + newFile(5), + ] + + const asyncValidatorResolvingWithSuccess = (id) => + new Promise((resolve) => + setTimeout( + () => + resolve([ + { + file: files[id], + id: 'server_generated_id_' + id, + exists: false, + }, + ]), + 1 + ) + ) + + const asyncValidatorNeverResolving = () => + new Promise(() => undefined) + + const asyncFileHandlerFnSuccess = jest + .fn(asyncValidatorResolvingWithSuccess) + .mockReturnValueOnce(asyncValidatorResolvingWithSuccess(0)) + .mockReturnValueOnce(asyncValidatorNeverResolving()) + .mockReturnValueOnce(asyncValidatorResolvingWithSuccess(2)) + .mockReturnValueOnce(asyncValidatorNeverResolving()) + + render() + + const element = getRootElement() + + await waitFor(() => { + fireEvent.drop(element, { + dataTransfer: { + files: [files[0]], + }, + }) + + fireEvent.drop(element, { + dataTransfer: { + files: [files[1], files[3], files[4]], + }, + }) + + fireEvent.drop(element, { + dataTransfer: { + files: [files[2]], + }, + }) + + fireEvent.drop(element, { + dataTransfer: { + files: [files[5]], + }, + }) + + expect( + document.querySelectorAll('.dnb-upload__file-cell').length + ).toBe(6) + + expect(screen.queryByText('0.png')).toBeInTheDocument() + expect(screen.queryByText('1.png')).not.toBeInTheDocument() + expect(screen.queryByText('2.png')).toBeInTheDocument() + expect(screen.queryByText('3.png')).not.toBeInTheDocument() + expect(screen.queryByText('4.png')).not.toBeInTheDocument() + expect(screen.queryByText('5.png')).not.toBeInTheDocument() + + expect( + document.querySelectorAll('.dnb-progress-indicator').length + ).toBe(4) + }) + }) + it('should not add existing file using fileHandler with async function', async () => { const file = createMockFile('fileName.png', 100, 'image/png') diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx index 1c52a1c6557..db2837a8397 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/stories/Upload.stories.tsx @@ -96,3 +96,74 @@ export const WithAsyncFileHandler = () => { ) } + +export const AsyncEverything = () => { + const acceptedFileTypes = ['jpg', 'pdf', 'png'] + + async function mockAsyncFileRemoval({ fileItem }) { + const request = createRequest() + console.log('making API request to remove: ' + fileItem.file.name) + await request(3000) // Simulate a request + } + + async function mockAsyncOnFileClick({ fileItem }) { + const request = createRequest() + console.log( + 'making API request to fetch the url of the file: ' + + fileItem.file.name + ) + await request(3000) // Simulate a request + window.open( + 'https://eufemia.dnb.no/images/avatars/1501870.jpg', + '_blank' + ) + } + + async function mockAsyncFileUpload( + newFiles: UploadValue + ): Promise { + const updatedFiles: UploadValue = [] + + for (const [, file] of Object.entries(newFiles)) { + const formData = new FormData() + formData.append('file', file.file, file.file.name) + + const request = createRequest() + await request(3000) // Simulate a request + + const mockResponse = { + ok: false, // Fails virus check + json: async () => ({ + server_generated_id: + 'server_generated_id' + + '_' + + file.file.name + + '_' + + crypto.randomUUID(), + }), + } + + const data = await mockResponse.json() + updatedFiles.push({ + ...file, + id: data.server_generated_id, + }) + } + + return updatedFiles + } + + return ( + console.log(form)}> + + + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Element/Element.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Element/Element.tsx index 74344c0af3c..462dec3e8f3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Element/Element.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Element/Element.tsx @@ -78,7 +78,7 @@ export default function FormElement(props: Props) { key={key} state={key} id={`${id}-form-status-${key}`} - className="dnb-forms-status" + className="dnb-forms-form__status-message" show={Boolean(value)} no_animation={false} shellSpace={{ top: 'small' }} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/InfoOverlay.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/InfoOverlay.tsx new file mode 100644 index 00000000000..6f1a82c1542 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/InfoOverlay.tsx @@ -0,0 +1,202 @@ +import React, { useCallback, useContext, useRef } from 'react' +import classnames from 'classnames' +import Visibility from '../Visibility' +import DataContext from '../../DataContext/Context' +import { + SharedStateId, + useSharedState, +} from '../../../../shared/helpers/useSharedState' +import useMounted from '../../../../shared/helpers/useMounted' +import setContent, { InfoOverlayContent } from './setContent' +import { + Button, + Flex, + HeightAnimation, + Section, +} from '../../../../components' +import { HeightAnimationAllProps } from '../../../../components/HeightAnimation' +import { P } from '../../../../elements' +import { useTranslation } from '../../hooks' +import MainHeading from '../MainHeading' +import SubmitButton from '../SubmitButton' + +export type Props = { + /** + * The content to show. + * If not given, the children will be shown. + * Can be `success`, `error` or a custom content. + */ + content?: InfoOverlayContent + onCancel?: () => void + + /** Predefined content */ + success?: { + title?: React.ReactNode + description?: React.ReactNode + buttonText?: React.ReactNode + buttonHref?: string + buttonClickHandler?: () => void + } + /** Predefined content */ + error?: { + title?: React.ReactNode + description?: React.ReactNode + retryButton?: React.ReactNode + cancelButton?: React.ReactNode + } + + // Various props + id?: SharedStateId + children: React.ReactNode + className?: string +} + +function InfoOverlay(props: Props) { + const { id: idProp, formState } = useContext(DataContext) + + const { + id = idProp, + content: contentProp, + success, + error, + onCancel, + className, + children, + ...restProps + } = props + + const { data } = useSharedState<{ + content?: InfoOverlayContent + }>(id) + const { content = contentProp } = data || {} + + const translations = useTranslation() + const mountedRef = useMounted() + const innerRef = useRef(null) + const onAnimationEnd: HeightAnimationAllProps['onAnimationEnd'] = + useCallback( + (state) => { + if (mountedRef.current && state === 'opened') { + innerRef.current.focus?.() + } + }, + [mountedRef] + ) + + // To keep the content visible while hiding it with the HightAnimation + const currentContentRef = useRef() + if (content) { + currentContentRef.current = content + } + + const onCancelHandler = useCallback(() => { + if (id) { + setContent(id, undefined) + } + onCancel?.() + }, [id, onCancel]) + + const childrenAreVisible = + typeof content !== 'undefined' ? !(content === content) : undefined + const statusContentIsVisible = + typeof content !== 'undefined' ? content === content : false + const status = + typeof content === 'string' && !content.includes(' ') + ? content + : undefined + + let statusContent = content + + if (currentContentRef.current === 'success') { + const tr = translations.InfoOverlaySuccess + const { + title, + description, + buttonText, + buttonHref, + buttonClickHandler, + } = success || {} + + statusContent = ( +
+ + {title ?? tr.title} +

{description ?? tr.description}

+ +
+
+ ) + } else if (currentContentRef.current === 'error') { + const tr = translations.InfoOverlayError + const { title, description, cancelButton, retryButton } = error || {} + + statusContent = ( +
+ + {title ?? tr.title} + +

+ {formState === 'pending' + ? tr.retryingText + : description ?? tr.description} +

+
+ + + {retryButton ?? tr.retryButton} + +
+
+ ) + } + + return ( +
+ + {statusContent} + + + + {children} + +
+ ) +} + +InfoOverlay.setContent = setContent +InfoOverlay._supportsSpacingProps = true + +export default InfoOverlay diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/InfoOverlayDocs.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/InfoOverlayDocs.tsx new file mode 100644 index 00000000000..e7d16cbef72 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/InfoOverlayDocs.tsx @@ -0,0 +1,62 @@ +import { PropertiesTableProps } from '../../../../shared/types' + +export const InfoOverlaySuccessProperties: PropertiesTableProps = { + title: { + doc: 'The title of the component.', + type: 'React.Node', + status: 'optional', + }, + description: { + doc: 'The description of the component.', + type: 'React.Node', + status: 'optional', + }, + buttonText: { + doc: 'The text of the button.', + type: 'React.Node', + status: 'optional', + }, + buttonHref: { + doc: 'The href of the button.', + type: 'string', + status: 'optional', + }, + buttonClickHandler: { + doc: 'The click handler of the button.', + type: 'function', + status: 'optional', + }, + '[Section](/uilib/components/section/properties)': { + doc: 'All Section properties.', + type: 'various', + status: 'optional', + }, +} + +export const InfoOverlayErrorProperties: PropertiesTableProps = { + title: { + doc: 'The title of the component.', + type: 'React.Node', + status: 'optional', + }, + description: { + doc: 'The description of the component.', + type: 'React.Node', + status: 'optional', + }, + cancelButton: { + doc: 'The text of the cancel button.', + type: 'React.Node', + status: 'optional', + }, + retryButton: { + doc: 'The text of the retry button.', + type: 'React.Node', + status: 'optional', + }, + '[Section](/uilib/components/section/properties)': { + doc: 'All Section properties.', + type: 'various', + status: 'optional', + }, +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/__tests__/InfoOverlay.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/__tests__/InfoOverlay.test.tsx new file mode 100644 index 00000000000..2043b561060 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/__tests__/InfoOverlay.test.tsx @@ -0,0 +1,405 @@ +import React from 'react' +import { act, fireEvent, render } from '@testing-library/react' +import nbNO from '../../../constants/locales/nb-NO' +import { Form } from '../../..' + +describe('Form.InfoOverlay', () => { + it('should render success with correct text', () => { + const nb = nbNO['nb-NO'].InfoOverlaySuccess + const formId = {} + + render( + + fallback content + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('p')).toBeNull() + + act(() => { + Form.InfoOverlay.setContent(formId, 'success') + }) + + expect(document.querySelector('h2')).toHaveTextContent(nb.title) + expect(document.querySelector('p')).toHaveTextContent(nb.description) + + const anchors = document.querySelectorAll('a') + expect(anchors).toHaveLength(1) + + const [anchor] = Array.from(anchors) + expect(anchor).toHaveTextContent(nb.buttonText) + expect(anchor).toHaveAttribute('href', '/') + }) + + it('should render error with correct text', async () => { + const nb = nbNO['nb-NO'].InfoOverlayError + const formId = {} + + render( + + fallback content + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('p')).toBeNull() + + act(() => { + Form.InfoOverlay.setContent(formId, 'error') + }) + + expect(document.querySelector('h2')).toHaveTextContent(nb.title) + expect(document.querySelector('p')).toHaveTextContent(nb.description) + + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(2) + + const [backButton, retryButton] = Array.from(buttons) + expect(backButton).toHaveTextContent(nb.cancelButton) + expect(retryButton).toHaveTextContent(nb.retryButton) + }) + + it('should accept custom buttonHref', () => { + const formId = {} + + render( + + + fallback content + + + ) + + act(() => { + Form.InfoOverlay.setContent(formId, 'success') + }) + + const anchor = document.querySelector('a') + expect(anchor).toHaveAttribute('href', 'http://custom') + }) + + it('should disable href when buttonClickHandler is given', () => { + const formId = {} + const buttonClickHandler = jest.fn() + + render( + + + fallback content + + + ) + + act(() => { + Form.InfoOverlay.setContent(formId, 'success') + }) + + const button = document.querySelector('button') + fireEvent.click(button) + + expect(button).not.toHaveAttribute('href') + expect(buttonClickHandler).toHaveBeenCalledTimes(1) + }) + + it('should render success with custom text', () => { + const formId = {} + + render( + + + fallback content + + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('p')).toBeNull() + + act(() => { + Form.InfoOverlay.setContent(formId, 'success') + }) + + expect(document.querySelector('h2')).toHaveTextContent('Custom title') + expect(document.querySelector('p')).toHaveTextContent( + 'Custom description' + ) + + const anchors = document.querySelectorAll('a') + expect(anchors).toHaveLength(1) + + const [anchor] = Array.from(anchors) + expect(anchor).toHaveTextContent('Custom button text') + expect(anchor).toHaveAttribute('href', '/') + }) + + it('should render error with custom text', async () => { + const formId = {} + + render( + + + fallback content + + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('p')).toBeNull() + + act(() => { + Form.InfoOverlay.setContent(formId, 'error') + }) + + expect(document.querySelector('h2')).toHaveTextContent('Custom title') + expect(document.querySelector('p')).toHaveTextContent( + 'Custom description' + ) + + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(2) + + const [backButton, retryButton] = Array.from(buttons) + expect(backButton).toHaveTextContent('Custom cancel') + expect(retryButton).toHaveTextContent('Custom retry') + }) + + it('should keep children in the DOM', () => { + const formId = {} + + render( + + + content + + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('output')).toHaveTextContent('content') + + act(() => { + Form.InfoOverlay.setContent(formId, 'success') + }) + + expect(document.querySelector('h2')).toBeInTheDocument() + expect(document.querySelector('output')).toHaveTextContent('content') + }) + + it('should support "id" prop', () => { + const formId = {} + + render( + fallback content + ) + + const element = document.querySelector('.dnb-forms-info-overlay') + expect(element).not.toHaveTextContent('custom content') + + act(() => { + Form.InfoOverlay.setContent(formId, 'custom content') + }) + expect(element).toHaveTextContent('custom content') + }) + + it('"id" prop should take precedence over Form.Handler id', () => { + const formId = {} + + render( + + fallback content + + ) + + const element = document.querySelector('.dnb-forms-info-overlay') + expect(element).not.toHaveTextContent('custom content') + + act(() => { + Form.InfoOverlay.setContent(formId, 'custom content') + }) + expect(element).toHaveTextContent('custom content') + }) + + it('"setContent" should take precedence over content prop', () => { + const formId = {} + + render( + + never shown + + ) + + const element = document.querySelector('.dnb-forms-info-overlay') + expect(element).toHaveTextContent('other content') + + act(() => { + Form.InfoOverlay.setContent(formId, 'custom content') + }) + expect(element).toHaveTextContent('custom content') + }) + + it('should support "content" prop', () => { + render( + + never shown + + ) + + const element = document.querySelector('.dnb-forms-info-overlay') + expect(element).toHaveTextContent('custom content') + }) + + it('should not set class of "--*"', () => { + render( + + never shown + + ) + + const element = document.querySelector('.dnb-forms-info-overlay') + expect(element.className).toBe('dnb-forms-info-overlay dnb-no-focus') + }) + + it('should set class of "--success"', () => { + render( + never shown + ) + + const element = document.querySelector('.dnb-forms-info-overlay') + expect(element).toHaveClass('dnb-forms-info-overlay--success') + }) + + it('should set class of "--error"', () => { + render( + never shown + ) + + const element = document.querySelector('.dnb-forms-info-overlay') + expect(element).toHaveClass('dnb-forms-info-overlay--error') + }) + + it('should handle focus', () => { + const formId = {} + + render( + + + content + + + ) + + expect(document.querySelector('body')).toHaveFocus() + + act(() => { + Form.InfoOverlay.setContent(formId, 'success') + }) + + expect( + document.querySelector('.dnb-forms-info-overlay--success') + ).toHaveFocus() + + act(() => { + document.querySelector('body').focus() + Form.InfoOverlay.setContent(formId, undefined) + }) + + expect(document.querySelector('.dnb-forms-info-overlay')).toHaveFocus() + expect( + document.querySelector('.dnb-forms-info-overlay') + ).not.toHaveClass('dnb-forms-info-overlay--success') + + act(() => { + document.querySelector('body').focus() + Form.InfoOverlay.setContent(formId, 'success') + }) + + expect( + document.querySelector('.dnb-forms-info-overlay--success') + ).toHaveFocus() + expect(document.querySelector('.dnb-forms-info-overlay')).toHaveClass( + 'dnb-no-focus' + ) + expect( + document.querySelector('.dnb-forms-info-overlay') + ).toHaveAttribute('tabindex', '-1') + }) + + it('should show content when cancel button is clicked', () => { + const formId = {} + + render( + + + content + + + ) + + act(() => { + Form.InfoOverlay.setContent(formId, 'error') + }) + + expect(document.querySelector('.dnb-forms-info-overlay')).toHaveClass( + 'dnb-forms-info-overlay--error' + ) + + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(2) + + const [backButton] = Array.from(buttons) + + fireEvent.click(backButton) + + expect( + document.querySelector('.dnb-forms-info-overlay') + ).not.toHaveClass('dnb-forms-info-overlay--error') + }) + + it('should call onCancel when clicking on cancel button', () => { + const formId = {} + const onCancel = jest.fn() + + render( + + + content + + + ) + + act(() => { + Form.InfoOverlay.setContent(formId, 'error') + }) + + expect(document.querySelector('.dnb-forms-info-overlay')).toHaveClass( + 'dnb-forms-info-overlay--error' + ) + expect(onCancel).not.toHaveBeenCalled() + + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(2) + + const [backButton] = Array.from(buttons) + + fireEvent.click(backButton) + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/index.ts new file mode 100644 index 00000000000..d7affe5170a --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/index.ts @@ -0,0 +1,2 @@ +export { default } from './InfoOverlay' +export * from './InfoOverlay' diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/setContent.ts b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/setContent.ts new file mode 100644 index 00000000000..32cef9c1c9b --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/setContent.ts @@ -0,0 +1,18 @@ +import { + SharedStateId, + createSharedState, +} from '../../../../shared/helpers/useSharedState' + +export type InfoOverlayContent = + | 'success' + | 'error' + | React.ReactNode + | undefined + +export default function setContent( + id: SharedStateId, + content: InfoOverlayContent +) { + const sharedState = createSharedState(id) + sharedState.extend({ content }) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/stories/InfoOverlay.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/stories/InfoOverlay.stories.tsx new file mode 100644 index 00000000000..59afc2f03b3 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/InfoOverlay/stories/InfoOverlay.stories.tsx @@ -0,0 +1,71 @@ +import { Field, Form, Wizard } from '../../..' +import { Button } from '../../../../../components' + +export default { + title: 'Eufemia/Extensions/Forms/InfoOverlay', +} + +export function BothStatuses() { + const formId = () => null + return ( + <> + { + await new Promise((r) => setTimeout(r, 1000)) // Simulate a request + + Form.InfoOverlay.setContent(formId, 'success') + }} + > + + + + + + + + + + +
+ ----- Content ---- + + ) +} + +export function WithAWizard() { + return ( + { + await new Promise((r) => setTimeout(r, 1000)) + Form.InfoOverlay.setContent('unique-id', 'success') + console.log('data', data) + }} + > + + { + await new Promise((r) => setTimeout(r, 1000)) + }} + > + + + + + + + + + + + + ) +} 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 7583639a269..1032ce5f9ef 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/Visibility.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Visibility/Visibility.tsx @@ -1,17 +1,18 @@ -import React, { AriaAttributes } from 'react' +import React, { AriaAttributes, useCallback } from 'react' import { warn } from '../../../../shared/helpers' import useMountEffect from '../../../../shared/helpers/useMountEffect' +import useMounted from '../../../../shared/helpers/useMounted' import HeightAnimation, { - HeightAnimationProps, + HeightAnimationAllProps, } from '../../../../components/HeightAnimation' import FieldProvider from '../../Field/Provider' import useVisibility from './useVisibility' +import VisibilityContext from './VisibilityContext' import type { Path, UseFieldProps } from '../../types' import type { DataAttributes } from '../../hooks/useFieldProps' import { FilterData } from '../../DataContext' -import VisibilityContext from './VisibilityContext' export type VisibleWhen = | { @@ -76,11 +77,15 @@ export type Props = { animate?: boolean /** Keep the content in the DOM, even if it's not visible */ keepInDOM?: boolean + /** Callback for when the content gets visible. */ + onVisible?: HeightAnimationAllProps['onOpen'] + /** Callback for when animation has ended */ + onAnimationEnd?: HeightAnimationAllProps['onAnimationEnd'] /** To compensate for CSS gap between the rows, so animation does not jump during the animation. Provide a CSS unit or `auto`. Defaults to `null`. */ - compensateForGap?: HeightAnimationProps['compensateForGap'] + compensateForGap?: HeightAnimationAllProps['compensateForGap'] /** When visibility is hidden, and `keepInDOM` is true, pass these props to the children */ fieldPropsWhenHidden?: UseFieldProps & DataAttributes & AriaAttributes - element?: HeightAnimationProps['element'] + element?: HeightAnimationAllProps['element'] children: React.ReactNode /** @deprecated Use `visibleWhen` instead */ @@ -103,6 +108,8 @@ function Visibility({ visibleWhenNot, inferData, filterData, + onVisible, + onAnimationEnd, animate, keepInDOM, compensateForGap, @@ -141,6 +148,16 @@ function Visibility({ {children} ) + const mountedRef = useMounted() + + const onOpen: HeightAnimationAllProps['onOpen'] = useCallback( + (state) => { + if (mountedRef.current) { + onVisible?.(state) + } + }, + [mountedRef, onVisible] + ) if (animate) { const props = !open ? fieldPropsWhenHidden : null @@ -148,6 +165,8 @@ function Visibility({ return ( { ) }) + describe('events', () => { + it('should not call onVisible initially', async () => { + const onVisible = jest.fn() + + render( + + + + content + + + ) + + const checkbox = document.querySelector('input[type="checkbox"]') + + expect(onVisible).toHaveBeenCalledTimes(0) + + await userEvent.click(checkbox) + + expect(onVisible).toHaveBeenCalledTimes(1) + expect(onVisible).toHaveBeenLastCalledWith(true) + }) + + it('should call onVisible when visible again', async () => { + const onVisible = jest.fn() + + render( + + + + content + + + ) + + const checkbox = document.querySelector('input[type="checkbox"]') + + expect(onVisible).toHaveBeenCalledTimes(0) + + await userEvent.click(checkbox) + expect(onVisible).toHaveBeenCalledTimes(1) + expect(onVisible).toHaveBeenLastCalledWith(true) + + await userEvent.click(checkbox) + expect(onVisible).toHaveBeenCalledTimes(2) + expect(onVisible).toHaveBeenLastCalledWith(false) + }) + + it('should call onAnimationEnd when animation is done', async () => { + const onAnimationEnd = jest.fn() + + render( + + + + content + + + ) + + const checkbox = document.querySelector('input[type="checkbox"]') + + expect(onAnimationEnd).toHaveBeenCalledTimes(0) + + await userEvent.click(checkbox) + await waitFor(() => { + expect(onAnimationEnd).toHaveBeenCalledTimes(1) + expect(onAnimationEnd).toHaveBeenLastCalledWith('opened') + }) + + await userEvent.click(checkbox) + await waitFor(() => { + expect(onAnimationEnd).toHaveBeenLastCalledWith('closed') + }) + + await userEvent.click(checkbox) + await waitFor(() => { + expect(onAnimationEnd).toHaveBeenLastCalledWith('opened') + }) + }) + + it('should not call onAnimationEnd when "animation" is false', async () => { + const onAnimationEnd = jest.fn() + + render( + + + + content + + + ) + + const checkbox = document.querySelector('input[type="checkbox"]') + + expect(onAnimationEnd).toHaveBeenCalledTimes(0) + + await userEvent.click(checkbox) + + expect(() => { + expect(onAnimationEnd).toHaveBeenCalledTimes(0) + }).toNeverResolve() + }) + }) + it('should use given "element"', () => { render( diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts index 3b2c6c245b6..ac25a3abde4 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts @@ -11,6 +11,7 @@ export { default as MainHeading } from './MainHeading' export { default as SubHeading } from './SubHeading' export { default as Visibility } from './Visibility' export { default as Section } from './Section' +export { default as InfoOverlay } from './InfoOverlay' export { default as Card } from './Card' export { default as Isolation } from './Isolation' export { default as Snapshot } from './Snapshot' diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/Upload/Upload.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/Upload/Upload.tsx index 7e2f95674ac..ff0cb78f6a7 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Value/Upload/Upload.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Value/Upload/Upload.tsx @@ -3,25 +3,23 @@ import classnames from 'classnames' import { useValueProps } from '../../hooks' import { ValueProps } from '../../types' import ValueBlock from '../../ValueBlock' -import { Anchor } from '../../../../components' import Icon from '../../../../components/Icon' import ListFormat, { ListFormatProps, } from '../../../../components/list-format' -import type { - UploadFile, - UploadProps, -} from '../../../../components/upload/types' +import type { UploadFile } from '../../../../components/upload/types' import { fileExtensionImages } from '../../../../components/upload/UploadFileListCell' import { BYTES_IN_A_MEGA_BYTE, getFileTypeFromExtension, } from '../../../../components/upload/UploadVerify' +import { Props as FieldUploadProps } from '../../Field/Upload/Upload' import { format } from '../../../../components/number-format/NumberUtils' +import { UploadFileLink } from '../../../../components/upload/UploadFileListLink' export type Props = ValueProps> & Omit & - Pick & { + Pick & { displaySize?: boolean } @@ -35,6 +33,7 @@ function Upload(props: Props) { listType, download = false, displaySize = false, + onFileClick, ...rest } = useValueProps(props) @@ -45,21 +44,28 @@ function Upload(props: Props) { if (!file) { return } + const onFileClickHandler = () => { + if (typeof onFileClick === 'function') { + onFileClick({ fileItem: uploadFile }) + } + } + const imageUrl = URL.createObjectURL(file) + + const text = + file.name + (displaySize ? ' ' + getSize(file.size) : '') + return ( {getIcon(file)} - - {file.name} - {displaySize && getSize(file.size)} - + text={text} + href={imageUrl} + download={download} + onClick={onFileClick && onFileClickHandler} + /> ) }) || undefined diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/Upload/UploadDocs.ts b/packages/dnb-eufemia/src/extensions/forms/Value/Upload/UploadDocs.ts index 29ab7016fc9..552c42334b8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Value/Upload/UploadDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Value/Upload/UploadDocs.ts @@ -2,7 +2,9 @@ import { PropertiesTableProps } from '../../../../shared/types' import { ListFormatProperties } from '../../../../components/list-format/ListFormatDocs' -export const UploadProperties: PropertiesTableProps = { +import { UploadFieldEvents } from '../../Field/Upload/UploadDocs' + +export const UploadValueProperties: PropertiesTableProps = { download: { doc: 'Causes the browser to treat all listed files as downloadable instead of opening them in a new browser tab or window. Defaults to `false`.', type: 'boolean', @@ -15,3 +17,7 @@ export const UploadProperties: PropertiesTableProps = { }, ...ListFormatProperties, } + +export const UploadValueEvents: PropertiesTableProps = { + onFileClick: UploadFieldEvents.onFileClick, +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/Upload/__tests__/Upload.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/Upload/__tests__/Upload.test.tsx index da1f60417e4..3840bf114d2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Value/Upload/__tests__/Upload.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Value/Upload/__tests__/Upload.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { screen, render } from '@testing-library/react' +import { screen, render, fireEvent } from '@testing-library/react' import { Value, Form } from '../../..' import { createMockFile } from '../../../../../components/upload/__tests__/testHelpers' @@ -339,6 +339,30 @@ describe('Value.Upload', () => { expect(screen.queryByText(fileName)).toBeInTheDocument() }) + it('executes onFileClick event when button is clicked', () => { + const fileName = 'file.png' + const onFileClick = jest.fn() + + render( + + ) + + const buttonElement = document.querySelector('.dnb-button') + + fireEvent.click(buttonElement) + + expect(onFileClick).toHaveBeenCalledTimes(1) + }) + it('renders the anchor href', () => { const fileName = 'file.png' const mockUrl = 'mock-url' 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 4b13a5a2f32..fb1cd366bc2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/Container/WizardContainer.tsx @@ -362,9 +362,9 @@ function WizardContainer(props: Props) { // - Handle shared state useLayoutEffect(() => { if (id && hasContext) { - sharedStateRef.current?.extend?.(providerValue) + sharedStateRef.current.extend(providerValue) } - }, [id, providerValue]) // eslint-disable-line react-hooks/exhaustive-deps + }, [hasContext, id, providerValue]) useLayoutEffect(() => { updateTitlesRef.current?.() diff --git a/packages/dnb-eufemia/src/extensions/forms/Wizard/style/dnb-wizard-layout.scss b/packages/dnb-eufemia/src/extensions/forms/Wizard/style/dnb-wizard-layout.scss index 87a14b6a361..72603abdec1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Wizard/style/dnb-wizard-layout.scss +++ b/packages/dnb-eufemia/src/extensions/forms/Wizard/style/dnb-wizard-layout.scss @@ -67,7 +67,7 @@ @include allAbove('medium') { .dnb-forms-form:has(.dnb-forms-wizard-layout--sidebar) - .dnb-forms-status { + .dnb-forms-form__status-message { margin-left: 21.5rem; } } 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 d2df5c1b137..b108ac91e13 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 @@ -35,6 +35,18 @@ export default { text: 'Remove', confirmRemoveText: 'Are you sure you want to delete this?', }, + InfoOverlaySuccess: { + title: 'Thank you', + description: 'We have received your information.', + buttonText: 'Back to homepage', + }, + InfoOverlayError: { + title: 'Sorry, something went wrong', + description: 'Please try again or contact us.', + cancelButton: 'Back', + retryButton: 'Try again', + retryingText: 'Retrying...', + }, SectionViewContainer: { editButton: 'Edit', }, 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 1110f5caee2..f5c353f6a55 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 @@ -34,6 +34,19 @@ export default { text: 'Fjern', confirmRemoveText: 'Er du sikker på at du vil slette dette?', }, + InfoOverlaySuccess: { + title: 'Takk skal du ha', + description: 'Vi har mottatt din informasjon.', + buttonText: 'Tilbake til forsiden', + }, + InfoOverlayError: { + title: 'Beklager, noe gikk galt', + description: + 'Prøv igjen eller ta kontakt med oss om feilen vedstår.', + cancelButton: 'Tilbake', + retryButton: 'Prøv igjen', + retryingText: 'Prøver på nytt...', + }, SectionViewContainer: { editButton: 'Endre', }, 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 85ca015d91a..d2661e96c2a 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 @@ -1102,7 +1102,7 @@ describe('useFieldProps', () => { : undefined }, - // Step: when ever handleBlur is called, and there is not error yet + // Step: whenever handleBlur is called, and there is not error yet onBlurValidator: (value: string) => { return value === 'throw-onBlurValidator' ? new Error(value) diff --git a/packages/dnb-eufemia/src/fragments/drawer-list/DrawerListProvider.js b/packages/dnb-eufemia/src/fragments/drawer-list/DrawerListProvider.js index 9bc1005ab4e..35887c7f7ca 100644 --- a/packages/dnb-eufemia/src/fragments/drawer-list/DrawerListProvider.js +++ b/packages/dnb-eufemia/src/fragments/drawer-list/DrawerListProvider.js @@ -236,6 +236,7 @@ export default class DrawerListProvider extends React.PureComponent { on_resize, page_offset, observer_element, + direction: directionProp, } = this.props // const skipPortal = isTrue(skip_portal) @@ -260,61 +261,87 @@ export default class DrawerListProvider extends React.PureComponent { const spaceToTopOffset = 2 * 16 const spaceToBottomOffset = 2 * 16 const elem = this.state.wrapper_element || this._refRoot.current + const getSpaceToBottom = ({ rootElem, pageYOffset }) => { + const spaceToBottom = + rootElem.clientHeight - + (getOffsetTop(elem) + elem.offsetHeight) + + pageYOffset + + const html = document.documentElement + if (spaceToBottom < customMinHeight && rootElem !== html) { + return getSpaceToBottom({ + rootElem: html, + pageYOffset, + }) + } - const renderDirection = () => { - try { - // make calculation for both direction and height - const rootElem = customElem || document.documentElement - - const pageYOffset = !isNaN(parseFloat(page_offset)) - ? parseFloat(page_offset) - : rootElem.scrollTop /* pageYOffset */ - const spaceToTop = - getOffsetTop(elem) + elem.offsetHeight - pageYOffset - const spaceToBottom = - rootElem.clientHeight /* innerHeight */ - - (getOffsetTop(elem) + elem.offsetHeight) + - pageYOffset - - const direction = + return spaceToBottom + } + + const calculateMaxHeight = () => { + // make calculation for both direction and height + const rootElem = customElem || document.documentElement + + const pageYOffset = !isNaN(parseFloat(page_offset)) + ? parseFloat(page_offset) + : rootElem.scrollTop + const spaceToTop = + getOffsetTop(elem) + elem.offsetHeight - pageYOffset + const spaceToBottom = getSpaceToBottom({ rootElem, pageYOffset }) + + let direction = directionProp + if (!direction || direction === 'auto') { + direction = Math.max(spaceToBottom - directionOffset, directionOffset) < customMinHeight && spaceToTop > customMinHeight ? 'top' : 'bottom' + } - // make sure we never get higher than we have defined in CSS - let max_height = customMaxHeight - if (!(max_height > 0)) { - max_height = - direction === 'top' - ? spaceToTop - - ((this.state.wrapper_element || this._refRoot.current) - .offsetHeight || 0) - - spaceToTopOffset - : spaceToBottom - spaceToBottomOffset - - // get the view port height, like in CSS - let vh = 0 - if (typeof window.visualViewport !== 'undefined') { - vh = window.visualViewport.height - } else { - vh = Math.max( - document.documentElement.clientHeight, - window.innerHeight || 0 - ) - } + // make sure we never get higher than we have defined in CSS + let maxHeight = customMaxHeight + if (!(maxHeight > 0)) { + if (direction === 'top') { + maxHeight = + spaceToTop - + ((this.state.wrapper_element || this._refRoot.current) + .offsetHeight || 0) - + spaceToTopOffset + } - // like defined in CSS - vh = vh * (isScrollable ? 0.7 : 0.9) + if (direction === 'bottom') { + maxHeight = spaceToBottom - spaceToBottomOffset + } - if (max_height > vh) { - max_height = vh - } + // get the view port height, like in CSS + let vh = 0 + if (typeof window.visualViewport !== 'undefined') { + vh = window.visualViewport.height + } else { + vh = Math.max( + document.documentElement.clientHeight, + window.innerHeight || 0 + ) + } + + // like defined in CSS + vh = vh * (isScrollable ? 0.7 : 0.9) - // convert px to rem - max_height = roundToNearest(max_height, 8) / 16 + if (maxHeight > vh) { + maxHeight = vh } + // convert px to rem + maxHeight = roundToNearest(maxHeight, 8) / 16 + } + + return { direction, maxHeight } + } + + const renderDirection = () => { + try { + const { direction, maxHeight: max_height } = calculateMaxHeight() + // update the states if (this.props.direction === 'auto') { this.setState({ diff --git a/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/DrawerList.test.tsx b/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/DrawerList.test.tsx index 78ad040941a..d97ce7a21dd 100644 --- a/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/DrawerList.test.tsx +++ b/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/DrawerList.test.tsx @@ -472,17 +472,6 @@ describe('DrawerList component', () => { expect( document.querySelector(`.dnb-drawer-list--${directionBottom}`) ).toBeInTheDocument() - - expect( - document - .querySelector('.dnb-drawer-list__options') - .getAttribute('style') - ).toBe('max-height: 33.5rem;') - }) - - it('has working direction observer', async () => { - render() - await testDirectionObserver() }) it('will call on_hide after "esc" key', async () => { @@ -494,7 +483,7 @@ describe('DrawerList component', () => { Array.from(document.querySelector('span.dnb-drawer-list').classList) ).toEqual([ 'dnb-drawer-list', - 'dnb-drawer-list--top', + 'dnb-drawer-list--bottom', 'dnb-drawer-list--opened', 'dnb-drawer-list--triangle-position-left', 'dnb-drawer-list--left', @@ -512,7 +501,7 @@ describe('DrawerList component', () => { ) ).toEqual([ 'dnb-drawer-list', - 'dnb-drawer-list--top', + 'dnb-drawer-list--bottom', 'dnb-drawer-list--hidden', 'dnb-drawer-list--triangle-position-left', 'dnb-drawer-list--left', @@ -653,6 +642,84 @@ describe('DrawerList component', () => { expect(on_hide.mock.calls.length).toBe(1) expect(on_hide.mock.calls[0][0].attributes).toMatchObject(params) }) + + describe('height calculation', () => { + it('has given max-height when max_height is set', () => { + render() + + expect( + document + .querySelector('.dnb-drawer-list__options') + .getAttribute('style') + ).toBe('max-height: 10rem;') + }) + + it('has correct max-height with direction top', () => { + jest + .spyOn(document.documentElement, 'clientHeight', 'get') + .mockImplementationOnce(() => 100) + + let count = 0 + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + get() { + if (this.classList.contains('dnb-drawer-list__root')) { + count++ + switch (count) { + case 1: + return 300 + default: + return 200 + } + } + }, + }) + + const directionTop = 'top' + render( + + ) + + expect( + document + .querySelector('.dnb-drawer-list__options') + .getAttribute('style') + ).toBe('max-height: 4rem;') + }) + + it('has correct max-height with direction bottom', () => { + jest + .spyOn(document.documentElement, 'clientHeight', 'get') + .mockImplementationOnce(() => 300) + + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get() { + if (this.classList.contains('dnb-drawer-list__root')) { + return 200 + } + }, + }) + + const directionTop = 'bottom' + + render( + + ) + + expect( + document + .querySelector('.dnb-drawer-list__options') + .getAttribute('style') + ).toBe('max-height: 4rem;') + }) + }) + + describe('direction observer', () => { + it('should results in correct direction', async () => { + render() + await testDirectionObserver() + }) + }) }) describe('DrawerList markup', () => { diff --git a/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/DrawerListTestMocks.js b/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/DrawerListTestMocks.js index 7200578c530..b1a8f761164 100644 --- a/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/DrawerListTestMocks.js +++ b/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/DrawerListTestMocks.js @@ -35,6 +35,10 @@ export function mockImplementationForDirectionObserver() { } export async function testDirectionObserver() { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + value: 0, + }) + // the setDirectionObserver fn is changing this expect( document.querySelector('.dnb-drawer-list--bottom') diff --git a/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/__image_snapshots__/drawerlist-for-ui-have-to-match-the-disabled-option.snap.png b/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/__image_snapshots__/drawerlist-for-ui-have-to-match-the-disabled-option.snap.png index 3f7766011e0..054ca8e6af8 100644 Binary files a/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/__image_snapshots__/drawerlist-for-ui-have-to-match-the-disabled-option.snap.png and b/packages/dnb-eufemia/src/fragments/drawer-list/__tests__/__image_snapshots__/drawerlist-for-ui-have-to-match-the-disabled-option.snap.png differ diff --git a/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts b/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts index 5fe8b8e566a..e79c8783a8b 100644 --- a/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts +++ b/packages/dnb-eufemia/src/shared/helpers/__tests__/useSharedState.test.ts @@ -220,6 +220,37 @@ describe('useSharedState', () => { expect(resultA.current.data).toEqual({ foo: 'baz' }) expect(resultB.current.data).toEqual({ foo: 'baz' }) }) + + it('should sync all hooks, except the one that is set to "preventSyncOfSameInstance"', () => { + const { result: resultA } = renderHook(() => + useSharedState(identifier) + ) + const { result: resultB } = renderHook(() => + useSharedState(identifier) + ) + + expect(resultA.current.data).toEqual(undefined) + expect(resultB.current.data).toEqual(undefined) + + act(() => { + resultA.current.update({ foo: 'bar' }) + }) + + expect(resultA.current.data).toEqual({ foo: 'bar' }) + expect(resultB.current.data).toEqual({ foo: 'bar' }) + + act(() => { + // If "preventSyncOfSameInstance" is set to true, + // then the "resultA" will not be synced, so resultA will still have "bar". + resultB.current.update( + { foo: 'baz' }, + { preventSyncOfSameInstance: true } + ) + }) + + expect(resultA.current.data).toEqual({ foo: 'bar' }) + expect(resultB.current.data).toEqual({ foo: 'baz' }) + }) }) describe('createReferenceKey', () => { diff --git a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx index 6d837bceb3f..f0775984e3b 100644 --- a/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx +++ b/packages/dnb-eufemia/src/shared/helpers/useSharedState.tsx @@ -31,16 +31,24 @@ export function useSharedState( onChange = null ) { const [, forceUpdate] = useReducer(() => ({}), {}) - const hasMounted = useMounted() + const hasMountedRef = useMounted() const waitForMountedRef = useRef(false) + const instanceRef = useRef({}) const forceRerender = useCallback(() => { - if (hasMounted.current) { + if (hasMountedRef.current) { forceUpdate() } else { waitForMountedRef.current = true } - }, [hasMounted]) + }, [hasMountedRef]) + + const shouldSync = useCallback((fn: () => void) => { + // Do not rerender the "same component", when the hook is used. Only other subscribers will rerender. + if (instanceRef.current === fn?.['ref']) { + return false + } + }, []) useMountEffect(() => { if (waitForMountedRef.current) { @@ -50,16 +58,20 @@ export function useSharedState( const sharedState = useMemo(() => { if (id) { - return createSharedState(id, initialData) + return createSharedState(id, initialData, { shouldSync }) } - }, [id, initialData]) + }, [id, initialData, shouldSync]) const sharedAttachment = useMemo(() => { if (id) { - return createSharedState(createReferenceKey(id, 'oc'), { onChange }) + return createSharedState( + createReferenceKey(id, 'oc'), + { onChange }, + { shouldSync } + ) } - }, [id, onChange]) + }, [id, onChange, shouldSync]) - const sync = useCallback( + const syncAttachment = useCallback( (newData: Data) => { if (id) { sharedAttachment.data?.onChange?.(newData) @@ -69,32 +81,38 @@ export function useSharedState( ) const update = useCallback( - (newData: Data) => { + (newData: Data, opts?: Options) => { if (id) { - sharedState.update(newData) + sharedState.update(newData, opts) } }, [id, sharedState] ) + const get = useCallback(() => { + if (id) { + return sharedState?.get?.() + } + }, [id, sharedState]) + const set = useCallback( (newData: Data) => { if (id) { sharedState.set(newData) - sync(newData) + syncAttachment(newData) } }, - [id, sharedState, sync] + [id, sharedState, syncAttachment] ) const extend = useCallback( - (newData: Data) => { + (newData: Data, opts?: Options) => { if (id) { - sharedState.extend(newData) - sync(newData) + sharedState.extend(newData, opts) + syncAttachment(newData) } }, - [id, sharedState, sync] + [id, sharedState, syncAttachment] ) useLayoutEffect(() => { @@ -102,6 +120,7 @@ export function useSharedState( return } + forceRerender['ref'] = instanceRef.current sharedState.subscribe(forceRerender) return () => { @@ -117,13 +136,12 @@ export function useSharedState( }, [id, onChange, sharedAttachment]) return { - get: sharedState?.get, + get, data: sharedState?.get?.() as Data, hadInitialData: sharedState?.hadInitialData, update, set, extend, - sync, } } @@ -133,8 +151,8 @@ export interface SharedStateReturn { data: Data get: () => Data set: (newData: Partial) => void - extend: (newData: Partial) => void - update: (newData: Partial) => void + extend: (newData: Partial, opts?: Options) => void + update: (newData: Partial, opts?: Options) => void } interface SharedStateInstance extends SharedStateReturn { @@ -148,6 +166,10 @@ const sharedStates: Map< SharedStateInstance > = new Map() +type Options = { + preventSyncOfSameInstance?: boolean +} + /** * Creates a shared state instance with the specified ID and initial data. */ @@ -155,28 +177,44 @@ export function createSharedState( /** The identifier for the shared state. */ id: SharedStateId, /** The initial data for the shared state. */ - initialData?: Data + initialData?: Data, + /** Optional configuration options. */ + { + /** A function that returns true if the component should be rerendered. */ + shouldSync = null, + } = {} ): SharedStateInstance { if (!sharedStates.get(id)) { let subscribers: Subscriber[] = [] + const sync = (opts: Options = {}) => { + subscribers.forEach((subscriber) => { + const syncNow = opts.preventSyncOfSameInstance + ? shouldSync?.(subscriber) !== false + : true + if (syncNow) { + subscriber() + } + }) + } + const get = () => sharedStates.get(id).data const set = (newData: Partial) => { sharedStates.get(id).data = { ...newData } } - const update = (newData: Partial) => { + const update = (newData: Partial, opts?: Options) => { set(newData) - sync() + sync(opts) } - const extend = (newData: Data) => { + const extend = (newData: Data, opts?: Options) => { sharedStates.get(id).data = { ...sharedStates.get(id).data, ...newData, } - sync() + sync(opts) } const subscribe = (subscriber: Subscriber) => { @@ -189,10 +227,6 @@ export function createSharedState( subscribers = subscribers.filter((sub) => sub !== subscriber) } - const sync = () => { - subscribers.forEach((subscriber) => subscriber()) - } - sharedStates.set(id, { data: undefined, get, diff --git a/packages/dnb-eufemia/src/shared/locales/en-GB.ts b/packages/dnb-eufemia/src/shared/locales/en-GB.ts index 51924aae8e7..bee90d107ec 100644 --- a/packages/dnb-eufemia/src/shared/locales/en-GB.ts +++ b/packages/dnb-eufemia/src/shared/locales/en-GB.ts @@ -156,7 +156,7 @@ export default { fileSizeContent: '%size MB', buttonText: 'Choose files', buttonTextSingular: 'Choose file', - loadingText: 'Uploading', + loadingText: 'Loading', errorLargeFile: 'The file you are trying to upload is too big, the maximum size supported is %size MB.', errorAmountLimit: diff --git a/packages/dnb-eufemia/src/shared/locales/nb-NO.ts b/packages/dnb-eufemia/src/shared/locales/nb-NO.ts index 4fd35e9cadc..1309eb93d70 100644 --- a/packages/dnb-eufemia/src/shared/locales/nb-NO.ts +++ b/packages/dnb-eufemia/src/shared/locales/nb-NO.ts @@ -155,7 +155,7 @@ export default { fileSizeContent: '%size MB', buttonText: 'Velg filer', buttonTextSingular: 'Velg fil', - loadingText: 'Laster opp', + loadingText: 'Laster', errorLargeFile: 'Filen du prøver å laste opp er for stor, den maksimale støttede størrelsen er %size MB.', errorAmountLimit: