diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts index 17805c09a8684..641f9b7a9a2b3 100644 --- a/cypress/e2e/surveys.cy.ts +++ b/cypress/e2e/surveys.cy.ts @@ -165,4 +165,58 @@ describe('Surveys', () => { cy.get('[data-attr=delete-survey]').click() cy.get('.Toastify__toast-body').contains('Survey deleted').should('be.visible') }) + + it('creates a new multiple choice survey with an open-ended choice', () => { + cy.get('h1').should('contain', 'Surveys') + cy.get('[data-attr=new-survey]').click() + cy.get('[data-attr=new-blank-survey]').click() + + // add a multiple choice question with an open-ended question + cy.get('[data-attr=survey-name]').focus().type(name).should('have.value', name) + cy.get('[data-attr="survey-question-type-0"]').click() + cy.contains('Multiple choice select').click() + cy.get('button').contains('Add open-ended choice').click() + + // check default open-ended choice form input and appearance after + // open-ended choice was added + cy.get('.LemonInput__input[value="Other"]') + cy.get('.choice-option').eq(3).contains('Other:') + cy.get('.choice-option').eq(3).find('input[type="text"]').should('have.value', '') + + // typing in open-ended question's appearance automatically checks the + // checkbox + cy.get('.choice-option').eq(3).find('input[type="checkbox"]').should('not.be.checked') + cy.get('.choice-option').eq(3).find('input[type="text"]').type('Outreach') + cy.get('.choice-option').eq(3).find('input[type="checkbox"]').should('be.checked') + + // clicking on open-ended question's appearance label unchecks or checks + // the checkbox + cy.get('.choice-option').eq(3).click() + cy.get('.choice-option').eq(3).find('input[type="checkbox"]').should('not.be.checked') + cy.get('.choice-option').eq(3).click() + cy.get('.choice-option').eq(3).find('input[type="checkbox"]').should('be.checked') + + // removing text in open-ended question's appearance automatically + // unchecks the checkbox + cy.get('.choice-option').eq(3).find('input[type="text"]').clear() + + // open-ended question label doesn't change even if appearance input + // changes + cy.get('.LemonInput__input[value="Other"]') + + // change open-ended choice after the label was added + cy.contains('Choices').parent().find('input[type="text"]').eq(3).clear() + cy.contains('Choices').parent().find('input[type="text"]').eq(3).type('First Choice') + cy.get('.choice-option').eq(3).contains('First Choice:') + + // attempt to create and save survey + cy.get('[data-attr=save-survey]').first().click() + + // after save there should be a launch button + cy.get('button[data-attr="launch-survey"]').should('have.text', 'Launch') + + cy.clickNavMenu('surveys') + cy.get('[data-attr=surveys-table]').should('contain', name) + cy.get(`[data-row-key="${name}"]`).contains(name).click() + }) }) diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index b2ce0bb71285c..ce2feaa10d600 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -33,6 +33,7 @@ beforeEach(() => { 'surveys-new-creation-flow': true, 'surveys-results-visualizations': true, 'auto-redirect': true, + 'surveys-open-choice': true, notebooks: true, }) ) diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 69c5a65aaa2f8..e49b8f3f9387b 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -178,6 +178,7 @@ export const FEATURE_FLAGS = { NETWORK_PAYLOAD_CAPTURE: 'network-payload-capture', // owner: #team-monitoring FEATURE_FLAG_COHORT_CREATION: 'feature-flag-cohort-creation', // owner: @neilkakkar #team-feature-success INSIGHT_HORIZONTAL_CONTROLS: 'insight-horizontal-controls', // owner: @benjackwhite + SURVEYS_OPEN_CHOICE: 'surveys-open-choice', // owner: @ssoonmi, #team-feature-success } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/scenes/surveys/EditSurvey.scss b/frontend/src/scenes/surveys/EditSurvey.scss index 2995d06667887..67fe48acdefa9 100644 --- a/frontend/src/scenes/surveys/EditSurvey.scss +++ b/frontend/src/scenes/surveys/EditSurvey.scss @@ -7,3 +7,12 @@ background: var(--border-light); } } + +.question-choice-open-ended-footer { + position: absolute; + bottom: -5px; + left: 6px; + font-size: 10px; + background-color: var(--bg-3000); + padding: 0 5px; +} diff --git a/frontend/src/scenes/surveys/SurveyAppearance.scss b/frontend/src/scenes/surveys/SurveyAppearance.scss index 32ee384fc1d83..7dd612f0a5e4e 100644 --- a/frontend/src/scenes/surveys/SurveyAppearance.scss +++ b/frontend/src/scenes/surveys/SurveyAppearance.scss @@ -226,12 +226,13 @@ opacity: 1 !important; } -.multiple-choice-options input[type='checkbox']:checked + label { +.multiple-choice-options input:checked + label { font-weight: bold; + border: 1.5px solid rgb(0 0 0 / 100%); } -.multiple-choice-options input:checked + label { - border: 1.5px solid rgb(0 0 0); +.multiple-choice-options input:checked + label input { + font-weight: bold; } .multiple-choice-options label { @@ -243,6 +244,26 @@ 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 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: relative; bottom: 0; diff --git a/frontend/src/scenes/surveys/SurveyAppearance.tsx b/frontend/src/scenes/surveys/SurveyAppearance.tsx index c98574af938d9..81067c0ea06d1 100644 --- a/frontend/src/scenes/surveys/SurveyAppearance.tsx +++ b/frontend/src/scenes/surveys/SurveyAppearance.tsx @@ -529,6 +529,64 @@ export function SurveyRatingAppearance({ ) } +const OpenEndedChoice = ({ + label, + initialChecked, + inputType, + index, +}: { + label: string + initialChecked: boolean + inputType: string + textColor: string + index: number +}): JSX.Element => { + const textRef = useRef(null) + const checkRef = useRef(null) + + return ( +
{ + if (checkRef.current?.checked || checkRef.current?.disabled) { + textRef.current?.focus() + } + }} + > + + + {check} +
+ ) +} + export function SurveyMultipleChoiceAppearance({ multipleChoiceQuestion, appearance, @@ -587,18 +645,29 @@ export function SurveyMultipleChoiceAppearance({ /> )}
- {(multipleChoiceQuestion.choices || []).map((choice, idx) => ( -
- + multipleChoiceQuestion?.hasOpenChoice && idx === multipleChoiceQuestion.choices?.length - 1 ? ( + - - {check} -
- ))} + ) : ( +
+ + + {check} +
+ ) + )}
diff --git a/frontend/src/scenes/surveys/SurveyEdit.tsx b/frontend/src/scenes/surveys/SurveyEdit.tsx index 64d2e0aad64ae..456350e651a54 100644 --- a/frontend/src/scenes/surveys/SurveyEdit.tsx +++ b/frontend/src/scenes/surveys/SurveyEdit.tsx @@ -452,82 +452,186 @@ export default function SurveyEdit(): JSX.Element { question.type === SurveyQuestionType.MultipleChoice) && (
- + {({ - value, - onChange, - }: { - value: string[] - onChange: (newValue: string[]) => void + value: hasOpenChoice, + onChange: toggleHasOpenChoice, }) => ( -
- {(value || []).map( - ( - choice: string, - index: number - ) => ( -
- { - const newChoices = - [...value] - newChoices[ - index - ] = val - onChange( - newChoices - ) - }} - /> - - } - size="small" - status="muted" - noPadding - onClick={() => { - const newChoices = - [...value] - newChoices.splice( - index, - 1 - ) - onChange( - newChoices - ) - }} - /> + + {({ value, onChange }) => ( +
+ {(value || []).map( + ( + choice: string, + index: number + ) => { + const isOpenChoice = + hasOpenChoice && + index === + value?.length - + 1 + return ( +
+ { + const newChoices = + [ + ...value, + ] + newChoices[ + index + ] = + val + onChange( + newChoices + ) + }} + /> + {isOpenChoice && ( + + open-ended + + )} + + } + size="small" + status="muted" + noPadding + onClick={() => { + const newChoices = + [ + ...value, + ] + newChoices.splice( + index, + 1 + ) + onChange( + newChoices + ) + if ( + isOpenChoice + ) { + toggleHasOpenChoice( + false + ) + } + }} + /> +
+ ) + } + )} +
+ {(value || []).length < + 6 && ( + <> + + } + type="secondary" + fullWidth={ + false + } + onClick={() => { + if ( + !value + ) { + onChange( + [ + '', + ] + ) + } else if ( + hasOpenChoice + ) { + const newChoices = + value.slice( + 0, + -1 + ) + newChoices.push( + '' + ) + newChoices.push( + value[ + value.length - + 1 + ] + ) + onChange( + newChoices + ) + } else { + onChange( + [ + ...value, + '', + ] + ) + } + }} + > + Add choice + + {featureFlags[ + FEATURE_FLAGS + .SURVEYS_OPEN_CHOICE + ] && + !hasOpenChoice && ( + + } + type="secondary" + fullWidth={ + false + } + onClick={() => { + if ( + !value + ) { + onChange( + [ + 'Other', + ] + ) + } else { + onChange( + [ + ...value, + 'Other', + ] + ) + } + toggleHasOpenChoice( + true + ) + }} + > + Add + open-ended + choice + + )} + + )}
- ) +
)} -
- {(value || []).length < 6 && ( - } - type="secondary" - fullWidth={false} - onClick={() => { - if (!value) { - onChange(['']) - } else { - onChange([ - ...value, - '', - ]) - } - }} - > - Add choice - - )} -
-
+ )}
diff --git a/frontend/src/scenes/surveys/surveyLogic.test.ts b/frontend/src/scenes/surveys/surveyLogic.test.ts index 77719f03bdba4..6661f3d359760 100644 --- a/frontend/src/scenes/surveys/surveyLogic.test.ts +++ b/frontend/src/scenes/surveys/surveyLogic.test.ts @@ -5,7 +5,7 @@ import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' import { Survey, SurveyQuestionType, SurveyType } from '~/types' -const SURVEY: Survey = { +const MULTIPLE_CHOICE_SURVEY: Survey = { id: '018b22a3-09b1-0000-2f5b-1bd8352ceec9', name: 'Multiple Choice survey', description: '', @@ -16,7 +16,7 @@ const SURVEY: Survey = { questions: [ { type: SurveyQuestionType.MultipleChoice, - choices: ['Tutorials', 'Customer case studies', 'Product announcements'], + choices: ['Tutorials', 'Customer case studies', 'Product announcements', 'Other'], question: 'Which types of content would you like to see more of?', description: '', }, @@ -50,7 +50,244 @@ const SURVEY: Survey = { targeting_flag_filters: undefined, } -describe('survey logic', () => { +const SINGLE_CHOICE_SURVEY: Survey = { + id: '118b22a3-09b1-0000-2f5b-1bd8352ceec9', + name: 'Single Choice survey', + description: '', + type: SurveyType.Popover, + linked_flag: null, + linked_flag_id: null, + targeting_flag: null, + questions: [ + { + type: SurveyQuestionType.SingleChoice, + choices: ['Yes', 'No'], + question: 'Would you like us to continue this feature?', + description: '', + }, + ], + conditions: null, + appearance: { + position: 'right', + whiteLabel: false, + borderColor: '#c9c6c6', + placeholder: '', + backgroundColor: '#eeeded', + submitButtonText: 'Submit', + ratingButtonColor: 'white', + submitButtonColor: 'black', + thankYouMessageHeader: 'Thank you for your feedback!', + displayThankYouMessage: true, + ratingButtonActiveColor: 'black', + }, + created_at: '2023-10-12T06:46:32.113745Z', + created_by: { + id: 1, + uuid: '018aa8a6-10e8-0000-dba2-0e956f7bae38', + distinct_id: 'TGqg9Cn4jLkj9X87oXni9ZPBD6VbOxMtGV1GfJeB5LO', + first_name: 'test', + email: 'test@posthog.com', + is_email_verified: false, + }, + start_date: '2023-10-12T06:46:34.482000Z', + end_date: null, + archived: false, + targeting_flag_filters: undefined, +} + +const MULTIPLE_CHOICE_SURVEY_WITH_OPEN_CHOICE: Survey = { + id: '018b22a3-09b1-0000-2f5b-1bd8352ceec9', + name: 'Multiple Choice survey', + description: '', + type: SurveyType.Popover, + linked_flag: null, + linked_flag_id: null, + targeting_flag: null, + questions: [ + { + type: SurveyQuestionType.MultipleChoice, + choices: ['Tutorials', 'Customer case studies', 'Product announcements', 'Other'], + question: 'Which types of content would you like to see more of?', + description: '', + hasOpenChoice: true, + }, + ], + conditions: null, + appearance: { + position: 'right', + whiteLabel: false, + borderColor: '#c9c6c6', + placeholder: '', + backgroundColor: '#eeeded', + submitButtonText: 'Submit', + ratingButtonColor: 'white', + submitButtonColor: 'black', + thankYouMessageHeader: 'Thank you for your feedback!', + displayThankYouMessage: true, + ratingButtonActiveColor: 'black', + }, + created_at: '2023-10-12T06:46:32.113745Z', + created_by: { + id: 1, + uuid: '018aa8a6-10e8-0000-dba2-0e956f7bae38', + distinct_id: 'TGqg9Cn4jLkj9X87oXni9ZPBD6VbOxMtGV1GfJeB5LO', + first_name: 'test', + email: 'test@posthog.com', + is_email_verified: false, + }, + start_date: '2023-10-12T06:46:34.482000Z', + end_date: null, + archived: false, + targeting_flag_filters: undefined, +} + +const SINGLE_CHOICE_SURVEY_WITH_OPEN_CHOICE: Survey = { + id: '118b22a3-09b1-0000-2f5b-1bd8352ceec9', + name: 'Single Choice survey', + description: '', + type: SurveyType.Popover, + linked_flag: null, + linked_flag_id: null, + targeting_flag: null, + questions: [ + { + type: SurveyQuestionType.SingleChoice, + choices: ['Yes', 'No', 'Maybe (explain)'], + question: 'Would you like us to continue this feature?', + description: '', + hasOpenChoice: true, + }, + ], + conditions: null, + appearance: { + position: 'right', + whiteLabel: false, + borderColor: '#c9c6c6', + placeholder: '', + backgroundColor: '#eeeded', + submitButtonText: 'Submit', + ratingButtonColor: 'white', + submitButtonColor: 'black', + thankYouMessageHeader: 'Thank you for your feedback!', + displayThankYouMessage: true, + ratingButtonActiveColor: 'black', + }, + created_at: '2023-10-12T06:46:32.113745Z', + created_by: { + id: 1, + uuid: '018aa8a6-10e8-0000-dba2-0e956f7bae38', + distinct_id: 'TGqg9Cn4jLkj9X87oXni9ZPBD6VbOxMtGV1GfJeB5LO', + first_name: 'test', + email: 'test@posthog.com', + is_email_verified: false, + }, + start_date: '2023-10-12T06:46:34.482000Z', + end_date: null, + archived: false, + targeting_flag_filters: undefined, +} + +describe('multiple choice survey logic', () => { + let logic: ReturnType + + beforeEach(() => { + initKeaTests() + logic = surveyLogic({ id: 'new' }) + logic.mount() + + useMocks({ + get: { + '/api/projects/:team/surveys/': () => [200, { results: [] }], + '/api/projects/:team/surveys/responses_count': () => [200, {}], + }, + post: { + '/api/projects/:team/query/': () => [ + 200, + { + results: [ + [336, '"Tutorials"'], + [312, '"Customer case studies"'], + [20, '"Other"'], + ], + }, + ], + }, + }) + }) + + describe('main', () => { + it('post processes return values', async () => { + await expectLogic(logic, () => { + logic.actions.loadSurveySuccess(MULTIPLE_CHOICE_SURVEY) + }).toDispatchActions(['loadSurveySuccess']) + + await expectLogic(logic, () => { + logic.actions.loadSurveyMultipleChoiceResults({ questionIndex: 0 }) + }) + .toFinishAllListeners() + .toMatchValues({ + surveyMultipleChoiceResults: { + 0: { + labels: ['Tutorials', 'Customer case studies', 'Other', 'Product announcements'], + data: [336, 312, 20, 0], + }, + }, + }) + }) + }) +}) + +describe('single choice survey logic', () => { + let logic: ReturnType + + beforeEach(() => { + initKeaTests() + logic = surveyLogic({ id: 'new' }) + logic.mount() + + useMocks({ + get: { + '/api/projects/:team/surveys/': () => [200, { results: [] }], + '/api/projects/:team/surveys/responses_count': () => [200, {}], + }, + post: { + '/api/projects/:team/query/': () => [ + 200, + { + results: [ + ['"Yes"', 101], + ['"No"', 1], + ], + }, + ], + }, + }) + }) + + describe('main', () => { + it('post processes return values', async () => { + await expectLogic(logic, () => { + logic.actions.loadSurveySuccess(SINGLE_CHOICE_SURVEY) + }).toDispatchActions(['loadSurveySuccess']) + + await expectLogic(logic, () => { + logic.actions.loadSurveySingleChoiceResults({ questionIndex: 1 }) + }) + .toFinishAllListeners() + .toMatchValues({ + surveySingleChoiceResults: { + 1: { + labels: ['"Yes"', '"No"'], + data: [101, 1], + total: 102, + }, + }, + }) + }) + }) +}) + +describe('multiple choice survey with open choice logic', () => { let logic: ReturnType beforeEach(() => { @@ -70,6 +307,7 @@ describe('survey logic', () => { results: [ [336, '"Tutorials"'], [312, '"Customer case studies"'], + [20, '"Other choice"'], ], }, ], @@ -80,7 +318,7 @@ describe('survey logic', () => { describe('main', () => { it('post processes return values', async () => { await expectLogic(logic, () => { - logic.actions.loadSurveySuccess(SURVEY) + logic.actions.loadSurveySuccess(MULTIPLE_CHOICE_SURVEY_WITH_OPEN_CHOICE) }).toDispatchActions(['loadSurveySuccess']) await expectLogic(logic, () => { @@ -90,8 +328,58 @@ describe('survey logic', () => { .toMatchValues({ surveyMultipleChoiceResults: { 0: { - labels: ['Tutorials', 'Customer case studies', 'Product announcements'], - data: [336, 312, 0], + labels: ['Tutorials', 'Customer case studies', 'Other choice', 'Product announcements'], + data: [336, 312, 20, 0], + }, + }, + }) + }) + }) +}) + +describe('single choice survey with open choice logic', () => { + let logic: ReturnType + + beforeEach(() => { + initKeaTests() + logic = surveyLogic({ id: 'new' }) + logic.mount() + + useMocks({ + get: { + '/api/projects/:team/surveys/': () => [200, { results: [] }], + '/api/projects/:team/surveys/responses_count': () => [200, {}], + }, + post: { + '/api/projects/:team/query/': () => [ + 200, + { + results: [ + ['"Yes"', 101], + ['"Only if I could customize my choices"', 1], + ], + }, + ], + }, + }) + }) + + describe('main', () => { + it('post processes return values', async () => { + await expectLogic(logic, () => { + logic.actions.loadSurveySuccess(SINGLE_CHOICE_SURVEY_WITH_OPEN_CHOICE) + }).toDispatchActions(['loadSurveySuccess']) + + await expectLogic(logic, () => { + logic.actions.loadSurveySingleChoiceResults({ questionIndex: 1 }) + }) + .toFinishAllListeners() + .toMatchValues({ + surveySingleChoiceResults: { + 1: { + labels: ['"Yes"', '"Only if I could customize my choices"'], + data: [101, 1], + total: 102, }, }, }) diff --git a/frontend/src/scenes/surveys/surveyLogic.tsx b/frontend/src/scenes/surveys/surveyLogic.tsx index f6da43d62fea6..889f7849eee1e 100644 --- a/frontend/src/scenes/surveys/surveyLogic.tsx +++ b/frontend/src/scenes/surveys/surveyLogic.tsx @@ -336,9 +336,10 @@ export const surveyLogic = kea([ return [r[0], r[1].slice(1, r[1].length - 1)] }) - // Zero-fill - question.choices.forEach((choice) => { - if (results?.length && !results.some((r) => r[1] === choice)) { + // Zero-fill choices that are not open-ended + question.choices.forEach((choice, idx) => { + const isOpenChoice = idx == question.choices.length - 1 && question?.hasOpenChoice + if (results?.length && !isOpenChoice && !results.some((r) => r[1] === choice)) { results.push([0, choice]) } }) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bf9bba96c3194..5e027807a2bcf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2242,6 +2242,7 @@ export interface RatingSurveyQuestion extends SurveyQuestionBase { export interface MultipleSurveyQuestion extends SurveyQuestionBase { type: SurveyQuestionType.SingleChoice | SurveyQuestionType.MultipleChoice choices: string[] + hasOpenChoice?: boolean } export type SurveyQuestion = BasicSurveyQuestion | LinkSurveyQuestion | RatingSurveyQuestion | MultipleSurveyQuestion diff --git a/posthog/api/survey.py b/posthog/api/survey.py index ef3e8c166dac8..70090e575cec3 100644 --- a/posthog/api/survey.py +++ b/posthog/api/survey.py @@ -135,6 +135,10 @@ def validate_questions(self, value): if description and nh3.is_html(description): cleaned_question["description"] = nh3_clean_with_allow_list(description) + choices = raw_question.get("choices") + if choices and not isinstance(choices, list): + raise serializers.ValidationError("Question choices must be a list of strings") + cleaned_questions.append(cleaned_question) return cleaned_questions diff --git a/posthog/api/test/test_survey.py b/posthog/api/test/test_survey.py index 75cd3d1c91e5b..e1b5d3163605b 100644 --- a/posthog/api/test/test_survey.py +++ b/posthog/api/test/test_survey.py @@ -1144,6 +1144,27 @@ def test_validate_malformed_questions_as_array_of_strings(self): assert response.status_code == status.HTTP_400_BAD_REQUEST, response_data assert response_data["detail"] == "Questions must be a list of objects" + def test_validate_malformed_question_choices_as_string(self): + response = self.client.post( + f"/api/projects/{self.team.id}/surveys/", + data={ + "name": "Notebooks beta release survey", + "description": "Get feedback on the new notebooks feature", + "type": "popover", + "questions": [ + { + "question": "this is my question", + "type": "multiple_choice", + "choices": "these are my question choices", + } + ], + }, + format="json", + ) + response_data = response.json() + assert response.status_code == status.HTTP_400_BAD_REQUEST, response_data + assert response_data["detail"] == "Question choices must be a list of strings" + class TestSurveysAPIList(BaseTest, QueryMatchingTest): def setUp(self):