diff --git a/doc-app/app/controllers/docs/ember-input-validation/prefabs/file-list.ts b/doc-app/app/controllers/docs/ember-input-validation/prefabs/file-list.ts new file mode 100644 index 00000000..f517cec2 --- /dev/null +++ b/doc-app/app/controllers/docs/ember-input-validation/prefabs/file-list.ts @@ -0,0 +1,22 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { ImmerChangeset } from 'ember-immer-changeset'; +import type { Owner } from '@ember/test-helpers/build-owner'; + +export default class DocsEmberInputValidationPrefabsFileListController extends Controller { + changeset = new ImmerChangeset({ + files: [new File([], 'file.txt')], + disabled: [new File([], 'file.txt')], + error: '', + }); + + constructor(owner: Owner) { + super(owner); + this.changeset.addError({ + message: 'This is an error message', + value: '', + originalValue: '', + key: 'error', + }); + } +} diff --git a/doc-app/app/controllers/docs/ember-input-validation/prefabs/file.ts b/doc-app/app/controllers/docs/ember-input-validation/prefabs/file.ts index b12e0dcd..94d1d1ec 100644 --- a/doc-app/app/controllers/docs/ember-input-validation/prefabs/file.ts +++ b/doc-app/app/controllers/docs/ember-input-validation/prefabs/file.ts @@ -20,5 +20,4 @@ export default class DocsEmberInputValidationPrefabsFileController extends Contr key: 'error', }); } - } diff --git a/doc-app/app/router.ts b/doc-app/app/router.ts index 8e192a01..7afa0b95 100644 --- a/doc-app/app/router.ts +++ b/doc-app/app/router.ts @@ -58,6 +58,7 @@ Router.map(function (this: RouterDSL) { this.route('datepicker'); this.route('datepicker-range'); this.route('file'); + this.route('file-list'); }); this.route('installation'); this.route('checkbox'); diff --git a/doc-app/app/templates/docs.hbs b/doc-app/app/templates/docs.hbs index 59e54d78..42e0e0c5 100644 --- a/doc-app/app/templates/docs.hbs +++ b/doc-app/app/templates/docs.hbs @@ -15,7 +15,7 @@ {{! prefab section }} {{! validation section }} - {{!-- + {{! - --}} + }} {{! form section }} @@ -59,6 +59,10 @@ @route="docs.ember-input-validation.prefabs.email" /> + + + + + + + + + +## Mandatory properties + +- `@validationField`: The field name in the changeset for validation. +- `@changeset`: The changeset object for form validation. + +## Optional properties + +- `@label`: The label for the input field. +- `@disabled`: Whether the input field is disabled. +- `@mandatory`: Whether the input file multiple field is mandatory. +- `@onChange`: The action to be called when the selection changes. +- `@disableDownload`: Whether the download button is disabled. +- `@placeholder`: The placeholder for the dropzone area. diff --git a/doc-app/app/templates/docs/ember-input-validation/prefabs/file.md b/doc-app/app/templates/docs/ember-input-validation/prefabs/file.md index 6b1fa271..704ae577 100644 --- a/doc-app/app/templates/docs/ember-input-validation/prefabs/file.md +++ b/doc-app/app/templates/docs/ember-input-validation/prefabs/file.md @@ -38,4 +38,4 @@ This is an input with type File - `@disabled`: Whether the input field is disabled. - `@mandatory`: Whether the textarea field is mandatory. - `@onChange`: The action to be called when the selection changes. -- `@changeEvent`: The event to trigger the onChange action. \ No newline at end of file +- `@changeEvent`: The event to trigger the onChange action. diff --git a/doc-app/tests/integration/components/ember-input-validation/prefabs/tpk-validation-file-list-test.gts b/doc-app/tests/integration/components/ember-input-validation/prefabs/tpk-validation-file-list-test.gts new file mode 100644 index 00000000..2290c2c1 --- /dev/null +++ b/doc-app/tests/integration/components/ember-input-validation/prefabs/tpk-validation-file-list-test.gts @@ -0,0 +1,141 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, triggerEvent } from '@ember/test-helpers'; +import { ImmerChangeset } from 'ember-immer-changeset'; +import { setupIntl } from 'ember-intl/test-support'; +import TpkValidationFileList from '@triptyk/ember-input-validation/components/prefabs/tpk-validation-file-list'; +import { click } from '@ember/test-helpers'; +import { assertTpkCssClassesExist } from '../generic-test-functions/assert-tpk-css-classes-exist'; +import { a11yAudit } from 'ember-a11y-testing/test-support'; +import { settled } from '@ember/test-helpers'; + + +module( + 'Integration | Component | Prefabs | tpk-validation-file-list', + function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'fr-fr'); + + function setupChangeset({ + files = [], + }: { + files: File[] + }) { + return new ImmerChangeset<{ + files: File[]; + }>({ + files, + }); + } + + async function renderComponent(params: { changeset: ImmerChangeset; disabled?: boolean; disableDownload?: boolean; }) { + await render( + , + ); + } + + + test('Should show download and delete buttons by default when there are files', async function (assert) { + const changeset = setupChangeset({ + files: [new File(['Ember Rules!'], 'file.txt')] + }); + await renderComponent({changeset}); + assert.dom('.tpk-file-list-list-item-action-download').exists(); + assert.dom('.tpk-file-list-list-item-action-delete').exists(); + }); + + test('Should hide download and delete buttons when disableDownload is true and disabled', async function (assert) { + const changeset = setupChangeset({ + files: [new File(['Ember Rules!'], 'file.txt')] + }); + await renderComponent({changeset, disabled: true, disableDownload: true}); + assert.dom('.tpk-file-list-list-item-action-download').doesNotExist(); + assert.dom('.tpk-file-list-list-item-action-delete').doesNotExist(); + }); + + test('Drag and drop files should add them to the changeset and show them in the list', async function (assert) { + const changeset = setupChangeset({ + files: [] + }); + await renderComponent({ changeset }); + await triggerEvent('.tpk-file-list-placeholder-container', 'drop', { + dataTransfer: { + files: [new File(['Ember Rules!'], 'file.txt'), new File(['Ember Rules!'], 'loempia.txt')], + } + }); + assert.dom('.tpk-file-list-list-item').exists({ count: 2 }); + assert.strictEqual(changeset.get('files').length, 2); + }); + + test('Drop a file with a default file in changeset should add the file to the changeset and not remove the default file', async function (assert) { + const changeset = setupChangeset({ + files: [new File(['Ember Rules!'], 'file.txt')] + }); + await renderComponent({ changeset }); + await triggerEvent('.tpk-file-list-placeholder-container', 'drop', { + dataTransfer: { + files: [new File(['Ember Rules!'], 'file.txt')], + } + }); + assert.dom('.tpk-file-list-list-item').exists({ count: 2 }); + assert.strictEqual(changeset.get('files').length, 2); + }); + + test('Delete button should remove the file from the changeset', async function (assert) { + const changeset = setupChangeset({ + files: [new File(['Ember Rules!'], 'file.txt')] + }); + await renderComponent({ changeset }); + await click('.tpk-file-list-list-item:first-child .tpk-file-list-list-item-action-delete'); + assert.dom('.tpk-file-list-list-item').doesNotExist(); + assert.strictEqual(changeset.get('files').length, 0); + }); + + test('It changes data-has-error attribue on error', async function (assert) { + const changeset = setupChangeset({ + files: [] + }); + await renderComponent({changeset}); + + changeset.addError({ + message: 'required', + value: '', + originalValue: '', + key: 'files', + }); + + await settled(); + assert.dom(`[data-test-tpk-file-list-input]`).hasNoText(); + + assert + .dom(`[data-test-tpk-prefab-file-list-container]`) + .hasAttribute('data-has-error', 'true'); + }); + + test('CSS classes exist and have been attached to the correct element', async function (assert) { + const changeset = setupChangeset({ + files: [] + }); + await renderComponent({changeset}); + await assertTpkCssClassesExist(assert,'file-list'); + }); + + test('Accessibility', async function (assert) { + assert.expect(0); + const changeset = setupChangeset({ + files: [] + }); + await renderComponent({changeset}); + await a11yAudit(); + }); + }, +); diff --git a/packages/ember-input-validation/package.json b/packages/ember-input-validation/package.json index e05061f9..2e6f6d40 100644 --- a/packages/ember-input-validation/package.json +++ b/packages/ember-input-validation/package.json @@ -119,7 +119,11 @@ "./static/DE.svg": "/DE.svg", "./static/LU.svg": "/LU.svg", "./static/NL.svg": "/NL.svg", - "./static/FR.svg": "/FR.svg" + "./static/FR.svg": "/FR.svg", + "./static/upload.svg": "/upload.svg", + "./static/download.svg": "/download.svg", + "./static/delete.svg": "/delete.svg", + "./static/document.svg": "/document.svg" }, "app-js": { "./components/base.js": "./dist/_app_/components/base.js", @@ -131,6 +135,7 @@ "./components/prefabs/tpk-validation-datepicker.js": "./dist/_app_/components/prefabs/tpk-validation-datepicker.js", "./components/prefabs/tpk-validation-email.js": "./dist/_app_/components/prefabs/tpk-validation-email.js", "./components/prefabs/tpk-validation-errors.js": "./dist/_app_/components/prefabs/tpk-validation-errors.js", + "./components/prefabs/tpk-validation-file-list.js": "./dist/_app_/components/prefabs/tpk-validation-file-list.js", "./components/prefabs/tpk-validation-file.js": "./dist/_app_/components/prefabs/tpk-validation-file.js", "./components/prefabs/tpk-validation-iban.js": "./dist/_app_/components/prefabs/tpk-validation-iban.js", "./components/prefabs/tpk-validation-input.js": "./dist/_app_/components/prefabs/tpk-validation-input.js", diff --git a/packages/ember-input-validation/src/app.css b/packages/ember-input-validation/src/app.css index 5eca2200..cc61a012 100644 --- a/packages/ember-input-validation/src/app.css +++ b/packages/ember-input-validation/src/app.css @@ -8,6 +8,7 @@ @import url('./components/prefabs/styles/tpk-datepicker-range.css'); @import url('./components/prefabs/styles/tpk-email.css'); @import url('./components/prefabs/styles/tpk-file.css'); +@import url('./components/prefabs/styles/tpk-file-list.css'); @import url('./components/prefabs/styles/tpk-timepicker.css'); @import url('./components/prefabs/styles/tpk-iban.css'); @import url('./components/prefabs/styles/tpk-mobile.css'); diff --git a/packages/ember-input-validation/src/components/prefabs/styles/tpk-file-list.css b/packages/ember-input-validation/src/components/prefabs/styles/tpk-file-list.css new file mode 100644 index 00000000..cc56cabd --- /dev/null +++ b/packages/ember-input-validation/src/components/prefabs/styles/tpk-file-list.css @@ -0,0 +1,86 @@ +.tpk-file-list-container { + @apply relative flex flex-col w-full pb-4; +} + +.tpk-file-list-container .tpk-label { + @apply label; +} + +.tpk-file-list-input { + @apply hidden; +} + +.tpk-file-list-placeholder-container { + @apply min-h-[180px] w-full border-2 border-dashed border-base-300 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-colors duration-200 hover:border-neutral-content hover:bg-base-200/10; +} + +.tpk-file-list-placeholder-icon { + @apply w-8 h-8 mb-2 opacity-60; +} + +.tpk-file-list-placeholder-container.disabled { + @apply hidden; +} + +.tpk-file-list-placeholder { + @apply text-base-content/60 text-sm text-center; +} + +.tpk-file-list-container[data-has-error~='true'] + .tpk-file-list-placeholder-container { + @apply border-error bg-error/5; +} + +.tpk-file-list-container[data-has-error~='true'] .tpk-label { + @apply text-error; +} + +.tpk-file-list-container[data-has-error~='true'] .tpk-file-list-placeholder { + @apply text-error/60; +} + +.tpk-file-list-container[data-has-error~='true'] .tpk-validation-errors { + @apply text-error text-sm absolute right-0 -bottom-1; +} + +/* File list */ + +.tpk-file-list-list { + @apply space-y-4 mt-4; +} + +.tpk-file-list-list.disabled { + @apply mt-0; +} + +.tpk-file-list-list-item { + @apply flex items-center space-x-6 px-4 py-2 border border-base-300 rounded; +} + +.tpk-file-list-list-item-preview { + @apply w-7 h-7 flex-shrink-0; +} + +.tpk-file-list-list-item-preview img { + @apply w-7 h-7 object-cover rounded object-center; +} + +.tpk-file-list-list-item-content { + @apply flex flex-col flex-grow truncate text-ellipsis; +} + +.tpk-file-list-list-item-content-name { + @apply text-sm font-semibold truncate text-ellipsis; +} + +.tpk-file-list-list-item-content-size { + @apply text-xs text-base-content/60; +} + +.tpk-file-list-list-item-action { + @apply flex gap-2 flex-shrink-0; +} + +.tpk-file-list-list-item-action button, .tpk-file-list-list-item-action img { + @apply w-6 h-6; +} diff --git a/packages/ember-input-validation/src/components/prefabs/tpk-validation-file-list.gts b/packages/ember-input-validation/src/components/prefabs/tpk-validation-file-list.gts new file mode 100644 index 00000000..d5a41eec --- /dev/null +++ b/packages/ember-input-validation/src/components/prefabs/tpk-validation-file-list.gts @@ -0,0 +1,215 @@ +import { type BaseValidationSignature } from '../base.ts'; +import TpkValidationErrorsComponent from './tpk-validation-errors.gts'; +import MandatoryLabelComponent from './mandatory-label.gts'; +import TpkValidationFileComponent, { + type TpkValidationFileComponentSignature, +} from '../tpk-validation-file.gts'; +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import type { Changeset } from 'ember-immer-changeset'; +import { fn } from '@ember/helper'; +import { modifier } from 'ember-modifier'; + +export interface TpkValidationFileListPrefabSignature + extends BaseValidationSignature { + Args: BaseValidationSignature['Args'] & + TpkValidationFileComponentSignature['Args'] & { + mandatory?: boolean; + placeholder?: string; + disableDownload?: boolean; + }; + Blocks: { + default: []; + }; + Element: HTMLElement; +} + +export interface FileListSignature { + Args: { + changeset: Changeset; + validationField: string; + disableDownload?: boolean; + disabled?: boolean; + }; + Blocks: { + default: []; + }; + Element: HTMLElement; +} + +const handleSpaceToOpenListChoice = modifier(function ( + this: object, + element: HTMLElement, +) { + element.addEventListener('keydown', (event) => { + if (event.key === ' ') { + event.preventDefault(); + const labelInput = element.parentElement as HTMLLabelElement; + + if (labelInput instanceof HTMLLabelElement) { + labelInput.click(); + } + } + }); +}); + +export default class TpkValidationFileListComponent extends Component { + @action + handleDrop(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + + if (this.args.disabled) return; + + let filesFromDrop = event.dataTransfer?.files; + + if (filesFromDrop && filesFromDrop.length > 0) { + const files: File[] = Array.from(filesFromDrop); + + if (this.args.onChange) { + return this.args.onChange(files); + } + const currentFiles = this.args.changeset.get( + this.args.validationField, + ) as File[]; + return this.args.changeset.set(this.args.validationField, [ + ...currentFiles, + ...files, + ]); + } + } + + +} + +export class FileListComponent extends Component { + changesetGet = (path: string): File[] => { + return this.args.changeset.get(path) as File[]; + }; + + startWith = (str: string, start: string) => { + return str.startsWith(start); + }; + + setImagePreview = (file: File): string => { + return URL.createObjectURL(file); + }; + + getSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; + }; + + @action + deleteFile(fileToDelete: File) { + const currentFiles = this.args.changeset.get( + this.args.validationField, + ) as File[]; + const updatedFiles = currentFiles.filter((file) => file !== fileToDelete); + this.args.changeset.set(this.args.validationField, updatedFiles); + } + + async downloadFile(file: File) { + window.open(URL.createObjectURL(file)); + } + + +} diff --git a/packages/ember-input-validation/src/components/tpk-validation-file.gts b/packages/ember-input-validation/src/components/tpk-validation-file.gts index 95643b41..7f0a0877 100644 --- a/packages/ember-input-validation/src/components/tpk-validation-file.gts +++ b/packages/ember-input-validation/src/components/tpk-validation-file.gts @@ -3,7 +3,9 @@ import { BaseValidationComponent, type BaseValidationSignature, } from './base.ts'; -import TpkFile, { type TpkFileSignature } from '@triptyk/ember-input/components/tpk-file'; +import TpkFile, { + type TpkFileSignature, +} from '@triptyk/ember-input/components/tpk-file'; import { hash } from '@ember/helper'; @@ -35,10 +37,18 @@ export default class TpkValidationFileComponent extends BaseValidationComponent< if (this.args.onChange) { return this.args.onChange(file); } - return this.args.changeset.set( - this.args.validationField, - this.args.multiple === true ? file : file[0], - ); + + if (this.args.multiple === true) { + const currentFiles = + (this.args.changeset.get(this.args.validationField) as File[]) ?? []; + + return this.args.changeset.set(this.args.validationField, [ + ...currentFiles, + ...file, + ]); + } else { + return this.args.changeset.set(this.args.validationField, file[0]); + } }