From 6dd5f00e9fb38709abb7655c479ce000b6710f8f Mon Sep 17 00:00:00 2001 From: Vinicius Goulart Date: Fri, 23 Aug 2024 03:15:17 +0200 Subject: [PATCH 1/2] feat: implement basic filepicker on viewer --- .../form-js-viewer/assets/form-js-base.css | 25 +- .../components/form-fields/FilePicker.js | 100 +++++++- .../components/form-fields/FilePicker.spec.js | 214 ++++++++++++++++++ 3 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 packages/form-js-viewer/test/spec/render/components/form-fields/FilePicker.spec.js diff --git a/packages/form-js-viewer/assets/form-js-base.css b/packages/form-js-viewer/assets/form-js-base.css index 0abef512c..b1973016f 100644 --- a/packages/form-js-viewer/assets/form-js-base.css +++ b/packages/form-js-viewer/assets/form-js-base.css @@ -456,6 +456,7 @@ .fjs-container .fjs-input[type='tel'], .fjs-container .fjs-input[type='number'], .fjs-container .fjs-button[type='submit'], +.fjs-container .fjs-button[type='button'], .fjs-container .fjs-button[type='reset'], .fjs-container .fjs-textarea, .fjs-container .fjs-select { @@ -631,7 +632,8 @@ margin: 6px 10px 6px 4px; } -.fjs-container .fjs-button[type='submit'] { +.fjs-container .fjs-button[type='submit'], +.fjs-container .fjs-button[type='button'] { color: var(--cds-text-inverse, var(--color-white)); background-color: var(--color-accent); border-color: var(--color-accent); @@ -644,12 +646,14 @@ } .fjs-container .fjs-button[type='submit'], +.fjs-container .fjs-button[type='button'], .fjs-container .fjs-button[type='reset'] { min-width: 100px; width: auto; } -.fjs-container .fjs-button[type='submit'] { +.fjs-container .fjs-button[type='submit'], +.fjs-container .fjs-button[type='button'] { font-weight: 600; } @@ -660,6 +664,7 @@ .fjs-container .fjs-input[type='tel']:focus, .fjs-container .fjs-input[type='number']:focus, .fjs-container .fjs-button[type='submit']:focus, +.fjs-container .fjs-button[type='button']:focus, .fjs-container .fjs-button[type='reset']:focus, .fjs-container .fjs-textarea:focus, .fjs-container .fjs-select:focus { @@ -676,7 +681,8 @@ outline: none; } -.fjs-container .fjs-button[type='submit']:focus { +.fjs-container .fjs-button[type='submit']:focus, +.fjs-container .fjs-button[type='button']:focus { border-color: var(--color-accent); } @@ -719,6 +725,7 @@ } .fjs-container .fjs-button[type='submit']:disabled, +.fjs-container .fjs-button[type='button']:disabled, .fjs-container .fjs-button[type='reset']:disabled { color: var(--cds-text-on-color-disabled, var(--color-text-light)); background-color: var(--color-background-disabled); @@ -726,6 +733,7 @@ } .fjs-container .fjs-button[type='submit']:read-only, +.fjs-container .fjs-button[type='button']:read-only, .fjs-container .fjs-button[type='reset']:read-only { color: var(--text-light); background-color: var(--color-background-readonly); @@ -1269,3 +1277,14 @@ .fjs-container .flatpickr-calendar { width: 326px; } + +.fjs-hidden { + display: none; +} + +.fjs-container .fjs-filepicker-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} diff --git a/packages/form-js-viewer/src/render/components/form-fields/FilePicker.js b/packages/form-js-viewer/src/render/components/form-fields/FilePicker.js index 3db489a64..9b6dad040 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/FilePicker.js +++ b/packages/form-js-viewer/src/render/components/form-fields/FilePicker.js @@ -1,8 +1,86 @@ +import { formFieldClasses } from '../Util'; +import { Label } from '../Label'; +import { Errors } from '../Errors'; +import { useRef, useState } from 'preact/hooks'; +import { useSingleLineTemplateEvaluation } from '../../hooks'; + +const type = 'filepicker'; + /** + * @typedef Props + * @property {(props: { value: string }) => void} onChange + * @property {string} domId + * @property {string[]} errors + * @property {boolean} disabled + * @property {boolean} readonly + * @property {boolean} required + * @property {Object} field + * @property {string} field.id + * @property {string} [field.label] + * @property {string} [field.accept] + * @property {boolean} [field.multiple] + * + * @param {Props} props * @returns {import("preact").JSX.Element} */ -export function FilePicker() { - return null; +export function FilePicker(props) { + /** @type {import("preact/hooks").Ref} */ + const fileInputRef = useRef(null); + /** @type {[File[],import("preact/hooks").StateUpdater]} */ + const [selectedFiles, setSelectedFiles] = useState([]); + const { field, onChange, domId, errors = [], disabled, readonly, required } = props; + const { label, multiple = '', accept = '', id } = field; + const evaluatedAccept = useSingleLineTemplateEvaluation(accept); + const evaluatedMultiple = + useSingleLineTemplateEvaluation(typeof multiple === 'string' ? multiple : multiple.toString()) === 'true'; + const errorMessageId = `${domId}-error-message`; + + return ( +
+
+ ); } FilePicker.config = { @@ -16,3 +94,21 @@ FilePicker.config = { }, create: (options = {}) => ({ ...options }), }; + +// helper ////////// + +/** + * @param {File[]} files + * @returns {string} + */ +function getSelectedFilesLabel(files) { + if (files.length === 0) { + return 'No files selected'; + } + + if (files.length === 1) { + return files[0].name; + } + + return `${files.length} files selected`; +} diff --git a/packages/form-js-viewer/test/spec/render/components/form-fields/FilePicker.spec.js b/packages/form-js-viewer/test/spec/render/components/form-fields/FilePicker.spec.js new file mode 100644 index 000000000..dc2ad2b62 --- /dev/null +++ b/packages/form-js-viewer/test/spec/render/components/form-fields/FilePicker.spec.js @@ -0,0 +1,214 @@ +import { fireEvent, render, screen } from '@testing-library/preact/pure'; + +import { FilePicker } from '../../../../../src/render/components/form-fields/FilePicker'; + +import { createFormContainer, expectNoViolations } from '../../../../TestHelper'; + +import { MockFormContext } from '../helper'; + +let container; + +describe('FilePicker', function () { + beforeEach(function () { + container = createFormContainer(); + }); + + afterEach(function () { + container.remove(); + }); + + it('should render', function () { + // when + createFilePicker({ + field: { + ...defaultField, + label: 'My files', + }, + }); + + // then + + expect(screen.getByLabelText('My files')).to.exist; + expect(screen.getByRole('button', { name: 'Browse' })).to.exist; + expect(screen.getByText('No files selected')).to.exist; + }); + + it('should render errors', function () { + // when + createFilePicker({ + errors: ['Something went wrong'], + }); + + // then + expect(screen.getByText('Something went wrong')).to.exist; + }); + + it('should change the label with single file selected', function () { + // given + const file = new File([''], 'test.png', { type: 'image/png' }); + const { container } = createFilePicker(); + + // when + + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { + files: [file], + }, + }); + + // then + + expect(screen.getByText('test.png')).to.exist; + }); + + it('should change the label with multiple files selected', function () { + // given + const file = new File([''], 'test1.png', { type: 'image/png' }); + const { container } = createFilePicker(); + + // when + + fireEvent.change(container.querySelector('input[type="file"]'), { + target: { + files: [file, file], + }, + }); + + // then + + expect(screen.getByText('2 files selected')).to.exist; + }); + + it('should accept multiple files and limit the file types', function () { + // when + const { container } = createFilePicker({ + field: { + ...defaultField, + accept: 'image/*', + multiple: true, + }, + }); + + // then + + expect(screen.getByRole('button', { name: 'Browse' })).to.exist; + expect(screen.getByText('No files selected')).to.exist; + expect(container.querySelector('input[type="file"]')).to.have.property('accept', 'image/*'); + expect(container.querySelector('input[type="file"]')).to.have.property('multiple'); + }); + + it('should accept multiple files and limit the file types (expression)', function () { + // when + const { container } = createFilePicker({ + initialData: { + mime: 'image/svg', + acceptMultiple: true, + }, + field: { + ...defaultField, + accept: '=mime', + multiple: '=acceptMultiple', + }, + }); + + // then + + expect(screen.getByRole('button', { name: 'Browse' })).to.exist; + expect(screen.getByText('No files selected')).to.exist; + expect(container.querySelector('input[type="file"]')).to.have.property('accept', 'image/svg'); + expect(container.querySelector('input[type="file"]')).to.have.property('multiple'); + }); + + it('#create', function () { + // assume + const { config } = FilePicker; + + // when + const field = config.create(); + + // then + expect(field).to.eql({}); + + // but when + const customField = config.create({ + custom: true, + }); + + // then + expect(customField).to.contain({ + custom: true, + }); + }); + + describe('a11y', function () { + it('should have no violations', async function () { + // given + this.timeout(10000); + + const { container } = createFilePicker(); + + // then + await expectNoViolations(container); + }); + + it('should have no violations for readonly', async function () { + // given + this.timeout(10000); + + const { container } = createFilePicker({ + value: true, + readonly: true, + }); + + // then + await expectNoViolations(container); + }); + + it('should have no violations for errors', async function () { + // given + this.timeout(10000); + + const { container } = createFilePicker({ + value: true, + errors: ['Something went wrong'], + }); + + // then + await expectNoViolations(container); + }); + }); +}); + +// helper ////////// + +const defaultField = { + id: 'Filepicker_1', + type: 'filepicker', +}; + +function createFilePicker({ services, ...restOptions } = {}) { + const options = { + domId: 'test-filepicker', + field: defaultField, + onChange: () => {}, + ...restOptions, + }; + + return render( + + + , + { + container: options.container || container.querySelector('.fjs-form'), + }, + ); +} From 334d134b5928f0b56239a8a4dc877d24db1ff951 Mon Sep 17 00:00:00 2001 From: Vinicius Goulart Date: Fri, 23 Aug 2024 16:17:32 +0200 Subject: [PATCH 2/2] feat: reset filepicker on import.done and reset --- .../components/form-fields/FilePicker.js | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/form-js-viewer/src/render/components/form-fields/FilePicker.js b/packages/form-js-viewer/src/render/components/form-fields/FilePicker.js index 9b6dad040..cb644d3cd 100644 --- a/packages/form-js-viewer/src/render/components/form-fields/FilePicker.js +++ b/packages/form-js-viewer/src/render/components/form-fields/FilePicker.js @@ -1,8 +1,8 @@ import { formFieldClasses } from '../Util'; import { Label } from '../Label'; import { Errors } from '../Errors'; -import { useRef, useState } from 'preact/hooks'; -import { useSingleLineTemplateEvaluation } from '../../hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { useService, useSingleLineTemplateEvaluation } from '../../hooks'; const type = 'filepicker'; @@ -28,6 +28,7 @@ export function FilePicker(props) { const fileInputRef = useRef(null); /** @type {[File[],import("preact/hooks").StateUpdater]} */ const [selectedFiles, setSelectedFiles] = useState([]); + const eventBus = useService('eventBus'); const { field, onChange, domId, errors = [], disabled, readonly, required } = props; const { label, multiple = '', accept = '', id } = field; const evaluatedAccept = useSingleLineTemplateEvaluation(accept); @@ -35,6 +36,23 @@ export function FilePicker(props) { useSingleLineTemplateEvaluation(typeof multiple === 'string' ? multiple : multiple.toString()) === 'true'; const errorMessageId = `${domId}-error-message`; + useEffect(() => { + const reset = () => { + setSelectedFiles([]); + onChange({ + value: null, + }); + }; + + eventBus.on('import.done', reset); + eventBus.on('reset', reset); + + return () => { + eventBus.off('import.done', reset); + eventBus.off('reset', reset); + }; + }, [eventBus, onChange]); + return (