diff --git a/assets/js/__tests__/quick-tag.spec.ts b/assets/js/__tests__/quick-tag.spec.ts new file mode 100644 index 00000000..6b5d1ecd --- /dev/null +++ b/assets/js/__tests__/quick-tag.spec.ts @@ -0,0 +1,159 @@ +import { $, $$ } from '../utils/dom'; +import { assertNotNull } from '../utils/assert'; +import { setupQuickTag } from '../quick-tag'; +import { fetchMock } from '../../test/fetch-mock.ts'; +import { waitFor } from '@testing-library/dom'; + +const quickTagData = `
+ Tag + + + +
+
+
+
+
+
+
+
+
`; + +describe('Batch tagging', () => { + let tagButton: HTMLAnchorElement; + let abortButton: HTMLAnchorElement; + let submitButton: HTMLAnchorElement; + let toggleAllButton: HTMLAnchorElement; + let mediaBoxes: HTMLDivElement[]; + + beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = quickTagData; + + tagButton = assertNotNull($('.js-quick-tag')); + abortButton = assertNotNull($('.js-quick-tag--abort')); + submitButton = assertNotNull($('.js-quick-tag--submit')); + toggleAllButton = assertNotNull($('.js-quick-tag--all')); + mediaBoxes = $$('.media-box'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should prompt the user on click', () => { + const spy = vi.spyOn(window, 'prompt').mockImplementation(() => 'a'); + tagButton.click(); + + expect(spy).toHaveBeenCalledOnce(); + expect(tagButton.classList).toContain('hidden'); + expect(abortButton.classList).not.toContain('hidden'); + expect(submitButton.classList).not.toContain('hidden'); + expect(toggleAllButton.classList).not.toContain('hidden'); + }); + + it('should not modify media boxes before entry', () => { + mediaBoxes[0].click(); + expect(mediaBoxes[0].firstElementChild).not.toHaveClass('media-box__header--selected'); + }); + + it('should restore the list of tagged images on reload', () => { + // TODO: this is less than ideal, because it depends on the internal + // implementation of the quick-tag file. But we can't reload the page + // with jsdom. + localStorage.setItem('quickTagQueue', JSON.stringify(['0', '1'])); + localStorage.setItem('quickTagName', JSON.stringify('a')); + + setupQuickTag(); + expect(mediaBoxes[0].firstElementChild).toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).toHaveClass('media-box__header--selected'); + }); + + describe('after entry', () => { + beforeEach(() => { + vi.spyOn(window, 'prompt').mockImplementation(() => 'a'); + tagButton.click(); + }); + + it('should abort the tagging process if accepted', () => { + const spy = vi.spyOn(window, 'confirm').mockImplementation(() => true); + abortButton.click(); + + expect(spy).toHaveBeenCalledOnce(); + expect(tagButton.classList).not.toContain('hidden'); + expect(abortButton.classList).toContain('hidden'); + expect(submitButton.classList).toContain('hidden'); + expect(toggleAllButton.classList).toContain('hidden'); + }); + + it('should not abort the tagging process if rejected', () => { + const spy = vi.spyOn(window, 'confirm').mockImplementation(() => false); + abortButton.click(); + + expect(spy).toHaveBeenCalledOnce(); + expect(tagButton.classList).toContain('hidden'); + expect(abortButton.classList).not.toContain('hidden'); + expect(submitButton.classList).not.toContain('hidden'); + expect(toggleAllButton.classList).not.toContain('hidden'); + }); + + it('should toggle media box state on click', () => { + mediaBoxes[0].click(); + expect(mediaBoxes[0].firstElementChild).toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).not.toHaveClass('media-box__header--selected'); + }); + + it('should toggle all media box states', () => { + mediaBoxes[0].click(); + toggleAllButton.click(); + expect(mediaBoxes[0].firstElementChild).not.toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).toHaveClass('media-box__header--selected'); + }); + }); + + describe('for submission', () => { + beforeAll(() => { + fetchMock.enableMocks(); + }); + + afterAll(() => { + fetchMock.disableMocks(); + }); + + beforeEach(() => { + vi.spyOn(window, 'prompt').mockImplementation(() => 'a'); + tagButton.click(); + + fetchMock.resetMocks(); + mediaBoxes[0].click(); + mediaBoxes[1].click(); + }); + + it('should return to normal state on successful submission', () => { + fetchMock.mockResponse('{"failed":[]}'); + submitButton.click(); + + expect(fetch).toHaveBeenCalledOnce(); + + return waitFor(() => { + expect(mediaBoxes[0].firstElementChild).not.toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).not.toHaveClass('media-box__header--selected'); + }); + }); + + it('should show error on failed submission', () => { + fetchMock.mockResponse('{"failed":[0,1]}'); + submitButton.click(); + + const spy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + expect(fetch).toHaveBeenCalledOnce(); + + return waitFor(() => { + expect(spy).toHaveBeenCalledOnce(); + expect(mediaBoxes[0].firstElementChild).not.toHaveClass('media-box__header--selected'); + expect(mediaBoxes[1].firstElementChild).not.toHaveClass('media-box__header--selected'); + }); + }); + }); +}); diff --git a/assets/js/quick-tag.js b/assets/js/quick-tag.js deleted file mode 100644 index 4457784a..00000000 --- a/assets/js/quick-tag.js +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Quick Tag - */ - -import store from './utils/store'; -import { $, $$, toggleEl, onLeftClick } from './utils/dom'; -import { fetchJson, handleError } from './utils/requests'; - -const imageQueueStorage = 'quickTagQueue'; -const currentTagStorage = 'quickTagName'; - -function currentQueue() { - return store.get(imageQueueStorage) || []; -} - -function currentTags() { - return store.get(currentTagStorage) || ''; -} - -function getTagButton() { - return $('.js-quick-tag'); -} - -function setTagButton(text) { - $('.js-quick-tag--submit span').textContent = text; -} - -function toggleActiveState() { - toggleEl($('.js-quick-tag'), $('.js-quick-tag--abort'), $('.js-quick-tag--all'), $('.js-quick-tag--submit')); - - setTagButton(`Submit (${currentTags()})`); - - $$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected')); - $$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected')); - currentQueue().forEach(id => - $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')), - ); -} - -function activate() { - store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:')); - - if (currentTags()) toggleActiveState(); -} - -function reset() { - store.remove(currentTagStorage); - store.remove(imageQueueStorage); - - toggleActiveState(); -} - -function promptReset() { - if (window.confirm('Are you sure you want to abort batch tagging?')) { - reset(); - } -} - -function submit() { - setTagButton(`Wait... (${currentTags()})`); - - fetchJson('PUT', '/admin/batch/tags', { - tags: currentTags(), - image_ids: currentQueue(), - }) - .then(handleError) - .then(r => r.json()) - .then(data => { - if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`); - - reset(); - }); -} - -function modifyImageQueue(mediaBox) { - if (currentTags()) { - const imageId = mediaBox.dataset.imageId; - const queue = currentQueue(); - const isSelected = queue.includes(imageId); - - isSelected ? queue.splice(queue.indexOf(imageId), 1) : queue.push(imageId); - - $$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el => - el.classList.toggle('media-box__header--selected'), - ); - - store.set(imageQueueStorage, queue); - } -} - -function toggleAllImages() { - $$('#imagelist-container .media-box').forEach(modifyImageQueue); -} - -function clickHandler(event) { - const targets = { - '.js-quick-tag': activate, - '.js-quick-tag--abort': promptReset, - '.js-quick-tag--submit': submit, - '.js-quick-tag--all': toggleAllImages, - '.media-box': modifyImageQueue, - }; - - for (const target in targets) { - if (event.target && event.target.closest(target)) { - targets[target](event.target.closest(target)); - currentTags() && event.preventDefault(); - } - } -} - -function setupQuickTag() { - if (getTagButton() && currentTags()) toggleActiveState(); - if (getTagButton()) onLeftClick(clickHandler); -} - -export { setupQuickTag }; diff --git a/assets/js/quick-tag.ts b/assets/js/quick-tag.ts new file mode 100644 index 00000000..3d462cb2 --- /dev/null +++ b/assets/js/quick-tag.ts @@ -0,0 +1,124 @@ +/** + * Quick Tag + */ + +import store from './utils/store'; +import { assertNotNull, assertNotUndefined } from './utils/assert'; +import { $, $$, toggleEl } from './utils/dom'; +import { fetchJson, handleError } from './utils/requests'; +import { delegate, leftClick } from './utils/events'; + +const imageQueueStorage = 'quickTagQueue'; +const currentTagStorage = 'quickTagName'; + +function currentQueue(): string[] { + return store.get(imageQueueStorage) || []; +} + +function currentTags(): string { + return store.get(currentTagStorage) || ''; +} + +function setTagButton(text: string) { + assertNotNull($('.js-quick-tag--submit span')).textContent = text; +} + +function toggleActiveState() { + toggleEl($$('.js-quick-tag,.js-quick-tag--abort,.js-quick-tag--all,.js-quick-tag--submit')); + + setTagButton(`Submit (${currentTags()})`); + + $$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected')); + $$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected')); + + currentQueue().forEach(id => + $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')), + ); +} + +function activate(event: Event) { + event.preventDefault(); + + store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:')); + + if (currentTags()) { + toggleActiveState(); + } +} + +function reset() { + store.remove(currentTagStorage); + store.remove(imageQueueStorage); + + toggleActiveState(); +} + +function promptReset(event: Event) { + event.preventDefault(); + + if (window.confirm('Are you sure you want to abort batch tagging?')) { + reset(); + } +} + +function submit(event: Event) { + event.preventDefault(); + + setTagButton(`Wait... (${currentTags()})`); + + fetchJson('PUT', '/admin/batch/tags', { + tags: currentTags(), + image_ids: currentQueue(), + }) + .then(handleError) + .then(r => r.json()) + .then(data => { + if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`); + + reset(); + }); +} + +function modifyImageQueue(event: Event, mediaBox: HTMLDivElement) { + if (!currentTags()) { + return; + } + + const imageId = assertNotUndefined(mediaBox.dataset.imageId); + const queue = currentQueue(); + const isSelected = queue.includes(imageId); + + if (isSelected) { + queue.splice(queue.indexOf(imageId), 1); + } else { + queue.push(imageId); + } + + for (const boxHeader of $$(`.media-box__header[data-image-id="${imageId}"]`)) { + boxHeader.classList.toggle('media-box__header--selected'); + } + + store.set(imageQueueStorage, queue); + event.preventDefault(); +} + +function toggleAllImages(event: Event, _target: Element) { + for (const mediaBox of $$('#imagelist-container .media-box')) { + modifyImageQueue(event, mediaBox); + } +} + +delegate(document, 'click', { + '.js-quick-tag': leftClick(activate), + '.js-quick-tag--abort': leftClick(promptReset), + '.js-quick-tag--submit': leftClick(submit), + '.js-quick-tag--all': leftClick(toggleAllImages), + '.media-box': leftClick(modifyImageQueue), +}); + +export function setupQuickTag() { + const tagButton = $('.js-quick-tag'); + if (tagButton && currentTags()) { + toggleActiveState(); + } +}