Skip to content

Commit

Permalink
Merge pull request #347 from wrenny-ko/client-tag-validation
Browse files Browse the repository at this point in the history
Client-side tag input validation on image upload submit
  • Loading branch information
liamwhite authored Aug 29, 2024
2 parents c83b9f1 + 3430786 commit 42138a2
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 2 deletions.
58 changes: 58 additions & 0 deletions assets/js/__tests__/upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const errorResponse = {
};
/* 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', () => {
let mockPng: File;
let mockWebm: File;
Expand Down Expand Up @@ -58,13 +61,23 @@ describe('Image upload form', () => {
let scraperError: HTMLDivElement;
let fetchButton: HTMLButtonElement;
let tagsEl: HTMLTextAreaElement;
let taginputEl: HTMLDivElement;
let sourceEl: HTMLInputElement;
let descrEl: HTMLTextAreaElement;
let submitButton: HTMLButtonElement;

const assertFetchButtonIsDisabled = () => {
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(() => {
document.documentElement.insertAdjacentHTML(
'beforeend',
Expand All @@ -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" />
<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>
<div class="actions">
<button class="button input--separate-top" type="submit">Upload</button>
</div>
</form>`,
);

Expand All @@ -87,9 +105,11 @@ describe('Image upload form', () => {
remoteUrl = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[1]);
scraperError = assertNotUndefined($$<HTMLInputElement>('.js-scraper')[2]);
tagsEl = assertNotNull($<HTMLTextAreaElement>('.js-image-tags-input'));
taginputEl = assertNotNull($<HTMLDivElement>('.js-taginput'));
sourceEl = assertNotNull($<HTMLInputElement>('.js-source-url'));
descrEl = assertNotNull($<HTMLTextAreaElement>('.js-image-descr-input'));
fetchButton = assertNotNull($<HTMLButtonElement>('#js-scraper-preview'));
submitButton = assertNotNull($<HTMLButtonElement>('.actions > .button'));

setupImageUpload();
fetchMock.resetMocks();
Expand Down Expand Up @@ -193,4 +213,42 @@ describe('Image upload form', () => {
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]);
});
}
}
});
});
92 changes: 91 additions & 1 deletion assets/js/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Fetch and display preview images for various image upload forms.
*/

import { assertNotNull } from './utils/assert';
import { fetchJson, handleError } from './utils/requests';
import { $, $$, clearEl, hideEl, makeEl, showEl } from './utils/dom';
import { addTag } from './tagsinput';
Expand Down Expand Up @@ -171,9 +172,98 @@ function setupImageUpload() {
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);
fetchButton.addEventListener('click', registerBeforeUnload);
form.addEventListener('submit', unregisterBeforeUnload);
form.addEventListener('submit', submitHandler);
}

export { setupImageUpload };
2 changes: 1 addition & 1 deletion lib/philomena_web/templates/image/new.html.slime
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@
= render PhilomenaWeb.CaptchaView, "_captcha.html", name: "image", conn: @conn

.actions
= submit "Upload", class: "button input--separate-top", autocomplete: "off", data: [disable_with: "Please wait..."]
= submit "Upload", class: "button input--separate-top", autocomplete: "off"

0 comments on commit 42138a2

Please sign in to comment.