From cda82fc076a75e53cf10abd42e43afdaae7f872b Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Mon, 16 Oct 2023 09:24:39 +0200 Subject: [PATCH] feat(surveys): Multiple Choice question results (#17954) --- .../insights/views/LineGraph/LineGraph.tsx | 31 +++++- frontend/src/scenes/surveys/SurveyView.tsx | 29 ++++-- .../src/scenes/surveys/surveyLogic.test.ts | 99 +++++++++++++++++++ frontend/src/scenes/surveys/surveyLogic.tsx | 91 +++++++++++++++-- frontend/src/scenes/surveys/surveyViewViz.tsx | 94 ++++++++++++++++-- 5 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 frontend/src/scenes/surveys/surveyLogic.test.ts diff --git a/frontend/src/scenes/insights/views/LineGraph/LineGraph.tsx b/frontend/src/scenes/insights/views/LineGraph/LineGraph.tsx index 15a079aee48fb..666e7894fdf3b 100644 --- a/frontend/src/scenes/insights/views/LineGraph/LineGraph.tsx +++ b/frontend/src/scenes/insights/views/LineGraph/LineGraph.tsx @@ -207,6 +207,7 @@ export interface LineGraphProps { showPersonsModal?: boolean tooltip?: TooltipConfig inCardView?: boolean + inSurveyView?: boolean isArea?: boolean incompletenessOffsetFromEnd?: number // Number of data points at end of dataset to replace with a dotted line. Only used in line graphs. labelGroupType: number | 'people' | 'none' @@ -217,6 +218,8 @@ export interface LineGraphProps { showPercentStackView?: boolean | null supportsPercentStackView?: boolean hideAnnotations?: boolean + hideXAxis?: boolean + hideYAxis?: boolean } export const LineGraph = (props: LineGraphProps): JSX.Element => { @@ -238,6 +241,7 @@ export function LineGraph_({ showPersonsModal = true, compare = false, inCardView, + inSurveyView, isArea = false, incompletenessOffsetFromEnd = -1, tooltip: tooltipConfig, @@ -248,6 +252,8 @@ export function LineGraph_({ showPercentStackView, supportsPercentStackView, hideAnnotations, + hideXAxis, + hideYAxis, }: LineGraphProps): JSX.Element { let datasets = _datasets @@ -566,6 +572,7 @@ export function LineGraph_({ if (type === GraphType.Bar) { options.scales = { x: { + display: !hideXAxis, beginAtZero: true, stacked: true, ticks: { @@ -575,9 +582,11 @@ export function LineGraph_({ grid: gridOptions, }, y: { + display: !hideYAxis, beginAtZero: true, stacked: true, ticks: { + display: !hideYAxis, ...tickOptions, precision, callback: (value) => { @@ -590,8 +599,8 @@ export function LineGraph_({ } else if (type === GraphType.Line) { options.scales = { x: { + display: !hideXAxis, beginAtZero: true, - display: true, ticks: tickOptions, grid: { ...gridOptions, @@ -600,10 +609,11 @@ export function LineGraph_({ }, }, y: { + display: !hideYAxis, beginAtZero: true, - display: true, stacked: showPercentStackView || isArea, ticks: { + display: !hideYAxis, ...tickOptions, precision, callback: (value) => { @@ -616,9 +626,10 @@ export function LineGraph_({ } else if (isHorizontal) { options.scales = { x: { + display: !hideXAxis, beginAtZero: true, - display: true, ticks: { + display: !hideXAxis, ...tickOptions, precision, callback: (value) => { @@ -628,8 +639,15 @@ export function LineGraph_({ grid: gridOptions, }, y: { + display: true, beforeFit: (scale) => { - if (shouldAutoResize) { + if (inSurveyView) { + const ROW_HEIGHT = 60 + const dynamicHeight = scale.ticks.length * ROW_HEIGHT + const height = dynamicHeight + const parentNode: any = scale.chart?.canvas?.parentNode + parentNode.style.height = `${height}px` + } else if (shouldAutoResize) { // automatically resize the chart container to fit the number of rows const MIN_HEIGHT = 575 const ROW_HEIGHT = 16 @@ -656,7 +674,10 @@ export function LineGraph_({ return labelDescriptors.join(' - ') }, }, - grid: gridOptions, + grid: { + ...gridOptions, + display: !inSurveyView, + }, }, } options.indexAxis = 'y' diff --git a/frontend/src/scenes/surveys/SurveyView.tsx b/frontend/src/scenes/surveys/SurveyView.tsx index 9805fb361babc..0acde15f1df26 100644 --- a/frontend/src/scenes/surveys/SurveyView.tsx +++ b/frontend/src/scenes/surveys/SurveyView.tsx @@ -28,7 +28,12 @@ import { dayjs } from 'lib/dayjs' import { defaultSurveyAppearance, SURVEY_EVENT_NAME } from './constants' import { FEATURE_FLAGS } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { Summary, RatingQuestionBarChart, SingleChoiceQuestionPieChart } from './surveyViewViz' +import { + Summary, + RatingQuestionBarChart, + SingleChoiceQuestionPieChart, + MultipleChoiceQuestionBarChart, +} from './surveyViewViz' export function SurveyView({ id }: { id: string }): JSX.Element { const { survey, surveyLoading } = useValues(surveyLogic) @@ -263,6 +268,8 @@ export function SurveyResult({ disableEventsTable }: { disableEventsTable?: bool surveyRatingResultsReady, surveySingleChoiceResults, surveySingleChoiceResultsReady, + surveyMultipleChoiceResults, + surveyMultipleChoiceResultsReady, } = useValues(surveyLogic) const { setCurrentQuestionIndexAndType } = useActions(surveyLogic) const { featureFlags } = useValues(featureFlagLogic) @@ -291,6 +298,15 @@ export function SurveyResult({ disableEventsTable }: { disableEventsTable?: bool questionIndex={i} /> ) + } else if (question.type === SurveyQuestionType.MultipleChoice) { + return ( + + ) } })} @@ -337,11 +353,12 @@ export function SurveyResult({ disableEventsTable }: { disableEventsTable?: bool )} {(currentQuestionIndexAndType.type === SurveyQuestionType.SingleChoice || - currentQuestionIndexAndType.type === SurveyQuestionType.MultipleChoice) && ( -
- -
- )} + currentQuestionIndexAndType.type === SurveyQuestionType.MultipleChoice) && + !featureFlags[FEATURE_FLAGS.SURVEYS_RESULTS_VISUALIZATIONS] && ( +
+ +
+ )} {!disableEventsTable && (surveyLoading ? : )} ) diff --git a/frontend/src/scenes/surveys/surveyLogic.test.ts b/frontend/src/scenes/surveys/surveyLogic.test.ts new file mode 100644 index 0000000000000..0eb577d4d94a7 --- /dev/null +++ b/frontend/src/scenes/surveys/surveyLogic.test.ts @@ -0,0 +1,99 @@ +import { initKeaTests } from '~/test/init' +import { surveyLogic } from 'scenes/surveys/surveyLogic' +import { expectLogic } from 'kea-test-utils' +import { useMocks } from '~/mocks/jest' +import { Survey, SurveyQuestionType, SurveyType } from '~/types' + +const SURVEY: 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'], + question: 'Which types of content would you like to see more of?', + 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, +} + +describe('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"'], + ], + }, + ], + }, + }) + }) + + describe('main', () => { + it('post processes return values', async () => { + await expectLogic(logic, () => { + logic.actions.loadSurveySuccess(SURVEY) + }).toDispatchActions(['loadSurveySuccess']) + + await expectLogic(logic, () => { + logic.actions.loadSurveyMultipleChoiceResults({ questionIndex: 0 }) + }) + .toFinishAllListeners() + .toMatchValues({ + surveyMultipleChoiceResults: { + 0: { + labels: ['Tutorials', 'Customer case studies', 'Product announcements'], + data: [336, 312, 0], + }, + }, + }) + }) + }) +}) diff --git a/frontend/src/scenes/surveys/surveyLogic.tsx b/frontend/src/scenes/surveys/surveyLogic.tsx index 4d816f2e19607..896f2b11a9fa3 100644 --- a/frontend/src/scenes/surveys/surveyLogic.tsx +++ b/frontend/src/scenes/surveys/surveyLogic.tsx @@ -10,7 +10,6 @@ import { ChartDisplayType, PropertyFilterType, PropertyOperator, - RatingSurveyQuestion, Survey, SurveyQuestionBase, SurveyQuestionType, @@ -49,7 +48,10 @@ export interface SurveyUserStats { } export interface SurveyRatingResults { - [key: number]: number[] + [key: number]: { + data: number[] + total: number + } } export interface SurveySingleChoiceResults { @@ -60,6 +62,13 @@ export interface SurveySingleChoiceResults { } } +export interface SurveyMultipleChoiceResults { + [key: number]: { + labels: string[] + data: number[] + } +} + export interface QuestionResultsReady { [key: string]: boolean } @@ -187,7 +196,12 @@ export const surveyLogic = kea([ questionIndex: number }): Promise => { const { survey } = values - const question = values.survey.questions[questionIndex] as RatingSurveyQuestion + + const question = values.survey.questions[questionIndex] + if (question.type !== SurveyQuestionType.Rating) { + throw new Error(`Survey question type must be ${SurveyQuestionType.Rating}`) + } + const startDate = dayjs((survey as Survey).created_at).format('YYYY-MM-DD') const endDate = survey.end_date ? dayjs(survey.end_date).add(1, 'day').format('YYYY-MM-DD') @@ -211,12 +225,14 @@ export const surveyLogic = kea([ const responseJSON = await api.query(query) const { results } = responseJSON - const resultArr = new Array(question.scale).fill(0) + let total = 0 + const data = new Array(question.scale).fill(0) results?.forEach(([value, count]) => { - resultArr[value - 1] = count + total += count + data[value - 1] = count }) - return { ...values.surveyRatingResults, [questionIndex]: resultArr } + return { ...values.surveyRatingResults, [questionIndex]: { total, data } } }, }, surveySingleChoiceResults: { @@ -256,6 +272,58 @@ export const surveyLogic = kea([ return { ...values.surveySingleChoiceResults, [questionIndex]: { labels, data, total } } }, }, + surveyMultipleChoiceResults: { + loadSurveyMultipleChoiceResults: async ({ + questionIndex, + }: { + questionIndex: number + }): Promise => { + const { survey } = values + + const question = values.survey.questions[questionIndex] + if (question.type !== SurveyQuestionType.MultipleChoice) { + throw new Error(`Survey question type must be ${SurveyQuestionType.MultipleChoice}`) + } + + const startDate = dayjs((survey as Survey).created_at).format('YYYY-MM-DD') + const endDate = survey.end_date + ? dayjs(survey.end_date).add(1, 'day').format('YYYY-MM-DD') + : dayjs().add(1, 'day').format('YYYY-MM-DD') + + const query: HogQLQuery = { + kind: NodeKind.HogQLQuery, + query: ` + SELECT count(), arrayJoin(JSONExtractArrayRaw(properties, '$survey_response')) AS choice + FROM events + WHERE event == 'survey sent' + AND properties.$survey_id == '${survey.id}' + AND timestamp >= '${startDate}' + AND timestamp <= '${endDate}' + GROUP BY choice + ORDER BY count() DESC + `, + } + const responseJSON = await api.query(query) + let { results } = responseJSON + + // Remove outside quotes + results = results?.map((r) => { + 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)) { + results.push([0, choice]) + } + }) + + const data = results?.map((r) => r[0]) + const labels = results?.map((r) => r[1]) + + return { ...values.surveyRatingResults, [questionIndex]: { labels, data } } + }, + }, })), listeners(({ actions }) => ({ createSurveySuccess: ({ survey }) => { @@ -367,6 +435,17 @@ export const surveyLogic = kea([ }, }, ], + surveyMultipleChoiceResultsReady: [ + {}, + { + loadSurveyMultipleChoiceResultsSuccess: (state, { payload }) => { + if (!payload || !payload.hasOwnProperty('questionIndex')) { + return { ...state } + } + return { ...state, [payload.questionIndex]: true } + }, + }, + ], writingHTMLDescription: [ false, { diff --git a/frontend/src/scenes/surveys/surveyViewViz.tsx b/frontend/src/scenes/surveys/surveyViewViz.tsx index 278a65b7a37b0..df818be92fcbc 100644 --- a/frontend/src/scenes/surveys/surveyViewViz.tsx +++ b/frontend/src/scenes/surveys/surveyViewViz.tsx @@ -4,6 +4,7 @@ import { SurveyRatingResults, QuestionResultsReady, SurveySingleChoiceResults, + SurveyMultipleChoiceResults, SurveyUserStats, } from './surveyLogic' import { useActions, useValues, BindLogic } from 'kea' @@ -165,6 +166,7 @@ export function RatingQuestionBarChart({ }): JSX.Element { const { loadSurveyRatingResults } = useActions(surveyLogic) const { survey } = useValues(surveyLogic) + const barColor = '#1d4aff' const question = survey.questions[questionIndex] if (question.type !== SurveyQuestionType.Rating) { @@ -179,6 +181,8 @@ export function RatingQuestionBarChart({
{!surveyRatingResultsReady[questionIndex] ? ( + ) : !surveyRatingResults[questionIndex].total ? ( + <> ) : (
{`1-${question.scale} rating`}
@@ -187,6 +191,10 @@ export function RatingQuestionBarChart({
(i + 1).toString()).map( @@ -215,8 +224,8 @@ export function RatingQuestionBarChart({
-
{question.lowerBoundLabel}
-
{question.upperBoundLabel}
+
{question.lowerBoundLabel}
+
{question.upperBoundLabel}
)} @@ -268,6 +277,8 @@ export function SingleChoiceQuestionPieChart({
{!surveySingleChoiceResultsReady[questionIndex] ? ( + ) : !surveySingleChoiceResults[questionIndex].data.length ? ( + <> ) : (
Single choice
@@ -339,3 +350,74 @@ export function SingleChoiceQuestionPieChart({
) } + +export function MultipleChoiceQuestionBarChart({ + questionIndex, + surveyMultipleChoiceResults, + surveyMultipleChoiceResultsReady, +}: { + questionIndex: number + surveyMultipleChoiceResults: SurveyMultipleChoiceResults + surveyMultipleChoiceResultsReady: QuestionResultsReady +}): JSX.Element { + const { loadSurveyMultipleChoiceResults } = useActions(surveyLogic) + const { survey } = useValues(surveyLogic) + const barColor = '#1d4aff' + + const question = survey.questions[questionIndex] + if (question.type !== SurveyQuestionType.MultipleChoice) { + throw new Error(`Question type must be ${SurveyQuestionType.MultipleChoice}`) + } + + useEffect(() => { + loadSurveyMultipleChoiceResults({ questionIndex }) + }, [questionIndex]) + + return ( +
+ {!surveyMultipleChoiceResultsReady[questionIndex] ? ( + + ) : !surveyMultipleChoiceResults[questionIndex].data.length ? ( + <> + ) : ( +
+
Multiple choice
+
{question.question}
+
+ + + +
+
+ )} +
+ ) +}