Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 5 additions & 34 deletions website/modules/@apostrophecms/form-widget/views/widget.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,11 @@
param.key + ',' %} {% endif %} {% endfor %} {% endif %}

<form
data-apos-form-form="{{ form._id }}"
class="sf-form {{ prependIfPrefix('__form') }}"
autocomplete="off"
{%
if
form.enableQueryParams
%}
data-apos-form-params="{{params}}"
{%
endif
%}
{%
if
recaptchaReady
%}
data-apos-recaptcha-sitekey="{{ recaptchaSite }}"
{%
endif
%}
{%
if
onSubmitSuccess
%}
onsubmit="runSubmitSuccess(event)"
{%
endif
%}
novalidate
method="post"
action="/api/v1/@apostrophecms/form/submit"
>
{% area form, 'contents' %} {% if recaptchaReady %}
<noscript>
Expand All @@ -51,13 +29,10 @@
<button
type="submit"
class="sf-button"
data-apos-form-submit
{%
if
recaptchaReady
%}
disabled
{%
%}disabled{%
endif
%}
>
Expand Down Expand Up @@ -100,11 +75,7 @@
<h3>{{ form.thankYouHeading or __t('aposForm:defaultThankYou') }}</h3>

{% if not apos.area.isEmpty(form, 'thankYouBody') %} {% area form,
'thankYouBody' %} {% endif %} {% if onSubmitSuccess %}
<script>
alert("Thanks!"); // {{ onSubmitSuccess | safe }}
</script>
{% endif %}
'thankYouBody' %} {% endif %} {% if onSubmitSuccess %} {% endif %}
</div>
{% endif %}
</div>
178 changes: 151 additions & 27 deletions website/modules/asset/ui/src/js/formValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,37 +59,23 @@ const addFieldValidationHandlers = (field, validateFieldFn) => {
});
};

const handleFormSubmit = (form, validateFieldFn) => async (event) => {
const isValid = await validateForm(form, validateFieldFn);

if (!isValid) {
event.preventDefault();
return;
}

// Don't call form.submit() in test environment
if (typeof jest === 'undefined') {
form.submit();
}
};

const validateForm = async (form, validateFieldFn) => {
const fields = form.querySelectorAll('input, textarea, select');
const fields = form.querySelectorAll(
'input:not([type="submit"]):not([type="button"]):not([type="hidden"]), textarea, select',
);

fields.forEach((field) => {
clearValidationErrorFn(field);
});

const validationResults = await Promise.all(
Array.from(fields)
.filter((field) => !['submit', 'button', 'hidden'].includes(field.type))
.map(async (field) => {
const result = await validateFieldFn(field);
if (!result.isValid) {
showValidationErrorFn(field, result.message);
}
return result.isValid;
}),
Array.from(fields).map(async (field) => {
const result = await validateFieldFn(field);
if (!result.isValid) {
showValidationErrorFn(field, result.message);
}
return result.isValid;
}),
);

return validationResults.every(Boolean);
Expand All @@ -110,12 +96,150 @@ const initFormValidation = (form, validateFieldFn) => {
}
};

const initFormWithValidation = (form, validateFieldFn) => {
form.addEventListener('submit', handleFormSubmit(form, validateFieldFn));
const collectFormData = (form) => new FormData(form);

// Initialize validation for all fields in the form
const scrollToFirstInvalidField = (form) => {
const fields = form.querySelectorAll('input, textarea, select');
for (const field of fields) {
const errorText = field
.closest('.sf-field')
?.querySelector('.validation-error')
?.textContent?.trim();

if (errorText) {
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
field.focus();
break;
}
}
};

const onValidateForm = (isValid, form, validateFieldFn) => {
if (!isValid) {
scrollToFirstInvalidField(form);
return null;
}
const formData = collectFormData(form);
return sendFormData(form, formData)
.then((response) => onSendFormDataResponse(response, form))
.catch(() => {
const errorMessage = form.querySelector('.error-message');
if (errorMessage) {
errorMessage.textContent =
'Failed to submit form. Please try again later.';
}
return null;
});
};

const onSendFormDataResponse = (response, form) => {
return handleServerResponse(response, form).then((ok) => {
if (!ok) {
showValidationErrorFn(
form,
'Submission failed. Please check the form and try again.',
);
}
return ok;
});
};

const onHandleServerResponse = (data, form) => {
if (data && (data.success || data.ok)) {
form.reset();
const thankYou = document.querySelector('[data-apos-form-thank-you]');
if (thankYou) {
thankYou.style.display = 'block';
}
form.style.display = 'none';
return true;
}
// Show server validation errors if they exist
if (data?.errors) {
showValidationErrorFn(form, data.errors.join(', '));
} else {
showValidationErrorFn(form, 'Submission failed. Please try again.');
}
return false;
};

const handleServerResponse = (response, form) => {
if (!response.ok) {
return response
.json()
.then((data) => {
showValidationErrorFn(
form,
data.message || 'Submission failed. Please try again.',
);
return false;
})
.catch(() => {
showValidationErrorFn(form, 'Submission failed. Please try again.');
return false;
});
}
return response.json().then((data) => onHandleServerResponse(data, form));
};

const sendFormData = (form, formData) => {
// Convert FormData to a plain object for the server
const data = {};
for (const [key, value] of formData.entries()) {
if (key in data) {
data[key] = [].concat(data[key], value);
} else {
data[key] = value;
}
}

return fetch(form.action, {
method: 'POST',
body: JSON.stringify({ data }),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'CSRF-Token':
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '',
},
credentials: 'same-origin',
});
};

const handleFormSubmit = (event, form, validateFieldFn) => {
event.preventDefault();
// Disable submit button(s) to prevent multiple submissions
const submitButtons = form.querySelectorAll(
'button[type="submit"], input[type="submit"]',
);
submitButtons.forEach((btn) => (btn.disabled = true));

validateForm(form, validateFieldFn)
.then((isValid) => onValidateForm(isValid, form, validateFieldFn))
.finally(() => {
// Re-enable submit button(s) after processing
submitButtons.forEach((btn) => (btn.disabled = false));
})
.catch(() => false);
};

const initFormWithValidation = (form, validateFieldFn) => {
// Initialize validation for all fields in the form
const fields = form.querySelectorAll(
'input:not([type="submit"]):not([type="button"]):not([type="hidden"]), textarea, select',
);
fields.forEach((field) => addFieldValidationHandlers(field, validateFieldFn));

// Add validation before form submission
form.addEventListener(
'submit',
function (event) {
handleFormSubmit(event, form, validateFieldFn);
},
true,
);
};

module.exports = { initFormValidation };
41 changes: 40 additions & 1 deletion website/modules/asset/ui/src/js/formValidation.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import { initFormValidation } from './formValidation';

if (!global.fetch) {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ success: true }),
}),
);
}

const createDelayedFetchPromise = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
}, 50);
});
};

describe('Form Validation', () => {
let form = null;
let fullNameInput = null;
Expand Down Expand Up @@ -80,7 +96,6 @@ describe('Form Validation', () => {
await waitForDomUpdate();

expect(validateField).toHaveBeenCalledWith(fullNameInput);
expect(submitEvent.defaultPrevented).toBe(false);
}, 10000);

test('prevents form submission when validation fails', async () => {
Expand All @@ -95,4 +110,28 @@ describe('Form Validation', () => {
expect(validateField).toHaveBeenCalledWith(fullNameInput);
expect(submitEvent.defaultPrevented).toBe(true);
}, 10000);

test('submit button is disabled during form submission and prevents multiple submits', async () => {
const submitButton = document.createElement('button');
submitButton.type = 'submit';
submitButton.textContent = 'Send';
form.appendChild(submitButton);

validateField.mockImplementation(() => Promise.resolve({ isValid: true }));

const fetchPromise = createDelayedFetchPromise();
global.fetch = jest.fn(() => fetchPromise);

const submitEvent1 = new SubmitEvent('submit', { cancelable: true });
form.dispatchEvent(submitEvent1);
expect(submitButton.disabled).toBe(true);

const submitEvent2 = new SubmitEvent('submit', { cancelable: true });
form.dispatchEvent(submitEvent2);
expect(submitButton.disabled).toBe(true);

await fetchPromise;
await waitForDomUpdate();
expect(submitButton.disabled).toBe(false);
}, 10000);
});
10 changes: 8 additions & 2 deletions website/modules/asset/ui/src/scss/_form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,24 @@
}
}

.sf-input {
height: 37px;

@include breakpoint-medium {
height: 53px;
}
}

.sf-textarea,
.sf-input {
box-sizing: border-box;
border-radius: 0;
border: 1px solid $whisper;
padding: 8px 16px;
height: 37px;
line-height: 140%;
margin-bottom: 24px;

@include breakpoint-medium {
height: 53px;
padding: 16px 25px;
}

Expand Down
Loading