From b716ffa933a86bd1bcd9fe9184b6fc1363a397fd Mon Sep 17 00:00:00 2001 From: Tomasz Dziezyk Date: Fri, 9 Aug 2024 12:40:58 +0200 Subject: [PATCH 1/4] KAw-7706 Add form block --- blocks/form/form-fields.js | 208 +++++++++++++++++++++++++++++++++++++ blocks/form/form.css | 95 +++++++++++++++++ blocks/form/form.js | 126 ++++++++++++++++++++++ styles/styles.css | 10 +- 4 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 blocks/form/form-fields.js create mode 100644 blocks/form/form.css create mode 100644 blocks/form/form.js diff --git a/blocks/form/form-fields.js b/blocks/form/form-fields.js new file mode 100644 index 0000000..e022916 --- /dev/null +++ b/blocks/form/form-fields.js @@ -0,0 +1,208 @@ +import { toClassName } from '../../scripts/aem.js'; + +function createFieldWrapper(fd) { + const fieldWrapper = document.createElement('div'); + if (fd.Style) fieldWrapper.className = fd.Style; + fieldWrapper.classList.add('field-wrapper', `${fd.Type}-wrapper`); + + fieldWrapper.dataset.fieldset = fd.Fieldset; + + return fieldWrapper; +} + +const linkHandler = (plainText) => { + // regexp to find markdown links + const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + + // replace makrdown links with HTML links + const textWithLinks = plainText.replace(markdownLinkRegex, (match, linkText, url) => `${linkText}`); + + return textWithLinks; +}; + +const ids = []; +function generateFieldId(fd, suffix = '') { + const slug = toClassName(`form-${fd.Name}${suffix}`); + ids[slug] = ids[slug] || 0; + const idSuffix = ids[slug] ? `-${ids[slug]}` : ''; + ids[slug] += 1; + return `${slug}${idSuffix}`; +} + +function createLabel(fd) { + const label = document.createElement('label'); + label.id = generateFieldId(fd, '-label'); + label.innerHTML = linkHandler(fd.Label || fd.Name); + label.setAttribute('for', fd.Id); + if (fd.Mandatory.toLowerCase() === 'true' || fd.Mandatory.toLowerCase() === 'x') { + label.dataset.required = true; + } + return label; +} + +function createErrorMessage() { + const errorWrapper = document.createElement('div'); + errorWrapper.classList.add('form-error-message-wrapper'); + const errorMessage = document.createElement('span'); + errorMessage.classList.add('form-error-message'); + + errorWrapper.append(errorMessage); + + return errorWrapper; +} + +function setCommonAttributes(field, fd) { + const isMandator = fd.Mandatory && (fd.Mandatory.toLowerCase() === 'true' || fd.Mandatory.toLowerCase() === 'x'); + + field.id = fd.Id; + field.name = fd.Name; + field.required = isMandator; + field.placeholder = fd.Placeholder; + field.value = fd.Value; + + field.oninvalid = (e) => { + e.preventDefault(); + + const fieldWrapper = e.target.closest('.field-wrapper'); + const errorMesageEl = fieldWrapper.querySelector('.form-error-message'); + + fieldWrapper.classList.add('error'); + + if (errorMesageEl) { + errorMesageEl.textContent = e.target.validationMessage; + } + }; + + field.oninput = (e) => { + const fieldWrapper = e.target.closest('.field-wrapper'); + const errorMesageEl = fieldWrapper.querySelector('.form-error-message'); + + fieldWrapper.classList.remove('error'); + + if (errorMesageEl) { + errorMesageEl.textContent = ''; + } + }; +} + +const createPlaintext = (fd) => { + const fieldWrapper = createFieldWrapper(fd); + + const text = document.createElement('p'); + text.innerHTML = linkHandler(fd.Value || fd.Label); + text.id = fd.Id; + + fieldWrapper.append(text); + + return { field: text, fieldWrapper }; +}; + +const createSelect = async (fd) => { + const select = document.createElement('select'); + setCommonAttributes(select, fd); + const addOption = ({ text, value }) => { + const option = document.createElement('option'); + option.text = text.trim(); + option.value = value.trim(); + if (option.value === select.value) { + option.setAttribute('selected', ''); + } + select.add(option); + return option; + }; + + if (fd.Placeholder) { + const ph = addOption({ text: fd.Placeholder, value: '' }); + ph.setAttribute('disabled', ''); + } + + if (fd.Options) { + let options = []; + if (fd.Options.startsWith('https://')) { + const optionsUrl = new URL(fd.Options); + const resp = await fetch(`${optionsUrl.pathname}${optionsUrl.search}`); + const json = await resp.json(); + json.data.forEach((opt) => { + options.push({ + text: opt.Option, + value: opt.Value || opt.Option, + }); + }); + } else { + options = fd.Options.split(',').map((opt) => ({ + text: opt.trim(), + value: opt.trim().toLowerCase(), + })); + } + + options.forEach((opt) => addOption(opt)); + } + + const fieldWrapper = createFieldWrapper(fd); + fieldWrapper.append(select); + fieldWrapper.prepend(createLabel(fd)); + fieldWrapper.append(createErrorMessage()); + + return { field: select, fieldWrapper }; +}; + +const createConfirmation = (fd, form) => { + form.dataset.confirmation = new URL(fd.Value).pathname; + + return {}; +}; + +const createSubmit = (fd) => { + const button = document.createElement('button'); + button.textContent = fd.Label || fd.Name; + button.classList.add('button'); + button.type = 'submit'; + + const fieldWrapper = createFieldWrapper(fd); + fieldWrapper.append(button); + return { field: button, fieldWrapper }; +}; + +const createInput = (fd) => { + const field = document.createElement('input'); + field.type = fd.Type; + setCommonAttributes(field, fd); + + const fieldWrapper = createFieldWrapper(fd); + const label = createLabel(fd); + field.setAttribute('aria-labelledby', label.id); + fieldWrapper.append(field); + if (fd.Type === 'radio' || fd.Type === 'checkbox') { + fieldWrapper.append(label); + } else { + fieldWrapper.prepend(label); + } + fieldWrapper.append(createErrorMessage()); + + return { field, fieldWrapper }; +}; + +const createCheckbox = (fd) => { + const { field, fieldWrapper } = createInput(fd); + if (!field.value) field.value = 'checked'; + fieldWrapper.classList.add('selection-wrapper'); + + return { field, fieldWrapper }; +}; + +const FIELD_CREATOR_FUNCTIONS = { + select: createSelect, + plaintext: createPlaintext, + submit: createSubmit, + confirmation: createConfirmation, + checkbox: createCheckbox, +}; + +export default async function createField(fd, form) { + fd.Id = fd.Id || generateFieldId(fd); + const type = fd.Type.toLowerCase(); + const createFieldFunc = FIELD_CREATOR_FUNCTIONS[type] || createInput; + const fieldElements = await createFieldFunc(fd, form); + + return fieldElements.fieldWrapper; +} diff --git a/blocks/form/form.css b/blocks/form/form.css new file mode 100644 index 0000000..0251c6e --- /dev/null +++ b/blocks/form/form.css @@ -0,0 +1,95 @@ +.form { + margin-bottom: 20px; +} + +.form label { + padding-right: 10px; +} + +.form .text-wrapper label, +.form .field-wrapper.email-wrapper label { + display: none; +} + +.form input { + box-sizing: border-box; + height: 40px; + max-width: 100%; + padding: 0 10px; + width: 100%; + background: rgb(255 255 255 / 0%); + color: #1d1d1b; + transition: 0.2s ease-in-out; + transition-property: color, background-color, border-color, box-shadow; + border: 1px solid rgb(29 29 27); + font-size: 16px; + line-height: 24px; + font-family: var(--body-font-family); +} + +.form input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.form input:focus { + outline: 0; + background-color: rgb(255 255 255 / 0%); + color: red; + border-color: red; +} + +input::placeholder { + color: #1d1d1b; +} + +.form.block .field-wrapper { + padding-top: 20px; +} + +.form .field-wrapper.plaintext-wrapper { + padding-top: 0; +} + +.form .field-wrapper.plaintext-wrapper p { + margin: 0; +} + +.form .field-wrapper input[type="checkbox"] { + margin-left: 16px; +} + + +.field-wrapper.submit-wrapper { + display: flex; + justify-content: center; +} + +.form .field-wrapper.error input { + border-color: red; +} + +.form .field-wrapper.error input[type="checkbox"] { + outline: solid 1px red; +} + +.form .field-wrapper .form-error-message-wrapper { + display: none; + padding: 0; + color: red; +} + +.form .error .form-error-message-wrapper { + display: flex; +} + +.form .form-general-error-wrapper { + border: 2px solid rgb(255 185 0); + padding: 8px 16px; + margin-top: 20px; + display: none; +} + +.form .form-general-error-wrapper.show { + display: flex; +} diff --git a/blocks/form/form.js b/blocks/form/form.js new file mode 100644 index 0000000..8274907 --- /dev/null +++ b/blocks/form/form.js @@ -0,0 +1,126 @@ +import createField from './form-fields.js'; +import { sampleRUM } from '../../scripts/aem.js'; +import { getTextLabel } from '../../scripts/scripts.js'; + +async function createForm(formHref) { + const { pathname } = new URL(formHref); + const resp = await fetch(pathname); + const json = await resp.json(); + + const form = document.createElement('form'); + // eslint-disable-next-line prefer-destructuring + form.dataset.action = json.data.find((el) => el.Type === 'submit').Value; + + const fields = await Promise.all(json.data.map((fd) => createField(fd, form))); + fields.forEach((field) => { + if (field) { + form.append(field); + } + }); + + // group fields into fieldsets + const fieldsets = form.querySelectorAll('fieldset'); + fieldsets.forEach((fieldset) => { + form.querySelectorAll(`[data-fieldset="${fieldset.name}"`).forEach((field) => { + fieldset.append(field); + }); + }); + + // create general erro form message + const generalErrorWrapper = document.createElement('div'); + const generalErrorText = document.createElement('span'); + generalErrorWrapper.classList.add('form-general-error-wrapper'); + generalErrorText.classList.add('form-general-error-text'); + + generalErrorWrapper.append(generalErrorText); + form.append(generalErrorWrapper); + + return form; +} + +function generatePayload(form) { + const payload = {}; + + [...form.elements].forEach((field) => { + if (field.name && field.type !== 'submit' && !field.disabled) { + if (field.type === 'radio') { + if (field.checked) payload[field.name] = field.value; + } else if (field.type === 'checkbox') { + if (field.checked) payload[field.name] = payload[field.name] ? `${payload[field.name]},${field.value}` : field.value; + } else { + payload[field.name] = field.value; + } + } + }); + return payload; +} + +function handleSubmitError(form, error) { + // eslint-disable-next-line no-console + console.error(error); + form.querySelector('button[type="submit"]').disabled = false; + const generalErrorWrapper = form.querySelector('.form-general-error-wrapper'); + const generalErrorText = generalErrorWrapper.querySelector('.form-general-error-text'); + + generalErrorWrapper.classList.add('show'); + generalErrorText.textContent = getTextLabel('submitError'); + + sampleRUM('form:error', { source: '.form', target: error.stack || error.message || 'unknown error' }); +} + +async function handleSubmit(form) { + if (form.getAttribute('data-submitting') === 'true') return; + + const submit = form.querySelector('button[type="submit"]'); + try { + form.setAttribute('data-submitting', 'true'); + submit.disabled = true; + + // create payload + const payload = generatePayload(form); + const response = await fetch(form.dataset.action, { + method: 'POST', + body: JSON.stringify({ data: payload }), + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.ok) { + sampleRUM('form:submit', { source: '.form', target: form.dataset.action }); + if (form.dataset.confirmation) { + window.location.href = form.dataset.confirmation; + } + } else { + const error = await response.text(); + throw new Error(error); + } + } catch (e) { + handleSubmitError(form, e); + } finally { + form.setAttribute('data-submitting', 'false'); + } +} + +export default async function decorate(block) { + const formLink = block.querySelector('a[href$=".json"]'); + + if (!formLink) return; + + const form = await createForm(formLink.href); + block.replaceChildren(form); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + const valid = form.checkValidity(); + + if (valid) { + handleSubmit(form); + } else { + const firstInvalidEl = form.querySelector(':invalid:not(fieldset)'); + if (firstInvalidEl) { + firstInvalidEl.focus(); + firstInvalidEl.scrollIntoView({ behavior: 'smooth' }); + } + } + }); +} diff --git a/styles/styles.css b/styles/styles.css index 13706fd..5603582 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -187,7 +187,10 @@ button:focus { button:disabled, button:disabled:hover { - cursor: unset; + cursor: not-allowed; + background-color: transparent; + color: #999; + border: 1px solid #e5e5e5; } main img { @@ -326,4 +329,9 @@ main .border-top { .width-400 { max-width: 400px; margin: auto; +} + +.width-700 { + max-width: 704px; + margin: auto; } \ No newline at end of file From 55eab9943a1d6e692d8e2a676e6fe330c1cefd9b Mon Sep 17 00:00:00 2001 From: Tomasz Dziezyk Date: Fri, 9 Aug 2024 13:00:34 +0200 Subject: [PATCH 2/4] KAW-7706 Add textarea --- blocks/form/form-fields.js | 16 ++++++++++++++++ blocks/form/form.css | 14 +++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/blocks/form/form-fields.js b/blocks/form/form-fields.js index e022916..8066048 100644 --- a/blocks/form/form-fields.js +++ b/blocks/form/form-fields.js @@ -163,6 +163,21 @@ const createSubmit = (fd) => { return { field: button, fieldWrapper }; }; +const createTextArea = (fd) => { + const field = document.createElement('textarea'); + setCommonAttributes(field, fd); + + const fieldWrapper = createFieldWrapper(fd); + const label = createLabel(fd); + field.setAttribute('aria-labelledby', label.id); + field.setAttribute('rows', 5); + fieldWrapper.append(field); + fieldWrapper.prepend(label); + fieldWrapper.append(createErrorMessage()); + + return { field, fieldWrapper }; +}; + const createInput = (fd) => { const field = document.createElement('input'); field.type = fd.Type; @@ -196,6 +211,7 @@ const FIELD_CREATOR_FUNCTIONS = { submit: createSubmit, confirmation: createConfirmation, checkbox: createCheckbox, + textarea: createTextArea, }; export default async function createField(fd, form) { diff --git a/blocks/form/form.css b/blocks/form/form.css index 0251c6e..c9cc8c8 100644 --- a/blocks/form/form.css +++ b/blocks/form/form.css @@ -7,11 +7,13 @@ } .form .text-wrapper label, +.form .textarea-wrapper label, .form .field-wrapper.email-wrapper label { display: none; } -.form input { +.form input, +.form textarea { box-sizing: border-box; height: 40px; max-width: 100%; @@ -27,6 +29,10 @@ font-family: var(--body-font-family); } +.form textarea { + height: auto; +} + .form input[type="checkbox"] { width: 16px; height: 16px; @@ -39,7 +45,8 @@ border-color: red; } -input::placeholder { +input::placeholder, +textarea::placeholder { color: #1d1d1b; } @@ -65,7 +72,8 @@ input::placeholder { justify-content: center; } -.form .field-wrapper.error input { +.form .field-wrapper.error input, +.form .field-wrapper.error textarea { border-color: red; } From 388ec280d4079190c19b54da7459c1824d163237 Mon Sep 17 00:00:00 2001 From: Tomasz Dziezyk Date: Fri, 9 Aug 2024 13:16:44 +0200 Subject: [PATCH 3/4] KAW-7706 Fix footer margins --- blocks/footer/footer.css | 3 ++- blocks/form/form.css | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/blocks/footer/footer.css b/blocks/footer/footer.css index 822fa4a..5281b93 100644 --- a/blocks/footer/footer.css +++ b/blocks/footer/footer.css @@ -20,9 +20,10 @@ footer { margin: 0; } -.footer .footer-column { +.footer .columns .footer-column { padding-bottom: 60px; position: relative; + margin: 0; } .footer .footer-column:last-child { diff --git a/blocks/form/form.css b/blocks/form/form.css index c9cc8c8..d01c56b 100644 --- a/blocks/form/form.css +++ b/blocks/form/form.css @@ -31,6 +31,7 @@ .form textarea { height: auto; + padding: 10px; } .form input[type="checkbox"] { From a0be148e8912ca8a8471447b0eeeed332437ee74 Mon Sep 17 00:00:00 2001 From: Tomasz Dziezyk Date: Fri, 9 Aug 2024 13:25:05 +0200 Subject: [PATCH 4/4] KAW-7708 Remove unused code --- blocks/form/form.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/blocks/form/form.js b/blocks/form/form.js index 8274907..9536b0a 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -18,15 +18,7 @@ async function createForm(formHref) { } }); - // group fields into fieldsets - const fieldsets = form.querySelectorAll('fieldset'); - fieldsets.forEach((fieldset) => { - form.querySelectorAll(`[data-fieldset="${fieldset.name}"`).forEach((field) => { - fieldset.append(field); - }); - }); - - // create general erro form message + // create general error form message const generalErrorWrapper = document.createElement('div'); const generalErrorText = document.createElement('span'); generalErrorWrapper.classList.add('form-general-error-wrapper');