]} */
+ 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);
+ const evaluatedMultiple =
+ 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 (
+
+ );
+}
+
+FilePicker.config = {
+ type: 'filepicker',
+ keyed: true,
+ label: 'File picker',
+ group: 'basic-input',
+ emptyValue: null,
+ sanitizeValue: ({ value }) => {
+ return value;
+ },
+ 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/src/render/components/icons/FilePicker.svg b/packages/form-js-viewer/src/render/components/icons/FilePicker.svg
new file mode 100644
index 000000000..7cd733f13
--- /dev/null
+++ b/packages/form-js-viewer/src/render/components/icons/FilePicker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/form-js-viewer/src/render/components/icons/index.js b/packages/form-js-viewer/src/render/components/icons/index.js
index e1729d27f..7896d8201 100644
--- a/packages/form-js-viewer/src/render/components/icons/index.js
+++ b/packages/form-js-viewer/src/render/components/icons/index.js
@@ -20,6 +20,7 @@ import IFrameIcon from './IFrame.svg';
import ImageIcon from './Image.svg';
import GroupIcon from './Group.svg';
import TableIcon from './Table.svg';
+import FilePickerIcon from './FilePicker.svg';
export const iconsByType = (type) => {
return {
@@ -44,6 +45,7 @@ export const iconsByType = (type) => {
textfield: TextfieldIcon,
textarea: TextareaIcon,
table: TableIcon,
+ filepicker: FilePickerIcon,
default: FormIcon,
}[type];
};
diff --git a/packages/form-js-viewer/src/render/components/index.js b/packages/form-js-viewer/src/render/components/index.js
index 0256d2dab..6446b5abe 100644
--- a/packages/form-js-viewer/src/render/components/index.js
+++ b/packages/form-js-viewer/src/render/components/index.js
@@ -19,6 +19,7 @@ import { ExpressionField } from './form-fields/ExpressionField';
import { Textfield } from './form-fields/Textfield';
import { Textarea } from './form-fields/Textarea';
import { Table } from './form-fields/Table';
+import { FilePicker } from './form-fields/FilePicker';
import { Label } from './Label';
import { Description } from './Description';
@@ -52,6 +53,7 @@ export {
Textfield,
Textarea,
Table,
+ FilePicker,
};
export const formFields = [
@@ -61,6 +63,7 @@ export const formFields = [
Numberfield,
Datetime,
ExpressionField,
+ FilePicker,
/* Selection */
Checkbox,
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'),
+ },
+ );
+}
diff --git a/packages/form-json-schema/src/defs/component.json b/packages/form-json-schema/src/defs/component.json
index faadabc9c..31e157b2f 100644
--- a/packages/form-json-schema/src/defs/component.json
+++ b/packages/form-json-schema/src/defs/component.json
@@ -262,6 +262,16 @@
"$id": "#/component/content",
"description": "The content of a custom component.",
"type": "string"
+ },
+ "accept": {
+ "$id": "#/component/accept",
+ "description": "Define the accepted file types.",
+ "type": "string"
+ },
+ "multiple": {
+ "$id": "#/component/multiple",
+ "description": "Allow multiple files to be selected.",
+ "type": ["boolean", "string"]
}
},
"required": ["type"]
diff --git a/packages/form-json-schema/src/defs/field-types/inputs.json b/packages/form-json-schema/src/defs/field-types/inputs.json
index 14d871981..4fbb5f1f3 100644
--- a/packages/form-json-schema/src/defs/field-types/inputs.json
+++ b/packages/form-json-schema/src/defs/field-types/inputs.json
@@ -11,7 +11,8 @@
"taglist",
"textfield",
"textarea",
- "expression"
+ "expression",
+ "filepicker"
]
}
},
diff --git a/packages/form-json-schema/src/defs/rules/rules-allowed-properties.json b/packages/form-json-schema/src/defs/rules/rules-allowed-properties.json
index f64233bfa..576276d57 100644
--- a/packages/form-json-schema/src/defs/rules/rules-allowed-properties.json
+++ b/packages/form-json-schema/src/defs/rules/rules-allowed-properties.json
@@ -397,6 +397,39 @@
"content": false
}
}
+ },
+ {
+ "if": {
+ "not": {
+ "properties": {
+ "type": {
+ "const": "filepicker"
+ }
+ },
+ "required": ["type"]
+ }
+ },
+ "then": {
+ "properties": {
+ "accept": false,
+ "multiple": false
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "type": {
+ "const": "filepicker"
+ }
+ },
+ "required": ["type"]
+ },
+ "then": {
+ "properties": {
+ "description": false
+ }
+ }
}
]
}
diff --git a/packages/form-json-schema/src/defs/type.json b/packages/form-json-schema/src/defs/type.json
index 77d840553..e7587befc 100644
--- a/packages/form-json-schema/src/defs/type.json
+++ b/packages/form-json-schema/src/defs/type.json
@@ -22,6 +22,7 @@
"separator",
"table",
"iframe",
- "expression"
+ "expression",
+ "filepicker"
]
}
diff --git a/packages/form-json-schema/test/fixtures/accept-not-allowed.js b/packages/form-json-schema/test/fixtures/accept-not-allowed.js
new file mode 100644
index 000000000..f9e6423be
--- /dev/null
+++ b/packages/form-json-schema/test/fixtures/accept-not-allowed.js
@@ -0,0 +1,27 @@
+export const form = {
+ type: 'default',
+ components: [
+ {
+ type: 'textfield',
+ key: 'textfield_g35o3e',
+ accept: '.png,.jpg',
+ },
+ ],
+};
+
+export const errors = [
+ {
+ instancePath: '/components/0/accept',
+ keyword: 'false schema',
+ message: 'boolean schema is false',
+ params: {},
+ schemaPath: '#/properties/components/items/allOf/1/allOf/20/then/properties/accept/false schema',
+ },
+ {
+ instancePath: '/components/0',
+ schemaPath: '#/properties/components/items/allOf/1/allOf/20/if',
+ keyword: 'if',
+ params: { failingKeyword: 'then' },
+ message: 'must match "then" schema',
+ },
+];
diff --git a/packages/form-json-schema/test/fixtures/filepicker.js b/packages/form-json-schema/test/fixtures/filepicker.js
new file mode 100644
index 000000000..44d3893d7
--- /dev/null
+++ b/packages/form-json-schema/test/fixtures/filepicker.js
@@ -0,0 +1,13 @@
+export const form = {
+ type: 'default',
+ components: [
+ {
+ type: 'filepicker',
+ key: 'filepicker',
+ accept: '.png,.jpg',
+ multiple: true,
+ },
+ ],
+};
+
+export const errors = null;
diff --git a/packages/form-json-schema/test/fixtures/multiple-not-allowed.js b/packages/form-json-schema/test/fixtures/multiple-not-allowed.js
new file mode 100644
index 000000000..634dab41f
--- /dev/null
+++ b/packages/form-json-schema/test/fixtures/multiple-not-allowed.js
@@ -0,0 +1,27 @@
+export const form = {
+ type: 'default',
+ components: [
+ {
+ type: 'textfield',
+ key: 'textfield_g35o3e',
+ multiple: true,
+ },
+ ],
+};
+
+export const errors = [
+ {
+ instancePath: '/components/0/multiple',
+ keyword: 'false schema',
+ message: 'boolean schema is false',
+ params: {},
+ schemaPath: '#/properties/components/items/allOf/1/allOf/20/then/properties/multiple/false schema',
+ },
+ {
+ instancePath: '/components/0',
+ schemaPath: '#/properties/components/items/allOf/1/allOf/20/if',
+ keyword: 'if',
+ params: { failingKeyword: 'then' },
+ message: 'must match "then" schema',
+ },
+];
diff --git a/packages/form-json-schema/test/spec/validation.spec.js b/packages/form-json-schema/test/spec/validation.spec.js
index 6e0fe7f08..5b9d2766e 100644
--- a/packages/form-json-schema/test/spec/validation.spec.js
+++ b/packages/form-json-schema/test/spec/validation.spec.js
@@ -174,6 +174,12 @@ describe('validation', function () {
testForm('dataSource-not-allowed');
testForm('columns-columnsExpression-exclusive');
+
+ testForm('filepicker');
+
+ testForm('accept-not-allowed');
+
+ testForm('multiple-not-allowed');
});
describe('rules - default', function () {