From d2de984f0b57659700e292343b204b435ee8e93f Mon Sep 17 00:00:00 2001 From: gerjanvangeest Date: Tue, 6 Jun 2023 11:30:43 +0200 Subject: [PATCH] Feat/input file (#1881) * feat(input-file): create input-file component * chore: improvements after review * chore: update after review * chore: update translations * chore: - fixed demo with form submit, submit was not prevented - fixed checking allowed file extensions - fixed clicking on select file button in drag and drop area * chore: since the input-file does not upload files itself but enables user to select files, I replaced "upload" and "upload" with "select" and "selected" where applicable * chore: - removed unused properties allowedFileTypes and allowedFileExtensions from lion-input-file - cleaned up docs * chore: - changed type Array. to Array - removed redundant type definition * fix: - FocusMixin: moved registering events for from connectedCallback to firstUpdated since _focusableNode is sometimes not available yet - SlotMixin: changed updated to update in since slots were rendered too late (related to previous fix in FocusMixin.js) * fix: renamed lion-uploaded-file-list.js to lion-selected-file-list.js * fix: fixed test for lion-selected-file-list * fix: fixed typ * wip * fix: - fixed issue with multiple file selection where element would not select valid files after invalid ones - added getMessage method to FileValidation that returns empty string to prevent message being shown that error message must be configured - fixed tests * chore: replaced `uploadOnFormSubmit` with `uploadOnSelect` and flipped the default value to false. When `uploadOnSelect` is set to true, the file will be uploaded as soon as it is selected. * fix: - replaced `uploadOnFormSubmit` with `uploadOnSelect` and flipped the default value to false. When `uploadOnSelect` is set to true, the file will be uploaded as soon as it is selected. - fixed issue where a valid file was not selected and added to the file list if it was preceded by an invalid file * chore: removed redundant README.md * fix: fixed failing test * chore: added missing type annotation * chore: annotated event param as optional --------- Co-authored-by: Danny Moerkerke Co-authored-by: Thijs Louisse --- .changeset/serious-trees-thank.md | 5 + docs/blog/introducing-lion-ui.md | 2 +- docs/components/input-file/index.md | 3 + docs/components/input-file/overview.md | 32 + docs/components/input-file/use-cases.md | 425 ++++++ docs/fundamentals/systems/form/use-cases.md | 2 + packages/ui/components/core/src/SlotMixin.js | 4 +- .../ui/components/form-core/src/FocusMixin.js | 7 +- .../ui/components/form-core/src/LionField.js | 4 +- .../src/validate/LionValidationFeedback.js | 7 +- .../form-core/src/validate/ResultValidator.js | 2 +- .../types/validate/ValidateMixinTypes.ts | 4 +- .../test/dialog-integrations.test.js | 1 + .../test/form-group-methods.test.js | 48 +- .../test/form-integrations.test.js | 4 + .../test/helpers/umbrella-form.js | 47 +- .../components/input-file/src/FileHandle.js | 98 ++ .../input-file/src/LionInputFile.js | 892 +++++++++++++ .../input-file/src/LionSelectedFileList.js | 232 ++++ .../input-file/src/localizeNamespaceLoader.js | 75 ++ .../components/input-file/src/validators.js | 114 ++ .../input-file/test/FileValidators.test.js | 170 +++ .../input-file/test/lion-input-file.test.js | 1171 +++++++++++++++++ .../test/lion-selected-file-list.test.js | 207 +++ .../input-file/translations/bg-BG.js | 5 + .../components/input-file/translations/bg.js | 12 + .../input-file/translations/cs-CZ.js | 5 + .../components/input-file/translations/cs.js | 13 + .../input-file/translations/de-DE.js | 5 + .../components/input-file/translations/de.js | 12 + .../input-file/translations/en-AU.js | 5 + .../input-file/translations/en-GB.js | 5 + .../input-file/translations/en-PH.js | 5 + .../input-file/translations/en-US.js | 5 + .../components/input-file/translations/en.js | 12 + .../input-file/translations/es-ES.js | 5 + .../components/input-file/translations/es.js | 12 + .../input-file/translations/fr-BE.js | 5 + .../input-file/translations/fr-FR.js | 5 + .../components/input-file/translations/fr.js | 13 + .../input-file/translations/hu-HU.js | 5 + .../components/input-file/translations/hu.js | 12 + .../input-file/translations/it-IT.js | 5 + .../components/input-file/translations/it.js | 12 + .../input-file/translations/nl-BE.js | 5 + .../input-file/translations/nl-NL.js | 5 + .../components/input-file/translations/nl.js | 12 + .../input-file/translations/pl-PL.js | 5 + .../components/input-file/translations/pl.js | 12 + .../input-file/translations/ro-RO.js | 5 + .../components/input-file/translations/ro.js | 12 + .../input-file/translations/ru-RU.js | 5 + .../components/input-file/translations/ru.js | 12 + .../input-file/translations/sk-SK.js | 5 + .../components/input-file/translations/sk.js | 12 + .../input-file/translations/uk-UA.js | 5 + .../components/input-file/translations/uk.js | 12 + .../input-file/translations/zh-CN.js | 5 + .../components/input-file/translations/zh.js | 12 + .../input-file/types/input-file.d.ts | 32 + packages/ui/exports/define/lion-input-file.js | 3 + .../exports/define/lion-selected-file-list.js | 3 + packages/ui/exports/input-file.js | 3 + packages/ui/package.json | 1 + 64 files changed, 3807 insertions(+), 73 deletions(-) create mode 100644 .changeset/serious-trees-thank.md create mode 100644 docs/components/input-file/index.md create mode 100644 docs/components/input-file/overview.md create mode 100644 docs/components/input-file/use-cases.md create mode 100644 packages/ui/components/input-file/src/FileHandle.js create mode 100644 packages/ui/components/input-file/src/LionInputFile.js create mode 100644 packages/ui/components/input-file/src/LionSelectedFileList.js create mode 100644 packages/ui/components/input-file/src/localizeNamespaceLoader.js create mode 100644 packages/ui/components/input-file/src/validators.js create mode 100644 packages/ui/components/input-file/test/FileValidators.test.js create mode 100644 packages/ui/components/input-file/test/lion-input-file.test.js create mode 100644 packages/ui/components/input-file/test/lion-selected-file-list.test.js create mode 100644 packages/ui/components/input-file/translations/bg-BG.js create mode 100644 packages/ui/components/input-file/translations/bg.js create mode 100644 packages/ui/components/input-file/translations/cs-CZ.js create mode 100644 packages/ui/components/input-file/translations/cs.js create mode 100644 packages/ui/components/input-file/translations/de-DE.js create mode 100644 packages/ui/components/input-file/translations/de.js create mode 100644 packages/ui/components/input-file/translations/en-AU.js create mode 100644 packages/ui/components/input-file/translations/en-GB.js create mode 100644 packages/ui/components/input-file/translations/en-PH.js create mode 100644 packages/ui/components/input-file/translations/en-US.js create mode 100644 packages/ui/components/input-file/translations/en.js create mode 100644 packages/ui/components/input-file/translations/es-ES.js create mode 100644 packages/ui/components/input-file/translations/es.js create mode 100644 packages/ui/components/input-file/translations/fr-BE.js create mode 100644 packages/ui/components/input-file/translations/fr-FR.js create mode 100644 packages/ui/components/input-file/translations/fr.js create mode 100644 packages/ui/components/input-file/translations/hu-HU.js create mode 100644 packages/ui/components/input-file/translations/hu.js create mode 100644 packages/ui/components/input-file/translations/it-IT.js create mode 100644 packages/ui/components/input-file/translations/it.js create mode 100644 packages/ui/components/input-file/translations/nl-BE.js create mode 100644 packages/ui/components/input-file/translations/nl-NL.js create mode 100644 packages/ui/components/input-file/translations/nl.js create mode 100644 packages/ui/components/input-file/translations/pl-PL.js create mode 100644 packages/ui/components/input-file/translations/pl.js create mode 100644 packages/ui/components/input-file/translations/ro-RO.js create mode 100644 packages/ui/components/input-file/translations/ro.js create mode 100644 packages/ui/components/input-file/translations/ru-RU.js create mode 100644 packages/ui/components/input-file/translations/ru.js create mode 100644 packages/ui/components/input-file/translations/sk-SK.js create mode 100644 packages/ui/components/input-file/translations/sk.js create mode 100644 packages/ui/components/input-file/translations/uk-UA.js create mode 100644 packages/ui/components/input-file/translations/uk.js create mode 100644 packages/ui/components/input-file/translations/zh-CN.js create mode 100644 packages/ui/components/input-file/translations/zh.js create mode 100644 packages/ui/components/input-file/types/input-file.d.ts create mode 100644 packages/ui/exports/define/lion-input-file.js create mode 100644 packages/ui/exports/define/lion-selected-file-list.js create mode 100644 packages/ui/exports/input-file.js diff --git a/.changeset/serious-trees-thank.md b/.changeset/serious-trees-thank.md new file mode 100644 index 000000000..e383bc763 --- /dev/null +++ b/.changeset/serious-trees-thank.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': minor +--- + +[input-file] Create input-file component diff --git a/docs/blog/introducing-lion-ui.md b/docs/blog/introducing-lion-ui.md index 4d5f3c2ef..eeac565bd 100644 --- a/docs/blog/introducing-lion-ui.md +++ b/docs/blog/introducing-lion-ui.md @@ -51,5 +51,5 @@ Support for this `exports` field was added to TypeScript 4.7 so for that reason, ### A new changelog -`@lion/ui` has [a new single CHANGELOG.md]( https://github.com/ing-bank/lion/blob/master/packages/ui/CHANGELOG.md) in `/packages/ui` for the whole package. The older individual changelogs can be +`@lion/ui` has [a new single CHANGELOG.md](https://github.com/ing-bank/lion/blob/master/packages/ui/CHANGELOG.md) in `/packages/ui` for the whole package. The older individual changelogs can be found in the `/packages/ui/_legacy-changelogs` folder. diff --git a/docs/components/input-file/index.md b/docs/components/input-file/index.md new file mode 100644 index 000000000..25ee58a4f --- /dev/null +++ b/docs/components/input-file/index.md @@ -0,0 +1,3 @@ +# Input File ||20 + +-> go to Overview diff --git a/docs/components/input-file/overview.md b/docs/components/input-file/overview.md new file mode 100644 index 000000000..4c7ed506c --- /dev/null +++ b/docs/components/input-file/overview.md @@ -0,0 +1,32 @@ +# Input File >> Overview ||10 + +A web component based on the file input field. + +```js script +import { html } from '@mdjs/mdjs-preview'; +import '@lion/ui/define/lion-input-file.js'; +``` + +```js preview-story +export const main = () => { + return html` `; +}; +``` + +## Features + +- Based on our [input](../input/overview.md) +- Default labels and validation messages in different languages +- Options for multi file upload and drop-zone. + +## Installation + +```bash +npm i --save @lion/ui +``` + +```js +import { LionInputFile } from '@lion/ui/input-file.js'; +// or +import '@lion/ui/define/lion-input-file.js'; +``` diff --git a/docs/components/input-file/use-cases.md b/docs/components/input-file/use-cases.md new file mode 100644 index 000000000..5692bfd47 --- /dev/null +++ b/docs/components/input-file/use-cases.md @@ -0,0 +1,425 @@ +# Input File >> Use Cases ||20 + +```js script +import { Required, Validator } from '@lion/ui/form-core.js'; +import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js'; +import { html } from '@mdjs/mdjs-preview'; +import { ScopedElementsMixin } from '@open-wc/scoped-elements'; +import { LitElement } from 'lit'; +import '@lion/ui/define/lion-input-file.js'; +loadDefaultFeedbackMessages(); +``` + +## Features + +API calls to upload the selected files can be done in below two ways which is driven by `uploadOnSelect` property + +- On `form` submit +- On file selection + +## Parameters + +```html + + +``` + +### modelValue + +Array of [File](https://developer.mozilla.org/en-US/docs/Web/API/File); Contains all the uploaded files + +### multiple + +Boolean; setting to `true` allows selecting multiple files. + +### EnableDragAndDrop + +Boolean; setting to `true` allows file upload through drag and drop. + +### uploadOnSelect + +Boolean; + +- Set to `true` when API calls for file upload needs to be done on file selection +- Set to `false` when API calls for file upload needs to be done on form submit. +- Default is `false`. + +### uploadResponse + +An array of the file-specific data to display the feedback of fileUpload + +Data structure for uploadResponse array: + +```js +[ + { + name: 'file1.txt', // name of uploaded file + status: '', // SUCCESS | FAIL | LOADING + errorMessage: '', // custom error message to be displayed + id: '', // unique id for future execution reference + }, + { + name: 'file2.txt', // name of uploaded file + status: '', // SUCCESS | FAIL | LOADING + errorMessage: '', // custom error message to be displayed + id: '', // unique id for future execution reference + }, +]; +``` + +## Events + +### model-value-changed + +Fired when modelValue property changes. + +### file-list-changed + +Fired when files are uploaded. Event `detail` gives list of newly added files in `newFiles` key + +```js +ev.detail.newFiles; +``` + +### file-removed + +Fired when a file is removed. Event `detail` gives + +- File objected for removed file in `ev.detail.removedFile` +- Status of this file can be used as `ev.detail.status` +- uploadResponse for this file as set using `uploadResponse` property. Can use used as `ev.detail.uploadResponse` + +## Usage + +### Basic File upload + +When file has to be uploaded as soon as it is selected by the user. Use `file-list-changed` event to get the newly added file, upload it to your server and set the response back to component via `uploadResponse` property. + +```js preview-story +export const basicFileUpload = () => { + return html` + + + `; +}; +``` + +### Validation + +#### Accept + +The `accept` attribute value is a string that defines the file types the file input should accept. This string is a comma-separated list of unique file type specifiers. + +For more info please consult the [MDN documentation for "accept"](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept). + +```js preview-story +export const acceptValidator = () => { + return html` + + + `; +}; +``` + +#### Maximum File Size + +The `max-file-size` attribute sets the maximum file size in bytes. + +```js preview-story +export const sizeValidator = () => { + return html` + + + `; +}; +``` + +### Multiple file upload + +When file has to be uploaded as soon as it is selected by the user. Use `file-list-changed` event to get the newly added files, upload it to your server and set the response back to component via `uploadResponse` property for each file. + +```js preview-story +export const multipleFileUpload = () => { + return html` + + + `; +}; +``` + +### Pre-filled state + +The pre-filled state of the file upload component shows the files that were being assigned by the user initially. + +On every reload of page, the file upload component reverts to the initial state and using the `uploadResponse` property, shows the names(not links) of previously assigned files. + +**Note:** + +- the list of prefilled files will not be available in `modelValue` or `serializedValue`. This is because value cannot be assigned to native `input` with type `file` +- Only `Required` validation (if set on `lion-file-input`) is triggered for files which are prefilled. + +```js preview-story +export const prefilledState = () => { + return html` + + + `; +}; +``` + +### With form submit + +**Note:** When `lion-input-file` is prefilled, the list of prefilled files will not be available in `modelValue` or `serializedValue`. This is because value cannot be assigned to native `input` with type `file`. + +```js preview-story +const myFormReset = ev => { + const input = ev.target.querySelector('lion-input-file'); + input.reset(); + console.log(input.modelValue); +}; + +const myFormSubmit = ev => { + ev.preventDefault(); + const input = ev.target.querySelector('lion-input-file'); + console.log(input.hasFeedbackFor); + console.log(input.serializedValue); + return false; +}; + +export const withIngForm = () => { + class FilenameLengthValidator extends Validator { + static get validatorName() { + return 'FilenameLengthValidator'; + } + + static getMessage(data) { + return `Filename length should not exceed ${data.params.maxFilenameLength} characters`; + } + + // eslint-disable-next-line class-methods-use-this + checkFilenameLength(val, allowedFileNameLength) { + return val <= allowedFileNameLength; + } + + execute(modelVal, { maxFilenameLength }) { + const invalidFileIndex = modelVal.findIndex( + file => !this.checkFilenameLength(file.name.length, maxFilenameLength), + ); + return invalidFileIndex > -1; + } + } + + return html` +
+ + + + +
+ `; +}; +``` + +### Without form submit + +Set `uploadOnSelect` property to `true`. This option can be used when Server API calls are needed as soon as it is selected by the user. + +For this scenario, the list of files is displayed based on the `uploadResponse` property which needs to maintained by the consumers of this component + +Below is the flow: + +#### When user uploads new file(s) + +1. `file-list-changed` event is fired from component with the newly added files +2. Initiate the request to your backend API and set the status of the relevant files to `LOADING` in `uploadResponse` property +3. Once the API request in completed, set the status of relevant files to `SUCCESS` or `FAIL` in `uploadResponse` property + +#### When user deletes a file + +1. `file-removed` event is fired from component with the deleted file +2. Initiate the delete request to your backend API and set the status of the relevant files as `LOADING` in `uploadResponse` property +3. Once the API request in completed, delete the file object from `uploadResponse` property + +```js preview-story +export const uploadWithoutFormSubmit = () => { + return html` + + + `; +}; +``` + +### Drag and Drop + +Set the `enableDropZone` parameter to `true` to use the drag and drop functionality in the component. + +Drag and drop the files to be uploaded to the server. + +```js preview-story +export const dragAndDrop = () => { + return html` + + + `; +}; +``` + +### Posting file to API using axios + +You can retrieve the uploaded files in `file-list-changed` event or from `modelValue` property of this component + +To submit files you can refer to the following code snippet: + +```js +const formData = new FormData(); +const inputFile = document.querySelector('lion-input-file').modelValue; +formData.append('file', inputFile.querySelector('input').files); +axios.post('upload_file', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, +}); +``` diff --git a/docs/fundamentals/systems/form/use-cases.md b/docs/fundamentals/systems/form/use-cases.md index ea5225a83..c9dc04af3 100644 --- a/docs/fundamentals/systems/form/use-cases.md +++ b/docs/fundamentals/systems/form/use-cases.md @@ -16,6 +16,7 @@ import '@lion/ui/define/lion-input-amount.js'; import '@lion/ui/define/lion-input-date.js'; import '@lion/ui/define/lion-input-datepicker.js'; import '@lion/ui/define/lion-input-email.js'; +import '@lion/ui/define/lion-input-file.js'; import '@lion/ui/define/lion-input-tel.js'; import '@lion/ui/define/lion-input-tel-dropdown.js'; import '@lion/ui/define/lion-input-iban.js'; @@ -75,6 +76,7 @@ export const main = () => { + * Here we rerender slots defined with a `SlotRerenderObject` * @param {import('lit-element').PropertyValues } changedProperties */ - updated(changedProperties) { - super.updated(changedProperties); + update(changedProperties) { + super.update(changedProperties); if (this.__slotsThatNeedRerender.size) { for (const slotName of Array.from(this.__slotsThatNeedRerender)) { diff --git a/packages/ui/components/form-core/src/FocusMixin.js b/packages/ui/components/form-core/src/FocusMixin.js index a1102cdaf..03b905a17 100644 --- a/packages/ui/components/form-core/src/FocusMixin.js +++ b/packages/ui/components/form-core/src/FocusMixin.js @@ -51,8 +51,11 @@ const FocusMixinImplementation = superclass => this.autofocus = false; } - connectedCallback() { - super.connectedCallback(); + /** + * @param {import('lit').PropertyValues } changedProperties + */ + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); this.__registerEventsForFocusMixin(); this.__syncAutofocusToFocusableElement(); } diff --git a/packages/ui/components/form-core/src/LionField.js b/packages/ui/components/form-core/src/LionField.js index f7f2722ae..f14702858 100644 --- a/packages/ui/components/form-core/src/LionField.js +++ b/packages/ui/components/form-core/src/LionField.js @@ -72,9 +72,11 @@ export class LionField extends FormControlMixin( /** * Dispatches custom bubble event + * @param {Event=} ev * @protected */ - _onChange() { + // eslint-disable-next-line no-unused-vars + _onChange(ev) { /** @protectedEvent user-input-changed */ this.dispatchEvent(new Event('user-input-changed', { bubbles: true })); } diff --git a/packages/ui/components/form-core/src/validate/LionValidationFeedback.js b/packages/ui/components/form-core/src/validate/LionValidationFeedback.js index 4ff8ca534..290f4758b 100644 --- a/packages/ui/components/form-core/src/validate/LionValidationFeedback.js +++ b/packages/ui/components/form-core/src/validate/LionValidationFeedback.js @@ -3,10 +3,7 @@ import { html, LitElement } from 'lit'; /** * @typedef {import('./Validator.js').Validator} Validator * @typedef {import('lit').TemplateResult} TemplateResult - * @typedef {Object} messageMap - * @property {string | Node} message - * @property {string} type - * @property {Validator} [validator] + * @typedef {import('../../types/validate/ValidateMixinTypes.js').FeedbackMessage} FeedbackMessage */ /** @@ -47,7 +44,7 @@ export class LionValidationFeedback extends LitElement { if (this.currentType === 'success') { this.removeMessage = window.setTimeout(() => { this.removeAttribute('type'); - /** @type {messageMap[]} */ + /** @type {FeedbackMessage[]} */ this.feedbackData = []; }, 3000); } diff --git a/packages/ui/components/form-core/src/validate/ResultValidator.js b/packages/ui/components/form-core/src/validate/ResultValidator.js index 4588e771b..875d892d0 100644 --- a/packages/ui/components/form-core/src/validate/ResultValidator.js +++ b/packages/ui/components/form-core/src/validate/ResultValidator.js @@ -1,7 +1,7 @@ import { Validator } from './Validator.js'; /** - * @desc Instead of evaluating the result of a regular validator, a ResultValidator looks + * Instead of evaluating the result of a regular Validator, a ResultValidator looks * at the total result of regular Validators. Instead of an execute function, it uses a * 'executeOnResults' Validator. * ResultValidators cannot be async, and should not contain an execute method. diff --git a/packages/ui/components/form-core/types/validate/ValidateMixinTypes.ts b/packages/ui/components/form-core/types/validate/ValidateMixinTypes.ts index 4de5d08a1..c7fb36d3d 100644 --- a/packages/ui/components/form-core/types/validate/ValidateMixinTypes.ts +++ b/packages/ui/components/form-core/types/validate/ValidateMixinTypes.ts @@ -9,9 +9,9 @@ import { FormControlHost } from '../FormControlMixinTypes.js'; import { SyncUpdatableHost } from '../utils/SyncUpdatableMixinTypes.js'; import { Validator } from '../../src/validate/Validator.js'; -type FeedbackMessage = { +export type FeedbackMessage = { message: string | Node; - type: string; + type: ValidationType | string; validator?: Validator; }; diff --git a/packages/ui/components/form-integrations/test/dialog-integrations.test.js b/packages/ui/components/form-integrations/test/dialog-integrations.test.js index 393da46d6..fb4758c4d 100644 --- a/packages/ui/components/form-integrations/test/dialog-integrations.test.js +++ b/packages/ui/components/form-integrations/test/dialog-integrations.test.js @@ -37,6 +37,7 @@ describe('Form inside dialog Integrations', () => { 'lion-input-amount', 'lion-input-iban', 'lion-input-email', + 'lion-input-file', 'lion-input-tel', 'lion-input-tel-dropdown', 'lion-checkbox-group', diff --git a/packages/ui/components/form-integrations/test/form-group-methods.test.js b/packages/ui/components/form-integrations/test/form-group-methods.test.js index e82830809..2ec6b8c1f 100644 --- a/packages/ui/components/form-integrations/test/form-group-methods.test.js +++ b/packages/ui/components/form-integrations/test/form-group-methods.test.js @@ -1,49 +1,20 @@ import { elementUpdated, expect, fixture } from '@open-wc/testing'; import { html } from 'lit'; -import './helpers/umbrella-form.js'; import { getAllFieldsAndFormGroups } from './helpers/helpers.js'; - -import '@lion/ui/define/lion-button.js'; -import '@lion/ui/define/lion-button-submit.js'; -import '@lion/ui/define/lion-button-reset.js'; - -import '@lion/ui/define/lion-checkbox.js'; -import '@lion/ui/define/lion-checkbox-group.js'; -import '@lion/ui/define/lion-combobox.js'; - -import '@lion/ui/define/lion-dialog.js'; - -import '@lion/ui/define/lion-form.js'; -import '@lion/ui/define/lion-fieldset.js'; -import '@lion/ui/define/lion-select-rich.js'; -import '@lion/ui/define/lion-select.js'; - -import '@lion/ui/define/lion-input-stepper.js'; -import '@lion/ui/define/lion-input-range.js'; -import '@lion/ui/define/lion-input-amount.js'; -import '@lion/ui/define/lion-input.js'; -import '@lion/ui/define/lion-input-date.js'; -import '@lion/ui/define/lion-input-iban.js'; -import '@lion/ui/define/lion-input-email.js'; -import '@lion/ui/define/lion-input-tel-dropdown.js'; -import '@lion/ui/define/lion-input-datepicker.js'; - -import '@lion/ui/define/lion-textarea.js'; - -import '@lion/ui/define/lion-radio.js'; -import '@lion/ui/define/lion-radio-group.js'; - -import '@lion/ui/define/lion-listbox.js'; -import '@lion/ui/define/lion-option.js'; -import '@lion/ui/define/lion-options.js'; - -import '@lion/ui/define/lion-validation-feedback.js'; +import './helpers/umbrella-form.js'; /** * @typedef {import('../../form-core/src/LionField.js').LionField} LionField + * @typedef {import('../../input-file/types/input-file.js').InputFile} InputFile * @typedef {import('../../button/src/LionButton.js').LionButton} LionButton * @typedef {import('./helpers/umbrella-form.js').UmbrellaForm} UmbrellaForm */ +const file = /** @type {InputFile} */ ( + new File(['foo'], 'foo.txt', { + type: 'text/plain', + }) +); + const fullyPrefilledSerializedValue = { fullName: { firstName: 'Lorem', lastName: 'Ipsum' }, date: '2000-12-12', @@ -57,6 +28,7 @@ const fullyPrefilledSerializedValue = { favoriteFruit: 'Banana', favoriteMovie: 'Rocky', favoriteColor: 'hotpink', + file: [], lyrics: '1', notifications: { checked: true, @@ -81,6 +53,7 @@ const fullyChangedSerializedValue = { favoriteFruit: '', favoriteMovie: '', favoriteColor: '', + file: [file], lyrics: '2', notifications: { checked: false, @@ -189,6 +162,7 @@ describe(`Submitting/Resetting/Clearing Form`, async () => { favoriteFruit: '', favoriteMovie: '', favoriteColor: '', + file: [], lyrics: '', notifications: { checked: false, diff --git a/packages/ui/components/form-integrations/test/form-integrations.test.js b/packages/ui/components/form-integrations/test/form-integrations.test.js index ea4e459d7..8739192e6 100644 --- a/packages/ui/components/form-integrations/test/form-integrations.test.js +++ b/packages/ui/components/form-integrations/test/form-integrations.test.js @@ -41,6 +41,7 @@ describe('Form Integrations', () => { favoriteFruit: 'Banana', favoriteMovie: 'Rocky', favoriteColor: 'hotpink', + file: [], lyrics: '1', range: 2.3, terms: [], @@ -74,6 +75,7 @@ describe('Form Integrations', () => { favoriteFruit: 'Banana', favoriteMovie: 'Rocky', favoriteColor: 'hotpink', + file: '', lyrics: 'Fire up that loud', range: 2.3, terms: [], @@ -98,6 +100,7 @@ describe('Form Integrations', () => { money: '', iban: '', email: '', + file: [], checkers: ['foo', 'bar'], dinosaurs: 'brontosaurus', favoriteFruit: 'Banana', @@ -129,6 +132,7 @@ describe('Form Integrations', () => { 'lion-input-amount', 'lion-input-iban', 'lion-input-email', + 'lion-input-file', 'lion-input-tel', 'lion-input-tel-dropdown', 'lion-checkbox-group', diff --git a/packages/ui/components/form-integrations/test/helpers/umbrella-form.js b/packages/ui/components/form-integrations/test/helpers/umbrella-form.js index 303a4b25b..8974503ac 100644 --- a/packages/ui/components/form-integrations/test/helpers/umbrella-form.js +++ b/packages/ui/components/form-integrations/test/helpers/umbrella-form.js @@ -1,29 +1,31 @@ -import { LitElement, html } from 'lit'; -import { Required, MinLength } from '@lion/ui/form-core.js'; -import '@lion/ui/define/lion-form.js'; -import '@lion/ui/define/lion-fieldset.js'; -import '@lion/ui/define/lion-input.js'; -import '@lion/ui/define/lion-input-date.js'; -import '@lion/ui/define/lion-input-datepicker.js'; -import '@lion/ui/define/lion-input-amount.js'; -import '@lion/ui/define/lion-input-iban.js'; -import '@lion/ui/define/lion-input-email.js'; -import '@lion/ui/define/lion-input-tel.js'; -import '@lion/ui/define/lion-input-tel-dropdown.js'; +import '@lion/ui/define/lion-button-reset.js'; +import '@lion/ui/define/lion-button-submit.js'; import '@lion/ui/define/lion-checkbox-group.js'; import '@lion/ui/define/lion-checkbox.js'; -import '@lion/ui/define/lion-radio-group.js'; -import '@lion/ui/define/lion-radio.js'; -import '@lion/ui/define/lion-select.js'; -import '@lion/ui/define/lion-select-rich.js'; +import '@lion/ui/define/lion-combobox.js'; +import '@lion/ui/define/lion-fieldset.js'; +import '@lion/ui/define/lion-form.js'; +import '@lion/ui/define/lion-input-amount.js'; +import '@lion/ui/define/lion-input-date.js'; +import '@lion/ui/define/lion-input-datepicker.js'; +import '@lion/ui/define/lion-input-email.js'; +import '@lion/ui/define/lion-input-file.js'; +import '@lion/ui/define/lion-input-iban.js'; +import '@lion/ui/define/lion-input-range.js'; +import '@lion/ui/define/lion-input-stepper.js'; +import '@lion/ui/define/lion-input-tel-dropdown.js'; +import '@lion/ui/define/lion-input-tel.js'; +import '@lion/ui/define/lion-input.js'; import '@lion/ui/define/lion-listbox.js'; import '@lion/ui/define/lion-option.js'; -import '@lion/ui/define/lion-combobox.js'; -import '@lion/ui/define/lion-input-range.js'; -import '@lion/ui/define/lion-textarea.js'; -import '@lion/ui/define/lion-button.js'; +import '@lion/ui/define/lion-radio-group.js'; +import '@lion/ui/define/lion-radio.js'; +import '@lion/ui/define/lion-select-rich.js'; +import '@lion/ui/define/lion-select.js'; import '@lion/ui/define/lion-switch.js'; -import '@lion/ui/define/lion-input-stepper.js'; +import '@lion/ui/define/lion-textarea.js'; +import { MinLength, Required } from '@lion/ui/form-core.js'; +import { html, LitElement } from 'lit'; export class UmbrellaForm extends LitElement { get _lionFormNode() { @@ -83,6 +85,7 @@ export class UmbrellaForm extends LitElement { + diff --git a/packages/ui/components/input-file/src/FileHandle.js b/packages/ui/components/input-file/src/FileHandle.js new file mode 100644 index 000000000..596407b55 --- /dev/null +++ b/packages/ui/components/input-file/src/FileHandle.js @@ -0,0 +1,98 @@ +import { IsAcceptedFile } from './validators.js'; + +/** + * @typedef {import('../types/input-file.js').SystemFile} SystemFile + */ + +// Do these global constants add value? They are only used in this file +// Typing filehandle would be enough + +/** + * 500MB in bytes + */ +export const MAX_FILE_SIZE = 524288000; + +/** + * @typedef {Object} property + * @property {string} type + * @property {string} size + */ +export const FILE_FAILED_PROP = { + type: 'FILE_TYPE', + size: 'FILE_SIZE', +}; + +export const UPLOAD_FILE_STATUS = { + fail: 'FAIL', + pass: 'SUCCESS', +}; + +export class FileHandle { + /** + * @param {SystemFile} systemFile + * @param {{ allowedFileTypes: Array; allowedFileExtensions: Array; maxFileSize: number; }} _acceptCriteria + */ + constructor(systemFile, _acceptCriteria) { + /** + * @type {Array} + */ + this.failedProp = []; + this.systemFile = systemFile; + this._acceptCriteria = _acceptCriteria; + this.uploadFileStatus(); + if (this.failedProp.length === 0) { + this.createDownloadUrl(systemFile); + } + } + + // TDOO: same util as in validators.js + /** + * @param {string} fileName + * @return {string} + * @protected + */ + // eslint-disable-next-line class-methods-use-this + _getFileNameExtension(fileName) { + return fileName.slice(fileName.lastIndexOf('.') + 1); + } + + // TODO: seems to suggest upload is going on... + // checks the file size and type to set failedProp property + uploadFileStatus() { + if (this._acceptCriteria.allowedFileExtensions.length) { + const fileExtension = this._getFileNameExtension(this.systemFile.name); + if ( + !IsAcceptedFile.isExtensionAllowed( + fileExtension, + this._acceptCriteria.allowedFileExtensions, + ) + ) { + this.status = UPLOAD_FILE_STATUS.fail; + this.failedProp.push(FILE_FAILED_PROP.type); + } + } else if (this._acceptCriteria.allowedFileTypes.length) { + const fileType = this.systemFile.type; + if (!IsAcceptedFile.isFileTypeAllowed(fileType, this._acceptCriteria.allowedFileTypes)) { + this.status = UPLOAD_FILE_STATUS.fail; + this.failedProp.push(FILE_FAILED_PROP.type); + } + } + + if (IsAcceptedFile.checkFileSize(this.systemFile.size, this._acceptCriteria.maxFileSize)) { + if (this.status !== UPLOAD_FILE_STATUS.fail) { + this.status = UPLOAD_FILE_STATUS.pass; + } + } else { + this.status = UPLOAD_FILE_STATUS.fail; + this.failedProp.push(FILE_FAILED_PROP.size); + } + } + + /** + * @param {SystemFile} file + */ + createDownloadUrl(file) { + // @ts-ignore + this.downloadUrl = window.URL.createObjectURL(file); + } +} diff --git a/packages/ui/components/input-file/src/LionInputFile.js b/packages/ui/components/input-file/src/LionInputFile.js new file mode 100644 index 000000000..db53e5dbf --- /dev/null +++ b/packages/ui/components/input-file/src/LionInputFile.js @@ -0,0 +1,892 @@ +import { LionField } from '@lion/ui/form-core.js'; +import { LocalizeMixin } from '@lion/ui/localize.js'; +import { ScopedElementsMixin } from '@open-wc/scoped-elements'; +import { css, html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { FileHandle, MAX_FILE_SIZE } from './FileHandle.js'; +import { LionSelectedFileList } from './LionSelectedFileList.js'; +import { localizeNamespaceLoader } from './localizeNamespaceLoader.js'; +import { DuplicateFileNames, IsAcceptedFile } from './validators.js'; + +/** + * @typedef {import('lit').TemplateResult} TemplateResult + * @typedef {import('lit').RenderOptions} RenderOptions + * @typedef {import('../types/input-file.js').InputFile} InputFile + * @typedef {import('../types/input-file.js').SystemFile} SystemFile + * @typedef {import('../types/input-file.js').FileSelectResponse} FileSelectResponse + */ + +/** + * @param {number} bytes + * @param {number} decimals + */ +function formatBytes(bytes, decimals = 2) { + if (!+bytes) { + return '0 Bytes'; + } + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = [' bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / k ** i).toFixed(dm))}${sizes[i]}`; +} + +export class LionInputFile extends ScopedElementsMixin(LocalizeMixin(LionField)) { + static get scopedElements() { + return { + ...super.scopedElements, + 'lion-selected-file-list': LionSelectedFileList, + }; + } + + static get properties() { + return { + accept: { type: String }, + multiple: { type: Boolean, reflect: true }, + buttonLabel: { type: String, attribute: 'button-label' }, + maxFileSize: { type: Number, attribute: 'max-file-size' }, + enableDropZone: { type: Boolean, attribute: 'enable-drop-zone' }, + uploadOnSelect: { type: Boolean, attribute: 'upload-on-select' }, + _fileSelectResponse: { type: Array, state: false }, + _selectedFilesMetaData: { type: Array, state: true }, + }; + } + + static localizeNamespaces = [ + { 'lion-input-file': localizeNamespaceLoader }, + ...super.localizeNamespaces, + ]; + + static get validationTypes() { + return ['error', 'info']; + } + + /** + * @configure SlotMixin + */ + get slots() { + return { + ...super.slots, + input: () => html``, + 'file-select-button': () => + html``, + 'selected-file-list': () => ({ + template: html` + + `, + }), + }; + } + + /** + * @type {HTMLInputElement} + * @protected + */ + get _inputNode() { + return /** @type {HTMLInputElement} */ (super._inputNode); + } + + /** + * @protected + */ + get _buttonNode() { + return this.querySelector(`#select-button-${this._inputId}`); + } + + /** + * The helpt text for the input node. + * When no light dom defined via [slot=help-text], this value will be used + * @type {string} + */ + get buttonLabel() { + return this.__buttonLabel || this._buttonNode?.textContent || ''; + } + + /** + * @param {string} newValue + */ + set buttonLabel(newValue) { + const oldValue = this.buttonLabel; + /** @type {string} */ + this.__buttonLabel = newValue; + this.requestUpdate('buttonLabel', oldValue); + } + + /** + * @protected + * @configure FocusMixin + */ + // @ts-ignore + get _focusableNode() { + return this._buttonNode; + } + + /** + * @protected + */ + // TODO: no need to check anymore? https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable + // eslint-disable-next-line class-methods-use-this + get _isDragAndDropSupported() { + return 'draggable' in document.createElement('div'); + } + + constructor() { + super(); + this.type = 'file'; + /** + * @protected + * @type {InputFile[]} + */ + this._selectedFilesMetaData = []; + /** + * @type {FileSelectResponse[]} + */ + // TODO: make readonly? + this._fileSelectResponse = []; + /** + * @private + */ + this.__initialFileSelectResponse = this._fileSelectResponse; + // TODO: public default booleans are always false + this.uploadOnSelect = false; + this.multiple = false; + this.enableDropZone = false; + this.maxFileSize = MAX_FILE_SIZE; + this.accept = ''; + /** + * @type {InputFile[]} + */ + this.modelValue = []; + /** + * @protected + * @type {EventListener} + */ + this._onRemoveFile = this._onRemoveFile.bind(this); + + /** @private */ + this.__duplicateFileNamesValidator = new DuplicateFileNames({ show: false }); + } + + connectedCallback() { + super.connectedCallback(); + this.__initialFileSelectResponse = this._fileSelectResponse; + + this._inputNode.addEventListener('change', this._onChange); + this._inputNode.addEventListener('click', this._onClick); + this.addEventListener( + 'file-remove-requested', + /** @type {EventListener} */ (this._onRemoveFile), + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._inputNode.removeEventListener('change', this._onChange); + this._inputNode.removeEventListener('click', this._onClick); + this.removeEventListener( + 'file-remove-requested', + /** @type {EventListener} */ (this._onRemoveFile), + ); + } + + /** + * @configure LocalizeMixin + */ + onLocaleUpdated() { + super.onLocaleUpdated(); + if (this.multiple) { + // @ts-ignore + this.buttonLabel = this.msgLit('lion-input-file:selectTextMultipleFile'); + } else { + // @ts-ignore + this.buttonLabel = this.msgLit('lion-input-file:selectTextSingleFile'); + } + } + + get _acceptCriteria() { + /** @type {string[]} */ + let allowedFileTypes = []; + /** @type {string[]} */ + let allowedFileExtensions = []; + if (this.accept) { + const acceptedFiles = this.accept.replace(/\s+/g, '').replace(/\.+/g, '').split(','); + allowedFileTypes = acceptedFiles.filter(acceptedFile => acceptedFile.includes('/')); + allowedFileExtensions = acceptedFiles.filter(acceptedFile => !acceptedFile.includes('/')); + } + return { + allowedFileTypes, + allowedFileExtensions, + maxFileSize: this.maxFileSize, + }; + } + + /** + * @enhance LionField + * Resets modelValue to initial value. + * Interaction states are cleared + */ + reset() { + super.reset(); + this._selectedFilesMetaData = []; + this._fileSelectResponse = this.__initialFileSelectResponse; + this.modelValue = []; + // TODO: find out why it stays dirty + this.dirty = false; + } + + /** + * Clears modelValue. + * Interaction states are not cleared (use resetInteractionState for this) + * @override LionField + */ + clear() { + this._selectedFilesMetaData = []; + this._fileSelectResponse = []; + this.modelValue = []; + } + + /** + * @override ValidateMixin: override to hide the IsAcceptedFile feedback at component level as they are displayed at each file level in file list + * @param {string} type could be 'error', 'warning', 'info', 'success' or any other custom + * @param {object} meta meta info (interaction states etc) + * @protected + */ + _showFeedbackConditionFor(type, meta) { + return ( + super._showFeedbackConditionFor(type, meta) && + !( + this.validationStates.error?.FileTypeAllowed || this.validationStates.error?.FileSizeAllowed + ) + ); + } + + /** + * @configure FormatMixin + * @returns {InputFile[]} parsedValue + */ + parser() { + // @ts-ignore + return this._inputNode.files ? Array.from(this._inputNode.files) : []; + } + + /** + * @configure FormatMixin + * @param {InputFile[]} v - modelValue: File[] + * @returns {string} formattedValue + */ + // eslint-disable-next-line no-unused-vars + formatter(v) { + return this._inputNode?.value || ''; + } + + /** @private */ + __setupDragDropEventListeners() { + // TODO: this will break as soon as a Subclasser changes the template ... (changing class names is allowed, ids should be kept) + const dropZone = this.shadowRoot?.querySelector('.input-file__drop-zone'); + ['dragenter', 'dragover', 'dragleave'].forEach(eventName => { + dropZone?.addEventListener( + eventName, + (/** @type {Event} */ ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (eventName !== 'dragleave') { + this.setAttribute('is-dragging', ''); + } else { + this.removeAttribute('is-dragging'); + } + }, + false, + ); + }); + + window.addEventListener( + 'drop', + ev => { + if (ev.target === this._inputNode) { + ev.preventDefault(); + } + this.removeAttribute('is-dragging'); + }, + false, + ); + } + + /** + * @param {import('lit').PropertyValues } changedProperties + */ + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + + this.__setupFileValidators(); + // We need to update our light dom + this._enhanceSelectedList(); + + // TODO: if there's no inputNode by now, we should either throw an error or add a slot change listener + if (this._inputNode) { + // TODO: should it be possible to change this on the fly (in updated)? + this._inputNode.type = this.type; + this._inputNode.setAttribute('tabindex', '-1'); + // TODO: should it be possible to change this on the fly (in updated)? + this._inputNode.multiple = this.multiple; + if (this.accept.length) { + // TODO: should it be possible to change this on the fly (in updated)? + this._inputNode.accept = this.accept; + } + } + + if (this.enableDropZone && this._isDragAndDropSupported) { + this.__setupDragDropEventListeners(); + this.setAttribute('drop-zone', ''); + } + } + + /** + * @param {import('lit').PropertyValues } changedProperties + */ + updated(changedProperties) { + super.updated(changedProperties); + + // TODO: mention code originates from LionInput, but we could not extend from it bc/o x/y/z + if (changedProperties.has('disabled')) { + this._inputNode.disabled = this.disabled; + this.validate(); + } + + if (changedProperties.has('buttonLabel') && this._buttonNode) { + this._buttonNode.textContent = this.buttonLabel; + } + + // TODO: mention code originates from LionInput, but we could not extend from it bc/o x/y/z + if (changedProperties.has('name')) { + this._inputNode.name = this.name; + } + + if (changedProperties.has('_ariaLabelledNodes')) { + this.__syncAriaLabelledByAttributesToButton(); + } + + if (changedProperties.has('_ariaDescribedNodes')) { + this.__syncAriaDescribedByAttributesToButton(); + } + + /** + * Update _selectedFilesMetaData only if: + * 1. It is invoked from the file-removed event handler. + * 2. There is a mismatch between the selected files and files on UI. + */ + if (changedProperties.has('_fileSelectResponse')) { + if (this._selectedFilesMetaData.length === 0) { + this._fileSelectResponse.forEach(preResponse => { + const file = { + systemFile: { + name: preResponse.name, + }, + response: preResponse, + status: preResponse.status, + validationFeedback: [ + { + message: preResponse.errorMessage, + }, + ], + }; + // @ts-ignore + this._selectedFilesMetaData = [...this._selectedFilesMetaData, file]; + }); + } + this._selectedFilesMetaData.forEach(file => { + if ( + !this._fileSelectResponse.some(response => response.name === file.systemFile.name) && + this.uploadOnSelect + ) { + this.__removeFileFromList(file); + } else { + this._fileSelectResponse.forEach(response => { + if (response.name === file.systemFile.name) { + // eslint-disable-next-line no-param-reassign + file.response = response; + // eslint-disable-next-line no-param-reassign + file.downloadUrl = response.downloadUrl ? response.downloadUrl : file.downloadUrl; + // eslint-disable-next-line no-param-reassign + file.status = response.status; + // eslint-disable-next-line no-param-reassign + file.validationFeedback = [ + { + type: response.errorMessage?.length > 0 ? 'error' : 'success', + message: response.errorMessage, + }, + ]; + } + }); + // this._selectedFilesMetaData = [...this._selectedFilesMetaData]; + } + }); + } + } + + // TODO: this method also triggers a validator... + /** + * @private + * @param {InputFile[]} fileList + */ + __computeNewAddedFiles(fileList) { + const computedFileList = fileList.filter( + file => + this._selectedFilesMetaData.findIndex( + existLionFile => existLionFile.systemFile.name === file.name, + ) === -1, + ); + // TODO: put this logic in the Validator itself. Changing the param should trigger a re-validate + this.__duplicateFileNamesValidator.param = { + show: fileList.length !== computedFileList.length, + }; + this.validate(); + + return computedFileList; + } + + /** + * @param {DragEvent} ev + * @protected + */ + _processDroppedFiles(ev) { + ev.preventDefault(); + this.removeAttribute('is-dragging'); + + const isDraggingMultipleWhileNotSupported = + ev.dataTransfer && ev.dataTransfer.items.length > 1 && !this.multiple; + if (isDraggingMultipleWhileNotSupported || !ev.dataTransfer?.files) { + return; + } + + this._inputNode.files = ev.dataTransfer.files; + // @ts-ignore + this.modelValue = Array.from(ev.dataTransfer.files); + // if same file is selected again, e.dataTransfer.files lists that file. + // So filter if the file already exists + // @ts-ignore + // const newFiles = this.__computeNewAddedFiles(Array.from(ev.dataTransfer.files)); + // if (newFiles.length > 0) { + // this._processFiles(newFiles); + // } + this._processFiles(Array.from(ev.dataTransfer.files)); + } + + /** + * @override + * @param {Event=} ev + * @protected + */ + + _onChange(ev) { + // Here, we take over the responsibility of InteractionStateMixin, as _leaveEvent is not the best trigger in this case. + // Giving feedback right after the file dialog is closed results in best UX. + this.touched = true; + // Here, we connect ourselves to the FormatMixin flow... + // TODO: should we call super._onChange(ev) here instead? + this._onUserInputChanged(); + this._processFiles(/** @type {HTMLInputElement & {files:InputFile[]}} */ (ev?.target)?.files); + } + + /** + * Clear _inputNode.value to make sure onChange is called even for duplicate files + * @param {Event} ev + * @protected + */ + // eslint-disable-next-line class-methods-use-this + _onClick(ev) { + // @ts-ignore + ev.target.value = ''; // eslint-disable-line no-param-reassign + } + + /** + * @protected + */ + _enhanceSelectedList() { + /** + * @type {LionSelectedFileList | null} + */ + const selectedFileList = this.querySelector('[slot="selected-file-list"]'); + if (selectedFileList) { + selectedFileList.setAttribute('id', `selected-file-list-${this._inputId}`); + this.addToAriaDescribedBy(selectedFileList, { idPrefix: 'selected-file-list' }); + } + } + + /** + * @protected + */ + __syncAriaLabelledByAttributesToButton() { + if (this._inputNode.hasAttribute('aria-labelledby')) { + const ariaLabelledBy = this._inputNode.getAttribute('aria-labelledby'); + this._buttonNode?.setAttribute( + 'aria-labelledby', + `select-button-${this._inputId} ${ariaLabelledBy}`, + ); + } + } + + /** + * @protected + */ + __syncAriaDescribedByAttributesToButton() { + if (this._inputNode.hasAttribute('aria-describedby')) { + const ariaDescribedby = this._inputNode.getAttribute('aria-describedby') || ''; + this._buttonNode?.setAttribute('aria-describedby', ariaDescribedby); + } + } + + /** + * @private + */ + __setupFileValidators() { + // TODO: update .param when _acceptCriteria changes + this.defaultValidators = [ + new IsAcceptedFile(this._acceptCriteria), + this.__duplicateFileNamesValidator, + ]; + } + + /** + * Runs on drag or change event + * + * @param {InputFile[]} selectedFiles + * @protected + */ + _processFiles(selectedFiles) { + // file size and type validators are required only when file is selected and not in case of prefill + // TODO: is this needed every time? + const newFiles = this.__computeNewAddedFiles(Array.from(selectedFiles)); + if (!this.multiple && newFiles.length > 0) { + this._selectedFilesMetaData = []; + this._fileSelectResponse = []; + } + + /** + * @type {InputFile} + */ + let fileObj; + for (const selectedFile of newFiles.values()) { + // @ts-ignore + fileObj = new FileHandle(selectedFile, this._acceptCriteria); + if (fileObj.failedProp?.length) { + this._handleErroredFiles(fileObj); + this._fileSelectResponse = [ + ...this._fileSelectResponse, + { + name: fileObj.systemFile.name, + status: 'FAIL', + // @ts-expect-error + errorMessage: fileObj.validationFeedback[0].message, + }, + ]; + } else { + this._fileSelectResponse = [ + ...this._fileSelectResponse, + { + name: fileObj.systemFile.name, + status: 'SUCCESS', + }, + ]; + } + this._selectedFilesMetaData = [...this._selectedFilesMetaData, fileObj]; + this._handleErrors(); + } + + // only send error-free files to file-list-changed event + const _successFiles = this._selectedFilesMetaData + .filter( + ({ systemFile, status }) => + newFiles.includes(/** @type {InputFile} */ (systemFile)) && status === 'SUCCESS', + ) + .map(({ systemFile }) => /** @type {InputFile} */ (systemFile)); + + if (_successFiles.length > 0) { + this._dispatchFileListChangeEvent(_successFiles); + } + } + + /** + * @param {InputFile[]} newFiles + * @protected + */ + _dispatchFileListChangeEvent(newFiles) { + this.dispatchEvent( + new CustomEvent('file-list-changed', { + // TODO: check if composed and bubbles are needed + // composed: true, + // bubbles: true, + detail: { + newFiles, + }, + }), + ); + } + + /** + * @protected + */ + _handleErrors() { + let hasErrors = false; + this._selectedFilesMetaData.forEach(fileObj => { + if (fileObj.failedProp && fileObj.failedProp.length > 0) { + hasErrors = true; + } + }); + + // TODO: handle via ValidateMixin (otherwise it breaks as soon as private ValidateMixin internals change) + if (hasErrors) { + this.hasFeedbackFor?.push('error'); + // @ts-ignore use private property + this.shouldShowFeedbackFor.push('error'); + } else if (this._prevHasErrors && this.hasFeedbackFor.includes('error')) { + const hasFeedbackForIndex = this.hasFeedbackFor.indexOf('error'); + this.hasFeedbackFor.slice(hasFeedbackForIndex, hasFeedbackForIndex + 1); + // @ts-ignore use private property + const shouldShowFeedbackForIndex = this.shouldShowFeedbackFor.indexOf('error'); + // @ts-ignore use private property + this.shouldShowFeedbackFor.slice(shouldShowFeedbackForIndex, shouldShowFeedbackForIndex + 1); + } + this._prevHasErrors = hasErrors; + } + + /** + * @param {InputFile} fileObj + * @protected + */ + /* eslint-disable no-param-reassign */ + _handleErroredFiles(fileObj) { + fileObj.validationFeedback = []; + const { allowedFileExtensions, allowedFileTypes } = this._acceptCriteria; + /** + * @type {string[]} + */ + let array = []; + let arrayLength = 0; + let lastItem; + + if (allowedFileExtensions.length) { + array = allowedFileExtensions; + // eslint-disable-next-line no-return-assign + array = array.map(item => (item = `.${item}`)); + lastItem = array.pop(); + arrayLength = array.length; + } else if (allowedFileTypes.length) { + allowedFileTypes.forEach(MIMETypes => { + if (MIMETypes.endsWith('/*')) { + array.push(MIMETypes.slice(0, -2)); + } else if (MIMETypes === 'text/plain') { + array.push('text'); + } else { + const index = MIMETypes.indexOf('/'); + const subTypes = MIMETypes.slice(index + 1); + + if (!subTypes.includes('+')) { + array.push(`.${subTypes}`); + } else { + const subType = subTypes.split('+'); + array.push(`.${subType[0]}`); + } + } + }); + lastItem = array.pop(); + arrayLength = array.length; + } + let message = ''; + if (!lastItem) { + message = `${this.msgLit('lion-input-file:allowedFileSize', { + maxSize: formatBytes(this.maxFileSize), + })}`; + } else if (!arrayLength) { + message = `${this.msgLit('lion-input-file:allowedFileValidatorSimple', { + allowedType: lastItem, + maxSize: formatBytes(this.maxFileSize), + })}`; + } else { + message = `${this.msgLit('lion-input-file:allowedFileValidatorComplex', { + allowedTypesArray: array.join(', '), + allowedTypesLastItem: lastItem, + maxSize: formatBytes(this.maxFileSize), + })}`; + } + + const errorObj = { + message, + type: 'error', + }; + fileObj.validationFeedback?.push(errorObj); + } + + /** + * @private + * @param {InputFile} removedFile + */ + __removeFileFromList(removedFile) { + this._selectedFilesMetaData = this._selectedFilesMetaData.filter( + currentFile => currentFile.systemFile.name !== removedFile.systemFile.name, + ); + // checks if the file is not a pre-filled file + if (this.modelValue) { + this.modelValue = this.modelValue.filter( + (/** @type {InputFile} */ currentFile) => currentFile.name !== removedFile.systemFile.name, + ); + } + this._inputNode.value = ''; + this._handleErrors(); + } + + /** + * @param {CustomEvent} ev + * @protected + */ + _onRemoveFile(ev) { + if (this.disabled) { + return; + } + const { removedFile } = ev.detail; + if (!this.uploadOnSelect && removedFile) { + this.__removeFileFromList(removedFile); + } + + this._removeFile(removedFile); + } + + // TODO: this doesn't remove the file from the list, but fires an event + /** + * @param {InputFile} removedFile + * @protected + */ + _removeFile(removedFile) { + this.dispatchEvent( + // TODO: check if composed and bubbles are needed + new CustomEvent('file-removed', { + // bubbles: true, + // composed: true, + detail: { + removedFile, + status: removedFile.status, + _fileSelectResponse: removedFile.response, + }, + }), + ); + } + + /** + * Every time .formattedValue is attempted to sync to the view value (on change/blur and on + * modelValue change), this condition is checked. In case of the input-file we don't want + * this sync to happen, since the view value is already correct. + * @override FormatMixin + * @return {boolean} + * @protected + */ + // eslint-disable-next-line class-methods-use-this + _reflectBackOn() { + return false; + } + + /** + * Helper method for the mutually exclusive Required Validator + * @override ValidateMixin + */ + _isEmpty() { + return this.modelValue?.length === 0; + } + + /** + * @return {TemplateResult} + * @protected + */ + _dropZoneTemplate() { + return html` +
+
+ ${this.msgLit('lion-input-file:dragAndDropText')} +
+ +
+ `; + } + + /** + * @override FormControlMixin + * @return {TemplateResult} + * @protected + */ + // eslint-disable-next-line class-methods-use-this + _inputGroupAfterTemplate() { + return html` `; + } + + /** + * @override FormControlMixin + * @return {TemplateResult} + * @protected + */ + _inputGroupInputTemplate() { + return html` + + ${this.enableDropZone && this._isDragAndDropSupported + ? this._dropZoneTemplate() + : html` +
+ +
+ `} + `; + } + + static get styles() { + return [ + super.styles, + css` + .input-group__container { + position: relative; + display: flex; + flex-direction: column; + width: fit-content; + } + + :host([drop-zone]) .input-group__container { + width: auto; + } + + .input-group__container ::slotted(input[type='file']) { + /** Invisible, since means of interaction is button */ + position: absolute; + opacity: 0; + /** Full cover positioned, so it will be a drag and drop surface */ + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + .input-file__drop-zone { + display: flex; + position: relative; + flex-direction: column; + align-items: center; + border: dashed 2px black; + padding: 24px 0; + } + `, + ]; + } + + /** + * @param {MouseEvent} ev + */ + __openDialogOnBtnClick(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this._inputNode.click(); + } +} diff --git a/packages/ui/components/input-file/src/LionSelectedFileList.js b/packages/ui/components/input-file/src/LionSelectedFileList.js new file mode 100644 index 000000000..030455dfa --- /dev/null +++ b/packages/ui/components/input-file/src/LionSelectedFileList.js @@ -0,0 +1,232 @@ +import { uuid } from '@lion/ui/core.js'; +import { LionValidationFeedback } from '@lion/ui/form-core.js'; +import { LocalizeMixin } from '@lion/ui/localize.js'; +import { ScopedElementsMixin } from '@open-wc/scoped-elements'; +import { css, html, LitElement, nothing } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { localizeNamespaceLoader } from './localizeNamespaceLoader.js'; + +/** + * @typedef {import('lit').TemplateResult} TemplateResult + * @typedef {import('../types/input-file.js').InputFile} InputFile + * @typedef {import('../types/input-file.js').SystemFile} SystemFile + */ + +export class LionSelectedFileList extends LocalizeMixin(ScopedElementsMixin(LitElement)) { + static get scopedElements() { + return { + // @ts-expect-error [external] fix types scopedElements + ...super.scopedElements, + 'lion-validation-feedback': LionValidationFeedback, + }; + } + + static get properties() { + return { + fileList: { type: Array }, + multiple: { type: Boolean }, + }; + } + + static localizeNamespaces = [ + { 'lion-input-file': localizeNamespaceLoader }, + ...super.localizeNamespaces, + ]; + + constructor() { + super(); + /** + * @type {InputFile[]} + */ + this.fileList = []; + this.multiple = false; + } + + /** + * @param {import('lit').PropertyValues} changedProperties + */ + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has('fileList')) { + this._enhanceLightDomA11y(); + } + } + + _enhanceLightDomA11y() { + const fileFeedbackElementList = this.shadowRoot?.querySelectorAll('[id^="file-feedback"]'); + // TODO: this will break as soon as a Subclasser changes the template ... + const inputFileNode = this.parentNode?.parentNode; + fileFeedbackElementList?.forEach(feedbackEl => { + // Generic focus/blur handling that works for both Fields/FormGroups + inputFileNode?.addEventListener('focusin', () => { + feedbackEl.setAttribute('aria-live', 'polite'); + }); + inputFileNode?.addEventListener('focusout', () => { + feedbackEl.setAttribute('aria-live', 'assertive'); + }); + }); + } + + /** + * @protected + * @param {InputFile} removedFile + */ + _removeFile(removedFile) { + this.dispatchEvent( + // TODO: do we need bubble and composed for an internal event that only LionInputFile should know about? + new CustomEvent('file-remove-requested', { + // composed: true, + // bubbles: true, + detail: { + removedFile, + status: removedFile.status, + _fileSelectResponse: removedFile.response, + }, + }), + ); + } + + /** + * @protected + * @param {Array} validationFeedback + * @param {string} fileUuid + * @return {TemplateResult} + */ + // eslint-disable-next-line class-methods-use-this + _validationFeedbackTemplate(validationFeedback, fileUuid) { + return html` + + `; + } + + /** + * @protected + * @param {InputFile} file + * @return {TemplateResult|nothing} + */ + // eslint-disable-next-line class-methods-use-this, no-unused-vars + _listItemBeforeTemplate(file) { + return nothing; + } + + /** + * @protected + * @param {InputFile} file + * @param {string} fileUuid + * @return {TemplateResult} + */ + // eslint-disable-next-line no-unused-vars + _listItemAfterTemplate(file, fileUuid) { + return html` + + `; + } + + /** + * @protected + * @return {TemplateResult} + */ + // eslint-disable-next-line class-methods-use-this + _removeButtonContentTemplate() { + return html`✖️`; + } + + /** + * @protected + * @param {InputFile} file + * @return {TemplateResult} + */ + _selectedListItemTemplate(file) { + const fileUuid = uuid(); + return html` +
+
+ ${this._listItemBeforeTemplate(file)} + + ${this.msgLit('lion-input-file:fileNameDescriptionLabel')} + ${file.downloadUrl && file.status !== 'LOADING' + ? html` + ${file.systemFile?.name} + ` + : file.systemFile?.name} + + ${this._listItemAfterTemplate(file, fileUuid)} +
+ ${file.status === 'FAIL' && file.validationFeedback + ? html` + ${repeat( + file.validationFeedback, + validationFeedback => html` + ${this._validationFeedbackTemplate([validationFeedback], fileUuid)} + `, + )} + ` + : nothing} +
+ `; + } + + render() { + return this.fileList?.length + ? html` + ${this.multiple + ? html` +
    + ${this.fileList.map( + file => html`
  • ${this._selectedListItemTemplate(file)}
  • `, + )} +
+ ` + : html` ${this._selectedListItemTemplate(this.fileList[0])} `} + ` + : nothing; + } + + static get styles() { + return [ + css` + .selected__list { + list-style-type: none; + margin-block-start: 0; + margin-block-end: 0; + padding-inline-start: 0; + } + + .sr-only { + position: absolute; + top: 0; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(100%); + clip: rect(1px, 1px, 1px, 1px); + white-space: nowrap; + border: 0; + margin: 0; + padding: 0; + } + `, + ]; + } +} diff --git a/packages/ui/components/input-file/src/localizeNamespaceLoader.js b/packages/ui/components/input-file/src/localizeNamespaceLoader.js new file mode 100644 index 000000000..e65572764 --- /dev/null +++ b/packages/ui/components/input-file/src/localizeNamespaceLoader.js @@ -0,0 +1,75 @@ +/* eslint-disable import/no-extraneous-dependencies */ +export const localizeNamespaceLoader = /** @param {string} locale */ locale => { + switch (locale) { + case 'bg-BG': + return import('@lion/ui/input-file-translations/bg-BG.js'); + case 'bg': + return import('@lion/ui/input-file-translations/bg.js'); + case 'cs-CZ': + return import('@lion/ui/input-file-translations/cs-CZ.js'); + case 'cs': + return import('@lion/ui/input-file-translations/cs.js'); + case 'de-DE': + return import('@lion/ui/input-file-translations/de-DE.js'); + case 'de': + return import('@lion/ui/input-file-translations/de.js'); + case 'en-AU': + return import('@lion/ui/input-file-translations/en-AU.js'); + case 'en-GB': + return import('@lion/ui/input-file-translations/en-GB.js'); + case 'en-US': + return import('@lion/ui/input-file-translations/en-US.js'); + case 'en-PH': + case 'en': + return import('@lion/ui/input-file-translations/en.js'); + case 'es-ES': + return import('@lion/ui/input-file-translations/es-ES.js'); + case 'es': + return import('@lion/ui/input-file-translations/es.js'); + case 'fr-FR': + return import('@lion/ui/input-file-translations/fr-FR.js'); + case 'fr-BE': + return import('@lion/ui/input-file-translations/fr-BE.js'); + case 'fr': + return import('@lion/ui/input-file-translations/fr.js'); + case 'hu-HU': + return import('@lion/ui/input-file-translations/hu-HU.js'); + case 'hu': + return import('@lion/ui/input-file-translations/hu.js'); + case 'it-IT': + return import('@lion/ui/input-file-translations/it-IT.js'); + case 'it': + return import('@lion/ui/input-file-translations/it.js'); + case 'nl-BE': + return import('@lion/ui/input-file-translations/nl-BE.js'); + case 'nl-NL': + return import('@lion/ui/input-file-translations/nl-NL.js'); + case 'nl': + return import('@lion/ui/input-file-translations/nl.js'); + case 'pl-PL': + return import('@lion/ui/input-file-translations/pl-PL.js'); + case 'pl': + return import('@lion/ui/input-file-translations/pl.js'); + case 'ro-RO': + return import('@lion/ui/input-file-translations/ro-RO.js'); + case 'ro': + return import('@lion/ui/input-file-translations/ro.js'); + case 'ru-RU': + return import('@lion/ui/input-file-translations/ru-RU.js'); + case 'ru': + return import('@lion/ui/input-file-translations/ru.js'); + case 'sk-SK': + return import('@lion/ui/input-file-translations/sk-SK.js'); + case 'sk': + return import('@lion/ui/input-file-translations/sk.js'); + case 'uk-UA': + return import('@lion/ui/input-file-translations/uk-UA.js'); + case 'uk': + return import('@lion/ui/input-file-translations/uk.js'); + case 'zh-CN': + case 'zh': + return import('@lion/ui/input-file-translations/zh.js'); + default: + return import('@lion/ui/input-file-translations/en.js'); + } +}; diff --git a/packages/ui/components/input-file/src/validators.js b/packages/ui/components/input-file/src/validators.js new file mode 100644 index 000000000..f54457ba8 --- /dev/null +++ b/packages/ui/components/input-file/src/validators.js @@ -0,0 +1,114 @@ +import { Validator } from '@lion/ui/form-core.js'; +import { getLocalizeManager } from '@lion/ui/localize-no-side-effects.js'; + +/** + * @typedef {import('../../form-core/types/validate/validate.js').ValidatorConfig} ValidatorConfig + * @typedef {import('../../form-core/types/validate/validate.js').ValidatorParam} ValidatorParam + * @typedef {import('../../form-core/types/validate/validate.js').ValidatorOutcome} ValidatorOutcome + * @typedef {import('../types/input-file.js').InputFile} InputFile + */ + +/* eslint max-classes-per-file: ["error", 2] */ +export class IsAcceptedFile extends Validator { + static validatorName = 'IsAcceptedFile'; + + /** + * @param {number} fileFileSize + * @param {number} maxFileSize + */ + static checkFileSize(fileFileSize, maxFileSize) { + return fileFileSize <= maxFileSize; + } + + /** + * Gets the extension of a file name + * @param {string} fileName like myFile.txt + * @return {string} like .txt + */ + static getExtension(fileName) { + return fileName?.slice(fileName.lastIndexOf('.')); + } + + /** + * @param {string} extension + * @param {string[]} allowedFileExtensions + */ + static isExtensionAllowed(extension, allowedFileExtensions) { + return allowedFileExtensions?.find( + allowedExtension => allowedExtension.toUpperCase() === extension.toUpperCase(), + ); + } + + /** + * @param {string} type + * @param {string[]} allowedFileTypes + */ + static isFileTypeAllowed(type, allowedFileTypes) { + return allowedFileTypes?.find(allowedType => allowedType.toUpperCase() === type.toUpperCase()); + } + + /** + * @param {Array} modelValue array of file objects + * @param {{ allowedFileTypes: string[]; allowedFileExtensions: string[]; maxFileSize: number; }} params + * @returns {Boolean} + */ + execute(modelValue, params = this.param) { + let isInvalidType; + let isInvalidFileExt; + + const ctor = /** @type {typeof IsAcceptedFile} */ (this.constructor); + const { allowedFileTypes, allowedFileExtensions, maxFileSize } = params; + if (allowedFileTypes?.length) { + isInvalidType = modelValue.some(file => !ctor.isFileTypeAllowed(file.type, allowedFileTypes)); + return isInvalidType; + } + if (allowedFileExtensions?.length) { + isInvalidFileExt = modelValue.some( + file => !ctor.isExtensionAllowed(ctor.getExtension(file.name), allowedFileExtensions), + ); + return isInvalidFileExt; + } + const invalidFileSize = modelValue.findIndex( + file => !ctor.checkFileSize(file.size, maxFileSize), + ); + return invalidFileSize > -1; + } + + // @todo: validation should be handled with ValidateMixin so overriding getMessage to return an empty string should + // not be necessary anymore + static async getMessage() { + return ''; + } +} + +export class DuplicateFileNames extends Validator { + static validatorName = 'DuplicateFileNames'; + + /** + * @param {ValidatorParam} [param] + * @param {ValidatorConfig} [config] + */ + constructor(param, config) { + super(param, config); + this.type = 'info'; + } + + /** + * @param {InputFile[]} modelValue + * @param {ValidatorParam} [params] + * @returns {ValidatorOutcome|Promise} + */ + execute(modelValue, params = this.param) { + return params.show; + } + + /** + * @returns {Promise} + */ + static async getMessage() { + const localizeManager = getLocalizeManager(); + // TODO: we need to make sure namespace is loaded + // TODO: keep Validators localize system agnostic + return localizeManager.msg('lion-input-file:uploadTextDuplicateFileName'); + } +} diff --git a/packages/ui/components/input-file/test/FileValidators.test.js b/packages/ui/components/input-file/test/FileValidators.test.js new file mode 100644 index 000000000..3b6e7dcbb --- /dev/null +++ b/packages/ui/components/input-file/test/FileValidators.test.js @@ -0,0 +1,170 @@ +import { expect } from '@open-wc/testing'; +import { IsAcceptedFile } from '../src/validators.js'; + +describe('lion-input-file: IsAcceptedFile', () => { + describe('valid file type', () => { + it('should return false for allowed file extension .csv', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ name: 'foo.csv' }], { + allowedFileExtensions: ['.csv'], + allowedFileTypes: undefined, + }); + expect(returnVal).to.be.false; + }); + + it('should return false for allowed file extension .csv and .pdf', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ name: 'foo.pdf' }], { + allowedFileExtensions: ['.csv', '.pdf'], + allowedFileTypes: undefined, + }); + expect(returnVal).to.be.false; + }); + + it('should return true for not allowed file extension .csv and .pdf', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ name: 'foo.pdf' }], { + allowedFileExtensions: ['.txt'], + allowedFileTypes: undefined, + }); + expect(returnVal).to.be.true; + }); + + it('should return false for valid file type for single file', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ type: 'foo' }], { + allowedFileTypes: ['foo'], + }); + + expect(returnVal).to.be.false; + }); + + it('should return false for valid file type for multiple file', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ type: 'foo' }, { type: 'foo' }], { + allowedFileTypes: ['foo'], + }); + + expect(returnVal).to.be.false; + }); + + it('should return false if no file types are defined, and has small file size', async () => { + const fileTypeObj = new IsAcceptedFile(); + const returnVal = fileTypeObj.execute( + [ + // @ts-ignore ignore file typing + { type: 'foo', size: 1 }, + // @ts-ignore ignore file typing + { type: 'foo', size: 1 }, + ], + { + allowedFileTypes: [], + maxFileSize: 1028, + }, + ); + + expect(returnVal).to.be.false; + }); + + it('should return false for valid file type for multiple file and multiple allowed types', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ type: 'foo' }, { type: 'bar' }], { + allowedFileTypes: ['foo', 'bar'], + }); + + expect(returnVal).to.be.false; + }); + }); + + describe('invalid file type', () => { + it('should return true for invalid file type for single file', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ type: 'bar' }], { + allowedFileTypes: ['foo'], + }); + + expect(returnVal).to.be.true; + }); + + it('should return false for invalid file type for multiple file', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ type: 'foo' }, { type: 'bar' }], { + allowedFileTypes: ['foo'], + }); + + expect(returnVal).to.be.true; + }); + + it('should return false for invalid file type for multiple file and multiple allowed types', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ type: 'foo' }, { type: 'pdf' }], { + allowedFileTypes: ['foo', 'bar'], + }); + + expect(returnVal).to.be.true; + }); + + it('should return true for invalid file type for empty type', async () => { + const fileTypeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileTypeObj.execute([{ type: '' }], { + allowedFileTypes: ['foo'], + }); + + expect(returnVal).to.be.true; + }); + }); + + describe('valid file size', () => { + it('should return false for valid file size for single file', async () => { + const fileSizeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileSizeObj.execute([{ size: 5 }], { + maxFileSize: 10, + }); + + expect(returnVal).to.be.false; + }); + + it('should return false for valid file size for multiple file', async () => { + const fileSizeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileSizeObj.execute([{ size: 5 }, { size: 9 }], { + maxFileSize: 10, + }); + + expect(returnVal).to.be.false; + }); + }); + + describe('invalid file size', () => { + it('should return true for invalid file size for single file', async () => { + const fileSizeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileSizeObj.execute([{ size: 5 }], { + maxFileSize: 3, + }); + + expect(returnVal).to.be.true; + }); + + it('should return false for invalid file size for multiple file', async () => { + const fileSizeObj = new IsAcceptedFile(); + // @ts-ignore ignore file typing + const returnVal = fileSizeObj.execute([{ size: 5 }, { size: 9 }], { + maxFileSize: 7, + }); + + expect(returnVal).to.be.true; + }); + }); +}); diff --git a/packages/ui/components/input-file/test/lion-input-file.test.js b/packages/ui/components/input-file/test/lion-input-file.test.js new file mode 100644 index 000000000..5d2e99f2c --- /dev/null +++ b/packages/ui/components/input-file/test/lion-input-file.test.js @@ -0,0 +1,1171 @@ +import '@lion/ui/define/lion-input-file.js'; +import { Required } from '@lion/ui/form-core.js'; +import { getInputMembers } from '@lion/ui/input-test-helpers.js'; +import { expect, fixture as _fixture, html, oneEvent } from '@open-wc/testing'; +import sinon from 'sinon'; + +/** + * @typedef {import('../src/LionInputFile.js').LionInputFile} LionInputFile + * @typedef {import('../types/input-file.js').InputFile} InputFile + * @typedef {import('../types/input-file.js').SystemFile} SystemFile + * @typedef {import('lit').TemplateResult} TemplateResult + */ +const fixture = /** @type {(arg: TemplateResult|string) => Promise} */ (_fixture); + +const filesListChanged = (/** @type {LionInputFile} */ el, /** @type { CustomEvent } */ ev) => { + // eslint-disable-next-line no-param-reassign + el._fileSelectResponse = [...ev.detail.newFiles]; +}; + +function mimicSelectFile( + /** @type {LionInputFile} */ formControl, + /** @type {InputFile[]} */ mockFiles, +) { + // @ts-expect-error [allow-protected-in-tests] + formControl._processFiles(mockFiles); + // TODO: doesn't this set latest file.name only to formControl._inputNode.value? + mockFiles.forEach(file => { + // @ts-expect-error [allow-protected-in-tests] + Object.defineProperty(formControl._inputNode, 'value', { + value: `C:\\fakepath\\${file.name}`, + writable: true, + }); + }); + // @ts-expect-error [allow-protected-in-tests] + Object.defineProperty(formControl._inputNode, 'files', { value: mockFiles, writable: true }); + // @ts-expect-error [allow-protected-in-tests] + formControl._inputNode.dispatchEvent(new Event('input', { bubbles: true })); +} + +const file = /** @type {InputFile} */ ( + new File(['foo'], 'foo.txt', { + type: 'text/plain', + }) +); +const file2 = /** @type {InputFile} */ ( + new File(['bar'], 'bar.txt', { + type: 'text/plain', + }) +); +const file3 = /** @type {InputFile} */ ( + new File(['foo3'], 'foo3.txt', { + type: 'text/plain', + }) +); +const file4 = /** @type {InputFile} */ ( + new File(['foo4'], 'foo4.txt', { + type: 'text/plain', + }) +); + +describe('lion-input-file', () => { + it('has a type of "file"', async () => { + const el = await fixture(html``); + // @ts-expect-error [allow-protected-in-tests] + const { _inputNode } = getInputMembers(el); + expect(_inputNode.type).to.equal('file'); + }); + + it('should add single file and dispatch "file-list-changed" event with newly added file', async () => { + const el = await fixture(html``); + + const fileListChangedEventPromise = oneEvent(el, 'file-list-changed'); + mimicSelectFile(el, [file]); + const fileListChangedEvent = await fileListChangedEventPromise; + // @ts-expect-error [allow-protected-in-tests] + expect(el._selectedFilesMetaData.length).to.equal(1); + expect(el._fileSelectResponse.length).to.equal(1); + + expect(fileListChangedEvent).to.exist; + expect(fileListChangedEvent.detail.newFiles.length).to.equal(1); + expect(fileListChangedEvent.detail.newFiles[0].name).to.equal('foo.txt'); + }); + + it('should select 1 file', async () => { + const el = await fixture(html``); + // @ts-expect-error [allow-protected-in-test] + const processedFilesSpy = sinon.spy(el, '_processFiles'); + + // @ts-expect-error [allow-protected-in-test] + await el._onChange({ target: { files: [file] } }); + expect(processedFilesSpy).have.been.calledOnce; + processedFilesSpy.restore(); + }); + + it('should retain selected file when "Cancel" button is clicked in system file explorer dialog', async () => { + const el = await fixture(html``); + + mimicSelectFile(el, [file]); + + await el.updateComplete; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + expect(el._fileSelectResponse.length).to.equal(1); + + // when cancel is clicked, native input value is blank which means modelValue is blank + el.modelValue = []; + + await el.updateComplete; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + }); + + it('has an attribute focused when focused', async () => { + const el = await fixture(html` `); + + // @ts-expect-error [allow-protected-in-test] + el._buttonNode.focus(); + await el.updateComplete; + expect(el.hasAttribute('focused')).to.be.true; + + // @ts-expect-error [allow-protected-in-test] + el._buttonNode.blur(); + await el.updateComplete; + expect(el.hasAttribute('focused')).to.be.false; + }); + + it('should set touched property true on change', async () => { + const el = await fixture(html` `); + expect(el.touched).to.be.false; + + // @ts-expect-error [allow-protected-in-test] + await el._onChange({ target: { files: [file] } }); + expect(el.touched).to.be.true; + }); + + it('should replace previous file when new file is selected', async () => { + const el = await fixture(html``); + + mimicSelectFile(el, [file]); + await el.updateComplete; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].systemFile.name).to.equal('foo.txt'); + + mimicSelectFile(el, [file2]); + await el.updateComplete; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].systemFile.name).to.equal('bar.txt'); + }); + + describe('invalid file types', async () => { + const fileWrongType = /** @type {InputFile} */ ( + new File(['foobar'], 'foobar.txt', { + type: 'xxxxx', + }) + ); + + it('should not be added to the selected list', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].failedProp?.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].validationFeedback).to.exist; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].status).to.equal('FAIL'); + expect(el._fileSelectResponse[0].status).to.equal('FAIL'); + }); + + it('error message should use main type when "/*" is used', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.deep.equal('Please select a(n) text file with max 500MB.'); + }); + }); + + it('error message should use main type when "text/plain" is used', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.deep.equal('Please select a(n) text file with max 500MB.'); + }); + }); + + it('error message should use sub type when e.g. "text/html" is used', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.deep.equal('Please select a(n) .html file with max 500MB.'); + }); + }); + + it('error message should use the first sub type when e.g. "image/svg+xml" is used', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.deep.equal('Please select a(n) .svg file with max 500MB.'); + }); + }); + + it('can reflect multiple types in the error message', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.deep.equal('Please select a .html or .csv file with max 500MB.'); + }); + }); + + it('can reflect multiple types in the error message also with a space " "', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.deep.equal('Please select a .html or .csv file with max 500MB.'); + }); + }); + + it('can reflect multiple types in the error message with preference to extensions', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.deep.equal('Please select a(n) .jpg file with max 500MB.'); + }); + }); + }); + + describe('invalid file extensions', async () => { + const fileWrongType = /** @type {InputFile} */ ( + new File(['foobar'], 'foobar.txt', { + type: 'xxxxx', + }) + ); + + it('should not be added to the selected list', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].failedProp?.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].validationFeedback).to.exist; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].status).to.equal('FAIL'); + expect(el._fileSelectResponse[0].status).to.equal('FAIL'); + }); + + it('error message should add the file extension to the validator message', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.equal('Please select a(n) .jpg file with max 500MB.'); + }); + }); + + it('error message should add all file extensions to the validator message', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.equal('Please select a .jpg, .png or .pdf file with max 500MB.'); + }); + }); + + it('error message should add all file extensions to the validator message also works without spaces " "', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.equal('Please select a .jpg, .png or .pdf file with max 500MB.'); + }); + }); + + it('error message should add all file extensions to the validator message also works without dots "."', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.equal('Please select a .jpg, .png or .pdf file with max 500MB.'); + }); + }); + }); + + describe('invalid file sizes', async () => { + // Size of this file is 4 bytes + const fileWrongSize = /** @type {InputFile} */ (new File(['foobar'], 'foobar.txt')); + + it('should not be added to the selected list', async () => { + const el = await fixture(html` `); + + mimicSelectFile(el, [fileWrongSize]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].failedProp?.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].validationFeedback).to.exist; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].status).to.equal('FAIL'); + expect(el._fileSelectResponse[0].status).to.equal('FAIL'); + }); + + it('error message should show only the max file size if no type/extension restrictions are defined', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongSize]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.equal('Please select a file with max 2 bytes.'); + }); + }); + + it('error message should show the correct max file size if type/extension restrictions are defined', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [fileWrongSize]); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + el._selectedFilesMetaData[0].validationFeedback?.forEach(error => { + expect(error.message).to.equal('Please select a .jpg, .png or .pdf file with max 2 bytes.'); + }); + }); + }); + + it('should send "file-list-changed" event if selecting files succeeded partially', async () => { + const el = await fixture(html``); + + // Size of this file is 4 bytes + const fileWrongSize = /** @type {InputFile} */ (new File(['foobar'], 'foobar.txt')); + + setTimeout(() => { + mimicSelectFile(el, [fileWrongSize, file2]); + }); + + const fileListChangedEvent = await oneEvent(el, 'file-list-changed'); + + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(2); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].status).to.equal('FAIL'); + expect(el._fileSelectResponse[0].status).to.equal('FAIL'); + + expect(fileListChangedEvent.detail.newFiles.length).to.equal(1); + }); + + it('should update downloadurl for successful files', async () => { + const el = await fixture(html``); + + setTimeout(() => { + mimicSelectFile(el, [file]); + }); + const fileListChangedEvent = await oneEvent(el, 'file-list-changed'); + filesListChanged(el, fileListChangedEvent); + + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].downloadUrl).to.exist; + }); + + describe('format', () => { + it('modelValue is an array of files', async () => { + const el = await fixture(html` `); + + mimicSelectFile(el, [file]); + await el.updateComplete; + expect(el.modelValue).to.deep.equal([file]); + }); + + it('view value is a string', async () => { + const el = await fixture(html` `); + + mimicSelectFile(el, [file]); + await el.updateComplete; + + expect(el.value).to.equal('C:\\fakepath\\foo.txt'); + // @ts-expect-error [allow-protected-in-tests] + expect(el._inputNode.value).to.equal('C:\\fakepath\\foo.txt'); + }); + + it('formattedValue is a string', async () => { + const el = await fixture(html` `); + + mimicSelectFile(el, [file]); + await el.updateComplete; + + expect(el.formattedValue).to.equal('C:\\fakepath\\foo.txt'); + }); + + it('serializedValue is an array of files', async () => { + const el = await fixture(html` `); + + mimicSelectFile(el, [file]); + await el.updateComplete; + expect(el.serializedValue).to.deep.equal([file]); + }); + + it('fires `model-value-changed` for every programmatic modelValue change', async () => { + const el = await fixture(html` `); + let counter = 0; + let isTriggeredByUser = false; + + el.addEventListener('model-value-changed', event => { + counter += 1; + isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser; + }); + + el.modelValue = [file]; + expect(counter).to.equal(1); + expect(isTriggeredByUser).to.be.false; + + // TODO: should no change mean no event? + // el.modelValue = [file]; + // expect(counter).to.equal(1); + + el.modelValue = [file, file2]; + expect(counter).to.equal(2); + }); + + it('fires `model-value-changed` for every user input, adding `isTriggeredByUser` in event detail', async () => { + const el = await fixture(html` `); + let counter = 0; + let isTriggeredByUser = false; + + el.addEventListener('model-value-changed', event => { + counter += 1; + isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser; + }); + + mimicSelectFile(el, [file]); + await el.updateComplete; + expect(counter).to.equal(1); + expect(isTriggeredByUser).to.be.true; + + mimicSelectFile(el, [file, file2]); + await el.updateComplete; + expect(counter).to.equal(2); + }); + }); + + describe('multiple file select', () => { + it('should add multiple files', async () => { + const el = await fixture(html` + + `); + + mimicSelectFile(el, [file, file2]); + + await el.updateComplete; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(2); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].systemFile.name).to.equal('foo.txt'); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[1].systemFile.name).to.equal('bar.txt'); + + expect(el._fileSelectResponse.length).to.equal(2); + expect(el._fileSelectResponse[0].name).to.equal('foo.txt'); + expect(el._fileSelectResponse[1].name).to.equal('bar.txt'); + }); + + it('should add new files and retain previous files', async () => { + const el = await fixture(html` `); + + setTimeout(() => { + mimicSelectFile(el, [file, file2]); + }); + const fileListChangedEvent = await oneEvent(el, 'file-list-changed'); + filesListChanged(el, fileListChangedEvent); + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(2); + + setTimeout(() => { + mimicSelectFile(el, [file3, file4]); + }); + const fileListChangedEvent1 = await oneEvent(el, 'file-list-changed'); + filesListChanged(el, fileListChangedEvent1); + await el.updateComplete; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(4); + }); + + it('should add multiple files and dispatch file-list-changed event ONLY with newly added file', async () => { + const el = await fixture(html` `); + + setTimeout(() => { + mimicSelectFile(el, [file, file2]); + }); + const fileListChangedEvent = await oneEvent(el, 'file-list-changed'); + filesListChanged(el, fileListChangedEvent); + + setTimeout(() => { + mimicSelectFile(el, [file3, file4]); + }); + + const fileListChangedEvent1 = await oneEvent(el, 'file-list-changed'); + filesListChanged(el, fileListChangedEvent1); + + expect(fileListChangedEvent1).to.exist; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(4); + expect(fileListChangedEvent1.detail.newFiles.length).to.equal(2); + expect(fileListChangedEvent1.detail.newFiles[0].name).to.equal('foo3.txt'); + expect(fileListChangedEvent1.detail.newFiles[1].name).to.equal('foo4.txt'); + }); + + it('should not allow duplicate files to be selected and show notification message', async () => { + const el = await fixture(html` `); + + setTimeout(() => { + mimicSelectFile(el, [file]); + }); + + // create condition to also show the feedback + el.prefilled = true; + const fileListChangedEvent = await oneEvent(el, 'file-list-changed'); + + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].systemFile.name).to.equal('foo.txt'); + + expect(fileListChangedEvent).to.exist; + expect(fileListChangedEvent.detail.newFiles.length).to.equal(1); + expect(fileListChangedEvent.detail.newFiles[0].name).to.equal('foo.txt'); + + const fileDuplicate = /** @type {InputFile} */ ( + new File(['foo'], 'foo.txt', { + type: 'text/plain', + }) + ); + + mimicSelectFile(el, [fileDuplicate]); + await el.updateComplete; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + expect(el.hasFeedbackFor).to.deep.equals(['info'], 'hasFeedbackFor'); + expect(el.showsFeedbackFor).to.deep.equals(['info'], 'showsFeedbackFor'); + }); + + it('should add valid files and skip invalid ones', async () => { + const fileWrongSize = /** @type {InputFile} */ (new File(['foobar'], 'foobar.txt')); + const fileWrongType = /** @type {InputFile} */ ( + new File(['foobar'], 'foobar.txt', { + type: 'xxxxx', + }) + ); + + const el = await fixture( + html` `, + ); + + setTimeout(() => { + mimicSelectFile(el, [file, fileWrongSize, file2, fileWrongType]); + }); + + const fileListChangedEvent = await oneEvent(el, 'file-list-changed'); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(4); + expect(fileListChangedEvent).to.exist; + expect(fileListChangedEvent.detail.newFiles.length).to.equal(2); + expect(fileListChangedEvent.detail.newFiles[0].name).to.equal('foo.txt'); + expect(fileListChangedEvent.detail.newFiles[1].name).to.equal('bar.txt'); + }); + }); + + describe('status and error', () => { + /** + * @type {LionInputFile} + */ + let el; + beforeEach(async () => { + el = await fixture(html``); + }); + + it('should set _fileSelectResponse data to _selectedFilesMetaData for rendering error and status', async () => { + mimicSelectFile(el, [file]); + + el._fileSelectResponse = [{ name: 'foo.txt', status: 'LOADING', errorMessage: '500' }]; + + await el.updateComplete; + + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].status).to.equal('LOADING'); + // @ts-ignore + expect(el._selectedFilesMetaData[0].validationFeedback[0].message).to.equal('500'); + }); + + it('should not fire file-list-changed event if invalid file is selected', async () => { + const filePdf = /** @type {InputFile} */ ( + new File(['foo'], 'foo.pdf', { + type: 'application/pdf', + }) + ); + // @ts-ignore + const fileListChangedSpy = sinon.spy(el, '_dispatchFileListChangeEvent'); + + mimicSelectFile(el, [filePdf]); + + expect(fileListChangedSpy).have.not.been.called; + fileListChangedSpy.restore(); + }); + + it('updates showFeedbackFor when a wrong file has been added/removed', async () => { + const fileWrongType = /** @type {InputFile} */ ( + new File(['foobar'], 'foobar.txt', { + type: 'xxxxx', + }) + ); + + // create condition to also show the feedback + el.prefilled = true; + + mimicSelectFile(el, [fileWrongType]); + await el.updateComplete; + expect(el.hasFeedbackFor).to.deep.equals(['error'], 'hasFeedbackFor'); + expect(el.showsFeedbackFor).to.deep.equals(['error'], 'showsFeedbackFor'); + + // on change the showsFeedbackFor should be empty again + mimicSelectFile(el, [file]); + await el.updateComplete; + expect(el.hasFeedbackFor).to.deep.equals([]); + expect(el.showsFeedbackFor).to.deep.equals([]); + }); + }); + + describe('validations when used with lion-form', () => { + it('should throw error with Required validator', async () => { + const el = await fixture(html` + + + `); + + el.touched = true; + el.dirty = true; + await el.updateComplete; + await el.feedbackComplete; + // @ts-ignore input type="file" is a specific input member + const { _feedbackNode } = getInputMembers(el); + expect(_feedbackNode.feedbackData?.[0].message).to.equal('FooBar'); + expect(el.hasFeedbackFor.includes('error')).to.be.true; + }); + + it('reset method should remove File from modelValue but keep _fileSelectResponse', async () => { + const _fileSelectResponse = [ + { + name: 'file1.txt', + status: 'SUCCESS', + errorMessage: '', + downloadUrl: '/downloadFile', + }, + ]; + const el = await fixture(html` + + `); + + // @ts-expect-error [allow-protected-in-test] + el._fileSelectResponse = _fileSelectResponse; + await el.updateComplete; + + mimicSelectFile(el, [file]); + await el.updateComplete; + + await el.reset(); + expect(el.modelValue).to.deep.equal([]); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData).to.deep.equal([]); + expect(el._fileSelectResponse).to.deep.equal(_fileSelectResponse); + }); + + it('clear method should remove File from modelValue and _fileSelectResponse', async () => { + const _fileSelectResponse = [ + { + name: 'file1.txt', + status: 'SUCCESS', + errorMessage: '', + downloadUrl: '/downloadFile', + }, + ]; + const el = await fixture(html` `); + + // @ts-expect-error [allow-protected-in-test] + el._fileSelectResponse = _fileSelectResponse; + + setTimeout(() => { + mimicSelectFile(el, [file]); + }); + await oneEvent(el, 'file-list-changed'); + await el.clear(); + expect(el.modelValue).to.deep.equal([]); + expect(el._fileSelectResponse).to.deep.equal([]); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData).to.deep.equal([]); + }); + }); + + describe('file select component with prefilled state', () => { + /** + * @type {LionInputFile} + */ + let el; + const _fileSelectResponse = [ + { + name: 'file1.txt', + status: 'SUCCESS', + errorMessage: '', + downloadUrl: '/downloadFile', + }, + { + name: 'file2.txt', + status: 'FAIL', + errorMessage: 'something went wrong', + }, + ]; + + beforeEach(async () => { + el = await fixture(html` + + `); + + // @ts-expect-error [allow-protected-in-test] + el._fileSelectResponse = _fileSelectResponse; + + await el.updateComplete; + }); + + it('should update the _selectedFilesMetaData according to _fileSelectResponse', () => { + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(2); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].systemFile.name).to.equal('file1.txt'); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].status).to.equal('SUCCESS'); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].downloadUrl).to.equal('/downloadFile'); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[1].validationFeedback[0].message).to.equal( + 'something went wrong', + ); + }); + + it('should remove file on click of cross button', async () => { + /** + * @type {Partial} + */ + const removedFile = { + name: 'file2.txt', + status: 'FAIL', + systemFile: { name: 'file2.txt' }, + response: { name: 'file2.txt', status: 'FAIL' }, + }; + // @ts-expect-error [allow-protected-in-test] + const removeFileSpy = sinon.spy(el, '_removeFile'); + + // assertion for displayed file list to be same + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(2); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[1].systemFile.name).to.equal('file2.txt'); + + el.dispatchEvent( + new CustomEvent('file-remove-requested', { + composed: true, + bubbles: true, + detail: { + removedFile, + status: removedFile.status, + _fileSelectResponse: removedFile.response, + }, + }), + ); + + await el.updateComplete; + expect(removeFileSpy).have.been.calledOnce; + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + removeFileSpy.restore(); + }); + + it('should fire file-removed event with _fileSelectResponse in the details', async () => { + /** + * @type {Partial} + */ + const removedFile = { + name: 'file2.txt', + status: 'FAIL', + systemFile: { name: 'file2.txt' }, + response: { name: 'file2.txt', status: 'FAIL' }, + }; + + setTimeout(() => { + // @ts-ignore ignore file typing + el._removeFile(removedFile); + }); + + await el.updateComplete; + + const removeFileEvent = await oneEvent(el, 'file-removed'); + + // assertion for event data + expect(removeFileEvent).to.exist; + expect(removeFileEvent.detail.removedFile).to.deep.equal({ + name: 'file2.txt', + status: 'FAIL', + systemFile: { + name: 'file2.txt', + }, + response: { + name: 'file2.txt', + status: 'FAIL', + }, + }); + expect(removeFileEvent.detail.status).to.deep.equal('FAIL'); + expect(removeFileEvent.detail._fileSelectResponse).to.deep.equal({ + name: 'file2.txt', + status: 'FAIL', + }); + }); + }); + + describe('drag and drop', () => { + /** + * @type {LionInputFile} + */ + let el; + beforeEach(async () => { + el = await fixture(html` + + `); + + await el.updateComplete; + }); + + it('should set "is-dragging" on dragenter', async () => { + const dropzone = el.shadowRoot?.querySelector('.input-file__drop-zone'); + dropzone?.dispatchEvent(new Event('dragenter', { bubbles: true })); + expect(el.hasAttribute('is-dragging')).to.equal(true); + }); + + it('should set "is-dragging" on dragover', async () => { + const dropzone = el.shadowRoot?.querySelector('.input-file__drop-zone'); + dropzone?.dispatchEvent(new Event('dragover', { bubbles: true })); + expect(el.hasAttribute('is-dragging')).to.equal(true); + }); + + it('should remove "is-dragging" on dragleave', async () => { + const dropzone = el.shadowRoot?.querySelector('.input-file__drop-zone'); + dropzone?.dispatchEvent(new Event('dragenter', { bubbles: true })); + await el.updateComplete; + + dropzone?.dispatchEvent(new Event('dragleave', { bubbles: true })); + expect(el.hasAttribute('is-dragging')).to.equal(false); + }); + + it('should remove "is-dragging" on drop', async () => { + const dropzone = el.shadowRoot?.querySelector('.input-file__drop-zone'); + dropzone?.dispatchEvent(new Event('dragenter', { bubbles: true })); + await el.updateComplete; + + window.dispatchEvent(new Event('drop', { bubbles: true })); + expect(el.hasAttribute('is-dragging')).to.equal(false); + }); + + it('should call _processFiles method', async () => { + const list = new DataTransfer(); + // @ts-ignore + list.items.add(file); + const droppedFiles = list.files; + // @ts-ignore + const _processFilesSpy = sinon.spy(el, '_processFiles'); + + // @ts-expect-error [allow-protected-in-test] + await el._processDroppedFiles({ + // @ts-ignore + dataTransfer: { files: droppedFiles, items: [{ name: 'test.txt' }] }, + preventDefault: () => {}, + }); + + expect(_processFilesSpy).have.been.calledOnce; + _processFilesSpy.restore(); + }); + }); + + describe('uploadOnSelect as true', () => { + it('should not remove file on click of cross button and fire file-removed event', async () => { + /** + * @type {Partial} + */ + const removeFile = { + name: 'foo.txt', + status: 'SUCCESS', + systemFile: { name: 'foo.txt' }, + response: { name: 'foo.txt', status: 'SUCCESS' }, + }; + + const el = await fixture(html` + + `); + + el.modelValue = [file]; + mimicSelectFile(el, [file]); + + await el.updateComplete; + + setTimeout(() => { + // @ts-ignore ignore file typing + el._removeFile(removeFile); + }); + + const removeFileEvent = await oneEvent(el, 'file-removed'); + + // assertion for displayed file list to be same + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].systemFile.name).to.equal('foo.txt'); + + // assertion for event data + expect(removeFileEvent).to.exist; + expect(removeFileEvent.detail.removedFile).to.deep.equal({ + name: 'foo.txt', + status: 'SUCCESS', + systemFile: { + name: 'foo.txt', + }, + response: { + name: 'foo.txt', + status: 'SUCCESS', + }, + }); + expect(removeFileEvent.detail.status).to.deep.equal('SUCCESS'); + expect(removeFileEvent.detail._fileSelectResponse).to.deep.equal({ + name: 'foo.txt', + status: 'SUCCESS', + }); + }); + + it('should remove file from displayed list if not available in _fileSelectResponse array', async () => { + const _fileSelectResponse = [ + { + name: 'file1.txt', + status: 'SUCCESS', + errorMessage: '', + downloadUrl: '/downloadFile', + }, + { + name: 'file2.txt', + status: 'FAIL', + errorMessage: 'something went wrong', + }, + ]; + + const el = await fixture(html` + + `); + + // @ts-expect-error [allow-protected-in-test] + el._fileSelectResponse = _fileSelectResponse; + + await el.updateComplete; + // assertion for displayed file list to be same + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(2); + el._fileSelectResponse = [ + { + name: 'file1.txt', + status: 'SUCCESS', + errorMessage: '', + downloadUrl: '/downloadFile', + }, + ]; + await el.updateComplete; + // assertion for displayed file list to be same + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData.length).to.equal(1); + // @ts-expect-error [allow-protected-in-test] + expect(el._selectedFilesMetaData[0].systemFile.name).to.equal('file1.txt'); + // @ts-expect-error [allow-protected-in-tests] + expect(el._inputNode.value).to.equal(''); + }); + }); + + describe('Accessibility', async () => { + it('is accessible', async () => { + const el = await fixture(html``); + await expect(el).to.be.accessible(); + }); + + it('is accessible when a file is selected', async () => { + const _fileSelectResponse = [ + { + name: 'file1.txt', + status: 'SUCCESS', + errorMessage: '', + downloadUrl: '/downloadFile', + }, + ]; + const el = await fixture(html` `); + + // @ts-expect-error [allow-protected-in-test] + el._fileSelectResponse = _fileSelectResponse; + + await expect(el).to.be.accessible(); + }); + + it('is accessible with an selected file and disabled', async () => { + const _fileSelectResponse = [ + { + name: 'file1.txt', + status: 'SUCCESS', + errorMessage: '', + downloadUrl: '/downloadFile', + }, + ]; + + const el = await fixture(html` `); + + // @ts-expect-error [allow-protected-in-test] + el._fileSelectResponse = _fileSelectResponse; + + await expect(el).to.be.accessible(); + }); + + describe('has correct aria-roles', async () => { + it('select-button has aria-labelledby set to itself and the label', async () => { + const el = await fixture(html` `); + // @ts-expect-error [allow-protected-in-test] + expect(el._buttonNode?.getAttribute('aria-labelledby')).to.contain( + // @ts-expect-error [allow-protected-in-test] + `select-button-${el._inputId}`, + ); + // @ts-expect-error [allow-protected-in-test] + expect(el._buttonNode?.getAttribute('aria-labelledby')).to.contain( + // @ts-expect-error [allow-protected-in-test] + `label-${el._inputId}`, + ); + }); + + it('select-button has aria-describedby set to the help-text, selected list and the feedback message', async () => { + const _fileSelectResponse = [ + { + name: 'file1.txt', + status: 'SUCCESS', + errorMessage: '', + downloadUrl: '/downloadFile', + }, + ]; + const el = await fixture(html` + + `); + + // @ts-expect-error [allow-protected-in-test] + el._fileSelectResponse = _fileSelectResponse; + + // @ts-expect-error [allow-protected-in-test] + expect(el._buttonNode?.getAttribute('aria-describedby')).to.contain( + // @ts-expect-error [allow-protected-in-test] + `help-text-${el._inputId}`, + ); + // @ts-expect-error [allow-protected-in-test] + expect(el._buttonNode?.getAttribute('aria-describedby')).to.contain( + // @ts-expect-error [allow-protected-in-test] + `feedback-${el._inputId}`, + ); + await el.updateComplete; + // @ts-expect-error [allow-protected-in-test] + expect(el._buttonNode?.getAttribute('aria-describedby')).to.contain( + // @ts-expect-error [allow-protected-in-test] + `selected-file-list-${el._inputId}`, + ); + }); + }); + }); +}); diff --git a/packages/ui/components/input-file/test/lion-selected-file-list.test.js b/packages/ui/components/input-file/test/lion-selected-file-list.test.js new file mode 100644 index 000000000..4a9bb2ecb --- /dev/null +++ b/packages/ui/components/input-file/test/lion-selected-file-list.test.js @@ -0,0 +1,207 @@ +import '@lion/ui/define/lion-selected-file-list.js'; +import { expect, fixture as _fixture, html, oneEvent } from '@open-wc/testing'; +import sinon from 'sinon'; + +/** + * @typedef {import('../src/LionSelectedFileList.js').LionSelectedFileList} LionSelectedFileList + * @typedef {import('../types/input-file.js').InputFile} InputFile + * @typedef {import('lit').TemplateResult} TemplateResult + */ +const fixture = /** @type {(arg: TemplateResult|string) => Promise} */ ( + _fixture +); + +describe('lion-selected-file-list', () => { + /** + * @type {Partial} + */ + const fileSuccess = { + name: 'foo.txt', + status: 'SUCCESS', + downloadUrl: '#foo', + systemFile: { + name: 'foo.txt', + type: 'text/plain', + size: 1000, + status: 'SUCCESS', + }, + response: { + name: 'foo', + type: 'text/plain', + size: 1000, + status: 'SUCCESS', + }, + }; + + /** + * @type {Partial} + */ + const fileLoading = { + status: 'LOADING', + systemFile: { + name: 'bar.txt', + type: 'text/plain', + size: 100, + status: 'LOADING', + }, + }; + + /** + * @type {Partial} + */ + const fileFail = { + systemFile: { + name: 'foobar.txt', + type: 'text/plain', + size: 1000, + status: 'FAIL', + }, + status: 'FAIL', + validationFeedback: [{ message: 'foobar', type: 'error' }], + }; + + it('when empty show nothing', async () => { + const el = await fixture(html``); + expect(el.children.length).to.equal(0); + }); + + it('can have 1 item', async () => { + const el = await fixture(html` + + `); + const fileItems = el.shadowRoot?.querySelectorAll('.selected__list__item'); + expect(fileItems?.length).to.equal(1); + }); + + it('shows a list when multiple', async () => { + const el = await fixture(html` + + `); + const fileItems = el.shadowRoot?.querySelectorAll('.selected__list__item'); + expect(fileItems?.length).to.equal(2); + const fileList = el.shadowRoot?.querySelector('.selected__list'); + expect(fileList?.tagName).to.equal('UL'); + }); + + it('displays an anchor when status="SUCCESS" and a downloadUrl is available', async () => { + const el = await fixture(html` + + `); + const fileItems = el.shadowRoot?.querySelectorAll('.selected__list__item'); + // @ts-ignore + expect(fileItems[0].querySelector('.selected__list__item__label__link')).to.exist; + // @ts-ignore + expect(fileItems[1].querySelector('.selected__list__item__label__link')).to.not.exist; + // @ts-ignore + expect(fileItems[2].querySelector('.selected__list__item__label__link')).to.not.exist; + }); + + it('displays a feedback message when status="FAIL"', async () => { + const el = await fixture(html` + + `); + const fileItems = el.shadowRoot?.querySelectorAll('.selected__list__item'); + const feedback = fileItems ? fileItems[0].querySelector('lion-validation-feedback') : undefined; + // @ts-ignore + expect(feedback).to.exist; + expect(feedback).shadowDom.to.equal('foobar'); + }); + + it('should call removeFile method on click of remove button', async () => { + const el = await fixture(html` + + `); + + /** + * @type {HTMLButtonElement | null | undefined} + */ + const removeButton = el.shadowRoot?.querySelector('.selected__list__item__remove-button'); + const removeFileSpy = sinon.spy(el, /** @type {keyof LionSelectedFileList} */ ('_removeFile')); + removeButton?.click(); + expect(removeFileSpy).have.been.calledOnce; + removeFileSpy.restore(); + }); + + it('should fire file-remove-requested event with removed file data', async () => { + const el = await fixture(html` + + `); + + /** + * @type {Partial} + */ + const removedFile = { + name: el.fileList[0].name, + status: el.fileList[0].status, + systemFile: { + name: el.fileList[0].name, + }, + response: { + name: el.fileList[0].name, + status: el.fileList[0].status, + }, + }; + + setTimeout(() => { + // @ts-ignore ignore file typing + el._removeFile(removedFile); + }); + + const removeFileEvent = await oneEvent(el, 'file-remove-requested'); + expect(removeFileEvent).to.exist; + expect(removeFileEvent.detail.removedFile).to.deep.equal({ + name: 'foo.txt', + status: 'SUCCESS', + systemFile: { + name: 'foo.txt', + }, + response: { + name: 'foo.txt', + status: 'SUCCESS', + }, + }); + expect(removeFileEvent.detail.status).to.deep.equal('SUCCESS'); + expect(removeFileEvent.detail._fileSelectResponse).to.deep.equal({ + name: 'foo.txt', + status: 'SUCCESS', + }); + }); + + describe('Accessibility', async () => { + it('is accessible', async () => { + const el = await fixture( + html``, + ); + await expect(el).to.be.accessible(); + }); + + it(`adds aria-live="polite" to the feedback slot on focus, aria-live="assertive" to the feedback slot on blur, + so error messages appearing on blur will be read before those of the next input`, async () => { + const el = await fixture(html` + + `); + const fileFeedback = el?.shadowRoot?.querySelectorAll('[id^="file-feedback"]')[0]; + const removeButton = /** @type {HTMLButtonElement} */ ( + el.shadowRoot?.querySelector('.selected__list__item__remove-button') + ); + // @ts-ignore + expect(fileFeedback.getAttribute('aria-live')).to.equal('assertive'); + + removeButton?.focus(); + // @ts-ignore + expect(fileFeedback.getAttribute('aria-live')).to.equal('polite'); + + removeButton?.blur(); + // @ts-ignore + expect(fileFeedback.getAttribute('aria-live')).to.equal('assertive'); + }); + }); +}); diff --git a/packages/ui/components/input-file/translations/bg-BG.js b/packages/ui/components/input-file/translations/bg-BG.js new file mode 100644 index 000000000..00f5e5f61 --- /dev/null +++ b/packages/ui/components/input-file/translations/bg-BG.js @@ -0,0 +1,5 @@ +import bg from './bg.js'; + +export default { + ...bg, +}; diff --git a/packages/ui/components/input-file/translations/bg.js b/packages/ui/components/input-file/translations/bg.js new file mode 100644 index 000000000..aeb71cb10 --- /dev/null +++ b/packages/ui/components/input-file/translations/bg.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Моля, качете файл с макс. размер {maxSize}.', + allowedFileValidatorSimple: 'Моля, качете файл от тип {allowedType} с макс. размер {maxSize}.', + allowedFileValidatorComplex: + 'Моля, качете файл от тип {allowedTypesArray} или {allowedTypesLastItem} с макс. размер {maxSize}.', + dragAndDropText: 'Плъзнете и пуснете Вашите файлове тук или', + fileNameDescriptionLabel: 'Име на файл: {fileName}', + removeButtonLabel: 'Отстраняване на файла {fileName}', + selectTextDuplicateFileName: 'Файл със същото име на файл вече е налице.', + selectTextMultipleFile: 'Избор на файлове', + selectTextSingleFile: 'Избор на файл', +}; diff --git a/packages/ui/components/input-file/translations/cs-CZ.js b/packages/ui/components/input-file/translations/cs-CZ.js new file mode 100644 index 000000000..2cac51aa8 --- /dev/null +++ b/packages/ui/components/input-file/translations/cs-CZ.js @@ -0,0 +1,5 @@ +import cs from './cs.js'; + +export default { + ...cs, +}; diff --git a/packages/ui/components/input-file/translations/cs.js b/packages/ui/components/input-file/translations/cs.js new file mode 100644 index 000000000..a3ddfbb95 --- /dev/null +++ b/packages/ui/components/input-file/translations/cs.js @@ -0,0 +1,13 @@ +export default { + allowedFileSize: 'Nahrajte soubor s max. velikostí {maxSize}.', + allowedFileValidatorSimple: + 'Nahrajte soubor typu {allowedTypesLastItem} s max. velikostí {maxSize}.', + allowedFileValidatorComplex: + 'Nahrajte soubor typu {allowedTypesArray} nebo {allowedTypesLastItem} s max. velikostí {maxSize}.', + dragAndDropText: 'Přetáhněte soubory sem nebo', + fileNameDescriptionLabel: 'Název souboru: {fileName}', + removeButtonLabel: 'Odebrat soubor {fileName}', + selectTextDuplicateFileName: 'Soubor se stejným názvem byl již přítomen.', + selectTextMultipleFile: 'Vybrat soubory', + selectTextSingleFile: 'Vybrat soubor', +}; diff --git a/packages/ui/components/input-file/translations/de-DE.js b/packages/ui/components/input-file/translations/de-DE.js new file mode 100644 index 000000000..8e3fb7c86 --- /dev/null +++ b/packages/ui/components/input-file/translations/de-DE.js @@ -0,0 +1,5 @@ +import de from './de.js'; + +export default { + ...de, +}; diff --git a/packages/ui/components/input-file/translations/de.js b/packages/ui/components/input-file/translations/de.js new file mode 100644 index 000000000..1ca7a7746 --- /dev/null +++ b/packages/ui/components/input-file/translations/de.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Laden Sie eine Datei mit max. {maxSize} hoch.', + allowedFileValidatorSimple: 'Laden Sie eine {allowedType}-Datei mit max. {maxSize} hoch.', + allowedFileValidatorComplex: + 'Laden Sie eine {allowedTypesArray} oder {allowedTypesLastItem}-Datei mit max. {maxSize} hoch.', + dragAndDropText: 'Ziehen Sie Ihre Dateien per Drag & Drop hierher oder', + fileNameDescriptionLabel: 'Dateiname: {fileName}', + removeButtonLabel: 'Datei {fileName} entfernen', + selectTextDuplicateFileName: 'Eine Datei mit demselben Dateinamen war bereits vorhanden.', + selectTextMultipleFile: 'Dateien auswählen', + selectTextSingleFile: 'Datei auswählen', +}; diff --git a/packages/ui/components/input-file/translations/en-AU.js b/packages/ui/components/input-file/translations/en-AU.js new file mode 100644 index 000000000..e164dab30 --- /dev/null +++ b/packages/ui/components/input-file/translations/en-AU.js @@ -0,0 +1,5 @@ +import en from './en.js'; + +export default { + ...en, +}; diff --git a/packages/ui/components/input-file/translations/en-GB.js b/packages/ui/components/input-file/translations/en-GB.js new file mode 100644 index 000000000..e164dab30 --- /dev/null +++ b/packages/ui/components/input-file/translations/en-GB.js @@ -0,0 +1,5 @@ +import en from './en.js'; + +export default { + ...en, +}; diff --git a/packages/ui/components/input-file/translations/en-PH.js b/packages/ui/components/input-file/translations/en-PH.js new file mode 100644 index 000000000..e164dab30 --- /dev/null +++ b/packages/ui/components/input-file/translations/en-PH.js @@ -0,0 +1,5 @@ +import en from './en.js'; + +export default { + ...en, +}; diff --git a/packages/ui/components/input-file/translations/en-US.js b/packages/ui/components/input-file/translations/en-US.js new file mode 100644 index 000000000..e164dab30 --- /dev/null +++ b/packages/ui/components/input-file/translations/en-US.js @@ -0,0 +1,5 @@ +import en from './en.js'; + +export default { + ...en, +}; diff --git a/packages/ui/components/input-file/translations/en.js b/packages/ui/components/input-file/translations/en.js new file mode 100644 index 000000000..7dfaf51dd --- /dev/null +++ b/packages/ui/components/input-file/translations/en.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Please select a file with max {maxSize}.', + allowedFileValidatorSimple: 'Please select a(n) {allowedType} file with max {maxSize}.', + allowedFileValidatorComplex: + 'Please select a {allowedTypesArray} or {allowedTypesLastItem} file with max {maxSize}.', + dragAndDropText: 'Drag & Drop your files here or', // TODO: or what? Why is Drag & Drop capitalized? + fileNameDescriptionLabel: 'File name: {fileName}', + removeButtonLabel: 'Remove {fileName} file', + selectTextDuplicateFileName: 'A file with same filename was already present.', + selectTextMultipleFile: 'Select files', + selectTextSingleFile: 'Select file', +}; diff --git a/packages/ui/components/input-file/translations/es-ES.js b/packages/ui/components/input-file/translations/es-ES.js new file mode 100644 index 000000000..94a009944 --- /dev/null +++ b/packages/ui/components/input-file/translations/es-ES.js @@ -0,0 +1,5 @@ +import es from './es.js'; + +export default { + ...es, +}; diff --git a/packages/ui/components/input-file/translations/es.js b/packages/ui/components/input-file/translations/es.js new file mode 100644 index 000000000..5ea291dd8 --- /dev/null +++ b/packages/ui/components/input-file/translations/es.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Cargue un archivo de {maxSize} como máximo.', + allowedFileValidatorSimple: 'Cargue un archivo {allowedType} de {maxSize} como máximo.', + allowedFileValidatorComplex: + 'Cargue un archivo {allowedTypesArray} o {allowedTypesLastItem} de {maxSize} como máximo.', + dragAndDropText: 'Arrastre y suelte los archivos aquí o', + fileNameDescriptionLabel: 'Nombre de archivo: {fileName}', + removeButtonLabel: 'Elimine el archivo: {fileName}', + selectTextDuplicateFileName: 'Ya había un archivo con el mismo nombre de archivo.', + selectTextMultipleFile: 'Seleccionar archivos', + selectTextSingleFile: 'Seleccionar archivo', +}; diff --git a/packages/ui/components/input-file/translations/fr-BE.js b/packages/ui/components/input-file/translations/fr-BE.js new file mode 100644 index 000000000..da02615de --- /dev/null +++ b/packages/ui/components/input-file/translations/fr-BE.js @@ -0,0 +1,5 @@ +import fr from './fr.js'; + +export default { + ...fr, +}; diff --git a/packages/ui/components/input-file/translations/fr-FR.js b/packages/ui/components/input-file/translations/fr-FR.js new file mode 100644 index 000000000..da02615de --- /dev/null +++ b/packages/ui/components/input-file/translations/fr-FR.js @@ -0,0 +1,5 @@ +import fr from './fr.js'; + +export default { + ...fr, +}; diff --git a/packages/ui/components/input-file/translations/fr.js b/packages/ui/components/input-file/translations/fr.js new file mode 100644 index 000000000..d3af1c035 --- /dev/null +++ b/packages/ui/components/input-file/translations/fr.js @@ -0,0 +1,13 @@ +export default { + allowedFileSize: 'Veuillez télécharger un fichier avec une taille maximale de {maxSize}.', + allowedFileValidatorSimple: + 'Veuillez télécharger un fichier {allowedType} avec une taille maximale de {maxSize}.', + allowedFileValidatorComplex: + 'Veuillez télécharger un fichier {allowedTypesArray} ou {allowedTypesLastItem} avec une taille maximale de {maxSize}.', + dragAndDropText: 'Glissez et déposez vos fichiers ici ou', + fileNameDescriptionLabel: 'Nom de fichier: {fileName}', + removeButtonLabel: 'Supprimer le fichier {fileName}', + selectTextDuplicateFileName: 'Un fichier portant le même nom de fichier était déjà présent.', + selectTextMultipleFile: 'Sélectionnez des fichiers', + selectTextSingleFile: 'Sélectionnez un fichier', +}; diff --git a/packages/ui/components/input-file/translations/hu-HU.js b/packages/ui/components/input-file/translations/hu-HU.js new file mode 100644 index 000000000..130ba8f66 --- /dev/null +++ b/packages/ui/components/input-file/translations/hu-HU.js @@ -0,0 +1,5 @@ +import hu from './hu.js'; + +export default { + ...hu, +}; diff --git a/packages/ui/components/input-file/translations/hu.js b/packages/ui/components/input-file/translations/hu.js new file mode 100644 index 000000000..e7c26518e --- /dev/null +++ b/packages/ui/components/input-file/translations/hu.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Töltsön fel egy legfeljebb {maxSize} méretű fájlt.', + allowedFileValidatorSimple: 'Töltsön fel egy legfeljebb {maxSize} méretű {allowedType} fájlt.', + allowedFileValidatorComplex: + 'Töltsön fel egy legfeljebb {maxSize} méretű {allowedTypesArray} vagy {allowedTypesLastItem} fájlt.', + dragAndDropText: 'Húzza át a fájlokat ide vagy', + fileNameDescriptionLabel: 'Fájlnév: {fileName}', + removeButtonLabel: 'A(z) {fileName} fájl eltávolítása', + selectTextDuplicateFileName: 'Már volt ilyen nevű fájl.', + selectTextMultipleFile: 'Fájl(ok) kiválasztása', + selectTextSingleFile: 'Fájl kiválasztása', +}; diff --git a/packages/ui/components/input-file/translations/it-IT.js b/packages/ui/components/input-file/translations/it-IT.js new file mode 100644 index 000000000..397b5a03b --- /dev/null +++ b/packages/ui/components/input-file/translations/it-IT.js @@ -0,0 +1,5 @@ +import it from './it.js'; + +export default { + ...it, +}; diff --git a/packages/ui/components/input-file/translations/it.js b/packages/ui/components/input-file/translations/it.js new file mode 100644 index 000000000..032137789 --- /dev/null +++ b/packages/ui/components/input-file/translations/it.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Caricare un file di {maxSize} max.', + allowedFileValidatorSimple: 'Caricare un file {allowedType} di {maxSize} max.', + allowedFileValidatorComplex: + 'Caricare un file {allowedTypesArray} o {allowedTypesLastItem} di {maxSize} max.', + dragAndDropText: 'Trascinare i file qui o', + fileNameDescriptionLabel: 'Nome file: {fileName}', + removeButtonLabel: 'Rimuovere il file {fileName}', + selectTextDuplicateFileName: 'Un file con lo stesso nome file era già presente.', + selectTextMultipleFile: 'Seleziona file', + selectTextSingleFile: 'Seleziona file', +}; diff --git a/packages/ui/components/input-file/translations/nl-BE.js b/packages/ui/components/input-file/translations/nl-BE.js new file mode 100644 index 000000000..93467cea6 --- /dev/null +++ b/packages/ui/components/input-file/translations/nl-BE.js @@ -0,0 +1,5 @@ +import nl from './nl.js'; + +export default { + ...nl, +}; diff --git a/packages/ui/components/input-file/translations/nl-NL.js b/packages/ui/components/input-file/translations/nl-NL.js new file mode 100644 index 000000000..93467cea6 --- /dev/null +++ b/packages/ui/components/input-file/translations/nl-NL.js @@ -0,0 +1,5 @@ +import nl from './nl.js'; + +export default { + ...nl, +}; diff --git a/packages/ui/components/input-file/translations/nl.js b/packages/ui/components/input-file/translations/nl.js new file mode 100644 index 000000000..b035173fc --- /dev/null +++ b/packages/ui/components/input-file/translations/nl.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Upload een bestand van maximaal {maxSize}.', + allowedFileValidatorSimple: 'Upload een {allowedType}-bestand van maximaal {maxSize}.', + allowedFileValidatorComplex: + 'Upload een {allowedTypesArray} of {allowedTypesLastItem}-bestand van maximaal {maxSize}.', + dragAndDropText: 'Sleep uw bestanden hierheen of', + fileNameDescriptionLabel: 'Bestandsnaam: {fileName}', + removeButtonLabel: 'Verwijder het bestand {fileName}', + selectTextDuplicateFileName: 'Er bestaat al een bestand met dezelfde bestandsnaam.', + selectTextMultipleFile: 'Selecteer bestanden', + selectTextSingleFile: 'Selecteer bestand', +}; diff --git a/packages/ui/components/input-file/translations/pl-PL.js b/packages/ui/components/input-file/translations/pl-PL.js new file mode 100644 index 000000000..cb0d0b8b6 --- /dev/null +++ b/packages/ui/components/input-file/translations/pl-PL.js @@ -0,0 +1,5 @@ +import pl from './pl.js'; + +export default { + ...pl, +}; diff --git a/packages/ui/components/input-file/translations/pl.js b/packages/ui/components/input-file/translations/pl.js new file mode 100644 index 000000000..0e7c87cce --- /dev/null +++ b/packages/ui/components/input-file/translations/pl.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Prześlij plik o maks. rozmiarze {maxSize}.', + allowedFileValidatorSimple: 'Prześlij plik {allowedType} o maks. rozmiarze {maxSize}.', + allowedFileValidatorComplex: + 'Prześlij plik {allowedTypesArray} lub {allowedTypesLastItem} o maks. rozmiarze {maxSize}.', + dragAndDropText: 'Przeciągnij i upuść pliki tutaj lub', + fileNameDescriptionLabel: 'Nazwa pliku: {fileName}', + removeButtonLabel: 'Usuń plik {fileName}', + selectTextDuplicateFileName: 'Plik o tej samej nazwie już istnieje.', + selectTextMultipleFile: 'Wybierz pliki', + selectTextSingleFile: 'Wybierz plik', +}; diff --git a/packages/ui/components/input-file/translations/ro-RO.js b/packages/ui/components/input-file/translations/ro-RO.js new file mode 100644 index 000000000..8acc92b29 --- /dev/null +++ b/packages/ui/components/input-file/translations/ro-RO.js @@ -0,0 +1,5 @@ +import ro from './ro.js'; + +export default { + ...ro, +}; diff --git a/packages/ui/components/input-file/translations/ro.js b/packages/ui/components/input-file/translations/ro.js new file mode 100644 index 000000000..f04bc1586 --- /dev/null +++ b/packages/ui/components/input-file/translations/ro.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Încărcaţi un fişier de max. {maxSize}.', + allowedFileValidatorSimple: 'Încărcaţi un fişier {allowedType} de max. {maxSize}.', + allowedFileValidatorComplex: + 'Încărcaţi un fişier {allowedTypesArray} sau {allowedTypesLastItem} de max. {maxSize}.', + dragAndDropText: 'Glisaţi şi fixaţi fişierele aici sau', + fileNameDescriptionLabel: 'Nume fişier: {fileName}', + removeButtonLabel: 'Eliminaţi fişierul {filename}', + selectTextDuplicateFileName: 'Există deja un fişier cu acelaşi nume de fişier.', + selectTextMultipleFile: 'Selectare fișiere', + selectTextSingleFile: 'Selectare fișier', +}; diff --git a/packages/ui/components/input-file/translations/ru-RU.js b/packages/ui/components/input-file/translations/ru-RU.js new file mode 100644 index 000000000..e5f8f2aa1 --- /dev/null +++ b/packages/ui/components/input-file/translations/ru-RU.js @@ -0,0 +1,5 @@ +import ru from './ru.js'; + +export default { + ...ru, +}; diff --git a/packages/ui/components/input-file/translations/ru.js b/packages/ui/components/input-file/translations/ru.js new file mode 100644 index 000000000..62e4a8521 --- /dev/null +++ b/packages/ui/components/input-file/translations/ru.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Загрузите файл размером не более {maxSize}.', + allowedFileValidatorSimple: 'Загрузите файл {allowedType} размером не более {maxSize}.', + allowedFileValidatorComplex: + 'Загрузите файл {allowedTypesArray} или {allowedTypesLastItem} размером не более {maxSize}.', + dragAndDropText: 'Перетащите файлы сюда или', + fileNameDescriptionLabel: 'Название файла: {fileName}', + removeButtonLabel: 'Удалить файл {fileName}', + selectTextDuplicateFileName: 'Файл с таким названием уже существует.', + selectTextMultipleFile: 'Выберите файлы', + selectTextSingleFile: 'Выберите файл', +}; diff --git a/packages/ui/components/input-file/translations/sk-SK.js b/packages/ui/components/input-file/translations/sk-SK.js new file mode 100644 index 000000000..3000b323f --- /dev/null +++ b/packages/ui/components/input-file/translations/sk-SK.js @@ -0,0 +1,5 @@ +import sk from './sk.js'; + +export default { + ...sk, +}; diff --git a/packages/ui/components/input-file/translations/sk.js b/packages/ui/components/input-file/translations/sk.js new file mode 100644 index 000000000..ad9903c50 --- /dev/null +++ b/packages/ui/components/input-file/translations/sk.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Nahrajte súbor s maximálnou veľkosťou {maxSize}.', + allowedFileValidatorSimple: 'Nahrajte súbor {allowedType} s maximálnou veľkosťou {maxSize}.', + allowedFileValidatorComplex: + 'Nahrajte súbor {allowedTypesArray} alebo {allowedTypesLastItem} s maximálnou veľkosťou {maxSize}.', + dragAndDropText: 'Súbory presuňte sem alebo', + fileNameDescriptionLabel: 'Názov súboru: {fileName}', + removeButtonLabel: 'Odstrániť súbor {fileName}', + selectTextDuplicateFileName: 'Súbor s rovnakým názvom súboru už existoval.', + selectTextMultipleFile: 'Vybrať súbory', + selectTextSingleFile: 'Vybrať súbor', +}; diff --git a/packages/ui/components/input-file/translations/uk-UA.js b/packages/ui/components/input-file/translations/uk-UA.js new file mode 100644 index 000000000..e255cc021 --- /dev/null +++ b/packages/ui/components/input-file/translations/uk-UA.js @@ -0,0 +1,5 @@ +import uk from './uk.js'; + +export default { + ...uk, +}; diff --git a/packages/ui/components/input-file/translations/uk.js b/packages/ui/components/input-file/translations/uk.js new file mode 100644 index 000000000..45379d787 --- /dev/null +++ b/packages/ui/components/input-file/translations/uk.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: 'Завантажте файл розміром до {maxSize}.', + allowedFileValidatorSimple: 'Завантажте файл {allowedType} розміром до {maxSize}.', + allowedFileValidatorComplex: + 'Завантажте файл {allowedTypesArray} або {allowedTypesLastItem} розміром до {maxSize}.', + dragAndDropText: 'Перетягніть файли сюди або', + fileNameDescriptionLabel: 'Ім’я файлу: {fileName}', + removeButtonLabel: 'Видалення файлу {fileName}', + selectTextDuplicateFileName: 'Файл із такою назвою вже існував.', + selectTextMultipleFile: 'Виберіть файли', + selectTextSingleFile: 'Виберіть файл', +}; diff --git a/packages/ui/components/input-file/translations/zh-CN.js b/packages/ui/components/input-file/translations/zh-CN.js new file mode 100644 index 000000000..6702b7f7f --- /dev/null +++ b/packages/ui/components/input-file/translations/zh-CN.js @@ -0,0 +1,5 @@ +import zh from './zh.js'; + +export default { + ...zh, +}; diff --git a/packages/ui/components/input-file/translations/zh.js b/packages/ui/components/input-file/translations/zh.js new file mode 100644 index 000000000..ee2ad6cd8 --- /dev/null +++ b/packages/ui/components/input-file/translations/zh.js @@ -0,0 +1,12 @@ +export default { + allowedFileSize: '请上传最大 {maxSize} 的文件。', + allowedFileValidatorSimple: '请上传最大 {maxSize} 的 {allowedType} 文件。', + allowedFileValidatorComplex: + '请上传最大 {maxSize} 的 {allowedTypesArray} 或 {allowedTypesLastItem} 文件。', + dragAndDropText: '将您的文件拖放到此处,或', + fileNameDescriptionLabel: '文件名: {fileName}', + removeButtonLabel: '删除 {fileName} 文件', + selectTextDuplicateFileName: '已存在具有相同文件名的文件。', + selectTextMultipleFile: '选择多个文件', + selectTextSingleFile: '选择文件', +}; diff --git a/packages/ui/components/input-file/types/input-file.d.ts b/packages/ui/components/input-file/types/input-file.d.ts new file mode 100644 index 000000000..8e5e18383 --- /dev/null +++ b/packages/ui/components/input-file/types/input-file.d.ts @@ -0,0 +1,32 @@ +import { FeedbackMessage } from "../../form-core/types/validate/ValidateMixinTypes.js"; + +type FileBasics = { + name: string; + /** size in bytes */ + size: number; + type: string; + status?: 'SUCCESS' | 'FAIL' | 'LOADING'; +}; + +export type InputFile = { + downloadUrl?: string; + errorMessage?: FeedbackMessage.message; + failedProp?: Array; + response?: FileSelectResponse; + systemFile: Partial; + validationFeedback?: Array; +} & FileBasics & Partial; + +export type SystemFile = { + downloadUrl?: string; + errorMessage?: FeedbackMessage.message; + failedProp?: Array; + response?: FileSelectResponse; +} & FileBasics & Partial; + + +export type FileSelectResponse = { + downloadUrl?: string; + errorMessage?: FeedbackMessage.message; + id?: string; +} & Partial; diff --git a/packages/ui/exports/define/lion-input-file.js b/packages/ui/exports/define/lion-input-file.js new file mode 100644 index 000000000..eb4c0884f --- /dev/null +++ b/packages/ui/exports/define/lion-input-file.js @@ -0,0 +1,3 @@ +import { LionInputFile } from '../input-file.js'; + +customElements.define('lion-input-file', LionInputFile); diff --git a/packages/ui/exports/define/lion-selected-file-list.js b/packages/ui/exports/define/lion-selected-file-list.js new file mode 100644 index 000000000..bc612af5b --- /dev/null +++ b/packages/ui/exports/define/lion-selected-file-list.js @@ -0,0 +1,3 @@ +import { LionSelectedFileList } from '../input-file.js'; + +customElements.define('lion-selected-file-list', LionSelectedFileList); diff --git a/packages/ui/exports/input-file.js b/packages/ui/exports/input-file.js new file mode 100644 index 000000000..98fa5136d --- /dev/null +++ b/packages/ui/exports/input-file.js @@ -0,0 +1,3 @@ +export { LionInputFile } from '../components/input-file/src/LionInputFile.js'; +export { LionSelectedFileList } from '../components/input-file/src/LionSelectedFileList.js'; +export { IsAcceptedFile, DuplicateFileNames } from '../components/input-file/src/validators.js'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 407b5950c..2a0427a9b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,6 +21,7 @@ "./pagination-translations/*": "./components/pagination/translations/*", "./progress-indicator-translations/*": "./components/progress-indicator/translations/*", "./input-datepicker-translations/*": "./components/input-datepicker/translations/*", + "./input-file-translations/*": "./components/input-file/translations/*", "./input-iban-translations/*": "./components/input-iban/translations/*", "./input-tel-translations/*": "./components/input-tel/translations/*", "./overlays-translations/*": "./components/overlays/translations/*",