diff --git a/assets/js/__tests__/input-duplicator.spec.ts b/assets/js/__tests__/input-duplicator.spec.ts
new file mode 100644
index 000000000..fc7adf0b7
--- /dev/null
+++ b/assets/js/__tests__/input-duplicator.spec.ts
@@ -0,0 +1,91 @@
+import { inputDuplicatorCreator } from '../input-duplicator';
+import { assertNotNull } from '../utils/assert';
+import { $, $$, removeEl } from '../utils/dom';
+
+describe('Input duplicator functionality', () => {
+ beforeEach(() => {
+ document.documentElement.insertAdjacentHTML('beforeend', `
`);
+ });
+
+ afterEach(() => {
+ removeEl($$('form'));
+ });
+
+ function runCreator() {
+ inputDuplicatorCreator({
+ addButtonSelector: '.js-add-input',
+ fieldSelector: '.js-input-source',
+ maxInputCountSelector: '.js-max-input-count',
+ removeButtonSelector: '.js-remove-input',
+ });
+ }
+
+ it('should ignore forms without a duplicator button', () => {
+ removeEl($$('button'));
+ expect(runCreator()).toBeUndefined();
+ });
+
+ it('should duplicate the input elements', () => {
+ runCreator();
+
+ expect($$('input')).toHaveLength(1);
+
+ assertNotNull($('.js-add-input')).click();
+
+ expect($$('input')).toHaveLength(2);
+ });
+
+ it('should duplicate the input elements when the button is before the inputs', () => {
+ const form = assertNotNull($('form'));
+ const buttonDiv = assertNotNull($('.js-button-container'));
+ removeEl(buttonDiv);
+ form.insertAdjacentElement('afterbegin', buttonDiv);
+ runCreator();
+
+ assertNotNull($('.js-add-input')).click();
+
+ expect($$('input')).toHaveLength(2);
+ });
+
+ it('should not create more input elements than the limit', () => {
+ runCreator();
+
+ for (let i = 0; i < 5; i += 1) {
+ assertNotNull($('.js-add-input')).click();
+ }
+
+ expect($$('input')).toHaveLength(3);
+ });
+
+ it('should remove duplicated input elements', () => {
+ runCreator();
+
+ assertNotNull($('.js-add-input')).click();
+ assertNotNull($('.js-remove-input')).click();
+
+ expect($$('input')).toHaveLength(1);
+ });
+
+ it('should not remove the last input element', () => {
+ runCreator();
+
+ assertNotNull($('.js-remove-input')).click();
+ assertNotNull($('.js-remove-input')).click();
+ for (let i = 0; i < 5; i += 1) {
+ assertNotNull($('.js-remove-input')).click();
+ }
+
+ expect($$('input')).toHaveLength(1);
+ });
+});
diff --git a/assets/js/input-duplicator.js b/assets/js/input-duplicator.js
deleted file mode 100644
index 2ffa89bc1..000000000
--- a/assets/js/input-duplicator.js
+++ /dev/null
@@ -1,83 +0,0 @@
-import { $, $$, disableEl, enableEl, removeEl } from './utils/dom';
-import { delegate, leftClick } from './utils/events';
-
-/**
- * @typedef InputDuplicatorOptions
- * @property {string} addButtonSelector
- * @property {string} fieldSelector
- * @property {string} maxInputCountSelector
- * @property {string} removeButtonSelector
- */
-
-/**
- * @param {InputDuplicatorOptions} options
- */
-function inputDuplicatorCreator({
- addButtonSelector,
- fieldSelector,
- maxInputCountSelector,
- removeButtonSelector
-}) {
- const addButton = $(addButtonSelector);
- if (!addButton) {
- return;
- }
-
- const form = addButton.closest('form');
- const fieldRemover = (event, target) => {
- event.preventDefault();
-
- // Prevent removing the final field element to not "brick" the form
- const existingFields = $$(fieldSelector, form);
- if (existingFields.length <= 1) {
- return;
- }
-
- removeEl(target.closest(fieldSelector));
- enableEl(addButton);
- };
-
- delegate(document, 'click', {
- [removeButtonSelector]: leftClick(fieldRemover)
- });
-
-
- const maxOptionCount = parseInt($(maxInputCountSelector, form).innerHTML, 10);
- addButton.addEventListener('click', e => {
- e.preventDefault();
-
- const existingFields = $$(fieldSelector, form);
- let existingFieldsLength = existingFields.length;
- if (existingFieldsLength < maxOptionCount) {
- // The last element matched by the `fieldSelector` will be the last field, make a copy
- const prevField = existingFields[existingFieldsLength - 1];
- const prevFieldCopy = prevField.cloneNode(true);
- const prevFieldCopyInputs = $$('input', prevFieldCopy);
- prevFieldCopyInputs.forEach(prevFieldCopyInput => {
- // Reset new input's value
- prevFieldCopyInput.value = '';
- prevFieldCopyInput.removeAttribute('value');
- // Increment sequential attributes of the input
- ['name', 'id'].forEach(attr => {
- prevFieldCopyInput.setAttribute(attr, prevFieldCopyInput[attr].replace(/\d+/g, `${existingFieldsLength}`));
- });
- });
-
- // Insert copy before the last field's next sibling, or if none, at the end of its parent
- if (prevField.nextElementSibling) {
- prevField.parentNode.insertBefore(prevFieldCopy, prevField.nextElementSibling);
- }
- else {
- prevField.parentNode.appendChild(prevFieldCopy);
- }
- existingFieldsLength++;
- }
-
- // Remove the button if we reached the max number of options
- if (existingFieldsLength >= maxOptionCount) {
- disableEl(addButton);
- }
- });
-}
-
-export { inputDuplicatorCreator };
diff --git a/assets/js/input-duplicator.ts b/assets/js/input-duplicator.ts
new file mode 100644
index 000000000..e82c892d7
--- /dev/null
+++ b/assets/js/input-duplicator.ts
@@ -0,0 +1,76 @@
+import { assertNotNull } from './utils/assert';
+import { $, $$, disableEl, enableEl, removeEl } from './utils/dom';
+import { delegate, leftClick } from './utils/events';
+
+export interface InputDuplicatorOptions {
+ addButtonSelector: string;
+ fieldSelector: string;
+ maxInputCountSelector: string;
+ removeButtonSelector: string;
+}
+
+export function inputDuplicatorCreator({
+ addButtonSelector,
+ fieldSelector,
+ maxInputCountSelector,
+ removeButtonSelector
+}: InputDuplicatorOptions) {
+ const addButton = $(addButtonSelector);
+ if (!addButton) {
+ return;
+ }
+
+ const form = assertNotNull(addButton.closest('form'));
+ const fieldRemover = (event: MouseEvent, target: HTMLElement) => {
+ event.preventDefault();
+
+ // Prevent removing the final field element to not "brick" the form
+ const existingFields = $$(fieldSelector, form);
+ if (existingFields.length <= 1) {
+ return;
+ }
+
+ removeEl(assertNotNull(target.closest(fieldSelector)));
+ enableEl(addButton);
+ };
+
+ delegate(form, 'click', {
+ [removeButtonSelector]: leftClick(fieldRemover)
+ });
+
+
+ const maxOptionCountElement = assertNotNull($(maxInputCountSelector, form));
+ const maxOptionCount = parseInt(maxOptionCountElement.innerHTML, 10);
+
+ addButton.addEventListener('click', e => {
+ e.preventDefault();
+
+ const existingFields = $$(fieldSelector, form);
+ let existingFieldsLength = existingFields.length;
+
+ if (existingFieldsLength < maxOptionCount) {
+ // The last element matched by the `fieldSelector` will be the last field, make a copy
+ const prevField = existingFields[existingFieldsLength - 1];
+ const prevFieldCopy = prevField.cloneNode(true) as HTMLElement;
+
+ $$('input', prevFieldCopy).forEach(prevFieldCopyInput => {
+ // Reset new input's value
+ prevFieldCopyInput.value = '';
+ prevFieldCopyInput.removeAttribute('value');
+
+ // Increment sequential attributes of the input
+ prevFieldCopyInput.setAttribute('name', prevFieldCopyInput.name.replace(/\d+/g, `${existingFieldsLength}`));
+ prevFieldCopyInput.setAttribute('id', prevFieldCopyInput.id.replace(/\d+/g, `${existingFieldsLength}`));
+ });
+
+ prevField.insertAdjacentElement('afterend', prevFieldCopy);
+
+ existingFieldsLength++;
+ }
+
+ // Remove the button if we reached the max number of options
+ if (existingFieldsLength >= maxOptionCount) {
+ disableEl(addButton);
+ }
+ });
+}