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 = `
`;
+
+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();
+ }
+}