Convert quicktag to TypeScript

This commit is contained in:
Liam 2024-11-11 13:43:06 -05:00
parent 81493f72be
commit c06033aa10
3 changed files with 283 additions and 117 deletions

View file

@ -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 = `<div>
<a class="js-quick-tag">Tag</a>
<a class="js-quick-tag--abort hidden"><span>Abort tagging</span></a>
<a class="js-quick-tag--submit hidden"><span>Submit</span></a>
<a class="js-quick-tag--all hidden"><span>Toggle all</span></a>
<div id="imagelist-container">
<div class="media-box" data-image-id="0">
<div class="media-box__header" data-image-id="0"></div>
</div>
<div class="media-box" data-image-id="1">
<div class="media-box__header" data-image-id="1"></div>
</div>
</div>
</div>`;
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($<HTMLAnchorElement>('.js-quick-tag'));
abortButton = assertNotNull($<HTMLAnchorElement>('.js-quick-tag--abort'));
submitButton = assertNotNull($<HTMLAnchorElement>('.js-quick-tag--submit'));
toggleAllButton = assertNotNull($<HTMLAnchorElement>('.js-quick-tag--all'));
mediaBoxes = $$<HTMLDivElement>('.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');
});
});
});
});

View file

@ -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 };

124
assets/js/quick-tag.ts Normal file
View file

@ -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<string[]>(imageQueueStorage) || [];
}
function currentTags(): string {
return store.get<string>(currentTagStorage) || '';
}
function setTagButton(text: string) {
assertNotNull($('.js-quick-tag--submit span')).textContent = text;
}
function toggleActiveState() {
toggleEl($$<HTMLElement>('.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 $$<HTMLDivElement>('#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 = $<HTMLAnchorElement>('.js-quick-tag');
if (tagButton && currentTags()) {
toggleActiveState();
}
}