mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 20:18:00 +01:00
Merge pull request #369 from philomena-dev/quick-tag-ts
Convert quicktag to TypeScript
This commit is contained in:
commit
41c10d59cb
3 changed files with 283 additions and 117 deletions
159
assets/js/__tests__/quick-tag.spec.ts
Normal file
159
assets/js/__tests__/quick-tag.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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
124
assets/js/quick-tag.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue