mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 20:18:00 +01:00
Merge pull request #367 from philomena-dev/tagsinput-ts
Convert tagsinput to TypeScript
This commit is contained in:
commit
81493f72be
5 changed files with 253 additions and 29 deletions
188
assets/js/__tests__/tagsinput.spec.ts
Normal file
188
assets/js/__tests__/tagsinput.spec.ts
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
import { $, $$, hideEl } from '../utils/dom';
|
||||||
|
import { assertNotNull } from '../utils/assert';
|
||||||
|
import { TermSuggestion } from '../utils/suggestions';
|
||||||
|
import { setupTagsInput, addTag, reloadTagsInput } from '../tagsinput';
|
||||||
|
|
||||||
|
const formData = `<form class="tags-form">
|
||||||
|
<div class="js-tag-block fancy-tag-upload">
|
||||||
|
<textarea class="js-taginput js-taginput-plain"></textarea>
|
||||||
|
<div class="js-taginput js-taginput-fancy">
|
||||||
|
<input type="text" class="js-taginput-input" placeholder="add a tag">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="js-taginput-show">Fancy Editor</button>
|
||||||
|
<button class="js-taginput-hide hidden">Plain Editor</button>
|
||||||
|
<input type="submit" value="Save Tags">
|
||||||
|
</form>`;
|
||||||
|
|
||||||
|
describe('Fancy tags input', () => {
|
||||||
|
let form: HTMLFormElement;
|
||||||
|
let tagBlock: HTMLDivElement;
|
||||||
|
let plainInput: HTMLTextAreaElement;
|
||||||
|
let fancyInput: HTMLDivElement;
|
||||||
|
let fancyText: HTMLInputElement;
|
||||||
|
let fancyShowButton: HTMLButtonElement;
|
||||||
|
let plainShowButton: HTMLButtonElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.booru.fancyTagUpload = true;
|
||||||
|
window.booru.fancyTagEdit = true;
|
||||||
|
document.body.innerHTML = formData;
|
||||||
|
|
||||||
|
form = assertNotNull($<HTMLFormElement>('.tags-form'));
|
||||||
|
tagBlock = assertNotNull($<HTMLDivElement>('.js-tag-block'));
|
||||||
|
plainInput = assertNotNull($<HTMLTextAreaElement>('.js-taginput-plain'));
|
||||||
|
fancyInput = assertNotNull($<HTMLDivElement>('.js-taginput-fancy'));
|
||||||
|
fancyText = assertNotNull($<HTMLInputElement>('.js-taginput-input'));
|
||||||
|
fancyShowButton = assertNotNull($<HTMLButtonElement>('.js-taginput-show'));
|
||||||
|
plainShowButton = assertNotNull($<HTMLButtonElement>('.js-taginput-hide'));
|
||||||
|
|
||||||
|
// prevent these from submitting the form
|
||||||
|
fancyShowButton.addEventListener('click', e => e.preventDefault());
|
||||||
|
plainShowButton.addEventListener('click', e => e.preventDefault());
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const type = (i & 2) === 0 ? 'upload' : 'edit';
|
||||||
|
const name = (i & 2) === 0 ? 'fancyTagUpload' : 'fancyTagEdit';
|
||||||
|
const value = (i & 1) === 0;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-loop-func
|
||||||
|
it(`should imply ${name}:${value} <-> ${type}:${value} on setup`, () => {
|
||||||
|
window.booru.fancyTagEdit = false;
|
||||||
|
window.booru.fancyTagUpload = false;
|
||||||
|
window.booru[name] = value;
|
||||||
|
|
||||||
|
plainInput.value = 'a, b';
|
||||||
|
tagBlock.classList.remove('fancy-tag-edit', 'fancy-tag-upload');
|
||||||
|
tagBlock.classList.add(`fancy-tag-${type}`);
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(0);
|
||||||
|
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(value ? 2 : 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should move tags from the plain to the fancy editor when the fancy editor is shown', () => {
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(0);
|
||||||
|
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
plainInput.value = 'a, b';
|
||||||
|
fancyShowButton.click();
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move tags from the plain to the fancy editor on reload event', () => {
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(0);
|
||||||
|
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
plainInput.value = 'a, b';
|
||||||
|
reloadTagsInput(plainInput);
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond to addtag events', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
addTag(plainInput, 'a');
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not respond to addtag events if the container is hidden', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
hideEl(fancyInput);
|
||||||
|
addTag(plainInput, 'a');
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond to autocomplete events', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
fancyText.dispatchEvent(new CustomEvent<TermSuggestion>('autocomplete', { detail: { value: 'a', label: 'a' } }));
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow removing previously added tags by clicking them', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
addTag(plainInput, 'a');
|
||||||
|
assertNotNull($<HTMLAnchorElement>('span.tag a', fancyInput)).click();
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow removing previously added tags by adding one with a minus sign prepended', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
addTag(plainInput, 'a');
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||||
|
addTag(plainInput, '-a');
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disallow adding empty tags', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
addTag(plainInput, '');
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disallow adding existing tags', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
addTag(plainInput, 'a');
|
||||||
|
addTag(plainInput, 'a');
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should submit the form on ctrl+enter', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
|
||||||
|
const ev = new KeyboardEvent('keydown', { keyCode: 13, ctrlKey: true, bubbles: true });
|
||||||
|
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
form.addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
fancyText.dispatchEvent(ev);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when backspacing on empty input and there are no tags', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
|
||||||
|
const ev = new KeyboardEvent('keydown', { keyCode: 8, bubbles: true });
|
||||||
|
fancyText.dispatchEvent(ev);
|
||||||
|
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erases the last added tag when backspacing on empty input', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
addTag(plainInput, 'a');
|
||||||
|
addTag(plainInput, 'b');
|
||||||
|
|
||||||
|
const ev = new KeyboardEvent('keydown', { keyCode: 8, bubbles: true });
|
||||||
|
fancyText.dispatchEvent(ev);
|
||||||
|
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds new tag when comma is pressed', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
|
||||||
|
const ev = new KeyboardEvent('keydown', { keyCode: 188, bubbles: true });
|
||||||
|
fancyText.value = 'a';
|
||||||
|
fancyText.dispatchEvent(ev);
|
||||||
|
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||||
|
expect(fancyText.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds new tag when enter is pressed', () => {
|
||||||
|
setupTagsInput(tagBlock);
|
||||||
|
|
||||||
|
const ev = new KeyboardEvent('keydown', { keyCode: 13, bubbles: true });
|
||||||
|
fancyText.value = 'a';
|
||||||
|
fancyText.dispatchEvent(ev);
|
||||||
|
|
||||||
|
expect($$('span.tag', fancyInput)).toHaveLength(1);
|
||||||
|
expect(fancyText.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,14 +2,20 @@
|
||||||
* Fancy tag editor.
|
* Fancy tag editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { assertNotNull, assertType } from './utils/assert';
|
||||||
import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom';
|
import { $, $$, clearEl, removeEl, showEl, hideEl, escapeCss, escapeHtml } from './utils/dom';
|
||||||
|
import { TermSuggestion } from './utils/suggestions';
|
||||||
|
|
||||||
function setupTagsInput(tagBlock) {
|
export function setupTagsInput(tagBlock: HTMLDivElement) {
|
||||||
const [textarea, container] = $$('.js-taginput', tagBlock);
|
const form = assertNotNull(tagBlock.closest('form'));
|
||||||
const setup = $('.js-tag-block ~ button', tagBlock.parentNode);
|
const textarea = assertNotNull($<HTMLTextAreaElement>('.js-taginput-plain', tagBlock));
|
||||||
const inputField = $('input', container);
|
const container = assertNotNull($<HTMLDivElement>('.js-taginput-fancy'));
|
||||||
|
const parentField = assertNotNull(tagBlock.parentElement);
|
||||||
|
const setup = assertNotNull($<HTMLButtonElement>('.js-tag-block ~ button', parentField));
|
||||||
|
const inputField = assertNotNull($<HTMLInputElement>('input', container));
|
||||||
|
const submitButton = assertNotNull($<HTMLInputElement | HTMLButtonElement>('[type="submit"]', form));
|
||||||
|
|
||||||
let tags = [];
|
let tags: string[] = [];
|
||||||
|
|
||||||
// Load in the current tag set from the textarea
|
// Load in the current tag set from the textarea
|
||||||
setup.addEventListener('click', importTags);
|
setup.addEventListener('click', importTags);
|
||||||
|
@ -27,7 +33,7 @@ function setupTagsInput(tagBlock) {
|
||||||
inputField.addEventListener('keydown', handleKeyEvent);
|
inputField.addEventListener('keydown', handleKeyEvent);
|
||||||
|
|
||||||
// Respond to autocomplete form clicks
|
// Respond to autocomplete form clicks
|
||||||
inputField.addEventListener('autocomplete', handleAutocomplete);
|
inputField.addEventListener('autocomplete', handleAutocomplete as EventListener);
|
||||||
|
|
||||||
// Respond to Ctrl+Enter shortcut
|
// Respond to Ctrl+Enter shortcut
|
||||||
tagBlock.addEventListener('keydown', handleCtrlEnter);
|
tagBlock.addEventListener('keydown', handleCtrlEnter);
|
||||||
|
@ -35,19 +41,19 @@ function setupTagsInput(tagBlock) {
|
||||||
// TODO: Cleanup this bug fix
|
// TODO: Cleanup this bug fix
|
||||||
// Switch to fancy tagging if user settings want it
|
// Switch to fancy tagging if user settings want it
|
||||||
if (fancyEditorRequested(tagBlock)) {
|
if (fancyEditorRequested(tagBlock)) {
|
||||||
showEl($$('.js-taginput-fancy'));
|
showEl($$<HTMLElement>('.js-taginput-fancy'));
|
||||||
showEl($$('.js-taginput-hide'));
|
showEl($$<HTMLElement>('.js-taginput-hide'));
|
||||||
hideEl($$('.js-taginput-plain'));
|
hideEl($$<HTMLElement>('.js-taginput-plain'));
|
||||||
hideEl($$('.js-taginput-show'));
|
hideEl($$<HTMLElement>('.js-taginput-show'));
|
||||||
importTags();
|
importTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAutocomplete(event) {
|
function handleAutocomplete(event: CustomEvent<TermSuggestion>) {
|
||||||
insertTag(event.detail.value);
|
insertTag(event.detail.value);
|
||||||
inputField.focus();
|
inputField.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddTag(event) {
|
function handleAddTag(event: AddtagEvent) {
|
||||||
// Ignore if not in tag edit mode
|
// Ignore if not in tag edit mode
|
||||||
if (container.classList.contains('hidden')) return;
|
if (container.classList.contains('hidden')) return;
|
||||||
|
|
||||||
|
@ -55,14 +61,16 @@ function setupTagsInput(tagBlock) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTagClear(event) {
|
function handleTagClear(event: Event) {
|
||||||
if (event.target.dataset.tagName) {
|
const target = assertType(event.target, HTMLElement);
|
||||||
|
|
||||||
|
if (target.dataset.tagName) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
removeTag(event.target.dataset.tagName, event.target.parentNode);
|
removeTag(target.dataset.tagName, assertNotNull(target.parentElement));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyEvent(event) {
|
function handleKeyEvent(event: KeyboardEvent) {
|
||||||
const { keyCode, ctrlKey, shiftKey } = event;
|
const { keyCode, ctrlKey, shiftKey } = event;
|
||||||
|
|
||||||
// allow form submission with ctrl+enter if no text was typed
|
// allow form submission with ctrl+enter if no text was typed
|
||||||
|
@ -73,7 +81,7 @@ function setupTagsInput(tagBlock) {
|
||||||
// backspace on a blank input field
|
// backspace on a blank input field
|
||||||
if (keyCode === 8 && inputField.value === '') {
|
if (keyCode === 8 && inputField.value === '') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const erased = $('.tag:last-of-type', container);
|
const erased = $<HTMLElement>('.tag:last-of-type', container);
|
||||||
|
|
||||||
if (erased) removeTag(tags[tags.length - 1], erased);
|
if (erased) removeTag(tags[tags.length - 1], erased);
|
||||||
}
|
}
|
||||||
|
@ -86,14 +94,14 @@ function setupTagsInput(tagBlock) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCtrlEnter(event) {
|
function handleCtrlEnter(event: KeyboardEvent) {
|
||||||
const { keyCode, ctrlKey } = event;
|
const { keyCode, ctrlKey } = event;
|
||||||
if (keyCode !== 13 || !ctrlKey) return;
|
if (keyCode !== 13 || !ctrlKey) return;
|
||||||
|
|
||||||
$('[type="submit"]', tagBlock.closest('form')).click();
|
submitButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertTag(name) {
|
function insertTag(name: string) {
|
||||||
name = name.trim(); // eslint-disable-line no-param-reassign
|
name = name.trim(); // eslint-disable-line no-param-reassign
|
||||||
|
|
||||||
// Add if not degenerate or already present
|
// Add if not degenerate or already present
|
||||||
|
@ -102,9 +110,9 @@ function setupTagsInput(tagBlock) {
|
||||||
// Remove instead if the tag name starts with a minus
|
// Remove instead if the tag name starts with a minus
|
||||||
if (name[0] === '-') {
|
if (name[0] === '-') {
|
||||||
name = name.slice(1); // eslint-disable-line no-param-reassign
|
name = name.slice(1); // eslint-disable-line no-param-reassign
|
||||||
const tagLink = $(`[data-tag-name="${escapeCss(name)}"]`, container);
|
const tagLink = assertNotNull($(`[data-tag-name="${escapeCss(name)}"]`, container));
|
||||||
|
|
||||||
return removeTag(name, tagLink.parentNode);
|
return removeTag(name, assertNotNull(tagLink.parentElement));
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.push(name);
|
tags.push(name);
|
||||||
|
@ -116,7 +124,7 @@ function setupTagsInput(tagBlock) {
|
||||||
inputField.value = '';
|
inputField.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTag(name, element) {
|
function removeTag(name: string, element: HTMLElement) {
|
||||||
removeEl(element);
|
removeEl(element);
|
||||||
|
|
||||||
// Remove the tag from the list
|
// Remove the tag from the list
|
||||||
|
@ -134,7 +142,7 @@ function setupTagsInput(tagBlock) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fancyEditorRequested(tagBlock) {
|
function fancyEditorRequested(tagBlock: HTMLDivElement) {
|
||||||
// Check whether the user made the fancy editor the default for each type of tag block.
|
// Check whether the user made the fancy editor the default for each type of tag block.
|
||||||
return (
|
return (
|
||||||
(window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload')) ||
|
(window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload')) ||
|
||||||
|
@ -142,19 +150,17 @@ function fancyEditorRequested(tagBlock) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTagListener() {
|
export function setupTagListener() {
|
||||||
document.addEventListener('addtag', event => {
|
document.addEventListener('addtag', event => {
|
||||||
if (event.target.value) event.target.value += ', ';
|
if (event.target.value) event.target.value += ', ';
|
||||||
event.target.value += event.detail.name;
|
event.target.value += event.detail.name;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag(textarea, name) {
|
export function addTag(textarea: HTMLInputElement | HTMLTextAreaElement, name: string) {
|
||||||
textarea.dispatchEvent(new CustomEvent('addtag', { detail: { name }, bubbles: true }));
|
textarea.dispatchEvent(new CustomEvent('addtag', { detail: { name }, bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadTagsInput(textarea) {
|
export function reloadTagsInput(textarea: HTMLInputElement | HTMLTextAreaElement) {
|
||||||
textarea.dispatchEvent(new CustomEvent('reload'));
|
textarea.dispatchEvent(new CustomEvent('reload'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export { setupTagsInput, setupTagListener, addTag, reloadTagsInput };
|
|
|
@ -7,6 +7,8 @@ import { fireEvent } from '@testing-library/dom';
|
||||||
window.booru = {
|
window.booru = {
|
||||||
timeAgo: () => {},
|
timeAgo: () => {},
|
||||||
csrfToken: 'mockCsrfToken',
|
csrfToken: 'mockCsrfToken',
|
||||||
|
fancyTagEdit: true,
|
||||||
|
fancyTagUpload: true,
|
||||||
hiddenTag: '/mock-tagblocked.svg',
|
hiddenTag: '/mock-tagblocked.svg',
|
||||||
hiddenTagList: [],
|
hiddenTagList: [],
|
||||||
hideStaffTools: 'true',
|
hideStaffTools: 'true',
|
||||||
|
|
8
assets/types/booru-object.d.ts
vendored
8
assets/types/booru-object.d.ts
vendored
|
@ -73,6 +73,14 @@ interface BooruObject {
|
||||||
* List of image IDs in the current gallery.
|
* List of image IDs in the current gallery.
|
||||||
*/
|
*/
|
||||||
galleryImages?: number[];
|
galleryImages?: number[];
|
||||||
|
/**
|
||||||
|
* Fancy tag setting for uploading images.
|
||||||
|
*/
|
||||||
|
fancyTagUpload: boolean;
|
||||||
|
/**
|
||||||
|
* Fancy tag setting for editing images.
|
||||||
|
*/
|
||||||
|
fancyTagEdit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
20
assets/types/tags.ts
Normal file
20
assets/types/tags.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Addtag {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddtagEvent extends CustomEvent<Addtag> {
|
||||||
|
target: HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReloadEvent extends CustomEvent {
|
||||||
|
target: HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalEventHandlersEventMap {
|
||||||
|
addtag: AddtagEvent;
|
||||||
|
reload: ReloadEvent;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue