mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-30 14:57:59 +01:00
Merge pull request #347 from wrenny-ko/client-tag-validation
Client-side tag input validation on image upload submit
This commit is contained in:
commit
42138a219b
3 changed files with 150 additions and 2 deletions
|
@ -25,6 +25,9 @@ const errorResponse = {
|
||||||
};
|
};
|
||||||
/* eslint-enable camelcase */
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
|
const tagSets = ['', 'a tag', 'safe', 'one, two, three', 'safe, explicit', 'safe, explicit, three', 'safe, two, three'];
|
||||||
|
const tagErrorCounts = [1, 2, 1, 1, 2, 1, 0];
|
||||||
|
|
||||||
describe('Image upload form', () => {
|
describe('Image upload form', () => {
|
||||||
let mockPng: File;
|
let mockPng: File;
|
||||||
let mockWebm: File;
|
let mockWebm: File;
|
||||||
|
@ -58,13 +61,23 @@ describe('Image upload form', () => {
|
||||||
let scraperError: HTMLDivElement;
|
let scraperError: HTMLDivElement;
|
||||||
let fetchButton: HTMLButtonElement;
|
let fetchButton: HTMLButtonElement;
|
||||||
let tagsEl: HTMLTextAreaElement;
|
let tagsEl: HTMLTextAreaElement;
|
||||||
|
let taginputEl: HTMLDivElement;
|
||||||
let sourceEl: HTMLInputElement;
|
let sourceEl: HTMLInputElement;
|
||||||
let descrEl: HTMLTextAreaElement;
|
let descrEl: HTMLTextAreaElement;
|
||||||
|
let submitButton: HTMLButtonElement;
|
||||||
|
|
||||||
const assertFetchButtonIsDisabled = () => {
|
const assertFetchButtonIsDisabled = () => {
|
||||||
if (!fetchButton.hasAttribute('disabled')) throw new Error('fetchButton is not disabled');
|
if (!fetchButton.hasAttribute('disabled')) throw new Error('fetchButton is not disabled');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assertSubmitButtonIsDisabled = () => {
|
||||||
|
if (!submitButton.hasAttribute('disabled')) throw new Error('submitButton is not disabled');
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertSubmitButtonIsEnabled = () => {
|
||||||
|
if (submitButton.hasAttribute('disabled')) throw new Error('submitButton is disabled');
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.documentElement.insertAdjacentHTML(
|
document.documentElement.insertAdjacentHTML(
|
||||||
'beforeend',
|
'beforeend',
|
||||||
|
@ -77,7 +90,12 @@ describe('Image upload form', () => {
|
||||||
|
|
||||||
<input id="image_sources_0_source" name="image[sources][0][source]" type="text" class="js-source-url" />
|
<input id="image_sources_0_source" name="image[sources][0][source]" type="text" class="js-source-url" />
|
||||||
<textarea id="image_tag_input" name="image[tag_input]" class="js-image-tags-input"></textarea>
|
<textarea id="image_tag_input" name="image[tag_input]" class="js-image-tags-input"></textarea>
|
||||||
|
<div class="js-taginput"></div>
|
||||||
|
<button id="tagsinput-save" type="button" class="button">Save</button>
|
||||||
<textarea id="image_description" name="image[description]" class="js-image-descr-input"></textarea>
|
<textarea id="image_description" name="image[description]" class="js-image-descr-input"></textarea>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button input--separate-top" type="submit">Upload</button>
|
||||||
|
</div>
|
||||||
</form>`,
|
</form>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -87,9 +105,11 @@ describe('Image upload form', () => {
|
||||||
remoteUrl = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[1]);
|
remoteUrl = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[1]);
|
||||||
scraperError = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[2]);
|
scraperError = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[2]);
|
||||||
tagsEl = assertNotNull($<HTMLTextAreaElement>('.js-image-tags-input'));
|
tagsEl = assertNotNull($<HTMLTextAreaElement>('.js-image-tags-input'));
|
||||||
|
taginputEl = assertNotNull($<HTMLDivElement>('.js-taginput'));
|
||||||
sourceEl = assertNotNull($<HTMLInputElement>('.js-source-url'));
|
sourceEl = assertNotNull($<HTMLInputElement>('.js-source-url'));
|
||||||
descrEl = assertNotNull($<HTMLTextAreaElement>('.js-image-descr-input'));
|
descrEl = assertNotNull($<HTMLTextAreaElement>('.js-image-descr-input'));
|
||||||
fetchButton = assertNotNull($<HTMLButtonElement>('#js-scraper-preview'));
|
fetchButton = assertNotNull($<HTMLButtonElement>('#js-scraper-preview'));
|
||||||
|
submitButton = assertNotNull($<HTMLButtonElement>('.actions > .button'));
|
||||||
|
|
||||||
setupImageUpload();
|
setupImageUpload();
|
||||||
fetchMock.resetMocks();
|
fetchMock.resetMocks();
|
||||||
|
@ -193,4 +213,42 @@ describe('Image upload form', () => {
|
||||||
expect(scraperError.innerText).toEqual('Error 1 Error 2');
|
expect(scraperError.innerText).toEqual('Error 1 Error 2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function submitForm(frm): Promise<boolean> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
function onSubmit() {
|
||||||
|
frm.removeEventListener('submit', onSubmit);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
frm.addEventListener('submit', onSubmit);
|
||||||
|
|
||||||
|
if (!fireEvent.submit(frm)) {
|
||||||
|
frm.removeEventListener('submit', onSubmit);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should prevent form submission if tag checks fail', async () => {
|
||||||
|
for (let i = 0; i < tagSets.length; i += 1) {
|
||||||
|
taginputEl.value = tagSets[i];
|
||||||
|
|
||||||
|
if (await submitForm(form)) {
|
||||||
|
// form submit succeeded
|
||||||
|
await waitFor(() => {
|
||||||
|
assertSubmitButtonIsDisabled();
|
||||||
|
const succeededUnloadEvent = new Event('beforeunload', { cancelable: true });
|
||||||
|
expect(fireEvent(window, succeededUnloadEvent)).toBe(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// form submit prevented
|
||||||
|
frm = form;
|
||||||
|
await waitFor(() => {
|
||||||
|
assertSubmitButtonIsEnabled();
|
||||||
|
expect(frm.querySelectorAll('.help-block')).toHaveLength(tagErrorCounts[i]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
* Fetch and display preview images for various image upload forms.
|
* Fetch and display preview images for various image upload forms.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { assertNotNull } from './utils/assert';
|
||||||
import { fetchJson, handleError } from './utils/requests';
|
import { fetchJson, handleError } from './utils/requests';
|
||||||
import { $, $$, clearEl, hideEl, makeEl, showEl } from './utils/dom';
|
import { $, $$, clearEl, hideEl, makeEl, showEl } from './utils/dom';
|
||||||
import { addTag } from './tagsinput';
|
import { addTag } from './tagsinput';
|
||||||
|
@ -171,9 +172,98 @@ function setupImageUpload() {
|
||||||
window.removeEventListener('beforeunload', beforeUnload);
|
window.removeEventListener('beforeunload', beforeUnload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTagError(message) {
|
||||||
|
const buttonAfter = $('#tagsinput-save');
|
||||||
|
const errorElement = makeEl('span', { className: 'help-block tag-error', innerText: message });
|
||||||
|
|
||||||
|
buttonAfter.insertAdjacentElement('beforebegin', errorElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTagErrors() {
|
||||||
|
$$('.tag-error').forEach(el => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratingsTags = ['safe', 'suggestive', 'questionable', 'explicit', 'semi-grimdark', 'grimdark', 'grotesque'];
|
||||||
|
|
||||||
|
// populate tag error helper bars as necessary
|
||||||
|
// return true if all checks pass
|
||||||
|
// return false if any check fails
|
||||||
|
function validateTags() {
|
||||||
|
const tagInput = $('textarea.js-taginput');
|
||||||
|
|
||||||
|
if (!tagInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsArr = tagInput.value.split(',').map(t => t.trim());
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
let hasRating = false;
|
||||||
|
let hasSafe = false;
|
||||||
|
let hasOtherRating = false;
|
||||||
|
|
||||||
|
tagsArr.forEach(tag => {
|
||||||
|
if (ratingsTags.includes(tag)) {
|
||||||
|
hasRating = true;
|
||||||
|
if (tag === 'safe') {
|
||||||
|
hasSafe = true;
|
||||||
|
} else {
|
||||||
|
hasOtherRating = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasRating) {
|
||||||
|
errors.push('Tag input must contain at least one rating tag');
|
||||||
|
} else if (hasSafe && hasOtherRating) {
|
||||||
|
errors.push('Tag input may not contain any other rating if safe');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagsArr.length < 3) {
|
||||||
|
errors.push('Tag input must contain at least 3 tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.forEach(msg => createTagError(msg));
|
||||||
|
|
||||||
|
return errors.length === 0; // true: valid if no errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableUploadButton() {
|
||||||
|
const submitButton = $('.button.input--separate-top');
|
||||||
|
if (submitButton !== null) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.innerText = 'Please wait...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// delay is needed because Safari stops the submit if the button is immediately disabled
|
||||||
|
requestAnimationFrame(() => submitButton.setAttribute('disabled', 'disabled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitHandler(event) {
|
||||||
|
// Remove any existing tag error elements
|
||||||
|
clearTagErrors();
|
||||||
|
|
||||||
|
if (validateTags()) {
|
||||||
|
// Disable navigation check
|
||||||
|
unregisterBeforeUnload();
|
||||||
|
|
||||||
|
// Prevent duplicate attempts to submit the form
|
||||||
|
disableUploadButton();
|
||||||
|
|
||||||
|
// Let the form submission complete
|
||||||
|
} else {
|
||||||
|
// Scroll to view validation errors
|
||||||
|
assertNotNull($('.fancy-tag-upload')).scrollIntoView();
|
||||||
|
|
||||||
|
// Prevent the form from being submitted
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fileField.addEventListener('change', registerBeforeUnload);
|
fileField.addEventListener('change', registerBeforeUnload);
|
||||||
fetchButton.addEventListener('click', registerBeforeUnload);
|
fetchButton.addEventListener('click', registerBeforeUnload);
|
||||||
form.addEventListener('submit', unregisterBeforeUnload);
|
form.addEventListener('submit', submitHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { setupImageUpload };
|
export { setupImageUpload };
|
||||||
|
|
|
@ -88,4 +88,4 @@
|
||||||
= render PhilomenaWeb.CaptchaView, "_captcha.html", name: "image", conn: @conn
|
= render PhilomenaWeb.CaptchaView, "_captcha.html", name: "image", conn: @conn
|
||||||
|
|
||||||
.actions
|
.actions
|
||||||
= submit "Upload", class: "button input--separate-top", autocomplete: "off", data: [disable_with: "Please wait..."]
|
= submit "Upload", class: "button input--separate-top", autocomplete: "off"
|
||||||
|
|
Loading…
Reference in a new issue