Skip to content

Commit e282cdb

Browse files
authored
feat: Low form views auto detect (#814)
Please ensure your pull request adheres to the following guidelines: - [ ] make sure to link the related issues in this description - [ ] when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes ## Related Issues Thanks for contributing!
1 parent 420d275 commit e282cdb

13 files changed

+448
-26
lines changed

src/forms-opportunities/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
export const FORM_OPPORTUNITY_TYPES = {
1313
LOW_CONVERSION: 'high-form-views-low-conversions',
1414
LOW_NAVIGATION: 'high-page-views-low-form-nav',
15+
LOW_VIEWS: 'high-page-views-low-form-views',
1516
};

src/forms-opportunities/formcalc.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function aggregateFormVitalsByDevice(formVitalsCollection) {
2828
formVitalsCollection.forEach((item) => {
2929
const {
3030
url, formview = {}, formengagement = {}, pageview = {}, formsubmit = {},
31-
trafficacquisition = {},
31+
trafficacquisition = {}, formsource = '',
3232
} = item;
3333

3434
const totals = {
@@ -56,6 +56,7 @@ function aggregateFormVitalsByDevice(formVitalsCollection) {
5656
totals.pageview = calculateSums(pageview, totals.pageview);
5757
totals.formsubmit = calculateSums(formsubmit, totals.formsubmit);
5858
totals.trafficacquisition = trafficacquisition;
59+
totals.formsource = formsource;
5960
resultMap.set(url, totals);
6061
});
6162

@@ -116,14 +117,11 @@ export function getHighPageViewsLowFormViewsMetrics(formVitalsCollection) {
116117
resultMap.forEach((metrics, url) => {
117118
const { total: pageViews } = metrics.pageview;
118119
const { total: formViews } = metrics.formview;
119-
const { total: formEngagement } = metrics.formengagement;
120120

121121
if (hasHighPageViews(pageViews) && hasLowFormViews(pageViews, formViews)) {
122122
urls.push({
123123
url,
124-
pageViews,
125-
formViews,
126-
formEngagement,
124+
...metrics,
127125
});
128126
}
129127
});

src/forms-opportunities/handler.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { generateOpptyData } from './utils.js';
1919
import { getScrapedDataForSiteId } from '../support/utils.js';
2020
import createLowConversionOpportunities from './oppty-handlers/low-conversion-handler.js';
2121
import createLowNavigationOpportunities from './oppty-handlers/low-navigation-handler.js';
22+
import createLowViewsOpportunities from './oppty-handlers/low-views-handler.js';
2223

2324
const { AUDIT_STEP_DESTINATIONS } = Audit;
2425
const FORMS_OPPTY_QUERIES = [
@@ -100,8 +101,10 @@ export async function processOpportunityStep(context) {
100101
log.info(`[Form Opportunity] [Site Id: ${site.getId()}] processing opportunity`);
101102
const scrapedData = await getScrapedDataForSiteId(site, context);
102103
const latestAudit = await site.getLatestAuditByAuditType('forms-opportunities');
103-
await createLowConversionOpportunities(finalUrl, latestAudit, scrapedData, context);
104-
await createLowNavigationOpportunities(finalUrl, latestAudit, scrapedData, context);
104+
const excludeForms = new Set();
105+
await createLowNavigationOpportunities(finalUrl, latestAudit, scrapedData, context, excludeForms);
106+
await createLowViewsOpportunities(finalUrl, latestAudit, scrapedData, context, excludeForms);
107+
await createLowConversionOpportunities(finalUrl, latestAudit, scrapedData, context, excludeForms);
105108
log.info(`[Form Opportunity] [Site Id: ${site.getId()}] opportunity identified`);
106109
return {
107110
status: 'complete',

src/forms-opportunities/oppty-handlers/low-conversion-handler.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ function generateDefaultGuidance(scrapedData, oppoty) {
8282
* @param auditUrl - The URL of the audit
8383
* @param auditData - The audit data containing the audit result and additional details.
8484
* @param context - The context object containing the data access and logger objects.
85+
* @param excludeForms - A set of Forms to exclude from the opportunity creation process.
8586
*/
8687
// eslint-disable-next-line max-len
87-
export default async function createLowConversionOpportunities(auditUrl, auditDataObject, scrapedData, context) {
88+
export default async function createLowConversionOpportunities(auditUrl, auditDataObject, scrapedData, context, excludeForms = new Set()) {
8889
const {
8990
dataAccess, log, sqs, site, env,
9091
} = context;
@@ -107,9 +108,9 @@ export default async function createLowConversionOpportunities(auditUrl, auditDa
107108
// eslint-disable-next-line max-len
108109
const formOpportunities = await generateOpptyData(formVitals, context, [FORM_OPPORTUNITY_TYPES.LOW_CONVERSION]);
109110
log.debug(`forms opportunities ${JSON.stringify(formOpportunities, null, 2)}`);
110-
const filteredOpportunities = filterForms(formOpportunities, scrapedData, log);
111+
const filteredOpportunities = filterForms(formOpportunities, scrapedData, log, excludeForms);
112+
filteredOpportunities.forEach((oppty) => excludeForms.add(oppty.form + oppty.formsource));
111113
log.info(`filtered opportunties high form views low conversion for form ${JSON.stringify(filteredOpportunities, null, 2)}`);
112-
113114
try {
114115
for (const opptyData of filteredOpportunities) {
115116
let highFormViewsLowConversionsOppty = opportunities.find(

src/forms-opportunities/oppty-handlers/low-navigation-handler.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ const formPathSegments = ['contact', 'newsletter', 'sign', 'enrol', 'subscribe',
1818
* @param auditUrl - The URL of the audit
1919
* @param auditData - The audit data containing the audit result and additional details.
2020
* @param context - The context object containing the data access and logger objects.
21+
* @param excludeForms - A set of Forms to exclude from the opportunity creation process.
2122
*/
2223
// eslint-disable-next-line max-len
23-
export default async function createLowNavigationOpportunities(auditUrl, auditDataObject, scrapedData, context) {
24+
export default async function createLowNavigationOpportunities(auditUrl, auditDataObject, scrapedData, context, excludeForms = new Set()) {
2425
const { dataAccess, log } = context;
2526
const { Opportunity } = dataAccess;
2627

@@ -47,14 +48,19 @@ export default async function createLowNavigationOpportunities(auditUrl, auditDa
4748
const filteredOpportunitiesByNavigation = formOpportunities.filter((opportunity) => formPathSegments.some((substring) => opportunity.form?.includes(substring)
4849
&& !opportunity.formNavigation?.url?.includes('search')));
4950

50-
const filteredOpportunities = filterForms(filteredOpportunitiesByNavigation, scrapedData, log);
51+
const filteredOpportunities = filterForms(
52+
filteredOpportunitiesByNavigation,
53+
scrapedData,
54+
log,
55+
excludeForms,
56+
);
57+
filteredOpportunities.forEach((oppty) => excludeForms.add(oppty.form + oppty.formsource));
5158
log.info(`filtered opportunities: high-page-views-low-form-navigations: ${JSON.stringify(filteredOpportunities, null, 2)}`);
52-
5359
try {
5460
for (const opptyData of filteredOpportunities) {
5561
let highPageViewsLowFormNavOppty = opportunities.find(
5662
(oppty) => oppty.getType() === FORM_OPPORTUNITY_TYPES.LOW_NAVIGATION
57-
&& oppty.getData().form === opptyData.form,
63+
&& oppty.getData().form === opptyData.form,
5864
);
5965

6066
const opportunityData = {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { FORM_OPPORTUNITY_TYPES } from '../constants.js';
14+
import { filterForms, generateOpptyData } from '../utils.js';
15+
16+
/**
17+
* @param auditUrl - The URL of the audit
18+
* @param auditData - The audit data containing the audit result and additional details.
19+
* @param context - The context object containing the data access and logger objects.
20+
* @param excludeForms - A set of Forms to exclude from the opportunity creation process.
21+
*/
22+
// eslint-disable-next-line max-len
23+
export default async function createLowViewsOpportunities(auditUrl, auditDataObject, scrapedData, context, excludeForms = new Set()) {
24+
const { dataAccess, log } = context;
25+
const { Opportunity } = dataAccess;
26+
27+
const auditData = JSON.parse(JSON.stringify(auditDataObject));
28+
log.info(`Syncing high page views low form views opportunity for ${auditData.siteId}`);
29+
let opportunities;
30+
31+
try {
32+
opportunities = await Opportunity.allBySiteIdAndStatus(auditData.siteId, 'NEW');
33+
} catch (e) {
34+
log.error(`Fetching opportunities for siteId ${auditData.siteId} failed with error: ${e.message}`);
35+
throw new Error(`Failed to fetch opportunities for siteId ${auditData.siteId}: ${e.message}`);
36+
}
37+
38+
const { formVitals } = auditData.auditResult;
39+
// eslint-disable-next-line max-len
40+
const formOpportunities = await generateOpptyData(formVitals, context, [FORM_OPPORTUNITY_TYPES.LOW_VIEWS]);
41+
log.debug(`forms opportunities high-page-views-low-form-views: ${JSON.stringify(formOpportunities, null, 2)}`);
42+
43+
const filteredOpportunities = filterForms(formOpportunities, scrapedData, log, excludeForms);
44+
filteredOpportunities.forEach((oppty) => excludeForms.add(oppty.form + oppty.formsource));
45+
log.info(`filtered opportunities: high-page-views-low-form-views: ${JSON.stringify(filteredOpportunities, null, 2)}`);
46+
try {
47+
for (const opptyData of filteredOpportunities) {
48+
let highPageViewsLowFormViewsOptty = opportunities.find(
49+
(oppty) => oppty.getType() === FORM_OPPORTUNITY_TYPES.LOW_VIEWS
50+
&& oppty.getData().form === opptyData.form,
51+
);
52+
53+
const opportunityData = {
54+
siteId: auditData.siteId,
55+
auditId: auditData.auditId,
56+
runbook: 'https://adobe.sharepoint.com/:w:/s/AEM_Forms/EeYKNa4HQkRAleWXjC5YZbMBMhveB08F1yTTUQSrP97Eow?e=cZdsnA',
57+
type: FORM_OPPORTUNITY_TYPES.LOW_VIEWS,
58+
origin: 'AUTOMATION',
59+
title: 'The form has low views',
60+
description: 'The form has low views but the page containing the form has higher traffic',
61+
tags: ['Forms Conversion'],
62+
data: {
63+
...opptyData,
64+
},
65+
guidance: {
66+
recommendations: [
67+
{
68+
insight: `The form in the page: ${opptyData.form} has low discoverability and only ${(opptyData.formViews / opptyData.pageViews) * 100}% visitors landing on the page are viewing the form.`,
69+
recommendation: 'Position the form higher up on the page so users see it without scrolling. Consider using clear and compelling CTAs, minimizing distractions, and ensuring strong visibility across devices.',
70+
type: 'guidance',
71+
rationale: 'Forms that are visible above the fold are more likely to be seen and interacted with by users.',
72+
},
73+
],
74+
},
75+
};
76+
77+
log.info(`Forms Opportunity created high page views low form views ${JSON.stringify(opportunityData, null, 2)}`);
78+
if (!highPageViewsLowFormViewsOptty) {
79+
// eslint-disable-next-line no-await-in-loop
80+
highPageViewsLowFormViewsOptty = await Opportunity.create(opportunityData);
81+
} else {
82+
highPageViewsLowFormViewsOptty.setAuditId(auditData.auditId);
83+
highPageViewsLowFormViewsOptty.setData({
84+
...highPageViewsLowFormViewsOptty.getData(),
85+
...opportunityData.data,
86+
});
87+
highPageViewsLowFormViewsOptty.setGuidance(opportunityData.guidance);
88+
// eslint-disable-next-line no-await-in-loop
89+
await highPageViewsLowFormViewsOptty.save();
90+
}
91+
}
92+
} catch (e) {
93+
log.error(`Creating Forms opportunity for high page views low form views for siteId ${auditData.siteId} failed with error: ${e.message}`, e);
94+
}
95+
log.info(`Successfully synced Opportunity for site: ${auditData.siteId} and high page views low form views audit type.`);
96+
}

src/forms-opportunities/utils.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { GetObjectCommand } from '@aws-sdk/client-s3';
1717
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
1818
import {
1919
getHighPageViewsLowFormCtrMetrics, getHighFormViewsLowConversionMetrics,
20+
getHighPageViewsLowFormViewsMetrics,
2021
} from './formcalc.js';
2122
import { FORM_OPPORTUNITY_TYPES } from './constants.js';
2223

@@ -90,10 +91,10 @@ function getFormMetrics(metricObject) {
9091
});
9192
}
9293

93-
function convertToLowNavOpptyData(metricObject) {
94+
function convertToLowViewOpptyData(metricObject) {
9495
const {
9596
formview: { total: formViews, mobile: formViewsMobile, desktop: formViewsDesktop },
96-
CTA, trafficacquisition,
97+
trafficacquisition,
9798
} = metricObject;
9899
return {
99100
trackedFormKPIName: 'Form Views',
@@ -131,10 +132,18 @@ function convertToLowNavOpptyData(metricObject) {
131132
},
132133
},
133134
],
134-
formNavigation: CTA,
135135
};
136136
}
137137

138+
function convertToLowNavOpptyData(metricObject) {
139+
const {
140+
CTA,
141+
} = metricObject;
142+
const opptyData = convertToLowViewOpptyData(metricObject);
143+
opptyData.formNavigation = CTA;
144+
return opptyData;
145+
}
146+
138147
function convertToLowConversionOpptyData(metricObject) {
139148
const { trafficacquisition } = metricObject;
140149
const deviceWiseMetrics = getFormMetrics(metricObject);
@@ -187,6 +196,7 @@ function convertToLowConversionOpptyData(metricObject) {
187196
async function convertToOpportunityData(opportunityType, metricObject, context) {
188197
const {
189198
url, pageview: { total: pageViews }, formview: { total: formViews },
199+
formsource = '',
190200
} = metricObject;
191201

192202
const {
@@ -204,12 +214,15 @@ async function convertToOpportunityData(opportunityType, metricObject, context)
204214
opportunityData = convertToLowConversionOpptyData(metricObject);
205215
} else if (opportunityType === FORM_OPPORTUNITY_TYPES.LOW_NAVIGATION) {
206216
opportunityData = convertToLowNavOpptyData(metricObject);
217+
} else if (opportunityType === FORM_OPPORTUNITY_TYPES.LOW_VIEWS) {
218+
opportunityData = convertToLowViewOpptyData(metricObject);
207219
}
208220

209221
const screenshot = await getPresignedUrl('screenshot-desktop-fullpage.png', context, url, site);
210222
opportunityData = {
211223
...opportunityData,
212224
form: url,
225+
formsource,
213226
formViews,
214227
pageViews,
215228
screenshot,
@@ -222,7 +235,8 @@ async function convertToOpportunityData(opportunityType, metricObject, context)
222235
export async function generateOpptyData(
223236
formVitals,
224237
context,
225-
opportunityTypes = [FORM_OPPORTUNITY_TYPES.LOW_CONVERSION, FORM_OPPORTUNITY_TYPES.LOW_NAVIGATION],
238+
opportunityTypes = [FORM_OPPORTUNITY_TYPES.LOW_CONVERSION,
239+
FORM_OPPORTUNITY_TYPES.LOW_NAVIGATION, FORM_OPPORTUNITY_TYPES.LOW_VIEWS],
226240
) {
227241
const formVitalsCollection = formVitals.filter(
228242
(row) => row.formengagement && row.formsubmit && row.formview,
@@ -231,6 +245,7 @@ export async function generateOpptyData(
231245
Object.entries({
232246
[FORM_OPPORTUNITY_TYPES.LOW_CONVERSION]: getHighFormViewsLowConversionMetrics,
233247
[FORM_OPPORTUNITY_TYPES.LOW_NAVIGATION]: getHighPageViewsLowFormCtrMetrics,
248+
[FORM_OPPORTUNITY_TYPES.LOW_VIEWS]: getHighPageViewsLowFormViewsMetrics,
234249
})
235250
.filter(([opportunityType]) => opportunityTypes.includes(opportunityType))
236251
.flatMap(([opportunityType, metricsMethod]) => metricsMethod(formVitalsCollection)
@@ -249,12 +264,13 @@ export function shouldExcludeForm(scrapedFormData) {
249264
* @param formOpportunities
250265
* @param scrapedData
251266
* @param log
267+
* @param excludeUrls urls to exclude from opportunity creation
252268
* @returns {*}
253269
*/
254-
export function filterForms(formOpportunities, scrapedData, log) {
270+
export function filterForms(formOpportunities, scrapedData, log, excludeUrls = new Set()) {
255271
return formOpportunities.filter((opportunity) => {
256272
let urlMatches = false;
257-
if (opportunity.form.includes('search')) {
273+
if (opportunity.form.includes('search') || excludeUrls.has(opportunity.form + opportunity.formsource)) {
258274
return false; // exclude search pages
259275
}
260276
if (isNonEmptyArray(scrapedData?.formData)) {

test/audits/forms/formcalc.test.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe('Form Calc functions', () => {
3131
pageview: { total: 8670, desktop: 4670, mobile: 4000 },
3232
url: 'https://www.surest.com/info/win',
3333
trafficacquisition: {},
34+
formsource: '.myform',
3435
},
3536
{
3637
formengagement: { total: 300, desktop: 0, mobile: 300 },
@@ -39,6 +40,7 @@ describe('Form Calc functions', () => {
3940
pageview: { total: 8670, desktop: 4670, mobile: 4000 },
4041
url: 'https://www.surest.com/newsletter',
4142
trafficacquisition: {},
43+
formsource: '',
4244
},
4345
]);
4446
});
@@ -48,15 +50,21 @@ describe('Form Calc functions', () => {
4850
expect(result).to.eql([
4951
{
5052
url: 'https://www.surest.com/info/win',
51-
pageViews: 8670,
52-
formViews: 300,
53-
formEngagement: 4300,
53+
formengagement: { total: 4300, desktop: 4000, mobile: 300 },
54+
formsubmit: { total: 0, desktop: 0, mobile: 0 },
55+
formview: { total: 300, desktop: 0, mobile: 300 },
56+
pageview: { total: 8670, desktop: 4670, mobile: 4000 },
57+
trafficacquisition: {},
58+
formsource: '.myform',
5459
},
5560
{
5661
url: 'https://www.surest.com/newsletter',
57-
pageViews: 8670,
58-
formViews: 300,
59-
formEngagement: 300,
62+
formengagement: { total: 300, desktop: 0, mobile: 300 },
63+
formsubmit: { total: 0, desktop: 0, mobile: 0 },
64+
formview: { total: 300, desktop: 0, mobile: 300 },
65+
pageview: { total: 8670, desktop: 4670, mobile: 4000 },
66+
trafficacquisition: {},
67+
formsource: '',
6068
},
6169
]);
6270
});
@@ -70,6 +78,7 @@ describe('Form Calc functions', () => {
7078
formview: { total: 300, desktop: 0, mobile: 300 },
7179
formengagement: { total: 300, desktop: 0, mobile: 300 },
7280
formsubmit: { total: 0, desktop: 0, mobile: 0 },
81+
formsource: '',
7382
trafficacquisition: {},
7483
CTA: {
7584
url: 'https://www.surest.com/about-us',

test/audits/forms/low-conv-oppoty-handler.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,15 @@ describe('createLowConversionOpportunities handler method', () => {
185185
expect(dataAccessStub.Opportunity.create).to.be.calledWith(testData.opportunityData5);
186186
expect(logStub.info).to.be.calledWith('Successfully synced Opportunity for site: site-id and high-form-views-low-conversions audit type.');
187187
});
188+
189+
it('should not create low conversion opportunity if another opportunity already exists', async () => {
190+
const excludeUrls = new Set();
191+
excludeUrls.add('https://www.surest.com/newsletter');
192+
excludeUrls.add('https://www.surest.com/info/win-1.form');
193+
await createLowConversionOpportunities(auditUrl, auditData, undefined, context, excludeUrls);
194+
expect(dataAccessStub.Opportunity.create).to.be.callCount(3);
195+
expect(excludeUrls.has('https://www.surest.com/contact-us.mycontact')).to.be.true;
196+
expect(excludeUrls.has('https://www.surest.com/info/win-2')).to.be.true;
197+
expect(excludeUrls.has('https://www.surest.com/info/win')).to.be.true;
198+
});
188199
});

0 commit comments

Comments
 (0)