diff --git a/src/api/forms/service/__stubs__/metrics.js b/src/api/forms/service/__stubs__/metrics.js index d72b7d83..74e2c7c9 100644 --- a/src/api/forms/service/__stubs__/metrics.js +++ b/src/api/forms/service/__stubs__/metrics.js @@ -23,7 +23,17 @@ export function getExpectedOverviewMetrics(timestamp) { slug: 'form-1-title', status: 'draft' }, - featureCounts: {}, + featureMetrics: { + features: { Sections: 1 }, + formStructure: { + conditions: 0, + pages: 1, + questionTypes: 0, + questions: 0, + sections: 1 + }, + questionTypes: {} + }, submissionsCount: 0, updatedAt: timestamp }, @@ -37,7 +47,17 @@ export function getExpectedOverviewMetrics(timestamp) { slug: 'form-2-title', status: 'draft' }, - featureCounts: {}, + featureMetrics: { + features: { Sections: 1 }, + formStructure: { + conditions: 0, + pages: 1, + questionTypes: 0, + questions: 0, + sections: 1 + }, + questionTypes: {} + }, submissionsCount: 0, updatedAt: timestamp }, @@ -51,7 +71,17 @@ export function getExpectedOverviewMetrics(timestamp) { slug: 'form-3-title', status: 'draft' }, - featureCounts: {}, + featureMetrics: { + features: {}, + formStructure: { + conditions: 0, + pages: 1, + questionTypes: 0, + questions: 0, + sections: 0 + }, + questionTypes: {} + }, submissionsCount: 0, updatedAt: timestamp } @@ -67,7 +97,17 @@ export function getExpectedOverviewMetrics(timestamp) { slug: 'form-2-title', status: 'live' }, - featureCounts: {}, + featureMetrics: { + features: { Sections: 1 }, + formStructure: { + conditions: 0, + pages: 1, + questionTypes: 0, + questions: 0, + sections: 1 + }, + questionTypes: {} + }, submissionsCount: 0, updatedAt: timestamp } diff --git a/src/api/forms/service/report-overview.js b/src/api/forms/service/report-overview.js index fef36c9c..ae345bf2 100644 --- a/src/api/forms/service/report-overview.js +++ b/src/api/forms/service/report-overview.js @@ -131,7 +131,7 @@ export function collectOverviewMetrics(metadata, definition, definitionType) { formId: metadata.id, formStatus: metadata.live ? FormStatus.Live : FormStatus.Draft, summaryMetrics: calcSummaryMetrics(metadata, definition, definitionType), - featureCounts: {}, + featureMetrics: calcFeatureMetrics(definition), submissionsCount: 0, updatedAt: new Date() } @@ -157,6 +157,73 @@ export function calcSummaryMetrics(metadata, definition, definitionType) { }) } +/** + * @param {FormDefinition} definition + */ +export function calcFeatureMetrics(definition) { + const allComponents = /** @type {ComponentDef[]} */ ([]) + for (const page of definition.pages) { + if (hasComponentsEvenIfNoNext(page)) { + allComponents.push(...page.components) + } + } + const questionTypes = getQuestionTypeCounts(allComponents) + return { + questionTypes: Object.fromEntries(questionTypes), + features: getComponentUsageFeatureMetrics(definition), + formStructure: getFormStructureCounts(definition, questionTypes) + } +} + +/** + * @param {ComponentDef[]} components + */ +export function getQuestionTypeCounts(components) { + const componentCounts = /** @type {Map} */ (new Map()) + for (const component of components) { + const count = componentCounts.get(component.type) ?? 0 + componentCounts.set(component.type, count + 1) + } + return componentCounts +} + +/** + * @param {FormDefinition} definition + */ +export function getComponentUsageFeatureMetrics(definition) { + const features = getFeatureList(definition) + if (definition.pages.some((p) => p.section)) { + features.push('Sections') + } + if (definition.pages.some((p) => p.condition)) { + features.push('Conditional logic') + } + const featureResult = /** @type {Record} */ ({}) + features.forEach((f) => { + featureResult[f] = 1 + }) + return featureResult +} + +/** + * @param {FormDefinition} definition + * @param {Map} questionTypes + */ +export function getFormStructureCounts(definition, questionTypes) { + let numOfQuestions = 0 + questionTypes.forEach((value) => { + numOfQuestions += value + }) + + return { + pages: definition.pages.length, + questions: numOfQuestions, + sections: definition.pages.filter((p) => p.section).length, + conditions: definition.pages.filter((p) => p.condition).length, + questionTypes: questionTypes.size + } +} + /** * @param {FormDefinition} definition */ @@ -201,5 +268,5 @@ export function getUniqueComponentTypes(definition) { /** * @import { ClientSession } from 'mongodb' - * @import { FormDefinition, FormMetadata } from '@defra/forms-model' + * @import { ComponentDef, FormDefinition, FormMetadata } from '@defra/forms-model' */ diff --git a/src/api/forms/service/report-overview.test.js b/src/api/forms/service/report-overview.test.js index 2539e737..140a57b5 100644 --- a/src/api/forms/service/report-overview.test.js +++ b/src/api/forms/service/report-overview.test.js @@ -20,9 +20,12 @@ import * as formDefinition from '~/src/api/forms/repositories/form-definition-re import { getMetadataCursorOfAllForms } from '~/src/api/forms/repositories/form-metadata-repository.js' import { getExpectedOverviewMetrics } from '~/src/api/forms/service/__stubs__/metrics.js' import { + calcFeatureMetrics, generateReportOverview, + getComponentUsageFeatureMetrics, getDefinitionIfExists, getFeatureList, + getQuestionTypeCounts, getUniqueComponentTypes } from '~/src/api/forms/service/report-overview.js' import { client } from '~/src/mongo.js' @@ -113,12 +116,22 @@ describe('report-overview', () => { // @ts-expect-error - resolves to an async iterator like FindCursor .mockReturnValueOnce(mockAsyncIterator) + const pageWithSection = /** @type {FormDefinition} */ ({ + pages: [{ section: 'abc' }] + }) + // Form 1 - draft and no live - jest.mocked(formDefinition.get).mockResolvedValueOnce(buildDefinition({})) + jest + .mocked(formDefinition.get) + .mockResolvedValueOnce(buildDefinition(pageWithSection)) // Form 2 - draft and live - jest.mocked(formDefinition.get).mockResolvedValueOnce(buildDefinition({})) - jest.mocked(formDefinition.get).mockResolvedValueOnce(buildDefinition({})) + jest + .mocked(formDefinition.get) + .mockResolvedValueOnce(buildDefinition(pageWithSection)) + jest + .mocked(formDefinition.get) + .mockResolvedValueOnce(buildDefinition(pageWithSection)) // Form 3 - draft and no live jest.mocked(formDefinition.get).mockResolvedValueOnce(buildDefinition({})) @@ -245,6 +258,148 @@ describe('report-overview', () => { }) }) + describe('calcFeatureMetrics', () => { + it('should return calculated metrics', () => { + const summaryPage = buildSummaryPage({ + // @ts-expect-error - forcing the controller type + controller: ControllerType.SummaryWithConfirmationEmail + }) + const questionPageId = 'd9c99072-d25d-4688-ab7d-3822cffe802b' + const questionPage = buildQuestionPage({ + id: questionPageId, + components: [ + buildTextFieldComponent(), + buildTextFieldComponent(), + buildCheckboxComponent(), + buildTextFieldComponent(), + buildCheckboxComponent(), + buildRadioComponent(), + buildDeclarationFieldComponent() + ] + }) + const fileUploadPage = buildFileUploadPage() + const paymentPage = buildQuestionPage({ + components: [buildPaymentComponent()] + }) + const sectionPage1 = buildQuestionPage({ + section: 'some-section-id1' + }) + const sectionPage2 = buildQuestionPage({ + section: 'some-section-id2' + }) + + const definition = buildDefinition({ + pages: [ + questionPage, + fileUploadPage, + sectionPage1, + sectionPage2, + paymentPage, + summaryPage + ] + }) + expect(calcFeatureMetrics(definition)).toEqual({ + features: { + 'File upload': 1, + 'Email confirmation': 1, + 'GOV.UK Pay': 1, + Declarations: 1, + Sections: 1 + }, + formStructure: { + conditions: 0, + pages: 6, + questionTypes: 6, + questions: 9, + sections: 2 + }, + questionTypes: { + CheckboxesField: 2, + DeclarationField: 1, + FileUploadField: 1, + PaymentField: 1, + RadiosField: 1, + TextField: 3 + } + }) + }) + }) + + describe('getQuestionTypeCounts', () => { + it('should return counts', () => { + const components = [ + buildTextFieldComponent(), + buildTextFieldComponent(), + buildCheckboxComponent(), + buildTextFieldComponent(), + buildRadioComponent(), + buildCheckboxComponent(), + buildTextFieldComponent(), + buildRadioComponent(), + buildCheckboxComponent(), + buildTextFieldComponent(), + buildDeclarationFieldComponent() + ] + expect(Object.fromEntries(getQuestionTypeCounts(components))).toEqual({ + CheckboxesField: 3, + DeclarationField: 1, + RadiosField: 2, + TextField: 5 + }) + }) + }) + + describe('get component usage features', () => { + it('should return list of features', () => { + const summaryPage = buildSummaryPage({ + // @ts-expect-error - forcing the controller type + controller: ControllerType.SummaryWithConfirmationEmail + }) + const questionPageId = 'd9c99072-d25d-4688-ab7d-3822cffe802b' + const questionPage = buildQuestionPage({ + id: questionPageId, + components: [ + buildTextFieldComponent(), + buildTextFieldComponent(), + buildCheckboxComponent(), + buildTextFieldComponent(), + buildCheckboxComponent(), + buildRadioComponent(), + buildDeclarationFieldComponent() + ] + }) + const fileUploadPage = buildFileUploadPage() + const paymentPage = buildQuestionPage({ + components: [buildPaymentComponent()] + }) + const conditionPage = buildQuestionPage({ + condition: 'some-condition-id' + }) + const sectionPage = buildQuestionPage({ + section: 'some-section-id' + }) + + const definition = buildDefinition({ + pages: [ + questionPage, + fileUploadPage, + paymentPage, + conditionPage, + sectionPage, + summaryPage + ] + }) + expect(getComponentUsageFeatureMetrics(definition)).toEqual({ + 'File upload': 1, + 'Email confirmation': 1, + 'GOV.UK Pay': 1, + Declarations: 1, + Sections: 1, + 'Conditional logic': 1 + }) + }) + }) + describe('getDefinitionIfExists', () => { it('should not throw if error is NOT_FOUND', async () => { jest.mocked(formDefinition.get).mockImplementationOnce(() => { @@ -268,3 +423,7 @@ describe('report-overview', () => { }) }) }) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */