Merge pull request #347 from wrenny-ko/client-tag-validation

Client-side tag input validation on image upload submit
This commit is contained in:
liamwhite 2024-08-29 18:41:19 -04:00 committed by GitHub
commit 42138a219b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 150 additions and 2 deletions

View file

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

View file

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

View file

@ -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"