diff --git a/libs/blocks/marketo/marketo-multi.js b/libs/blocks/marketo/marketo-multi.js new file mode 100644 index 0000000000..4a4f5bafb2 --- /dev/null +++ b/libs/blocks/marketo/marketo-multi.js @@ -0,0 +1,100 @@ +import { createTag } from '../../utils/utils.js'; +import { debounce } from '../../utils/action.js'; + +const VALIDATION_STEP = { + name: '2', + phone: '2', + mktoFormsJobTitle: '2', + mktoFormsFunctionalArea: '2', + company: '3', + state: '3', + postcode: '3', + mktoFormsPrimaryProductInterest: '3', + mktoFormsCompanyType: '3', +}; + +function updateStepDetails(formEl, step, totalSteps) { + formEl.classList.add('hide-errors'); + formEl.classList.remove('show-warnings'); + formEl.dataset.step = step; + formEl.querySelector('.step-details .step').textContent = `Step ${step} of ${totalSteps}`; + formEl.querySelector('#mktoButton_new').textContent = step === totalSteps ? 'Submit' : 'Next'; + formEl.querySelector(`.mktoFormRowTop[data-validate="${step}"]:not(.mktoHidden) input`)?.focus(); +} + +function showPreviousStep(formEl, totalSteps) { + const currentStep = parseInt(formEl.dataset.step, 10); + const previousStep = currentStep - 1; + const backBtn = formEl.querySelector('.back-btn'); + + updateStepDetails(formEl, previousStep, totalSteps); + if (previousStep === 1) backBtn?.remove(); +} + +const showNextStep = (formEl, currentStep, totalSteps) => { + if (currentStep === totalSteps) return; + const nextStep = currentStep + 1; + const stepDetails = formEl.querySelector('.step-details'); + + if (!stepDetails.querySelector('.back-btn')) { + const backBtn = createTag('button', { class: 'back-btn', type: 'button' }, 'Back'); + backBtn.addEventListener('click', () => showPreviousStep(formEl, totalSteps)); + stepDetails.prepend(backBtn); + } + + updateStepDetails(formEl, nextStep, totalSteps); +}; + +export const formValidate = (formEl) => { + const currentStep = parseInt(formEl.dataset.step, 10) || 1; + + if (formEl.querySelector(`.mktoFormRowTop[data-validate="${currentStep}"] .mktoInvalid`)) { + return false; + } + + const totalSteps = formEl.closest('.marketo').classList.contains('multi-3') ? 3 : 2; + showNextStep(formEl, currentStep, totalSteps); + + return currentStep === totalSteps; +}; + +function setValidationSteps(formEl, totalSteps) { + formEl.querySelectorAll('.mktoFormRowTop').forEach((row) => { + const rowAttr = row.getAttribute('data-mktofield') || row.getAttribute('data-mkto_vis_src'); + const step = VALIDATION_STEP[rowAttr] ? Math.min(VALIDATION_STEP[rowAttr], totalSteps) : 1; + row.dataset.validate = rowAttr?.startsWith('adobe-privacy') ? totalSteps : step; + }); +} + +function onRender(formEl, totalSteps) { + const currentStep = parseInt(formEl.dataset.step, 10); + const submitButton = formEl.querySelector('#mktoButton_new'); + if (submitButton) submitButton.textContent = currentStep === totalSteps ? 'Submit' : 'Next'; + formEl.querySelector('.step-details .step').textContent = `Step ${currentStep} of ${totalSteps}`; + + setValidationSteps(formEl, totalSteps); +} + +const readyForm = (form, totalSteps) => { + const formEl = form.getFormElem().get(0); + form.onValidate(() => formValidate(formEl)); + + const stepEl = createTag('p', { class: 'step' }, `Step 1 of ${totalSteps}`); + const stepDetails = createTag('div', { class: 'step-details' }, stepEl); + formEl.append(stepDetails); + + const debouncedOnRender = debounce(() => onRender(formEl, totalSteps), 10); + const observer = new MutationObserver(debouncedOnRender); + observer.observe(formEl, { childList: true, subtree: true }); + debouncedOnRender(); +}; + +export default (el) => { + if (!el.classList.contains('multi-step')) return; + const formEl = el.querySelector('form'); + const totalSteps = el.classList.contains('multi-3') ? 3 : 2; + formEl.dataset.step = 1; + + const { MktoForms2 } = window; + MktoForms2.whenReady((form) => { readyForm(form, totalSteps); }); +}; diff --git a/libs/blocks/marketo/marketo.css b/libs/blocks/marketo/marketo.css index f853564ee9..f9c2b4cd29 100644 --- a/libs/blocks/marketo/marketo.css +++ b/libs/blocks/marketo/marketo.css @@ -7,6 +7,8 @@ --marketo-form-focus: #147AF3; --marketo-form-placeholder-height: calc(78px * 7 + 57px); /* 7 rows + submit */ --marketo-form-placeholder-height-desktop: calc(78px * 4 + 57px); /* 4 rows + submit */ + --marketo-form-placeholder-height-multi: calc(78px * 2 + 57px); /* 2 rows + submit */ + --marketo-form-placeholder-height-multi-desktop: calc(78px + 57px); /* 1 row + submit */ --marketo-form-min-height: 215px; --marketo-form-max-height: 10000px; } @@ -25,6 +27,10 @@ min-height: var(--marketo-form-placeholder-height); } +.marketo.marketo.multi-step.loading form { + min-height: var(--marketo-form-placeholder-height-multi); +} + .marketo .marketo-title { font-size: 28px; font-weight: bold; @@ -87,6 +93,50 @@ display: contents; } +.marketo.multi-step .mktoFormRow.mktoFormRowTop[data-validate="2"], +.marketo.multi-step .mktoFormRow.mktoFormRowTop[data-validate="3"] { + display: none; +} + +.marketo.multi-step .mktoForm[data-step="2"] .mktoFormRow.mktoFormRowTop[data-validate="2"], +.marketo.multi-step .mktoForm[data-step="3"] .mktoFormRow.mktoFormRowTop[data-validate="3"] { + display: contents; +} + +.marketo.multi-step .mktoForm[data-step="2"] .mktoFormRow.mktoFormRowTop[data-validate="1"], +.marketo.multi-step .mktoForm[data-step="3"] .mktoFormRow.mktoFormRowTop[data-validate="1"] { + display: none; +} + +.marketo.multi-step .mktoForm[data-step="1"] .mktoFormRow.mktoFormRowTop.adobe-privacy, +.marketo.multi-step .mktoForm[data-step="2"] .mktoFormRow.mktoFormRowTop.adobe-privacy { + display: none; +} + +.marketo.multi-step .mktoForm[data-step="3"] .mktoFormRow.mktoFormRowTop.adobe-privacy, +.marketo.multi-step.multi-2 .mktoForm[data-step="2"] .mktoFormRow.mktoFormRowTop.adobe-privacy { + display: grid; +} + +.marketo.multi-step .step-details { + display: flex; + justify-content: center; + font-size: var(--type-body-xs-size); + font-weight: normal; + line-height: var(--type-body-xs-lh); + grid-column: span 2; +} + +.marketo.multi-step .step-details button { + background: none; + border: none; + padding: 0; + text-decoration: underline; + cursor: pointer; + color: var(--link-color-dark); + margin-right: 8px; +} + .marketo .mktoFormCol.mktoVisible { width: 100%; margin-bottom: 11px; @@ -322,12 +372,12 @@ outline: 2px solid var(--color-accent-focus-ring); } -.marketo .mktoForm .mktoFormRow.msg-error { +.marketo .mktoFormRow.mktoFormRowTop.msg-error .mktoFormCol { display: none; } -.marketo .mktoForm.show-warnings .mktoFormRow.msg-error { - display: contents; +.marketo .show-warnings .mktoFormRow.mktoFormRowTop.msg-error .mktoFormCol { + display: block; } .marketo .mktoForm .mktoFormRow.msg-error .mktoHtmlText { @@ -513,6 +563,16 @@ grid-column: span 2; } + .marketo.multi-step .mktoForm[data-step="2"] .mktoFormRow.mktoFormRowTop[data-validate="2"].comments, + .marketo.multi-step .mktoForm[data-step="2"] .mktoFormRow.mktoFormRowTop[data-validate="2"].demo, + .marketo.multi-step .mktoForm[data-step="2"] .mktoFormRow.mktoFormRowTop[data-validate="2"].name, + .marketo.multi-step .mktoForm[data-step="3"] .mktoFormRow.mktoFormRowTop[data-validate="3"].comments, + .marketo.multi-step .mktoForm[data-step="3"] .mktoFormRow.mktoFormRowTop[data-validate="3"].demo, + .marketo.multi-step .mktoForm[data-step="3"] .mktoFormRow.mktoFormRowTop[data-validate="3"].name { + display: block; + grid-column: span 2; + } + .marketo .mktoFormRow.mktoFormRowTop.name .mktoFormRow[data-mktofield="Salutation"] { grid-area: salutation; } @@ -585,9 +645,17 @@ min-height: var(--marketo-form-placeholder-height-desktop); } + .marketo.multi-step.loading form { + min-height: var(--marketo-form-placeholder-height-multi-desktop); + } + .marketo .mktoForm { max-height: var(--marketo-form-placeholder-height-desktop); } + + .marketo.multi-step .mktoForm { + max-height: var(--marketo-form-placeholder-height-multi-desktop); + } .resource-form.section.two-up { grid-template-columns: repeat(2, 1fr); diff --git a/libs/blocks/marketo/marketo.js b/libs/blocks/marketo/marketo.js index 9f2a9b54ed..894aa60919 100644 --- a/libs/blocks/marketo/marketo.js +++ b/libs/blocks/marketo/marketo.js @@ -180,6 +180,10 @@ export const loadMarketo = (el, formData) => { MktoForms2.loadForm(`//${baseURL}`, munchkinID, formID); MktoForms2.whenReady((form) => { readyForm(form, formData); }); + /* c8 ignore next 3 */ + if (el.classList.contains('multi-step')) { + import('./marketo-multi.js').then(({ default: multiStep }) => multiStep(el)); + } }) .catch(() => { /* c8 ignore next 2 */ @@ -261,6 +265,10 @@ export default function init(el) { fragment.append(formWrapper); el.replaceChildren(fragment); el.classList.add('loading'); + /* c8 ignore next 3 */ + if (el.classList.contains('multi-2') || el.classList.contains('multi-3')) { + el.classList.add('multi-step'); + } loadLink(`https://${baseURL}`, { rel: 'dns-prefetch' }); diff --git a/test/blocks/marketo/marketo-multi.test.js b/test/blocks/marketo/marketo-multi.test.js new file mode 100644 index 0000000000..68a9ff9683 --- /dev/null +++ b/test/blocks/marketo/marketo-multi.test.js @@ -0,0 +1,78 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import sinon, { stub } from 'sinon'; +import init, { formValidate } from '../../../libs/blocks/marketo/marketo-multi.js'; + +const innerHTML = await readFile({ path: './mocks/multi-step-2.html' }); + +describe('marketo multi-step', () => { + let clock; + + beforeEach(() => { + document.body.innerHTML = innerHTML; + clock = sinon.useFakeTimers(); + window.MktoForms2 = { whenReady: stub().callsFake((callback) => callback({ onValidate: () => {}, getFormElem: () => ({ get: () => document.querySelector('form') }) })) }; + + const el = document.querySelector('.marketo'); + init(el); + clock.tick(300); + }); + + afterEach(() => { + window.MktoForms2 = undefined; + clock.restore(); + }); + + it('initializes multi-step form', () => { + const el = document.querySelector('.marketo'); + const stepDetails = el.querySelector('.step-details .step'); + + expect(stepDetails).to.exist; + expect(stepDetails.textContent).to.equal('Step 1 of 2'); + expect(window.MktoForms2.whenReady.calledOnce).to.be.true; + + const step1 = el.querySelector('.mktoFormRowTop[data-validate="1"]'); + const step2 = el.querySelector('.mktoFormRowTop[data-validate="2"]'); + expect(step1).to.exist; + expect(step2).to.exist; + }); + + it('shows next step on valid form step', () => { + const formEl = document.querySelector('form'); + + formValidate(formEl); + clock.tick(200); + + expect(formEl.dataset.step).to.equal('2'); + expect(formEl.querySelector('#mktoButton_new').textContent).to.equal('Submit'); + expect(formEl.querySelector('.back-btn')).to.exist; + }); + + it('does not show next step on invalid form submission', () => { + const formEl = document.querySelector('form'); + formEl.querySelector('.mktoFormRowTop[data-validate="1"] input').classList.add('mktoInvalid'); + + const result = formValidate(formEl); + clock.tick(200); + + expect(result).to.be.false; + expect(formEl.dataset.step).to.equal('1'); + }); + + it('shows previous step on back button click', () => { + const formEl = document.querySelector('form'); + + formValidate(formEl); + clock.tick(200); + + expect(formEl.dataset.step).to.equal('2'); + const backBtn = formEl.querySelector('.back-btn'); + + backBtn.click(); + clock.tick(200); + + expect(formEl.dataset.step).to.equal('1'); + expect(formEl.querySelector('.back-btn')).to.be.null; + expect(formEl.querySelector('.step-details .step').textContent).to.equal('Step 1 of 2'); + }); +}); diff --git a/test/blocks/marketo/mocks/multi-step-2.html b/test/blocks/marketo/mocks/multi-step-2.html new file mode 100644 index 0000000000..db68c5c7ae --- /dev/null +++ b/test/blocks/marketo/mocks/multi-step-2.html @@ -0,0 +1,49 @@ + +
Marketo Template: flex_contact
+ +