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) {