Skip to content

Commit

Permalink
feat: survey previews now support branching
Browse files Browse the repository at this point in the history
  • Loading branch information
lucasheriques committed Jan 17, 2025
1 parent 579408c commit 9efaf48
Show file tree
Hide file tree
Showing 3 changed files with 306 additions and 1 deletion.
12 changes: 11 additions & 1 deletion frontend/src/scenes/surveys/SurveyFormAppearance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Survey, SurveyType } from '~/types'
import { NewSurvey } from './constants'
import { SurveyAPIEditor } from './SurveyAPIEditor'
import { SurveyAppearancePreview } from './SurveyAppearancePreview'
import { getNextQuestionIndex } from './utils/branchingPreviewLogic'

interface SurveyFormAppearanceProps {
previewPageIndex: number
Expand All @@ -23,7 +24,16 @@ export function SurveyFormAppearance({
<SurveyAppearancePreview
survey={survey as Survey}
previewPageIndex={previewPageIndex}
onPreviewSubmit={() => handleSetSelectedPageIndex(previewPageIndex + 1)}
onPreviewSubmit={(response) => {
handleSetSelectedPageIndex(
getNextQuestionIndex(
survey.questions[previewPageIndex],
survey.questions.length,
previewPageIndex,
response
)
)
}}
/>
<LemonSelect
onChange={(pageIndex) => handleSetSelectedPageIndex(pageIndex)}
Expand Down
216 changes: 216 additions & 0 deletions frontend/src/scenes/surveys/utils/branchingPreviewLogic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { SurveyQuestionBranchingType, SurveyQuestionType } from '~/types'

import { getNextQuestionIndex, getRatingScaleResponse } from './branchingPreviewLogic'

describe('getRatingScaleResponse', () => {
describe('10-point NPS scale', () => {
it('correctly identifies detractors (0-6)', () => {
for (let i = 0; i <= 6; i++) {
expect(getRatingScaleResponse(i, 10)).toBe('detractors')
}
})

it('correctly identifies passives (7-8)', () => {
for (let i = 7; i <= 8; i++) {
expect(getRatingScaleResponse(i, 10)).toBe('passives')
}
})

it('correctly identifies promoters (9-10)', () => {
for (let i = 9; i <= 10; i++) {
expect(getRatingScaleResponse(i, 10)).toBe('promoters')
}
})
})

describe('7-point scale', () => {
it('correctly identifies negative responses (1-3)', () => {
for (let i = 1; i <= 3; i++) {
expect(getRatingScaleResponse(i, 7)).toBe('negative')
}
})

it('correctly identifies neutral responses (4)', () => {
expect(getRatingScaleResponse(4, 7)).toBe('neutral')
})

it('correctly identifies positive responses (5-7)', () => {
for (let i = 5; i <= 7; i++) {
expect(getRatingScaleResponse(i, 7)).toBe('positive')
}
})
})

describe('5-point scale', () => {
it('correctly identifies negative responses (1-2)', () => {
for (let i = 1; i <= 2; i++) {
expect(getRatingScaleResponse(i, 5)).toBe('negative')
}
})

it('correctly identifies neutral responses (3)', () => {
expect(getRatingScaleResponse(3, 5)).toBe('neutral')
})

it('correctly identifies positive responses (4-5)', () => {
for (let i = 4; i <= 5; i++) {
expect(getRatingScaleResponse(i, 5)).toBe('positive')
}
})
})
it('returns neutral for unknown scales', () => {
expect(getRatingScaleResponse(1, 4)).toBe('neutral')
})
})

describe('getNextQuestionIndex', () => {
const confirmationMessageIndex = 999
const currentIndex = 1

describe('basic branching types', () => {
it('returns next question index when no branching is defined', () => {
const question = {
type: SurveyQuestionType.Rating as const,
question: 'Test question',
description: '',
descriptionContentType: 'text' as const,
display: 'number' as const,
scale: 10,
lowerBoundLabel: 'low',
upperBoundLabel: 'high',
}
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 5)).toBe(currentIndex + 1)
})

it('returns next question index for NextQuestion branching type', () => {
const question = {
type: SurveyQuestionType.Rating as const,
question: 'Test question',
description: '',
descriptionContentType: 'text' as const,
display: 'number' as const,
scale: 10,
lowerBoundLabel: 'low',
upperBoundLabel: 'high',
branching: { type: SurveyQuestionBranchingType.NextQuestion as const },
}
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 5)).toBe(currentIndex + 1)
})

it('returns confirmation message index for End branching type', () => {
const question = {
type: SurveyQuestionType.Rating as const,
question: 'Test question',
description: '',
descriptionContentType: 'text' as const,
display: 'number' as const,
scale: 10,
lowerBoundLabel: 'low',
upperBoundLabel: 'high',
branching: { type: SurveyQuestionBranchingType.End as const },
}
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 5)).toBe(
confirmationMessageIndex
)
})

it('returns specific question index for SpecificQuestion branching type', () => {
const targetIndex = 5
const question = {
type: SurveyQuestionType.Rating as const,
question: 'Test question',
description: '',
descriptionContentType: 'text' as const,
display: 'number' as const,
scale: 10,
lowerBoundLabel: 'low',
upperBoundLabel: 'high',
branching: {
type: SurveyQuestionBranchingType.SpecificQuestion as const,
index: targetIndex,
},
}
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 5)).toBe(targetIndex)
})
})

describe('response-based branching', () => {
describe('rating questions', () => {
it('handles rating question branching based on response', () => {
const question = {
type: SurveyQuestionType.Rating as const,
question: 'Test question',
description: '',
descriptionContentType: 'text' as const,
display: 'number' as const,
scale: 10,
lowerBoundLabel: 'low',
upperBoundLabel: 'high',
branching: {
type: SurveyQuestionBranchingType.ResponseBased as const,
responseValues: {
detractors: 2,
passives: 3,
promoters: SurveyQuestionBranchingType.End,
},
},
}
// Test detractor response (0-6)
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 5)).toBe(2)
// Test passive response (7-8)
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 7)).toBe(3)
// Test promoter response (9-10)
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 9)).toBe(
confirmationMessageIndex
)
})
})

describe('single choice questions', () => {
it('handles single choice question branching based on response', () => {
const question = {
type: SurveyQuestionType.SingleChoice as const,
question: 'Test question',
description: '',
descriptionContentType: 'text' as const,
choices: ['Option A', 'Option B', 'Option C'],
branching: {
type: SurveyQuestionBranchingType.ResponseBased as const,
responseValues: {
0: 2,
1: SurveyQuestionBranchingType.End,
2: 3,
},
},
}
// Test Option A response
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 'Option A')).toBe(2)
// Test Option B response
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 'Option B')).toBe(
confirmationMessageIndex
)
// Test Option C response
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, 'Option C')).toBe(3)
})
})
})

describe('fallback behavior', () => {
it('returns next question index for unsupported question types with response-based branching', () => {
const question = {
type: SurveyQuestionType.MultipleChoice as const,
question: 'Test question',
description: '',
descriptionContentType: 'text' as const,
choices: ['Option A', 'Option B', 'Option C'],
branching: {
type: SurveyQuestionBranchingType.ResponseBased as const,
responseValues: {},
},
}
expect(getNextQuestionIndex(question, confirmationMessageIndex, currentIndex, ['Option A'])).toBe(
currentIndex + 1
)
})
})
})
79 changes: 79 additions & 0 deletions frontend/src/scenes/surveys/utils/branchingPreviewLogic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
BasicSurveyQuestion,
LinkSurveyQuestion,
MultipleSurveyQuestion,
RatingSurveyQuestion,
SurveyQuestionBranchingType,
SurveyQuestionType,
} from '~/types'

export function getRatingScaleResponse(response: number, scale: number): string {
switch (scale) {
case 10: // NPS scale
if (response <= 6) {
return 'detractors'
}
if (response <= 8) {
return 'passives'
}
return 'promoters'
case 7: // 7-point likert scale
if (response <= 3) {
return 'negative'
}
if (response === 4) {
return 'neutral'
}
return 'positive'
case 5: // 5-point scale
if (response <= 2) {
return 'negative'
}
if (response === 3) {
return 'neutral'
}
return 'positive'
default:
return 'neutral'
}
}

export function getNextQuestionIndex(
currentQuestion: RatingSurveyQuestion | MultipleSurveyQuestion | BasicSurveyQuestion | LinkSurveyQuestion,
confirmationMessageIndex: number,
currentIndex: number,
response: string | string[] | number | null
): number {
if (!currentQuestion.branching || currentQuestion.branching.type === SurveyQuestionBranchingType.NextQuestion) {
return currentIndex + 1
}

if (currentQuestion.branching.type === SurveyQuestionBranchingType.End) {
return confirmationMessageIndex
}

if (currentQuestion.branching.type === SurveyQuestionBranchingType.SpecificQuestion) {
return currentQuestion.branching.index
}

if (currentQuestion.branching.type === SurveyQuestionBranchingType.ResponseBased) {
if (currentQuestion.type === SurveyQuestionType.Rating) {
// safe to assume response as number, since it's a Rating question
const responseKey = getRatingScaleResponse(response as number, currentQuestion.scale)
if (currentQuestion.branching.responseValues[responseKey] === SurveyQuestionBranchingType.End) {
return confirmationMessageIndex
}
return currentQuestion.branching.responseValues[responseKey]
}
if (currentQuestion.type === SurveyQuestionType.SingleChoice) {
// safe to assume response as string, since it's a SingleChoice question
const responseKey = currentQuestion.choices.indexOf(response as string)
if (currentQuestion.branching.responseValues[responseKey] === SurveyQuestionBranchingType.End) {
return confirmationMessageIndex
}
return currentQuestion.branching.responseValues[responseKey]
}
}

return currentIndex + 1
}

0 comments on commit 9efaf48

Please sign in to comment.