diff --git a/app/javascript/adminterface/lib/__tests__/has_many.html b/app/javascript/adminterface/lib/__tests__/has_many.html
new file mode 100644
index 00000000..cee83d0e
--- /dev/null
+++ b/app/javascript/adminterface/lib/__tests__/has_many.html
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/app/javascript/adminterface/lib/__tests__/has_many.spec.js b/app/javascript/adminterface/lib/__tests__/has_many.spec.js
new file mode 100644
index 00000000..59c19304
--- /dev/null
+++ b/app/javascript/adminterface/lib/__tests__/has_many.spec.js
@@ -0,0 +1,225 @@
+/* eslint-env jest */
+import fs from 'fs'
+import path from 'path'
+import Sortable from 'sortablejs'
+import HasManyClass from '../has_many'
+
+jest.mock('sortablejs')
+
+describe('HasMany', () => {
+ const html = fs.readFileSync(path.resolve(__dirname, './has_many.html'))
+ let element
+
+ const options = {
+ list: '.has-many-list',
+ item: 'fieldset.has_many_fields',
+ addLink: 'a.button.has_many_add',
+ removeLink: 'a.button.has_many_remove',
+ destroyInput: 'input[name$="[_destroy]"]'
+ }
+
+ beforeEach(() => {
+ document.documentElement.innerHTML = html
+ element = document.querySelector('div')
+ global.adminterface = {
+ addObserver: jest.fn()
+ }
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ test('constructor initiates correctly', () => {
+ const spyOnBind = jest.spyOn(HasManyClass.prototype, '_bind')
+ spyOnBind.mockImplementation(() => {})
+
+ const HasMany = new HasManyClass(element)
+ expect(HasMany.element).toStrictEqual(element)
+
+ expect(spyOnBind).toHaveBeenCalledTimes(1)
+ })
+
+ test('bind calls bindEvents, initSortable and admininterface', () => {
+ const spyOnBindEvents = jest.spyOn(HasManyClass.prototype, '_bindEvents')
+ spyOnBindEvents.mockImplementation(() => {})
+
+ const spyOnInitSortable = jest.spyOn(HasManyClass.prototype, '_initSortable')
+ spyOnInitSortable.mockImplementation(() => {})
+
+ const HasMany = new HasManyClass(element)
+
+ expect(spyOnBindEvents).toHaveBeenCalledWith(element)
+ expect(spyOnInitSortable).toHaveBeenCalledTimes(1)
+ expect(global.adminterface.addObserver).toHaveBeenCalledTimes(1)
+ expect(global.adminterface.addObserver).toHaveBeenCalledWith(
+ element,
+ HasMany,
+ HasMany.constructor.name,
+ []
+ )
+ })
+
+ test('initSortable assigns new instance of Sortable to sortable property', () => {
+ const spyOnBindEvents = jest.spyOn(HasManyClass.prototype, '_bindEvents')
+ spyOnBindEvents.mockImplementation(() => {})
+
+ const HasMany = new HasManyClass(element)
+
+ const MockSortableArgument = {
+ handle: '.handle',
+ animation: 150,
+ ghostClass: 'sortable-placeholder',
+ dragClass: 'sortable-drag',
+ onUpdate: expect.any(Function)
+ }
+
+ expect(Sortable).toHaveBeenCalledTimes(1)
+ expect(Sortable).toHaveBeenCalledWith(
+ document.querySelector(HasMany.options.list),
+ MockSortableArgument
+ )
+ const sortable = new Sortable(
+ document.querySelector(HasMany.options.list),
+ MockSortableArgument
+ )
+ expect(HasMany.sortable.toString()).toStrictEqual(sortable.toString())
+ })
+
+ test('recomputePosition changes sortableInput value', () => {
+ const query = 'input[name$="[address]"]'
+ const sortableInput = element.querySelector(query)
+
+ expect(sortableInput.value.length).not.toEqual(0)
+
+ const HasMany = new HasManyClass(element)
+ HasMany._recomputePosition()
+
+ expect(sortableInput.value.length).toEqual(0)
+ })
+
+ test('bindDestroyEvent called with element and change event added', () => {
+ const spyOnBindDestroyEvent = jest.spyOn(
+ HasManyClass.prototype,
+ '_bindDestroyEvent'
+ )
+ const destroyInput = document.querySelector(options.destroyInput)
+ destroyInput.addEventListener = jest.fn()
+
+ new HasManyClass(element) // eslint-disable-line no-new
+
+ expect(spyOnBindDestroyEvent).toHaveBeenCalledTimes(1)
+ expect(spyOnBindDestroyEvent).toHaveBeenCalledWith(destroyInput)
+
+ expect(destroyInput.addEventListener).toHaveBeenCalledTimes(1)
+ expect(destroyInput.addEventListener).toHaveBeenCalledWith(
+ 'change',
+ expect.any(Function)
+ )
+ })
+
+ test('bindRemoveEvent called with element and click event added', () => {
+ const spyOnBindRemoveEvent = jest.spyOn(
+ HasManyClass.prototype,
+ '_bindRemoveEvent'
+ )
+ const removeLinks = document.querySelector(options.removeLink)
+ removeLinks.addEventListener = jest.fn()
+
+ new HasManyClass(element) // eslint-disable-line no-new
+
+ expect(spyOnBindRemoveEvent).toHaveBeenCalledTimes(1)
+ expect(spyOnBindRemoveEvent).toHaveBeenCalledWith(removeLinks)
+
+ expect(removeLinks.addEventListener).toHaveBeenCalledTimes(1)
+ expect(removeLinks.addEventListener).toHaveBeenCalledWith(
+ 'click',
+ expect.any(Function)
+ )
+ })
+
+ test('bindRemoveEventCallBack', () => {
+ const mockEvent = {
+ preventDefault: () => {},
+ target: document.body.querySelector('fieldset')
+ }
+ const spyOnRecomputePosition = jest.spyOn(
+ HasManyClass.prototype,
+ '_recomputePosition'
+ )
+ const HasMany = new HasManyClass(element)
+ const spyOnDispatch = jest.spyOn(HasMany.element, 'dispatchEvent')
+
+ const fieldSet = mockEvent.target.closest(HasMany.options.item)
+ fieldSet.remove = jest.fn()
+
+ const parent = HasMany.element
+ const removeBeforeEvent = HasMany.events.removeBefore
+ removeBeforeEvent.detail = { fieldSet, parent }
+ const removeAfterEvent = HasMany.events.removeAfter
+ removeAfterEvent.detail = { fieldSet, parent }
+
+ const output = HasMany._bindRemoveEventCallBack(mockEvent)
+
+ expect(spyOnRecomputePosition).toHaveBeenCalledTimes(1)
+ expect(spyOnDispatch).toHaveBeenCalledTimes(2)
+ expect(spyOnDispatch.mock.calls).toMatchObject([
+ [removeBeforeEvent],
+ [removeAfterEvent]
+ ])
+ expect(fieldSet.remove).toHaveBeenCalledTimes(1)
+ expect(output).toBeTruthy()
+ })
+
+ test('bindAddEvent called with correctly element and click event added', () => {
+ const spyOnBindAddEvent = jest.spyOn(
+ HasManyClass.prototype,
+ '_bindAddEvent'
+ )
+
+ const addLinks = document.querySelector(options.addLink)
+ addLinks.addEventListener = jest.fn()
+
+ new HasManyClass(element) // eslint-disable-line no-new
+
+ expect(spyOnBindAddEvent).toHaveBeenCalledTimes(1)
+ expect(spyOnBindAddEvent).toHaveBeenCalledWith(addLinks)
+
+ expect(addLinks.addEventListener).toHaveBeenCalledTimes(1)
+ expect(addLinks.addEventListener).toHaveBeenCalledWith(
+ 'click',
+ expect.any(Function)
+ )
+ })
+
+ test('bindAddEventCallBack', () => {
+ const mockEvent = {
+ preventDefault: () => {},
+ target: document.body.querySelector(options.addLink)
+ }
+ const HasMany = new HasManyClass(element)
+ const spyOnDispatchEvent = jest.spyOn(HasMany.element, 'dispatchEvent')
+ const $list = HasMany.element.querySelector(HasMany.options.list)
+ const spyOnListAppendChild = jest.spyOn($list, 'appendChild')
+ const spyOnBindEvents = jest.spyOn(HasMany, '_bindEvents')
+ const spyOnRecomputePosition = jest.spyOn(HasMany, '_recomputePosition')
+ const beforeAdd = HasMany.events.addBefore
+ const parent = HasMany.element
+ const datasetHTML = document.createElement('div')
+
+ const output = HasMany._bindAddEventCallBack(mockEvent)
+
+ expect(spyOnDispatchEvent).toHaveBeenCalledTimes(2)
+ expect(spyOnDispatchEvent.mock.calls).toMatchObject([
+ [beforeAdd, [parent]],
+ [HasMany.events.addAfter]
+ ])
+
+ expect(spyOnListAppendChild).toHaveBeenCalledTimes(1)
+ expect(spyOnListAppendChild).toHaveBeenCalledWith(datasetHTML)
+ expect(spyOnBindEvents).toHaveBeenCalledTimes(1)
+ expect(spyOnBindEvents).toHaveBeenCalledWith(datasetHTML)
+ expect(spyOnRecomputePosition).toHaveBeenCalledTimes(1)
+ expect(output).toBeTruthy()
+ })
+})
diff --git a/app/javascript/adminterface/lib/has_many.js b/app/javascript/adminterface/lib/has_many.js
index 69fc3394..27e3608f 100644
--- a/app/javascript/adminterface/lib/has_many.js
+++ b/app/javascript/adminterface/lib/has_many.js
@@ -51,54 +51,57 @@ class HasMany {
}
_bindAddEvent (el) {
- el.addEventListener('click', (e) => {
- let beforeAdd
- const el = e.target
- const parent = this.element
+ el.addEventListener('click', (e) => this._bindAddEventCallBack(e))
+ }
- e.preventDefault()
- parent.dispatchEvent(beforeAdd = this.events.addBefore, [parent])
+ _bindAddEventCallBack (e) {
+ let beforeAdd
+ const el = e.target
+ const parent = this.element
+ e.preventDefault()
+ parent.dispatchEvent(beforeAdd = this.events.addBefore, [parent])
- if (!beforeAdd.defaultPrevented) {
- let index = parent.dataset.hasManyIndex || parent.querySelectorAll(this.options.item).length - 1
- parent.setAttribute('data-has-many-index', ++index)
+ if (!beforeAdd.defaultPrevented) {
+ let index = parent.dataset.hasManyIndex || parent.querySelectorAll(this.options.item).length - 1
+ parent.setAttribute('data-has-many-index', ++index)
- const regex = new RegExp(el.dataset.placeholder, 'g')
- const html = el.dataset.html.replaceAll(regex, index)
- const newNode = document.createElement('div')
- newNode.innerHTML = html
+ const regex = new RegExp(el.dataset.placeholder, 'g')
+ const html = el.dataset.html.replace(regex, index)
+ const newNode = document.createElement('div')
+ newNode.innerHTML = html
- const fieldset = newNode.firstElementChild
- const $list = this.element.querySelector(this.options.list)
+ const fieldset = newNode.firstElementChild
+ const $list = this.element.querySelector(this.options.list)
- $list.appendChild(fieldset)
- this._bindEvents(fieldset)
- this._recomputePosition()
+ $list.appendChild(fieldset)
+ this._bindEvents(fieldset)
+ this._recomputePosition()
- const addAfterEvent = this.events.addAfter
- addAfterEvent.detail = { fieldset, parent }
- return parent.dispatchEvent(this.events.addAfter)
- }
- })
+ const addAfterEvent = this.events.addAfter
+ addAfterEvent.detail = { fieldset, parent }
+ return parent.dispatchEvent(this.events.addAfter)
+ }
}
_bindRemoveEvent (el) {
- el.addEventListener('click', (e) => {
- const el = e.target
- const parent = this.element
- const fieldset = el.closest(this.options.item)
- const removeBeforeEvent = this.events.removeBefore
- const removeAfterEvent = this.events.removeAfter
-
- e.preventDefault()
- this._recomputePosition()
+ el.addEventListener('click', (e) => this._bindRemoveEventCallBack(e))
+ }
- removeBeforeEvent.detail = { fieldset, parent }
- removeAfterEvent.detail = { fieldset, parent }
- parent.dispatchEvent(removeBeforeEvent)
- fieldset.remove()
- return parent.dispatchEvent(removeAfterEvent)
- })
+ _bindRemoveEventCallBack (e) {
+ const el = e.target
+ const parent = this.element
+ const fieldset = el.closest(this.options.item)
+ const removeBeforeEvent = this.events.removeBefore
+ const removeAfterEvent = this.events.removeAfter
+
+ e.preventDefault()
+ this._recomputePosition()
+
+ removeBeforeEvent.detail = { fieldset, parent }
+ removeAfterEvent.detail = { fieldset, parent }
+ parent.dispatchEvent(removeBeforeEvent)
+ fieldset.remove()
+ return parent.dispatchEvent(removeAfterEvent)
}
_bindEvents (el) {