From a0b1c8a533525f89033622637f4292a58c452ff8 Mon Sep 17 00:00:00 2001 From: Li Yi Yu Date: Wed, 22 Nov 2023 18:05:33 -0500 Subject: [PATCH] feat(surveys): Add open-ended choices for multiple and single choice surveys (#910) * feat(surveys) Add open-ended choices for multiple and single choice surveys * Change multiple and single choice questions to have has_open_choice field * Change has_open_choice to hasOpenChoice * Remove border around text input --------- Co-authored-by: Soon-Mi Sugihara --- src/__tests__/extensions/surveys.js | 99 +++++++++++++++++++++++++++++ src/extensions/surveys.ts | 95 +++++++++++++++++++++------ src/posthog-surveys-types.ts | 1 + 3 files changed, 176 insertions(+), 19 deletions(-) diff --git a/src/__tests__/extensions/surveys.js b/src/__tests__/extensions/surveys.js index 4d8a24246..06c69ba44 100644 --- a/src/__tests__/extensions/surveys.js +++ b/src/__tests__/extensions/surveys.js @@ -279,4 +279,103 @@ describe('survey display logic', () => { expect(ratingQuestion2.querySelectorAll('.question-0-rating-0').length).toBe(0) expect(ratingQuestion2.querySelectorAll('.question-0-rating-1').length).toBe(1) }) + + test('open choice value on a multiple choice question is determined by a text input', () => { + mockSurveys = [ + { + id: 'testSurvey2', + name: 'Test survey 2', + appearance: null, + questions: [ + { + question: 'Which types of content would you like to see more of?', + description: 'This is a question description', + type: 'multiple_choice', + choices: ['Tutorials', 'Product Updates', 'Events', 'Other'], + hasOpenChoice: true, + }, + ], + }, + ] + const singleQuestionSurveyForm = createMultipleQuestionSurvey(mockPostHog, mockSurveys[0]) + + const checkboxInputs = singleQuestionSurveyForm + .querySelector('.tab.question-0') + .querySelectorAll('input[type=checkbox]') + let checkboxInputValues = [...checkboxInputs].map((input) => input.value) + expect(checkboxInputValues).toEqual(['Tutorials', 'Product Updates', 'Events', '']) + const openChoiceTextInput = singleQuestionSurveyForm + .querySelector('.tab.question-0') + .querySelector('input[type=text]') + openChoiceTextInput.value = 'NEW VALUE 1' + openChoiceTextInput.dispatchEvent(new Event('input')) + expect(singleQuestionSurveyForm.querySelector('.form-submit').disabled).toEqual(false) + checkboxInputValues = [...checkboxInputs].map((input) => input.value) + expect(checkboxInputValues).toEqual(['Tutorials', 'Product Updates', 'Events', 'NEW VALUE 1']) + checkboxInputs[0].click() + const checkboxInputsChecked = [...checkboxInputs].map((input) => input.checked) + expect(checkboxInputsChecked).toEqual([true, false, false, true]) + + singleQuestionSurveyForm.dispatchEvent(new Event('submit')) + expect(mockPostHog.capture).toBeCalledTimes(1) + expect(mockPostHog.capture).toBeCalledWith('survey sent', { + $survey_name: 'Test survey 2', + $survey_id: 'testSurvey2', + $survey_questions: ['Which types of content would you like to see more of?'], + $survey_response: ['Tutorials', 'NEW VALUE 1'], + sessionRecordingUrl: undefined, + $set: { + ['$survey_responded/testSurvey2']: true, + }, + }) + }) + + test('open choice value on a single choice question is determined by a text input', () => { + mockSurveys = [ + { + id: 'testSurvey2', + name: 'Test survey 2', + appearance: null, + questions: [ + { + question: 'Which features do you use the most?', + description: 'This is a question description', + type: 'single_choice', + choices: ['Surveys', 'Feature flags', 'Analytics', 'Another Feature'], + hasOpenChoice: true, + }, + ], + }, + ] + const singleQuestionSurveyForm = createMultipleQuestionSurvey(mockPostHog, mockSurveys[0]) + + const radioInputs = singleQuestionSurveyForm + .querySelector('.tab.question-0') + .querySelectorAll('input[type=radio]') + let radioInputValues = [...radioInputs].map((input) => input.value) + expect(radioInputValues).toEqual(['Surveys', 'Feature flags', 'Analytics', '']) + const openChoiceTextInput = singleQuestionSurveyForm + .querySelector('.tab.question-0') + .querySelector('input[type=text]') + openChoiceTextInput.value = 'NEW VALUE 2' + openChoiceTextInput.dispatchEvent(new Event('input')) + expect(singleQuestionSurveyForm.querySelector('.form-submit').disabled).toEqual(false) + radioInputValues = [...radioInputs].map((input) => input.value) + expect(radioInputValues).toEqual(['Surveys', 'Feature flags', 'Analytics', 'NEW VALUE 2']) + const radioInputsChecked = [...radioInputs].map((input) => input.checked) + expect(radioInputsChecked).toEqual([false, false, false, true]) + + singleQuestionSurveyForm.dispatchEvent(new Event('submit')) + expect(mockPostHog.capture).toBeCalledTimes(1) + expect(mockPostHog.capture).toBeCalledWith('survey sent', { + $survey_name: 'Test survey 2', + $survey_id: 'testSurvey2', + $survey_questions: ['Which features do you use the most?'], + $survey_response: 'NEW VALUE 2', + sessionRecordingUrl: undefined, + $set: { + ['$survey_responded/testSurvey2']: true, + }, + }) + }) }) diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts index 6cfab5d7c..409dc93ca 100644 --- a/src/extensions/surveys.ts +++ b/src/extensions/surveys.ts @@ -261,12 +261,13 @@ const style = (id: string, appearance: SurveyAppearance | null) => { display: inline-block; opacity: 100% !important; } - .multiple-choice-options input[type=checkbox]:checked + label { - font-weight: bold; - } .multiple-choice-options input:checked + label { + font-weight: bold; border: 1.5px solid rgba(0,0,0); } + .multiple-choice-options input:checked + label input { + font-weight: bold; + } .multiple-choice-options label { width: 100%; cursor: pointer; @@ -275,6 +276,26 @@ const style = (id: string, appearance: SurveyAppearance | null) => { border-radius: 4px; background: white; } + .multiple-choice-options .choice-option-open label { + padding-right: 30px; + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: 100%; + } + .multiple-choice-options .choice-option-open label span { + width: 100%; + } + .multiple-choice-options .choice-option-open input:disabled + label { + opacity: 0.6; + } + .multiple-choice-options .choice-option-open label input { + position: relative; + opacity: 1; + flex-grow: 1; + border: 0; + outline: 0; + } .thank-you-message { position: fixed; bottom: 0px; @@ -600,8 +621,9 @@ export const createMultipleChoicePopup = ( const surveyQuestion = question.question const surveyDescription = question.description const surveyQuestionChoices = question.choices - const singleOrMultiSelect = question.type + const isSingleChoice = question.type === 'single_choice' const isOptional = !!question.optional + const hasOpenChoice = !!question.hasOpenChoice const form = `
@@ -613,9 +635,22 @@ export const createMultipleChoicePopup = (
${surveyQuestionChoices .map((option, idx) => { - const inputType = singleOrMultiSelect === 'single_choice' ? 'radio' : 'checkbox' - const singleOrMultiSelectString = `
- ${checkSVG}
` + let choiceClass = 'choice-option' + let val = option + if (hasOpenChoice && idx === surveyQuestionChoices.length - 1) { + option = `${option}:` + choiceClass += ' choice-option-open' + val = '' + } + const inputType = isSingleChoice ? 'radio' : 'checkbox' + const singleOrMultiSelectString = `
+ + + ${checkSVG} +
` return singleOrMultiSelectString }) .join(' ')} @@ -639,14 +674,13 @@ export const createMultipleChoicePopup = ( onsubmit: (e: Event) => { e.preventDefault() const targetElement = e.target as HTMLFormElement - const selectedChoices = - singleOrMultiSelect === 'single_choice' - ? (targetElement.querySelector('input[type=radio]:checked') as HTMLInputElement)?.value - : [ - ...(targetElement.querySelectorAll( - 'input[type=checkbox]:checked' - ) as NodeListOf), - ].map((choice) => choice.value) + const selectedChoices = isSingleChoice + ? (targetElement.querySelector('input[type=radio]:checked') as HTMLInputElement)?.value + : [ + ...(targetElement.querySelectorAll( + 'input[type=checkbox]:checked' + ) as NodeListOf), + ].map((choice) => choice.value) posthog.capture('survey sent', { $survey_name: survey.name, $survey_id: survey.id, @@ -670,10 +704,9 @@ export const createMultipleChoicePopup = ( } if (!isOptional) { formElement.addEventListener('change', () => { - const selectedChoices: NodeListOf = - singleOrMultiSelect === 'single_choice' - ? formElement.querySelectorAll('input[type=radio]:checked') - : formElement.querySelectorAll('input[type=checkbox]:checked') + const selectedChoices: NodeListOf = isSingleChoice + ? formElement.querySelectorAll('input[type=radio]:checked') + : formElement.querySelectorAll('input[type=checkbox]:checked') if ((selectedChoices.length ?? 0) > 0) { ;(formElement.querySelector('.form-submit') as HTMLButtonElement).disabled = false } else { @@ -681,6 +714,30 @@ export const createMultipleChoicePopup = ( } }) } + const openChoiceWrappers = formElement.querySelectorAll('.choice-option-open') + for (const openChoiceWrapper of openChoiceWrappers) { + const textInput = openChoiceWrapper.querySelector('input[type=text]') as HTMLInputElement + const inputType = isSingleChoice ? 'radio' : 'checkbox' + const checkInput = openChoiceWrapper.querySelector(`input[type=${inputType}]`) as HTMLInputElement + openChoiceWrapper.addEventListener('click', () => { + if (checkInput?.checked || checkInput?.disabled) textInput?.focus() + }) + textInput.addEventListener('click', (e) => e.stopPropagation()) + textInput.addEventListener('input', (e) => { + const textInput = e.target as HTMLInputElement + if (checkInput) { + checkInput.value = textInput.value + if (textInput.value) { + checkInput.disabled = false + checkInput.checked = true + } else { + checkInput.disabled = true + checkInput.checked = false + } + formElement.dispatchEvent(new Event('change')) + } + }) + } return formElement } diff --git a/src/posthog-surveys-types.ts b/src/posthog-surveys-types.ts index 30b122b9f..077dfc0eb 100644 --- a/src/posthog-surveys-types.ts +++ b/src/posthog-surveys-types.ts @@ -62,6 +62,7 @@ export interface RatingSurveyQuestion extends SurveyQuestionBase { export interface MultipleSurveyQuestion extends SurveyQuestionBase { type: SurveyQuestionType.SingleChoice | SurveyQuestionType.MultipleChoice choices: string[] + hasOpenChoice?: boolean } export enum SurveyQuestionType {