Skip to content

Commit c3b8878

Browse files
committed
Merge branch 'main' into 706-fix/close-button-for-modal
2 parents 8dc4676 + 7e40a29 commit c3b8878

5 files changed

Lines changed: 208 additions & 64 deletions

File tree

website/modules/@apostrophecms/form-widget/views/widget.html

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,11 @@
1414
param.key + ',' %} {% endif %} {% endfor %} {% endif %}
1515

1616
<form
17-
data-apos-form-form="{{ form._id }}"
1817
class="sf-form {{ prependIfPrefix('__form') }}"
1918
autocomplete="off"
20-
{%
21-
if
22-
form.enableQueryParams
23-
%}
24-
data-apos-form-params="{{params}}"
25-
{%
26-
endif
27-
%}
28-
{%
29-
if
30-
recaptchaReady
31-
%}
32-
data-apos-recaptcha-sitekey="{{ recaptchaSite }}"
33-
{%
34-
endif
35-
%}
36-
{%
37-
if
38-
onSubmitSuccess
39-
%}
40-
onsubmit="runSubmitSuccess(event)"
41-
{%
42-
endif
43-
%}
19+
novalidate
20+
method="post"
21+
action="/api/v1/@apostrophecms/form/submit"
4422
>
4523
{% area form, 'contents' %} {% if recaptchaReady %}
4624
<noscript>
@@ -51,13 +29,10 @@
5129
<button
5230
type="submit"
5331
class="sf-button"
54-
data-apos-form-submit
5532
{%
5633
if
5734
recaptchaReady
58-
%}
59-
disabled
60-
{%
35+
%}disabled{%
6136
endif
6237
%}
6338
>
@@ -100,11 +75,7 @@
10075
<h3>{{ form.thankYouHeading or __t('aposForm:defaultThankYou') }}</h3>
10176

10277
{% if not apos.area.isEmpty(form, 'thankYouBody') %} {% area form,
103-
'thankYouBody' %} {% endif %} {% if onSubmitSuccess %}
104-
<script>
105-
alert("Thanks!"); // {{ onSubmitSuccess | safe }}
106-
</script>
107-
{% endif %}
78+
'thankYouBody' %} {% endif %} {% if onSubmitSuccess %} {% endif %}
10879
</div>
10980
{% endif %}
11081
</div>

website/modules/asset/ui/src/js/formValidation.js

Lines changed: 151 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -59,37 +59,23 @@ const addFieldValidationHandlers = (field, validateFieldFn) => {
5959
});
6060
};
6161

62-
const handleFormSubmit = (form, validateFieldFn) => async (event) => {
63-
const isValid = await validateForm(form, validateFieldFn);
64-
65-
if (!isValid) {
66-
event.preventDefault();
67-
return;
68-
}
69-
70-
// Don't call form.submit() in test environment
71-
if (typeof jest === 'undefined') {
72-
form.submit();
73-
}
74-
};
75-
7662
const validateForm = async (form, validateFieldFn) => {
77-
const fields = form.querySelectorAll('input, textarea, select');
63+
const fields = form.querySelectorAll(
64+
'input:not([type="submit"]):not([type="button"]):not([type="hidden"]), textarea, select',
65+
);
7866

7967
fields.forEach((field) => {
8068
clearValidationErrorFn(field);
8169
});
8270

8371
const validationResults = await Promise.all(
84-
Array.from(fields)
85-
.filter((field) => !['submit', 'button', 'hidden'].includes(field.type))
86-
.map(async (field) => {
87-
const result = await validateFieldFn(field);
88-
if (!result.isValid) {
89-
showValidationErrorFn(field, result.message);
90-
}
91-
return result.isValid;
92-
}),
72+
Array.from(fields).map(async (field) => {
73+
const result = await validateFieldFn(field);
74+
if (!result.isValid) {
75+
showValidationErrorFn(field, result.message);
76+
}
77+
return result.isValid;
78+
}),
9379
);
9480

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

113-
const initFormWithValidation = (form, validateFieldFn) => {
114-
form.addEventListener('submit', handleFormSubmit(form, validateFieldFn));
99+
const collectFormData = (form) => new FormData(form);
115100

116-
// Initialize validation for all fields in the form
101+
const scrollToFirstInvalidField = (form) => {
117102
const fields = form.querySelectorAll('input, textarea, select');
103+
for (const field of fields) {
104+
const errorText = field
105+
.closest('.sf-field')
106+
?.querySelector('.validation-error')
107+
?.textContent?.trim();
108+
109+
if (errorText) {
110+
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
111+
field.focus();
112+
break;
113+
}
114+
}
115+
};
116+
117+
const onValidateForm = (isValid, form, validateFieldFn) => {
118+
if (!isValid) {
119+
scrollToFirstInvalidField(form);
120+
return null;
121+
}
122+
const formData = collectFormData(form);
123+
return sendFormData(form, formData)
124+
.then((response) => onSendFormDataResponse(response, form))
125+
.catch(() => {
126+
const errorMessage = form.querySelector('.error-message');
127+
if (errorMessage) {
128+
errorMessage.textContent =
129+
'Failed to submit form. Please try again later.';
130+
}
131+
return null;
132+
});
133+
};
134+
135+
const onSendFormDataResponse = (response, form) => {
136+
return handleServerResponse(response, form).then((ok) => {
137+
if (!ok) {
138+
showValidationErrorFn(
139+
form,
140+
'Submission failed. Please check the form and try again.',
141+
);
142+
}
143+
return ok;
144+
});
145+
};
146+
147+
const onHandleServerResponse = (data, form) => {
148+
if (data && (data.success || data.ok)) {
149+
form.reset();
150+
const thankYou = document.querySelector('[data-apos-form-thank-you]');
151+
if (thankYou) {
152+
thankYou.style.display = 'block';
153+
}
154+
form.style.display = 'none';
155+
return true;
156+
}
157+
// Show server validation errors if they exist
158+
if (data?.errors) {
159+
showValidationErrorFn(form, data.errors.join(', '));
160+
} else {
161+
showValidationErrorFn(form, 'Submission failed. Please try again.');
162+
}
163+
return false;
164+
};
165+
166+
const handleServerResponse = (response, form) => {
167+
if (!response.ok) {
168+
return response
169+
.json()
170+
.then((data) => {
171+
showValidationErrorFn(
172+
form,
173+
data.message || 'Submission failed. Please try again.',
174+
);
175+
return false;
176+
})
177+
.catch(() => {
178+
showValidationErrorFn(form, 'Submission failed. Please try again.');
179+
return false;
180+
});
181+
}
182+
return response.json().then((data) => onHandleServerResponse(data, form));
183+
};
184+
185+
const sendFormData = (form, formData) => {
186+
// Convert FormData to a plain object for the server
187+
const data = {};
188+
for (const [key, value] of formData.entries()) {
189+
if (key in data) {
190+
data[key] = [].concat(data[key], value);
191+
} else {
192+
data[key] = value;
193+
}
194+
}
195+
196+
return fetch(form.action, {
197+
method: 'POST',
198+
body: JSON.stringify({ data }),
199+
headers: {
200+
'Content-Type': 'application/json',
201+
'Accept': 'application/json',
202+
'CSRF-Token':
203+
document
204+
.querySelector('meta[name="csrf-token"]')
205+
?.getAttribute('content') || '',
206+
},
207+
credentials: 'same-origin',
208+
});
209+
};
210+
211+
const handleFormSubmit = (event, form, validateFieldFn) => {
212+
event.preventDefault();
213+
// Disable submit button(s) to prevent multiple submissions
214+
const submitButtons = form.querySelectorAll(
215+
'button[type="submit"], input[type="submit"]',
216+
);
217+
submitButtons.forEach((btn) => (btn.disabled = true));
218+
219+
validateForm(form, validateFieldFn)
220+
.then((isValid) => onValidateForm(isValid, form, validateFieldFn))
221+
.finally(() => {
222+
// Re-enable submit button(s) after processing
223+
submitButtons.forEach((btn) => (btn.disabled = false));
224+
})
225+
.catch(() => false);
226+
};
227+
228+
const initFormWithValidation = (form, validateFieldFn) => {
229+
// Initialize validation for all fields in the form
230+
const fields = form.querySelectorAll(
231+
'input:not([type="submit"]):not([type="button"]):not([type="hidden"]), textarea, select',
232+
);
118233
fields.forEach((field) => addFieldValidationHandlers(field, validateFieldFn));
234+
235+
// Add validation before form submission
236+
form.addEventListener(
237+
'submit',
238+
function (event) {
239+
handleFormSubmit(event, form, validateFieldFn);
240+
},
241+
true,
242+
);
119243
};
120244

121245
module.exports = { initFormValidation };

website/modules/asset/ui/src/js/formValidation.test.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
import { initFormValidation } from './formValidation';
22

3+
if (!global.fetch) {
4+
global.fetch = jest.fn(() =>
5+
Promise.resolve({
6+
json: () => Promise.resolve({ success: true }),
7+
}),
8+
);
9+
}
10+
11+
const createDelayedFetchPromise = () => {
12+
return new Promise((resolve) => {
13+
setTimeout(() => {
14+
resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
15+
}, 50);
16+
});
17+
};
18+
319
describe('Form Validation', () => {
420
let form = null;
521
let fullNameInput = null;
@@ -80,7 +96,6 @@ describe('Form Validation', () => {
8096
await waitForDomUpdate();
8197

8298
expect(validateField).toHaveBeenCalledWith(fullNameInput);
83-
expect(submitEvent.defaultPrevented).toBe(false);
8499
}, 10000);
85100

86101
test('prevents form submission when validation fails', async () => {
@@ -95,4 +110,28 @@ describe('Form Validation', () => {
95110
expect(validateField).toHaveBeenCalledWith(fullNameInput);
96111
expect(submitEvent.defaultPrevented).toBe(true);
97112
}, 10000);
113+
114+
test('submit button is disabled during form submission and prevents multiple submits', async () => {
115+
const submitButton = document.createElement('button');
116+
submitButton.type = 'submit';
117+
submitButton.textContent = 'Send';
118+
form.appendChild(submitButton);
119+
120+
validateField.mockImplementation(() => Promise.resolve({ isValid: true }));
121+
122+
const fetchPromise = createDelayedFetchPromise();
123+
global.fetch = jest.fn(() => fetchPromise);
124+
125+
const submitEvent1 = new SubmitEvent('submit', { cancelable: true });
126+
form.dispatchEvent(submitEvent1);
127+
expect(submitButton.disabled).toBe(true);
128+
129+
const submitEvent2 = new SubmitEvent('submit', { cancelable: true });
130+
form.dispatchEvent(submitEvent2);
131+
expect(submitButton.disabled).toBe(true);
132+
133+
await fetchPromise;
134+
await waitForDomUpdate();
135+
expect(submitButton.disabled).toBe(false);
136+
}, 10000);
98137
});

website/modules/asset/ui/src/scss/_form.scss

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,24 @@
4040
}
4141
}
4242

43+
.sf-input {
44+
height: 37px;
45+
46+
@include breakpoint-medium {
47+
height: 53px;
48+
}
49+
}
50+
4351
.sf-textarea,
4452
.sf-input {
4553
box-sizing: border-box;
4654
border-radius: 0;
4755
border: 1px solid $whisper;
4856
padding: 8px 16px;
49-
height: 37px;
5057
line-height: 140%;
5158
margin-bottom: 24px;
5259

5360
@include breakpoint-medium {
54-
height: 53px;
5561
padding: 16px 25px;
5662
}
5763

website/modules/asset/ui/src/scss/_leadership-team.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@
210210
animation: fadeSlideIn 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
211211
}
212212
}
213+
214+
@include breakpoint-extra-large {
215+
overflow-y: hidden;
216+
}
213217
}
214218

215219
.leader-header {

0 commit comments

Comments
 (0)